#!/usr/bin/env python3
"""
Cyberdelia Release Diff
Compare two directory snapshots and report added, removed, changed, unchanged,
and probable-renamed files using SHA-256.

Designed for public-record / FOIA / declassification release audits.
Dependency-free Python 3.10+.
"""
from __future__ import annotations

import argparse
import csv
import fnmatch
import hashlib
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Iterable

VERSION = "0.1.0"


@dataclass(frozen=True)
class FileRecord:
    path: str
    bytes: int
    sha256: str


def digest(path: Path, chunk_size: int = 1024 * 1024) -> str:
    h = hashlib.sha256()
    with path.open("rb") as f:
        while True:
            chunk = f.read(chunk_size)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest()


def ignored(rel: str, patterns: Iterable[str]) -> bool:
    return any(fnmatch.fnmatch(rel, pattern) for pattern in patterns)


def inventory(root: Path, ignore_patterns: Iterable[str]) -> dict[str, FileRecord]:
    if not root.is_dir():
        raise ValueError(f"Not a directory: {root}")
    records: dict[str, FileRecord] = {}
    for path in sorted(p for p in root.rglob("*") if p.is_file()):
        rel = path.relative_to(root).as_posix()
        if ignored(rel, ignore_patterns):
            continue
        stat = path.stat()
        records[rel] = FileRecord(rel, stat.st_size, digest(path))
    return records


def pair_renames(
    removed: dict[str, FileRecord],
    added: dict[str, FileRecord],
) -> tuple[list[dict[str, str]], set[str], set[str]]:
    removed_by_hash: dict[str, list[FileRecord]] = {}
    added_by_hash: dict[str, list[FileRecord]] = {}
    for rec in removed.values():
        removed_by_hash.setdefault(rec.sha256, []).append(rec)
    for rec in added.values():
        added_by_hash.setdefault(rec.sha256, []).append(rec)

    renames: list[dict[str, str]] = []
    used_removed: set[str] = set()
    used_added: set[str] = set()

    for sha in sorted(set(removed_by_hash) & set(added_by_hash)):
        left = sorted(removed_by_hash[sha], key=lambda x: x.path)
        right = sorted(added_by_hash[sha], key=lambda x: x.path)
        for old, new in zip(left, right):
            renames.append({"from": old.path, "to": new.path, "sha256": sha})
            used_removed.add(old.path)
            used_added.add(new.path)

    return renames, used_removed, used_added


def compare(old_root: Path, new_root: Path, ignore_patterns: Iterable[str]) -> dict:
    old = inventory(old_root, ignore_patterns)
    new = inventory(new_root, ignore_patterns)

    old_paths = set(old)
    new_paths = set(new)
    common = old_paths & new_paths

    changed = []
    unchanged = []
    for rel in sorted(common):
        a, b = old[rel], new[rel]
        if a.sha256 == b.sha256:
            unchanged.append(asdict(b))
        else:
            changed.append({
                "path": rel,
                "old_bytes": a.bytes,
                "new_bytes": b.bytes,
                "old_sha256": a.sha256,
                "new_sha256": b.sha256,
            })

    raw_removed = {p: old[p] for p in sorted(old_paths - new_paths)}
    raw_added = {p: new[p] for p in sorted(new_paths - old_paths)}
    renames, used_removed, used_added = pair_renames(raw_removed, raw_added)

    removed = [asdict(rec) for p, rec in raw_removed.items() if p not in used_removed]
    added = [asdict(rec) for p, rec in raw_added.items() if p not in used_added]

    return {
        "tool": "Cyberdelia Release Diff",
        "version": VERSION,
        "old_root": str(old_root.resolve()),
        "new_root": str(new_root.resolve()),
        "ignore": list(ignore_patterns),
        "summary": {
            "old_files": len(old),
            "new_files": len(new),
            "added": len(added),
            "removed": len(removed),
            "changed": len(changed),
            "renamed_same_bytes": len(renames),
            "unchanged": len(unchanged),
        },
        "added": added,
        "removed": removed,
        "changed": changed,
        "renamed_same_bytes": renames,
        "unchanged": unchanged,
    }


def write_csv(report: dict, path: Path) -> None:
    rows = []
    for rec in report["added"]:
        rows.append(["added", rec["path"], "", rec["bytes"], "", rec["sha256"]])
    for rec in report["removed"]:
        rows.append(["removed", rec["path"], rec["bytes"], "", rec["sha256"], ""])
    for rec in report["changed"]:
        rows.append([
            "changed", rec["path"], rec["old_bytes"], rec["new_bytes"],
            rec["old_sha256"], rec["new_sha256"]
        ])
    for rec in report["renamed_same_bytes"]:
        rows.append(["renamed", f'{rec["from"]} -> {rec["to"]}', "", "", rec["sha256"], rec["sha256"]])
    for rec in report["unchanged"]:
        rows.append(["unchanged", rec["path"], rec["bytes"], rec["bytes"], rec["sha256"], rec["sha256"]])

    with path.open("w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["state", "path", "old_bytes", "new_bytes", "old_sha256", "new_sha256"])
        w.writerows(rows)


def parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description="Compare two public-record release directories by path, size, and SHA-256."
    )
    p.add_argument("old", nargs="?", type=Path, help="older release directory")
    p.add_argument("new", nargs="?", type=Path, help="newer release directory")
    p.add_argument("-o", "--output", type=Path, help="write JSON report to this file")
    p.add_argument("--csv", type=Path, help="also write a flat CSV report")
    p.add_argument(
        "--ignore",
        action="append",
        default=[],
        metavar="GLOB",
        help="ignore relative paths matching glob; repeatable",
    )
    p.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
    return p


def main() -> int:
    args = parser().parse_args()
    if args.old is None or args.new is None:
        parser().print_help(sys.stderr)
        return 2

    try:
        report = compare(args.old, args.new, args.ignore)
    except (OSError, ValueError) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    encoded = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
    if args.output:
        args.output.write_text(encoded, encoding="utf-8")
    else:
        sys.stdout.write(encoded)

    if args.csv:
        write_csv(report, args.csv)

    s = report["summary"]
    delta = s["added"] + s["removed"] + s["changed"] + s["renamed_same_bytes"]
    return 1 if delta else 0


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