"""The turn state machine (docs/specs/2026-08-10-ask-gpt-resume-live-design.md §1):
attaching -> submitted -> named(meta) -> streaming -> response_done -> collecting ->
done | error. Scripted independently of fake_browser.py's one-shot delivery model so
each poll iteration can carry its own text/thinking snapshot.
"""

from __future__ import annotations

import json

import pytest

import chat
import registry

UUID_URL = "https://chatgpt.com/c/22222222-2222-4222-8222-222222222222"


class ScriptedBrowser:
    """`polls[i]` is what LIVE_STATE/PROGRESS answer on send()'s i-th poll; the index
    advances once per iteration, pinned at the last entry once exhausted."""

    def __init__(self, polls, thinking=None, harvest=None):
        self.polls = polls
        self.thinking = thinking or []
        self.harvest_data = harvest or {"text": "", "images": [], "url": UUID_URL}
        self.step = 0
        self._index = 0
        self.clicked_send = False
        self.download_dir = None
        self.document_title = "ChatGPT"
        self.document_titles = []

    def navigate(self, url):
        pass

    def url(self):
        return self.harvest_data.get("url", "")

    def press(self, key=""):
        pass

    def click(self, selector):
        if selector == chat.SEND:
            self.clicked_send = True
        return True

    def js_click(self, selector):
        return True

    def send_keys(self, selector, text):
        return True

    def screenshot(self, dest):
        return dest

    def script(self, source, args=None):
        if source == chat.AUTH_SESSION:
            return {"user": {"email": "owner@example.com"}}
        raise AssertionError("no download fetch expected in this scenario")

    def sync_script(self, source, args=None):
        args = args or []
        if source == chat.DOCUMENT_TITLE:
            if self.document_titles:
                return self.document_titles.pop(0)
            return self.document_title
        if source == chat.TYPE:
            return True
        if source == chat.USER_COUNT:
            return 1 if self.clicked_send else 0
        if source == chat.TURN_COUNT:
            return self.polls[self._index]["turns"] if self.clicked_send else 0
        if source == chat.PROGRESS:
            # PROGRESS is queried unconditionally, once per poll — advancing the
            # index here (not in the live-only scripts) keeps `on_event=None` moving.
            self._index = min(self.step, len(self.polls) - 1)
            self.step += 1
            poll = self.polls[self._index]
            return {"turns": poll["turns"], "imgs": 0, "text": len(poll["text"]), "stop": False}
        if source == chat.ACCEPT_STATE:
            # submit() checks this before ever touching PROGRESS, so it settles
            # (and the click is accepted) without disturbing `self._index` — the
            # timeline `polls`/`thinking` are indexed against stays aligned with
            # send()'s own loop. Composer idle (send button back) once the last
            # scripted poll is reached, same as the real page settling.
            gone = self._index < len(self.polls) - 1
            return {"sendGone": gone, "composerEmpty": True}
        if source == chat.LIVE_STATE:
            poll = self.polls[self._index]
            return {"url": poll.get("url", self.harvest_data.get("url", "")),
                    "text": poll["text"], "turns": poll["turns"]}
        if source == chat.THINKING:
            if not self.thinking:
                return {"present": False}
            return self.thinking[min(self._index, len(self.thinking) - 1)]
        if source == chat.HARVEST:
            return self.harvest_data
        raise AssertionError(f"unscripted call: {source[:60]!r}")


@pytest.fixture(autouse=True)
def no_sleep(monkeypatch):
    monkeypatch.setattr(chat.time, "sleep", lambda *_: None)


def types(events):
    return [event["type"] for event in events]


def test_meta_fires_once_when_the_url_first_resolves(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": "https://chatgpt.com/"},
        {"turns": 1, "text": "hi", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
    ])
    events = []
    chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    metas = [e for e in events if e["type"] == "meta"]
    assert len(metas) == 1
    assert metas[0]["conversation_id"] == "22222222-2222-4222-8222-222222222222"
    assert metas[0]["url"] == UUID_URL
    assert metas[0]["phase"] == "named"


def test_resume_meta_does_not_replace_existing_title_with_prompt(tmp_path):
    conversation_id = UUID_URL.rsplit("/", 1)[-1]
    registry.append("owner@example.com", conversation_id, "Existing title", UUID_URL)
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
    ])
    chat.ask_on(browser, "resumed prompt", out_dir=tmp_path, stamp="s", on_event=lambda event: None)

    titles = [json.loads(line)["title"] for line in registry.PATH.read_text().splitlines()]
    assert titles == ["Existing title", "Existing title"]


def test_resume_appends_late_browser_title(tmp_path):
    conversation_id = UUID_URL.rsplit("/", 1)[-1]
    registry.append("owner@example.com", conversation_id, "Existing title", UUID_URL)
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
    ])
    browser.document_titles = ["ChatGPT", "Late browser title", "Late browser title"]

    chat.ask_on(browser, "resumed prompt", out_dir=tmp_path, stamp="s", on_event=lambda event: None)

    titles = [json.loads(line)["title"] for line in registry.PATH.read_text().splitlines()]
    assert browser.url() == UUID_URL
    assert browser.sync_script(chat.TURN_COUNT) >= 1
    assert titles == ["Existing title", "Late browser title"]


def test_fresh_conversation_appends_late_browser_title(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": "https://chatgpt.com/"},
        {"turns": 1, "text": "hi", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
    ])
    browser.document_titles = ["ChatGPT", "Fresh late title", "Fresh late title"]

    chat.ask_on(browser, "fresh prompt", out_dir=tmp_path, stamp="s", on_event=lambda event: None)

    titles = [json.loads(line)["title"] for line in registry.PATH.read_text().splitlines()]
    assert titles == ["fresh-prompt", "Fresh late title"]


@pytest.mark.parametrize("wrong_url", [
    "https://chatgpt.com/",
    f"https://example.com/c/{UUID_URL.rsplit('/', 1)[-1]}",
    f"https://chatgpt.com/?next=/c/{UUID_URL.rsplit('/', 1)[-1]}",
])
def test_resume_stable_wrong_surface_title_preserves_existing_title(tmp_path, wrong_url):
    conversation_id = UUID_URL.rsplit("/", 1)[-1]
    registry.append("owner@example.com", conversation_id, "Existing title", UUID_URL)
    browser = ScriptedBrowser(
        polls=[
            {"turns": 0, "text": "", "url": UUID_URL},
            {"turns": 1, "text": "hi", "url": UUID_URL},
        ],
        harvest={"text": "hi", "images": [], "url": wrong_url},
    )
    browser.document_title = "Attention Required! | Cloudflare"

    chat.ask_on(browser, "resumed prompt", out_dir=tmp_path, stamp="s", on_event=lambda event: None)

    assert registry.lookup(conversation_id).title == "Existing title"


def test_resume_meta_rejects_cross_account_registry_owner(tmp_path):
    conversation_id = UUID_URL.rsplit("/", 1)[-1]
    registry.append("other@example.com", conversation_id, "Other account title", UUID_URL)
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
    ])
    events = []

    with pytest.raises(registry.RegistryError, match="belongs to other@example.com, not owner@example.com"):
        chat.ask_on(browser, "resumed prompt", out_dir=tmp_path, stamp="s",
                    on_event=events.append)

    terminal = [event for event in events if event["type"] in {"error", "done"}]
    assert [event["type"] for event in terminal] == ["error"]
    assert terminal[0]["phase"] == "named"
    assert terminal[0]["partial"] == {
        "phase": "named", "thinking": "", "answer": "", "saved_artifacts": []}
    assert len(registry.PATH.read_text().splitlines()) == 1


def test_final_registry_append_failure_emits_one_error_with_partial_state(
        tmp_path, monkeypatch):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": "https://chatgpt.com/"},
        {"turns": 1, "text": "hi", "url": UUID_URL},
        {"turns": 1, "text": "hi", "url": UUID_URL},
    ])
    events = []
    real_append = registry.append
    append_calls = 0

    def append(*args, **kwargs):
        nonlocal append_calls
        append_calls += 1
        if append_calls == 2:
            raise registry.RegistryError("registry write failed")
        return real_append(*args, **kwargs)

    monkeypatch.setattr(registry, "append", append)

    with pytest.raises(registry.RegistryError, match="registry write failed"):
        chat.ask_on(browser, "fresh prompt", out_dir=tmp_path, stamp="s",
                    on_event=events.append)

    terminal = [event for event in events if event["type"] in {"error", "done"}]
    assert [event["type"] for event in terminal] == ["error"]
    assert terminal[0]["phase"] == "collecting"
    assert terminal[0]["partial"] == {
        "phase": "collecting", "thinking": "", "answer": "hi", "saved_artifacts": []}
    conversation_id = UUID_URL.rsplit("/", 1)[-1]
    assert registry.lookup(conversation_id).title == "fresh-prompt"


def test_answer_delta_then_a_nonprefix_rewrite_is_a_replace(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": ""},
        {"turns": 1, "text": "Hel"},
        {"turns": 1, "text": "Totally different"},
        {"turns": 1, "text": "Totally different"},
    ])
    events = []
    chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    answers = [e for e in events if e["type"] == "answer"]
    replaces = [e for e in events if e["type"] == "replace"]
    assert answers and answers[0]["delta"] == "Hel"
    assert replaces and replaces[0]["answer"] == "Totally different"


def test_thinking_absent_emits_no_thinking_events(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": ""},
        {"turns": 1, "text": "hi"},
        {"turns": 1, "text": "hi"},
    ], thinking=[{"present": False}])
    events = []
    chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    assert not [e for e in events if e["type"] == "thinking"]


def test_thinking_present_and_read_emits_deltas(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": ""},
        {"turns": 1, "text": "hi"},
        {"turns": 1, "text": "hi"},
    ], thinking=[
        {"present": True, "expanding": True},
        {"present": True, "expanding": False, "text": "reasoning..."},
        {"present": True, "expanding": False, "text": "reasoning..."},
    ])
    events = []
    reply = chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    thinks = [e for e in events if e["type"] == "thinking"]
    assert thinks and thinks[0]["delta"] == "reasoning..."
    assert reply.thinking == "reasoning..."


def test_thinking_present_but_unreadable_emits_the_sentinel_once(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": ""},
        {"turns": 1, "text": "hi"},
        {"turns": 1, "text": "hi"},
    ], thinking=[
        {"present": True, "expanding": True},
        {"present": True, "expanding": False, "text": ""},
        {"present": True, "expanding": False, "text": ""},
    ])
    events = []
    chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    thinks = [e for e in events if e["type"] == "thinking"]
    assert [t["delta"] for t in thinks] == [chat.THINKING_UNHARVESTABLE]


def test_response_done_precedes_collecting_and_done_fires_after_it(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": ""},
        {"turns": 1, "text": "hi"},
        {"turns": 1, "text": "hi"},
    ], harvest={"text": "hi", "images": [], "url": UUID_URL})
    events = []
    chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    order = types(events)
    assert order.index("response_done") < order.index("done")
    assert order[-1] == "done"
    assert events[order.index("response_done")]["phase"] == "response_done"


def test_done_reply_carries_thinking_and_conversation_id(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": ""},
        {"turns": 1, "text": "hi"},
        {"turns": 1, "text": "hi"},
    ], harvest={"text": "hi", "images": [], "url": UUID_URL})
    events = []
    chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    done = next(e for e in events if e["type"] == "done")
    assert done["reply"]["conversation_id"] == "22222222-2222-4222-8222-222222222222"
    assert done["reply"]["text"] == "hi"


def test_no_meta_by_completion_is_a_protocol_error_with_partial_state(tmp_path):
    browser = ScriptedBrowser(polls=[
        {"turns": 0, "text": "", "url": "https://chatgpt.com/"},
        {"turns": 1, "text": "hi", "url": "https://chatgpt.com/"},
        {"turns": 1, "text": "hi", "url": "https://chatgpt.com/"},
    ])
    events = []
    with pytest.raises(chat.ProtocolTurnError):
        chat.ask_on(browser, "hello", out_dir=tmp_path, stamp="s", on_event=events.append)
    errors = [e for e in events if e["type"] == "error"]
    assert errors and errors[0]["class"] == "protocol"
    assert errors[0]["partial"]["answer"] == "hi"


def test_on_event_none_takes_no_extra_reads_and_is_unchanged(monkeypatch, tmp_path):
    """The default (no `--live`, not the daemon path) invocation must never call the
    live-only scripts at all — the byte-identical stdout guarantee starts here."""
    calls = []
    real = ScriptedBrowser(polls=[{"turns": 0, "text": ""}, {"turns": 1, "text": "hi"},
                                  {"turns": 1, "text": "hi"}],
                           harvest={"text": "hi", "images": [], "url": UUID_URL})
    wrapped_sync = real.sync_script

    def tracking_sync_script(source, args=None):
        calls.append(source)
        return wrapped_sync(source, args)
    real.sync_script = tracking_sync_script

    reply = chat.ask_on(real, "hello", out_dir=tmp_path, stamp="s")
    assert reply.text == "hi"
    assert chat.LIVE_STATE not in calls
    assert chat.THINKING not in calls
