"""Read-only, bounded Agent History for the wearable device.

This module never keeps a second conversation database: it normalizes the native
adapter's persisted messages on demand and returns at most four user/assistant
messages per page, newest first, with an opaque cursor for the next older page.
Reasoning, commentary, tool output, command output, file changes, credentials and
internal metadata are never exposed.

Adapter contract: every native adapter must hand this layer messages in
NEWEST-FIRST order. A native store that is oldest-first (the Pi SDK returns the
session tail oldest -> newest) is reversed inside its adapter, so the pagination
code here only ever sees one ordering and never has to know about a native
provider's sort direction.
"""
from __future__ import annotations

import hashlib
import base64
import json
import re

from .models import ControlError

HISTORY_MAX_MESSAGES = 4
HISTORY_MAX_FRAME_BYTES = 4096
HISTORY_MAX_TEXT_BYTES = 1200
ALLOWED_ROLES = ("user", "assistant")
_CURSOR_PATTERN = re.compile(r"^[A-Za-z0-9_-]{1,200}$")


def _redact(text: str) -> str:
    from device_gateway.speech import redact_credentials
    return redact_credentials(text)


def _clean(text: str, limit: int = HISTORY_MAX_TEXT_BYTES) -> str:
    text = ''.join(c for c in text.replace('\r\n', '\n')
                   if c in '\n\t' or ord(c) >= 32)
    text = _redact(text)
    encoded = text.encode('utf-8')
    if len(encoded) <= limit:
        return text
    # Redact first, then truncate on a code point boundary.
    return encoded[:limit].decode('utf-8', errors='ignore') + '…'


def encode_cursor(offset: int) -> str:
    """Opaque cursor. The device must never interpret it."""
    raw = f"h1:{offset}".encode('ascii')
    return base64.urlsafe_b64encode(raw).decode('ascii').rstrip('=')


def decode_cursor(cursor):
    """Return the oldest-relative offset, or None when the cursor is unusable."""
    if cursor in (None, ""):
        return 0
    if not isinstance(cursor, str) or not _CURSOR_PATTERN.match(cursor):
        return None
    padded = cursor + '=' * (-len(cursor) % 4)
    try:
        raw = base64.urlsafe_b64decode(padded.encode('ascii')).decode('ascii')
    except Exception:
        return None
    if not raw.startswith("h1:"):
        return None
    try:
        offset = int(raw[3:])
    except ValueError:
        return None
    return offset if offset >= 0 else None


def normalize_user_message_content(item: dict) -> str:
    """Extract the user-readable text from a Codex `userMessage` thread item.

    The current Codex app-server schema (verified against the locally generated
    0.154.0 protocol) has no `text` on `UserMessageThreadItem`; the prompt lives
    in `content[]`, whose entries are unions discriminated by `type`. Only the
    `text` variant carries prose the user typed. The structured variants
    (image / localImage / audio / localAudio / skill / mention) are attachments
    or references, not text, so they never enter the read-only history.
    Multiple text parts are joined in native order.
    """
    content = item.get("content")
    parts = []
    if isinstance(content, str):
        # Defensive: tolerate a plain-string content without special-casing it.
        parts.append(content)
    elif isinstance(content, list):
        for part in content:
            if not isinstance(part, dict):
                continue
            if part.get("type") == "text" and isinstance(part.get("text"), str):
                parts.append(part["text"])
    text = "\n".join(part for part in parts if part.strip())
    if text.strip():
        return text
    # Older revisions carried a flat `text` on the user item. Accept it as a
    # fallback so an older native build does not silently lose the prompt.
    legacy = item.get("text")
    return legacy if isinstance(legacy, str) else ""


def normalize_codex_turn(turn: dict, *, current=False) -> list[dict]:
    """Normalize one Codex turn into at most one user + one assistant message.

    Order inside a turn is preserved so a user prompt precedes its answer. The
    assistant prefers phase=final_answer, otherwise the last non-commentary
    agentMessage. Reasoning and tool items are skipped entirely.
    """
    if not isinstance(turn, dict) or (not current and turn.get("status") != "completed"):
        return []
    if turn.get("itemsView", "full") != "full":
        return []
    items = turn.get("items")
    if not isinstance(items, list):
        return []
    users, assistants = [], []
    for item in items:
        if not isinstance(item, dict):
            continue
        kind = item.get("type")
        if kind == "userMessage":
            text = normalize_user_message_content(item)
            if not text.strip():
                continue
            users.append(text)
            continue
        text = item.get("text")
        if not isinstance(text, str) or not text.strip():
            continue
        if kind == "agentMessage":
            if item.get("phase") == "commentary":
                continue
            assistants.append((item.get("phase"), text))
    result = []
    if current:
        return ([dict(role='user',text=text) for text in users] +
                [dict(role='assistant',text=text) for _,text in assistants])
    if users:
        result.append({"role": "user", "text": users[0]})
    if assistants:
        finals = [text for phase, text in assistants if phase == "final_answer"]
        chosen = (finals or [text for _, text in assistants])[-1]
        result.append({"role": "assistant", "text": chosen})
    return result


def normalize_pi_messages(messages) -> list[dict]:
    """Normalize persisted Pi session messages into user/assistant text only."""
    result = []
    if not isinstance(messages, list):
        return result
    for message in messages:
        if not isinstance(message, dict):
            continue
        role = message.get("role")
        if role not in ALLOWED_ROLES:
            continue  # toolResult / toolCall / reasoning / system are skipped.
        content = message.get("content")
        parts = []
        if isinstance(content, str):
            parts.append(content)
        elif isinstance(content, list):
            for part in content:
                if isinstance(part, dict) and part.get("type") == "text" and isinstance(part.get("text"), str):
                    parts.append(part["text"])
        text = "\n".join(part for part in parts if part.strip())
        if text.strip():
            result.append({"role": role, "text": text})
    return result


def build_page(messages: list[dict], cursor, *, agent: str, generation: int) -> dict:
    """Take the next older page of at most HISTORY_MAX_MESSAGES messages.

    Messages arrive newest first. The cursor counts how many messages have
    already been delivered from the newest end.
    """
    offset = decode_cursor(cursor)
    if offset is None:
        raise ControlError("Unsupported history cursor", 409)
    if offset > len(messages):
        raise ControlError("Stale history cursor", 409)
    window = messages[offset:offset + HISTORY_MAX_MESSAGES]
    next_offset = offset + len(window)
    next_cursor = encode_cursor(next_offset) if next_offset < len(messages) else ""
    payload = {
        "messages": [{"role": item["role"], "text": _clean(item["text"])} for item in window],
        "agent": agent,
        "generation": generation,
        "next_cursor": next_cursor or None,
        "oldest": not next_cursor,
    }
    frame = json.dumps(payload, ensure_ascii=False, separators=(',', ':'))
    if len(frame.encode('utf-8')) > HISTORY_MAX_FRAME_BYTES:
        # Shrink the text budget until the whole frame is bounded.
        budget = HISTORY_MAX_TEXT_BYTES
        while budget > 64:
            budget //= 2
            payload["messages"] = [{"role": item["role"], "text": _clean(item["text"], budget)}
                                   for item in window]
            frame = json.dumps(payload, ensure_ascii=False, separators=(',', ':'))
            if len(frame.encode('utf-8')) <= HISTORY_MAX_FRAME_BYTES:
                break
        if len(frame.encode('utf-8')) > HISTORY_MAX_FRAME_BYTES:
            raise ControlError("History page too large", 413)
    payload["request_bytes"] = len(frame.encode('utf-8'))
    return payload

def build_view_page(messages, cursor, *, agent, generation):
    """One bounded fragment per page; all redacted UTF-8 text remains traversable.

    h2 cursors bind the fragment index to this native snapshot. A changed native
    message list must be refreshed explicitly, never mixed into an old view.
    """
    fragments = []
    for message_index, item in enumerate(messages):
        text = ''.join(c for c in item['text'].replace('\r\n', '\n') if c in '\n\t' or ord(c) >= 32)
        text = _redact(text)
        parts = []
        part, size = [], 0
        for character in text:
            width = len(character.encode('utf-8'))
            if size + width > 1000:
                parts.append(''.join(part)); part, size = [], 0
            part.append(character); size += width
        if part or not parts: parts.append(''.join(part))
        for segment, part in enumerate(parts):
            fragments.append(dict(role=item['role'], text=part, message=message_index+1,
                                  segment=segment+1, segments=len(parts)))
    signature = hashlib.sha256(json.dumps(fragments,ensure_ascii=False,separators=(',',':')).encode()).hexdigest()[:16]
    def token(index):
        return base64.urlsafe_b64encode(f'h2:{index}:{signature}'.encode()).decode().rstrip('=')
    index = 0
    if cursor:
        try:
            if not isinstance(cursor,str) or not _CURSOR_PATTERN.fullmatch(cursor): raise ValueError()
            version, raw_index, snapshot = base64.urlsafe_b64decode(cursor+'='*(-len(cursor)%4)).decode().split(':')
            index = int(raw_index)
            if version!='h2' or snapshot!=signature or index<0 or index>=len(fragments): raise ValueError()
        except Exception:
            raise ControlError('View changed; reopen to refresh',409) from None
    next_cursor = token(index+1) if index+1<len(fragments) else None
    result = dict(messages=fragments[index:index+1],agent=agent,generation=generation,
                  next_cursor=next_cursor,previous_cursor=token(index-1) if index>0 else None,
                  oldest=not next_cursor,fragment=index+1 if fragments else 0,fragments=len(fragments))
    if len(json.dumps(result,ensure_ascii=False).encode())>3000: raise ControlError('View page too large',413)
    return result
