#!/usr/bin/env python3
"""Summarize active candidate browser smoke payloads before raw cleanup."""
from __future__ import annotations

import argparse
import html
import json
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"

SEARCH_ROOTS = [
    ROOT / "web",
    ROOT / "tools",
    ROOT / "docs",
    ROOT / "out",
]

TEXT_SUFFIXES = {
    ".html",
    ".js",
    ".json",
    ".md",
    ".py",
    ".txt",
}

HEAVY_KEYS = {
    "snapshots",
    "trace",
    "events",
    "logs",
    "records",
}


def read_json(path: Path) -> dict[str, Any]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        return {
            "status": "invalid-json",
            "jsonError": str(exc),
        }
    return data if isinstance(data, dict) else {"status": "non-object-json", "valueType": type(data).__name__}


def compact_value(value: Any) -> Any:
    if value is None or isinstance(value, (bool, int, float, str)):
        return value
    if isinstance(value, list):
        return {
            "type": "list",
            "count": len(value),
        }
    if isinstance(value, dict):
        return {
            "type": "object",
            "keyCount": len(value),
            "keys": list(value.keys())[:14],
        }
    return {
        "type": type(value).__name__,
    }


def candidate_id(path: Path) -> str:
    name = path.stem
    if name.startswith("candidate_"):
        name = name[len("candidate_"):]
    if name.endswith("_browser_smoke"):
        name = name[: -len("_browser_smoke")]
    return name


def iter_text_files() -> list[Path]:
    paths: list[Path] = []
    for root in SEARCH_ROOTS:
        if not root.exists():
            continue
        for path in root.rglob("*"):
            if not path.is_file():
                continue
            if path.suffix.lower() not in TEXT_SUFFIXES:
                continue
            paths.append(path)
    return sorted(paths)


def find_consumers(file_name: str, text_files: list[Path]) -> list[str]:
    consumers: list[str] = []
    for path in text_files:
        if path.name == file_name:
            continue
        if path.name.startswith("candidate_browser_smoke_summary."):
            continue
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError:
            continue
        if file_name in text:
            consumers.append(path.relative_to(ROOT).as_posix())
    return consumers


def is_boundary_consumer(path: str) -> bool:
    """Return true for the two consumers that are allowed to keep raw smoke alive.

    Summary generators derive compact checked-in evidence from the raw browser
    smoke.  Verifiers regenerate the raw smoke.  Neither is an active web/build
    surface consumer.
    """
    return path.startswith("tools/summarize_") or path.startswith("tools/verify_")


def summarize_payload(path: Path, payload: dict[str, Any], consumers: list[str]) -> dict[str, Any]:
    status = payload.get("status")
    if status is None:
        status = "present"
    top_level_keys = list(payload.keys())
    compact_keys = [key for key in top_level_keys if key not in HEAVY_KEYS][:24]
    snapshot_count = len(payload.get("snapshots", [])) if isinstance(payload.get("snapshots"), list) else 0
    trace_count = len(payload.get("trace", [])) if isinstance(payload.get("trace"), list) else 0
    active_consumers = [item for item in consumers if item.startswith(("web/", "tools/"))]
    boundary_consumers = [item for item in active_consumers if is_boundary_consumer(item)]
    surface_consumers = [item for item in active_consumers if item not in boundary_consumers]
    archival_references = [item for item in consumers if not item.startswith(("web/", "tools/"))]
    if surface_consumers:
        replacement_state = "summary-created-raw-still-surface-consumed"
    elif boundary_consumers:
        replacement_state = "summary-created-boundary-only"
    else:
        replacement_state = "summary-created-raw-unreferenced"
    return {
        "id": candidate_id(path),
        "file": path.relative_to(ROOT).as_posix(),
        "sizeBytes": path.stat().st_size,
        "status": status,
        "topLevelKeyCount": len(top_level_keys),
        "topLevelKeys": top_level_keys,
        "snapshotCount": snapshot_count,
        "traceCount": trace_count,
        "compactFields": {key: compact_value(payload.get(key)) for key in compact_keys},
        "consumerCount": len(consumers),
        "consumers": consumers,
        "activeConsumerCount": len(active_consumers),
        "activeConsumers": active_consumers,
        "boundaryConsumerCount": len(boundary_consumers),
        "boundaryConsumers": boundary_consumers,
        "surfaceConsumerCount": len(surface_consumers),
        "surfaceConsumers": surface_consumers,
        "archivalReferenceCount": len(archival_references),
        "archivalReferences": archival_references,
        "rawStillRequired": bool(surface_consumers),
        "rawRetainedForBoundary": bool(boundary_consumers),
        "replacementState": replacement_state,
        "evidenceBoundary": (
            "browser-local prototype smoke evidence; useful for web behavior regression checks, "
            "but not original EXE runtime proof unless paired with a separate EXE-grounded artifact"
        ),
    }


def build_summary(root: Path = ROOT) -> dict[str, Any]:
    out_dir = root / "out"
    paths = sorted(out_dir.glob("candidate_*_browser_smoke.json"))
    if not paths:
        existing = out_dir / "candidate_browser_smoke_summary.json"
        if existing.exists():
            summary = json.loads(existing.read_text(encoding="utf-8"))
            rows = summary.get("rows") if isinstance(summary, dict) else None
            if isinstance(rows, list):
                removed_size = 0
                for row in rows:
                    if not isinstance(row, dict):
                        continue
                    consumers = [
                        item
                        for item in row.get("consumers", [])
                        if not isinstance(item, str) or (root / item).exists()
                    ]
                    active_consumers = [item for item in consumers if item.startswith(("web/", "tools/"))]
                    boundary_consumers = [item for item in active_consumers if is_boundary_consumer(item)]
                    surface_consumers = [item for item in active_consumers if item not in boundary_consumers]
                    archival_references = [item for item in consumers if not item.startswith(("web/", "tools/"))]
                    last_size = int(row.get("lastRecordedSizeBytes") or row.get("sizeBytes") or 0)
                    removed_size += last_size
                    row["consumerCount"] = len(consumers)
                    row["consumers"] = consumers
                    row["activeConsumerCount"] = len(active_consumers)
                    row["activeConsumers"] = active_consumers
                    row["boundaryConsumerCount"] = len(boundary_consumers)
                    row["boundaryConsumers"] = boundary_consumers
                    row["surfaceConsumerCount"] = len(surface_consumers)
                    row["surfaceConsumers"] = surface_consumers
                    row["archivalReferenceCount"] = len(archival_references)
                    row["archivalReferences"] = archival_references
                    row["rawFilePresent"] = False
                    row["lastRecordedSizeBytes"] = last_size
                    row["sizeBytes"] = 0
                    row["rawStillRequired"] = False
                    row["rawRetainedForBoundary"] = False
                    row["replacementState"] = "compact-summary-retained-raw-removed"
                summary.update({
                    "status": "raw-removed-summary-retained",
                    "source": "tools/summarize_candidate_browser_smokes.py",
                    "sourceGlob": "out/candidate_*_browser_smoke.json",
                    "rawFilesPresent": False,
                    "rawRequiredCount": 0,
                    "rawBoundaryOnlyCount": 0,
                    "activeConsumerReferenceCount": 0,
                    "boundaryConsumerReferenceCount": 0,
                    "surfaceConsumerReferenceCount": 0,
                    "totalRawSizeBytes": 0,
                    "totalRawSizeMiB": 0,
                    "totalRemovedRawSizeBytes": removed_size,
                    "totalRemovedRawSizeMiB": round(removed_size / (1024 * 1024), 3),
                    "conclusion": (
                        "The raw candidate browser smoke payloads were removed from the active git surface. "
                        "This compact summary preserves the last checked evidence rows; rerun the matching "
                        "verifier scripts locally to regenerate ignored raw smoke files when a fresh browser "
                        "regression sample is needed."
                    ),
                    "nextSteps": [
                        "Keep this compact summary as the active evidence surface.",
                        "Regenerate raw candidate smoke files locally only when investigating browser regressions.",
                        "Do not commit regenerated raw candidate smoke JSON; they are ignored verifier outputs.",
                    ],
                })
                return summary
    text_files = iter_text_files()
    rows: list[dict[str, Any]] = []
    for path in paths:
        payload = read_json(path)
        consumers = find_consumers(path.name, text_files)
        rows.append(summarize_payload(path, payload, consumers))
    total_bytes = sum(row["sizeBytes"] for row in rows)
    passed_count = sum(1 for row in rows if row["status"] in {"passed", "present"})
    raw_required_count = sum(1 for row in rows if row["rawStillRequired"])
    active_consumer_count = sum(int(row["activeConsumerCount"]) for row in rows)
    boundary_consumer_count = sum(int(row["boundaryConsumerCount"]) for row in rows)
    surface_consumer_count = sum(int(row["surfaceConsumerCount"]) for row in rows)
    boundary_only_count = sum(
        1
        for row in rows
        if row["rawRetainedForBoundary"] and not row["rawStillRequired"]
    )
    if raw_required_count:
        status = "raw-still-required"
    elif boundary_only_count:
        status = "raw-boundary-only"
    else:
        status = "raw-ready-for-removal"
    return {
        "kind": "hwanse-candidate-browser-smoke-summary",
        "source": "tools/summarize_candidate_browser_smokes.py",
        "sourceGlob": "out/candidate_*_browser_smoke.json",
        "status": status,
        "sourceCount": len(rows),
        "passedOrPresentCount": passed_count,
        "rawRequiredCount": raw_required_count,
        "rawBoundaryOnlyCount": boundary_only_count,
        "activeConsumerReferenceCount": active_consumer_count,
        "boundaryConsumerReferenceCount": boundary_consumer_count,
        "surfaceConsumerReferenceCount": surface_consumer_count,
        "totalRawSizeBytes": total_bytes,
        "totalRawSizeMiB": round(total_bytes / (1024 * 1024), 3),
        "conclusion": (
            "The raw candidate browser smoke payloads are consolidated at summary level, "
            "and no longer feed active web/build/audit surfaces when surface consumer count is zero. "
            "They remain retained only as verifier outputs and compact-summary inputs unless a row lists "
            "surface consumers. Archival docs/out references are recorded separately and do not by themselves "
            "block deletion."
        ),
        "nextSteps": [
            "Keep raw candidate smoke files only when their verifier is still an active regression guard.",
            "Use the compact per-domain summaries for web/build/audit surfaces.",
            "If a verifier is retired, delete its raw smoke, verifier script, and summary generator together in one small cleanup commit.",
            "Do not treat docs/out archival references as deletion blockers.",
        ],
        "rows": rows,
    }


def human_size(size_bytes: int) -> str:
    if size_bytes >= 1024 * 1024:
        return f"{size_bytes / (1024 * 1024):.2f} MiB"
    if size_bytes >= 1024:
        return f"{size_bytes / 1024:.1f} KiB"
    return f"{size_bytes} B"


def row_size_label(row: dict[str, Any]) -> str:
    size = int(row.get("sizeBytes") or 0)
    if size:
        return human_size(size)
    last_size = int(row.get("lastRecordedSizeBytes") or 0)
    if last_size:
        return f"removed; last {human_size(last_size)}"
    return human_size(0)


def html_page(summary: dict[str, Any]) -> str:
    rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['id'])}</code></td>"
        f"<td><code>{html.escape(row['file'])}</code></td>"
        f"<td>{html.escape(str(row['status']))}</td>"
        f"<td>{html.escape(row_size_label(row))}</td>"
        f"<td>{row['topLevelKeyCount']}</td>"
        f"<td>{row['snapshotCount']}</td>"
        f"<td>{row['traceCount']}</td>"
        f"<td>{row['activeConsumerCount']}</td>"
        f"<td>{row['boundaryConsumerCount']}</td>"
        f"<td>{row['surfaceConsumerCount']}</td>"
        f"<td>{row['archivalReferenceCount']}</td>"
        f"<td>{html.escape(row['replacementState'])}</td>"
        "</tr>"
        for row in summary["rows"]
    )
    consumer_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['file'])}</code></td>"
        f"<td>{'<br>'.join(html.escape(item) for item in row['boundaryConsumers']) or '-'}</td>"
        f"<td>{'<br>'.join(html.escape(item) for item in row['surfaceConsumers']) or '-'}</td>"
        f"<td>{'<br>'.join(html.escape(item) for item in row['archivalReferences']) or '-'}</td>"
        "</tr>"
        for row in summary["rows"]
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Candidate Browser Smoke Summary</title>
  <style>
    body {{ margin: 24px; color: #202124; font-family: system-ui, sans-serif; line-height: 1.5; }}
    table {{ width: 100%; border-collapse: collapse; margin: 14px 0 28px; }}
    th, td {{ border: 1px solid #d7dce2; padding: 6px 8px; text-align: left; vertical-align: top; }}
    th {{ background: #f4f6f8; }}
    code {{ background: #f4f6f8; padding: 1px 4px; }}
  </style>
</head>
<body>
  <h1>Candidate Browser Smoke Summary</h1>
  <p>{html.escape(summary['conclusion'])}</p>
  <table>
    <tbody>
      <tr><th>source count</th><td>{summary['sourceCount']}</td></tr>
      <tr><th>passed/present</th><td>{summary['passedOrPresentCount']}</td></tr>
      <tr><th>raw still required</th><td>{summary['rawRequiredCount']}</td></tr>
      <tr><th>raw boundary-only</th><td>{summary['rawBoundaryOnlyCount']}</td></tr>
      <tr><th>active consumer references</th><td>{summary['activeConsumerReferenceCount']}</td></tr>
      <tr><th>boundary consumer references</th><td>{summary['boundaryConsumerReferenceCount']}</td></tr>
      <tr><th>surface consumer references</th><td>{summary['surfaceConsumerReferenceCount']}</td></tr>
      <tr><th>raw total size</th><td>{html.escape(human_size(int(summary['totalRawSizeBytes'])))}</td></tr>
      <tr><th>removed raw size</th><td>{html.escape(human_size(int(summary.get('totalRemovedRawSizeBytes') or 0)))}</td></tr>
      <tr><th>status</th><td>{html.escape(summary['status'])}</td></tr>
    </tbody>
  </table>
  <h2>Rows</h2>
  <table>
    <thead><tr><th>id</th><th>file</th><th>status</th><th>size</th><th>top keys</th><th>snapshots</th><th>trace</th><th>active</th><th>boundary</th><th>surface</th><th>archival refs</th><th>replacement</th></tr></thead>
    <tbody>{rows}</tbody>
  </table>
  <h2>Raw Consumers</h2>
  <table>
    <thead><tr><th>raw file</th><th>boundary consumers</th><th>surface consumers</th><th>archival references</th></tr></thead>
    <tbody>{consumer_rows}</tbody>
  </table>
  <h2>Next Steps</h2>
  <ol>
    {''.join(f'<li>{html.escape(step)}</li>' for step in summary['nextSteps'])}
  </ol>
</body>
</html>
"""


def markdown_page(summary: dict[str, Any]) -> str:
    lines = [
        "# Candidate Browser Smoke Summary",
        "",
        summary["conclusion"],
        "",
        f"- source count: {summary['sourceCount']}",
        f"- passed/present: {summary['passedOrPresentCount']}",
        f"- raw still required: {summary['rawRequiredCount']}",
        f"- raw boundary-only: {summary['rawBoundaryOnlyCount']}",
        f"- active consumer references: {summary['activeConsumerReferenceCount']}",
        f"- boundary consumer references: {summary['boundaryConsumerReferenceCount']}",
        f"- surface consumer references: {summary['surfaceConsumerReferenceCount']}",
        f"- raw total size: {human_size(int(summary['totalRawSizeBytes']))}",
        f"- status: `{summary['status']}`",
        "",
        "| id | status | size | boundary | surface | archival refs | replacement |",
        "|---|---:|---:|---:|---:|---:|---|",
    ]
    for row in summary["rows"]:
        lines.append(
            f"| `{row['id']}` | {row['status']} | {row_size_label(row)} | "
            f"{row['boundaryConsumerCount']} | {row['surfaceConsumerCount']} | "
            f"{row['archivalReferenceCount']} | {row['replacementState']} |"
        )
    lines.extend(["", "## Next Steps", ""])
    lines.extend(f"- {step}" for step in summary["nextSteps"])
    lines.append("")
    return "\n".join(lines)


def write_outputs(summary: dict[str, Any], out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "candidate_browser_smoke_summary.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "candidate_browser_smoke_summary.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--root", type=Path, default=ROOT)
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.root)
    write_outputs(summary, args.out_dir)
    print(f"wrote candidate browser smoke summary -> {args.out_dir / 'candidate_browser_smoke_summary.html'}")


if __name__ == "__main__":
    main()
