#!/usr/bin/env python3
"""Offline MN103S firmware READ(10) LBA ingestion sweep for GDR-8050L.

This is a current-version local proof helper.  It does not change the live iSCSI
bridge.  It runs a small fleet of representative READ(10) CDBs through the
bounded MN103S packet-session harness and verifies that firmware assembles the
host LBA into SRAM 0x05EC for each case.

Scope: proves firmware-side host-LBA ingestion over multiple CDB values.  It
still does not derive the 306080 media-window delta from firmware state.
"""
from __future__ import annotations

import argparse
import csv
import json
import os
import subprocess
import sys
from copy import deepcopy
from pathlib import Path
from typing import Any

from hlds_gdr_firmware_lba_cpu_smoke import (
    ASSEMBLED_PC,
    LBA_BYTE_PCS,
    READ_HANDLER_PCS,
    load_json,
    parse_int,
    proof_breakpoints,
    proof_route_patch,
    proof_watchpoints,
    rel_or_abs,
    resolve_firmware,
    write_json,
)

VERSION = "v220"

SWEEP_CASES = [
    {"name": "video_low_lba_0x00000010", "lba": 0x00000010, "blocks": 1},
    {"name": "video_dir_lba_0x00000100", "lba": 0x00000100, "blocks": 1},
    {"name": "sample_cpu_lba_0x0004ABBF", "lba": 0x0004ABBF, "blocks": 1},
    {"name": "boundary_probe_lba_0x00202000", "lba": 0x00202000, "blocks": 1},
    {"name": "unlocked_max_lba_0x00345B5F", "lba": 0x00345B5F, "blocks": 1},
]

QUICK_CASE_NAMES = {
    "video_low_lba_0x00000010",
    "sample_cpu_lba_0x0004ABBF",
    "boundary_probe_lba_0x00202000",
}


def selected_cases(case_set: str) -> list[dict[str, Any]]:
    if case_set == "quick":
        return [c for c in SWEEP_CASES if c["name"] in QUICK_CASE_NAMES]
    return list(SWEEP_CASES)


def norm_hex(v: Any) -> str:
    try:
        return f"0x{parse_int(v):08X}"
    except Exception:
        return str(v or "")


def cdb_read10(lba: int, blocks: int = 1) -> str:
    b = [0x28, 0x00, (lba >> 24) & 0xFF, (lba >> 16) & 0xFF, (lba >> 8) & 0xFF, lba & 0xFF, 0x00, (blocks >> 8) & 0xFF, blocks & 0xFF, 0x00]
    return " ".join(f"{x:02X}" for x in b)


def read_command(name: str, lba: int, blocks: int) -> dict[str, Any]:
    return {
        "tag": name,
        "title": name,
        "direction": "in",
        "cdb_hex": cdb_read10(lba, blocks),
        "data_in_hex": " ".join(f"{i & 0xFF:02X}" for i in range(2048 * max(1, blocks))),
    }


def make_case_config(config: dict[str, Any], config_path: Path, out_prefix: Path, firmware: str, case: dict[str, Any]) -> dict[str, Any]:
    cfg = deepcopy(config)
    cfg["test_name"] = f"GDR-8050L {VERSION} offline firmware LBA CPU sweep: {case['name']}"
    cfg["config_version"] = VERSION
    cfg["requires_emulator_version"] = "V7-direct-config-trace-v117-gcc-version-sync-gdr160-v220-firmware-lba-cpu-sweep"
    cfg["custom_bin"] = firmware
    cfg["proof_firmware_image"] = firmware
    if str(firmware).startswith("/mnt/data"):
        cfg["workspace"] = "/mnt/data"
    cfg["max_cycles"] = int(parse_int(cfg.get("max_cycles"), 240000))
    cfg.setdefault("initial_registers", {})["SP"] = "0x003FF000"
    cfg.setdefault("memory_aliases", [{"name":"SRAM alias 0x80000000->0x00000000","from":"0x80000000","to":"0x00000000","size":"0x400000"}])
    trace = cfg.setdefault("trace", {})
    trace["enabled"] = True
    trace["live_instruction_log"] = False
    trace["out_csv"] = str(out_prefix.with_suffix(".trace.csv"))
    trace["watchpoints"] = proof_watchpoints()
    trace["breakpoints"] = proof_breakpoints()
    gdr = trace.setdefault("gdr_hardware_model", {})
    gdr["enabled"] = True
    gdr.setdefault("timer", {"enabled": True, "addr": "0x0000DC54", "base": "0xF000", "ticks_per_cycle": "0x2000"})
    gdr.setdefault("packet_status", {"enabled": True})
    psm = trace.get("gdr_packet_session_model") or cfg.get("gdr_packet_session_model")
    if not isinstance(psm, dict) or not psm.get("commands"):
        raise SystemExit("config does not contain a usable gdr_packet_session_model with commands")
    trace["gdr_packet_session_model"] = psm
    psm["enabled"] = True
    psm["emit_events"] = True
    psm["stop_on_complete"] = True
    psm.update(proof_route_patch())
    cmds = list(psm.get("commands") or [])
    setup = cmds[:10] if len(cmds) >= 10 else cmds
    psm["commands"] = setup + [read_command(case["name"], int(case["lba"]), int(case.get("blocks", 1)))]
    return cfg


def parse_case_trace(trace_csv: Path, case: dict[str, Any]) -> dict[str, Any]:
    rows: list[dict[str, str]] = []
    with trace_csv.open("r", encoding="utf-8", newline="") as f:
        rows = list(csv.DictReader(f))
    expected_lba = int(case["lba"])
    cdb_compact = cdb_read10(expected_lba, int(case.get("blocks", 1))).replace(" ", "").upper()
    session_complete = any(r.get("event") == "gdr_session_complete" for r in rows)
    command_complete = [r for r in rows if r.get("event") == "gdr_session_command_complete"]
    handler_hits = sorted({norm_hex(r.get("pc")) for r in rows if r.get("event") == "break" and norm_hex(r.get("pc")) in READ_HANDLER_PCS})
    lba_byte_hits = sorted({norm_hex(r.get("pc")) for r in rows if r.get("event") == "break" and norm_hex(r.get("pc")) in LBA_BYTE_PCS})
    assembled_hits = [r for r in rows if r.get("event") == "break" and norm_hex(r.get("pc")) == ASSEMBLED_PC]
    writes = []
    for r in rows:
        if r.get("event") != "watch_write" or r.get("name") != "watch_05ec_assembled_lba":
            continue
        if norm_hex(r.get("pc")) != ASSEMBLED_PC:
            continue
        addr = parse_int(r.get("addr"), -1)
        if 0x05EC <= addr <= 0x05EF:
            writes.append({
                "seq": parse_int(r.get("seq"), 0),
                "pc": norm_hex(r.get("pc")),
                "addr": f"0x{addr:08X}",
                "value": parse_int(r.get("value"), 0) & 0xFF,
            })
    last_by_addr: dict[int, dict[str, Any]] = {}
    for w in writes:
        last_by_addr[parse_int(w["addr"])] = w
    assembled_bytes = [last_by_addr.get(0x05EC + i, {}).get("value") for i in range(4)]
    if all(v is not None for v in assembled_bytes):
        assembled_lba = int(assembled_bytes[0]) | (int(assembled_bytes[1]) << 8) | (int(assembled_bytes[2]) << 16) | (int(assembled_bytes[3]) << 24)
    else:
        assembled_lba = None
    dfa0_cdb_seen = any((
        ("dfa0_cdb@0x0000DFA0" in (r.get("notes") or "") and cdb_compact in (r.get("notes") or "").replace(" ", "").upper())
        or (r.get("event") == "gdr_session_dfa0_latch" and cdb_compact in (r.get("bytes_hex") or "").replace(" ", "").upper())
    ) for r in rows)
    ok = (
        session_complete
        and bool(command_complete)
        and bool(handler_hits)
        and len(lba_byte_hits) >= 4
        and bool(assembled_hits)
        and assembled_lba == expected_lba
        and dfa0_cdb_seen
    )
    return {
        "name": case["name"],
        "expected_host_lba": expected_lba,
        "expected_host_lba_hex": f"0x{expected_lba:08X}",
        "blocks": int(case.get("blocks", 1)),
        "cdb_hex": cdb_read10(expected_lba, int(case.get("blocks", 1))),
        "trace_csv": str(trace_csv),
        "rows": len(rows),
        "session_complete": session_complete,
        "command_complete_count": len(command_complete),
        "handler_hits": handler_hits,
        "lba_byte_read_pc_hits": lba_byte_hits,
        "assembled_lba_pc_hits": sorted({norm_hex(r.get("pc")) for r in assembled_hits}),
        "dfa0_cdb_seen": dfa0_cdb_seen,
        "assembled_05ec_lba": assembled_lba,
        "assembled_05ec_lba_hex": None if assembled_lba is None else f"0x{assembled_lba:08X}",
        "assembled_05ec_bytes_le": [None if v is None else f"{int(v)&0xFF:02X}" for v in assembled_bytes],
        "assembled_05ec_match": assembled_lba == expected_lba,
        "sample_05ec_writes": writes[:8],
        "ok": ok,
    }


def write_md(path: Path, report: dict[str, Any]) -> None:
    lines = [
        "# GDR-8050L v220 firmware LBA CPU sweep proof",
        "",
        f"- ok: `{str(report.get('ok')).lower()}`",
        f"- case_count: `{report.get('case_count')}`",
        f"- passed_count: `{report.get('passed_count')}`",
        f"- failed_count: `{report.get('failed_count')}`",
        f"- firmware_derived_host_lba: `{str(report.get('firmware_derived_host_lba')).lower()}`",
        f"- firmware_derived_delta: `{str(report.get('firmware_derived_delta')).lower()}`",
        "",
        "## Scope",
        "",
        str(report.get("note") or ""),
        "",
        "## Cases",
        "",
        "| case | CDB | expected LBA | assembled 0x05EC | match | dfa0 CDB | rows |",
        "|---|---|---:|---:|---|---|---:|",
    ]
    for c in report.get("cases", []):
        lines.append(
            f"| `{c.get('name')}` | `{c.get('cdb_hex')}` | `{c.get('expected_host_lba_hex')}` | `{c.get('assembled_05ec_lba_hex')}` | `{str(c.get('assembled_05ec_match')).lower()}` | `{str(c.get('dfa0_cdb_seen')).lower()}` | {c.get('rows')} |"
        )
    failed = [c for c in report.get("cases", []) if not c.get("ok")]
    lines += ["", "## Failures", ""]
    if not failed:
        lines.append("None.")
    else:
        for c in failed:
            lines.append(f"- `{c.get('name')}`: assembled={c.get('assembled_05ec_lba_hex')} expected={c.get('expected_host_lba_hex')} dfa0={c.get('dfa0_cdb_seen')} handlers={c.get('handler_hits')}")
    lines += ["", "## Common firmware PCs", "", "- READ handler candidates: `0x9001E3E0, 0x9001E4E8, 0x9001E500`", "- CDB LBA byte loads: `0x9001E527, 0x9001E52D, 0x9001E533, 0x9001E539`", "- SRAM assembled LBA store: `0x9001E541 -> 0x000005EC..0x000005EF`", ""]
    path.write_text("\n".join(lines), encoding="utf-8")


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Run GDR-8050L v220 firmware LBA CPU sweep proof")
    ap.add_argument("--config", default="gdr8050l_auth_v220_stateful_relock_keep_loaded_iscsi_config.json")
    ap.add_argument("--firmware")
    ap.add_argument("--mn103s-emu", default="mn103s_emu.py")
    ap.add_argument("--out-prefix", default="gdr8050l_auth_v220_firmware_lba_cpu_sweep")
    ap.add_argument("--json")
    ap.add_argument("--md")
    ap.add_argument("--case-set", choices=["quick", "full"], default="full", help="quick runs 3 representative cases; full runs all 5")
    ap.add_argument("--no-run", action="store_true", help="Only parse existing case traces under <out-prefix>_cases")
    args = ap.parse_args(argv)
    cases_to_run = selected_cases(args.case_set)

    config_path = Path(args.config).resolve()
    config = load_json(config_path)
    out_prefix = Path(args.out_prefix).resolve()
    case_dir = out_prefix.parent / f"{out_prefix.name}_cases"
    case_dir.mkdir(parents=True, exist_ok=True)
    json_path = Path(args.json).resolve() if args.json else out_prefix.with_suffix(".json")
    md_path = Path(args.md).resolve() if args.md else out_prefix.with_suffix(".md")
    run_log_path = out_prefix.with_suffix(".run.log")

    firmware = resolve_firmware(config, config_path, args.firmware)
    if not firmware or not Path(str(firmware)).exists():
        report = {"version": VERSION, "ok": False, "skipped": True, "reason": f"firmware image not found: {firmware}", "firmware_derived_host_lba": False, "firmware_derived_delta": False}
        write_json(json_path, report)
        write_md(md_path, report)
        print("[FIRMWARE-LBA-CPU-SWEEP] skipped firmware_missing ok=false")
        return 2

    emu_path = rel_or_abs(args.mn103s_emu, config_path.parent)
    case_reports = []
    with run_log_path.open("w", encoding="utf-8") as log:
        for case in cases_to_run:
            safe = case["name"]
            cp = case_dir / safe
            trace_csv = cp.with_suffix(".trace.csv")
            case_config_path = cp.with_suffix(".config.json")
            if not args.no_run:
                case_cfg = make_case_config(config, config_path, cp, str(firmware), case)
                case_cfg["trace"]["out_csv"] = str(trace_csv)
                write_json(case_config_path, case_cfg)
                cmd = [sys.executable, str(emu_path), str(case_config_path)]
                log.write(f"\n===== {case['name']} =====\n")
                log.flush()
                proc = subprocess.run(cmd, cwd=str(config_path.parent), text=True, stdout=log, stderr=subprocess.STDOUT)
                if proc.returncode != 0:
                    case_reports.append({"name": case["name"], "ok": False, "returncode": proc.returncode, "trace_csv": str(trace_csv), "config": str(case_config_path)})
                    continue
            r = parse_case_trace(trace_csv, case)
            r["config"] = str(case_config_path)
            case_reports.append(r)

    passed = sum(1 for c in case_reports if c.get("ok"))
    failed = len(case_reports) - passed
    report = {
        "version": VERSION,
        "firmware": str(firmware),
        "case_set": args.case_set,
        "case_count": len(case_reports),
        "passed_count": passed,
        "failed_count": failed,
        "cases": case_reports,
        "firmware_derived_host_lba": failed == 0 and len(case_reports) == len(cases_to_run),
        "firmware_derived_delta": False,
        "ok": failed == 0 and len(case_reports) == len(cases_to_run),
        "note": "This proves firmware-side READ(10) CDB/LBA ingestion into SRAM 0x05EC across a small representative host-LBA fleet. It still does not derive the 306080 media-window delta from firmware state/MMIO/SRAM.",
        "run_log": str(run_log_path),
        "case_dir": str(case_dir),
    }
    write_json(json_path, report)
    write_md(md_path, report)
    sample = ",".join(c.get("assembled_05ec_lba_hex") or "None" for c in case_reports)
    print(f"[FIRMWARE-LBA-CPU-SWEEP] cases={len(case_reports)} passed={passed} failed={failed} assembled=[{sample}] firmware_derived_host_lba={str(report['firmware_derived_host_lba']).lower()} firmware_derived_delta=false ok={str(report['ok']).lower()}")
    return 0 if report.get("ok") else 1


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