#!/usr/bin/env python3
"""Durable discovery sessions for unfamiliar CARBON web and API surfaces."""

from __future__ import annotations

import argparse
import html
import json
import os
import re
import sys
import tempfile
from functools import partial
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any

from carbon_flowgen import generate_flow_pack
from carbon_run import (
    REPORT_FAVICON,
    REPORT_THEME_CSS,
    atomic_json,
    load_json,
    redact,
    reject_embedded_secrets,
    render_list,
    report_heading,
    report_icon,
    run_id,
    run_lock,
    carbon_lockup,
    utc_now,
)

KINDS = {"web", "api", "mixed"}
SESSION_STATUSES = {"planned", "exploring", "paused", "completed", "canceled"}
CHANNELS = {
    "dom",
    "accessibility",
    "interaction",
    "network",
    "request",
    "response",
    "spec",
    "source",
    "log",
}
CONFIDENCE = {"low", "medium", "high"}
HYPOTHESIS_STATUSES = {"proposed", "supported", "rejected", "unknown"}
PRIORITIES = {"P0", "P1", "P2", "P3"}


def safe_text(value: str | None) -> str:
    if value is None:
        return ""
    reject_embedded_secrets(value)
    return str(redact(value))


def safe_list(values: list[str] | None) -> list[str]:
    return [safe_text(value) for value in (values or [])]


def append_event(session_dir: Path, event: dict[str, Any]) -> None:
    payload = redact({"time": utc_now(), **event})
    with (session_dir / "events.jsonl").open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(payload, ensure_ascii=False) + "\n")


def load_state(session_dir: Path) -> dict[str, Any]:
    state = load_json(session_dir / "learn.json")
    state.setdefault("flowModels", [])
    state.setdefault("generatedInputs", [])
    state.setdefault("generatedCases", [])
    return state


def save(session_dir: Path, state: dict[str, Any], event: dict[str, Any]) -> None:
    state["updatedAt"] = utc_now()
    state["lastEvent"] = str(event.get("detail", event.get("kind", "Updated")))
    atomic_json(session_dir / "learn.json", redact(state))
    append_event(session_dir, event)
    sync_learned_tests(session_dir, state)
    render(session_dir, state)


def sync_learned_tests(session_dir: Path, state: dict[str, Any]) -> None:
    tests = [*state["learnedTests"], *state["generatedCases"]]
    fragment = {
        "title": f"Learned tests: {state['title']}",
        "target": state["target"],
        "environment": state["environment"],
        "revision": state["revision"],
        "scope": [f"learned surface: {state['surface']}"],
        "assumptions": [
            "Promoted learning is a supported test candidate, not a recorded pass",
            "Automatically generated flow cases and candidate oracles remain planned hypotheses until execution produces evidence",
        ],
        "detectedFlows": [flow["title"] for flow in state["flowModels"]],
        "detectedItems": [state["surface"]],
        "availableAdapters": sorted(
            {test["adapter"] for test in tests}
        ),
        "setupDependencies": [],
        "credentialSources": [],
        "stopConditions": state["stopConditions"],
        "residualUncertainty": state["unknowns"],
        "concurrency": 1,
        "tests": tests,
        "learningSession": state["sessionId"],
        "stalenessAnchors": state["stalenessAnchors"],
        "promotedTestCount": len(state["learnedTests"]),
        "generatedCaseCount": len(state["generatedCases"]),
        "generatedInputCount": len(state["generatedInputs"]),
    }
    atomic_json(session_dir / "learned-tests.json", redact(fragment))


def initialize(args: argparse.Namespace) -> Path:
    target_root = Path(args.root).expanduser().resolve()
    state_root = (
        Path(args.state_root).expanduser().resolve() if args.state_root else target_root
    )
    identifier = args.session_id or f"learn-{run_id()}"
    session_dir = state_root / ".carbon" / "learning" / identifier
    if session_dir.exists():
        raise ValueError(f"Learning session already exists: {session_dir}")
    target = safe_text(args.target)
    surface = safe_text(args.surface)
    environment = safe_text(args.environment)
    now = utc_now()
    state = {
        "schemaVersion": 1,
        "sessionId": identifier,
        "title": safe_text(args.title) or f"Learn {surface}",
        "kind": args.kind,
        "target": target,
        "surface": surface,
        "environment": environment,
        "identity": safe_text(args.identity),
        "revision": safe_text(args.revision),
        "targetRoot": str(target_root),
        "stateRoot": str(state_root),
        "status": "planned",
        "createdAt": now,
        "updatedAt": now,
        "startedAt": None,
        "finishedAt": None,
        "pauseReason": "",
        "allowedActions": safe_list(args.allowed_action),
        "stopConditions": safe_list(args.stop_condition),
        "stalenessAnchors": safe_list(args.anchor),
        "observations": [],
        "hypotheses": [],
        "learnedTests": [],
        "flowModels": [],
        "generatedInputs": [],
        "generatedCases": [],
        "unknowns": [],
        "lastEvent": "Learning session created",
    }
    session_dir.mkdir(parents=True, exist_ok=False)
    atomic_json(session_dir / "learn.json", state)
    append_event(
        session_dir, {"kind": "session-created", "detail": "Learning session created"}
    )
    sync_learned_tests(session_dir, state)
    render(session_dir, state)
    return session_dir


def start(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] in {"completed", "canceled"}:
            raise ValueError(f"Cannot start a {state['status']} learning session")
        state["status"] = "exploring"
        state["startedAt"] = state["startedAt"] or utc_now()
        state["pauseReason"] = ""
        save(
            session_dir,
            state,
            {"kind": "session-started", "detail": "Safe discovery started"},
        )


def observe(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    if args.channel not in CHANNELS:
        raise ValueError(f"Unknown observation channel: {args.channel}")
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] in {"completed", "canceled"}:
            raise ValueError(f"Cannot observe in a {state['status']} session")
        observation = {
            "id": f"O-{len(state['observations']) + 1:03d}",
            "channel": args.channel,
            "surface": safe_text(args.surface) or state["surface"],
            "summary": safe_text(args.summary),
            "stateBefore": safe_text(args.state_before),
            "action": safe_text(args.action),
            "stateAfter": safe_text(args.state_after),
            "requestShape": safe_text(args.request_shape),
            "responseShape": safe_text(args.response_shape),
            "evidence": safe_list(args.evidence),
            "time": utc_now(),
        }
        state["observations"].append(observation)
        state["status"] = "exploring"
        state["startedAt"] = state["startedAt"] or utc_now()
        save(
            session_dir,
            state,
            {
                "kind": "observation-added",
                "observationId": observation["id"],
                "detail": f"{observation['id']}: {observation['summary']}",
            },
        )


def hypothesize(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] in {"completed", "canceled"}:
            raise ValueError(f"Cannot add a hypothesis to a {state['status']} session")
        hypothesis = {
            "id": f"H-{len(state['hypotheses']) + 1:03d}",
            "title": safe_text(args.title),
            "surface": safe_text(args.surface) or state["surface"],
            "claim": safe_text(args.claim),
            "confidence": args.confidence,
            "probe": safe_text(args.probe),
            "oracle": safe_text(args.oracle),
            "risk": safe_text(args.risk),
            "status": "proposed",
            "evidence": [],
            "createdAt": utc_now(),
            "resolvedAt": None,
        }
        state["hypotheses"].append(hypothesis)
        save(
            session_dir,
            state,
            {
                "kind": "hypothesis-added",
                "hypothesisId": hypothesis["id"],
                "detail": f"{hypothesis['id']}: {hypothesis['title']}",
            },
        )


def find_hypothesis(state: dict[str, Any], hypothesis_id: str) -> dict[str, Any]:
    for hypothesis in state["hypotheses"]:
        if hypothesis["id"] == hypothesis_id:
            return hypothesis
    raise ValueError(f"Unknown hypothesis id: {hypothesis_id}")


def resolve(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    if args.status not in HYPOTHESIS_STATUSES - {"proposed"}:
        raise ValueError(f"Invalid resolution status: {args.status}")
    if args.status in {"supported", "rejected"} and not args.evidence:
        raise ValueError(
            f"{args.status} resolution requires at least one evidence reference"
        )
    with run_lock(session_dir):
        state = load_state(session_dir)
        hypothesis = find_hypothesis(state, args.hypothesis_id)
        hypothesis["status"] = args.status
        hypothesis["evidence"].extend(safe_list(args.evidence))
        hypothesis["resolvedAt"] = utc_now()
        if args.status == "unknown":
            unknown = safe_text(args.note) or hypothesis["claim"]
            if unknown not in state["unknowns"]:
                state["unknowns"].append(unknown)
        save(
            session_dir,
            state,
            {
                "kind": "hypothesis-resolved",
                "hypothesisId": hypothesis["id"],
                "status": args.status,
                "detail": f"{hypothesis['id']} → {args.status}",
            },
        )


def promote(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    priority = args.priority.upper()
    if priority not in PRIORITIES:
        raise ValueError(f"Invalid priority: {priority}")
    with run_lock(session_dir):
        state = load_state(session_dir)
        hypothesis = find_hypothesis(state, args.hypothesis_id)
        if hypothesis["status"] != "supported" and not args.force:
            raise ValueError(
                f"{hypothesis['id']} is {hypothesis['status']}; promote only supported "
                "hypotheses or use --force with explicit justification"
            )
        if args.force and not args.justification:
            raise ValueError("--force requires --justification")
        test_id = safe_text(args.test_id)
        if not test_id or not re.fullmatch(r"[A-Za-z0-9._:-]+", test_id):
            raise ValueError(f"Invalid learned test id: {test_id!r}")
        if any(
            test["id"] == test_id
            for test in [*state["learnedTests"], *state["generatedCases"]]
        ):
            raise ValueError(f"Duplicate learned test id: {test_id}")
        flags = {
            "destructive": args.destructive,
            "costly": args.costly,
            "privacy": args.privacy,
            "payment": args.payment,
            "notification": args.notification,
            "security": args.security,
        }
        learned_test = {
            "id": test_id,
            "title": safe_text(args.title) or hypothesis["title"],
            "priority": priority,
            "type": safe_text(args.type),
            "flow": safe_text(args.flow),
            "item": hypothesis["surface"],
            "reason": hypothesis["risk"] or hypothesis["claim"],
            "provenance": [
                f"learning-session:{state['sessionId']}",
                f"hypothesis:{hypothesis['id']}",
                *hypothesis["evidence"],
                *(
                    [f"forced-promotion:{safe_text(args.justification)}"]
                    if args.force
                    else []
                ),
            ],
            "prerequisites": safe_list(args.prerequisite),
            "data": safe_list(args.data),
            "steps": [hypothesis["probe"]],
            "oracle": hypothesis["oracle"],
            "estimatedSeconds": max(1, args.estimated_seconds),
            "dependencies": safe_list(args.dependency),
            "parallelGroup": safe_text(args.parallel_group),
            "adapter": safe_text(args.adapter),
            "flags": flags,
            "stalenessAnchors": state["stalenessAnchors"],
        }
        state["learnedTests"].append(learned_test)
        save(
            session_dir,
            state,
            {
                "kind": "test-promoted",
                "hypothesisId": hypothesis["id"],
                "testId": test_id,
                "detail": f"{hypothesis['id']} promoted to {test_id}",
            },
        )


def generate_flow(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    spec_path = Path(args.spec).expanduser().resolve()
    raw = load_json(spec_path)
    reject_embedded_secrets(json.dumps(raw, ensure_ascii=False))
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] in {"completed", "canceled"}:
            raise ValueError(
                f"Cannot generate a flow pack in a {state['status']} session"
            )
        pack = generate_flow_pack(
            raw,
            session_id=state["sessionId"],
            target=state["target"],
            environment=state["environment"],
            revision=state["revision"],
        )
        flow = pack["flow"]
        existing = next(
            (item for item in state["flowModels"] if item["id"] == flow["id"]), None
        )
        if existing and not args.replace:
            raise ValueError(
                f"Flow {flow['id']} already exists; use --replace to regenerate it"
            )
        retained_cases = [
            item
            for item in state["generatedCases"]
            if item.get("flowId") != flow["id"]
        ]
        existing_test_ids = {
            item["id"] for item in [*state["learnedTests"], *retained_cases]
        }
        collisions = sorted(
            existing_test_ids & {item["id"] for item in pack["cases"]}
        )
        if collisions:
            raise ValueError(
                f"Generated test IDs conflict with existing tests: {collisions}"
            )
        state["flowModels"] = [
            item for item in state["flowModels"] if item["id"] != flow["id"]
        ]
        state["generatedInputs"] = [
            item
            for item in state["generatedInputs"]
            if item.get("flowId") != flow["id"]
        ]
        state["generatedCases"] = retained_cases
        flow_model = {
            **flow,
            "generatedAt": utc_now(),
            "sourceSpec": str(spec_path),
            "candidateNotice": pack["candidateNotice"],
        }
        inputs = [{**item, "flowId": flow["id"]} for item in pack["inputs"]]
        cases = [{**item, "flowId": flow["id"]} for item in pack["cases"]]
        state["flowModels"].append(flow_model)
        state["generatedInputs"].extend(inputs)
        state["generatedCases"].extend(cases)
        state["status"] = "exploring"
        state["startedAt"] = state["startedAt"] or utc_now()
        save(
            session_dir,
            state,
            {
                "kind": "flow-pack-generated",
                "flowId": flow["id"],
                "inputCount": len(inputs),
                "caseCount": len(cases),
                "detail": (
                    f"Generated {len(inputs)} inputs and {len(cases)} candidate "
                    f"cases for {flow['title']}"
                ),
            },
        )


def pause(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] not in {"planned", "exploring", "paused"}:
            raise ValueError(f"Cannot pause a {state['status']} session")
        state["status"] = "paused"
        state["pauseReason"] = safe_text(args.reason)
        save(
            session_dir,
            state,
            {"kind": "session-paused", "detail": f"Paused: {state['pauseReason']}"},
        )


def resume(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] != "paused":
            raise ValueError("Only a paused learning session can resume")
        state["status"] = "exploring"
        state["pauseReason"] = ""
        save(
            session_dir,
            state,
            {"kind": "session-resumed", "detail": "Learning session resumed"},
        )


def complete(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    with run_lock(session_dir):
        state = load_state(session_dir)
        if not state["observations"]:
            raise ValueError("Cannot complete learning without an observation")
        for hypothesis in state["hypotheses"]:
            if hypothesis["status"] == "proposed":
                hypothesis["status"] = "unknown"
                hypothesis["resolvedAt"] = utc_now()
                if hypothesis["claim"] not in state["unknowns"]:
                    state["unknowns"].append(hypothesis["claim"])
        for unknown in safe_list(args.unknown):
            if unknown not in state["unknowns"]:
                state["unknowns"].append(unknown)
        state["status"] = "completed"
        state["finishedAt"] = utc_now()
        state["pauseReason"] = ""
        save(
            session_dir,
            state,
            {
                "kind": "session-completed",
                "detail": (
                    f"Learning completed with {len(state['learnedTests'])} promoted "
                    f"tests and {len(state['generatedCases'])} generated candidates"
                ),
            },
        )


def cancel(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    with run_lock(session_dir):
        state = load_state(session_dir)
        if state["status"] == "completed":
            raise ValueError("Cannot cancel a completed learning session")
        state["status"] = "canceled"
        state["finishedAt"] = utc_now()
        state["pauseReason"] = safe_text(args.reason)
        save(
            session_dir,
            state,
            {"kind": "session-canceled", "detail": f"Canceled: {state['pauseReason']}"},
        )


def observation_html(observation: dict[str, Any]) -> str:
    transition = " → ".join(
        part
        for part in [
            observation["stateBefore"],
            observation["action"],
            observation["stateAfter"],
        ]
        if part
    )
    return f"""
    <article class="card">
      <div class="head"><div class="icon-heading">{report_icon("observation")}<strong>{html.escape(observation["id"])} · {html.escape(observation["channel"])}</strong></div><span>{html.escape(observation["surface"])}</span></div>
      <p>{html.escape(observation["summary"])}</p>
      <p class="muted">{html.escape(transition) or "No state transition recorded"}</p>
      <details><summary>Evidence and protocol shapes</summary>
        <p><strong>Request:</strong> {html.escape(observation["requestShape"]) or "not recorded"}</p>
        <p><strong>Response:</strong> {html.escape(observation["responseShape"]) or "not recorded"}</p>
        {render_list(observation["evidence"])}
      </details>
    </article>
    """


def hypothesis_html(hypothesis: dict[str, Any]) -> str:
    return f"""
    <article class="card hypothesis-{html.escape(hypothesis["status"])}">
      <div class="head"><div class="icon-heading">{report_icon("hypothesis")}<strong>{html.escape(hypothesis["id"])} · {html.escape(hypothesis["title"])}</strong></div><span class="status-{html.escape(hypothesis["status"])}">{html.escape(hypothesis["status"])}</span></div>
      <p>{html.escape(hypothesis["claim"])}</p>
      <p><strong>Probe:</strong> {html.escape(hypothesis["probe"])}</p>
      <p><strong>Oracle:</strong> {html.escape(hypothesis["oracle"])}</p>
      <p class="risk-text">{html.escape(hypothesis["confidence"])} confidence · {html.escape(hypothesis["risk"])}</p>
      {render_list(hypothesis["evidence"])}
    </article>
    """


def fixture_value_html(fixture: dict[str, Any]) -> str:
    value = fixture["value"]
    if fixture["sensitive"]:
        return "<em>synthetic sensitive value; inspect only in the local learning JSON</em>"
    if isinstance(value, dict) and value.get("generator") == "repeat":
        character = html.escape(json.dumps(value.get("value"), ensure_ascii=False))
        return f"<code>repeat {character} × {int(value.get('length', 0))}</code>"
    rendered = json.dumps(value, ensure_ascii=False, sort_keys=True)
    if len(rendered) > 140:
        rendered = f"{rendered[:137]}…"
    return f"<code>{html.escape(rendered)}</code>"


def flow_html(flow: dict[str, Any], state: dict[str, Any]) -> str:
    flow_inputs = [
        item for item in state["generatedInputs"] if item["flowId"] == flow["id"]
    ]
    flow_cases = [
        item for item in state["generatedCases"] if item["flowId"] == flow["id"]
    ]
    transitions = "".join(
        f"<li><strong>{html.escape(item['from'])} → {html.escape(item['to'])}</strong> · {html.escape(item['action'])}</li>"
        for item in flow["transitions"]
    )
    input_rows = "".join(
        f"<tr><td><code>{html.escape(item['id'])}</code></td><td>{html.escape(item['field'])}</td><td>{html.escape(item['category'])}</td><td>{fixture_value_html(item)}</td><td>{html.escape(item['expected'])}</td></tr>"
        for item in flow_inputs
    )
    case_rows = "".join(
        f"""<li><details><summary><strong>{html.escape(item['id'])}</strong> · {html.escape(item['title'])} · {html.escape(item['priority'])}</summary>
          <p>{html.escape(item['reason'])}</p>
          <p><strong>Candidate oracle:</strong> {html.escape(item['oracle'])}</p>
          <h4>Steps</h4>{render_list(item['steps'])}
          <h4>Data</h4>{render_list(item['data'])}
          <p class="muted">{html.escape(item['type'])} · {html.escape(item['adapter'])} · planned candidate</p>
        </details></li>"""
        for item in flow_cases
    )
    return f"""
    <article class="card">
      <div class="head"><div class="icon-heading">{report_icon("product")}<strong>{html.escape(flow["title"])}</strong></div><span>candidate model · {html.escape(flow["kind"])}</span></div>
      <p>{html.escape(flow["outcome"])}</p>
      <p class="muted">{len(flow["states"])} states · {len(flow["transitions"])} transitions · {len(flow_inputs)} generated inputs · {len(flow_cases)} generated cases</p>
      <details><summary>State transitions</summary><ul>{transitions}</ul></details>
      <details><summary>Generated test inputs</summary>
        <div class="table-wrap"><table><thead><tr><th>ID</th><th>Field</th><th>Variant</th><th>Value</th><th>Candidate expectation</th></tr></thead><tbody>{input_rows}</tbody></table></div>
      </details>
      <details><summary>Generated candidate cases</summary><ul>{case_rows}</ul></details>
      <p class="muted">{html.escape(flow["candidateNotice"])}</p>
    </article>
    """


def render(session_dir: Path, state: dict[str, Any] | None = None) -> Path:
    state = state or load_state(session_dir)
    refresh = state["status"] in {"planned", "exploring", "paused"}
    observations = "".join(map(observation_html, state["observations"])) or (
        '<p class="muted">No observations recorded yet.</p>'
    )
    hypotheses = "".join(map(hypothesis_html, state["hypotheses"])) or (
        '<p class="muted">No hypotheses recorded yet.</p>'
    )
    promoted = (
        "".join(
            f"<li><strong>{html.escape(test['id'])}</strong> · {html.escape(test['title'])} · {html.escape(test['priority'])}</li>"
            for test in state["learnedTests"]
        )
        or "<li>No tests promoted yet.</li>"
    )
    flows = "".join(flow_html(flow, state) for flow in state["flowModels"]) or (
        '<p class="muted">No learned flows generated yet.</p>'
    )
    refresh_script = (
        """
    <script>
      const key = "carbon-learn-" + document.body.dataset.session;
      document.querySelectorAll("details").forEach((details, index) => {
        const itemKey = key + "-" + index;
        details.open = sessionStorage.getItem(itemKey) === "open";
        details.addEventListener("toggle", () => sessionStorage.setItem(itemKey, details.open ? "open" : "closed"));
      });
      setTimeout(() => location.reload(), 30000);
    </script>
    """
        if refresh
        else ""
    )
    document = f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
  <link rel="icon" href="{REPORT_FAVICON}">
  <title>{html.escape(state["title"])} · CARBON Learn by testers.ai</title>
  <style>
    {REPORT_THEME_CSS}
    main {{ max-width: 1120px; }}
    .head {{ justify-content: space-between; gap: 14px; flex-wrap: wrap; }}
    .head strong {{ min-width: 0; }}
    .card {{ position: relative; overflow: hidden; }}
    .card::before {{ position: absolute; inset: 0 auto 0 0; width: 3px; background: var(--line-bright); content: ""; }}
    .hypothesis-supported::before {{ background: var(--good); }}
    .hypothesis-rejected::before {{ background: var(--bad); }}
    .hypothesis-unknown::before {{ background: var(--warn); }}
    .hypothesis-proposed::before {{ background: var(--info); }}
    .head .status-supported {{ border-color: rgba(55, 214, 189, .38); background: var(--good-soft); color: #70ead5; }}
    .head .status-rejected {{ border-color: rgba(251, 113, 133, .38); background: var(--bad-soft); color: #fda4af; }}
    .head .status-unknown {{ border-color: rgba(251, 191, 36, .38); background: var(--warn-soft); color: #fcd34d; }}
    .head .status-proposed {{ border-color: rgba(255, 255, 255, .38); background: var(--info-soft); color: #ffffff; }}
    .card > p:last-child {{ margin-bottom: 0; }}
    .session-metrics {{ grid-template-columns: repeat(auto-fit, minmax(125px, 1fr)); }}
    .session-metrics .metric:nth-child(3) strong,
    .session-metrics .metric:nth-child(4) strong {{ color: var(--good); }}
    .session-metrics .metric:nth-child(7) strong {{ color: var(--accent-bright); }}
    .boundary-grid {{ display: grid; grid-template-columns: repeat(4, 1fr); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); }}
    .boundary-grid section {{ min-width:0; border-right: 1px solid var(--line); padding: 18px; }}
    .boundary-grid section:last-child {{ border-right:0; }}
    .boundary-grid h3 {{ margin: 0 0 8px; color: var(--muted-strong); font-size: 11px; letter-spacing: .06em; text-transform: uppercase; }}
    @media (max-width: 680px) {{
      .boundary-grid {{ grid-template-columns: 1fr; }}
      .boundary-grid section {{ border-right:0; border-bottom:1px solid var(--line); }}
      .boundary-grid section:last-child {{ border-bottom:0; }}
    }}
  </style>
</head>
<body data-session="{html.escape(state["sessionId"])}">
<main>
  <header class="masthead">
    {carbon_lockup("Learn report")}
    <div class="masthead-meta">
      <span class="status-pill status-{html.escape(state["status"])}">{html.escape(state["status"])}</span>
      <span class="updated">Updated {html.escape(state["updatedAt"])}</span>
    </div>
  </header>

  <section class="hero">
    <p class="eyebrow">Discovery session · {html.escape(state["kind"])} · {html.escape(state["sessionId"])}</p>
    <h1>{html.escape(state["title"])}</h1>
    <p class="target-line">{html.escape(state["target"])} · {html.escape(state["surface"])} · {html.escape(state["identity"]) or "identity not recorded"}</p>
  </section>

  <div class="metrics session-metrics">
    <div class="metric"><div class="metric-top"><span>Observations</span>{report_icon("observation")}</div><strong>{len(state["observations"])}</strong></div>
    <div class="metric"><div class="metric-top"><span>Hypotheses</span>{report_icon("hypothesis")}</div><strong>{len(state["hypotheses"])}</strong></div>
    <div class="metric"><div class="metric-top"><span>Supported</span>{report_icon("confidence")}</div><strong>{sum(item["status"] == "supported" for item in state["hypotheses"])}</strong></div>
    <div class="metric"><div class="metric-top"><span>Promoted tests</span>{report_icon("tests")}</div><strong>{len(state["learnedTests"])}</strong></div>
    <div class="metric"><div class="metric-top"><span>Learned flows</span>{report_icon("product")}</div><strong>{len(state["flowModels"])}</strong></div>
    <div class="metric"><div class="metric-top"><span>Generated inputs</span>{report_icon("import")}</div><strong>{len(state["generatedInputs"])}</strong></div>
    <div class="metric"><div class="metric-top"><span>Generated cases</span>{report_icon("tests")}</div><strong>{len(state["generatedCases"])}</strong></div>
  </div>
  <section class="panel spotlight">
    {report_heading("Learning boundary", "learn")}
    <p><strong>Environment:</strong> {html.escape(state["environment"])} · <strong>Revision:</strong> {html.escape(state["revision"]) or "not recorded"}</p>
    <div class="boundary-grid">
      <section>{report_heading("Allowed actions", "execution", level=3)}{render_list(state["allowedActions"])}</section>
      <section>{report_heading("Stop conditions", "safety", level=3)}{render_list(state["stopConditions"])}</section>
      <section>{report_heading("Staleness anchors", "timeline", level=3)}{render_list(state["stalenessAnchors"])}</section>
      <section>{report_heading("Unknowns", "unknown", level=3)}{render_list(state["unknowns"])}</section>
    </div>
  </section>
  <section>{report_heading("Discovered flow packs", "product")}<p class="muted">Inputs and cases are created automatically from the learned state graph. They remain planned candidates until run evidence supports their oracles.</p>{flows}</section>
  <section>{report_heading("Observations", "observation")}{observations}</section>
  <section>{report_heading("Hypotheses", "hypothesis")}{hypotheses}</section>
  <section class="panel">{report_heading("Promoted learned tests", "tests")}<ul>{promoted}</ul><p>Plan fragment: <code>{html.escape(str(session_dir / "learned-tests.json"))}</code></p></section>

  <footer class="footer">
    <span>CARBON Learn is a <a href="https://testers.ai">testers.ai</a> product experience.</span>
    <span>Candidate behavior remains planned until execution produces evidence.</span>
  </footer>
</main>
{refresh_script}
</body>
</html>
"""
    output = session_dir / "report.html"
    with tempfile.NamedTemporaryFile(
        "w", encoding="utf-8", dir=session_dir, delete=False
    ) as handle:
        handle.write(document)
        temporary = Path(handle.name)
    os.replace(temporary, output)
    return output


def show(args: argparse.Namespace) -> None:
    state = load_state(Path(args.session_dir).expanduser().resolve())
    payload = {
        "sessionId": state["sessionId"],
        "status": state["status"],
        "kind": state["kind"],
        "observations": len(state["observations"]),
        "hypotheses": {
            status: sum(item["status"] == status for item in state["hypotheses"])
            for status in HYPOTHESIS_STATUSES
        },
        "promotedTests": len(state["learnedTests"]),
        "learnedFlows": len(state["flowModels"]),
        "generatedInputs": len(state["generatedInputs"]),
        "generatedCases": len(state["generatedCases"]),
        "unknowns": state["unknowns"],
        "updatedAt": state["updatedAt"],
    }
    print(json.dumps(payload, indent=2))


def render_command(args: argparse.Namespace) -> None:
    print(render(Path(args.session_dir).expanduser().resolve()))


def serve(args: argparse.Namespace) -> None:
    session_dir = Path(args.session_dir).expanduser().resolve()
    render(session_dir)
    handler = partial(SimpleHTTPRequestHandler, directory=str(session_dir))
    server = ThreadingHTTPServer(("127.0.0.1", args.port), handler)
    host, port = server.server_address
    print(f"http://{host}:{port}/report.html", flush=True)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        server.server_close()


def session_parser(subparsers: Any, name: str) -> argparse.ArgumentParser:
    parser = subparsers.add_parser(name)
    parser.add_argument("--session-dir", required=True)
    return parser


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    init = subparsers.add_parser("init")
    init.add_argument("--root", required=True)
    init.add_argument("--state-root")
    init.add_argument("--target", required=True)
    init.add_argument("--surface", required=True)
    init.add_argument("--kind", choices=sorted(KINDS), required=True)
    init.add_argument("--title")
    init.add_argument("--session-id")
    init.add_argument("--environment", default="local isolated test environment")
    init.add_argument("--identity")
    init.add_argument("--revision")
    init.add_argument("--allowed-action", action="append")
    init.add_argument("--stop-condition", action="append")
    init.add_argument("--anchor", action="append")
    init.set_defaults(function=lambda args: print(initialize(args)))

    start_parser = session_parser(subparsers, "start")
    start_parser.set_defaults(function=start)

    observation = session_parser(subparsers, "observe")
    observation.add_argument("--channel", choices=sorted(CHANNELS), required=True)
    observation.add_argument("--summary", required=True)
    observation.add_argument("--surface")
    observation.add_argument("--state-before")
    observation.add_argument("--action")
    observation.add_argument("--state-after")
    observation.add_argument("--request-shape")
    observation.add_argument("--response-shape")
    observation.add_argument("--evidence", action="append")
    observation.set_defaults(function=observe)

    hypothesis = session_parser(subparsers, "hypothesize")
    hypothesis.add_argument("--title", required=True)
    hypothesis.add_argument("--surface")
    hypothesis.add_argument("--claim", required=True)
    hypothesis.add_argument("--confidence", choices=sorted(CONFIDENCE), required=True)
    hypothesis.add_argument("--probe", required=True)
    hypothesis.add_argument("--oracle", required=True)
    hypothesis.add_argument("--risk", required=True)
    hypothesis.set_defaults(function=hypothesize)

    resolution = session_parser(subparsers, "resolve")
    resolution.add_argument("--hypothesis-id", required=True)
    resolution.add_argument(
        "--status", choices=sorted(HYPOTHESIS_STATUSES - {"proposed"}), required=True
    )
    resolution.add_argument("--evidence", action="append")
    resolution.add_argument("--note")
    resolution.set_defaults(function=resolve)

    promotion = session_parser(subparsers, "promote")
    promotion.add_argument("--hypothesis-id", required=True)
    promotion.add_argument("--test-id", required=True)
    promotion.add_argument("--title")
    promotion.add_argument("--priority", choices=sorted(PRIORITIES), required=True)
    promotion.add_argument("--type", default="learned functional")
    promotion.add_argument("--flow", default="")
    promotion.add_argument("--adapter", required=True)
    promotion.add_argument("--estimated-seconds", type=int, default=60)
    promotion.add_argument("--parallel-group", default="")
    promotion.add_argument("--prerequisite", action="append")
    promotion.add_argument("--data", action="append")
    promotion.add_argument("--dependency", action="append")
    promotion.add_argument("--destructive", action="store_true")
    promotion.add_argument("--costly", action="store_true")
    promotion.add_argument("--privacy", action="store_true")
    promotion.add_argument("--payment", action="store_true")
    promotion.add_argument("--notification", action="store_true")
    promotion.add_argument("--security", action="store_true")
    promotion.add_argument("--force", action="store_true")
    promotion.add_argument("--justification")
    promotion.set_defaults(function=promote)

    flow_generation = session_parser(subparsers, "generate-flow")
    flow_generation.add_argument("--spec", required=True)
    flow_generation.add_argument("--replace", action="store_true")
    flow_generation.set_defaults(function=generate_flow)

    pause_parser = session_parser(subparsers, "pause")
    pause_parser.add_argument("--reason", required=True)
    pause_parser.set_defaults(function=pause)

    resume_parser = session_parser(subparsers, "resume")
    resume_parser.set_defaults(function=resume)

    complete_parser = session_parser(subparsers, "complete")
    complete_parser.add_argument("--unknown", action="append")
    complete_parser.set_defaults(function=complete)

    cancel_parser = session_parser(subparsers, "cancel")
    cancel_parser.add_argument("--reason", required=True)
    cancel_parser.set_defaults(function=cancel)

    render_parser = session_parser(subparsers, "render")
    render_parser.set_defaults(function=render_command)

    show_parser = session_parser(subparsers, "show")
    show_parser.set_defaults(function=show)

    serve_parser = session_parser(subparsers, "serve")
    serve_parser.add_argument("--port", type=int, default=0)
    serve_parser.set_defaults(function=serve)
    return parser


def main() -> int:
    try:
        args = build_parser().parse_args()
        args.function(args)
        return 0
    except (ValueError, TimeoutError) as error:
        print(f"carbon_learn: {error}", file=sys.stderr)
        return 2


if __name__ == "__main__":
    raise SystemExit(main())
