#!/usr/bin/env python3
"""Fail-fast media-profile preflight for GDR/XGD1 Redump-style sessions.

v227 adds a startup/collection guard for the corrected option-1 dumper layout.
It verifies that the emulator is binding the intended backing ISO, .media.json,
.xdvd.json, and READ DVD STRUCTURE sidecar payloads before the live iSCSI target
serves data.  It is intentionally separate from the trace-only reports so a bad
media/profile pairing can be caught before Windows attaches to the target.
"""
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

EXPECTED_REDUMP = {
    'layout_type': 'original_xbox_xgd1_redump_style_2048',
    'sector_size': 2048,
    'reconstructed_output_sector_count': 3_820_880,
    'reconstructed_output_byte_count': 7_825_162_240,
    'layer_break_lba': 1_913_776,
    'game_output_start_lba': 198_144,
    'game_xiso_leadin_sector_count': 32,
    'xdfs_volume_lba_within_game_region': 32,
    'game_unlocked_source_start_lba': 32,
    'game_unlocked_source_sector_count': 3_431_232,
    'video_l0_sector_count': 6_832,
    'video_l1_start_lba': 3_820_720,
    'video_l1_sector_count': 160,
    'locked_visible_sector_count': 6_992,
    'unlocked_visible_sector_count': 3_431_264,
}
PAYLOAD_SIZES = {'ad_c0': 1636, 'pfi': 2048, 'dmi': 2048}
EXPECTED_WARNINGS = {
    'redump_filler_ranges_are_synthetic_zero_fill',
    'redump_style_padding_zero_filled_pending_hardware_capture',
}


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 []
    rows: 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)
            except Exception:
                continue
            if isinstance(obj, dict):
                rows.append(obj)
    return rows


def sidecar_for_iso(iso: Path | None) -> Path | None:
    if not iso:
        return None
    return iso.with_name(iso.stem + '.xdvd.json')


def media_for_sidecar(sidecar: Path | None) -> Path | None:
    if not sidecar:
        return None
    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_paths(args: argparse.Namespace) -> tuple[Path | None, Path | None, Path | None]:
    iso: Path | None = args.iso
    xdvd: Path | None = args.xdvd
    media: Path | None = args.media
    rows = load_jsonl(args.jsonl)
    if not iso:
        for row in rows:
            p = row.get('dynamic_media_path') or row.get('media_path')
            if p:
                iso = Path(str(p))
                break
    if not iso and args.config and args.config.exists():
        cfg = read_json(args.config)
        m = cfg.get('media') or {}
        for k in ('iso_path', 'path', 'file'):
            if m.get(k):
                iso = Path(str(m.get(k)))
                break
    if not xdvd and iso:
        xdvd = sidecar_for_iso(iso)
    if not media and xdvd:
        media = media_for_sidecar(xdvd)
    return iso, xdvd, media


def dig(doc: dict[str, Any], *path: str, default: Any = None) -> Any:
    cur: Any = doc
    for key in path:
        if not isinstance(cur, dict) or key not in cur:
            return default
        cur = cur.get(key)
    return cur


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


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(),
    }


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


def add_check(checks: list[dict[str, Any]], name: str, actual: Any, expected: Any = True, *, ok: bool | None = None, severity: str = 'error') -> None:
    checks.append({'name': name, 'actual': actual, 'expected': expected, 'ok': (actual == expected if ok is None else bool(ok)), 'severity': severity})


def payload_declared_meta(media_doc: dict[str, Any], xdvd_doc: dict[str, Any], key: str) -> dict[str, Any]:
    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]
    candidates: list[Any] = []
    for doc in (media_doc, xdvd_doc):
        for root_name in ('dvd_structures', 'payloads', 'sidecars', 'files'):
            root = doc.get(root_name)
            if isinstance(root, dict):
                for a in aliases:
                    if a in root:
                        candidates.append(root[a])
        for rec in doc.get('scsi_responses') or []:
            if isinstance(rec, dict) and str(rec.get('name') or '').lower() in aliases:
                candidates.append(rec)
    obj: dict[str, Any] = {}
    for c in candidates:
        if isinstance(c, dict):
            obj = c
            break
        if isinstance(c, str):
            obj = {'file': c}
            break
    hashes = obj.get('hashes') if isinstance(obj.get('hashes'), dict) else {}
    return {
        'declared_size': obj.get('size') or obj.get('bytes') or obj.get('byte_count') or obj.get('size_bytes') or obj.get('length'),
        'declared_crc32': norm_digest(hashes.get('crc32') or obj.get('crc32')),
        'declared_md5': norm_digest(hashes.get('md5') or obj.get('md5')),
        'declared_sha1': norm_digest(hashes.get('sha1') or obj.get('sha1')),
    }


def route_tuple(model: GDRMediaModel, state: str, lba: int, blocks: int, read_map: str = 'firmware_window') -> list[tuple[int, int, int, str]]:
    segs, _ = model.read_segments_for_host(state, lba, blocks, read_map=read_map)
    return [(int(s.get('host_lba', 0)), int(s.get('iso_lba', 0)), int(s.get('blocks', 0)), str(s.get('segment_note') or '')) for s in segs]


def build_report(args: argparse.Namespace) -> tuple[dict[str, Any], int]:
    iso, xdvd, media = resolve_paths(args)
    xdvd_doc = read_json(xdvd)
    media_doc = read_json(media)
    checks: list[dict[str, Any]] = []
    warnings: list[str] = []
    model: GDRMediaModel | None = None
    model_summary: dict[str, Any] = {}

    add_check(checks, 'iso_path_resolved', str(iso) if iso else '', ok=bool(iso))
    add_check(checks, 'xdvd_sidecar_exists', str(xdvd) if xdvd else '', ok=bool(xdvd and xdvd.exists()))
    add_check(checks, 'media_profile_exists', str(media) if media else '', ok=bool(media and media.exists()), severity='error' if args.require_media_profile else 'warning')
    if iso and iso.exists():
        add_check(checks, 'iso_exists', str(iso), ok=True)
    else:
        add_check(checks, 'iso_exists', str(iso) if iso else '', ok=False, severity='error' if args.strict_image else 'warning')

    if xdvd and xdvd.exists():
        try:
            model = GDRMediaModel.from_paths(xdvd, iso, xdvd_doc or None)
            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}')
            add_check(checks, 'model_loaded', repr(exc), ok=False)
    else:
        add_check(checks, 'model_loaded', 'no sidecar', ok=False)

    if model:
        add_check(checks, 'model_loaded', True)
        add_check(checks, 'media_profile_loaded', bool(model.media_profile_loaded), severity='error' if args.require_media_profile else 'warning')
        if model.is_redump_style or args.expect_redump:
            for key, expected in EXPECTED_REDUMP.items():
                actual = getattr(model, key) if hasattr(model, key) else (model.layout_type if key == 'layout_type' else None)
                add_check(checks, key, actual, expected)
            add_check(checks, 'locked_read_capacity_10_hex', model.read_capacity_10_response('locked').hex().upper(), '00001B4F00000800')
            add_check(checks, 'unlocked_read_capacity_10_hex', model.read_capacity_10_response('unlocked').hex().upper(), '00345B5F00000800')
            route_expect = [
                ('route_locked_l0_start', route_tuple(model, 'locked', 0, 1), [(0, 0, 1, 'redump_locked_video_l0')]),
                ('route_locked_l0_l1_cross', route_tuple(model, 'locked', 6831, 2), [(6831, 6831, 1, 'redump_locked_video_l0'), (6832, 3820720, 1, 'redump_locked_video_l1_tail')]),
                ('route_locked_l1_end', route_tuple(model, 'locked', 6991, 1), [(6991, 3820879, 1, 'redump_locked_video_l1_tail')]),
                ('route_unlocked_start', route_tuple(model, 'unlocked', 0, 1), [(0, 198144, 1, 'redump_unlocked_game_xiso_region')]),
                ('route_unlocked_xdfs_start', route_tuple(model, 'unlocked', 32, 1), [(32, 198176, 1, 'redump_unlocked_game_xiso_region')]),
                ('route_unlocked_end', route_tuple(model, 'unlocked', 3431263, 1), [(3431263, 3629407, 1, 'redump_unlocked_game_xiso_region')]),
                ('route_raw_redump_last', route_tuple(model, 'locked', 3820879, 1, 'raw_iso'), [(3820879, 3820879, 1, 'direct')]),
            ]
            for name, actual, expected in route_expect:
                add_check(checks, name, actual, expected)

        if iso and iso.exists():
            size = iso.stat().st_size
            expected_bytes = model.reconstructed_output_byte_count or model.sidecar_byte_count or (model.reconstructed_output_sector_count * model.sector_size if model.reconstructed_output_sector_count else 0)
            expected_sectors = model.reconstructed_output_sector_count or model.sidecar_sector_count or 0
            add_check(checks, 'iso_byte_count_matches_profile', size, expected_bytes, ok=(not expected_bytes or size == expected_bytes))
            if expected_sectors and model.sector_size:
                add_check(checks, 'iso_sector_count_matches_profile', size // model.sector_size, expected_sectors, ok=(size % model.sector_size == 0 and size // model.sector_size == expected_sectors))
            if args.hash_image:
                actual_hashes = digest_file(iso)
                image = media_doc.get('image') or xdvd_doc.get('image') or {}
                declared = image.get('hashes') or {}
                for key in ('crc32', 'md5', 'sha1'):
                    want = norm_digest(declared.get(key) or image.get(key))
                    if want:
                        add_check(checks, f'iso_{key}_matches_profile', actual_hashes.get(key), want)
        else:
            if args.strict_image:
                add_check(checks, 'iso_byte_count_matches_profile', 'missing', 'present')

        payload_results: dict[str, Any] = {}
        for key, expected_size in PAYLOAD_SIZES.items():
            data = model.payloads.get(key, b'')
            meta = payload_declared_meta(media_doc, xdvd_doc, key)
            entry: dict[str, Any] = {'present': bool(data), 'size': len(data), 'expected_size': expected_size, 'source': model.payload_files.get(key, '')}
            if data:
                digests = digest_bytes(data)
                entry.update(digests)
            payload_results[key] = entry
            add_check(checks, f'payload_{key}_present', bool(data), True)
            add_check(checks, f'payload_{key}_size', len(data), expected_size)
            if data:
                digests = digest_bytes(data)
                if meta.get('declared_size') not in (None, ''):
                    try:
                        add_check(checks, f'payload_{key}_declared_size', len(data), int(str(meta['declared_size']), 0))
                    except Exception:
                        pass
                for h in ('crc32', 'md5', 'sha1'):
                    want = meta.get(f'declared_{h}')
                    if want:
                        add_check(checks, f'payload_{key}_{h}_matches_declared', digests[h], want)
        model_summary['preflight_payloads'] = payload_results

    failures = [c for c in checks if not c.get('ok') and c.get('severity') == 'error']
    warning_checks = [c for c in checks if not c.get('ok') and c.get('severity') == 'warning']
    warning_set = list(dict.fromkeys(warnings))
    unexpected_warnings = [w for w in warning_set if w not in EXPECTED_WARNINGS]
    if args.fail_on_unexpected_warning and unexpected_warnings:
        add_check(checks, 'unexpected_model_warnings', unexpected_warnings, [], ok=False)
        failures = [c for c in checks if not c.get('ok') and c.get('severity') == 'error']

    report = {
        'version': 'v227-media-profile-preflight',
        'ok': len(failures) == 0,
        'check_count': len(checks),
        'failed_count': len(failures),
        'warning_check_count': len(warning_checks),
        'model_loaded': bool(model),
        'media_profile_loaded': bool(model and model.media_profile_loaded),
        'mode': 'redump_profile' if model and model.is_redump_style else ('legacy_or_no_media_profile' if model else 'unloaded'),
        'iso': str(iso) if iso else '',
        'xdvd': str(xdvd) if xdvd else '',
        'media_profile': str(media) if media else '',
        'strict_image': bool(args.strict_image),
        'hash_image': bool(args.hash_image),
        'checks': checks,
        'warnings': warning_set,
        'unexpected_warnings': unexpected_warnings,
        'model_summary': model_summary,
    }
    return report, 0 if report['ok'] else 1


def write_outputs(report: dict[str, Any], args: argparse.Namespace) -> None:
    if args.json:
        args.json.write_text(json.dumps(report, indent=2, sort_keys=True) + '\n', encoding='utf-8')
    lines = [
        '# GDR/XGD1 v227 media-profile preflight report',
        '',
        f"- ok: `{str(report.get('ok')).lower()}`",
        f"- mode: `{report.get('mode')}`",
        f"- model_loaded: `{str(report.get('model_loaded')).lower()}`",
        f"- media_profile_loaded: `{str(report.get('media_profile_loaded')).lower()}`",
        f"- check_count: `{report.get('check_count')}`",
        f"- failed_count: `{report.get('failed_count')}`",
        f"- warning_check_count: `{report.get('warning_check_count')}`",
        '',
        '## Paths',
        '',
        f"- iso: `{report.get('iso')}`",
        f"- xdvd: `{report.get('xdvd')}`",
        f"- media_profile: `{report.get('media_profile')}`",
        '',
        '## Failed checks',
        '',
    ]
    fails = [c for c in report.get('checks', []) if not c.get('ok') and c.get('severity') == 'error']
    if fails:
        lines += ['| check | actual | expected |', '|---|---|---|']
        for c in fails:
            lines.append(f"| `{c.get('name')}` | `{c.get('actual')}` | `{c.get('expected')}` |")
    else:
        lines.append('None.')
    lines += ['', '## Warnings', '']
    warnings = report.get('warnings') or []
    if warnings:
        for w in warnings:
            lines.append(f'- `{w}`')
    else:
        lines.append('None.')
    lines += ['', '## Checks', '', '| check | ok | actual | expected |', '|---|---:|---|---|']
    for c in report.get('checks', []):
        lines.append(f"| `{c.get('name')}` | `{str(c.get('ok')).lower()}` | `{c.get('actual')}` | `{c.get('expected')}` |")
    text = '\n'.join(lines) + '\n'
    if args.md:
        args.md.write_text(text, encoding='utf-8')
    else:
        print(text, end='')


def main() -> int:
    ap = argparse.ArgumentParser(description='v227 GDR/XGD1 media-profile preflight')
    ap.add_argument('--config', type=Path)
    ap.add_argument('--iso', type=Path)
    ap.add_argument('--xdvd', type=Path)
    ap.add_argument('--media', type=Path)
    ap.add_argument('--jsonl', type=Path, help='optional live trace used to recover dynamic_media_path')
    ap.add_argument('--strict-image', action='store_true', help='fail if the backing image is missing or size mismatches profile metadata')
    ap.add_argument('--require-media-profile', action='store_true', help='fail if adjacent .media.json is absent')
    ap.add_argument('--expect-redump', action='store_true', help='enforce corrected XGD1 Redump-style constants')
    ap.add_argument('--hash-image', action='store_true', help='hash the full ISO and compare declared image hashes; slow on 7.29 GiB images')
    ap.add_argument('--fail-on-unexpected-warning', action='store_true')
    ap.add_argument('--json', type=Path)
    ap.add_argument('--md', type=Path)
    args = ap.parse_args()
    report, rc = build_report(args)
    write_outputs(report, args)
    print(f"[MEDIA-PROFILE-PREFLIGHT] mode={report.get('mode')} model_loaded={str(report.get('model_loaded')).lower()} media_profile_loaded={str(report.get('media_profile_loaded')).lower()} checks={report.get('check_count')} failed={report.get('failed_count')} ok={str(report.get('ok')).lower()}")
    return rc


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