#!/usr/bin/env python3
"""Patch an existing GDR-8050L persistent-session config so READ payloads are ISO-backed.

This is the v202 handoff helper for moving the v192/v193 proven media-window
mapping out of the focused LBA proof builder and into normal persistent-session /
Windows-iSCSI configs.

It only mutates READ(10)/READ(12) command response data in the selected config.
It does not write media, does not change firmware, and does not alter non-READ
commands.
"""
from __future__ import annotations

import argparse
import json
import os
import re
import hashlib
from pathlib import Path
from typing import Any, Iterable

TARGET_EMULATOR_VERSION = "V7-direct-config-trace-v111-gcc-version-sync-gdr153-v202-mmc-media-recognition"
TARGET_CONFIG_VERSION = "v202"
COMPATIBLE_CONFIG_VERSIONS = ["v194", "v195", "v196", "v197", "v198", "v199", "v200", "v201", "v202"]

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


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


def is_banned_firmware_path(path: Any) -> bool:
    n = _norm_path_for_compare(path)
    return any(marker.replace("/", "\\") in n for marker in BANNED_FIRMWARE_PATH_MARKERS)


def win_join(base: str, rel: str) -> str:
    if not base:
        return rel
    if base.endswith(("\\", "/")):
        return base + rel
    return base + "\\" + rel


def choose_firmware_path(cfg: dict[str, Any], explicit: str | None, *, skip_exists_check: bool) -> tuple[str, dict[str, Any]]:
    """Select the chain-produced firmware path and never preserve the old fallback."""
    original_custom = str(cfg.get("custom_bin") or "").strip()
    original_proof = str(cfg.get("proof_firmware_image") or "").strip()
    workspace = str(cfg.get("workspace") or "").strip()

    candidates: list[tuple[str, str]] = []
    if explicit:
        candidates.append(("explicit", explicit))
    if original_proof and not is_banned_firmware_path(original_proof):
        candidates.append(("proof_firmware_image", original_proof))
    if original_custom and not is_banned_firmware_path(original_custom):
        candidates.append(("custom_bin", original_custom))
    if workspace:
        candidates.append(("workspace_decrypted_payloads", win_join(workspace, CANONICAL_GDR8050L_0L23_REL)))
    candidates.append(("relative_decrypted_payloads", CANONICAL_GDR8050L_0L23_REL))

    selected_source = ""
    selected = ""
    for source, path in candidates:
        if is_banned_firmware_path(path):
            continue
        # Prefer the known chain-produced GDR-8050L filename when available.
        if CANONICAL_GDR8050L_0L23_BASENAME.lower() in _norm_path_for_compare(path):
            selected_source = source
            selected = path
            break
    if not selected:
        raise SystemExit("could not select a chain-produced GDR-8050L firmware image; pass --firmware <decrypted_payloads\\GDR-8050L_0L23_...bin>")

    if is_banned_firmware_path(selected):
        raise SystemExit(f"refusing banned fallback firmware path: {selected}")

    if not skip_exists_check and (":" not in selected[:3] or os.name == "nt"):
        # On Windows this validates D:\... paths. On non-Windows CI, callers can
        # use --skip-firmware-exists-check for path-shape/provenance tests.
        if not Path(selected).exists():
            raise SystemExit(f"selected firmware path does not exist: {selected}")

    return selected, {
        "original_custom_bin": original_custom,
        "original_proof_firmware_image": original_proof,
        "selected_custom_bin": selected,
        "selected_source": selected_source,
        "banned_fallback_detected": bool(is_banned_firmware_path(original_custom) or is_banned_firmware_path(original_proof)),
        "canonical_basename": CANONICAL_GDR8050L_0L23_BASENAME,
    }


def _strip_jsonc(text: str) -> str:
    """Small JSONC fallback: remove // and /* */ comments outside strings."""
    out = []
    i = 0
    in_str = False
    esc = False
    n = len(text)
    while i < n:
        ch = text[i]
        nxt = text[i + 1] if i + 1 < n else ""
        if in_str:
            out.append(ch)
            if esc:
                esc = False
            elif ch == "\\":
                esc = True
            elif ch == '"':
                in_str = False
            i += 1
            continue
        if ch == '"':
            in_str = True
            out.append(ch)
            i += 1
            continue
        if ch == "/" and nxt == "/":
            i += 2
            while i < n and text[i] not in "\r\n":
                i += 1
            continue
        if ch == "/" and nxt == "*":
            i += 2
            while i + 1 < n and not (text[i] == "*" and text[i + 1] == "/"):
                i += 1
            i += 2
            continue
        out.append(ch)
        i += 1
    cleaned = "".join(out)
    cleaned = re.sub(r",\s*([}\]])", r"\1", cleaned)
    return cleaned


def load_json_or_jsonc(path: Path) -> dict[str, Any]:
    text = path.read_text(encoding="utf-8")
    try:
        return json.loads(text)
    except json.JSONDecodeError:
        return json.loads(_strip_jsonc(text))


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).strip(), 0)


def parse_hex_bytes(v: Any) -> bytes:
    if v is None:
        return b""
    if isinstance(v, bytes):
        return v
    if isinstance(v, bytearray):
        return bytes(v)
    if isinstance(v, list):
        return bytes((int(x) & 0xFF) for x in v)
    s = str(v).strip()
    if not s:
        return b""
    s = s.replace("0x", "").replace("0X", "")
    s = re.sub(r"[^0-9a-fA-F]", "", s)
    if len(s) % 2:
        s = "0" + s
    return bytes.fromhex(s)


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


def find_session_model(cfg: dict[str, Any]) -> tuple[dict[str, Any], str]:
    tr = cfg.get("trace")
    if isinstance(tr, dict) and isinstance(tr.get("gdr_packet_session_model"), dict):
        return tr["gdr_packet_session_model"], "trace.gdr_packet_session_model"
    if isinstance(cfg.get("gdr_packet_session_model"), dict):
        return cfg["gdr_packet_session_model"], "gdr_packet_session_model"
    raise SystemExit("config does not contain gdr_packet_session_model commands")


def read_cdb(cmd: dict[str, Any]) -> bytes:
    return parse_hex_bytes(cmd.get("cdb_hex", cmd.get("cdb", cmd.get("cdb_bytes"))))


def decode_read(cdb: bytes) -> dict[str, int] | None:
    if not cdb:
        return None
    op = cdb[0]
    if op == 0x28 and len(cdb) >= 10:
        lba = int.from_bytes(cdb[2:6], "big")
        blocks = int.from_bytes(cdb[7:9], "big") or 65536
        return {"opcode": op, "host_lba": lba, "blocks": blocks, "cdb_len": 10}
    if op == 0xA8 and len(cdb) >= 12:
        lba = int.from_bytes(cdb[2:6], "big")
        blocks = int.from_bytes(cdb[6:10], "big") or 0x100000000
        return {"opcode": op, "host_lba": lba, "blocks": blocks, "cdb_len": 12}
    return None


def choose_media_path(cfg: dict[str, Any], explicit: str | None) -> Path:
    if explicit:
        return Path(explicit)
    media = cfg.get("media") if isinstance(cfg.get("media"), dict) else {}
    for key in ("iso_path", "image", "path", "file"):
        val = media.get(key)
        if val:
            return Path(str(val))
    raise SystemExit("media path not supplied and config.media.iso_path was not found")


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


def sha256_hex(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def patch_config(
    cfg: dict[str, Any],
    media_path: Path,
    *,
    media_lba_delta: int,
    sector_size: int,
    max_blocks: int | None,
    include_locked: bool,
    dry_run: bool,
) -> dict[str, Any]:
    model, model_path = find_session_model(cfg)
    commands = model.get("commands")
    if not isinstance(commands, list):
        raise SystemExit(f"{model_path}.commands is missing or not a list")

    report: dict[str, Any] = {
        "ok": True,
        "model_path": model_path,
        "media_path": str(media_path),
        "media_lba_delta": media_lba_delta,
        "sector_size": sector_size,
        "dry_run": bool(dry_run),
        "patched": [],
        "skipped": [],
        "failed": [],
    }

    for idx, cmd in enumerate(commands):
        if not isinstance(cmd, dict):
            continue
        cdb = read_cdb(cmd)
        r = decode_read(cdb)
        tag = str(cmd.get("tag", f"cmd{idx:03d}"))
        if not r:
            continue
        if not include_locked and "locked" in tag.lower() and "unlocked" not in tag.lower():
            report["skipped"].append({"index": idx, "tag": tag, "reason": "locked_tag"})
            continue
        blocks = int(r["blocks"])
        if max_blocks is not None and blocks > max_blocks:
            report["skipped"].append({"index": idx, "tag": tag, "reason": "blocks_gt_max", "blocks": blocks})
            continue
        host_lba = int(r["host_lba"])
        media_lba = host_lba + media_lba_delta
        try:
            data = read_media_window(media_path, media_lba, blocks, sector_size)
        except Exception as e:
            report["ok"] = False
            report["failed"].append({"index": idx, "tag": tag, "host_lba": host_lba, "media_lba": media_lba, "blocks": blocks, "error": str(e)})
            continue
        old_len = len(parse_hex_bytes(cmd.get("data_in_hex", cmd.get("data_in"))))
        item = {
            "index": idx,
            "tag": tag,
            "opcode": f"0x{r['opcode']:02X}",
            "host_lba": host_lba,
            "media_lba": media_lba,
            "blocks": blocks,
            "bytes": len(data),
            "old_data_len": old_len,
            "sha256": sha256_hex(data),
            "preview_hex": hex_bytes(data[:32]),
        }
        if not dry_run:
            cmd["direction"] = "in"
            cmd["data_in_hex"] = hex_bytes(data)
            cmd["media_source"] = {
                "source": "v202_media_window_patch",
                "media_path": str(media_path),
                "sector_size": sector_size,
                "media_lba_delta": media_lba_delta,
                "host_lba": host_lba,
                "media_lba": media_lba,
                "blocks": blocks,
                "byte_len": len(data),
                "sha256": item["sha256"],
            }
            # Keep this flat alias because v192/v193 reducers also understand it.
            cmd["media_lba"] = media_lba
            cmd["host_lba"] = host_lba
        report["patched"].append(item)

    report["patched_count"] = len(report["patched"])
    report["failed_count"] = len(report["failed"])
    report["skipped_count"] = len(report["skipped"])
    if report["failed_count"]:
        report["ok"] = False
    return report


def write_md(path: Path, report: dict[str, Any]) -> None:
    lines = []
    lines.append("# GDR media-window session patch report")
    lines.append("")
    lines.append(f"- ok: `{report.get('ok')}`")
    lines.append(f"- dry_run: `{report.get('dry_run')}`")
    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"- patched_count: `{report.get('patched_count', 0)}`")
    lines.append(f"- skipped_count: `{report.get('skipped_count', 0)}`")
    lines.append(f"- failed_count: `{report.get('failed_count', 0)}`")
    fw = report.get("firmware_provenance") or {}
    if fw:
        lines.append(f"- firmware_selected_source: `{fw.get('selected_source')}`")
        lines.append(f"- firmware_selected_custom_bin: `{fw.get('selected_custom_bin')}`")
        lines.append(f"- firmware_banned_fallback_detected: `{fw.get('banned_fallback_detected')}`")
    lines.append("")
    if report.get("patched"):
        lines.append("## Patched READ commands")
        lines.append("")
        lines.append("| index | tag | host_lba | media_lba | blocks | bytes | sha256 |")
        lines.append("|---:|---|---:|---:|---:|---:|---|")
        for it in report["patched"]:
            lines.append(f"| {it['index']} | `{it['tag']}` | {it['host_lba']} | {it['media_lba']} | {it['blocks']} | {it['bytes']} | `{it['sha256']}` |")
        lines.append("")
    if report.get("failed"):
        lines.append("## Failures")
        lines.append("")
        lines.append("| index | tag | host_lba | media_lba | blocks | error |")
        lines.append("|---:|---|---:|---:|---:|---|")
        for it in report["failed"]:
            lines.append(f"| {it.get('index')} | `{it.get('tag')}` | {it.get('host_lba')} | {it.get('media_lba')} | {it.get('blocks')} | `{it.get('error')}` |")
        lines.append("")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--config", required=True, help="Input persistent-session config JSON/JSONC")
    ap.add_argument("--out-config", required=True, help="Output patched config")
    ap.add_argument("--media", help="ISO/raw2048 image path. Defaults to config.media.iso_path")
    ap.add_argument("--firmware", help="Chain-produced GDR-8050L plaintext firmware path. Defaults to proof_firmware_image or workspace\\decrypted_payloads canonical path; banned fallback paths are never preserved.")
    ap.add_argument("--skip-firmware-exists-check", action="store_true", help="Only skip the filesystem existence check for the selected firmware path; provenance/banned-path checks still run.")
    ap.add_argument("--media-lba-delta", default="306080", help="media_lba = host_lba + delta")
    ap.add_argument("--sector-size", default=None, help="Defaults to config.media.sector_size or 2048")
    ap.add_argument("--max-blocks", default=None, help="Skip READs above this transfer length")
    ap.add_argument("--include-locked", action="store_true", help="Also patch READ tags containing 'locked'")
    ap.add_argument("--dry-run", action="store_true", help="Write reports but do not mutate READ command payloads")
    ap.add_argument("--keep-config-version", action="store_true", help="Do not retag config_version/requires_emulator_version in the output config")
    ap.add_argument("--report-json", default=None)
    ap.add_argument("--report-md", default=None)
    args = ap.parse_args()

    in_path = Path(args.config)
    cfg = load_json_or_jsonc(in_path)
    firmware_path, firmware_report = choose_firmware_path(cfg, args.firmware, skip_exists_check=bool(args.skip_firmware_exists_check))
    media_path = choose_media_path(cfg, args.media)
    sector_size = parse_int(args.sector_size, parse_int((cfg.get("media") or {}).get("sector_size"), 2048))
    delta = parse_int(args.media_lba_delta, 306080)
    max_blocks = parse_int(args.max_blocks) if args.max_blocks not in (None, "") else None

    report = patch_config(
        cfg,
        media_path,
        media_lba_delta=delta,
        sector_size=sector_size,
        max_blocks=max_blocks,
        include_locked=bool(args.include_locked),
        dry_run=bool(args.dry_run),
    )
    report["firmware_provenance"] = firmware_report

    out_path = Path(args.out_config)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    if args.dry_run:
        # Preserve the original config while still writing a copy for reproducibility.
        out_obj = load_json_or_jsonc(in_path)
    else:
        out_obj = cfg
        out_obj.setdefault("media", {})["iso_path"] = str(media_path)
        out_obj.setdefault("media", {})["sector_size"] = sector_size
        out_obj.setdefault("media", {})["gdr_media_lba_delta"] = delta
        # v202: normalize firmware provenance at the same time as media-window handoff.
        # Do not preserve legacy fallback paths from old hand-auth configs.
        out_obj["previous_custom_bin"] = out_obj.get("custom_bin")
        out_obj["custom_bin"] = firmware_path
        out_obj["proof_firmware_image"] = firmware_path
        out_obj["proof_binary_source"] = "chain_decrypted_payloads"
        out_obj["firmware_provenance"] = firmware_report
        out_obj["test_name"] = str(out_obj.get("test_name", "GDR-8050L session")) + " [v202 media-window patched; strict firmware provenance audited; live iSCSI dynamic READ backend + MMC media-recognition ready]"
        if not args.keep_config_version:
            out_obj["previous_config_version"] = out_obj.get("config_version")
            out_obj["previous_requires_emulator_version"] = out_obj.get("requires_emulator_version")
            out_obj["config_version"] = TARGET_CONFIG_VERSION
            out_obj["requires_emulator_version"] = TARGET_EMULATOR_VERSION
            out_obj["compatible_config_versions"] = COMPATIBLE_CONFIG_VERSIONS
    out_path.write_text(json.dumps(out_obj, indent=2, sort_keys=False) + "\n", encoding="utf-8")

    rj = Path(args.report_json) if args.report_json else out_path.with_suffix(".media_patch_report.json")
    rm = Path(args.report_md) if args.report_md else out_path.with_suffix(".media_patch_report.md")
    rj.write_text(json.dumps(report, indent=2, sort_keys=False) + "\n", encoding="utf-8")
    write_md(rm, report)
    print(f"[OK] wrote patched config: {out_path}")
    print(f"[OK] wrote report JSON: {rj}")
    print(f"[OK] wrote report MD: {rm}")
    print(f"[SESSION-MEDIA] patched={report.get('patched_count',0)} failed={report.get('failed_count',0)} all={bool(report.get('ok'))}")
    return 0 if report.get("ok") else 2


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