#!/usr/bin/env python3
"""Collect, redact, analyze, and attach page evidence to a durable CARBON run."""

from __future__ import annotations

import argparse
import hashlib
import json
import mimetypes
import re
import shutil
import sys
from collections import Counter
from html.parser import HTMLParser
from pathlib import Path
from typing import Any

from carbon_run import append_event, atomic_json, load_state, redact, render, run_lock, utc_now
from carbon_quality_confidence import assess_quality_confidence


SCHEMA = "carbon.page-evidence/v1"
TEXT_LIMIT = 10 * 1024 * 1024
BINARY_LIMIT = 100 * 1024 * 1024
CHANNELS = (
    "screenshot",
    "dom",
    "accessibility",
    "console",
    "network",
    "performance",
    "trace",
    "data",
    "storage",
    "video",
    "other",
)
STRATEGIES = ("auto", "breadth", "depth", "balanced")
IDENTIFIER = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$")
TEXT_CHANNELS = {"dom", "accessibility", "console", "network", "performance", "data", "storage"}
SENSITIVE_KEY = re.compile(
    r"authorization|proxy-authorization|cookie|set-cookie|api[-_]?key|password|passwd|secret|token|session|csrf|private[-_]?key",
    re.IGNORECASE,
)
SENSITIVE_LINE = re.compile(
    r"(?im)^(\s*(?:authorization|proxy-authorization|cookie|set-cookie|x-api-key|api[-_]?key|password|passwd|secret|token|session|csrf)[^:=\n]*\s*[:=]\s*).+$"
)
SENSITIVE_QUERY = re.compile(
    r"(?i)([?&](?:access_token|auth|authorization|code|key|password|secret|session|token)=)[^&\s\"']+"
)


def safe_slug(value: str, fallback: str = "artifact") -> str:
    slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
    return slug[:80] or fallback


def require_identifier(value: str, label: str) -> str:
    if not IDENTIFIER.fullmatch(value):
        raise ValueError(f"{label} must use 1-80 letters, numbers, dots, underscores, or hyphens")
    return value


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def scrub_json(value: Any, stats: dict[str, int]) -> Any:
    if isinstance(value, dict):
        cleaned: dict[str, Any] = {}
        for key, item in value.items():
            if SENSITIVE_KEY.search(str(key)):
                cleaned[str(key)] = "[REDACTED]"
                stats["redactions"] += 1
            else:
                cleaned[str(key)] = scrub_json(item, stats)
        return cleaned
    if isinstance(value, list):
        return [scrub_json(item, stats) for item in value]
    if isinstance(value, str):
        cleaned = scrub_text(value, stats)
        return cleaned
    return value


def scrub_text(value: str, stats: dict[str, int]) -> str:
    before = value
    value = str(redact(value))
    value, count = SENSITIVE_LINE.subn(r"\1[REDACTED]", value)
    stats["redactions"] += count
    value, count = SENSITIVE_QUERY.subn(r"\1[REDACTED]", value)
    stats["redactions"] += count
    if value != before and not count:
        stats["redactions"] += 1
    return value


def load_manifest(run_dir: Path) -> dict[str, Any]:
    path = run_dir / "evidence" / "manifest.json"
    if path.exists():
        value = json.loads(path.read_text(encoding="utf-8"))
        if not isinstance(value, dict) or value.get("schema") != SCHEMA:
            raise ValueError(f"Unsupported evidence manifest: {path}")
        value.setdefault("strategy", {})
        value.setdefault("personas", [])
        value.setdefault("explorationCharters", [])
        value.setdefault("personaFeedback", [])
        value.setdefault("explorationObservations", [])
        return value
    return {
        "schema": SCHEMA,
        "capturePolicy": {
            "everyTestedPage": True,
            "channels": list(CHANNELS),
            "textTelemetry": "bounded and redacted before persistence",
            "binaryEvidence": "capture only with synthetic or explicitly safe visible data",
            "missingChannels": "recorded as evidence gaps, never invented",
        },
        "pages": [],
        "strategy": {},
        "personas": [],
        "explorationCharters": [],
        "personaFeedback": [],
        "explorationObservations": [],
        "issues": [],
        "summary": {},
        "updatedAt": utc_now(),
    }


def save_manifest(run_dir: Path, manifest: dict[str, Any]) -> None:
    manifest["updatedAt"] = utc_now()
    atomic_json(run_dir / "evidence" / "manifest.json", manifest)


def page_by_id(manifest: dict[str, Any], page_id: str | None) -> dict[str, Any] | None:
    if not page_id:
        return None
    return next((page for page in manifest["pages"] if page.get("id") == page_id), None)


def next_page_id(manifest: dict[str, Any]) -> str:
    used = {str(page.get("id")) for page in manifest["pages"]}
    index = len(used) + 1
    while f"PAGE-{index:03d}" in used:
        index += 1
    return f"PAGE-{index:03d}"


def copy_artifact(run_dir: Path, page: dict[str, Any], kind: str, source_value: str) -> dict[str, Any]:
    source = Path(source_value).expanduser().resolve()
    if not source.is_file():
        raise ValueError(f"Evidence file does not exist: {source}")
    limit = TEXT_LIMIT if kind in TEXT_CHANNELS else BINARY_LIMIT
    source_size = source.stat().st_size
    if source_size > limit and kind not in TEXT_CHANNELS:
        raise ValueError(f"{kind} artifact exceeds the {limit // (1024 * 1024)} MB limit: {source}")
    page_dir = run_dir / "evidence" / "pages" / page["id"]
    page_dir.mkdir(parents=True, exist_ok=True)
    ordinal = sum(1 for artifact in page["artifacts"] if artifact["kind"] == kind) + 1
    suffix = source.suffix.lower() or (".txt" if kind in TEXT_CHANNELS else ".bin")
    destination = page_dir / f"{safe_slug(kind)}-{ordinal:02d}{suffix}"
    stats = {"redactions": 0}
    truncated = False
    if kind in TEXT_CHANNELS:
        raw = source.read_bytes()[:TEXT_LIMIT]
        truncated = source_size > len(raw)
        text = raw.decode("utf-8", errors="replace")
        try:
            parsed = json.loads(text)
        except json.JSONDecodeError:
            cleaned = scrub_text(text, stats)
        else:
            cleaned = json.dumps(scrub_json(parsed, stats), indent=2, ensure_ascii=False) + "\n"
        destination.write_text(cleaned, encoding="utf-8")
    else:
        shutil.copy2(source, destination)
    relative = destination.relative_to(run_dir).as_posix()
    return {
        "id": f"{page['id']}-{kind.upper()}-{ordinal:02d}",
        "kind": kind,
        "label": source.name,
        "path": relative,
        "mimeType": mimetypes.guess_type(destination.name)[0] or "application/octet-stream",
        "bytes": destination.stat().st_size,
        "sha256": sha256(destination),
        "redactions": stats["redactions"],
        "truncated": truncated,
        "capturedAt": utc_now(),
    }


def artifact_text(run_dir: Path, artifact: dict[str, Any]) -> str:
    path = (run_dir / artifact["path"]).resolve()
    if run_dir not in path.parents or not path.is_file():
        return ""
    try:
        return path.read_text(encoding="utf-8", errors="replace")
    except OSError:
        return ""


def load_artifact_json(run_dir: Path, artifact: dict[str, Any]) -> Any:
    try:
        return json.loads(artifact_text(run_dir, artifact))
    except json.JSONDecodeError:
        return None


class DomSignals(HTMLParser):
    def __init__(self) -> None:
        super().__init__()
        self.ids: list[str] = []
        self.labels: set[str] = set()
        self.controls: list[tuple[str, dict[str, str]]] = []
        self.images: list[dict[str, str]] = []
        self.html_lang = ""
        self.has_title = False

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        values = {key.lower(): str(value or "") for key, value in attrs}
        if values.get("id"):
            self.ids.append(values["id"])
        if tag == "html":
            self.html_lang = values.get("lang", "")
        elif tag == "title":
            self.has_title = True
        elif tag == "label" and values.get("for"):
            self.labels.add(values["for"])
        elif tag in {"input", "select", "textarea"}:
            self.controls.append((tag, values))
        elif tag == "img":
            self.images.append(values)


def issue(page: dict[str, Any], category: str, severity: str, title: str, detail: str, artifact_id: str, confidence: str = "corroborated") -> dict[str, Any]:
    key = f"{page['id']}|{category}|{title}|{artifact_id}"
    return {
        "id": "SIG-" + hashlib.sha1(key.encode()).hexdigest()[:10].upper(),
        "pageId": page["id"],
        "category": category,
        "severity": severity,
        "title": title,
        "detail": detail[:1400],
        "artifactId": artifact_id,
        "confidence": confidence,
    }


def analyze_dom(run_dir: Path, page: dict[str, Any], artifact: dict[str, Any]) -> list[dict[str, Any]]:
    text = artifact_text(run_dir, artifact)
    is_html_document = bool(
        re.search(r"<(?:!doctype\s+html|html\b|head\b|body\b)", text, re.I)
    )
    parser = DomSignals()
    try:
        parser.feed(text)
    except Exception:
        return [issue(page, "dom", "low", "DOM snapshot could not be fully parsed", "Review the stored DOM snapshot manually.", artifact["id"], "signal")]
    signals: list[dict[str, Any]] = []
    duplicates = sorted(key for key, count in Counter(parser.ids).items() if count > 1)
    if duplicates:
        signals.append(issue(page, "dom", "medium", "Duplicate element IDs", f"Duplicate IDs can break labels, selectors, and accessibility relationships: {', '.join(duplicates[:12])}", artifact["id"]))
    missing_alt = sum(1 for image in parser.images if "alt" not in image)
    if missing_alt:
        signals.append(issue(page, "accessibility", "medium", "Images missing alt attributes", f"{missing_alt} image element(s) have no alt attribute.", artifact["id"]))
    unlabeled = 0
    for _, values in parser.controls:
        if values.get("type", "").lower() == "hidden":
            continue
        control_id = values.get("id", "")
        if not (values.get("aria-label") or values.get("aria-labelledby") or values.get("title") or (control_id and control_id in parser.labels)):
            unlabeled += 1
    if unlabeled:
        signals.append(issue(page, "accessibility", "medium", "Form controls lack discoverable labels", f"{unlabeled} form control(s) lack an explicit label or accessible-name attribute in the captured DOM.", artifact["id"]))
    if is_html_document and not parser.html_lang:
        signals.append(issue(page, "accessibility", "low", "Document language is not declared", "The captured HTML element has no lang attribute.", artifact["id"]))
    if is_html_document and not parser.has_title:
        signals.append(issue(page, "content", "low", "Document title is missing", "The captured DOM has no title element.", artifact["id"]))
    return signals


def walk_dicts(value: Any):
    if isinstance(value, dict):
        yield value
        for item in value.values():
            yield from walk_dicts(item)
    elif isinstance(value, list):
        for item in value:
            yield from walk_dicts(item)


def analyze_console(run_dir: Path, page: dict[str, Any], artifact: dict[str, Any]) -> list[dict[str, Any]]:
    parsed = load_artifact_json(run_dir, artifact)
    records = list(walk_dicts(parsed)) if parsed is not None else []
    errors: list[str] = []
    warnings: list[str] = []
    for record in records:
        level = str(record.get("level") or record.get("type") or "").lower()
        message = str(record.get("message") or record.get("text") or record.get("error") or "").strip()
        if level in {"error", "pageerror", "uncaught", "fatal"} and message:
            errors.append(message)
        elif level in {"warning", "warn", "deprecation"} and message:
            warnings.append(message)
    if parsed is None:
        text = artifact_text(run_dir, artifact)
        errors = re.findall(r"(?im)^.*(?:uncaught|pageerror|console\.error|\berror\b).*$", text)[:20]
        warnings = re.findall(r"(?im)^.*(?:console\.warn|\bwarning\b|deprecat).*$", text)[:20]
    signals: list[dict[str, Any]] = []
    runtime_errors: list[str] = []
    missing_resources: list[str] = []
    other_resource_errors: list[str] = []
    for message in errors:
        lowered = message.lower()
        resource_failure = "failed to load resource" in lowered or (
            "resource" in lowered and re.search(r"\b[45]\d\d\b", lowered)
        )
        if resource_failure and re.search(r"\b404\b", lowered):
            missing_resources.append(message)
        elif resource_failure:
            other_resource_errors.append(message)
        else:
            runtime_errors.append(message)
    if runtime_errors:
        signals.append(issue(page, "console", "high", "Browser runtime or page errors", f"{len(runtime_errors)} runtime error signal(s). First: {runtime_errors[0][:500]}", artifact["id"]))
    if other_resource_errors:
        signals.append(issue(page, "console", "medium", "Browser resource request errors", f"{len(other_resource_errors)} resource error signal(s). First: {other_resource_errors[0][:500]}", artifact["id"], "signal"))
    if missing_resources:
        signals.append(issue(page, "console", "low", "Browser resources were not found", f"{len(missing_resources)} HTTP 404 resource signal(s). First: {missing_resources[0][:500]}", artifact["id"], "signal"))
    if warnings:
        signals.append(issue(page, "console", "low", "Browser console warnings", f"{len(warnings)} warning signal(s). First: {warnings[0][:500]}", artifact["id"], "signal"))
    return signals


def analyze_network(run_dir: Path, page: dict[str, Any], artifact: dict[str, Any]) -> list[dict[str, Any]]:
    parsed = load_artifact_json(run_dir, artifact)
    failures: list[str] = []
    server_errors: list[str] = []
    client_errors: list[str] = []
    insecure: list[str] = []
    entries: list[dict[str, Any]] = []
    if isinstance(parsed, dict) and isinstance(parsed.get("log", {}).get("entries"), list):
        entries = parsed["log"]["entries"]
    elif parsed is not None:
        entries = list(walk_dicts(parsed))
    for entry in entries:
        request = entry.get("request") if isinstance(entry.get("request"), dict) else entry
        response = entry.get("response") if isinstance(entry.get("response"), dict) else entry
        url = str(request.get("url") or entry.get("url") or "")
        status_value = response.get("status")
        try:
            status = int(status_value)
        except (TypeError, ValueError):
            status = 0
        error_value = entry.get("failure") or entry.get("errorText") or entry.get("error")
        if error_value:
            failures.append(f"{url or 'request'}: {error_value}")
        if status >= 500:
            server_errors.append(f"{status} {url}")
        elif status >= 400:
            client_errors.append(f"{status} {url}")
        if page.get("url", "").startswith("https://") and url.startswith("http://"):
            insecure.append(url)
    signals: list[dict[str, Any]] = []
    if server_errors or failures:
        sample = (server_errors + failures)[0]
        signals.append(issue(page, "network", "high", "Failed or server-error network activity", f"{len(server_errors)} HTTP 5xx and {len(failures)} failed request signal(s). First: {sample[:600]}", artifact["id"]))
    if client_errors:
        signals.append(issue(page, "network", "medium", "HTTP client errors", f"{len(client_errors)} HTTP 4xx response(s). First: {client_errors[0][:600]}", artifact["id"], "signal"))
    if insecure:
        signals.append(issue(page, "security", "high", "Mixed-content request", f"HTTPS page requested insecure HTTP resources. First: {insecure[0][:600]}", artifact["id"]))
    return signals


def analyze_accessibility(run_dir: Path, page: dict[str, Any], artifact: dict[str, Any]) -> list[dict[str, Any]]:
    parsed = load_artifact_json(run_dir, artifact)
    if parsed is None:
        return []
    violations: list[dict[str, Any]] = []
    for record in walk_dicts(parsed):
        value = record.get("violations")
        if isinstance(value, list):
            violations.extend(item for item in value if isinstance(item, dict))
    if not violations:
        return []
    counts = Counter(str(item.get("impact") or "unknown").lower() for item in violations)
    severity = "high" if counts["critical"] or counts["serious"] else "medium"
    sample = violations[0]
    return [issue(page, "accessibility", severity, "Automated accessibility violations", f"{len(violations)} violation(s): {dict(counts)}. First: {sample.get('id') or sample.get('description') or 'unnamed violation'}", artifact["id"])]


def find_metric(value: Any, names: set[str]) -> float | None:
    for record in walk_dicts(value):
        for key, item in record.items():
            normalized = re.sub(r"[^a-z]", "", str(key).lower())
            if normalized in names and isinstance(item, (int, float)):
                return float(item)
    return None


def analyze_performance(run_dir: Path, page: dict[str, Any], artifact: dict[str, Any]) -> list[dict[str, Any]]:
    parsed = load_artifact_json(run_dir, artifact)
    if parsed is None:
        return []
    metrics = {
        "LCP": (find_metric(parsed, {"lcp", "largestcontentfulpaint"}), 2500.0),
        "INP": (find_metric(parsed, {"inp", "interactiontonextpaint"}), 200.0),
        "CLS": (find_metric(parsed, {"cls", "cumulativelayoutshift"}), 0.1),
        "load": (find_metric(parsed, {"load", "loadtime", "loadeventend"}), 3000.0),
    }
    slow = [(name, value, limit) for name, (value, limit) in metrics.items() if value is not None and value > limit]
    if not slow:
        return []
    detail = ", ".join(f"{name}={value:g} (review > {limit:g})" for name, value, limit in slow)
    return [issue(page, "performance", "medium", "Performance metrics need review", detail, artifact["id"], "signal")]


def analyze_generic_text(run_dir: Path, page: dict[str, Any], artifact: dict[str, Any]) -> list[dict[str, Any]]:
    parsed = load_artifact_json(run_dir, artifact)
    errors: list[str] = []
    if parsed is not None:
        for record in walk_dicts(parsed):
            for key, value in record.items():
                normalized = str(key).lower()
                if normalized in {"error", "errors", "exception", "failure", "failures"} and value not in (None, "", False, 0, []):
                    errors.append(f"{key}: {str(value)[:400]}")
                if normalized in {"ok", "success"} and value is False:
                    errors.append(f"{key}=false")
    else:
        text = artifact_text(run_dir, artifact)
        errors = re.findall(r"(?im)^.*(?:traceback|uncaught|fatal|exception|\bfail(?:ed|ure)?\b|\berror\b).*$", text)[:20]
    if not errors:
        return []
    return [issue(page, artifact["kind"], "medium", f"{artifact['kind'].title()} artifact contains failure signals", f"{len(errors)} signal(s). First: {errors[0]}", artifact["id"], "signal")]


def analyze_page(run_dir: Path, page: dict[str, Any], state: dict[str, Any]) -> list[dict[str, Any]]:
    signals: list[dict[str, Any]] = []
    analyzers = {
        "dom": analyze_dom,
        "console": analyze_console,
        "network": analyze_network,
        "accessibility": analyze_accessibility,
        "performance": analyze_performance,
        "data": analyze_generic_text,
        "storage": analyze_generic_text,
        "other": analyze_generic_text,
    }
    for artifact in page["artifacts"]:
        analyzer = analyzers.get(artifact["kind"])
        if analyzer:
            signals.extend(analyzer(run_dir, page, artifact))
        if artifact.get("redactions"):
            signals.append(issue(page, "privacy", "low", "Sensitive telemetry was redacted", f"{artifact['redactions']} credential-like or sensitive field(s) were removed from {artifact['label']} before persistence.", artifact["id"], "observed"))
    categories = {signal["category"] for signal in signals if signal["severity"] in {"high", "medium"}}
    for review in page.get("visualReviews", []):
        if re.match(r"(?i)^\s*(?:issue|failure|problem|defect)\s*:", str(review)):
            signals.append(issue(page, "visual", "medium", "Visual review recorded a possible issue", str(review), "screenshot", "observed"))
    if "console" in categories and "network" in categories:
        signals.append(issue(page, "correlation", "high", "Console failure coincides with network failure", "Both client-runtime and request telemetry contain material failure signals on this page. Review their timestamps and the screenshot together before assigning cause.", "multiple", "correlated"))
    failed_tests = [test_id for test_id in page.get("testIds", []) if any(test.get("id") == test_id and test.get("status") == "failed" for test in state.get("tests", []))]
    if failed_tests and categories:
        signals.append(issue(page, "correlation", "high", "Artifact signals coincide with a failed test", f"Failed test(s) {', '.join(failed_tests)} share this page evidence bundle. The correlation supports investigation but does not by itself prove root cause.", "multiple", "correlated"))
    for feedback in page.get("personaFeedback", []):
        severity = str(feedback.get("severity") or "none").lower()
        frictions = feedback.get("frictions") if isinstance(feedback.get("frictions"), list) else []
        if severity in {"critical", "high", "medium", "low"} and frictions:
            signals.append(issue(
                page, "persona", severity,
                f"{feedback.get('name') or feedback.get('personaId') or 'Persona'} reported friction",
                "; ".join(str(value) for value in frictions[:6]),
                str(feedback.get("id") or "persona-feedback"),
                str(feedback.get("confidence") or "opinion"),
            ))
    for observation in page.get("explorationObservations", []):
        severity = str(observation.get("severity") or "none").lower()
        if severity in {"critical", "high", "medium", "low"}:
            signals.append(issue(
                page, "exploration", severity,
                str(observation.get("title") or "Exploratory testing observation"),
                str(observation.get("observation") or "Exploratory observation recorded."),
                str(observation.get("id") or "exploration"),
                "observed",
            ))
    deduped = {signal["id"]: signal for signal in signals}
    return sorted(deduped.values(), key=lambda value: ({"critical": 0, "high": 1, "medium": 2, "low": 3}.get(value["severity"], 4), value["category"], value["title"]))


def refresh_analysis(run_dir: Path, manifest: dict[str, Any], state: dict[str, Any]) -> None:
    all_issues: list[dict[str, Any]] = []
    counts = Counter()
    for page in manifest["pages"]:
        page["issues"] = analyze_page(run_dir, page, state)
        all_issues.extend(page["issues"])
        counts.update(artifact["kind"] for artifact in page["artifacts"])
        page["channelCoverage"] = {channel: any(artifact["kind"] == channel for artifact in page["artifacts"]) for channel in CHANNELS}
        page["evidenceGaps"] = sorted(set(page.get("unavailable", [])))
    manifest["issues"] = all_issues
    latest_feedback = {
        str(value.get("personaId")): value
        for value in manifest.get("personaFeedback", [])
        if isinstance(value, dict) and value.get("personaId")
    }
    completed_feedback = [value for value in latest_feedback.values() if value.get("status") in {"observed", "stopped", "reviewed"}]
    emotion_ratings: dict[str, float | None] = {}
    for dimension in ("joy", "happiness", "surprise"):
        values = [
            int(value["ratings"][dimension])
            for value in completed_feedback
            if isinstance(value.get("ratings"), dict) and value["ratings"].get(dimension) is not None
        ]
        emotion_ratings[dimension] = round(sum(values) / len(values), 1) if values else None
    missing_inputs = sorted({
        str(item)
        for value in latest_feedback.values()
        for item in (value.get("missingInputs") if isinstance(value.get("missingInputs"), list) else [])
        if str(item).strip()
    })
    manifest["summary"] = {
        "pages": len(manifest["pages"]),
        "artifacts": sum(len(page["artifacts"]) for page in manifest["pages"]),
        "screenshots": counts["screenshot"],
        "consoleArtifacts": counts["console"],
        "networkArtifacts": counts["network"],
        "issues": len(all_issues),
        "highSignals": sum(
            1 for value in all_issues
            if value["severity"] in {"critical", "high"}
            and value.get("category") not in {"correlation", "exploration"}
        ),
        "personaFeedback": len(manifest.get("personaFeedback", [])),
        "personasRepresented": len({str(value.get("personaId")) for value in manifest.get("personaFeedback", []) if value.get("personaId")}),
        "explorationObservations": len(manifest.get("explorationObservations", [])),
        "breadthObservations": sum(1 for value in manifest.get("explorationObservations", []) if value.get("strategy") == "breadth"),
        "depthObservations": sum(1 for value in manifest.get("explorationObservations", []) if value.get("strategy") == "depth"),
        "personaEmotionRatings": emotion_ratings,
        "personaMissingInputs": missing_inputs,
        "channels": dict(sorted(counts.items())),
    }


def attach_to_run(run_dir: Path, manifest: dict[str, Any], state: dict[str, Any], page: dict[str, Any] | None = None) -> None:
    state["evidenceSummary"] = manifest["summary"]
    state["evidenceManifest"] = "evidence/manifest.json"
    if page:
        kinds = sorted({artifact["kind"] for artifact in page["artifacts"]})
        handle = f"{page['id']} page evidence ({', '.join(kinds) or 'metadata only'})"
        for test_id in page.get("testIds", []):
            test = next((item for item in state.get("tests", []) if item.get("id") == test_id), None)
            if test is not None and handle not in test["evidence"]:
                test["evidence"].append(handle)
    state["qualityConfidence"] = assess_quality_confidence(state, manifest)
    atomic_json(run_dir / "run.json", redact(state))
    append_event(run_dir, {"kind": "page-evidence-updated", "pageId": page.get("id") if page else None, "detail": "Page evidence captured and analyzed"})
    render(run_dir, state)


def command_page(args: argparse.Namespace) -> None:
    run_dir = Path(args.run_dir).expanduser().resolve()
    if not (run_dir / "run.json").is_file():
        raise ValueError(f"Not a CARBON run directory: {run_dir}")
    with run_lock(run_dir):
        state = load_state(run_dir)
        manifest = load_manifest(run_dir)
        page_id = require_identifier(args.page_id or next_page_id(manifest), "page id")
        page = page_by_id(manifest, page_id)
        if page is None:
            page = {
                "id": page_id,
                "url": args.url,
                "title": args.title or args.url,
                "state": args.state or "observed",
                "viewport": args.viewport or "not recorded",
                "testIds": [],
                "notes": [],
                "visualReviews": [],
                "personaFeedback": [],
                "explorationObservations": [],
                "artifacts": [],
                "unavailable": [],
                "issues": [],
                "capturedAt": utc_now(),
            }
            manifest["pages"].append(page)
        else:
            page.update({key: value for key, value in {"url": args.url, "title": args.title, "state": args.state, "viewport": args.viewport}.items() if value})
        page["testIds"] = sorted(set(page.get("testIds", []) + args.test_id))
        if args.note:
            page["notes"] = list(dict.fromkeys(page.get("notes", []) + args.note))
        if args.visual_review:
            page["visualReviews"] = list(dict.fromkeys(page.get("visualReviews", []) + args.visual_review))
        page["unavailable"] = sorted(set(page.get("unavailable", []) + args.unavailable + args.sensitive_artifact))
        for kind in CHANNELS:
            for source in getattr(args, kind):
                artifact = copy_artifact(run_dir, page, kind, source)
                existing = next((item for item in page["artifacts"] if item["sha256"] == artifact["sha256"] and item["kind"] == kind), None)
                if existing:
                    (run_dir / artifact["path"]).unlink(missing_ok=True)
                else:
                    page["artifacts"].append(artifact)
        if args.account_missing:
            if not args.missing_reason or not args.missing_reason.strip():
                raise ValueError("--account-missing requires a non-empty --missing-reason")
            captured = {artifact["kind"] for artifact in page["artifacts"]}
            page["unavailable"] = sorted(set(page.get("unavailable", [])) | (set(CHANNELS) - captured))
            note = f"Unavailable evidence channels: {args.missing_reason.strip()}"
            page["notes"] = list(dict.fromkeys(page.get("notes", []) + [note]))
        refresh_analysis(run_dir, manifest, state)
        save_manifest(run_dir, manifest)
        attach_to_run(run_dir, manifest, state, page)
    print(json.dumps({"page": page, "summary": manifest["summary"], "report": str(run_dir / "report.html")}, indent=2))


def parse_ratings(values: list[str]) -> dict[str, int]:
    ratings: dict[str, int] = {}
    for value in values:
        if "=" not in value:
            raise ValueError("Ratings use dimension=1..5, for example usability=4")
        name, raw_score = value.split("=", 1)
        key = safe_slug(name.lower(), "rating")
        try:
            score = int(raw_score)
        except ValueError as error:
            raise ValueError(f"Invalid persona rating: {value}") from error
        if score < 1 or score > 5:
            raise ValueError(f"Persona rating must be between 1 and 5: {value}")
        ratings[key] = score
    return ratings


def command_strategy(args: argparse.Namespace) -> None:
    run_dir = Path(args.run_dir).expanduser().resolve()
    with run_lock(run_dir):
        state = load_state(run_dir)
        manifest = load_manifest(run_dir)
        selected = args.mode
        if selected == "auto":
            selected = str(state.get("testStrategy", {}).get("selected") or "breadth")
        manifest["strategy"] = {
            "requested": args.mode,
            "selected": selected,
            "rationale": scrub_text(args.rationale or str(state.get("testStrategy", {}).get("rationale") or ""), {"redactions": 0}),
            "updatedAt": utc_now(),
        }
        refresh_analysis(run_dir, manifest, state)
        save_manifest(run_dir, manifest)
        attach_to_run(run_dir, manifest, state)
    print(json.dumps({"strategy": manifest["strategy"], "report": str(run_dir / "report.html")}, indent=2))


def command_persona(args: argparse.Namespace) -> None:
    run_dir = Path(args.run_dir).expanduser().resolve()
    with run_lock(run_dir):
        state = load_state(run_dir)
        manifest = load_manifest(run_dir)
        page = page_by_id(manifest, args.page_id)
        if args.page_id and page is None:
            raise ValueError(f"Unknown page id for persona feedback: {args.page_id}")
        planned_personas = {str(value.get("id")) for value in state.get("personas", []) if isinstance(value, dict)}
        if args.persona_id not in planned_personas:
            raise ValueError(f"Persona {args.persona_id} was not planned for this run")
        ratings = parse_ratings(args.rating)
        if args.status in {"observed", "stopped"}:
            missing_ratings = sorted({"joy", "happiness", "surprise"} - set(ratings))
            if missing_ratings:
                raise ValueError("Observed persona journeys require 1..5 ratings for: " + ", ".join(missing_ratings))
            if not args.intent.strip() or not args.plan or not args.action or not args.outcome.strip():
                raise ValueError("Observed persona journeys require --intent, at least one --plan, at least one --action, and --outcome")
            if not any((args.positive, args.friction, args.joyful_moment, args.surprise, args.confusion, args.recommendation)):
                raise ValueError("Observed persona journeys require qualitative feedback: what worked, joy, surprise, confusion, friction, or a recommendation")
        elif args.status == "reviewed":
            if not args.intent.strip() or not args.plan or not args.action or not args.outcome.strip():
                raise ValueError("Review-only persona journeys require --intent, at least one --plan, at least one --action, and --outcome")
            if args.rating:
                raise ValueError("Review-only journeys cannot claim joy, happiness, or surprise ratings; leave them NOT MEASURED")
        elif not args.missing_input or not args.outcome.strip():
            raise ValueError("Blocked persona journeys require --missing-input and --outcome so CARBON can ask the user for useful context")
        identifier = f"PFB-{len(manifest.get('personaFeedback', [])) + 1:03d}"
        feedback = redact({
            "id": identifier,
            "personaId": args.persona_id,
            "name": args.name or args.persona_id,
            "role": args.role,
            "goal": args.goal,
            "status": args.status,
            "intent": args.intent,
            "plan": args.plan,
            "actions": args.action,
            "outcome": args.outcome,
            "pageId": page["id"] if page else None,
            "testIds": sorted(set(args.test_id)),
            "ratings": ratings,
            "positives": args.positive,
            "frictions": args.friction,
            "joyfulMoments": args.joyful_moment,
            "surprises": args.surprise,
            "confusions": args.confusion,
            "missingInputs": args.missing_input,
            "recommendations": args.recommendation,
            "evidence": args.evidence,
            "severity": args.severity,
            "confidence": args.confidence,
            "capturedAt": utc_now(),
        })
        manifest.setdefault("personaFeedback", []).append(feedback)
        if page:
            page.setdefault("personaFeedback", []).append(feedback)
            page["testIds"] = sorted(set(page.get("testIds", []) + args.test_id))
        refresh_analysis(run_dir, manifest, state)
        save_manifest(run_dir, manifest)
        attach_to_run(run_dir, manifest, state, page)
    questions = [f"To continue persona exploration, please provide or configure: {value}" for value in feedback.get("missingInputs", [])]
    print(json.dumps({"feedback": feedback, "summary": manifest["summary"], "questionsForUser": questions, "report": str(run_dir / "report.html"), "personaJourneyReport": str(run_dir / "persona-journeys.html"), "personaJourneyDocument": str(run_dir / "persona-journeys.md")}, indent=2))


def command_explore(args: argparse.Namespace) -> None:
    run_dir = Path(args.run_dir).expanduser().resolve()
    with run_lock(run_dir):
        state = load_state(run_dir)
        manifest = load_manifest(run_dir)
        page = page_by_id(manifest, args.page_id)
        if args.page_id and page is None:
            raise ValueError(f"Unknown page id for exploratory observation: {args.page_id}")
        planned_personas = {str(value.get("id")) for value in state.get("personas", []) if isinstance(value, dict)}
        if args.persona_id not in planned_personas:
            raise ValueError(f"Persona {args.persona_id} was not planned for this run")
        identifier = f"EXP-{len(manifest.get('explorationObservations', [])) + 1:03d}"
        observation = redact({
            "id": identifier,
            "personaId": args.persona_id,
            "strategy": args.strategy,
            "charterId": args.charter_id,
            "charter": args.charter,
            "title": args.title,
            "pageId": page["id"] if page else None,
            "testIds": sorted(set(args.test_id)),
            "action": args.action,
            "observation": args.observation,
            "nextStep": args.next_step,
            "coverage": args.coverage,
            "severity": args.severity,
            "status": args.status,
            "capturedAt": utc_now(),
        })
        manifest.setdefault("explorationObservations", []).append(observation)
        if page:
            page.setdefault("explorationObservations", []).append(observation)
            page["testIds"] = sorted(set(page.get("testIds", []) + args.test_id))
        refresh_analysis(run_dir, manifest, state)
        save_manifest(run_dir, manifest)
        attach_to_run(run_dir, manifest, state, page)
    print(json.dumps({"observation": observation, "summary": manifest["summary"], "report": str(run_dir / "report.html"), "personaJourneyReport": str(run_dir / "persona-journeys.html"), "personaJourneyDocument": str(run_dir / "persona-journeys.md")}, indent=2))


def command_analyze(args: argparse.Namespace) -> None:
    run_dir = Path(args.run_dir).expanduser().resolve()
    with run_lock(run_dir):
        state = load_state(run_dir)
        manifest = load_manifest(run_dir)
        refresh_analysis(run_dir, manifest, state)
        save_manifest(run_dir, manifest)
        attach_to_run(run_dir, manifest, state)
    print(json.dumps({"summary": manifest["summary"], "issues": manifest["issues"], "report": str(run_dir / "report.html")}, indent=2))


def command_show(args: argparse.Namespace) -> None:
    run_dir = Path(args.run_dir).expanduser().resolve()
    print(json.dumps(load_manifest(run_dir), indent=2))


def parser() -> argparse.ArgumentParser:
    root = argparse.ArgumentParser(description=__doc__)
    sub = root.add_subparsers(dest="command", required=True)
    page = sub.add_parser("page", help="Register or update the complete evidence bundle for one tested page/state.")
    page.add_argument("--run-dir", required=True)
    page.add_argument("--page-id")
    page.add_argument("--url", required=True)
    page.add_argument("--title")
    page.add_argument("--state")
    page.add_argument("--viewport")
    page.add_argument("--test-id", action="append", default=[])
    page.add_argument("--note", action="append", default=[])
    page.add_argument("--visual-review", action="append", default=[], help="Human/agent visual inspection of the captured screenshot, including general layout/content issues or an explicit no-issue observation.")
    page.add_argument("--unavailable", action="append", choices=CHANNELS, default=[])
    page.add_argument("--account-missing", action="store_true", help="Explicitly mark every uncaptured evidence channel unavailable in this page update.")
    page.add_argument("--missing-reason", help="Required explanation when --account-missing is used.")
    page.add_argument("--sensitive-artifact", action="append", choices=CHANNELS, default=[], help="Record a channel as deliberately withheld because it may expose private data.")
    for channel in CHANNELS:
        page.add_argument(f"--{channel}", action="append", default=[], metavar="FILE")
    page.set_defaults(function=command_page)
    strategy = sub.add_parser("strategy", help="Record the selected breadth/depth exploration strategy for this run.")
    strategy.add_argument("--run-dir", required=True)
    strategy.add_argument("--mode", choices=STRATEGIES, required=True)
    strategy.add_argument("--rationale", default="")
    strategy.set_defaults(function=command_strategy)
    persona = sub.add_parser("persona", help="Record a required intent-led persona journey for a page, API, source review, or other CARBON run.")
    persona.add_argument("--run-dir", required=True)
    persona.add_argument("--page-id")
    persona.add_argument("--persona-id", required=True)
    persona.add_argument("--name")
    persona.add_argument("--role", default="")
    persona.add_argument("--goal", default="")
    persona.add_argument("--status", choices=("observed", "stopped", "reviewed", "blocked"), default="observed")
    persona.add_argument("--intent", default="")
    persona.add_argument("--plan", action="append", default=[])
    persona.add_argument("--action", action="append", default=[])
    persona.add_argument("--outcome", default="")
    persona.add_argument("--test-id", action="append", default=[])
    persona.add_argument("--rating", action="append", default=[])
    persona.add_argument("--positive", action="append", default=[])
    persona.add_argument("--friction", action="append", default=[])
    persona.add_argument("--joyful-moment", action="append", default=[])
    persona.add_argument("--surprise", action="append", default=[])
    persona.add_argument("--confusion", action="append", default=[])
    persona.add_argument("--missing-input", action="append", default=[])
    persona.add_argument("--recommendation", action="append", default=[])
    persona.add_argument("--evidence", action="append", default=[])
    persona.add_argument("--severity", choices=("none", "low", "medium", "high", "critical"), default="none")
    persona.add_argument("--confidence", choices=("opinion", "hypothesis", "observed", "corroborated"), default="opinion")
    persona.set_defaults(function=command_persona)
    explore = sub.add_parser("explore", help="Record an intent-led persona's breadth/depth exploratory observation for a page, API, source review, or other run.")
    explore.add_argument("--run-dir", required=True)
    explore.add_argument("--page-id")
    explore.add_argument("--persona-id", required=True)
    explore.add_argument("--strategy", choices=("breadth", "depth"), required=True)
    explore.add_argument("--charter-id", required=True)
    explore.add_argument("--charter", required=True)
    explore.add_argument("--title", required=True)
    explore.add_argument("--test-id", action="append", default=[])
    explore.add_argument("--action", default="")
    explore.add_argument("--observation", required=True)
    explore.add_argument("--next-step", default="")
    explore.add_argument("--coverage", action="append", default=[])
    explore.add_argument("--severity", choices=("none", "low", "medium", "high", "critical"), default="none")
    explore.add_argument("--status", choices=("observed", "passed", "failed", "blocked", "stopped"), default="observed")
    explore.set_defaults(function=command_explore)
    analyze = sub.add_parser("analyze", help="Re-run cross-artifact issue analysis for every collected page.")
    analyze.add_argument("--run-dir", required=True)
    analyze.set_defaults(function=command_analyze)
    show = sub.add_parser("show")
    show.add_argument("--run-dir", required=True)
    show.set_defaults(function=command_show)
    return root


def main() -> int:
    try:
        args = parser().parse_args()
        args.function(args)
        return 0
    except (ValueError, OSError, json.JSONDecodeError) as error:
        print(f"carbon_evidence: {error}", file=sys.stderr)
        return 2


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