#!/usr/bin/env python3
"""Diff two canonical harness documents (spec.md, spec_version 1).

    compare.py old.json new.json [--allowlist allowlist.json]

Exit 0 = green (every difference matched an allowlist entry),
exit 1 = red, exit 2 = usage/load error. The report lists every diff
with its path, both values, and — when allowed — the allowlist reason.
"""

from __future__ import annotations

import argparse
import json
import sys
from dataclasses import dataclass, field
from fnmatch import fnmatchcase
from pathlib import Path

DEFAULT_ALLOWLIST = Path(__file__).resolve().parent / "allowlist.json"
_MISSING = object()
_SHOW_LIMIT = 20  # cap rendered list elements per diff


@dataclass
class Diff:
    path: str
    kind: str  # changed | only_in_old | only_in_new
    old: object
    new: object
    allowed: bool = False
    reason: str | None = None


@dataclass
class Report:
    diffs: list[Diff] = field(default_factory=list)
    stale_patterns: list[str] = field(default_factory=list)

    @property
    def failures(self) -> list[Diff]:
        return [d for d in self.diffs if not d.allowed]

    @property
    def ok(self) -> bool:
        return not self.failures


def _is_scalar_list(value: object) -> bool:
    return isinstance(value, list) and all(
        isinstance(v, (str, int, bool)) or v is None for v in value)


def flatten(doc: object, prefix: str = "") -> dict[str, object]:
    """Leaf paths joined with '/'. Scalar lists stay whole (diffed as
    added/removed sets); lists of objects recurse by index."""
    if isinstance(doc, dict):
        out: dict[str, object] = {}
        for key, value in doc.items():
            out.update(flatten(value, f"{prefix}/{key}" if prefix else str(key)))
        return out
    if isinstance(doc, list) and not _is_scalar_list(doc):
        out = {}
        for i, value in enumerate(doc):
            out.update(flatten(value, f"{prefix}/{i}"))
        return out
    return {prefix: doc}


def load_allowlist(path: Path) -> list[dict]:
    data = json.loads(path.read_text(encoding="utf-8"))
    entries = data["entries"]
    for entry in entries:
        if not entry.get("path") or not entry.get("reason"):
            raise ValueError(f"allowlist entry needs path + reason: {entry}")
    return entries


def run_compare(old_doc: dict, new_doc: dict, allowlist: list[dict]) -> Report:
    old_flat, new_flat = flatten(old_doc), flatten(new_doc)
    report = Report()
    used = set()
    for path in sorted(old_flat.keys() | new_flat.keys()):
        old = old_flat.get(path, _MISSING)
        new = new_flat.get(path, _MISSING)
        if old == new:
            continue
        if old is _MISSING:
            diff = Diff(path, "only_in_new", None, new)
        elif new is _MISSING:
            diff = Diff(path, "only_in_old", old, None)
        else:
            diff = Diff(path, "changed", old, new)
        for entry in allowlist:
            if fnmatchcase(path, entry["path"]):
                diff.allowed, diff.reason = True, entry["reason"]
                used.add(entry["path"])
                break
        report.diffs.append(diff)
    report.stale_patterns = [e["path"] for e in allowlist
                             if e["path"] not in used]
    return report


def _render_value(value: object) -> str:
    if isinstance(value, list):
        shown = ", ".join(json.dumps(v, ensure_ascii=False)
                          for v in value[:_SHOW_LIMIT])
        more = f", … +{len(value) - _SHOW_LIMIT}" if len(value) > _SHOW_LIMIT else ""
        return f"[{len(value)} items: {shown}{more}]"
    return json.dumps(value, ensure_ascii=False)


def _render_diff(diff: Diff) -> str:
    if diff.kind == "changed" and isinstance(diff.old, list) and isinstance(diff.new, list):
        removed = [v for v in diff.old if v not in diff.new]
        added = [v for v in diff.new if v not in diff.old]
        detail = (f"removed {_render_value(removed)}, added {_render_value(added)}"
                  if removed or added else "reordered")
        return f"  {diff.path}\n    {detail}"
    if diff.kind == "only_in_old":
        return f"  {diff.path}\n    only in OLD: {_render_value(diff.old)}"
    if diff.kind == "only_in_new":
        return f"  {diff.path}\n    only in NEW: {_render_value(diff.new)}"
    return (f"  {diff.path}\n    old: {_render_value(diff.old)}"
            f"\n    new: {_render_value(diff.new)}")


def render_report(report: Report, old_meta: dict, new_meta: dict) -> str:
    lines = [
        "diff-harness compare",
        f"  old: {old_meta.get('source_mode')} extracted {old_meta.get('extracted_at')}",
        f"  new: {new_meta.get('source_mode')} extracted {new_meta.get('extracted_at')}",
        "",
    ]
    failures = report.failures
    allowed = [d for d in report.diffs if d.allowed]
    if failures:
        lines.append(f"FAILURES ({len(failures)}):")
        lines += [_render_diff(d) for d in failures]
        lines.append("")
    if allowed:
        lines.append(f"allowed diffs ({len(allowed)}):")
        for d in allowed:
            lines.append(f"  {d.path}  [{d.reason}]")
        lines.append("")
    if report.stale_patterns:
        lines.append("WARNING stale allowlist patterns (matched nothing):")
        lines += [f"  {p}" for p in report.stale_patterns]
        lines.append("")
    lines.append("RESULT: GREEN — no unexplained differences" if report.ok
                 else f"RESULT: RED — {len(failures)} unexplained difference(s)")
    return "\n".join(lines)


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Compare two canonical harness JSON documents.")
    parser.add_argument("old", help="old-side canonical JSON")
    parser.add_argument("new", help="new-side canonical JSON")
    parser.add_argument("--allowlist", type=Path, default=DEFAULT_ALLOWLIST)
    args = parser.parse_args(argv)

    try:
        old_doc = json.loads(Path(args.old).read_text(encoding="utf-8"))
        new_doc = json.loads(Path(args.new).read_text(encoding="utf-8"))
        allowlist = load_allowlist(args.allowlist)
    except (OSError, ValueError, KeyError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    report = run_compare(old_doc, new_doc, allowlist)
    print(render_report(report, old_doc.get("meta", {}), new_doc.get("meta", {})))
    return 0 if report.ok else 1


if __name__ == "__main__":
    sys.exit(main())
