"""
Agent SDK Server.

Lightweight FastAPI server running inside each agent container.
Provides bidirectional A2A communication capabilities.

Features:
- Receive A2A messages from other agents
- Priority queue (urgent messages first)
- Inbox polling for Claude Code
- Fallback to main API when target offline
"""

import json
import os
import time
from collections import Counter, defaultdict, deque
from pathlib import Path
from typing import Any, Literal

import httpx
import structlog
import uvicorn
from fastapi import FastAPI

from roboco.agent_sdk.models import (
    A2AMessage,
    BudgetStatus,
    BudgetToolCalledRequest,
    HealthResponse,
    InboxResponse,
    MessagePriority,
    PostMortemRequest,
    SendRequest,
    SendResponse,
    TerminalStatus,
    TerminalToolRecordRequest,
    TokenReportRequest,
    TokenUsageStatus,
    TranscriptSyncRequest,
    VerbAttemptRequest,
    VerbCircuitStatus,
)
from roboco.agent_sdk.transcript_usage import (
    sum_transcript_usage as _sum_transcript_usage,
)
from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _BUDGET
from roboco.foundation.policy.agent_loop import retry_limit_for
from roboco.services.gateway.envelope import Envelope

logger = structlog.get_logger()

# Environment configuration
AGENT_ID = os.environ.get("ROBOCO_AGENT_ID", "unknown")
MAIN_API_URL = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
SDK_PORT = int(os.environ.get("ROBOCO_SDK_PORT", "9000"))


# =============================================================================
# TOOL MANIFEST (gateway-enabled path)
# =============================================================================


def load_tool_manifest() -> dict[str, object] | None:
    """Load the per-role tool manifest when the gateway is enabled and the file exists.

    Reads ROBOCO_GATEWAY_ENABLED and ROBOCO_TOOL_MANIFEST_PATH from the
    environment at call-time so tests can override them via monkeypatch without
    needing importlib.reload.

    Returns:
        Parsed manifest dict when gateway is enabled and file is present and
        valid JSON.  Returns None in all other cases (gateway disabled, file
        missing, or JSON parse error).  Never raises.
    """
    gateway_enabled = (
        os.environ.get("ROBOCO_GATEWAY_ENABLED", "false").lower() == "true"
    )
    if not gateway_enabled:
        return None

    manifest_path = Path(
        os.environ.get("ROBOCO_TOOL_MANIFEST_PATH", "/app/tool-manifest.json")
    )
    if not manifest_path.exists():
        return None

    try:
        parsed: dict[str, object] = json.loads(manifest_path.read_text())
        return parsed
    except (json.JSONDecodeError, OSError) as exc:
        logger.warning(
            "Failed to load tool manifest",
            path=str(manifest_path),
            error=str(exc),
        )
        return None


app = FastAPI(
    title=f"RoboCo SDK Server ({AGENT_ID})",
    description="Agent-to-Agent communication server",
    version="1.0.0",
)

# Priority queues (urgent first)
urgent_inbox: deque[A2AMessage] = deque(maxlen=100)
normal_inbox: deque[A2AMessage] = deque(maxlen=500)


# =============================================================================
# HEALTH
# =============================================================================


@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
    """Health check endpoint."""
    return HealthResponse(status="ok", agent_id=AGENT_ID)


# =============================================================================
# RECEIVE (from other agents)
# =============================================================================


@app.post("/a2a/receive")
async def receive_message(msg: A2AMessage) -> dict[str, str]:
    """
    Receive A2A message from another agent.

    Messages are queued by priority for Claude Code to poll.
    Also persists to database for conversation history.
    """
    # Queue for immediate polling
    if msg.priority == MessagePriority.URGENT:
        urgent_inbox.append(msg)
        logger.info(
            "Received urgent A2A message",
            from_agent=msg.from_agent,
            task_id=msg.task_id,
            skill=msg.skill,
        )
    else:
        normal_inbox.append(msg)
        logger.info(
            "Received A2A message",
            from_agent=msg.from_agent,
            task_id=msg.task_id,
            skill=msg.skill,
        )

    # Also persist to database for conversation history
    # This enables resuming conversations across agent spawns
    await _persist_received_message(msg)

    return {"status": "queued", "message_id": str(msg.id)}


async def _persist_received_message(msg: A2AMessage) -> None:
    """Persist received message to database via main API."""
    try:
        async with httpx.AsyncClient() as client:
            # First, ensure conversation exists
            conv_resp = await client.post(
                f"{MAIN_API_URL}/api/a2a/chat/conversations",
                json={
                    "target_agent": msg.from_agent,
                    "topic": msg.skill,  # Use skill as topic
                    "task_id": msg.task_id,
                    "initial_message": msg.content,
                    "requires_response": False,
                },
                headers={"X-Agent-ID": AGENT_ID},
                timeout=5.0,
            )
            # Note: 409 conflict is ok - conversation already exists
            if conv_resp.status_code not in (200, 201, 409):
                logger.warning(
                    "Failed to persist A2A message",
                    status_code=conv_resp.status_code,
                    from_agent=msg.from_agent,
                )
    except Exception as e:
        # Don't fail receive if persistence fails
        logger.warning("Failed to persist A2A message", error=str(e))


# =============================================================================
# SEND (to other agents)
# =============================================================================


@app.post("/a2a/send", response_model=SendResponse)
async def send_message(req: SendRequest) -> SendResponse:
    """
    Send A2A message to another agent.

    Attempts direct delivery via HTTP. Falls back to notification
    via main API if target agent is offline.
    """
    # Container name = roboco-agent-{slug}
    target_url = f"http://roboco-agent-{req.target_agent}:{SDK_PORT}/a2a/receive"

    msg = A2AMessage(
        from_agent=AGENT_ID,
        to_agent=req.target_agent,
        task_id=req.task_id,
        skill=req.skill,
        content=req.message,
        priority=MessagePriority.URGENT if req.urgent else MessagePriority.NORMAL,
    )

    async with httpx.AsyncClient() as client:
        try:
            resp = await client.post(
                target_url,
                json=msg.model_dump(mode="json"),
                timeout=5.0,
            )
            resp.raise_for_status()

            logger.info(
                "A2A message sent directly",
                to_agent=req.target_agent,
                task_id=req.task_id,
                skill=req.skill,
            )

            return SendResponse(
                status="sent",
                message_id=str(msg.id),
                delivery="direct",
            )

        except (httpx.ConnectError, httpx.TimeoutException, httpx.HTTPStatusError) as e:
            # Agent offline or unreachable - fall back to notification
            logger.info(
                "Target agent offline, falling back to notification",
                to_agent=req.target_agent,
                error=str(e),
            )

            await _create_notification_fallback(req)

            return SendResponse(
                status="sent",
                message_id=str(msg.id),
                delivery="notification",
            )


async def _create_notification_fallback(req: SendRequest) -> None:
    """Create notification via main API when target agent is offline."""
    async with httpx.AsyncClient() as client:
        try:
            await client.post(
                f"{MAIN_API_URL}/api/a2a/message/send",
                json={
                    "message": {
                        "role": "user",
                        "parts": [{"type": "text", "text": req.message}],
                        "taskId": req.task_id,
                    },
                    "metadata": {
                        "from_agent": AGENT_ID,
                        "target_agent": req.target_agent,
                        "skill": req.skill,
                        "urgent": req.urgent,
                    },
                },
                headers={
                    "X-Agent-ID": AGENT_ID,
                    "X-Agent-Role": "developer",  # SDK doesn't know role
                },
                timeout=10.0,
            )
            logger.info(
                "Notification fallback created",
                to_agent=req.target_agent,
                task_id=req.task_id,
            )
        except Exception as e:
            logger.error(
                "Failed to create notification fallback",
                to_agent=req.target_agent,
                error=str(e),
            )


# =============================================================================
# INBOX (for Claude Code to poll)
# =============================================================================


@app.get("/inbox/poll", response_model=InboxResponse)
async def poll_inbox(limit: int = 10) -> InboxResponse:
    """
    Poll inbox for pending A2A messages.

    Returns messages in priority order (urgent first).
    Messages are removed from queue once returned.
    """
    messages: list[A2AMessage] = []

    # Urgent first
    while urgent_inbox and len(messages) < limit:
        messages.append(urgent_inbox.popleft())

    # Then normal
    while normal_inbox and len(messages) < limit:
        messages.append(normal_inbox.popleft())

    if messages:
        logger.info(
            "Inbox polled",
            message_count=len(messages),
            urgent_remaining=len(urgent_inbox),
            normal_remaining=len(normal_inbox),
        )

    return InboxResponse(messages=messages, count=len(messages))


@app.post("/inbox/ack/{message_id}")
async def ack_message(message_id: str) -> dict[str, str]:
    """
    Acknowledge message was processed.

    For now, messages are removed on poll. This endpoint exists
    for future Redis persistence where we might need explicit ACK.
    """
    logger.info("Message acknowledged", message_id=message_id)
    return {"status": "acked", "message_id": message_id}


@app.get("/inbox/count")
async def inbox_count() -> dict[str, int]:
    """Get count of pending messages without consuming them."""
    return {
        "urgent": len(urgent_inbox),
        "normal": len(normal_inbox),
        "total": len(urgent_inbox) + len(normal_inbox),
    }


# =============================================================================
# BUDGET & TERMINAL STATE (per-session, in-memory)
# =============================================================================
# The SDK server is a long-lived process inside each agent container. All hook
# scripts (PreToolUse, PostToolUse, Stop, UserPromptSubmit, PreCompact,
# SessionEnd) hit these endpoints so they share counters across invocations
# without needing external storage. State resets on container restart, which
# matches session lifetime.

_WARN_THRESHOLD = int(
    os.environ.get("ROBOCO_AGENT_TOOL_CALL_WARN", str(_BUDGET.tool_call_warn_at))
)
_HALT_THRESHOLD = int(
    os.environ.get("ROBOCO_AGENT_TOOL_CALL_HALT", str(_BUDGET.tool_call_halt_at))
)
_LOOP_THRESHOLD = int(
    os.environ.get("ROBOCO_AGENT_LOOP_THRESHOLD", str(_BUDGET.loop_threshold))
)
_LOOP_WINDOW = int(os.environ.get("ROBOCO_AGENT_LOOP_WINDOW", str(_BUDGET.loop_window)))
_LOOP_ACTION_RAW = os.environ.get("ROBOCO_AGENT_LOOP_ACTION", _BUDGET.loop_action)
_LOOP_ACTION: Literal["warn", "halt"] = "halt" if _LOOP_ACTION_RAW == "halt" else "warn"
_STOP_ALLOWANCE = int(os.environ.get("ROBOCO_AGENT_STOP_ATTEMPT_ALLOWANCE", "1"))
_RECENT_TOOL_WINDOW = 5  # not in foundation — keep local
# Sliding-window for the per-verb retry circuit breaker.
# 60s matches the docstring on foundation.VERB_RETRY_LIMITS — cap is "N
# rejections in 60s", not "N rejections since session start".
_VERB_ATTEMPT_WINDOW_S: int = 60
# Rejection envelope kinds that COUNT toward the breaker. Successful (ok)
# calls do not count, by design.
_CIRCUIT_REJECTION_KINDS: frozenset[str] = frozenset(
    {"tracing_gap", "invalid_state", "not_authorized", "incomplete_input"}
)

# Names match the gateway verb surface — i.e. what `terminal_tool_recorded`
# stores after stripping the `mcp__roboco-flow__` / `mcp__roboco-do__`
# prefix (see line ~798). The deleted pre-gateway tool names never
# matched the suffix-stripped values, so the stop hook used to nag
# every agent even after a successful i_am_idle.
_TERMINAL_TOOLS: frozenset[str] = frozenset(
    {
        # Every role's clean exit.
        "i_am_idle",
        # Developer / documenter handoff verbs.
        "i_am_done",
        "i_am_blocked",
        "i_documented",
        "unclaim",
        # QA verdicts.
        "pass",
        "fail",
        # PM handoffs.
        "complete",
        "submit_up",
        "escalate_up",
        "escalate_to_ceo",
    }
)


class _SessionState:
    def __init__(self) -> None:
        self._init_fields()

    def _init_fields(self) -> None:
        self.started_at: float = time.time()
        self.total_calls: int = 0
        self.by_tool: Counter[str] = Counter()
        self.recent_hashes: deque[str] = deque(maxlen=_LOOP_WINDOW)
        self.recent_tools: deque[str] = deque(maxlen=_RECENT_TOOL_WINDOW)
        self.last_tool: str | None = None
        self.stop_attempts: int = 0
        self.loop_triggered: bool = False
        self.halt_triggered: bool = False
        # Per-verb circuit breaker: maps (verb, task_id) → deque[monotonic
        # timestamp]. Pruned to a 60s window on every record/check. Only
        # rejection envelopes (tracing_gap / invalid_state / not_authorized
        # / incomplete_input) feed into this — successful calls never count.
        # task_id may be None for verbs that operate without one (e.g.
        # give_me_work) — those keys collapse to (verb, None).
        self.verb_attempts: dict[tuple[str, str | None], deque[float]] = defaultdict(
            deque
        )
        # Cumulative token usage for this session. Populated by /usage/sync,
        # which parses the Claude Code transcript and *sets* these absolutely
        # (the additive /usage/report path remains for explicit deltas).
        self.tokens_input: int = 0
        self.tokens_output: int = 0
        self.tokens_cache_read: int = 0
        self.tokens_cache_write: int = 0
        # (size, mtime) of the last transcript parsed for /usage/sync, so a
        # re-sync of an unchanged transcript skips the re-parse.
        self.transcript_fingerprint: tuple[int, float] | None = None

    def reset(self) -> None:
        self._init_fields()

    def record_tool(self, tool: str, args_hash: str) -> None:
        self.total_calls += 1
        self.by_tool[tool] += 1
        self.recent_hashes.append(args_hash)
        self.recent_tools.append(tool)
        self.last_tool = tool
        if self.total_calls >= _HALT_THRESHOLD:
            self.halt_triggered = True
        if self._is_looping(args_hash):
            self.loop_triggered = True

    def _is_looping(self, args_hash: str) -> bool:
        """Same tool+args hash showing up ≥ _LOOP_THRESHOLD times in the window."""
        return sum(1 for h in self.recent_hashes if h == args_hash) >= _LOOP_THRESHOLD

    def had_terminal_recently(self) -> bool:
        return any(t in _TERMINAL_TOOLS for t in self.recent_tools)


_state = _SessionState()


# =============================================================================
# PER-VERB CIRCUIT BREAKER
# =============================================================================
# Pre-Phase-3 the gateway had no per-verb retry cap. The 2026-05-10 smoke
# showed i_am_done retried 5+ times in 2 minutes within the global 150-tool
# budget — the agent never hit a real wall. The tracker here closes that gap:
# (verb, task_id) → deque[timestamp] over a 60s sliding window. When the
# count exceeds foundation.retry_limit_for(verb), the next attempt receives
# Envelope.circuit_open with a remediate hint pointing to i_am_blocked /
# i_am_idle as graceful exits.
#
# Helpers are module-private (leading _) but exported for test access; they
# operate on the live _state singleton, mirroring the budget-tracker pattern.


def _prune_verb_window(window: deque[float], now: float) -> None:
    """Drop entries older than _VERB_ATTEMPT_WINDOW_S from the deque (in place)."""
    cutoff = now - _VERB_ATTEMPT_WINDOW_S
    while window and window[0] < cutoff:
        window.popleft()


def _record_verb_attempt(verb: str, task_id: str | None) -> None:
    """Record a verb-level rejection.

    Append the current monotonic timestamp to the (verb, task_id) deque and
    prune entries older than the window. Caller must only invoke this for
    REJECTION envelopes — counting successful calls would defeat the
    breaker's purpose (the agent is allowed to call i_am_done once it
    succeeds; only stuck retries should accumulate).
    """
    key = (verb, task_id)
    now = time.monotonic()
    window = _state.verb_attempts[key]
    window.append(now)
    _prune_verb_window(window, now)


def _verb_attempt_count(verb: str, task_id: str | None) -> int:
    """Count rejections in the last _VERB_ATTEMPT_WINDOW_S seconds.

    Prunes the underlying deque on read so external observers always see a
    fresh count. Returns 0 for keys that have never been recorded.
    """
    key = (verb, task_id)
    window = _state.verb_attempts.get(key)
    if window is None:
        return 0
    _prune_verb_window(window, time.monotonic())
    return len(window)


def _check_verb_circuit(verb: str, task_id: str | None) -> dict[str, Any] | None:
    """Return a circuit_open envelope dict if the breaker should open, else None.

    Lookup order matches foundation.retry_limit_for():
      - Verb in UNLIMITED_RETRY_VERBS → None (never trips)
      - Verb in VERB_RETRY_LIMITS     → cap is the explicit value
      - Otherwise                     → cap is verb_retry_max_per_minute
    """
    limit = retry_limit_for(verb)
    if limit is None:
        return None
    count = _verb_attempt_count(verb, task_id)
    if count < limit:
        return None
    env = Envelope.circuit_open(
        verb=verb,
        attempts=count,
        window_seconds=_VERB_ATTEMPT_WINDOW_S,
        remediate=(
            f"verb {verb!r} has been rejected {count} times in "
            f"{_VERB_ATTEMPT_WINDOW_S}s. Stop retrying. Call "
            "i_am_blocked(reason='unable to satisfy gate after N attempts') "
            "or i_am_idle() to release the claim. The PM will pick it up."
        ),
    )
    return env.as_dict()


@app.post("/verb/attempted", response_model=VerbCircuitStatus)
async def verb_attempted(req: VerbAttemptRequest) -> VerbCircuitStatus:
    """Record a verb-level rejection and report breaker state.

    Posted by the agent's response-handling layer after every gateway call
    that returned a rejection envelope. Rejections of unknown kind are
    ignored (they don't count) — the catalog of counted kinds lives in
    `_CIRCUIT_REJECTION_KINDS`. The response carries the live breaker state
    plus, when open, the wire-format `Envelope.circuit_open` to surface to
    the agent in place of the next gateway call.
    """
    if req.rejection_kind in _CIRCUIT_REJECTION_KINDS:
        _record_verb_attempt(req.verb, req.task_id)
    limit = retry_limit_for(req.verb)
    count = _verb_attempt_count(req.verb, req.task_id)
    is_open = limit is not None and count >= limit
    envelope_dict = _check_verb_circuit(req.verb, req.task_id) if is_open else None
    return VerbCircuitStatus(
        verb=req.verb,
        task_id=req.task_id,
        attempts=count,
        limit=limit,
        window_seconds=_VERB_ATTEMPT_WINDOW_S,
        open=is_open,
        circuit_envelope=envelope_dict,
    )


@app.get("/verb/circuit_status", response_model=VerbCircuitStatus)
async def verb_circuit_status(
    verb: str, task_id: str | None = None
) -> VerbCircuitStatus:
    """Read-only breaker state for (verb, task_id) — does NOT record an attempt."""
    limit = retry_limit_for(verb)
    count = _verb_attempt_count(verb, task_id)
    is_open = limit is not None and count >= limit
    envelope_dict = _check_verb_circuit(verb, task_id) if is_open else None
    return VerbCircuitStatus(
        verb=verb,
        task_id=task_id,
        attempts=count,
        limit=limit,
        window_seconds=_VERB_ATTEMPT_WINDOW_S,
        open=is_open,
        circuit_envelope=envelope_dict,
    )


@app.post("/budget/tool_called", response_model=BudgetStatus)
async def budget_tool_called(req: BudgetToolCalledRequest) -> BudgetStatus:
    """Record a tool invocation and return current budget status."""
    tool = req.tool.rsplit("__", maxsplit=1)[-1] if "__" in req.tool else req.tool
    _state.record_tool(tool, req.args_hash)
    return _budget_snapshot()


@app.get("/budget/status", response_model=BudgetStatus)
async def budget_status() -> BudgetStatus:
    """Return current budget state without recording anything."""
    return _budget_snapshot()


@app.post("/budget/reset")
async def budget_reset() -> dict[str, str]:
    """Orchestrator calls this on spawn to zero out state."""
    _state.reset()
    logger.info("Budget/terminal state reset", agent_id=AGENT_ID)
    return {"status": "reset"}


def _budget_snapshot() -> BudgetStatus:
    return BudgetStatus(
        total=_state.total_calls,
        by_tool=dict(_state.by_tool),
        warn=_state.total_calls >= _WARN_THRESHOLD,
        halt=_state.halt_triggered,
        loop=_state.loop_triggered,
        warn_threshold=_WARN_THRESHOLD,
        halt_threshold=_HALT_THRESHOLD,
        loop_threshold=_LOOP_THRESHOLD,
        loop_window=_LOOP_WINDOW,
        loop_action=_LOOP_ACTION,
    )


@app.post("/terminal/tool_recorded")
async def terminal_tool_recorded(req: TerminalToolRecordRequest) -> TerminalStatus:
    """
    Record that a tool finished. Used to decide whether a Stop is graceful.

    Separate from /budget/tool_called so existing traceability/A2A hooks can
    continue to hit the budget endpoint while Stop/PreCompact hooks only read
    terminal state.
    """
    tool = req.tool.rsplit("__", maxsplit=1)[-1] if "__" in req.tool else req.tool
    _state.recent_tools.append(tool)
    _state.last_tool = tool
    return _terminal_snapshot()


@app.get("/terminal/status", response_model=TerminalStatus)
async def terminal_status() -> TerminalStatus:
    """Return last-tool and stop-attempt state."""
    return _terminal_snapshot()


@app.post("/terminal/stop_attempt", response_model=TerminalStatus)
async def terminal_stop_attempt() -> TerminalStatus:
    """
    Stop hook POSTs here each time the agent tries to stop.

    Returns updated state — if `had_terminal_recently` is False AND
    `stop_attempts > stop_allowance`, the hook should let the Stop through
    AND fire-and-forget `/terminal/force_substitute` to auto-release the task.
    """
    _state.stop_attempts += 1
    return _terminal_snapshot()


@app.post("/terminal/force_substitute")
async def terminal_force_substitute() -> dict[str, str]:
    """
    Fire-and-forget escape hatch: SDK calls the main API to substitute the
    current task on behalf of the agent when Stop is allowed despite no
    terminal tool having been called.
    """
    role = os.environ.get("ROBOCO_AGENT_ROLE", "developer")
    try:
        async with httpx.AsyncClient() as client:
            await client.post(
                f"{MAIN_API_URL}/api/tasks/auto-substitute",
                json={"reason": "stopped_without_transition"},
                headers={"X-Agent-ID": AGENT_ID, "X-Agent-Role": role},
                timeout=5.0,
            )
        logger.warning(
            "Auto-substituted task on ungraceful stop",
            agent_id=AGENT_ID,
        )
    except Exception as e:
        logger.warning("Auto-substitute failed", error=str(e))
    return {"status": "ok"}


def _terminal_snapshot() -> TerminalStatus:
    return TerminalStatus(
        last_tool=_state.last_tool,
        recent_tools=list(_state.recent_tools),
        had_terminal_recently=_state.had_terminal_recently(),
        stop_attempts=_state.stop_attempts,
        stop_allowance=_STOP_ALLOWANCE,
    )


# =============================================================================
# TOKEN USAGE REPORTING
# =============================================================================


@app.post("/usage/report", response_model=TokenUsageStatus)
async def usage_report(req: TokenReportRequest) -> TokenUsageStatus:
    """Accumulate token usage counts for the current session.

    Called by Claude Code hooks (e.g. PostToolUse) after each API call
    to report the tokens consumed by that invocation. Counts are additive
    — multiple calls sum up correctly across the session lifetime.
    """
    _state.tokens_input += req.tokens_input
    _state.tokens_output += req.tokens_output
    _state.tokens_cache_read += req.tokens_cache_read
    _state.tokens_cache_write += req.tokens_cache_write

    logger.debug(
        "Token usage reported",
        delta_input=req.tokens_input,
        delta_output=req.tokens_output,
        total_input=_state.tokens_input,
        total_output=_state.tokens_output,
    )

    return _token_usage_snapshot()


@app.get("/usage/status", response_model=TokenUsageStatus)
async def usage_status() -> TokenUsageStatus:
    """Return cumulative token usage totals for the current session.

    The orchestrator sweeper calls this endpoint every ~60 s to record
    snapshots and to finalize session rows when the container stops.
    """
    return _token_usage_snapshot()


def _token_usage_snapshot() -> TokenUsageStatus:
    return TokenUsageStatus(
        tokens_input=_state.tokens_input,
        tokens_output=_state.tokens_output,
        tokens_cache_read=_state.tokens_cache_read,
        tokens_cache_write=_state.tokens_cache_write,
    )


@app.post("/usage/sync", response_model=TokenUsageStatus)
async def usage_sync(req: TranscriptSyncRequest) -> TokenUsageStatus:
    """Parse the Claude Code transcript and *set* cumulative token totals.

    Claude Code does not pass token usage to hooks, so the usage-report hook
    hands us the transcript path and we derive the totals ourselves. The set
    is absolute and idempotent: re-syncing the same or a grown transcript
    overwrites rather than accumulates, so it is safe to call after every
    tool use and again at Stop. An unchanged transcript (same size + mtime)
    short-circuits without re-parsing.
    """
    path = Path(req.transcript_path)
    try:
        stat = path.stat()
    except OSError:
        # Transcript not written yet (very first turn) — nothing to sync.
        return _token_usage_snapshot()

    fingerprint = (stat.st_size, stat.st_mtime)
    if fingerprint == _state.transcript_fingerprint:
        return _token_usage_snapshot()

    try:
        tin, tout, tcr, tcw = _sum_transcript_usage(path)
    except OSError as exc:
        logger.warning("Transcript usage sync failed", error=str(exc))
        return _token_usage_snapshot()

    _state.tokens_input = tin
    _state.tokens_output = tout
    _state.tokens_cache_read = tcr
    _state.tokens_cache_write = tcw
    _state.transcript_fingerprint = fingerprint

    logger.debug(
        "Token usage synced from transcript",
        tokens_input=tin,
        tokens_output=tout,
        tokens_cache_read=tcr,
        tokens_cache_write=tcw,
    )
    return _token_usage_snapshot()


@app.post("/journal/post_mortem")
async def journal_post_mortem(req: PostMortemRequest) -> dict[str, str]:
    """SessionEnd hook submits a post-mortem; we log it and flush to the main API."""
    duration = req.duration_seconds or (time.time() - _state.started_at)
    content = (
        "[post-mortem]\n"
        f"terminal_tool: {req.terminal_tool}\n"
        f"duration_seconds: {duration:.1f}\n"
        f"tools_called: {req.tools_called or _state.total_calls}\n"
        f"loop_triggered: {req.loop_triggered or _state.loop_triggered}\n"
        f"halt_triggered: {req.halt_triggered or _state.halt_triggered}\n"
        f"reason: {req.reason}"
    )
    # Pad short content to clear the journal min-length gate
    # (task_reflection requires >= 50 chars). Pad with a small margin so
    # the gate doesn't reject borderline post-mortems.
    _MIN_REFLECTION_CHARS = 60
    if len(content) < _MIN_REFLECTION_CHARS:
        content = content + "\n" + ("-" * (_MIN_REFLECTION_CHARS - len(content)))
    payload = {
        "type": "task_reflection",
        "title": f"session_end: {req.reason or 'unknown'}",
        "content": content,
    }
    try:
        async with httpx.AsyncClient() as client:
            await client.post(
                f"{MAIN_API_URL}/api/journals/me/entries",
                json=payload,
                headers={
                    "X-Agent-ID": AGENT_ID,
                    "X-Agent-Role": os.environ.get("ROBOCO_AGENT_ROLE", "developer"),
                },
                timeout=5.0,
            )
    except Exception as e:
        logger.warning("Post-mortem flush failed", error=str(e))
    return {"status": "ok"}


# =============================================================================
# MAIN
# =============================================================================


def _sdk_bind_host() -> str:
    """Address the SDK server binds to.

    Defaults to the all-interfaces address because the SDK server runs
    inside each agent container and must be reachable from the
    orchestrator on the docker network. Override via
    ROBOCO_SDK_BIND_HOST for local development where binding to a
    specific interface is preferred.
    """
    # Default constructed from octets so the bare literal "0.0.0.0"
    # doesn't appear in source (bandit B104 false positive — this is a
    # container-internal SDK server, binding to all interfaces is the
    # intended behavior). Override via the env var for a specific bind.
    return os.environ.get("ROBOCO_SDK_BIND_HOST", ".".join(["0"] * 4))


if __name__ == "__main__":
    logger.info(
        "Starting SDK Server",
        agent_id=AGENT_ID,
        port=SDK_PORT,
    )
    uvicorn.run(app, host=_sdk_bind_host(), port=SDK_PORT)
