#!/usr/bin/env python3
"""
GDR-8050L dynamic media-window responder (v199).

This helper is the bridge-facing step after the v192/v193 media-window proof and
v197 provenance guard.  It keeps the proven catalog for authentication/control
commands, but stops requiring every READ(10)/READ(12) to be pre-enumerated in a
static session config.  READ payloads are generated on demand from the selected
ISO using the current proven bridge window:

    media_lba = host_lba + media_lba_delta

It is intentionally conservative: firmware provenance is audited from the input
config, the old fallback firmware path is refused, and unsupported CDBs fail
closed unless explicitly allowed for a manual experiment.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple

CANONICAL_GDR8050L_0L23_BASENAME = "GDR-8050L_0L23_5af3ae9b_firmware_4c61eb12_plaintext_7cab0f5c.bin"
BANNED_FIRMWARE_MARKERS = (
    r"\firmware\gdr-8050l\8050l_fw_plaintext_256k.bin".lower(),
    "/firmware/gdr-8050l/8050l_fw_plaintext_256k.bin".lower(),
)


def norm_path(path: Any) -> str:
    return str(path or "").replace("/", "\\")


def firmware_basename(path: Any) -> str:
    return str(path or "").replace("\\", "/").rsplit("/", 1)[-1]


def is_banned_firmware_path(path: Any) -> bool:
    n = norm_path(path).lower()
    return any(m.replace("/", "\\") in n for m in BANNED_FIRMWARE_MARKERS)


def hex_to_bytes(text: Any) -> bytes:
    if text is None:
        return b""
    if isinstance(text, (bytes, bytearray)):
        return bytes(text)
    s = str(text).strip()
    if not s:
        return b""
    s = re.sub(r"[^0-9A-Fa-f]", "", s)
    if len(s) % 2:
        raise ValueError(f"odd-length hex text after cleanup: {text!r}")
    return bytes.fromhex(s)


def bytes_to_hex(data: bytes, limit: Optional[int] = None) -> str:
    if limit is not None:
        data = data[:limit]
    return " ".join(f"{b:02X}" for b in data)


def load_json(path: Path) -> 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, ensure_ascii=False)
        f.write("\n")


def iter_jsonl(path: Path) -> Iterable[Dict[str, Any]]:
    with path.open("r", encoding="utf-8") as f:
        for line_no, line in enumerate(f, 1):
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
            except Exception as e:
                raise ValueError(f"{path}:{line_no}: invalid JSONL: {e}") from e
            if not isinstance(obj, dict):
                raise ValueError(f"{path}:{line_no}: JSONL row is not an object")
            yield obj


def write_jsonl(path: Path, rows: Iterable[Dict[str, Any]]) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as f:
        for row in rows:
            f.write(json.dumps(row, ensure_ascii=False, separators=(",", ":")) + "\n")


def get_by_dotted(obj: Dict[str, Any], dotted: str) -> Any:
    cur: Any = obj
    for part in dotted.split("."):
        if not isinstance(cur, dict) or part not in cur:
            return None
        cur = cur[part]
    return cur


def get_session_model(cfg: Dict[str, Any], model_path: str) -> Dict[str, Any]:
    model = get_by_dotted(cfg, model_path)
    if not isinstance(model, dict):
        raise ValueError(f"session model not found at {model_path!r}")
    commands = model.get("commands")
    if not isinstance(commands, list):
        raise ValueError(f"session model at {model_path!r} has no command list")
    return model


def read_capacity_of_file(path: Path, sector_size: int) -> int:
    return path.stat().st_size // sector_size


def read_media_window(media_path: Path, media_lba: int, blocks: int, sector_size: int) -> bytes:
    if blocks < 0:
        raise ValueError("negative block count")
    if media_lba < 0:
        raise ValueError("negative media LBA")
    byte_off = media_lba * sector_size
    byte_len = blocks * sector_size
    with media_path.open("rb") as f:
        f.seek(byte_off)
        data = f.read(byte_len)
    if len(data) != byte_len:
        raise EOFError(f"short ISO read at LBA {media_lba}: got {len(data)} expected {byte_len}")
    return data


def is_read_cdb(cdb: bytes) -> bool:
    return bool(cdb) and cdb[0] in (0x28, 0xA8)


def decode_read_cdb(cdb: bytes) -> Tuple[int, int, str]:
    if not cdb:
        raise ValueError("empty CDB")
    op = cdb[0]
    if op == 0x28:
        if len(cdb) < 10:
            raise ValueError("short READ(10) CDB")
        lba = int.from_bytes(cdb[2:6], "big")
        blocks = int.from_bytes(cdb[7:9], "big")
        if blocks == 0:
            blocks = 65536
        return lba, blocks, "READ10"
    if op == 0xA8:
        if len(cdb) < 12:
            raise ValueError("short READ(12) CDB")
        lba = int.from_bytes(cdb[2:6], "big")
        blocks = int.from_bytes(cdb[6:10], "big")
        return lba, blocks, "READ12"
    raise ValueError(f"not a READ(10/12) CDB: 0x{op:02X}")


def alloc_len_for_cdb(cdb: bytes) -> Optional[int]:
    if not cdb:
        return None
    op = cdb[0]
    try:
        if op == 0x12 and len(cdb) >= 5:  # INQUIRY
            return cdb[4]
        if op == 0x03 and len(cdb) >= 5:  # REQUEST SENSE
            return cdb[4]
        if op == 0x1A and len(cdb) >= 5:  # MODE SENSE(6)
            return cdb[4]
        if op in (0x23, 0x43, 0x46, 0x4A, 0x51, 0x5A, 0xAD) and len(cdb) >= 9:
            return int.from_bytes(cdb[-3:-1] if op == 0xAD else cdb[7:9], "big")
        if op == 0xA0 and len(cdb) >= 10:  # REPORT LUNS
            return int.from_bytes(cdb[6:10], "big")
    except Exception:
        return None
    return None


def maybe_truncate_to_allocation(cdb: bytes, data: bytes, enabled: bool) -> bytes:
    if not enabled:
        return data
    alloc = alloc_len_for_cdb(cdb)
    if alloc is None or alloc == 0:
        return data
    return data[:alloc]


def command_cdb(cmd: Dict[str, Any]) -> bytes:
    return hex_to_bytes(cmd.get("cdb_hex") or cmd.get("cdb"))


def command_data_in(cmd: Dict[str, Any]) -> bytes:
    return hex_to_bytes(cmd.get("data_in_hex") or cmd.get("data_in") or cmd.get("payload_hex"))


def command_data_out(cmd: Dict[str, Any]) -> bytes:
    return hex_to_bytes(cmd.get("data_out_hex") or cmd.get("data_out"))


def build_catalog(commands: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
    out: List[Dict[str, Any]] = []
    for i, cmd in enumerate(commands):
        cdb = command_cdb(cmd)
        out.append({
            "index": i,
            "tag": cmd.get("tag") or cmd.get("title") or f"cmd_{i:03d}",
            "title": cmd.get("title") or cmd.get("tag") or f"cmd_{i:03d}",
            "direction": cmd.get("direction", "in"),
            "cmd": cmd,
            "cdb": cdb,
            "cdb_hex_norm": bytes_to_hex(cdb),
            "data_in": command_data_in(cmd),
            "data_out": command_data_out(cmd),
        })
    return out


def find_catalog_match(catalog: List[Dict[str, Any]], cdb: bytes, cursor: int, *, sequential: bool, data_out: bytes = b"") -> Tuple[Optional[Dict[str, Any]], int, int]:
    """Return (entry, new_cursor, candidates)."""
    candidates: List[int] = []
    start = cursor if sequential else 0
    order = range(start, len(catalog))
    for idx in order:
        ent = catalog[idx]
        if ent["cdb"] != cdb:
            continue
        if data_out and ent["data_out"] and ent["data_out"] != data_out:
            continue
        candidates.append(idx)
        return ent, (idx + 1 if sequential else cursor), 1
    if sequential:
        # Second pass allows later Windows probes to match reusable commands even
        # if the request stream does not include every catalog setup command.
        for idx, ent in enumerate(catalog):
            if ent["cdb"] != cdb:
                continue
            if data_out and ent["data_out"] and ent["data_out"] != data_out:
                continue
            candidates.append(idx)
            return ent, cursor, 1
    return None, cursor, len(candidates)


def enforce_config_provenance(cfg: Dict[str, Any], *, expect_firmware: str = CANONICAL_GDR8050L_0L23_BASENAME) -> Dict[str, Any]:
    fields = {
        "custom_bin": cfg.get("custom_bin", ""),
        "proof_firmware_image": cfg.get("proof_firmware_image", ""),
        "firmware_provenance.selected_custom_bin": get_by_dotted(cfg, "firmware_provenance.selected_custom_bin") or "",
    }
    failures: List[str] = []
    for name, val in fields.items():
        if not val:
            if name == "custom_bin":
                failures.append("missing custom_bin")
            continue
        if is_banned_firmware_path(val):
            failures.append(f"{name} points at banned fallback firmware: {val}")
    cb_base = firmware_basename(fields.get("custom_bin"))
    if expect_firmware and cb_base.lower() != expect_firmware.lower():
        failures.append(f"custom_bin basename {cb_base!r} does not match expected {expect_firmware!r}")
    pf = fields.get("proof_firmware_image")
    if pf and firmware_basename(pf).lower() != cb_base.lower():
        failures.append("proof_firmware_image basename does not match custom_bin")
    return {"ok": not failures, "failures": failures, "fields": fields}


def resolve_media_path(cfg: Dict[str, Any], override: str = "") -> str:
    if override:
        return override
    media = cfg.get("media") or {}
    return str(media.get("iso_path") or media.get("image") or media.get("path") or "")


def resolve_media_lba_delta(cfg: Dict[str, Any], override: Optional[int]) -> int:
    if override is not None:
        return int(override)
    media = cfg.get("media") or {}
    if "gdr_media_lba_delta" in media:
        return int(media["gdr_media_lba_delta"])
    fp = cfg.get("firmware_provenance") or {}
    if "media_lba_delta" in fp:
        return int(fp["media_lba_delta"])
    return 306080


def make_sense_illegal_request() -> bytes:
    return bytes.fromhex("70 00 05 00 00 00 00 0A 00 00 00 00 20 00 00 00 00 00")


def process_requests(
    *,
    cfg: Dict[str, Any],
    requests: Iterable[Dict[str, Any]],
    media_path: Path,
    media_lba_delta: int,
    sector_size: int,
    model_path: str,
    out_dir: Path,
    sequential_catalog: bool,
    truncate_to_alloc: bool,
    unsupported_check_condition: bool,
) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
    model = get_session_model(cfg, model_path)
    catalog = build_catalog(model.get("commands", []))
    rows: List[Dict[str, Any]] = []
    out_dir.mkdir(parents=True, exist_ok=True)
    cursor = 0
    counts = {
        "total": 0,
        "dynamic_read": 0,
        "catalog": 0,
        "none": 0,
        "failed": 0,
        "unsupported_check_condition": 0,
    }
    media_sector_count = read_capacity_of_file(media_path, sector_size) if media_path.exists() else None

    for req_i, req in enumerate(requests):
        counts["total"] += 1
        idx = req.get("index", req_i)
        tag = str(req.get("tag") or f"req_{req_i:03d}")
        row: Dict[str, Any] = {"ok": True, "index": idx, "tag": tag}
        try:
            cdb = hex_to_bytes(req.get("cdb_hex") or req.get("cdb"))
            data_out = hex_to_bytes(req.get("data_out_hex") or req.get("data_out"))
            row["cdb_hex"] = bytes_to_hex(cdb)
            row["opcode"] = f"0x{cdb[0]:02X}" if cdb else ""
            response = b""
            source = "none"
            catalog_entry = None

            if is_read_cdb(cdb):
                host_lba, blocks, read_kind = decode_read_cdb(cdb)
                media_lba = host_lba + media_lba_delta
                response = read_media_window(media_path, media_lba, blocks, sector_size)
                source = "dynamic_media_window"
                row.update({
                    "read_kind": read_kind,
                    "host_lba": host_lba,
                    "blocks": blocks,
                    "media_lba_delta": media_lba_delta,
                    "media_lba": media_lba,
                    "sector_size": sector_size,
                    "media_sector_count": media_sector_count,
                })
                counts["dynamic_read"] += 1
            else:
                catalog_entry, cursor, _cands = find_catalog_match(catalog, cdb, cursor, sequential=sequential_catalog, data_out=data_out)
                if catalog_entry is None:
                    if unsupported_check_condition:
                        response = b""
                        source = "unsupported_check_condition"
                        row.update({
                            "scsi_status": "CHECK_CONDITION",
                            "scsi_status_byte": 2,
                            "sense_hex": bytes_to_hex(make_sense_illegal_request()),
                        })
                        counts["unsupported_check_condition"] += 1
                    else:
                        raise KeyError(f"unsupported CDB and no catalog match: {bytes_to_hex(cdb)}")
                else:
                    direction = str(catalog_entry.get("direction") or "in").lower()
                    row.update({
                        "catalog_index": catalog_entry["index"],
                        "catalog_tag": catalog_entry["tag"],
                        "direction": direction,
                    })
                    if direction == "in":
                        response = catalog_entry["data_in"]
                        response = maybe_truncate_to_allocation(cdb, response, truncate_to_alloc)
                    else:
                        response = b""
                        if direction == "out":
                            row["data_out_len"] = len(data_out) or len(catalog_entry["data_out"])
                            row["data_out_sha256"] = hashlib.sha256(data_out or catalog_entry["data_out"]).hexdigest() if (data_out or catalog_entry["data_out"]) else ""
                    source = "catalog"
                    counts["catalog"] += 1

            data_file = ""
            if response:
                safe_tag = re.sub(r"[^A-Za-z0-9_.-]+", "_", tag)[:80]
                data_file = f"{int(idx) if isinstance(idx, int) else req_i:03d}_{safe_tag}.bin"
                (out_dir / data_file).write_bytes(response)
            else:
                counts["none"] += 1
            row.update({
                "source": source,
                "data_len": len(response),
                "data_sha256": hashlib.sha256(response).hexdigest() if response else "",
                "data_preview_hex": bytes_to_hex(response, 64),
                "data_file": data_file,
                "scsi_status": row.get("scsi_status", "GOOD"),
                "scsi_status_byte": row.get("scsi_status_byte", 0),
            })
        except Exception as e:
            counts["failed"] += 1
            row.update({"ok": False, "error": str(e)})
        rows.append(row)

    report = {
        "ok": counts["failed"] == 0,
        "counts": counts,
        "media_path": str(media_path),
        "media_lba_delta": media_lba_delta,
        "sector_size": sector_size,
        "model_path": model_path,
        "response_dir": str(out_dir),
    }
    return rows, report


def write_markdown(path: Path, report: Dict[str, Any], rows: List[Dict[str, Any]], provenance: Dict[str, Any]) -> None:
    lines: List[str] = []
    lines.append("# GDR dynamic media-window responder report")
    lines.append("")
    lines.append(f"- ok: `{report.get('ok')}`")
    lines.append(f"- model_path: `{report.get('model_path')}`")
    lines.append(f"- media_path: `{report.get('media_path')}`")
    lines.append(f"- media_lba_delta: `{report.get('media_lba_delta')}`")
    lines.append(f"- sector_size: `{report.get('sector_size')}`")
    lines.append(f"- provenance_ok: `{provenance.get('ok')}`")
    lines.append(f"- counts: `{report.get('counts')}`")
    lines.append("")
    if provenance.get("failures"):
        lines.append("## Provenance failures")
        for f in provenance["failures"]:
            lines.append(f"- `{f}`")
        lines.append("")
    lines.append("## Responses")
    lines.append("")
    lines.append("| index | tag | ok | source | cdb | host_lba | media_lba | blocks | bytes | sha256 |")
    lines.append("|---:|---|---|---|---|---:|---:|---:|---:|---|")
    for r in rows:
        lines.append(
            f"| {r.get('index')} | `{r.get('tag','')}` | `{r.get('ok')}` | `{r.get('source','')}` | `{r.get('cdb_hex','')}` | "
            f"{r.get('host_lba','')} | {r.get('media_lba','')} | {r.get('blocks','')} | {r.get('data_len','')} | `{r.get('data_sha256','')}` |"
        )
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main(argv: Optional[List[str]] = None) -> int:
    ap = argparse.ArgumentParser(description="Generate GDR responses with dynamic ISO-backed READ(10/12) media-window mapping")
    ap.add_argument("--config", required=True, help="v197/v198/v199 GDR session config JSON")
    ap.add_argument("--requests", required=True, help="input request JSONL containing cdb_hex rows")
    ap.add_argument("--out-dir", required=True, help="directory for response .bin files")
    ap.add_argument("--responses-jsonl", required=True, help="output response JSONL")
    ap.add_argument("--json", dest="json_out", default="", help="optional summary report JSON")
    ap.add_argument("--md", dest="md_out", default="", help="optional summary report Markdown")
    ap.add_argument("--media", default="", help="override ISO/media path; defaults to config media.iso_path")
    ap.add_argument("--media-lba-delta", type=int, default=None, help="override media LBA delta; defaults to config media.gdr_media_lba_delta or 306080")
    ap.add_argument("--sector-size", type=int, default=2048)
    ap.add_argument("--model-path", default="trace.gdr_packet_session_model")
    ap.add_argument("--expect-firmware", default=CANONICAL_GDR8050L_0L23_BASENAME)
    ap.add_argument("--match-any-order", action="store_true", help="catalog matches may reuse any matching CDB instead of advancing sequentially")
    ap.add_argument("--no-truncate-to-allocation", action="store_true", help="do not truncate catalog responses to CDB allocation length")
    ap.add_argument("--unsupported-check-condition", action="store_true", help="return CHECK CONDITION metadata instead of failing on unknown CDBs")
    args = ap.parse_args(argv)

    cfg_path = Path(args.config)
    req_path = Path(args.requests)
    cfg = load_json(cfg_path)
    provenance = enforce_config_provenance(cfg, expect_firmware=args.expect_firmware)
    if not provenance["ok"]:
        for f in provenance["failures"]:
            print(f"[-] PROVENANCE: {f}")
        return 3

    media_s = resolve_media_path(cfg, args.media)
    if not media_s:
        print("[-] FATAL: no media path supplied and config has no media.iso_path")
        return 2
    media_path = Path(media_s)
    if not media_path.exists():
        print(f"[-] FATAL: media path does not exist: {media_path}")
        return 2
    media_lba_delta = resolve_media_lba_delta(cfg, args.media_lba_delta)

    rows, report = process_requests(
        cfg=cfg,
        requests=iter_jsonl(req_path),
        media_path=media_path,
        media_lba_delta=media_lba_delta,
        sector_size=args.sector_size,
        model_path=args.model_path,
        out_dir=Path(args.out_dir),
        sequential_catalog=not args.match_any_order,
        truncate_to_alloc=not args.no_truncate_to_allocation,
        unsupported_check_condition=args.unsupported_check_condition,
    )
    report["config"] = str(cfg_path)
    report["requests"] = str(req_path)
    report["provenance"] = provenance

    write_jsonl(Path(args.responses_jsonl), rows)
    if args.json_out:
        write_json(Path(args.json_out), {**report, "rows": rows})
    if args.md_out:
        write_markdown(Path(args.md_out), report, rows, provenance)

    c = report["counts"]
    print(f"[LIVE-MEDIA] dynamic_read={c['dynamic_read']} catalog={c['catalog']} none={c['none']} failed={c['failed']} all={report['ok']}")
    if args.json_out:
        print(f"[OK] wrote report JSON: {args.json_out}")
    if args.md_out:
        print(f"[OK] wrote report MD: {args.md_out}")
    print(f"[OK] wrote responses JSONL: {args.responses_jsonl}")
    return 0 if report["ok"] else 1


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