#!/usr/bin/env python3
"""GDR-8050L descriptor-builder microprobe.

This is an offline firmware CPU microprobe.  It starts the MN103S emulator at the
small descriptor-builder block around 0x9001B5EF, seeds SRAM 0x05EC with a host
LBA, and verifies that the firmware copies that LBA into descriptor SRAM 0x06E4
and arms the transfer flag at 0x06E0.

This does *not* prove the live READ/window path reaches this block.  It proves
that the block itself is a firmware descriptor builder for the LBA already
assembled at 0x05EC.  The live path proof target remains driving the natural
READ command route from 0x9001E541 into this block.
"""
from __future__ import annotations

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

try:
    from hlds_gdr_firmware_lba_cpu_smoke import (
        EXPECTED_GDR8050L_0L23_BASENAME,
        resolve_firmware,
        load_json,
        write_json,
        parse_int,
    )
except Exception:  # pragma: no cover
    EXPECTED_GDR8050L_0L23_BASENAME = "GDR-8050L_0L23_5af3ae9b_firmware_4c61eb12_plaintext_7cab0f5c.bin"
    def load_json(path: Path) -> dict[str, Any]:
        with path.open("r", encoding="utf-8") as f:
            return json.load(f)
    def write_json(path: Path, obj: Any) -> None:
        path.parent.mkdir(parents=True, exist_ok=True)
        with path.open("w", encoding="utf-8") as f:
            json.dump(obj, f, indent=2)
            f.write("\n")
    def parse_int(v: Any, default: int = 0) -> int:
        if v is None or v == "":
            return default
        if isinstance(v, int):
            return v
        return int(str(v), 0)
    def resolve_firmware(config: dict[str, Any], config_path: Path, firmware_arg: str | None) -> str:
        if firmware_arg:
            return firmware_arg
        return str(config.get("custom_bin") or EXPECTED_GDR8050L_0L23_BASENAME)

VERSION = "v250"
DESCRIPTOR_ENTRY_PC = 0x9001B5EF
DESCRIPTOR_WRITE_PC = 0x9001B5F2
TRANSFER_FLAG_PC = 0x9001B5F8
AFTER_DESCRIPTOR_CALL_PC = 0x9001B5FB
FUNCTION_RETURN_PC = 0x9001B66E
SRAM_05EC = 0x05EC
SRAM_06E0 = 0x06E0
SRAM_06E4 = 0x06E4

DEFAULT_CASES: list[tuple[str, int]] = [
    ("video_low_lba_0x00000010", 0x00000010),
    ("read12_sample_lba_0x0004ABBF", 0x0004ABBF),
    ("boundary_lba_0x00202000", 0x00202000),
    ("unlocked_max_lba_0x00345B5F", 0x00345B5F),
]


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


def hex32(v: int) -> str:
    return f"0x{v & 0xFFFFFFFF:08X}"


def lba_le(lba: int) -> list[int]:
    return [(lba >> (8 * i)) & 0xFF for i in range(4)]


def read_csv(path: Path) -> list[dict[str, str]]:
    if not path.exists():
        return []
    with path.open("r", encoding="utf-8", newline="") as f:
        return list(csv.DictReader(f))


def case_config(base_config: dict[str, Any], firmware_path: str, case_name: str, lba: int, case_dir: Path, mn103s_emu: str) -> dict[str, Any]:
    out_csv = case_dir / f"{case_name}.trace.csv"
    cfg: dict[str, Any] = {
        "test_name": f"GDR-8050L {VERSION} descriptor-builder microprobe: {case_name}",
        "workspace": base_config.get("workspace", str(Path.cwd())),
        "target_profile": "GDR-8050L",
        "custom_bin": firmware_path,
        "custom_base_addr": "0x90000000",
        "entry_point": hex32(DESCRIPTOR_ENTRY_PC),
        "max_cycles": 400,
        "header_skip_bytes": "0x0",
        "config_version": VERSION,
        "requires_emulator_version": base_config.get("requires_emulator_version", "V7-direct-config-trace-v120-gcc-version-sync-gdr163-v227-media-preflight"),
        "initial_registers": {"SP": "0x003FF000"},
        "memory_aliases": base_config.get("memory_aliases", [
            {"name": "SRAM alias 0x80000000->0x00000000", "from": "0x80000000", "to": "0x00000000", "size": "0x400000"}
        ]),
        "memory_injections": [
            {"address": hex32(SRAM_05EC), "data": lba_le(lba), "description": f"seed assembled LBA {hex32(lba)} at SRAM 0x05EC"},
            {"address": hex32(SRAM_06E0), "data": [0x00], "description": "clear transfer flag 0x06E0"},
            {"address": hex32(SRAM_06E4), "data": [0x00] * 8, "description": "clear descriptor LBA region 0x06E4..0x06EB"},
            {"address": "0x000005B2", "data": [0x00], "description": "clear status bit so post-copy helper returns quickly"},
        ],
        "trace": {
            "enabled": True,
            "live_instruction_log": False,
            "out_csv": str(out_csv),
            "watchpoints": [
                {"name": "watch_05ec_assembled_lba", "start": hex32(SRAM_05EC), "size": "0x04", "kind": "access", "description": "source LBA assembled by READ parser"},
                {"name": "watch_06e4_descriptor_lba", "start": hex32(SRAM_06E4), "size": "0x08", "kind": "access", "description": "descriptor LBA destination"},
                {"name": "watch_06e0_transfer_flag", "start": hex32(SRAM_06E0), "size": "0x01", "kind": "access", "description": "descriptor/transfer flag"},
                {"name": "watch_06f1_packet_phase", "start": "0x000006F1", "size": "0x01", "kind": "access", "description": "packet phase cleared after descriptor build"},
                {"name": "watch_da24_da3c_transfer_regs", "start": "0x0000DA24", "size": "0x20", "kind": "access", "description": "transfer-control MMIO/SRAM mirror area"},
            ],
            "breakpoints": [
                {"address": hex32(DESCRIPTOR_ENTRY_PC), "name": "descriptor_builder_entry_9001B5EF", "capture_memory": [
                    {"name": "src_05ec", "address": hex32(SRAM_05EC), "length": 4},
                    {"name": "dst_06e4", "address": hex32(SRAM_06E4), "length": 8},
                    {"name": "flag_06e0", "address": hex32(SRAM_06E0), "length": 1},
                ]},
                {"address": hex32(DESCRIPTOR_WRITE_PC), "name": "descriptor_builder_write_9001B5F2", "capture_memory": [
                    {"name": "src_05ec", "address": hex32(SRAM_05EC), "length": 4},
                    {"name": "dst_06e4", "address": hex32(SRAM_06E4), "length": 8},
                ]},
                {"address": hex32(TRANSFER_FLAG_PC), "name": "descriptor_builder_flag_9001B5F8", "capture_memory": [
                    {"name": "dst_06e4", "address": hex32(SRAM_06E4), "length": 8},
                    {"name": "flag_06e0", "address": hex32(SRAM_06E0), "length": 1},
                ]},
                {"address": hex32(AFTER_DESCRIPTOR_CALL_PC), "name": "descriptor_builder_after_copy_9001B5FB", "capture_memory": [
                    {"name": "dst_06e4", "address": hex32(SRAM_06E4), "length": 8},
                    {"name": "flag_06e0", "address": hex32(SRAM_06E0), "length": 1},
                ]},
                {"address": hex32(FUNCTION_RETURN_PC), "name": "descriptor_builder_return_9001B66E", "capture_memory": [
                    {"name": "dst_06e4", "address": hex32(SRAM_06E4), "length": 8},
                    {"name": "flag_06e0", "address": hex32(SRAM_06E0), "length": 1},
                    {"name": "phase_06f1", "address": "0x000006F1", "length": 1},
                ]},
            ],
        },
    }
    return cfg


def analyze_case(trace_csv: Path, case_name: str, lba: int, run_returncode: int, output_path: Path) -> dict[str, Any]:
    rows = read_csv(trace_csv)
    expected = lba_le(lba)
    writes_06e4 = [r for r in rows if r.get("event") == "watch_write" and r.get("name") == "watch_06e4_descriptor_lba" and norm_pc(r.get("pc")) == hex32(DESCRIPTOR_WRITE_PC)]
    # Collapse byte writes to 0x06E4..0x06E7 by address.
    by_addr: dict[int, int] = {}
    for r in writes_06e4:
        a = parse_int(r.get("addr"), -1)
        if SRAM_06E4 <= a <= (SRAM_06E4 + 3):
            by_addr[a] = parse_int(r.get("value"), 0) & 0xFF
    got = [by_addr.get(SRAM_06E4 + i) for i in range(4)]
    flag_writes = [r for r in rows if r.get("event") == "watch_write" and r.get("name") == "watch_06e0_transfer_flag" and norm_pc(r.get("pc")) == hex32(TRANSFER_FLAG_PC)]
    phase_clear = [r for r in rows if r.get("event") == "watch_write" and r.get("name") == "watch_06f1_packet_phase" and parse_int(r.get("value"), -1) == 0]
    bps = sorted({norm_pc(r.get("pc")) for r in rows if r.get("event") == "break"})
    pc_prefix = []
    seen = set()
    for r in rows:
        pc = norm_pc(r.get("pc"))
        if pc and pc not in seen and pc != "0x00000000":
            seen.add(pc)
            pc_prefix.append(pc)
        if len(pc_prefix) >= 24:
            break
    descriptor_match = got == expected
    flag_match = any(parse_int(r.get("value"), -1) == 0xF0 for r in flag_writes)
    ok = bool(rows) and descriptor_match and flag_match and run_returncode == 0
    return {
        "case": case_name,
        "input_lba": lba,
        "input_lba_hex": hex32(lba),
        "trace_csv": str(trace_csv),
        "stdout_stderr_log": str(output_path),
        "run_returncode": run_returncode,
        "row_count": len(rows),
        "breakpoint_pcs": bps,
        "pc_prefix": pc_prefix,
        "descriptor_06e4_bytes_expected": "".join(f"{b:02X}" for b in expected),
        "descriptor_06e4_bytes_written": "".join("??" if b is None else f"{b:02X}" for b in got),
        "descriptor_06e4_lba_match": descriptor_match,
        "descriptor_06e4_write_count": len(writes_06e4),
        "transfer_flag_06e0_f0": flag_match,
        "transfer_flag_write_count": len(flag_writes),
        "phase_06f1_clear_count": len(phase_clear),
        "firmware_descriptor_builder_proven_for_seeded_lba": descriptor_match and flag_match,
        "ok": ok,
    }


def run_case(mn103s_emu: Path, cfg: dict[str, Any], cfg_path: Path, output_path: Path, timeout: int) -> int:
    write_json(cfg_path, cfg)
    cmd = [sys.executable, str(mn103s_emu), str(cfg_path)]
    with output_path.open("w", encoding="utf-8", errors="replace") as f:
        try:
            p = subprocess.run(cmd, cwd=str(mn103s_emu.parent), stdout=f, stderr=subprocess.STDOUT, timeout=timeout)
            return int(p.returncode)
        except subprocess.TimeoutExpired as exc:
            f.write(f"\n[TIMEOUT] {exc}\n")
            return 124


def write_md(path: Path, report: dict[str, Any]) -> None:
    lines = [
        f"# GDR-8050L {VERSION} descriptor-builder microprobe",
        "",
        f"- ok: `{str(report.get('ok')).lower()}`",
        f"- firmware_descriptor_builder_seeded_lba_proven: `{str(report.get('firmware_descriptor_builder_seeded_lba_proven')).lower()}`",
        f"- firmware_derived_delta: `{str(report.get('firmware_derived_delta')).lower()}`",
        "",
        "## What this proves",
        "",
        "This offline CPU microprobe proves that the firmware block at `0x9001B5EF` reads the LBA from SRAM `0x05EC`, writes it into descriptor SRAM `0x06E4`, and arms transfer flag `0x06E0 = 0xF0` when the block is entered directly.",
        "",
        "## What this does not prove",
        "",
        "It does **not** prove the live READ command route reaches `0x9001B5EF`, and it does **not** promote the unlocked Redump delta to firmware-derived. The remaining target is to drive the natural firmware route from the READ CDB parser at `0x9001E541` into this descriptor builder.",
        "",
        "## Cases",
        "",
        "| case | input LBA | descriptor bytes expected | descriptor bytes written | 0x06E0=F0 | ok |",
        "|---|---:|---:|---:|---:|---:|",
    ]
    for c in report.get("cases", []) or []:
        lines.append(
            f"| `{c.get('case')}` | `{c.get('input_lba_hex')}` | `{c.get('descriptor_06e4_bytes_expected')}` | `{c.get('descriptor_06e4_bytes_written')}` | `{c.get('transfer_flag_06e0_f0')}` | `{c.get('ok')}` |"
        )
    lines += [
        "",
        "## Next local target",
        "",
        "Add a controlled continuation/hook to the READ CDB CPU sweep so the natural path after `0x9001E541` can be driven into `0x9001B5EF`, instead of only stopping at the D108 host-interface data-phase model decision.",
        "",
    ]
    path.write_text("\n".join(lines), encoding="utf-8")


def main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(description="Run GDR-8050L offline firmware descriptor-builder microprobe")
    ap.add_argument("--config", default="gdr8050l_auth_v250_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_v250_firmware_descriptor_builder_probe_report")
    ap.add_argument("--json")
    ap.add_argument("--md")
    ap.add_argument("--case-dir")
    ap.add_argument("--timeout", type=int, default=30)
    args = ap.parse_args(argv)

    base_dir = Path.cwd()
    config_path = Path(args.config)
    if not config_path.is_absolute():
        config_path = base_dir / config_path
    config = load_json(config_path) if config_path.exists() else {}
    firmware = resolve_firmware(config, config_path, args.firmware)
    firmware_path = Path(str(firmware))
    out_prefix = args.out_prefix
    json_path = Path(args.json) if args.json else base_dir / f"{out_prefix}.json"
    md_path = Path(args.md) if args.md else base_dir / f"{out_prefix}.md"
    if not json_path.is_absolute():
        json_path = base_dir / json_path
    if not md_path.is_absolute():
        md_path = base_dir / md_path
    case_dir = Path(args.case_dir) if args.case_dir else base_dir / f"{out_prefix}_cases"
    if not case_dir.is_absolute():
        case_dir = base_dir / case_dir
    case_dir.mkdir(parents=True, exist_ok=True)
    mn103s_emu = Path(args.mn103s_emu)
    if not mn103s_emu.is_absolute():
        mn103s_emu = base_dir / mn103s_emu

    cases: list[dict[str, Any]] = []
    if not firmware_path.exists():
        report = {
            "version": VERSION,
            "ok": False,
            "firmware_resolved": False,
            "firmware": str(firmware),
            "reason": "firmware_missing; run analyzer or pass --firmware",
            "firmware_derived_delta": False,
            "cases": [],
        }
        write_json(json_path, report)
        write_md(md_path, report)
        print(f"[FIRMWARE-DESCRIPTOR-BUILDER] ok=false reason=firmware_missing firmware={firmware}")
        return 1

    for case_name, lba in DEFAULT_CASES:
        cfg_path = case_dir / f"{case_name}.config.json"
        out_path = case_dir / f"{case_name}.run.log"
        trace_path = case_dir / f"{case_name}.trace.csv"
        cfg = case_config(config, str(firmware_path), case_name, lba, case_dir, str(mn103s_emu))
        rc = run_case(mn103s_emu, cfg, cfg_path, out_path, args.timeout)
        cases.append(analyze_case(trace_path, case_name, lba, rc, out_path))

    all_ok = bool(cases) and all(c.get("ok") for c in cases)
    report = {
        "version": VERSION,
        "ok": all_ok,
        "config": str(config_path),
        "firmware": str(firmware_path),
        "firmware_resolved": True,
        "mn103s_emu": str(mn103s_emu),
        "case_dir": str(case_dir),
        "case_count": len(cases),
        "passed_count": sum(1 for c in cases if c.get("ok")),
        "firmware_descriptor_builder_seeded_lba_proven": all_ok,
        "firmware_derived_delta": False,
        "proof_scope": "seeded_05ec_to_06e4_descriptor_builder_only_not_live_window_route",
        "cases": cases,
    }
    write_json(json_path, report)
    write_md(md_path, report)
    print(f"[FIRMWARE-DESCRIPTOR-BUILDER] cases={len(cases)} passed={report['passed_count']} firmware_descriptor_builder_seeded_lba_proven={str(all_ok).lower()} firmware_derived_delta=false ok={str(all_ok).lower()}")
    return 0 if all_ok else 1


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