#!/usr/bin/env python3
"""Reduce GDR-8050L v192 media/source-window proof traces.

v191 proved the natural FIFO route can assemble READ LBAs.  v192 verifies the
next layer: READ payload bytes are no longer just synthetic cmd.data_in pattern
bytes, but are tied to an explicit media/source-window mapping such as:

    media_lba = host_lba + delta

Input can be a route-scan artifact directory, a .zip containing config.json and
trace.csv, a trace.csv, or a config.json.  The strongest proof comes from a
trace/config pair generated with:

    --read-data-source media --media-lba-delta <delta>

The reducer can also reread the ISO if it is accessible with --verify-iso.
"""
from __future__ import annotations

import argparse
import csv
import io
import json
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable

READ_OPS = {0x28: "READ10", 0xA8: "READ12", 0xBE: "READ_CD"}


def parse_int(v: Any, default: int | None = None) -> int:
    if v is None or v == "":
        if default is None:
            raise ValueError("empty integer")
        return default
    if isinstance(v, int):
        return v
    s = str(v).strip()
    if not s or s.lower() == "n/a":
        if default is None:
            raise ValueError("empty integer")
        return default
    return int(s, 0)


def norm_int(v: Any) -> int | None:
    try:
        return parse_int(v)
    except Exception:
        return None


def parse_bytes_hex(text: Any) -> list[int]:
    if text is None:
        return []
    if isinstance(text, (bytes, bytearray)):
        return [int(x) & 0xFF for x in text]
    if isinstance(text, list):
        out: list[int] = []
        for x in text:
            try:
                out.append(parse_int(x) & 0xFF)
            except Exception:
                pass
        return out
    out: list[int] = []
    for token in str(text).replace(",", " ").split():
        token = token.strip()
        if not token:
            continue
        try:
            out.append(int(token, 16) & 0xFF)
        except ValueError:
            try:
                out.append(parse_int(token) & 0xFF)
            except Exception:
                pass
    return out


def bytes_hex(data: Iterable[int]) -> str:
    return " ".join(f"{int(b) & 0xFF:02X}" for b in data)


def parse_notes_kv(notes: str | None) -> dict[str, str]:
    out: dict[str, str] = {}
    if not notes:
        return out
    for part in str(notes).split(";"):
        if "=" not in part:
            continue
        k, v = part.split("=", 1)
        out[k.strip()] = v.strip()
    return out


def cdb_lba_blocks(cdb: list[int]) -> tuple[int | None, int | None, str | None]:
    if not cdb:
        return None, None, None
    op = cdb[0] & 0xFF
    if op in (0x28, 0xBE) and len(cdb) >= 10:
        lba = ((cdb[2] << 24) | (cdb[3] << 16) | (cdb[4] << 8) | cdb[5]) & 0xFFFFFFFF
        blocks = ((cdb[7] << 8) | cdb[8]) & 0xFFFF
        if blocks == 0 and op == 0x28:
            blocks = 65536
        return lba, blocks, READ_OPS.get(op)
    if op == 0xA8 and len(cdb) >= 12:
        lba = ((cdb[2] << 24) | (cdb[3] << 16) | (cdb[4] << 8) | cdb[5]) & 0xFFFFFFFF
        blocks = ((cdb[6] << 24) | (cdb[7] << 16) | (cdb[8] << 8) | cdb[9]) & 0xFFFFFFFF
        return lba, blocks, READ_OPS.get(op)
    return None, None, None


def resolve_path(path_text: str | None, *, base_dir: Path | None = None) -> Path | None:
    if not path_text:
        return None
    p = Path(str(path_text))
    if p.exists():
        return p
    if base_dir is not None and not p.is_absolute():
        q = base_dir / p
        if q.exists():
            return q
    return p


@dataclass
class MediaReadProof:
    index: int
    tag: str
    opcode: int | None = None
    op_name: str | None = None
    cdb: list[int] = field(default_factory=list)
    cdb_lba: int | None = None
    blocks: int | None = None
    data_in_len_declared: int | None = None
    assembled_values: list[int] = field(default_factory=list)
    command_complete: bool = False
    d108_complete: bool = False
    data_in_source_bytes: int = 0
    data_in_bytes: list[int] = field(default_factory=list)
    media_events: list[dict[str, Any]] = field(default_factory=list)
    config_media_source: dict[str, Any] = field(default_factory=dict)
    iso_verify: dict[str, Any] | None = None

    def is_read(self) -> bool:
        return self.opcode in READ_OPS

    def assembled_match(self) -> bool:
        return self.cdb_lba is not None and bool(self.assembled_values) and self.assembled_values[-1] == self.cdb_lba

    def expected_len(self) -> int | None:
        if self.data_in_len_declared is not None:
            return self.data_in_len_declared
        if self.config_media_source.get("byte_count") is not None:
            try:
                return parse_int(self.config_media_source.get("byte_count"))
            except Exception:
                pass
        if self.blocks is not None:
            sector_size = parse_int(self.config_media_source.get("sector_size"), 2048) if self.config_media_source else 2048
            return int(self.blocks) * int(sector_size)
        return None

    def media_lba(self) -> int | None:
        if self.config_media_source.get("media_lba") is not None:
            return norm_int(self.config_media_source.get("media_lba"))
        if self.media_events:
            notes = parse_notes_kv(self.media_events[-1].get("notes"))
            return norm_int(notes.get("media_lba"))
        return None

    def delta(self) -> int | None:
        if self.config_media_source.get("delta") is not None:
            return norm_int(self.config_media_source.get("delta"))
        if self.media_events:
            notes = parse_notes_kv(self.media_events[-1].get("notes"))
            return norm_int(notes.get("delta"))
        return None

    def sample_match(self) -> bool:
        if not self.media_events:
            return False
        sample = parse_bytes_hex(self.media_events[-1].get("bytes_hex"))
        return bool(sample) and self.data_in_bytes[:len(sample)] == sample

    def formula_match(self) -> bool:
        mlba = self.media_lba()
        delta = self.delta()
        if mlba is None or delta is None or self.cdb_lba is None:
            return False
        return ((int(self.cdb_lba) + int(delta)) & 0xFFFFFFFF) == (int(mlba) & 0xFFFFFFFF)

    def status(self) -> str:
        if not self.is_read():
            return "NON_READ"
        if not self.assembled_match():
            return "LBA_NOT_PROVEN"
        if not self.media_events and not self.config_media_source:
            return "NO_MEDIA_WINDOW"
        if not self.formula_match():
            return "MEDIA_FORMULA_MISMATCH"
        if self.media_events and not self.sample_match():
            return "MEDIA_SAMPLE_MISMATCH"
        exp = self.expected_len()
        if exp is not None and self.data_in_source_bytes != exp:
            return "DATA_LEN_MISMATCH"
        if not (self.d108_complete and self.command_complete):
            return "NO_COMPLETION"
        if self.iso_verify and not self.iso_verify.get("match"):
            return "ISO_VERIFY_MISMATCH"
        return "PROVEN"

    def to_json(self) -> dict[str, Any]:
        mlba = self.media_lba()
        delta = self.delta()
        return {
            "index": self.index,
            "tag": self.tag,
            "opcode": None if self.opcode is None else f"0x{self.opcode:02X}",
            "op_name": self.op_name,
            "cdb_hex": bytes_hex(self.cdb),
            "cdb_lba": self.cdb_lba,
            "cdb_lba_hex": None if self.cdb_lba is None else f"0x{self.cdb_lba:08X}",
            "blocks": self.blocks,
            "assembled_values_hex": [f"0x{x:08X}" for x in self.assembled_values],
            "assembled_match": self.assembled_match(),
            "media_lba": mlba,
            "media_lba_hex": None if mlba is None else f"0x{mlba & 0xFFFFFFFF:08X}",
            "delta": delta,
            "formula_match": self.formula_match(),
            "config_media_source": self.config_media_source,
            "media_events": self.media_events,
            "media_sample_match": self.sample_match(),
            "data_in_source_bytes": self.data_in_source_bytes,
            "data_in_first32_hex": bytes_hex(self.data_in_bytes[:32]),
            "expected_len": self.expected_len(),
            "d108_complete": self.d108_complete,
            "command_complete": self.command_complete,
            "iso_verify": self.iso_verify,
            "status": self.status(),
        }


def read_text_file(path: Path) -> str:
    return path.read_text(encoding="utf-8", errors="ignore")


def load_csv_rows_text(text: str) -> list[dict[str, str]]:
    return [dict(r) for r in csv.DictReader(io.StringIO(text))]


def load_config_commands(cfg: dict[str, Any]) -> dict[int, dict[str, Any]]:
    sess = cfg.get("gdr_packet_session_model") if isinstance(cfg.get("gdr_packet_session_model"), dict) else {}
    out: dict[int, dict[str, Any]] = {}
    for i, cmd in enumerate(sess.get("commands") or []):
        if isinstance(cmd, dict):
            out[i] = cmd
    return out


def find_bundles(paths: list[Path]) -> list[dict[str, Any]]:
    bundles: list[dict[str, Any]] = []
    for path in paths:
        if path.is_dir():
            traces = sorted(path.rglob("trace.csv"))
            for trace in traces:
                cfg = trace.with_name("config.json")
                bundles.append({
                    "source": str(trace),
                    "trace_rows": load_csv_rows_text(read_text_file(trace)),
                    "config": json.loads(read_text_file(cfg)) if cfg.exists() else None,
                    "base_dir": trace.parent,
                })
        elif path.suffix.lower() == ".zip":
            with zipfile.ZipFile(path) as z:
                names = z.namelist()
                traces = sorted(n for n in names if n.endswith("trace.csv"))
                for tr in traces:
                    prefix = tr.rsplit("/", 1)[0]
                    cfg_name = prefix + "/config.json"
                    cfg = json.loads(z.read(cfg_name).decode("utf-8", errors="ignore")) if cfg_name in names else None
                    text = z.read(tr).decode("utf-8", errors="ignore")
                    bundles.append({"source": f"{path}!{tr}", "trace_rows": load_csv_rows_text(text), "config": cfg, "base_dir": None})
        elif path.name.lower() == "trace.csv":
            cfg = path.with_name("config.json")
            bundles.append({
                "source": str(path),
                "trace_rows": load_csv_rows_text(read_text_file(path)),
                "config": json.loads(read_text_file(cfg)) if cfg.exists() else None,
                "base_dir": path.parent,
            })
        elif path.name.lower() == "config.json":
            trace = path.with_name("trace.csv")
            bundles.append({
                "source": str(trace),
                "trace_rows": load_csv_rows_text(read_text_file(trace)) if trace.exists() else [],
                "config": json.loads(read_text_file(path)),
                "base_dir": path.parent,
            })
    return bundles


def reduce_bundle(bundle: dict[str, Any], *, verify_iso: bool, iso_override: str | None) -> dict[str, Any]:
    rows: list[dict[str, str]] = bundle.get("trace_rows") or []
    cfg: dict[str, Any] | None = bundle.get("config")
    cfg_cmds = load_config_commands(cfg or {})
    current: MediaReadProof | None = None
    commands: list[MediaReadProof] = []
    by_tag: dict[str, MediaReadProof] = {}

    def command_from_arm(idx: int, tag: str, cdb: list[int], notes: dict[str, str]) -> MediaReadProof:
        lba, blocks, opname = cdb_lba_blocks(cdb)
        cmd_cfg = cfg_cmds.get(idx, {})
        cp = MediaReadProof(index=idx, tag=tag, opcode=(cdb[0] if cdb else None), op_name=opname, cdb=cdb, cdb_lba=lba, blocks=blocks)
        if "data_in_len" in notes:
            cp.data_in_len_declared = norm_int(notes.get("data_in_len"))
        media_src = cmd_cfg.get("media_source") if isinstance(cmd_cfg, dict) else None
        if isinstance(media_src, dict):
            cp.config_media_source = media_src
        return cp

    def get_cmd(name: str) -> MediaReadProof | None:
        return by_tag.get(name) or current

    for r in rows:
        ev = r.get("event") or ""
        name = r.get("name") or ""
        value = norm_int(r.get("value"))
        pc = norm_int(r.get("pc"))
        notes = parse_notes_kv(r.get("notes"))
        if ev == "gdr_session_command_arm":
            idx = parse_int(r.get("value"), len(commands))
            cdb = parse_bytes_hex(r.get("bytes_hex"))
            current = command_from_arm(idx, name or f"cmd{idx:02d}", cdb, notes)
            commands.append(current)
            by_tag[current.tag] = current
            continue
        cmd = get_cmd(name)
        if cmd is None:
            continue
        if ev == "gdr_session_data_in_source":
            cmd.data_in_source_bytes += 1
            if value is not None:
                # v192 sweep payloads are small enough to keep full data for exact ISO/sample proof.
                cmd.data_in_bytes.append(value & 0xFF)
        elif ev == "gdr_session_media_window_source":
            cmd.media_events.append({"pc": r.get("pc"), "cycle": r.get("cycle"), "value": r.get("value"), "bytes_hex": r.get("bytes_hex"), "notes": r.get("notes")})
        elif ev == "gdr_session_d108_hw_data_phase_complete":
            cmd.d108_complete = True
        elif ev == "gdr_session_command_complete":
            cmd.command_complete = True
        elif pc == 0x9001E541 and ev == "break":
            d0 = norm_int(r.get("d0"))
            if d0 is not None:
                cmd.assembled_values.append(d0 & 0xFFFFFFFF)

    read_cmds = [c for c in commands if c.is_read()]

    if verify_iso:
        for c in read_cmds:
            src = dict(c.config_media_source or {})
            iso_text = iso_override or src.get("source")
            iso_path = resolve_path(iso_text, base_dir=bundle.get("base_dir"))
            mlba = c.media_lba()
            exp = c.expected_len()
            sector_size = parse_int(src.get("sector_size"), 2048) if src else 2048
            if not iso_path or mlba is None or exp is None:
                c.iso_verify = {"attempted": True, "match": False, "reason": "missing_iso_or_media_lba_or_expected_len"}
                continue
            try:
                with open(iso_path, "rb") as f:
                    f.seek(int(mlba) * int(sector_size))
                    data = f.read(int(exp))
                c.iso_verify = {"attempted": True, "path": str(iso_path), "bytes_read": len(data), "match": list(data) == c.data_in_bytes[:len(data)] and len(data) == exp}
            except Exception as e:
                c.iso_verify = {"attempted": True, "path": str(iso_path), "match": False, "error": str(e)}

    return {
        "source": bundle.get("source"),
        "rows": len(rows),
        "has_config": cfg is not None,
        "read_commands": [c.to_json() for c in read_cmds],
        "read_count": len(read_cmds),
        "proven_read_count": sum(1 for c in read_cmds if c.status() == "PROVEN"),
        "failed_read_count": sum(1 for c in read_cmds if c.status() != "PROVEN"),
        "all_reads_proven": bool(read_cmds) and all(c.status() == "PROVEN" for c in read_cmds),
    }


def write_md(path: Path, report: dict[str, Any]) -> None:
    lines: list[str] = []
    lines.append("# GDR-8050L v192 media/source-window report")
    lines.append("")
    lines.append(f"inputs={len(report.get('traces') or [])}")
    lines.append(f"all_media_windows_proven={report.get('all_media_windows_proven')}")
    lines.append(f"proven_read_count={report.get('proven_read_count')} failed_read_count={report.get('failed_read_count')}")
    lines.append("")
    for tr in report.get("traces") or []:
        lines.append(f"## {tr.get('source')}")
        lines.append("")
        lines.append(f"rows={tr.get('rows')} read_count={tr.get('read_count')} proven={tr.get('proven_read_count')} failed={tr.get('failed_read_count')} has_config={tr.get('has_config')}")
        lines.append("")
        lines.append("| index | tag | op | host LBA | media LBA | delta | assembled | formula | data bytes | media sample | complete | status |")
        lines.append("|---:|---|---|---:|---:|---:|---:|---:|---:|---:|---:|---|")
        for c in tr.get("read_commands") or []:
            asm = (c.get("assembled_values_hex") or [""])[-1] if c.get("assembled_values_hex") else ""
            lines.append(
                f"| {c.get('index')} | `{c.get('tag')}` | {c.get('op_name') or c.get('opcode')} | "
                f"{c.get('cdb_lba_hex') or ''} | {c.get('media_lba_hex') or ''} | {c.get('delta') if c.get('delta') is not None else ''} | "
                f"{asm} | {1 if c.get('formula_match') else 0} | {c.get('data_in_source_bytes')} | "
                f"{1 if c.get('media_sample_match') else 0} | {1 if c.get('d108_complete') and c.get('command_complete') else 0} | {c.get('status')} |"
            )
        lines.append("")
        bad = [c for c in tr.get("read_commands") or [] if c.get("status") != "PROVEN"]
        if bad:
            lines.append("### Non-proven READs")
            lines.append("")
            for c in bad:
                lines.append(f"- `{c.get('tag')}` status={c.get('status')} host={c.get('cdb_lba_hex')} media={c.get('media_lba_hex')} delta={c.get('delta')} first32={c.get('data_in_first32_hex')}")
            lines.append("")
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> int:
    ap = argparse.ArgumentParser(description="Reduce GDR-8050L v192 media/source-window proof traces")
    ap.add_argument("paths", nargs="+", type=Path, help="route-scan directory, .zip, trace.csv, or config.json")
    ap.add_argument("--json", dest="json_out", type=Path, default=Path("gdr_media_window_report.json"))
    ap.add_argument("--md", dest="md_out", type=Path, default=Path("gdr_media_window_report.md"))
    ap.add_argument("--verify-iso", action="store_true", help="reread the ISO/media image and compare full emitted READ bytes")
    ap.add_argument("--iso-path", default=None, help="ISO/media path override for --verify-iso")
    args = ap.parse_args()

    bundles = find_bundles(args.paths)
    traces = [reduce_bundle(b, verify_iso=args.verify_iso, iso_override=args.iso_path) for b in bundles]
    report = {
        "format": "hlds-gdr-media-window-report",
        "version": 1,
        "traces": traces,
        "proven_read_count": sum(t.get("proven_read_count", 0) for t in traces),
        "failed_read_count": sum(t.get("failed_read_count", 0) for t in traces),
        "all_media_windows_proven": bool(traces) and all(t.get("all_reads_proven") for t in traces),
    }
    args.json_out.parent.mkdir(parents=True, exist_ok=True)
    args.json_out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
    write_md(args.md_out, report)
    print(f"[OK] wrote {args.json_out}")
    print(f"[OK] wrote {args.md_out}")
    print(f"[MEDIA-WINDOW] proven={report['proven_read_count']} failed={report['failed_read_count']} all={report['all_media_windows_proven']}")
    return 0


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