"""The conversation transcript log for ask-gpt.

Canonical `~/.overdeck/gptbridge/logs/<id>.log` is authoritative. The duplicate at
`<invocation-cwd>/ask-gpt/<id>.log` is a real file (never a symlink — a sandboxed
agent cannot follow one out of its sandbox), rewritten in full from the canonical
after every turn. See docs/specs/2026-08-10-ask-gpt-resume-live-design.md §4.
"""

from __future__ import annotations

import fcntl
import os
import re
import time
from contextlib import contextmanager
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path

STATE = Path.home() / ".overdeck" / "gptbridge"
LOGS = STATE / "logs"
LOCK_WAIT_SECONDS = 30.0

TURN_HEADER_RE = re.compile(
    r"^## turn (\d+) — (.*?)(?: \(effort: ([a-z]+)\))?"
    r"((?: \((?:hydrated|interrupted)\))*)\s*$")
END_MARKER_RE = re.compile(r"^<!-- end turn (\d+) -->$")


class ConvLogError(RuntimeError):
    pass


def now_iso() -> str:
    return datetime.now().astimezone().isoformat()


@dataclass
class Turn:
    number: int
    timestamp: str
    effort: str | None
    prompt: str
    thinking: str
    answer: str
    artifacts: list[str] = field(default_factory=list)
    hydrated: bool = False
    interrupted: bool = False

    def render(self) -> str:
        tags = ""
        if self.hydrated:
            tags += " (hydrated)"
        if self.interrupted:
            tags += " (interrupted)"
        effort = f" (effort: {self.effort})" if self.effort else ""
        lines = [
            f"## turn {self.number} — {self.timestamp}{effort}{tags}", "",
            "### prompt", self.prompt.strip() or "(none)", "",
            "### thinking", self.thinking.strip() or "(none)", "",
            "### answer", self.answer.strip() or "(none)", "",
            "### artifacts",
        ]
        lines += [f"- {path}" for path in self.artifacts] if self.artifacts else ["(none)"]
        lines += ["", f"<!-- end turn {self.number} -->", ""]
        return "\n".join(lines)


def _header(conversation_id: str, url: str, created: str) -> str:
    return f"# conversation {conversation_id}\nurl: {url}\ncreated: {created}\n\n"


def _atomic_write(path: Path, text: str) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_name(path.name + f".tmp-{os.getpid()}")
    tmp.write_text(text)
    os.replace(tmp, path)  # same directory as `path`: rename is atomic


def _strip_none(text: str) -> str:
    text = text.strip()
    return "" if text == "(none)" else text


def parse(text: str) -> list[Turn]:
    lines = text.splitlines()
    turns: list[Turn] = []
    i = 0
    while i < len(lines):
        match = TURN_HEADER_RE.match(lines[i])
        if not match:
            i += 1
            continue
        number = int(match.group(1))
        timestamp, effort, tags = match.group(2).strip(), match.group(3), match.group(4) or ""
        i += 1
        sections = {"prompt": "", "thinking": "", "answer": ""}
        artifacts: list[str] = []
        current = None
        closed = False
        while i < len(lines):
            line = lines[i]
            if END_MARKER_RE.match(line):
                closed = True
                i += 1
                break
            if TURN_HEADER_RE.match(line):
                break
            if line.startswith("### "):
                current = line[4:].strip()
                i += 1
                continue
            if current == "artifacts":
                if line.startswith("- "):
                    artifacts.append(line[2:].strip())
            elif current in sections:
                sections[current] = f"{sections[current]}\n{line}" if sections[current] else line
            i += 1
        turns.append(Turn(
            number=number, timestamp=timestamp, effort=effort,
            prompt=_strip_none(sections["prompt"]), thinking=_strip_none(sections["thinking"]),
            answer=_strip_none(sections["answer"]), artifacts=artifacts,
            hydrated="hydrated" in tags, interrupted="interrupted" in tags or not closed))
    return turns


def _annotate_unclosed(text: str) -> str:
    """A crash mid-turn leaves `## turn N` with no `<!-- end turn N -->`. Mark it
    interrupted before anything else appends — never blind-appended into."""
    lines = text.splitlines()
    open_header = None
    for idx, line in enumerate(lines):
        if TURN_HEADER_RE.match(line):
            open_header = idx
        elif END_MARKER_RE.match(line):
            open_header = None
    if open_header is None or "(interrupted)" in lines[open_header]:
        return text
    lines[open_header] += " (interrupted)"
    return "\n".join(lines) + "\n"


class ConversationLog:
    """The canonical transcript for one conversation id, plus its cwd duplicate."""

    def __init__(self, conversation_id: str):
        self.id = conversation_id
        self.path = LOGS / f"{conversation_id}.log"
        self.lock_path = LOGS / f"{conversation_id}.lock"

    def exists(self) -> bool:
        return self.path.exists()

    def turns(self) -> list[Turn]:
        return parse(self.path.read_text()) if self.path.exists() else []

    def ensure(self, url: str) -> None:
        """Create the header if new; repair an unclosed trailing block left by an
        earlier crash before this invocation appends anything of its own."""
        if self.path.exists():
            text = self.path.read_text()
            fixed = _annotate_unclosed(text)
            if fixed != text:
                _atomic_write(self.path, fixed)
            return
        _atomic_write(self.path, _header(self.id, url, now_iso()))

    def next_number(self) -> int:
        turns = self.turns()
        return turns[-1].number + 1 if turns else 1

    def append(self, turn: Turn) -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("a") as handle:
            handle.write(turn.render())

    def append_download(self, download: "Download") -> None:
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with self.path.open("a") as handle:
            handle.write(download.render())

    def rewrite_duplicate(self, cwd: Path) -> Path:
        dest = cwd / "ask-gpt" / f"{self.id}.log"
        _atomic_write(dest, self.path.read_text() if self.path.exists() else "")
        return dest


@dataclass
class Download:
    """§4b recovery: a turn-less record — no prompt was sent, so it does not get a
    `## turn N` slot; it just adds to the conversation's history."""

    timestamp: str
    paths: list[str] = field(default_factory=list)
    failures: list[str] = field(default_factory=list)

    def render(self) -> str:
        lines = [f"### downloaded — {self.timestamp}", ""]
        lines += [f"- {path}" for path in self.paths] if self.paths else ["(none)"]
        if self.failures:
            lines += ["", "failed:"] + [f"- {failure}" for failure in self.failures]
        lines += ["", f"<!-- end downloaded {self.timestamp} -->", ""]
        return "\n".join(lines)


def hydrate(log: ConversationLog, dom_turns: list[dict]) -> list[Turn]:
    """Append every DOM-visible turn beyond what the log already holds — a foreign
    conversation (log empty) or a turn from a run that crashed before logging it."""
    existing = log.turns()
    added: list[Turn] = []
    for item in dom_turns[len(existing):]:
        turn = Turn(
            number=log.next_number(), timestamp=now_iso(), effort=None,
            prompt=str(item.get("prompt", "")), thinking=str(item.get("thinking", "")),
            answer=str(item.get("answer", "")), artifacts=[], hydrated=True)
        log.append(turn)
        added.append(turn)
    return added


def write_failure(stamp: str, partial: dict, prompt: str, effort: str | None) -> Path:
    """Pre-ID failures (upload/login/navigation died before `meta`) cannot use
    `<id>.log` — the conversation-log promise starts when a conversation id exists."""
    path = LOGS / f"failed-{stamp}.log"
    turn = Turn(
        number=1, timestamp=now_iso(), effort=effort, prompt=prompt,
        thinking=str(partial.get("thinking", "")), answer=str(partial.get("answer", "")),
        artifacts=list(partial.get("saved_artifacts") or []), interrupted=True)
    header = (f"# ask-gpt failure (phase: {partial.get('phase', 'unknown')})\n"
             f"created: {now_iso()}\n\n")
    _atomic_write(path, header + turn.render())
    return path


@contextmanager
def held(conversation_id: str, timeout: float = LOCK_WAIT_SECONDS):
    """Cross-process mutual exclusion on one conversation id. Bounded wait, then
    the same message on every path that can raise it."""
    LOGS.mkdir(parents=True, exist_ok=True)
    handle = (LOGS / f"{conversation_id}.lock").open("w")
    deadline = time.monotonic() + timeout
    try:
        while True:
            try:
                fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
                break
            except OSError:
                if time.monotonic() > deadline:
                    raise ConvLogError(
                        f"another ask-gpt run is using conversation {conversation_id}"
                    ) from None
                time.sleep(0.5)
        yield
    finally:
        fcntl.flock(handle, fcntl.LOCK_UN)
        handle.close()
