"""Reconcile a stateless `messages[]` request against a stateful browser conversation.

An OpenAI client resends its whole history every request; the conversation in the
browser already holds it. Only the delta may be typed, and a history that no longer
extends what was delivered is not patched up — the conversation is replaced.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field

MAX_RESULT_CHARS = 60000


class ReplayNeeded(Exception):
    """The client's history no longer extends what this conversation was told."""


class ContextOverflow(Exception):
    """The exact browser input would exceed the adapter context ceiling."""


@dataclass
class Conversation:
    url: str = ""
    delivered: list[dict] = field(default_factory=list)
    turns: int = 0
    opened_at: float = 0.0
    effort: str = "medium"
    preamble: str = ""
    # The seat index this conversation opened on; later turns stay pinned to it
    # because the composer's typed-but-unsent state lives in that one browser.
    seat: int = -1
    # Conservative adapter occupancy, not billing usage.
    estimated_input: int = 0
    estimated_output: int = 0


def _text(message: dict) -> str:
    content = message.get("content")
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        return "\n".join(
            part.get("text", "") for part in content
            if isinstance(part, dict) and part.get("type") in (None, "text")
        )
    return "" if content is None else str(content)


def session_key(messages: list[dict], tools: list[dict], effort: str,
                client_session_id: str | None = None) -> str:
    """Identify a request; optional Pi identity isolates otherwise identical clients."""
    system = next((_text(m) for m in messages if m.get("role") == "system"), "")
    first_user = next((_text(m) for m in messages if m.get("role") == "user"), "")
    names = sorted(
        (t.get("function", {}).get("name") if "function" in t else t.get("name")) or ""
        for t in tools
    )
    # Preserve the legacy digest exactly when the optional header is absent.
    parts = [system, first_user, "\x01".join(names), effort]
    if client_session_id is not None:
        # Keep the historical headerless digest frozen, but do not truncate the
        # domain-separated client identity: concurrent identical Pi requests must
        # never share a browser conversation.
        seed = "\x00".join(parts + ["overdeck-client-session-v1", client_session_id])
        return hashlib.sha256(seed.encode("utf-8")).hexdigest()
    seed = "\x00".join(parts)
    return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]


class Registry:
    """The conversations this daemon has open, keyed by session."""

    def __init__(self) -> None:
        self._by_key: dict[str, Conversation] = {}

    def get(self, key: str) -> Conversation | None:
        return self._by_key.get(key)

    def open(self, key: str, preamble: str, effort: str, *, opened_at: float = 0.0,
             url: str = "", seat: int = -1) -> Conversation:
        conv = Conversation(url=url, delivered=[], turns=0, opened_at=opened_at,
                            effort=effort, preamble=preamble, seat=seat)
        self._by_key[key] = conv
        return conv

    def drop(self, key: str) -> None:
        self._by_key.pop(key, None)

    def __len__(self) -> int:
        return len(self._by_key)


def elide(content: str, limit: int = MAX_RESULT_CHARS) -> str:
    """Keep the head and tail of an oversized tool result; one huge read must not
    consume the whole turn."""
    if len(content) <= limit:
        return content
    removed = len(content) - limit
    half = limit // 2
    return f"{content[:half]}\n…{removed} chars elided…\n{content[len(content) - half:]}"


def render_envelope(messages: list[dict], *, include_assistant: bool = False) -> str:
    """The exact text typed into the composer for a delta or full replay."""
    blocks: list[str] = []
    for message in messages:
        role = message.get("role")
        if role == "tool":
            call_id = message.get("tool_call_id", "")
            blocks.append(f"TOOL RESULT [{call_id}]\n{elide(_text(message))}")
        elif role == "user":
            blocks.append(f"USER\n{_text(message)}")
        elif role == "assistant" and include_assistant:
            calls = message.get("tool_calls") or []
            if calls:
                blocks.append("ASSISTANT TOOL CALLS\n" + json.dumps(calls, ensure_ascii=False, sort_keys=True))
            elif _text(message):
                blocks.append(f"ASSISTANT\n{_text(message)}")
    return "\n\n".join(blocks)


def _call_ids(message: dict) -> list[str]:
    return [c.get("id", "") for c in (message.get("tool_calls") or [])]


def _same(a: dict, b: dict) -> bool:
    # An assistant turn's text is empty when it is a tool call, so its call ids are
    # the only thing that distinguishes it from a different tool call.
    return (a.get("role") == b.get("role")
            and _text(a) == _text(b)
            and a.get("tool_call_id") == b.get("tool_call_id")
            and _call_ids(a) == _call_ids(b))


CONTEXT_WINDOW = 120000
RESERVED_OUTPUT = 16000
MESSAGE_OVERHEAD = 16
TOOL_OVERHEAD = 8


def estimate_tokens(text: str) -> int:
    """Tokenizer-independent conservative byte estimate."""
    return len(text.encode("utf-8"))


def estimate_input(messages: list[dict], tools: list[dict] | None = None, *, rendered: str | None = None) -> int:
    """Estimate exactly the text that will be typed, not an API JSON approximation."""
    rendered = render_envelope(messages) if rendered is None else rendered
    return estimate_tokens(rendered) + MESSAGE_OVERHEAD * len(messages) + TOOL_OVERHEAD * len(tools or [])


def check_context(conv: Conversation, candidate_input: int) -> None:
    if conv.estimated_input + conv.estimated_output + candidate_input + RESERVED_OUTPUT > CONTEXT_WINDOW:
        raise ContextOverflow("estimated context window exceeded")


def usage(conv: Conversation) -> dict:
    return {"prompt_tokens": conv.estimated_input, "completion_tokens": conv.estimated_output,
            "total_tokens": conv.estimated_input + conv.estimated_output, "usage_estimated": True}


def deliver(conv: Conversation, messages: list[dict]) -> str:
    """The text that must be typed this turn — the suffix the conversation has not seen."""
    prefix = conv.delivered
    if len(messages) < len(prefix):
        raise ReplayNeeded("history is shorter than what was delivered")
    for delivered, current in zip(prefix, messages):
        if not _same(delivered, current):
            raise ReplayNeeded("history no longer extends what was delivered")
    suffix = messages[len(prefix):]
    if not suffix:
        raise ReplayNeeded("history adds nothing to type")
    rendered = render_envelope(suffix)
    check_context(conv, estimate_input(suffix))
    conv.delivered = list(messages)
    conv.estimated_input += estimate_input(suffix)
    return rendered


def deliver_all(conv: Conversation, messages: list[dict]) -> str:
    """The whole history, for a conversation that has seen none of it.

    A fresh chat can always be typed into, so this never raises — an empty history
    still needs a turn, or the caller would wait on a reply nobody asked for.
    """
    rendered = render_envelope(messages, include_assistant=True) or "USER\n(continue)"
    candidate = estimate_input(messages, rendered=rendered)
    check_context(conv, candidate)
    conv.delivered = list(messages)
    conv.estimated_input += candidate
    return rendered
