#!/usr/bin/env python3
"""Persist, extend, inspect, and render CARBON chatbot test suites."""

from __future__ import annotations

import argparse
import copy
import datetime as dt
import hashlib
import html
import json
import re
import shutil
from collections import Counter
from pathlib import Path
from typing import Any

from carbon_run import REPORT_FAVICON, REPORT_THEME_CSS, carbon_lockup, report_icon


SUITE_SCHEMA = "carbon.chatbot-suite/v1"
INPUT_SCHEMAS = {"carbon.chatbot-suite-input/v1", "icebergqa.chatbot-project/v1", SUITE_SCHEMA}
PRIORITIES = {"P0", "P1", "P2", "P3"}
STATUSES = {"planned", "passed", "failed", "blocked", "skipped", "not-run", "running", "human-review"}
SECRET_PATTERNS = [
    re.compile(r"\bsk-[A-Za-z0-9_-]{16,}\b"),
    re.compile(r"\bghp_[A-Za-z0-9]{20,}\b"),
    re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
    re.compile(r"\bBearer\s+[A-Za-z0-9._~+/-]{16,}\b", re.IGNORECASE),
    re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
]


def now() -> str:
    return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


def stamp() -> str:
    return dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")


def slug(value: Any, fallback: str = "chatbot-suite") -> str:
    result = re.sub(r"[^a-z0-9]+", "-", str(value or "").lower()).strip("-")
    return (result[:72] or fallback)


def clean(value: Any, limit: int = 20_000) -> str:
    text = str(value or "").replace("\x00", "").strip()[:limit]
    for pattern in SECRET_PATTERNS:
        text = pattern.sub("[REDACTED]", text)
    return text


def redact(value: Any) -> Any:
    if isinstance(value, str):
        return clean(value)
    if isinstance(value, list):
        return [redact(item) for item in value]
    if isinstance(value, dict):
        return {clean(key, 100): redact(item) for key, item in value.items()}
    return value


def strings(value: Any, limit: int = 100) -> list[str]:
    if value is None:
        return []
    rows = value if isinstance(value, list) else [value]
    return [clean(item, 4_000) for item in rows[:limit] if clean(item, 4_000)]


def objects(value: Any, limit: int = 500) -> list[dict[str, Any]]:
    return [item for item in (value if isinstance(value, list) else [])[:limit] if isinstance(item, dict)]


def normalize_turns(raw: dict[str, Any]) -> list[dict[str, str]]:
    turns = raw.get("turns")
    if isinstance(turns, list):
        normalized = []
        for turn in turns[:100]:
            if isinstance(turn, dict):
                content = clean(turn.get("content") or turn.get("input"), 12_000)
                role = clean(turn.get("role") or "user", 30).lower()
            else:
                content, role = clean(turn, 12_000), "user"
            if content:
                normalized.append({"role": role if role in {"user", "assistant", "tool", "system-fixture"} else "user", "content": content})
        if normalized:
            return normalized
    prompt = clean(raw.get("prompt") or raw.get("input"), 12_000)
    return [{"role": "user", "content": prompt}] if prompt else []


def normalize_oracles(raw: dict[str, Any]) -> list[dict[str, str]]:
    output = []
    for oracle in objects(raw.get("oracles"), 30):
        criterion = clean(oracle.get("criterion") or oracle.get("expected"), 4_000)
        if criterion:
            output.append({
                "type": clean(oracle.get("type") or "semantic", 40),
                "criterion": criterion,
                "evidence": clean(oracle.get("evidence") or "complete transcript", 2_000),
            })
    if not output:
        for expected in strings(raw.get("expectedBehavior") or raw.get("expected") or raw.get("passCriteria"), 30):
            output.append({"type": "semantic", "criterion": expected, "evidence": "complete transcript"})
    return output


def normalize_test(raw: dict[str, Any], index: int) -> dict[str, Any]:
    title = clean(raw.get("title") or raw.get("intent") or raw.get("id") or f"Chatbot case {index + 1}", 240)
    category = clean(raw.get("category") or raw.get("class") or "Domain-specific risk", 120)
    turns = normalize_turns(raw)
    priority = clean(raw.get("priority") or raw.get("severity") or "P2", 30).upper()
    priority = {"CRITICAL": "P0", "HIGH": "P1", "MEDIUM": "P2", "LOW": "P3"}.get(priority, priority)
    if priority not in PRIORITIES:
        priority = "P2"
    status = clean(raw.get("status") or "planned", 30).lower().replace("_", "-")
    if status not in STATUSES:
        status = "planned"
    identifier = clean(raw.get("id"), 120)
    if not identifier:
        digest = hashlib.sha256((category + "\0" + title + "\0" + json.dumps(turns, sort_keys=True)).encode()).hexdigest()[:10].upper()
        identifier = f"CHAT-{digest}"
    safety = raw.get("safety") if isinstance(raw.get("safety"), dict) else {}
    return {
        "id": identifier,
        "title": title,
        "category": category,
        "type": clean(raw.get("type") or ("multi-turn" if len(turns) > 1 else "single-turn"), 80),
        "priority": priority,
        "risk": clean(raw.get("risk") or raw.get("reason") or "Unspecified conversational risk", 2_000),
        "rationale": clean(raw.get("rationale") or raw.get("intent") or "Candidate derived from available chatbot context", 2_000),
        "provenance": redact(objects(raw.get("provenance"), 30) or ([{"kind": "source", "ref": clean(raw.get("source"), 2_000)}] if raw.get("source") else [])),
        "persona": clean(raw.get("persona"), 160),
        "preconditions": strings(raw.get("preconditions"), 30),
        "setup": strings(raw.get("setup"), 30),
        "data": redact(raw.get("data") if isinstance(raw.get("data"), dict) else {}),
        "turns": turns,
        "expectedBehavior": strings(raw.get("expectedBehavior") or raw.get("expected") or raw.get("passCriteria"), 30),
        "prohibitedBehavior": strings(raw.get("prohibitedBehavior"), 30),
        "oracles": normalize_oracles(raw),
        "evidenceRequirements": strings(raw.get("evidenceRequirements") or ["complete transcript", "target and build identity", "latency"], 30),
        "repetitions": max(1, min(20, int(raw.get("repetitions") or 1))),
        "tags": sorted(set(strings(raw.get("tags"), 50))),
        "dependencies": strings(raw.get("dependencies"), 30),
        "safety": {key: bool(safety.get(key)) for key in ("destructive", "paid", "public", "highVolume")},
        "status": status,
    }


def fingerprint(test: dict[str, Any]) -> str:
    payload = {
        "category": re.sub(r"\W+", "", test["category"].lower()),
        "title": re.sub(r"\W+", "", test["title"].lower()),
        "turns": [re.sub(r"\s+", " ", turn["content"].lower()).strip() for turn in test["turns"]],
    }
    return hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()


def input_suite(payload: dict[str, Any]) -> dict[str, Any]:
    schema = clean(payload.get("schema"), 100)
    if schema and schema not in INPUT_SCHEMAS:
        raise ValueError(f"Unsupported chatbot suite schema: {schema}")
    raw = payload.get("suite") if isinstance(payload.get("suite"), dict) else payload
    return raw


def normalize_suite(raw: dict[str, Any]) -> dict[str, Any]:
    name = clean(raw.get("name") or "Chatbot test suite", 240)
    tests = [normalize_test(test, index) for index, test in enumerate(objects(raw.get("tests"), 10_000))]
    if not tests:
        raise ValueError("The chatbot suite must include at least one test.")
    target = raw.get("target") if isinstance(raw.get("target"), dict) else {}
    return {
        "schema": SUITE_SCHEMA,
        "id": slug(raw.get("id") or name),
        "name": name,
        "description": clean(raw.get("description") or "Risk-based conversational validation suite", 2_000),
        "target": {
            "kind": clean(target.get("kind") or "unspecified", 60),
            "url": clean(target.get("url"), 4_000),
            "entryHint": clean(target.get("entryHint"), 2_000),
        },
        "contextSummary": clean(raw.get("contextSummary") or raw.get("context"), 12_000),
        "goals": strings(raw.get("goals"), 100),
        "risks": strings(raw.get("risks"), 100),
        "personas": redact(raw.get("personas") if isinstance(raw.get("personas"), list) else []),
        "sources": redact(objects(raw.get("sources"), 200)),
        "openQuestions": strings(raw.get("openQuestions"), 100),
        "tests": tests,
        "execution": redact(raw.get("execution") if isinstance(raw.get("execution"), dict) else {"mode": "local", "visibility": "private", "captureEvidence": True}),
        "createdAt": clean(raw.get("createdAt") or now(), 80),
        "updatedAt": now(),
        "revision": max(1, int(raw.get("revision") or 1)),
    }


def suite_root(root: Path) -> Path:
    return root.resolve() / ".carbon" / "chatbot" / "suites"


def load_json(path: Path) -> dict[str, Any]:
    value = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(value, dict):
        raise ValueError(f"{path} must contain a JSON object")
    return value


def merge(existing: dict[str, Any], incoming: dict[str, Any]) -> tuple[dict[str, Any], int, int]:
    merged = copy.deepcopy(existing)
    seen_ids = {test["id"] for test in merged.get("tests", [])}
    seen_fingerprints = {fingerprint(test) for test in merged.get("tests", [])}
    added = deduplicated = 0
    for test in incoming["tests"]:
        fp = fingerprint(test)
        if test["id"] in seen_ids or fp in seen_fingerprints:
            deduplicated += 1
            continue
        merged.setdefault("tests", []).append(test)
        seen_ids.add(test["id"])
        seen_fingerprints.add(fp)
        added += 1
    for key in ("goals", "risks", "openQuestions"):
        merged[key] = list(dict.fromkeys(strings(existing.get(key), 500) + strings(incoming.get(key), 500)))
    for key in ("sources", "personas"):
        rows = existing.get(key, []) + incoming.get(key, [])
        unique: dict[str, Any] = {}
        for item in rows:
            unique[json.dumps(item, sort_keys=True)] = item
        merged[key] = list(unique.values())
    for key in ("description", "target", "contextSummary", "execution"):
        if incoming.get(key):
            merged[key] = incoming[key]
    merged["updatedAt"] = now()
    merged["revision"] = int(existing.get("revision") or 1) + 1
    return merged, added, deduplicated


def list_html(items: list[str], empty: str = "None recorded") -> str:
    if not items:
        return f'<p class="muted">{html.escape(empty)}</p>'
    return "<ul>" + "".join(f"<li>{html.escape(item)}</li>" for item in items) + "</ul>"


def render_suite(suite: dict[str, Any], stats: dict[str, int]) -> str:
    tests = suite["tests"]
    categories = Counter(test["category"] for test in tests)
    priorities = Counter(test["priority"] for test in tests)
    statuses = Counter(test["status"] for test in tests)
    high_risk = priorities["P0"] + priorities["P1"]
    coverage_rows = "".join(
        f'<button class="coverage-row" data-category-button="{html.escape(category)}"><span>{html.escape(category)}</span><strong>{count}</strong><i style="--coverage:{min(100, round(count / max(categories.values()) * 100))}%"></i></button>'
        for category, count in categories.most_common()
    )
    cards = []
    for test in tests:
        searchable = " ".join([test["id"], test["title"], test["category"], test["type"], test["risk"], *test["tags"], *(turn["content"] for turn in test["turns"])]).lower()
        turns = "".join(
            f'<div class="turn"><span>{html.escape(turn["role"])}</span><p>{html.escape(turn["content"])}</p></div>' for turn in test["turns"]
        )
        expected = list_html(test["expectedBehavior"], "No expected behavior recorded")
        prohibited = list_html(test["prohibitedBehavior"], "No prohibited behavior recorded")
        oracles = "".join(
            f'<div class="oracle"><span>{html.escape(oracle["type"])}</span><p>{html.escape(oracle["criterion"])}</p><small>{html.escape(oracle["evidence"])}</small></div>' for oracle in test["oracles"]
        ) or '<p class="muted">Oracle requires review.</p>'
        tags = "".join(f'<span class="tag">{html.escape(tag)}</span>' for tag in test["tags"])
        cards.append(f'''<article class="case" data-search="{html.escape(searchable)}" data-category="{html.escape(test['category'])}" data-priority="{test['priority']}" data-status="{html.escape(test['status'])}" data-type="{html.escape(test['type'])}">
          <div class="case-rail priority-{test['priority'].lower()}"></div>
          <div class="case-head"><div><div class="case-kicker"><span>{html.escape(test['id'])}</span><span>{html.escape(test['category'])}</span></div><h3>{html.escape(test['title'])}</h3></div><div class="badges"><span class="priority priority-{test['priority'].lower()}">{test['priority']}</span><span class="status">{html.escape(test['status'])}</span></div></div>
          <p class="risk"><b>Risk</b> {html.escape(test['risk'])}</p><p class="rationale">{html.escape(test['rationale'])}</p>
          <div class="turns">{turns}</div>
          <details><summary>Expected behavior and oracles</summary><div class="detail-grid"><section><h4>Expected</h4>{expected}</section><section><h4>Must not</h4>{prohibited}</section></div><div class="oracles">{oracles}</div></details>
          <details><summary>Setup, evidence, and provenance</summary><div class="detail-grid"><section><h4>Preconditions</h4>{list_html(test['preconditions'])}<h4>Setup</h4>{list_html(test['setup'])}</section><section><h4>Evidence</h4>{list_html(test['evidenceRequirements'])}<h4>Repetitions</h4><p>{test['repetitions']}</p></section></div><pre>{html.escape(json.dumps(test['provenance'], indent=2))}</pre></details>
          <div class="case-foot"><span>{html.escape(test['type'])}</span><div>{tags}</div></div>
        </article>''')
    generated = html.escape(suite["updatedAt"])
    target = suite.get("target", {})
    target_copy = " · ".join(item for item in [clean(target.get("kind")), clean(target.get("url"))] if item) or "Not configured"
    custom_css = r'''
.hero-chatbot{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(250px,.5fr);gap:36px;padding:48px 0 32px;border-bottom:1px solid var(--line)}
.eyebrow,.case-kicker,.priority,.status,.tag,.turn span{font:750 10px/1.2 ui-monospace,monospace;letter-spacing:.08em;text-transform:uppercase}.eyebrow{color:var(--accent)}
.hero-chatbot h1{max-width:780px;margin:10px 0 15px;font-size:clamp(42px,7vw,88px);letter-spacing:-.06em}.hero-chatbot p{max-width:760px;color:var(--muted);font-size:17px}.hero-side{border-left:1px solid var(--line);padding-left:28px}.hero-side small{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.hero-side strong{display:block;margin:8px 0 24px;font-size:22px}.metrics-chat{display:grid;grid-template-columns:repeat(5,1fr);gap:1px;margin:1px 0 30px;background:var(--line)}.metric-chat{padding:20px;background:#050505}.metric-chat span{display:block;color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.08em}.metric-chat strong{display:block;margin-top:8px;font-size:32px}.metric-risk strong{color:#ff8a24}
.suite-layout{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:30px;align-items:start}.sidebar{position:sticky;top:16px}.sidebar section{margin-bottom:30px}.sidebar h2{font-size:13px;text-transform:uppercase;letter-spacing:.08em}.coverage-row{position:relative;display:grid;grid-template-columns:1fr auto;width:100%;gap:12px;border:0;border-bottom:1px solid var(--line);padding:12px 0;background:transparent;color:#fff;text-align:left;cursor:pointer}.coverage-row i{position:absolute;bottom:-1px;left:0;width:var(--coverage);height:1px;background:#fff}.coverage-row span{font-size:12px}.coverage-row strong{color:var(--muted)}
.context-panel{padding:22px;border:1px solid var(--line);background:#050505}.context-panel p,.context-panel li{color:var(--muted);font-size:13px}.toolbar{position:sticky;top:0;z-index:5;display:grid;grid-template-columns:minmax(220px,1fr) repeat(4,minmax(115px,.32fr));gap:8px;padding:12px 0;background:#000}.toolbar input,.toolbar select{width:100%;border:1px solid var(--line);border-radius:5px;padding:11px 12px;background:#090909;color:#fff}.result-count{margin:12px 0 20px;color:var(--muted);font-size:12px}.case-list{display:grid;gap:14px}.case{position:relative;overflow:hidden;border:1px solid var(--line);border-radius:7px;padding:24px 24px 18px;background:#060606}.case[hidden]{display:none}.case-rail{position:absolute;inset:0 auto 0 0;width:3px;background:#555}.case-rail.priority-p0,.case-rail.priority-p1{background:#ff8a24}.case-head{display:flex;justify-content:space-between;gap:20px}.case-kicker{display:flex;flex-wrap:wrap;gap:12px;color:#888}.case h3{margin:10px 0 5px;font-size:24px;letter-spacing:-.025em}.badges{display:flex;align-items:flex-start;gap:6px}.priority,.status,.tag{display:inline-flex;border:1px solid #2a2a2a;border-radius:999px;padding:6px 8px}.priority-p0,.priority-p1{border-color:#9d4d0d;color:#ff9a42;background:#1c0d02}.status{color:#bbb}.risk{margin:18px 0 5px;color:#d9d9d9}.risk b{margin-right:8px;color:#ff8a24;text-transform:uppercase;font-size:11px;letter-spacing:.08em}.rationale{color:var(--muted)}.turns{display:grid;gap:8px;margin:18px 0}.turn{display:grid;grid-template-columns:74px 1fr;gap:12px;border-left:1px solid #333;padding:10px 12px;background:#090909}.turn span{color:#888}.turn p{margin:0}.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:24px}.case h4{margin:16px 0 6px;color:#aaa;font-size:10px;text-transform:uppercase;letter-spacing:.08em}.case li,.case p{font-size:13px}.oracle{display:grid;grid-template-columns:100px 1fr;gap:12px;border-top:1px solid var(--line);padding:12px 0}.oracle span{color:#fff;font-size:11px;text-transform:uppercase}.oracle p{margin:0}.oracle small{grid-column:2;color:#777}.case pre{max-height:220px;overflow:auto;border:1px solid var(--line);padding:12px;background:#020202;color:#aaa}.case-foot{display:flex;justify-content:space-between;align-items:center;gap:10px;margin-top:16px;padding-top:14px;border-top:1px solid var(--line);color:#888;font-size:11px}.case-foot div{display:flex;flex-wrap:wrap;gap:5px}.tag{padding:4px 6px}
.decision-strip{display:grid;grid-template-columns:repeat(3,1fr);gap:1px;margin:35px 0;background:var(--line)}.decision{padding:24px;background:#070707}.decision svg{width:28px;height:28px}.decision h3{margin:26px 0 8px}.decision p{color:var(--muted);font-size:13px}.footer{margin-bottom:30px}
@media(max-width:900px){.hero-chatbot,.suite-layout{grid-template-columns:1fr}.hero-side{border-left:0;border-top:1px solid var(--line);padding:20px 0 0}.metrics-chat{grid-template-columns:repeat(2,1fr)}.sidebar{position:static}.toolbar{grid-template-columns:1fr 1fr}.toolbar input{grid-column:1/-1}.decision-strip{grid-template-columns:1fr}.detail-grid{grid-template-columns:1fr}}
@media(max-width:560px){.hero-chatbot{gap:22px;padding:30px 0 24px}.hero-chatbot h1{font-size:clamp(38px,13vw,54px);overflow-wrap:anywhere}.metrics-chat{grid-template-columns:1fr 1fr}.metric-chat{min-width:0;padding:16px}.metric-chat span,.eyebrow,.case-kicker,.priority,.status,.tag,.turn span{font-size:11px}.toolbar{position:static;grid-template-columns:1fr}.toolbar input{grid-column:auto}.toolbar input,.toolbar select{min-height:44px;font-size:16px}.coverage-row{min-height:44px}.case{padding:20px 16px 16px}.case-head{align-items:flex-start;flex-direction:column}.badges{flex-wrap:wrap}.turn{grid-template-columns:1fr;gap:5px}.oracle{grid-template-columns:1fr}.oracle small{grid-column:auto}.case-foot{align-items:flex-start;flex-direction:column}.case pre{font-size:12px}.decision{padding:20px}.hero-side strong{overflow-wrap:anywhere}}
'''
    script = r'''
const q=document.querySelector('#q'),filters=[...document.querySelectorAll('.toolbar select')],cards=[...document.querySelectorAll('.case')],count=document.querySelector('#visible-count');
function apply(){const text=q.value.toLowerCase().trim();let visible=0;cards.forEach(card=>{const okText=!text||card.dataset.search.includes(text);const ok=filters.every(f=>!f.value||card.dataset[f.dataset.field]===f.value);card.hidden=!(okText&&ok);if(!card.hidden)visible++});count.textContent=visible+' of '+cards.length+' cases visible'}
q.addEventListener('input',apply);filters.forEach(f=>f.addEventListener('change',apply));document.querySelectorAll('[data-category-button]').forEach(b=>b.addEventListener('click',()=>{document.querySelector('#category').value=b.dataset.categoryButton;apply();document.querySelector('.case-list').scrollIntoView({behavior:'smooth'})}));apply();
'''
    return f'''<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="color-scheme" content="dark"><link rel="icon" href="{REPORT_FAVICON}"><title>CARBON · {html.escape(suite['name'])}</title><style>{REPORT_THEME_CSS}{custom_css}</style></head><body><main>
<header class="masthead">{carbon_lockup('Chatbot suite')}<div class="masthead-meta"><span class="status-pill status-planned">PLANNED</span><span class="updated">{generated}</span></div></header>
<section class="hero-chatbot"><div><div class="eyebrow">Conversational validation · revision {suite['revision']}</div><h1>{html.escape(suite['name'])}</h1><p>{html.escape(suite['description'])}</p></div><aside class="hero-side"><small>Target</small><strong>{html.escape(target_copy)}</strong><small>Evidence state</small><strong>Candidate suite · not run</strong></aside></section>
<section class="metrics-chat"><div class="metric-chat"><span>Total cases</span><strong>{len(tests)}</strong></div><div class="metric-chat"><span>Coverage families</span><strong>{len(categories)}</strong></div><div class="metric-chat metric-risk"><span>P0 + P1 risk</span><strong>{high_risk}</strong></div><div class="metric-chat"><span>Added now</span><strong>{stats.get('added',len(tests))}</strong></div><div class="metric-chat"><span>Deduplicated</span><strong>{stats.get('deduplicated',0)}</strong></div></section>
<section class="decision-strip"><div class="decision">{report_icon('coverage')}<h3>Extend coverage</h3><p>Add materially new cases from new context, findings, product facts, or risk gaps. CARBON preserves prior versions.</p></div><div class="decision">{report_icon('execution')}<h3>Execute locally</h3><p>Run against a website, API, CLI, or local function in an isolated context and attach transcript evidence.</p></div><div class="decision">{report_icon('learn')}<h3>Learn the surface</h3><p>Discover unfamiliar launchers, composers, reply regions, reset behavior, consent gates, and recovery paths.</p></div></section>
<div class="suite-layout"><aside class="sidebar"><section><h2>Coverage map</h2>{coverage_rows}</section><section class="context-panel"><h2>Context</h2><p>{html.escape(suite['contextSummary'] or 'No context summary recorded.')}</p><h2>Goals</h2>{list_html(suite['goals'])}<h2>Risks</h2>{list_html(suite['risks'])}<h2>Open questions</h2>{list_html(suite['openQuestions'])}</section></aside>
<div><div class="toolbar"><input id="q" type="search" placeholder="Search cases, prompts, risks, tags…" aria-label="Search tests"><select id="category" data-field="category"><option value="">All categories</option>{''.join(f'<option>{html.escape(c)}</option>' for c in sorted(categories))}</select><select data-field="priority"><option value="">All priorities</option>{''.join(f'<option>{p}</option>' for p in ('P0','P1','P2','P3') if priorities[p])}</select><select data-field="status"><option value="">All statuses</option>{''.join(f'<option>{html.escape(s)}</option>' for s in sorted(statuses))}</select><select data-field="type"><option value="">All types</option>{''.join(f'<option>{html.escape(t)}</option>' for t in sorted({x['type'] for x in tests}))}</select></div><div id="visible-count" class="result-count"></div><div class="case-list">{''.join(cards)}</div></div></div>
<footer class="footer"><span>CARBON · Selenium with a brain</span><span>Planned cases are not execution evidence · <a href="https://testers.ai">testers.ai</a></span></footer></main><script>{script}</script></body></html>'''


def write_json(path: Path, value: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_suffix(path.suffix + ".tmp")
    temp.write_text(json.dumps(value, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
    temp.replace(path)


def command_inspect(args: argparse.Namespace) -> dict[str, Any]:
    root = Path(args.root).expanduser().resolve()
    suites = []
    for path in sorted(suite_root(root).glob("*/suite.json")):
        try:
            suite = load_json(path)
            suites.append({
                "id": suite.get("id", path.parent.name), "name": suite.get("name", path.parent.name),
                "tests": len(suite.get("tests", [])), "revision": suite.get("revision", 1),
                "updatedAt": suite.get("updatedAt"), "jsonPath": str(path), "htmlPath": str(path.with_name("suite.html")),
            })
        except (OSError, ValueError, json.JSONDecodeError):
            suites.append({"id": path.parent.name, "error": "Unreadable suite", "jsonPath": str(path)})
    return {"schema": "carbon.chatbot-suite-index/v1", "root": str(root), "count": len(suites), "suites": suites}


def command_render(args: argparse.Namespace) -> dict[str, Any]:
    root = Path(args.root).expanduser().resolve()
    incoming = normalize_suite(input_suite(load_json(Path(args.input).expanduser().resolve())))
    base = suite_root(root)
    suite_id = incoming["id"]
    suite_dir = base / suite_id
    existing_path = suite_dir / "suite.json"
    existing_count = added = deduplicated = 0
    if args.mode == "new" and existing_path.exists():
        suite_id = f"{suite_id}-{stamp().lower()}"
        incoming["id"] = suite_id
        suite_dir = base / suite_id
        existing_path = suite_dir / "suite.json"
    if existing_path.exists():
        existing = load_json(existing_path)
        existing_count = len(existing.get("tests", []))
        versions = suite_dir / "versions"
        versions.mkdir(parents=True, exist_ok=True)
        shutil.copy2(existing_path, versions / f"revision-{int(existing.get('revision') or 1):04d}-{stamp()}.json")
        incoming, added, deduplicated = merge(existing, incoming)
    else:
        added = len(incoming["tests"])
    incoming["id"] = suite_id
    incoming = redact(incoming)
    json_path = suite_dir / "suite.json"
    html_path = suite_dir / "suite.html"
    write_json(json_path, incoming)
    html_path.write_text(render_suite(incoming, {"existing": existing_count, "added": added, "deduplicated": deduplicated}), encoding="utf-8")
    result = {
        "schema": "carbon.chatbot-suite-render-result/v1", "suiteId": suite_id, "mode": args.mode,
        "existing": existing_count, "added": added, "deduplicated": deduplicated, "total": len(incoming["tests"]),
        "revision": incoming["revision"], "jsonPath": str(json_path), "htmlPath": str(html_path),
        "jsonLink": json_path.as_uri(), "chatLink": f"[Open the chatbot suite]({html_path})",
        "evidenceState": "planned-not-run",
    }
    return result


def parser() -> argparse.ArgumentParser:
    result = argparse.ArgumentParser(description=__doc__)
    commands = result.add_subparsers(dest="command", required=True)
    inspect_parser = commands.add_parser("inspect", help="List existing local chatbot suites")
    inspect_parser.add_argument("--root", required=True)
    inspect_parser.set_defaults(handler=command_inspect)
    render_parser = commands.add_parser("render", help="Persist and render a chatbot suite")
    render_parser.add_argument("--root", required=True)
    render_parser.add_argument("--input", required=True)
    render_parser.add_argument("--mode", choices=("extend", "new"), default="extend")
    render_parser.set_defaults(handler=command_render)
    return result


def main() -> int:
    args = parser().parse_args()
    print(json.dumps(args.handler(args), indent=2))
    return 0


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