#!/usr/bin/env python3
"""Verify GDR/XGD1 sidecar payload integrity and READ DVD STRUCTURE trace routing.

v227 adds a lightweight media-payload integrity gate for the corrected Redump
media-profile path.  It does not change live iSCSI behavior.  The report is
intended to catch mismatches between:
  * .media.json/.xdvd.json payload sidecars (AD C0, PFI, DMI)
  * hashes/sizes declared by the dumper when present
  * READ DVD STRUCTURE responses actually served by the bridge trace

Full ISO hashing is optional via --hash-image because Redump-style images are
~7.29 GiB and normal collect_logs should stay quick.
"""
from __future__ import annotations

import argparse
import binascii
import hashlib
import json
from pathlib import Path
from typing import Any

from gdr_media_model import GDRMediaModel

PAYLOAD_EXPECTED_SIZES = {
    'ad_c0': 1636,
    'pfi': 2048,
    'dmi': 2048,
}
PAYLOAD_LABELS = {
    'ad_c0': 'AD C0 / Xbox control block',
    'pfi': 'PFI / physical format information',
    'dmi': 'DMI / disc manufacturing information',
}


def read_json(path: Path | None) -> dict[str, Any]:
    if not path or not path.exists():
        return {}
    try:
        return json.loads(path.read_text(encoding='utf-8'))
    except Exception:
        return {}


def load_jsonl(path: Path | None) -> list[dict[str, Any]]:
    if not path or not path.exists():
        return []
    out: list[dict[str, Any]] = []
    with path.open('r', encoding='utf-8', errors='replace') as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                obj = json.loads(line)
                if isinstance(obj, dict):
                    out.append(obj)
            except Exception:
                continue
    return out


def bytes_from_row(row: dict[str, Any], response_dir: Path | None) -> bytes | None:
    hx = str(row.get('data_hex') or '').strip()
    if hx:
        try:
            return bytes.fromhex(hx.replace(' ', ''))
        except Exception:
            return None
    fn = row.get('data_file')
    if response_dir and fn:
        p = response_dir / str(fn)
        if p.exists():
            return p.read_bytes()
    # Some traces include only a preview.  Do not use it for exact compare.
    return None


def digest_bytes(data: bytes) -> dict[str, str]:
    return {
        'crc32': f'{binascii.crc32(data) & 0xFFFFFFFF:08x}',
        'md5': hashlib.md5(data).hexdigest(),
        'sha1': hashlib.sha1(data).hexdigest(),
        'sha256': hashlib.sha256(data).hexdigest(),
    }


def digest_file(path: Path, include_sha256: bool = True) -> dict[str, str]:
    crc = 0
    md5 = hashlib.md5()
    sha1 = hashlib.sha1()
    sha256 = hashlib.sha256() if include_sha256 else None
    with path.open('rb') as fh:
        while True:
            chunk = fh.read(1024 * 1024)
            if not chunk:
                break
            crc = binascii.crc32(chunk, crc)
            md5.update(chunk)
            sha1.update(chunk)
            if sha256 is not None:
                sha256.update(chunk)
    out = {'crc32': f'{crc & 0xFFFFFFFF:08x}', 'md5': md5.hexdigest(), 'sha1': sha1.hexdigest()}
    if sha256 is not None:
        out['sha256'] = sha256.hexdigest()
    return out


def norm_hex_digest(value: Any) -> str:
    return str(value or '').strip().lower().replace(' ', '').replace(':', '')


def first_present(*values: Any) -> Any:
    for v in values:
        if v not in (None, ''):
            return v
    return None


def obj_from_aliases(doc: dict[str, Any], key: str) -> dict[str, Any]:
    """Find a flexible metadata object for a payload key in media/xdvd JSON."""
    aliases = {
        'ad_c0': ['ad_c0', 'xbox_control_block', 'read_dvd_structure_xbox_control_block', 'read_dvd_structure_ad_c0'],
        'pfi': ['pfi', 'physical_format_information', 'read_dvd_structure_pfi'],
        'dmi': ['dmi', 'disc_manufacturing_information', 'read_dvd_structure_dmi'],
    }[key]
    roots: list[Any] = []
    for root_key in ('dvd_structures', 'payloads', 'sidecars', 'files'):
        root = doc.get(root_key)
        if isinstance(root, dict):
            roots.append(root)
    for root in roots:
        for a in aliases:
            val = root.get(a)
            if isinstance(val, dict):
                return val
            if isinstance(val, str):
                return {'file': val}
    for rec in doc.get('scsi_responses') or []:
        if not isinstance(rec, dict):
            continue
        name = str(rec.get('name') or '').lower()
        if name in aliases:
            return rec
    return {}


def expected_payload_meta(media_doc: dict[str, Any], xdvd_doc: dict[str, Any], key: str) -> dict[str, Any]:
    obj = obj_from_aliases(media_doc, key) or obj_from_aliases(xdvd_doc, key)
    hashes = obj.get('hashes') if isinstance(obj.get('hashes'), dict) else {}
    size = first_present(obj.get('size'), obj.get('bytes'), obj.get('byte_count'), obj.get('size_bytes'), obj.get('length'))
    return {
        'declared_size': int(size) if str(size or '').isdigit() else None,
        'declared_crc32': norm_hex_digest(first_present(hashes.get('crc32'), obj.get('crc32'))),
        'declared_md5': norm_hex_digest(first_present(hashes.get('md5'), obj.get('md5'))),
        'declared_sha1': norm_hex_digest(first_present(hashes.get('sha1'), obj.get('sha1'))),
        'declared_sha256': norm_hex_digest(first_present(hashes.get('sha256'), obj.get('sha256'))),
        'declared_file': str(first_present(obj.get('file'), obj.get('path'), obj.get('response_file'), obj.get('filename')) or ''),
    }


def classify_read_dvd_structure(cdb_hex: str, data_len: int) -> str:
    try:
        cdb = bytes.fromhex(cdb_hex.replace(' ', ''))
    except Exception:
        return 'unknown'
    if not cdb or cdb[0] != 0xAD:
        return 'unknown'
    # Xbox control block uses vendor-ish C0 selector in the observed 8050L CDB.
    if len(cdb) >= 12 and cdb[11] == 0xC0:
        return 'ad_c0'
    fmt = cdb[7] if len(cdb) > 7 else 0
    if fmt == 0x04:
        return 'dmi'
    if fmt == 0x00 and data_len == 2048:
        return 'pfi'
    if fmt == 0x01:
        return 'format_01_probe'
    return f'format_{fmt:02x}'


def sidecar_for_iso(iso: Path) -> Path:
    # Preserve names like Red Faction II[TQ00501A].iso -> .xdvd.json
    return iso.with_name(iso.stem + '.xdvd.json')


def media_for_sidecar(sidecar: Path) -> Path:
    name = sidecar.name
    low = name.lower()
    if low.endswith('.xdvd.json'):
        return sidecar.with_name(name[:-len('.xdvd.json')] + '.media.json')
    if low.endswith('.json'):
        return sidecar.with_suffix('.media.json')
    return sidecar.with_suffix('.media.json')


def resolve_from_config(config: Path | None, iso_arg: Path | None, sidecar_arg: Path | None) -> tuple[Path | None, Path | None, dict[str, Any], dict[str, Any]]:
    cfg = read_json(config)
    iso: Path | None = iso_arg
    sidecar: Path | None = sidecar_arg
    if not iso and cfg:
        media = cfg.get('media') or {}
        for key in ('iso_path', 'path', 'file'):
            if media.get(key):
                iso = Path(str(media.get(key)))
                break
    if not sidecar and iso:
        sidecar = sidecar_for_iso(iso)
    xdvd_doc = read_json(sidecar)
    media_doc = read_json(media_for_sidecar(sidecar)) if sidecar else {}
    return iso, sidecar, xdvd_doc, media_doc


def build_report(args: argparse.Namespace) -> dict[str, Any]:
    iso, sidecar, xdvd_doc, media_doc = resolve_from_config(args.config, args.iso, args.sidecar)
    rows = load_jsonl(args.jsonl)
    response_dir = args.response_dir
    warnings: list[str] = []
    checks: list[dict[str, Any]] = []

    model: GDRMediaModel | None = None
    model_loaded = False
    model_summary: dict[str, Any] = {}
    if sidecar and sidecar.exists():
        try:
            model = GDRMediaModel.from_paths(sidecar, iso, xdvd_doc or None)
            model_loaded = True
            model_summary = model.summary()
            warnings.extend(str(w) for w in model_summary.get('warnings') or [])
        except Exception as exc:
            warnings.append(f'model_load_failed:{exc}')
    elif sidecar:
        warnings.append(f'sidecar_missing:{sidecar}')

    # Payload source/hash checks.
    payload_checks: dict[str, dict[str, Any]] = {}
    for key, expected_size in PAYLOAD_EXPECTED_SIZES.items():
        data = (model.payloads.get(key) if model else b'') or b''
        meta = expected_payload_meta(media_doc, xdvd_doc, key)
        dig = digest_bytes(data) if data else {}
        entry = {
            'key': key,
            'label': PAYLOAD_LABELS[key],
            'present': bool(data),
            'size': len(data),
            'expected_size': expected_size,
            'size_ok': len(data) == expected_size if data else False,
            'source': (model.payload_files.get(key, '') if model else ''),
            'payload_source': (model.payload_source.get(key, '') if model else ''),
            'declared': meta,
            'hashes': dig,
            'declared_hash_checks': {},
        }
        # The fixed known sizes are required only when the model/sidecars
        # are actually loadable.  Local Linux validation may replay Windows
        # traces without access to D:\\... sidecars; that mode remains
        # trace-only and should not emit false failures.
        if model_loaded:
            checks.append({'name': f'payload_{key}_present', 'ok': bool(data), 'actual': bool(data), 'expected': True})
            checks.append({'name': f'payload_{key}_size', 'ok': entry['size_ok'], 'actual': len(data), 'expected': expected_size})
            if meta.get('declared_size') is not None:
                ok = int(meta['declared_size']) == len(data)
                checks.append({'name': f'payload_{key}_declared_size', 'ok': ok, 'actual': len(data), 'expected': int(meta['declared_size'])})
            for hname in ('crc32', 'md5', 'sha1', 'sha256'):
                declared = meta.get(f'declared_{hname}') or ''
                if declared and dig:
                    ok = declared == dig[hname]
                    entry['declared_hash_checks'][hname] = ok
                    checks.append({'name': f'payload_{key}_{hname}', 'ok': ok, 'actual': dig[hname], 'expected': declared})
        payload_checks[key] = entry

    # Trace READ DVD STRUCTURE rows: verify bridge actually served the payloads.
    trace_payload_rows: list[dict[str, Any]] = []
    trace_compare_counts: dict[str, int] = {k: 0 for k in PAYLOAD_EXPECTED_SIZES}
    trace_mismatches: list[dict[str, Any]] = []
    for row in rows:
        cdb_hex = str(row.get('cdb_hex') or '')
        if not cdb_hex.upper().startswith('AD'):
            continue
        kind = classify_read_dvd_structure(cdb_hex, int(row.get('data_len') or 0))
        data = bytes_from_row(row, response_dir)
        data_len = len(data) if data is not None else int(row.get('data_len') or 0)
        digest = digest_bytes(data) if data is not None else {}
        compare_ok = None
        expected_key = kind if kind in PAYLOAD_EXPECTED_SIZES else None
        if expected_key and data is not None and model and model.payloads.get(expected_key):
            compare_ok = data == model.payloads[expected_key]
            trace_compare_counts[expected_key] += 1
            if not compare_ok:
                trace_mismatches.append({
                    'index': row.get('index'),
                    'kind': kind,
                    'data_len': data_len,
                    'trace_sha256': digest.get('sha256', ''),
                    'expected_sha256': digest_bytes(model.payloads[expected_key])['sha256'],
                })
        trace_payload_rows.append({
            'index': row.get('index'),
            'kind': kind,
            'cdb_hex': cdb_hex,
            'source': row.get('source', ''),
            'data_len': data_len,
            'data_file': row.get('data_file', ''),
            'sha256': digest.get('sha256') or row.get('data_sha256', ''),
            'compare_ok': compare_ok,
        })

    for key in PAYLOAD_EXPECTED_SIZES:
        # Only require a trace compare if that payload was observed in the trace.
        observed = [r for r in trace_payload_rows if r.get('kind') == key]
        if observed and model_loaded:
            ok = trace_compare_counts.get(key, 0) == len(observed) and not any(r.get('compare_ok') is False for r in observed)
            checks.append({'name': f'trace_{key}_matches_profile_payload', 'ok': ok, 'actual': trace_compare_counts.get(key, 0), 'expected': len(observed)})

    image_check: dict[str, Any] = {}
    if iso:
        image_check['path'] = str(iso)
        image_check['exists'] = iso.exists()
        if iso.exists():
            size = iso.stat().st_size
            image_check['byte_count'] = size
            declared = None
            img = (media_doc.get('image') or {}) if media_doc else (xdvd_doc.get('image') or {})
            for val in (img.get('byte_count'), (xdvd_doc.get('disc') or {}).get('reconstructed_output_byte_count')):
                try:
                    if val is not None:
                        declared = int(val)
                        break
                except Exception:
                    pass
            if declared is not None:
                ok = size == declared
                checks.append({'name': 'image_declared_byte_count', 'ok': ok, 'actual': size, 'expected': declared})
                image_check['declared_byte_count'] = declared
                image_check['declared_byte_count_ok'] = ok
            if args.hash_image:
                image_hashes = digest_file(iso, include_sha256=False)
                image_check['hashes'] = image_hashes
                hashes = img.get('hashes') if isinstance(img.get('hashes'), dict) else {}
                for hname in ('crc32', 'md5', 'sha1'):
                    declared_hash = norm_hex_digest(first_present(hashes.get(hname), img.get(hname)))
                    if declared_hash:
                        ok = image_hashes.get(hname) == declared_hash
                        checks.append({'name': f'image_{hname}', 'ok': ok, 'actual': image_hashes.get(hname), 'expected': declared_hash})
        else:
            warnings.append(f'iso_missing:{iso}')

    failed = [c for c in checks if not c.get('ok')]
    # If the sidecar is absent on the local Linux validator because config paths
    # are Windows-only, still allow trace-only reports to pass when no trace
    # mismatches exist.  On the user's Windows run, sidecar/model checks should
    # be active.
    ok = len(failed) == 0 if model_loaded else (len(trace_mismatches) == 0)

    return {
        'version': 'v227-media-preflight',
        'ok': ok,
        'model_loaded': model_loaded,
        'config': str(args.config) if args.config else '',
        'jsonl': str(args.jsonl) if args.jsonl else '',
        'response_dir': str(response_dir) if response_dir else '',
        'iso': str(iso) if iso else '',
        'sidecar': str(sidecar) if sidecar else '',
        'media_profile': str(media_for_sidecar(sidecar)) if sidecar else '',
        'check_count': len(checks),
        'failed_count': len(failed),
        'checks': checks,
        'failures': failed,
        'warnings': list(dict.fromkeys(warnings)),
        'payload_checks': payload_checks,
        'trace_payload_rows': trace_payload_rows,
        'trace_payload_row_count': len(trace_payload_rows),
        'trace_compare_counts': trace_compare_counts,
        'trace_mismatches': trace_mismatches,
        'image_check': image_check,
        'model_summary': model_summary,
    }


def write_md(report: dict[str, Any], path: Path) -> None:
    lines: list[str] = []
    lines += ['# GDR/XGD1 v227 media payload integrity report', '']
    for key in ('ok', 'model_loaded', 'check_count', 'failed_count', 'trace_payload_row_count'):
        val = report.get(key)
        if isinstance(val, bool):
            val = str(val).lower()
        lines.append(f'- {key}: `{val}`')
    lines += ['', '## Paths', '']
    for key in ('iso', 'sidecar', 'media_profile', 'jsonl', 'response_dir'):
        lines.append(f'- {key}: `{report.get(key, "")}`')
    lines += ['', '## Payloads', '', '| payload | present | size | expected | source | crc32 | md5 | sha1 |', '|---|---:|---:|---:|---|---|---|---|']
    for key in ('ad_c0', 'pfi', 'dmi'):
        p = report['payload_checks'].get(key, {})
        h = p.get('hashes') or {}
        lines.append(f'| `{key}` | `{str(p.get("present", False)).lower()}` | `{p.get("size", 0)}` | `{p.get("expected_size", "")}` | `{p.get("source", "")}` | `{h.get("crc32", "")}` | `{h.get("md5", "")}` | `{h.get("sha1", "")}` |')
    lines += ['', '## READ DVD STRUCTURE trace rows', '', '| index | kind | len | source | compare_ok | sha256 |', '|---:|---|---:|---|---:|---|']
    for r in report.get('trace_payload_rows') or []:
        cmp = r.get('compare_ok')
        cmp_s = '' if cmp is None else str(cmp).lower()
        lines.append(f'| `{r.get("index", "")}` | `{r.get("kind", "")}` | `{r.get("data_len", "")}` | `{r.get("source", "")}` | `{cmp_s}` | `{r.get("sha256", "")}` |')
    lines += ['', '## Failed checks', '']
    if report.get('failures'):
        for f in report['failures']:
            lines.append(f'- `{f.get("name")}` actual=`{f.get("actual")}` expected=`{f.get("expected")}`')
    else:
        lines.append('None.')
    if report.get('warnings'):
        lines += ['', '## Warnings', '']
        for w in report['warnings']:
            lines.append(f'- `{w}`')
    path.write_text('\n'.join(lines) + '\n', encoding='utf-8')


def main() -> int:
    ap = argparse.ArgumentParser(description='Validate GDR/XGD1 media payload hashes and trace READ DVD STRUCTURE routing')
    ap.add_argument('--config', type=Path)
    ap.add_argument('--iso', type=Path)
    ap.add_argument('--sidecar', type=Path)
    ap.add_argument('--jsonl', type=Path)
    ap.add_argument('--response-dir', type=Path)
    ap.add_argument('--hash-image', action='store_true', help='also compute CRC32/MD5/SHA1 for the full ISO image')
    ap.add_argument('--json', type=Path)
    ap.add_argument('--md', type=Path)
    args = ap.parse_args()
    report = build_report(args)
    if args.json:
        args.json.write_text(json.dumps(report, indent=2, sort_keys=True), encoding='utf-8')
    if args.md:
        write_md(report, args.md)
    print(f"[MEDIA-PAYLOAD-INTEGRITY] model_loaded={str(report['model_loaded']).lower()} payloads={sum(1 for p in report['payload_checks'].values() if p.get('present'))} trace_payload_rows={report['trace_payload_row_count']} failed={report['failed_count']} ok={str(report['ok']).lower()}")
    return 0 if report.get('ok') else 2


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