"""Local web server to browse spens session data (LLM traces + nono audit log).

The frontend lives in spens/static/ (index.html, style.css, app.js) and is served
by the HTTP handler below. Edit those files directly; no build step required.
"""

from __future__ import annotations

import difflib
import json
import re
import threading
import webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

from spens.sessions import (
    SPENS_DIR_NAME,
    is_chat_request,
    load_audit_session,
    load_nono_session_meta,
    load_request_log,
    load_trace_index,
    read_json,
    read_ndjson,
)
from spens.summarizer import compute_session_summary, read_summary, write_summary

DEFAULT_PORT = 7331


# ---------------------------------------------------------------------------
# Data loading
# ---------------------------------------------------------------------------


def _find_sessions_dir(start: Path, spens_dir: Path | None = None) -> Path:
    """Locate the ``sessions`` directory.

    When *spens_dir* is given (``--spens-dir``), it is used directly as the
    spens directory.  Otherwise walk upward from *start* to find a
    ``.spens/sessions`` directory, falling back to ``<start>/.spens/sessions``.
    """
    if spens_dir is not None:
        return spens_dir.resolve() / "sessions"
    for p in [start, *start.parents]:
        candidate = p / SPENS_DIR_NAME / "sessions"
        if candidate.is_dir():
            return candidate
    return start / SPENS_DIR_NAME / "sessions"


def _load_ledger(session_dir: Path) -> list[dict[str, Any]]:
    ledger = session_dir / "nono-audit" / "audit" / "ledger.ndjson"
    return read_ndjson(ledger)


def _read_object(rollback_dir: Path, hash_hex: str | None) -> bytes | None:
    """Read a content-addressed object from the rollback objects store."""
    if not hash_hex:
        return None
    obj_path = rollback_dir / "objects" / hash_hex[:2] / hash_hex[2:]
    if not obj_path.exists():
        return None
    return obj_path.read_bytes()


def _generate_diff(
    old_content: bytes | None,
    new_content: bytes | None,
    path: str,
) -> tuple[str | None, bool]:
    """Generate a unified diff. Returns (diff_text, is_binary)."""
    old_text: str | None = None
    new_text: str | None = None
    if old_content is not None:
        try:
            old_text = old_content.decode("utf-8")
        except UnicodeDecodeError:
            return None, True
    if new_content is not None:
        try:
            new_text = new_content.decode("utf-8")
        except UnicodeDecodeError:
            return None, True

    old_lines = (old_text or "").splitlines(keepends=True)
    new_lines = (new_text or "").splitlines(keepends=True)

    label = path.rsplit("/", 1)[-1] if "/" in path else path
    diff = difflib.unified_diff(old_lines, new_lines, fromfile=f"a/{label}", tofile=f"b/{label}")
    diff_text = "".join(diff)

    diff_lines = diff_text.splitlines(keepends=True)
    if len(diff_lines) > 500:
        diff_text = "".join(diff_lines[:500]) + f"\n... ({len(diff_lines) - 500} more lines truncated)\n"

    return diff_text or None, False


def _load_rollback(session_dir: Path) -> dict[str, Any]:
    """Load rollback file-change data for a session."""
    rollback_root = session_dir / "nono-audit" / "rollbacks"
    if not rollback_root.is_dir():
        return {"available": False, "steps": []}

    all_steps: list[dict[str, Any]] = []
    for rb_session in sorted(rollback_root.iterdir()):
        if not rb_session.is_dir() or not (rb_session / "session.json").exists():
            continue
        session_meta = read_json(rb_session / "session.json") or {}
        changes_dir = rb_session / "changes"
        if not changes_dir.is_dir():
            continue

        for change_file in sorted(changes_dir.glob("*.json")):
            step_num = int(change_file.stem)
            raw_changes = read_json(change_file) or []

            processed: list[dict[str, Any]] = []
            for change in raw_changes:
                path = change.get("path", "")
                change_type = change.get("change_type", "Unknown")
                old_hash = change.get("old_hash")
                new_hash = change.get("new_hash")
                size_delta = change.get("size_delta", 0)

                old_content = _read_object(rb_session, old_hash)
                new_content = _read_object(rb_session, new_hash)
                diff_text, is_binary = _generate_diff(old_content, new_content, path)

                processed.append({
                    "path": path,
                    "change_type": change_type,
                    "size_delta": size_delta,
                    "old_hash": old_hash,
                    "new_hash": new_hash,
                    "diff": diff_text,
                    "is_binary": is_binary,
                    "is_workspace": path.startswith("/workspace/"),
                })

            all_steps.append({
                "step": step_num,
                "session_id": session_meta.get("session_id", ""),
                "changes": processed,
            })

    return {"available": bool(all_steps), "steps": all_steps}


def list_sessions(sessions_dir: Path) -> list[dict[str, Any]]:
    """Return a sorted list of session summaries.

    Uses ``session_summary.json`` when available for fast display; falls back
    to computing on the fly and writes the summary for future reads.
    """
    out: list[dict[str, Any]] = []
    if not sessions_dir.is_dir():
        return out
    for d in sorted(sessions_dir.iterdir(), reverse=True):
        if not d.is_dir():
            continue

        summary = read_summary(d)
        if summary is None:
            # Compute and cache summary for next time
            try:
                summary = compute_session_summary(d)
                write_summary(d, summary)
            except Exception:
                summary = None

        if summary is not None:
            out.append({
                "id": summary.get("session_id", d.name),
                "command": summary.get("command", ""),
                "name": summary.get("name", ""),
                "started": summary.get("started", ""),
                "ended": summary.get("ended", ""),
                "exit_code": summary.get("exit_code"),
                "status": summary.get("status", ""),
                "audit_event_count": summary.get("audit", {}).get("event_count", 0),
                "trace_count": summary.get("llm", {}).get("calls", 0),
                "llm": summary.get("llm", {}),
                "network": summary.get("network", {}),
                "files": summary.get("files", {}),
                "estimated_cost_usd": summary.get("llm", {}).get("estimated_cost_usd", 0.0),
            })
            continue

        # Fallback for sessions that can't be summarised
        nono_meta = load_nono_session_meta(d) or {}
        audit_sess, audit_events = load_audit_session(d)
        audit_sess = audit_sess or {}

        started = (
            nono_meta.get("started")
            or audit_sess.get("started")
            or ""
        )
        command = nono_meta.get("command") or audit_sess.get("command") or []
        command_str = " ".join(command) if isinstance(command, list) else str(command)

        trace_count = 0
        traces_dir = d / "traces"
        if traces_dir.is_dir():
            captured = traces_dir / "captured.jsonl"
            if captured.exists():
                trace_count = sum(1 for line in captured.read_text(encoding="utf-8").splitlines() if line.strip())

        out.append({
            "id": d.name,
            "command": command_str,
            "name": nono_meta.get("name", ""),
            "started": started,
            "ended": audit_sess.get("ended", ""),
            "exit_code": nono_meta.get("exit_code", audit_sess.get("exit_code")),
            "status": nono_meta.get("status", ""),
            "audit_event_count": len(audit_events),
            "trace_count": trace_count,
        })
    return out


# ---------------------------------------------------------------------------
# Trace parsing
# ---------------------------------------------------------------------------


def _assemble_openai_chunks(chunks: list[dict[str, Any]]) -> dict[str, Any]:
    """Assemble an OpenAI-style streaming response into a single message."""
    content_parts: list[str] = []
    reasoning_parts: list[str] = []
    tool_calls: dict[int, dict[str, Any]] = {}
    finish_reason: str | None = None
    usage: dict[str, Any] | None = None
    model: str | None = None

    for chunk in chunks:
        c = chunk.get("content")
        if not isinstance(c, dict):
            continue
        if model is None and c.get("model"):
            model = c["model"]
        choices = c.get("choices") or []
        for choice in choices:
            delta = choice.get("delta") or {}
            if delta.get("content"):
                content_parts.append(delta["content"])
            if delta.get("reasoning_content"):
                reasoning_parts.append(delta["reasoning_content"])
            if choice.get("finish_reason"):
                finish_reason = choice["finish_reason"]
            tc = delta.get("tool_calls")
            if isinstance(tc, list):
                for call in tc:
                    idx = call.get("index", 0)
                    slot = tool_calls.setdefault(idx, {"id": "", "name": "", "arguments": ""})
                    if call.get("id"):
                        slot["id"] = call["id"]
                    fn = call.get("function") or {}
                    if fn.get("name"):
                        slot["name"] = fn["name"]
                    if fn.get("arguments"):
                        slot["arguments"] += fn["arguments"]
        if c.get("usage"):
            usage = c["usage"]

    return {
        "content": "".join(content_parts),
        "reasoning": "".join(reasoning_parts),
        "tool_calls": [tool_calls[k] for k in sorted(tool_calls)],
        "finish_reason": finish_reason,
        "usage": usage,
        "model": model,
    }


def _assemble_anthropic_chunks(chunks: list[dict[str, Any]]) -> dict[str, Any]:
    """Assemble an Anthropic-style streaming response into content blocks."""
    blocks: dict[int, dict[str, Any]] = {}
    stop_reason: str | None = None
    usage: dict[str, Any] | None = None
    model: str | None = None

    for chunk in chunks:
        c = chunk.get("content")
        if not isinstance(c, dict):
            continue
        ctype = c.get("type")

        if ctype == "message_start":
            msg = c.get("message") or {}
            model = msg.get("model")
            u = msg.get("usage")
            if isinstance(u, dict):
                usage = dict(u)
            continue

        if ctype == "content_block_start":
            idx = c.get("index", 0)
            blk = c.get("content_block") or {}
            blocks[idx] = {
                "type": blk.get("type", "text"),
                "text": blk.get("text", ""),
                "thinking": blk.get("thinking", ""),
                "id": blk.get("id", ""),
                "name": blk.get("name", ""),
                "input": blk.get("input") or {},
            }
            continue

        if ctype == "content_block_delta":
            idx = c.get("index", 0)
            blk = blocks.setdefault(
                idx, {"type": "text", "text": "", "thinking": "", "id": "", "name": "", "input": {}}
            )
            delta = c.get("delta") or {}
            dtype = delta.get("type", "")
            if dtype == "text_delta":
                blk["text"] += delta.get("text", "")
            elif dtype == "thinking_delta":
                blk["thinking"] += delta.get("thinking", "")
            elif dtype == "input_json_delta":
                blk.setdefault("_input_json", "")
                blk["_input_json"] += delta.get("partial_json", "")
            continue

        if ctype == "message_delta":
            delta = c.get("delta") or {}
            if delta.get("stop_reason"):
                stop_reason = delta["stop_reason"]
            u = c.get("usage")
            if isinstance(u, dict):
                usage = usage or {}
                usage.update(u)
            continue

    # parse accumulated tool input json
    for blk in blocks.values():
        raw = blk.pop("_input_json", None)
        if raw:
            try:
                blk["input"] = json.loads(raw)
            except json.JSONDecodeError:
                blk["input"] = {"_raw": raw}

    return {
        "blocks": [blocks[k] for k in sorted(blocks)],
        "stop_reason": stop_reason,
        "usage": usage,
        "model": model,
    }


def _assemble_openai_responses_chunks(chunks: list[dict[str, Any]]) -> dict[str, Any]:
    """Assemble an OpenAI Responses API streaming response into output items."""
    text_parts: list[str] = []
    tool_calls: dict[int, dict[str, Any]] = {}
    status: str | None = None
    usage: dict[str, Any] | None = None
    model: str | None = None

    for chunk in chunks:
        c = chunk.get("content")
        if not isinstance(c, dict):
            continue
        etype = c.get("type", "")

        if etype == "response.created":
            resp = c.get("response") or {}
            model = resp.get("model")
            status = resp.get("status")
        elif etype == "response.output_text.delta":
            text_parts.append(c.get("delta", ""))
        elif etype == "response.output_item.added":
            item = c.get("item") or {}
            idx = c.get("output_index", 0)
            itype = item.get("type", "")
            if itype in ("custom_tool_call", "function_call"):
                slot = tool_calls.setdefault(idx, {
                    "id": item.get("call_id") or item.get("id", ""),
                    "name": item.get("name", ""),
                    "arguments": "",
                    "type": itype,
                })
                if item.get("input"):
                    slot["arguments"] = item["input"]
                if item.get("arguments"):
                    slot["arguments"] = item["arguments"]
        elif etype == "response.custom_tool_call_input.delta":
            idx = c.get("output_index", 0)
            slot = tool_calls.setdefault(idx, {"id": "", "name": "", "arguments": "", "type": "custom_tool_call"})
            slot["arguments"] += c.get("delta", "")
        elif etype == "response.function_call_arguments.delta":
            idx = c.get("output_index", 0)
            slot = tool_calls.setdefault(idx, {"id": "", "name": "", "arguments": "", "type": "function_call"})
            slot["arguments"] += c.get("delta", "")
        elif etype == "response.completed":
            resp = c.get("response") or {}
            status = resp.get("status") or status
            u = resp.get("usage")
            if isinstance(u, dict):
                usage = u
            if model is None:
                model = resp.get("model")

    return {
        "content": "".join(text_parts),
        "tool_calls": [tool_calls[k] for k in sorted(tool_calls)],
        "status": status,
        "usage": usage,
        "model": model,
    }


def _summarize_responses_input(input_items: list[Any]) -> list[dict[str, Any]]:
    """Convert Responses API input list into display messages."""
    messages: list[dict[str, Any]] = []
    for item in input_items:
        if not isinstance(item, dict):
            continue
        itype = item.get("type", "")
        role = item.get("role", "")

        if itype == "message":
            content = item.get("content")
            parts: list[dict[str, Any]] = []
            if isinstance(content, str):
                parts.append({"type": "text", "text": content})
            elif isinstance(content, list):
                for block in content:
                    if not isinstance(block, dict):
                        continue
                    btype = block.get("type", "input_text")
                    text = block.get("text", "")
                    if btype in ("input_text", "output_text", "text"):
                        parts.append({"type": "text", "text": text})
                    elif btype == "thinking":
                        parts.append({"type": "thinking", "text": text})
                    else:
                        parts.append({"type": btype, "text": text})
            messages.append({"role": role or "user", "parts": parts})

        elif itype in ("custom_tool_call", "function_call"):
            args = item.get("input") or item.get("arguments") or ""
            messages.append({
                "role": "assistant",
                "parts": [{
                    "type": "tool_use",
                    "id": item.get("call_id", item.get("id", "")),
                    "name": item.get("name", ""),
                    "input": _safe_json(args),
                }],
            })

        elif itype in ("custom_tool_call_output", "function_call_output"):
            output = item.get("output")
            if isinstance(output, list):
                out_text = "\n".join(
                    b.get("text", "") for b in output if isinstance(b, dict)
                )
            else:
                out_text = str(output or "")
            messages.append({
                "role": "tool",
                "parts": [{
                    "type": "tool_result",
                    "tool_use_id": item.get("call_id", ""),
                    "content": out_text,
                }],
            })

        elif itype == "reasoning":
            summary = item.get("summary")
            if isinstance(summary, list) and summary:
                text = " ".join(
                    s.get("text", "") for s in summary if isinstance(s, dict)
                )
                if text:
                    messages.append({
                        "role": "assistant",
                        "parts": [{"type": "thinking", "text": text}],
                    })

        elif itype == "additional_tools":
            continue

    return messages


def _summarize_message(msg: dict[str, Any]) -> dict[str, Any]:
    """Normalize a chat message (OpenAI or Anthropic format) for display."""
    role = msg.get("role", "?")
    content = msg.get("content")

    # String content (OpenAI style)
    if isinstance(content, str):
        return {"role": role, "parts": [{"type": "text", "text": content}]}

    # List content (Anthropic style)
    if isinstance(content, list):
        parts: list[dict[str, Any]] = []
        for block in content:
            if not isinstance(block, dict):
                continue
            btype = block.get("type", "text")
            if btype == "text":
                parts.append({"type": "text", "text": block.get("text", "")})
            elif btype == "thinking":
                parts.append({"type": "thinking", "text": block.get("thinking", "")})
            elif btype == "tool_use":
                parts.append({
                    "type": "tool_use",
                    "id": block.get("id", ""),
                    "name": block.get("name", ""),
                    "input": block.get("input") or {},
                })
            elif btype == "tool_result":
                parts.append({
                    "type": "tool_result",
                    "tool_use_id": block.get("tool_use_id", ""),
                    "content": block.get("content", ""),
                    "is_error": block.get("is_error", False),
                })
            elif btype == "image":
                parts.append({"type": "image", "source": block.get("source", {})})
            else:
                parts.append({"type": btype, "raw": block})
        return {"role": role, "parts": parts}

    # OpenAI tool-call assistant message
    if msg.get("tool_calls"):
        parts = []
        if content:
            parts.append({"type": "text", "text": str(content)})
        for call in msg["tool_calls"]:
            fn = call.get("function") or {}
            parts.append({
                "type": "tool_use",
                "id": call.get("id", ""),
                "name": fn.get("name", ""),
                "input": _safe_json(fn.get("arguments", "")),
            })
        return {"role": role, "parts": parts}

    # OpenAI tool result message
    if msg.get("tool_call_id"):
        return {
            "role": role,
            "parts": [{"type": "tool_result", "tool_use_id": msg.get("tool_call_id", ""), "content": content or ""}],
        }

    return {"role": role, "parts": [{"type": "text", "text": str(content) if content is not None else ""}]}


def _safe_json(raw: Any) -> Any:
    if isinstance(raw, (dict, list)):
        return raw
    if isinstance(raw, str):
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return {"_raw": raw}
    return raw


def _msg_text(msg: dict[str, Any]) -> str:
    """Extract a flat text fingerprint from a message for chain comparison."""
    parts = msg.get("parts", [])
    texts = []
    for p in parts:
        if p.get("type") == "text":
            texts.append(p.get("text", ""))
        elif p.get("type") == "thinking":
            texts.append("[thinking]")
        elif p.get("type") == "tool_use":
            texts.append(f"[tool:{p.get('name', '')}]")
        elif p.get("type") == "tool_result":
            texts.append("[tool_result]")
    return "\n".join(texts)


def _first_user_text(messages: list[dict[str, Any]]) -> str:
    """Extract the first user message text for chain identity (ignoring dynamic system headers)."""
    for msg in messages:
        if msg.get("role") == "user":
            return _msg_text(msg)[:500]
    return ""


def _compute_display_messages(chat_calls: list[dict[str, Any]]) -> None:
    """Set display_messages on each call to only the delta from the previous call in the same chain.

    Calls belong to the same chain when they share the same first user message
    (the task/question) and the message list grows. This avoids repeating the
    full context every turn while staying robust to dynamic system-prompt content
    (e.g. Claude Code's per-call billing header).
    """
    prev_key: str | None = None
    prev_count = 0
    for call in chat_calls:
        messages = call["messages"]
        key = _first_user_text(messages)
        if key != prev_key or len(messages) < prev_count:
            call["display_messages"] = messages
            call["chain_start"] = True
        else:
            delta = messages[prev_count:]
            call["display_messages"] = delta if delta else []
            call["chain_start"] = False
        prev_key = key
        prev_count = len(messages)


def parse_traces(session_dir: Path) -> dict[str, Any]:
    """Parse all trace files for a session into chat calls + other http calls."""
    index = load_trace_index(session_dir)
    requests = index.requests
    chunks = index.chunks
    responses = index.responses
    metas = index.metas

    chat_calls: list[dict[str, Any]] = []
    http_calls: list[dict[str, Any]] = []

    for rid in index.ordered_ids:
        req = requests[rid]
        url = req.get("url", "")
        method = req.get("method", "")
        body = req.get("body")

        if is_chat_request(url, body):
            # Determine API style
            is_anthropic = (
                ("/messages" in url and "anthropic" in url.lower())
                or isinstance(body.get("system"), list)
            )
            is_responses = "/responses" in url or (
                "input" in body and "messages" not in body
            )

            # Assemble response
            if rid in chunks:
                if is_anthropic:
                    resp = _assemble_anthropic_chunks(chunks[rid])
                elif is_responses:
                    resp = _assemble_openai_responses_chunks(chunks[rid])
                else:
                    resp = _assemble_openai_chunks(chunks[rid])
            elif rid in responses:
                rbody = responses[rid].get("body") or {}
                if is_anthropic:
                    resp = {
                        "blocks": rbody.get("content", []),
                        "stop_reason": rbody.get("stop_reason"),
                        "usage": rbody.get("usage"),
                        "model": rbody.get("model"),
                    }
                elif is_responses:
                    output_items = rbody.get("output") or []
                    content_parts = []
                    tool_calls = []
                    for oi in output_items:
                        otype = oi.get("type", "")
                        if otype == "message":
                            for c in oi.get("content") or []:
                                if c.get("type") in ("output_text", "text"):
                                    content_parts.append(c.get("text", ""))
                        elif otype in ("custom_tool_call", "function_call"):
                            tool_calls.append({
                                "id": oi.get("call_id") or oi.get("id", ""),
                                "name": oi.get("name", ""),
                                "arguments": oi.get("input") or oi.get("arguments") or "",
                                "type": otype,
                            })
                    resp = {
                        "content": "".join(content_parts),
                        "tool_calls": tool_calls,
                        "status": rbody.get("status"),
                        "usage": rbody.get("usage"),
                        "model": rbody.get("model"),
                    }
                else:
                    choice = (rbody.get("choices") or [{}])[0]
                    msg = choice.get("message") or {}
                    resp = {
                        "content": msg.get("content", ""),
                        "reasoning": msg.get("reasoning_content", ""),
                        "tool_calls": [
                            {
                                "id": tc.get("id", ""),
                                "name": (tc.get("function") or {}).get("name", ""),
                                "arguments": (tc.get("function") or {}).get("arguments", ""),
                            }
                            for tc in msg.get("tool_calls", [])
                        ],
                        "finish_reason": choice.get("finish_reason"),
                        "usage": rbody.get("usage"),
                        "model": rbody.get("model"),
                    }
            else:
                resp = {"error": "no response captured"}

            meta = metas.get(rid, {})
            latency_ms = responses.get(rid, {}).get("latency_ms") or meta.get("total_latency_ms")
            status = responses.get(rid, {}).get("status_code") or meta.get("status_code")

            # Summarize request messages
            if is_responses:
                input_items = body.get("input") or []
                if isinstance(input_items, str):
                    input_items = [{"type": "message", "role": "user", "content": input_items}]
                messages = _summarize_responses_input(input_items)
                instructions = body.get("instructions")
                if isinstance(instructions, str) and instructions:
                    messages.insert(0, {"role": "system", "parts": [{"type": "text", "text": instructions}]})
            else:
                messages = [_summarize_message(m) for m in body.get("messages", [])]
                system = body.get("system")
                if isinstance(system, str) and system:
                    messages.insert(0, {"role": "system", "parts": [{"type": "text", "text": system}]})
                elif isinstance(system, list):
                    sys_parts = [{"type": "text", "text": s.get("text", "")} for s in system if isinstance(s, dict)]
                    if sys_parts:
                        messages.insert(0, {"role": "system", "parts": sys_parts})

            # Normalize response for display
            resp_display = _normalize_response(resp, is_anthropic, is_responses)

            chat_calls.append({
                "id": rid,
                "timestamp": req.get("timestamp", ""),
                "url": url,
                "model": body.get("model") or resp_display.get("model", ""),
                "stream": body.get("stream", False),
                "messages": messages,
                "response": resp_display,
                "status_code": status,
                "latency_ms": latency_ms,
                "tools": body.get("tools", []),
            })
        else:
            resp = responses.get(rid, {})
            http_calls.append({
                "id": rid,
                "timestamp": req.get("timestamp", ""),
                "method": method,
                "url": url,
                "request_body": body,
                "status_code": resp.get("status_code"),
                "response_body": resp.get("body"),
                "latency_ms": resp.get("latency_ms"),
            })

    _compute_display_messages(chat_calls)

    return {"chat_calls": chat_calls, "http_calls": http_calls}


def _normalize_response(resp: dict[str, Any], is_anthropic: bool, is_responses: bool = False) -> dict[str, Any]:
    """Normalize a response dict to a common display format."""
    if "error" in resp:
        return resp

    if is_anthropic:
        blocks = resp.get("blocks", [])
        parts: list[dict[str, Any]] = []
        for blk in blocks:
            btype = blk.get("type", "text")
            if btype == "text":
                parts.append({"type": "text", "text": blk.get("text", "")})
            elif btype == "thinking":
                parts.append({"type": "thinking", "text": blk.get("thinking", "")})
            elif btype == "tool_use":
                parts.append({
                    "type": "tool_use",
                    "id": blk.get("id", ""),
                    "name": blk.get("name", ""),
                    "input": blk.get("input") or {},
                })
        return {
            "parts": parts,
            "stop_reason": resp.get("stop_reason"),
            "usage": resp.get("usage"),
            "model": resp.get("model"),
        }
    else:
        parts = []
        if resp.get("reasoning"):
            parts.append({"type": "thinking", "text": resp["reasoning"]})
        if resp.get("content"):
            parts.append({"type": "text", "text": resp["content"]})
        for tc in resp.get("tool_calls", []):
            parts.append({
                "type": "tool_use",
                "id": tc.get("id", ""),
                "name": tc.get("name", ""),
                "input": _safe_json(tc.get("arguments", "")),
            })
        return {
            "parts": parts,
            "stop_reason": resp.get("status") or resp.get("finish_reason"),
            "usage": resp.get("usage"),
            "model": resp.get("model"),
        }


# ---------------------------------------------------------------------------
# HTTP server
# ---------------------------------------------------------------------------


def _json_response(handler: BaseHTTPRequestHandler, data: Any) -> None:
    body = json.dumps(data, default=str).encode("utf-8")
    handler.send_response(200)
    handler.send_header("Content-Type", "application/json; charset=utf-8")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def _html_response(handler: BaseHTTPRequestHandler, text: str) -> None:
    body = text.encode("utf-8")
    handler.send_response(200)
    handler.send_header("Content-Type", "text/html; charset=utf-8")
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


STATIC_DIR = Path(__file__).parent / "static"

_STATIC_FILES = {
    "/index.html": "text/html; charset=utf-8",
    "/style.css": "text/css; charset=utf-8",
    "/app.js": "application/javascript; charset=utf-8",
    "/header.png": "image/png"
}


def _serve_static(handler: BaseHTTPRequestHandler, name: str) -> None:
    """Serve a file from spens/static/ with a safe fallback to 404."""
    path = (STATIC_DIR / name.lstrip("/")).resolve()
    if not path.is_file() or STATIC_DIR.resolve() not in path.parents:
        _not_found(handler)
        return
    body = path.read_bytes()
    handler.send_response(200)
    handler.send_header("Content-Type", _STATIC_FILES.get("/" + name.lstrip("/"), "application/octet-stream"))
    handler.send_header("Content-Length", str(len(body)))
    handler.end_headers()
    handler.wfile.write(body)


def _not_found(handler: BaseHTTPRequestHandler) -> None:
    handler.send_response(404)
    handler.send_header("Content-Type", "text/plain")
    handler.end_headers()
    handler.wfile.write(b"not found")


def make_handler(sessions_dir: Path) -> type[BaseHTTPRequestHandler]:
    sessions_dir = sessions_dir.resolve()

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, fmt: str, *args: Any) -> None:
            pass

        def do_GET(self) -> None:
            parsed = urlparse(self.path)
            path = parsed.path.rstrip("/") or "/"

            if path == "/":
                _serve_static(self, "index.html")
                return

            if path in _STATIC_FILES:
                _serve_static(self, path.lstrip("/"))
                return

            if path == "/api/sessions":
                _json_response(self, list_sessions(sessions_dir))
                return

            # /api/sessions/<id>
            m = re.match(r"^/api/sessions/([0-9a-f]+)$", path)
            if m:
                sid = m.group(1)
                sdir = sessions_dir / sid
                if not sdir.is_dir():
                    _not_found(self)
                    return
                nono_meta = load_nono_session_meta(sdir) or {}
                audit_sess, audit_events = load_audit_session(sdir)
                ledger = _load_ledger(sdir)
                _json_response(self, {
                    "id": sid,
                    "nono": nono_meta,
                    "audit_session": audit_sess,
                    "audit_events": audit_events,
                    "ledger": ledger,
                })
                return

            # /api/sessions/<id>/summary
            m = re.match(r"^/api/sessions/([0-9a-f]+)/summary$", path)
            if m:
                sid = m.group(1)
                sdir = sessions_dir / sid
                if not sdir.is_dir():
                    _not_found(self)
                    return
                summary = read_summary(sdir)
                if summary is None:
                    try:
                        summary = compute_session_summary(sdir)
                        write_summary(sdir, summary)
                    except Exception:
                        _json_response(self, {"error": "could not compute summary"})
                        return
                _json_response(self, summary)
                return

            # /api/sessions/<id>/traces
            m = re.match(r"^/api/sessions/([0-9a-f]+)/traces$", path)
            if m:
                sid = m.group(1)
                sdir = sessions_dir / sid
                if not sdir.is_dir():
                    _not_found(self)
                    return
                _json_response(self, parse_traces(sdir))
                return

            # /api/sessions/<id>/rollback
            m = re.match(r"^/api/sessions/([0-9a-f]+)/rollback$", path)
            if m:
                sid = m.group(1)
                sdir = sessions_dir / sid
                if not sdir.is_dir():
                    _not_found(self)
                    return
                _json_response(self, _load_rollback(sdir))
                return

            # /api/sessions/<id>/request-log
            m = re.match(r"^/api/sessions/([0-9a-f]+)/request-log$", path)
            if m:
                sid = m.group(1)
                sdir = sessions_dir / sid
                if not sdir.is_dir():
                    _not_found(self)
                    return
                _json_response(self, load_request_log(sdir))
                return

            _not_found(self)

    return Handler


def start_viewer(
    host: str = "127.0.0.1",
    port: int = DEFAULT_PORT,
    workspace: Path | None = None,
    open_browser: bool = True,
    spens_dir: Path | None = None,
) -> None:
    sessions_dir = _find_sessions_dir(workspace or Path.cwd(), spens_dir=spens_dir)
    if not sessions_dir.is_dir():
        print(f"[spens] No sessions directory found at {sessions_dir}")
        return

    handler = make_handler(sessions_dir)
    server = HTTPServer((host, port), handler)
    url = f"http://{host}:{port}"

    print(f"[spens] log-viewer serving on {url}")
    print(f"[spens] sessions dir: {sessions_dir}")
    print("[spens] press Ctrl+C to stop")

    if open_browser:
        threading.Timer(0.5, lambda: webbrowser.open(url)).start()

    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[spens] stopping")
    finally:
        server.server_close()
