"""
Step 26 — Voice Announcements (Web Speech API)

`speechSynthesis` / `SpeechSynthesisUtterance` are replaced by a lightweight
mock injected before every page load: each call to `speak()` is recorded into
`window.__spokenTexts` as `{ text, lang, volume }`.

page.clock.install() controls fake time so EMOM/TABATA timer-driven
pre-announcements are deterministic (as in Step 15).

- test_first_step_title_spoken_on_start: the first step's exercise title is
  announced when execution begins
- test_title_spoken_on_advance: advancing to the next step announces its title
- test_emom_speaks_next_at_10s: EMOM speaks "Next: X" 10 s before a slot ends
- test_tabata_speaks_next_on_rest: TABATA speaks "Next: X" when the rest phase
  opens
- test_no_duplicate_announcement_after_preannounce: the step-change
  announcement is skipped right after a pre-announcement for that same step
- test_utterance_lang_follows_app_language: utterance `lang` matches the
  current i18next language
- test_utterance_volume_follows_audio_volume: utterance `volume` matches the
  `audio_volume` setting
- test_voice_disabled_nothing_spoken: unchecking the setting silences all
  announcements, and the setting persists across reload
- test_works_with_speech_synthesis_absent: execution works normally (red path)
  when `speechSynthesis` does not exist
"""

import json
import pytest
from playwright.sync_api import Page, expect

# ── Speech mock injected before every page load ───────────────────────────────

_SPEECH_MOCK = """
window.__spokenTexts = [];
function FakeUtterance(text) {
    this.text = text;
    this.lang = '';
    this.volume = 1;
}
window.SpeechSynthesisUtterance = FakeUtterance;
Object.defineProperty(window, 'speechSynthesis', {
    configurable: true,
    value: {
        cancel: function () {},
        speak: function (u) {
            window.__spokenTexts.push({ text: u.text, lang: u.lang, volume: u.volume });
        },
    },
});
"""

_SPEECH_ABSENT = """
delete Window.prototype.speechSynthesis;
"""


def _setup(page: Page, app_url: str, mock_speech=True, install_clock=False):
    if install_clock:
        page.clock.install()
    if mock_speech:
        page.add_init_script(_SPEECH_MOCK)
    else:
        page.add_init_script(_SPEECH_ABSENT)
    page.goto(app_url)
    page.wait_for_function(
        "document.documentElement.getAttribute('data-db-ready') === 'true'",
        timeout=15000,
    )


def _create_exercise(page: Page, title: str) -> str:
    return page.evaluate(f"""async () => {{
        const uid = await window.__repos.settings.get('user_uuid')
        return window.__repos.exercises.create({{
            title: {repr(title)}, description: '', asymmetric: false, alternate: false,
            image_urls: '[]', video_urls: '[]',
            default_reps: 10, default_weight: 0, created_by: uid
        }})
    }}""")


def _create_combo(page: Page, title: str, combo_type: str, exercise_ids: list,
                   timer_config: dict = None):
    tc_json = json.dumps(timer_config or {})
    ex_json = json.dumps([{"exerciseId": eid, "defaultReps": 10, "defaultWeight": 0}
                          for eid in exercise_ids])
    return page.evaluate(f"""async () => {{
        const uid = await window.__repos.settings.get('user_uuid')
        const cid = await window.__repos.combos.create({{
            title: {repr(title)}, description: '', type: {repr(combo_type)},
            timer_config: {tc_json}, created_by: uid
        }})
        await window.__repos.combos.setExercises(cid, {ex_json})
        return cid
    }}""")


def _create_session(page: Page, title: str, items: list) -> str:
    items_json = json.dumps(items)
    return page.evaluate(f"""async () => {{
        const uid = await window.__repos.settings.get('user_uuid')
        const sid = await window.__repos.sessions.create({{
            title: {repr(title)}, description: '', created_by: uid
        }})
        const items = {items_json}
        await window.__repos.sessions.setItems(sid, items.map((it, i) => ({{
            item_id: it.item_id, item_type: it.item_type,
            position: i, repetitions: it.repetitions, weight: 0
        }})))
        return sid
    }}""")


def _clean_all(page: Page):
    page.evaluate("""async () => {
        for (const l of await window.__repos.executionLogs.getAll())
            await window.__repos.executionLogs.delete(l.id)
        for (const s of await window.__repos.sessions.getAll())
            await window.__repos.sessions.delete(s.id)
        for (const c of await window.__repos.combos.getAll())
            await window.__repos.combos.delete(c.id)
        for (const e of await window.__repos.exercises.getAll())
            await window.__repos.exercises.delete(e.id)
    }""")


def _navigate_to_execute(page: Page, app_url: str, sess_id: str):
    page.goto(f"{app_url}#/sessions/{sess_id}/execute")
    page.wait_for_selector('[data-testid="execute-session-title"]', timeout=10000)
    page.wait_for_timeout(300)


def _spoken(page: Page) -> list[dict]:
    return page.evaluate("window.__spokenTexts")


# ── Tests ──────────────────────────────────────────────────────────────────────


def test_first_step_title_spoken_on_start(page: Page, app_url: str):
    """The first step's exercise title is announced when execution begins."""
    _setup(page, app_url)
    _clean_all(page)

    ex1 = _create_exercise(page, "Voice Ex1")
    sess_id = _create_session(page, "Voice Session", [
        {"item_id": ex1, "item_type": "exercise", "repetitions": 5},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    spoken = _spoken(page)
    assert any(s["text"] == "Voice Ex1" for s in spoken), f"Unexpected spoken texts: {spoken}"


def test_title_spoken_on_advance(page: Page, app_url: str):
    """Advancing to the next step announces its title."""
    _setup(page, app_url)
    _clean_all(page)

    ex1 = _create_exercise(page, "Voice Adv Ex1")
    ex2 = _create_exercise(page, "Voice Adv Ex2")
    sess_id = _create_session(page, "Voice Adv Session", [
        {"item_id": ex1, "item_type": "exercise", "repetitions": 5},
        {"item_id": ex2, "item_type": "exercise", "repetitions": 5},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    page.click('[data-testid="execute-done-btn"]')
    expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text("Voice Adv Ex2")

    spoken = _spoken(page)
    assert any(s["text"] == "Voice Adv Ex2" for s in spoken), f"Unexpected spoken texts: {spoken}"


def test_emom_speaks_next_at_10s(page: Page, app_url: str):
    """EMOM speaks 'Next: X' 10 s before a slot ends."""
    _setup(page, app_url, install_clock=True)
    _clean_all(page)

    ex1 = _create_exercise(page, "Voice EMOM Ex1")
    ex2 = _create_exercise(page, "Voice EMOM Ex2")
    combo_id = _create_combo(page, "Voice EMOM", "EMOM", [ex1, ex2])
    sess_id = _create_session(page, "Voice EMOM Session", [
        {"item_id": combo_id, "item_type": "combo", "repetitions": 2},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    page.clock.run_for(50_000)  # 60s slot - 10s = 50s elapsed
    page.wait_for_timeout(300)

    spoken = _spoken(page)
    assert any(s["text"] == "Next: Voice EMOM Ex2" for s in spoken), \
        f"Expected pre-announcement, got: {spoken}"


def test_tabata_speaks_next_on_rest(page: Page, app_url: str):
    """TABATA speaks 'Next: X' when the rest phase opens."""
    _setup(page, app_url, install_clock=True)
    _clean_all(page)

    ex1 = _create_exercise(page, "Voice Tab Ex1")
    ex2 = _create_exercise(page, "Voice Tab Ex2")
    combo_id = _create_combo(page, "Voice Tab", "TABATA", [ex1, ex2],
                              timer_config={"workTime": 10, "restTime": 10})
    sess_id = _create_session(page, "Voice Tab Session", [
        {"item_id": combo_id, "item_type": "combo", "repetitions": 1},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    page.clock.run_for(10_000)  # work phase ends, rest phase opens
    page.wait_for_timeout(300)

    spoken = _spoken(page)
    assert any(s["text"] == "Next: Voice Tab Ex2" for s in spoken), \
        f"Expected pre-announcement, got: {spoken}"


def test_no_duplicate_announcement_after_preannounce(page: Page, app_url: str):
    """The step-change announcement is skipped right after a pre-announcement."""
    _setup(page, app_url, install_clock=True)
    _clean_all(page)

    ex1 = _create_exercise(page, "Voice Dup Ex1")
    ex2 = _create_exercise(page, "Voice Dup Ex2")
    combo_id = _create_combo(page, "Voice Dup", "EMOM", [ex1, ex2])
    sess_id = _create_session(page, "Voice Dup Session", [
        {"item_id": combo_id, "item_type": "combo", "repetitions": 2},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    page.clock.run_for(60_000)  # full first slot: pre-announce at 50s, expire+advance at 60s
    page.wait_for_timeout(300)

    spoken = _spoken(page)
    ex2_mentions = [s["text"] for s in spoken if "Voice Dup Ex2" in s["text"]]
    assert ex2_mentions == ["Next: Voice Dup Ex2"], \
        f"Expected only the pre-announcement, no duplicate step-change announcement: {spoken}"


def test_utterance_lang_follows_app_language(page: Page, app_url: str):
    """Utterance `lang` matches the current i18next language."""
    _setup(page, app_url)
    _clean_all(page)

    page.evaluate("""async () => {
        await window.__repos.settings.set('language', 'fr')
    }""")
    page.reload()
    page.wait_for_function(
        "document.documentElement.getAttribute('data-db-ready') === 'true'",
        timeout=15000,
    )

    ex1 = _create_exercise(page, "Voice Lang Ex1")
    sess_id = _create_session(page, "Voice Lang Session", [
        {"item_id": ex1, "item_type": "exercise", "repetitions": 5},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    spoken = _spoken(page)
    match = next((s for s in spoken if s["text"] == "Voice Lang Ex1"), None)
    assert match is not None, f"Expected title spoken: {spoken}"
    assert match["lang"] == "fr-FR", f"Expected fr-FR, got {match['lang']}"

    # Reset language back to English for other tests
    page.evaluate("""async () => {
        await window.__repos.settings.set('language', 'en')
    }""")


def test_utterance_volume_follows_audio_volume(page: Page, app_url: str):
    """Utterance `volume` matches the `audio_volume` setting."""
    _setup(page, app_url)
    _clean_all(page)

    page.evaluate("""async () => {
        await window.__repos.settings.set('audio_volume', '30')
    }""")

    ex1 = _create_exercise(page, "Voice Vol Ex1")
    sess_id = _create_session(page, "Voice Vol Session", [
        {"item_id": ex1, "item_type": "exercise", "repetitions": 5},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    spoken = _spoken(page)
    match = next((s for s in spoken if s["text"] == "Voice Vol Ex1"), None)
    assert match is not None, f"Expected title spoken: {spoken}"
    assert match["volume"] == pytest.approx(0.3), f"Expected 0.3, got {match['volume']}"

    page.evaluate("""async () => {
        await window.__repos.settings.set('audio_volume', '50')
    }""")


def test_voice_disabled_nothing_spoken(page: Page, app_url: str):
    """Unchecking the setting silences announcements and persists across reload."""
    page.on("dialog", lambda dialog: dialog.accept())
    _setup(page, app_url)
    _clean_all(page)

    page.goto(f"{app_url}#/settings")
    page.wait_for_selector('[data-testid="settings-voice-enabled"]')
    checkbox = page.locator('[data-testid="settings-voice-enabled"]')
    expect(checkbox).to_be_checked()
    checkbox.uncheck()
    expect(checkbox).not_to_be_checked()

    page.reload()
    page.wait_for_selector('[data-testid="settings-voice-enabled"]')
    expect(page.locator('[data-testid="settings-voice-enabled"]')).not_to_be_checked()

    ex1 = _create_exercise(page, "Voice Off Ex1")
    sess_id = _create_session(page, "Voice Off Session", [
        {"item_id": ex1, "item_type": "exercise", "repetitions": 5},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    spoken = _spoken(page)
    assert spoken == [], f"Expected no announcements, got: {spoken}"

    # Re-enable for other tests
    page.goto(f"{app_url}#/settings")
    page.wait_for_selector('[data-testid="settings-voice-enabled"]')
    page.locator('[data-testid="settings-voice-enabled"]').check()


def test_works_with_speech_synthesis_absent(page: Page, app_url: str):
    """Execution works normally when `speechSynthesis` does not exist (red path)."""
    _setup(page, app_url, mock_speech=False)
    _clean_all(page)

    errors = []
    page.on("pageerror", lambda exc: errors.append(str(exc)))

    ex_id = _create_exercise(page, "Voice Absent Ex")
    sess_id = _create_session(page, "Voice Absent Session", [
        {"item_id": ex_id, "item_type": "exercise", "repetitions": 5},
    ])
    _navigate_to_execute(page, app_url, sess_id)

    expect(page.locator('[data-testid="execute-exercise-title"]')).to_have_text(
        "Voice Absent Ex"
    )
    page.click('[data-testid="execute-done-btn"]')
    page.wait_for_function(
        f"window.location.hash.includes('/sessions/{sess_id}') && !window.location.hash.includes('/execute')",
        timeout=5000,
    )

    logs = page.evaluate("window.__repos.executionLogs.getAll()")
    assert len(logs) == 1
    assert logs[0]["status"] == "completed"
    assert errors == [], f"Unexpected page errors: {errors}"
