#!/usr/bin/env python3
"""Media model helpers for the MN103S/GDR iSCSI emulator.

v227 adds support for the corrected option-1 dumper output:
  * adjacent ``.media.json`` files with format ``xdvd-media-profile``
  * Original Xbox/XGD1 Redump-style 2048-byte-sector reconstruction layout
  * split locked/video mapping where locked source LBA 6832..6991 maps to the
    video-L1 tail at the end of the Redump-style image
  * unlocked Xbox/game mapping where host LBA 0..3431263 maps to the embedded
    game/XISO region at ``game_output_start_lba``.

PFI/DMI/AD/lead-in/lead-out remain sidecar/profile metadata.  The ISO stream is
still normal 2048-byte READ user data only.
"""
from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

SECTOR_SIZE_DEFAULT = 2048

# Corrected Original Xbox/XGD1 Redump-style option-1 constants.  These are only
# defaults/fallbacks; real values are read from the .media.json/.xdvd.json layout
# whenever present.
XGD1_FULL_REDUMP_SECTORS = 3_820_880
XGD1_GAME_OUTPUT_START_LBA = 198_144
XGD1_VIDEO_TOTAL_SECTORS = 6_992
XGD1_VIDEO_L0_SECTORS = 6_832
XGD1_VIDEO_L1_SECTORS = 160
XGD1_VIDEO_L1_OUTPUT_START_LBA = XGD1_FULL_REDUMP_SECTORS - XGD1_VIDEO_L1_SECTORS
XGD1_GAME_XISO_LEADIN_SECTORS = 32
XGD1_UNLOCKED_SECTORS = 3_431_264


def _intish(value: Any, default: int | None = 0) -> int | None:
    try:
        if value is None:
            return None if default is None else int(default)
        if isinstance(value, str):
            if value.strip() == '':
                return None if default is None else int(default)
            return int(value, 0)
        return int(value)
    except Exception:
        return None if default is None else int(default)


def _relpath(base: Path, value: Any) -> Path | None:
    if not value:
        return None
    p = Path(str(value))
    if not p.is_absolute():
        p = base / p
    return p


def _u24be(data: bytes, off: int) -> int | None:
    if len(data) < off + 3:
        return None
    return (data[off] << 16) | (data[off + 1] << 8) | data[off + 2]



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

def _read_json_if_exists(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 _hex_bytes(value: Any) -> bytes:
    if value is None:
        return b''
    if isinstance(value, (bytes, bytearray)):
        return bytes(value)
    if isinstance(value, list):
        try:
            return bytes((int(x, 0) if isinstance(x, str) else int(x)) & 0xFF for x in value)
        except Exception:
            return b''
    text = str(value).strip().replace(' ', '').replace(':', '').replace('\n', '')
    if not text:
        return b''
    try:
        return bytes.fromhex(text)
    except Exception:
        return b''


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 _first_int(default: int = 0, *values: Any) -> int:
    for value in values:
        n = _intish(value, None)
        if n is not None:
            return int(n)
    return int(default)


def _first_text(*values: Any) -> str:
    for value in values:
        if value not in (None, ''):
            return str(value)
    return ''


def _profile_file_ref(value: Any) -> Any:
    """Return a file/path string from a media-profile reference.

    Newer dumper schemas may use either ``"pfi": "file.bin"`` or a small
    object such as ``{"file": "file.bin", ...}``.
    """
    if isinstance(value, str):
        return value
    if isinstance(value, dict):
        for key in ('file', 'path', 'response_file', 'filename'):
            if value.get(key):
                return value.get(key)
    return value


def _read_capacity_response_from_hex(value: Any) -> bytes:
    raw = _hex_bytes(value)
    return raw[:8] if len(raw) >= 8 else b''


@dataclass
class GDRMediaModel:
    xdvd_json: Path
    sidecar: dict[str, Any]
    iso_path: Path | None = None
    sector_size: int = SECTOR_SIZE_DEFAULT
    iso_byte_count: int = 0
    iso_sector_count: int = 0
    sidecar_sector_count: int = 0
    sidecar_byte_count: int = 0
    visible_start_lba: int = 0
    visible_sector_count: int = 0
    game_output_start_lba: int = 0
    game_unlocked_source_start_lba: int = 0
    game_unlocked_source_sector_count: int = 0
    game_xiso_leadin_sector_count: int = 0
    xdfs_volume_lba_within_game_region: int = 0
    game_sector_count: int = 0
    reconstructed_output_sector_count: int = 0
    reconstructed_output_byte_count: int = 0
    layer_break_lba: int = 0
    layers: int = 0
    xbox_physical_sectors: int = 0
    media_id: str = ''
    title: str = ''
    image_crc32: str = ''
    image_md5: str = ''
    image_sha1: str = ''
    reference_crc32: str = ''
    reference_md5: str = ''
    reference_sha1: str = ''
    layout_type: str = ''
    media_profile_path: Path | None = None
    media_profile: dict[str, Any] = field(default_factory=dict)
    media_profile_loaded: bool = False
    video_l0_start_lba: int = 0
    video_l0_sector_count: int = 0
    pregame_padding_start_lba: int = 0
    pregame_padding_sector_count: int = 0
    postgame_padding_start_lba: int = 0
    postgame_padding_sector_count: int = 0
    video_l1_start_lba: int = 0
    video_l1_sector_count: int = 0
    locked_visible_sector_count: int = 0
    unlocked_visible_sector_count: int = 0
    filler_policy: str = ''
    filler_verified_from_disc: bool = False
    pending_hardware_capture: bool = False
    payloads: dict[str, bytes] = field(default_factory=dict)
    payload_files: dict[str, str] = field(default_factory=dict)
    payload_source: dict[str, str] = field(default_factory=dict)
    warnings: list[str] = field(default_factory=list)
    validation_warnings: list[str] = field(default_factory=list)
    drive_state_observations: dict[str, dict[str, bytes]] = field(default_factory=dict)

    @classmethod
    def from_paths(cls, xdvd_json: str | Path, iso_path: str | Path | None, sidecar: dict[str, Any] | None = None) -> 'GDRMediaModel':
        xdvd = Path(xdvd_json)
        doc = sidecar if sidecar is not None else json.loads(xdvd.read_text(encoding='utf-8'))
        if doc.get('format') == 'xdvd-media-profile':
            # Allow direct inspection of a .media.json, but the live emulator
            # still normally passes .xdvd.json so auth responses are available.
            media_doc = doc
            sidecar_doc = doc
            media_path = xdvd
        else:
            sidecar_doc = doc
            media_path = _adjacent_media_profile_path(xdvd)
            media_doc = _read_json_if_exists(media_path)
            if media_doc.get('format') != 'xdvd-media-profile':
                media_doc = {}
                media_path = None

        image = sidecar_doc.get('image') or {}
        hashes = image.get('hashes') or {}
        disc = sidecar_doc.get('disc') or {}
        layout = cls._layout_from_sidecar(sidecar_doc)
        sector_size = _intish(image.get('sector_size'), SECTOR_SIZE_DEFAULT) or SECTOR_SIZE_DEFAULT
        iso = Path(iso_path) if iso_path else _relpath(xdvd.parent, image.get('file'))
        sidecar_sectors = _first_int(0, image.get('sector_count'), disc.get('reconstructed_output_sectors'), layout.get('reconstructed_output_sector_count'))
        sidecar_bytes = _first_int(0, image.get('byte_count'), disc.get('reconstructed_output_byte_count'))
        model = cls(
            xdvd_json=xdvd,
            sidecar=sidecar_doc,
            iso_path=iso,
            sector_size=sector_size,
            sidecar_sector_count=sidecar_sectors,
            sidecar_byte_count=sidecar_bytes,
            visible_start_lba=_intish(layout.get('visible_start_lba'), 0),
            visible_sector_count=_intish(layout.get('visible_sector_count'), 0),
            game_output_start_lba=_intish(layout.get('game_output_start_lba'), 0),
            game_unlocked_source_start_lba=_intish(layout.get('game_unlocked_source_start_lba'), 0),
            game_unlocked_source_sector_count=_intish(layout.get('game_unlocked_source_sector_count'), 0),
            game_xiso_leadin_sector_count=_intish(layout.get('game_xiso_leadin_sector_count'), 0),
            xdfs_volume_lba_within_game_region=_intish(layout.get('xdfs_volume_lba_within_game_region'), 0),
            game_sector_count=_first_int(0, layout.get('game_sector_count'), layout.get('drive_reported_unlocked_sector_count'), disc.get('drive_reported_unlocked_sectors')),
            reconstructed_output_sector_count=_first_int(0, layout.get('reconstructed_output_sector_count'), disc.get('reconstructed_output_sectors'), image.get('sector_count')),
            reconstructed_output_byte_count=_first_int(0, disc.get('reconstructed_output_byte_count'), image.get('byte_count')),
            layer_break_lba=_intish(layout.get('layer_break_lba'), 0),
            layers=_intish(disc.get('layers'), 0),
            xbox_physical_sectors=_intish(disc.get('xbox_physical_sectors'), 0),
            media_id=str(disc.get('media_id') or disc.get('id') or disc.get('title_id') or ''),
            title=str(disc.get('title') or disc.get('name') or ''),
            image_crc32=_first_text(hashes.get('crc32'), image.get('crc32')),
            image_md5=_first_text(hashes.get('md5'), image.get('md5')),
            image_sha1=_first_text(hashes.get('sha1'), image.get('sha1')),
            layout_type=str(layout.get('layout_type') or ''),
            video_l0_start_lba=_intish(layout.get('video_l0_start_lba'), 0),
            video_l0_sector_count=_intish(layout.get('video_l0_sector_count'), 0),
            pregame_padding_start_lba=_intish(layout.get('pregame_padding_start_lba'), 0),
            pregame_padding_sector_count=_intish(layout.get('pregame_padding_sector_count'), 0),
            postgame_padding_start_lba=_intish(layout.get('postgame_padding_start_lba'), 0),
            postgame_padding_sector_count=_intish(layout.get('postgame_padding_sector_count'), 0),
            video_l1_start_lba=_intish(layout.get('video_l1_start_lba'), 0),
            video_l1_sector_count=_intish(layout.get('video_l1_sector_count'), 0),
            locked_visible_sector_count=cls._drive_state_visible(sidecar_doc, 'locked') or _first_int(0, layout.get('drive_locked_visible_sector_count'), disc.get('drive_reported_locked_sectors')),
            unlocked_visible_sector_count=cls._drive_state_visible(sidecar_doc, 'unlocked') or _first_int(0, layout.get('drive_reported_unlocked_sector_count'), disc.get('drive_reported_unlocked_sectors')),
            media_profile_path=media_path,
            media_profile=media_doc,
            media_profile_loaded=bool(media_doc),
        )
        if media_doc:
            model._apply_media_profile(media_doc, media_path)
        model._derive_from_iso()
        model._load_payloads()
        model._derive_from_payloads()
        model._validate()
        return model

    @staticmethod
    def _layout_from_sidecar(doc: dict[str, Any]) -> dict[str, Any]:
        return (doc.get('layout') or doc.get('xbox_layout') or ((doc.get('image') or {}).get('layout') or {}))

    @staticmethod
    def _drive_state_visible(doc: dict[str, Any], state_name: str) -> int:
        for root_name in ('drive_states', 'drive_state_observations'):
            states = doc.get(root_name) or {}
            state = states.get(state_name) or {}
            val = _intish(state.get('visible_sector_count'), 0)
            if val:
                return val
            cap = state.get('read_capacity_10_hex')
            raw = _read_capacity_response_from_hex(cap)
            if raw:
                return int.from_bytes(raw[:4], 'big') + 1
        return 0

    def _apply_media_profile(self, doc: dict[str, Any], path: Path | None) -> None:
        image = doc.get('image') or {}
        layout = doc.get('layout') or {}
        recon = doc.get('reconstruction') or {}
        obs = doc.get('drive_state_observations') or {}
        if path:
            self.media_profile_path = path
        self.media_profile = doc
        self.media_profile_loaded = True

        if not self.media_id:
            self.media_id = str(doc.get('media_id') or '')
        if not self.title:
            self.title = str(doc.get('title_hint') or '')
        hashes = image.get('hashes') or {}
        refs = doc.get('redump_reference') or doc.get('reference') or {}
        if not self.image_crc32:
            self.image_crc32 = _first_text(hashes.get('crc32'), image.get('crc32'))
        if not self.image_md5:
            self.image_md5 = _first_text(hashes.get('md5'), image.get('md5'))
        if not self.image_sha1:
            self.image_sha1 = _first_text(hashes.get('sha1'), image.get('sha1'))
        if not self.reference_crc32:
            self.reference_crc32 = _first_text(_dig(refs, 'hashes', 'crc32'), refs.get('crc32'))
        if not self.reference_md5:
            self.reference_md5 = _first_text(_dig(refs, 'hashes', 'md5'), refs.get('md5'))
        if not self.reference_sha1:
            self.reference_sha1 = _first_text(_dig(refs, 'hashes', 'sha1'), refs.get('sha1'))
        if image.get('sector_size'):
            self.sector_size = _intish(image.get('sector_size'), self.sector_size)
        if image.get('sector_count') or layout.get('reconstructed_output_sector_count'):
            self.sidecar_sector_count = _first_int(self.sidecar_sector_count, image.get('sector_count'), layout.get('reconstructed_output_sector_count'))
        if image.get('byte_count'):
            self.sidecar_byte_count = _intish(image.get('byte_count'), self.sidecar_byte_count)
        if not self.iso_path:
            self.iso_path = _relpath((path or self.xdvd_json).parent, image.get('file'))

        self.layout_type = str(layout.get('layout_type') or self.layout_type)
        self.layers = _intish(layout.get('layers'), self.layers)
        self.layer_break_lba = _intish(layout.get('layer_break_lba'), self.layer_break_lba)
        self.visible_start_lba = _intish(layout.get('visible_start_lba'), self.visible_start_lba)
        self.visible_sector_count = _intish(layout.get('visible_sector_count'), self.visible_sector_count)
        self.video_l0_start_lba = _intish(layout.get('video_l0_start_lba'), self.video_l0_start_lba)
        self.video_l0_sector_count = _intish(layout.get('video_l0_sector_count'), self.video_l0_sector_count)
        self.pregame_padding_start_lba = _intish(layout.get('pregame_padding_start_lba'), self.pregame_padding_start_lba)
        self.pregame_padding_sector_count = _intish(layout.get('pregame_padding_sector_count'), self.pregame_padding_sector_count)
        self.game_output_start_lba = _intish(layout.get('game_output_start_lba'), self.game_output_start_lba)
        self.game_unlocked_source_start_lba = _intish(layout.get('game_unlocked_source_start_lba'), self.game_unlocked_source_start_lba)
        self.game_unlocked_source_sector_count = _intish(layout.get('game_unlocked_source_sector_count'), self.game_unlocked_source_sector_count)
        self.game_xiso_leadin_sector_count = _intish(layout.get('game_xiso_leadin_sector_count'), self.game_xiso_leadin_sector_count)
        self.xdfs_volume_lba_within_game_region = _intish(layout.get('xdfs_volume_lba_within_game_region'), self.xdfs_volume_lba_within_game_region)
        self.game_sector_count = _first_int(self.game_sector_count, layout.get('game_sector_count'), layout.get('drive_reported_unlocked_sector_count'))
        self.reconstructed_output_sector_count = _first_int(self.reconstructed_output_sector_count, layout.get('reconstructed_output_sector_count'), image.get('sector_count'))
        self.reconstructed_output_byte_count = _first_int(self.reconstructed_output_byte_count, image.get('byte_count'))
        self.postgame_padding_start_lba = _intish(layout.get('postgame_padding_start_lba'), self.postgame_padding_start_lba)
        self.postgame_padding_sector_count = _intish(layout.get('postgame_padding_sector_count'), self.postgame_padding_sector_count)
        self.video_l1_start_lba = _intish(layout.get('video_l1_start_lba'), self.video_l1_start_lba)
        self.video_l1_sector_count = _intish(layout.get('video_l1_sector_count'), self.video_l1_sector_count)
        self.filler_policy = str(recon.get('filler_policy') or self.filler_policy)
        self.filler_verified_from_disc = bool(recon.get('filler_verified_from_disc', self.filler_verified_from_disc))
        self.pending_hardware_capture = bool(recon.get('pending_hardware_capture', self.pending_hardware_capture))

        # Carry dumper validation warnings into the emulator report.  The latest
        # Redump-style dumper intentionally warns when filler/security ranges
        # are zero-filled pending hardware capture; that must be visible.
        validation = doc.get('validation') or {}
        for w in validation.get('warnings') or doc.get('warnings') or []:
            if str(w) not in self.validation_warnings:
                self.validation_warnings.append(str(w))

        for state_name, attr in (('locked', 'locked_visible_sector_count'), ('unlocked', 'unlocked_visible_sector_count')):
            state = obs.get(state_name) or {}
            cap_hex = state.get('read_capacity_10_hex')
            raw = _read_capacity_response_from_hex(cap_hex)
            if raw:
                self.drive_state_observations.setdefault(state_name, {})['read_capacity_10'] = raw
                setattr(self, attr, int.from_bytes(raw[:4], 'big') + 1)
            ms = _hex_bytes(state.get('mode_sense_3e_hex'))
            if ms:
                self.drive_state_observations.setdefault(state_name, {})['mode_sense_3e'] = ms

        # Explicit count fields are valid even when the corresponding response
        # hex is absent from a reduced profile.
        if layout.get('drive_locked_visible_sector_count'):
            self.locked_visible_sector_count = _intish(layout.get('drive_locked_visible_sector_count'), self.locked_visible_sector_count)
        if layout.get('drive_reported_unlocked_sector_count'):
            self.unlocked_visible_sector_count = _intish(layout.get('drive_reported_unlocked_sector_count'), self.unlocked_visible_sector_count)

    @property
    def is_redump_style(self) -> bool:
        if str(self.layout_type).lower() == 'original_xbox_xgd1_redump_style_2048':
            return True
        if self.iso_sector_count == XGD1_FULL_REDUMP_SECTORS:
            return True
        if self.video_l1_start_lba and self.video_l1_sector_count and self.game_output_start_lba == XGD1_GAME_OUTPUT_START_LBA:
            return True
        return False

    def layout_dict(self) -> dict[str, Any]:
        return {
            'layout_type': self.layout_type,
            'is_redump_style': self.is_redump_style,
            'visible_start_lba': self.visible_start_lba,
            'visible_sector_count': self.visible_sector_count,
            'locked_visible_sector_count': self.locked_visible_sector_count,
            'unlocked_visible_sector_count': self.unlocked_visible_sector_count,
            'video_l0_start_lba': self.video_l0_start_lba,
            'video_l0_sector_count': self.video_l0_sector_count,
            'pregame_padding_start_lba': self.pregame_padding_start_lba,
            'pregame_padding_sector_count': self.pregame_padding_sector_count,
            'game_output_start_lba': self.game_output_start_lba,
            'game_unlocked_source_start_lba': self.game_unlocked_source_start_lba,
            'game_unlocked_source_sector_count': self.game_unlocked_source_sector_count,
            'game_xiso_leadin_sector_count': self.game_xiso_leadin_sector_count,
            'xdfs_volume_lba_within_game_region': self.xdfs_volume_lba_within_game_region,
            'game_sector_count': self.game_sector_count,
            'reconstructed_output_sector_count': self.reconstructed_output_sector_count,
            'postgame_padding_start_lba': self.postgame_padding_start_lba,
            'postgame_padding_sector_count': self.postgame_padding_sector_count,
            'video_l1_start_lba': self.video_l1_start_lba,
            'video_l1_sector_count': self.video_l1_sector_count,
            'layer_break_lba': self.layer_break_lba,
        }

    def _derive_from_iso(self) -> None:
        if not self.iso_path:
            self.warnings.append('no_iso_path')
            return
        if not self.iso_path.exists():
            self.warnings.append(f'iso_missing:{self.iso_path}')
            return
        self.iso_byte_count = self.iso_path.stat().st_size
        if self.sector_size <= 0:
            self.sector_size = SECTOR_SIZE_DEFAULT
        if self.iso_byte_count % self.sector_size:
            self.warnings.append(f'iso_size_not_sector_aligned:{self.iso_byte_count}')
        self.iso_sector_count = self.iso_byte_count // self.sector_size
        if not self.reconstructed_output_sector_count:
            self.reconstructed_output_sector_count = self.iso_sector_count
        if not self.reconstructed_output_byte_count:
            self.reconstructed_output_byte_count = self.iso_byte_count
        # For corrected Redump-style images, the reconstructed ISO size is not
        # the same thing as the drive's host-visible GDR geometry.  Keep the old
        # xbox_physical_sectors fallback only for legacy contiguous images.
        if not self.xbox_physical_sectors and not self.is_redump_style:
            self.xbox_physical_sectors = self.iso_sector_count
        if not self.layers and self.iso_sector_count > 0:
            self.layers = 2 if self.iso_sector_count > 1_000_000 else 1
        if self.is_redump_style:
            if not self.video_l0_sector_count:
                self.video_l0_sector_count = XGD1_VIDEO_L0_SECTORS
            if not self.video_l1_sector_count:
                self.video_l1_sector_count = XGD1_VIDEO_L1_SECTORS
            if not self.video_l1_start_lba:
                self.video_l1_start_lba = max(0, self.iso_sector_count - self.video_l1_sector_count)
            if not self.game_output_start_lba:
                self.game_output_start_lba = XGD1_GAME_OUTPUT_START_LBA
            if not self.game_sector_count:
                self.game_sector_count = XGD1_UNLOCKED_SECTORS
            if not self.game_xiso_leadin_sector_count:
                self.game_xiso_leadin_sector_count = XGD1_GAME_XISO_LEADIN_SECTORS
            if not self.locked_visible_sector_count:
                self.locked_visible_sector_count = self.video_l0_sector_count + self.video_l1_sector_count
            if not self.unlocked_visible_sector_count:
                self.unlocked_visible_sector_count = self.game_sector_count

    def _load_payloads(self) -> None:
        name_map = {
            'read_dvd_structure_pfi': 'pfi',
            'read_dvd_structure_dmi': 'dmi',
            'read_dvd_structure_xbox_control_block': 'ad_c0',
            'read_dvd_structure_ad_c0': 'ad_c0',
        }
        for rec in self.sidecar.get('scsi_responses') or []:
            if not isinstance(rec, dict):
                continue
            key = name_map.get(str(rec.get('name', '')).lower())
            if not key:
                continue
            data = b''
            src = ''
            if rec.get('response_file'):
                rp = _relpath(self.xdvd_json.parent, rec.get('response_file'))
                if rp and rp.exists():
                    data = rp.read_bytes()
                    src = str(rp)
                else:
                    self.warnings.append(f'payload_missing:{key}:{rp}')
            elif rec.get('response_hex'):
                try:
                    text = str(rec.get('response_hex') or '').replace(' ', '')
                    data = bytes.fromhex(text)
                    src = 'sidecar.response_hex'
                except Exception as exc:
                    self.warnings.append(f'payload_bad_hex:{key}:{exc}')
            if data:
                self.payloads[key] = data
                self.payload_files[key] = src
                self.payload_source[key] = 'captured_payload'
        # New .media.json points at DVD structures too.  Use only when legacy
        # sidecar responses were absent; the .xdvd sidecar remains preferred for
        # exact CDB/replay compatibility.
        dvd_structs = (self.media_profile or {}).get('dvd_structures') or {}
        aliases = {
            'ad_c0': ('ad_c0', 'xbox_control_block', 'read_dvd_structure_xbox_control_block'),
            'pfi': ('pfi', 'physical_format_information'),
            'dmi': ('dmi', 'disc_manufacturing_information'),
        }
        for key, names in aliases.items():
            if key in self.payloads:
                continue
            ref = None
            for name in names:
                if name in dvd_structs:
                    ref = _profile_file_ref(dvd_structs.get(name))
                    break
            rp = _relpath((self.media_profile_path or self.xdvd_json).parent, ref)
            if rp and rp.exists():
                self.payloads[key] = rp.read_bytes()
                self.payload_files[key] = str(rp)
                self.payload_source[key] = 'media_profile_payload'

    def _derive_from_payloads(self) -> None:
        dmi = self.payloads.get('dmi', b'')
        if dmi and not self.media_id:
            mid = dmi[12:20].rstrip(b'\x00 ')
            try:
                self.media_id = mid.decode('ascii', errors='ignore')
            except Exception:
                self.media_id = ''

    def _validate(self) -> None:
        if self.sidecar_sector_count and self.iso_sector_count and self.sidecar_sector_count != self.iso_sector_count:
            self.warnings.append(f'sidecar_sector_count_mismatch:sidecar={self.sidecar_sector_count}:iso={self.iso_sector_count}')
        if self.sidecar_byte_count and self.iso_byte_count and self.sidecar_byte_count != self.iso_byte_count:
            self.warnings.append(f'sidecar_byte_count_mismatch:sidecar={self.sidecar_byte_count}:iso={self.iso_byte_count}')
        if self.xbox_physical_sectors and self.iso_sector_count and self.xbox_physical_sectors != self.iso_sector_count and not self.is_redump_style:
            self.warnings.append(f'physical_sector_count_mismatch:disc={self.xbox_physical_sectors}:iso={self.iso_sector_count}')
        if self.game_output_start_lba and self.game_sector_count and self.iso_sector_count:
            if self.game_output_start_lba + self.game_sector_count > self.iso_sector_count:
                self.warnings.append('game_region_exceeds_iso')
        if self.video_l1_start_lba and self.video_l1_sector_count and self.iso_sector_count:
            if self.video_l1_start_lba + self.video_l1_sector_count > self.iso_sector_count:
                self.warnings.append('video_l1_region_exceeds_iso')
        if self.layer_break_lba and self.iso_sector_count and self.layer_break_lba >= self.iso_sector_count:
            self.warnings.append('layer_break_outside_iso')
        if self.is_redump_style and self.filler_policy == 'zero_fill_until_drive_can_read_filler' and not self.filler_verified_from_disc:
            self.warnings.append('redump_filler_ranges_are_synthetic_zero_fill')
        if 'redump_style_padding_zero_filled_pending_hardware_capture' in self.validation_warnings:
            self.warnings.append('redump_style_padding_zero_filled_pending_hardware_capture')
        if self.reconstructed_output_sector_count and self.iso_sector_count and self.reconstructed_output_sector_count != self.iso_sector_count:
            self.warnings.append(f'reconstructed_output_sector_count_mismatch:profile={self.reconstructed_output_sector_count}:iso={self.iso_sector_count}')

    def read_capacity_10_response(self, state_name: str) -> bytes:
        obs = self.drive_state_observations.get(str(state_name), {})
        if obs.get('read_capacity_10'):
            return obs['read_capacity_10']
        count = self.unlocked_visible_sector_count if str(state_name) == 'unlocked' else self.locked_visible_sector_count
        if count:
            return max(0, int(count) - 1).to_bytes(4, 'big') + int(self.sector_size or SECTOR_SIZE_DEFAULT).to_bytes(4, 'big')
        return b''

    def mode_sense_3e_response(self, state_name: str) -> bytes:
        obs = self.drive_state_observations.get(str(state_name), {})
        return bytes(obs.get('mode_sense_3e') or b'')


    def get_dvd_structure_payload(self, fmt: int) -> tuple[str, bytes] | None:
        if fmt == 0x00 and self.payloads.get('pfi'):
            return 'pfi', self.payloads['pfi']
        if fmt == 0x04 and self.payloads.get('dmi'):
            return 'dmi', self.payloads['dmi']
        if fmt == 0xC0 and self.payloads.get('ad_c0'):
            return 'ad_c0', self.payloads['ad_c0']
        return None

    def read_segments_for_host(self, auth_stage: str, host_lba: int, blocks: int, *, read_map: str = 'firmware_window') -> tuple[list[dict[str, Any]], str]:
        """Map host-visible READ sectors to ISO sectors.

        Returns ``(segments, note)`` where each segment has ``iso_lba`` and
        ``blocks``.  Redump locked/video reads may need two non-contiguous
        segments when a request crosses the L0/L1 split.
        """
        host_lba = int(host_lba)
        blocks = int(blocks)
        if blocks <= 0:
            return [], 'empty'
        mode = str(read_map or 'firmware_window').lower().replace('-', '_')
        end = host_lba + blocks - 1
        if mode in ('direct', 'windows', 'v157', 'physical_raw', 'raw_full', 'raw_iso', 'option1_raw', 'full_raw', 'physical', 'output'):
            return [{'host_lba': host_lba, 'iso_lba': host_lba, 'blocks': blocks, 'segment_note': 'direct'}], 'direct'

        if self.is_redump_style and mode in ('firmware_window', 'firmware_lba_state', 'firmware', 'level2', 'stateful_xbox', 'xbox_raw', 'dual_alias', 'xbox_alias', 'xiso_alias', 'xbox_xiso', 'game_partition'):
            if auth_stage == 'unlocked':
                game_count = self.game_sector_count or self.unlocked_visible_sector_count or XGD1_UNLOCKED_SECTORS
                game_start = self.game_output_start_lba or XGD1_GAME_OUTPUT_START_LBA
                if 0 <= host_lba and end < game_count:
                    return [{'host_lba': host_lba, 'iso_lba': game_start + host_lba, 'blocks': blocks, 'segment_note': 'redump_unlocked_game_xiso_region'}], 'redump_unlocked_game_xiso_region'
                return [{'host_lba': host_lba, 'iso_lba': host_lba, 'blocks': blocks, 'segment_note': 'redump_unlocked_outside_game_direct'}], 'redump_unlocked_outside_game_direct'

            l0 = self.video_l0_sector_count or XGD1_VIDEO_L0_SECTORS
            l1_count = self.video_l1_sector_count or XGD1_VIDEO_L1_SECTORS
            l1_start = self.video_l1_start_lba or max(0, (self.iso_sector_count or XGD1_FULL_REDUMP_SECTORS) - l1_count)
            locked_count = self.locked_visible_sector_count or (l0 + l1_count)
            if host_lba < 0 or end >= locked_count:
                return [{'host_lba': host_lba, 'iso_lba': host_lba, 'blocks': blocks, 'segment_note': 'redump_locked_outside_visible_direct'}], 'redump_locked_outside_visible_direct'
            segs: list[dict[str, Any]] = []
            cur = host_lba
            remain = blocks
            if cur < l0:
                n = min(remain, l0 - cur)
                segs.append({'host_lba': cur, 'iso_lba': cur, 'blocks': n, 'segment_note': 'redump_locked_video_l0'})
                cur += n
                remain -= n
            if remain:
                n = remain
                segs.append({'host_lba': cur, 'iso_lba': l1_start + (cur - l0), 'blocks': n, 'segment_note': 'redump_locked_video_l1_tail'})
            note = 'redump_locked_video_split' if len(segs) > 1 else segs[0]['segment_note']
            return segs, note

        # Legacy contiguous/hybrid layout used by v192-v227 packages.
        if auth_stage == 'unlocked':
            game_output = self.game_output_start_lba
            game_src = self.game_unlocked_source_start_lba
            game_count = self.game_sector_count
            if game_count > 0 and game_src <= host_lba and end < game_src + game_count:
                return [{'host_lba': host_lba, 'iso_lba': game_output + (host_lba - game_src), 'blocks': blocks, 'segment_note': 'legacy_game_src_to_output'}], 'legacy_game_src_to_output'
        return [{'host_lba': host_lba, 'iso_lba': host_lba, 'blocks': blocks, 'segment_note': 'direct_locked_or_no_layout'}], 'direct_locked_or_no_layout'

    def read_host(self, media_path: str | Path, auth_stage: str, host_lba: int, blocks: int, *, read_map: str = 'firmware_window') -> tuple[bytes, dict[str, Any]]:
        path = Path(media_path)
        segs, note = self.read_segments_for_host(auth_stage, host_lba, blocks, read_map=read_map)
        if not path.exists():
            raise FileNotFoundError(f'ISO/raw2048 not found: {path}')
        size = path.stat().st_size
        data = bytearray()
        with path.open('rb') as fh:
            for seg in segs:
                off = int(seg['iso_lba']) * self.sector_size
                length = int(seg['blocks']) * self.sector_size
                if off < 0 or off + length > size:
                    raise ValueError(f'READ outside ISO segment: host_lba={host_lba} iso_lba={seg["iso_lba"]} blocks={seg["blocks"]} off={off} len={length} size={size} note={note}')
                fh.seek(off)
                chunk = fh.read(length)
                if len(chunk) != length:
                    raise IOError(f'short READ from ISO segment: got={len(chunk)} expected={length}')
                data += chunk
        info = {
            'media_layout_type': self.layout_type,
            'media_is_redump_style': self.is_redump_style,
            'media_profile_loaded': self.media_profile_loaded,
            'media_profile_path': str(self.media_profile_path) if self.media_profile_path else '',
            'read_map_note': note,
            'read_segments': segs,
            'media_lba': segs[0]['iso_lba'] if segs else host_lba,
            'media_lba_end': (segs[-1]['iso_lba'] + segs[-1]['blocks'] - 1) if segs else host_lba,
            'media_segment_count': len(segs),
            'effective_media_lba_delta': (int(segs[0]['iso_lba']) - int(host_lba)) if segs else 0,
        }
        return bytes(data), info

    def current_capacity_sector_count(self, auth_stage: str) -> int:
        if auth_stage == 'unlocked':
            return self.unlocked_visible_sector_count or self.game_sector_count or self.iso_sector_count or self.xbox_physical_sectors
        return self.locked_visible_sector_count or XGD1_VIDEO_TOTAL_SECTORS if self.is_redump_style else (self.visible_sector_count or self.iso_sector_count)

    def summary(self) -> dict[str, Any]:
        return {
            'version': 'v227-media-model-payload-integrity',
            'xdvd_json': str(self.xdvd_json),
            'media_profile_path': str(self.media_profile_path) if self.media_profile_path else '',
            'media_profile_loaded': self.media_profile_loaded,
            'iso_path': str(self.iso_path) if self.iso_path else '',
            'sector_size': self.sector_size,
            'iso_byte_count': self.iso_byte_count,
            'iso_sector_count': self.iso_sector_count,
            'sidecar_byte_count': self.sidecar_byte_count,
            'sidecar_sector_count': self.sidecar_sector_count,
            'reconstructed_output_byte_count': self.reconstructed_output_byte_count,
            'xbox_physical_sectors': self.xbox_physical_sectors,
            'layers': self.layers,
            'layout_type': self.layout_type,
            'is_redump_style': self.is_redump_style,
            'layer_break_lba': self.layer_break_lba,
            'visible_start_lba': self.visible_start_lba,
            'visible_sector_count': self.visible_sector_count,
            'locked_visible_sector_count': self.locked_visible_sector_count,
            'unlocked_visible_sector_count': self.unlocked_visible_sector_count,
            'video_l0_sector_count': self.video_l0_sector_count,
            'pregame_padding_start_lba': self.pregame_padding_start_lba,
            'pregame_padding_sector_count': self.pregame_padding_sector_count,
            'game_output_start_lba': self.game_output_start_lba,
            'game_unlocked_source_start_lba': self.game_unlocked_source_start_lba,
            'game_unlocked_source_sector_count': self.game_unlocked_source_sector_count,
            'game_xiso_leadin_sector_count': self.game_xiso_leadin_sector_count,
            'xdfs_volume_lba_within_game_region': self.xdfs_volume_lba_within_game_region,
            'game_sector_count': self.game_sector_count,
            'reconstructed_output_sector_count': self.reconstructed_output_sector_count,
            'postgame_padding_start_lba': self.postgame_padding_start_lba,
            'postgame_padding_sector_count': self.postgame_padding_sector_count,
            'video_l1_start_lba': self.video_l1_start_lba,
            'video_l1_sector_count': self.video_l1_sector_count,
            'filler_policy': self.filler_policy,
            'filler_verified_from_disc': self.filler_verified_from_disc,
            'pending_hardware_capture': self.pending_hardware_capture,
            'media_id': self.media_id,
            'title': self.title,
            'image_crc32': self.image_crc32,
            'image_md5': self.image_md5,
            'image_sha1': self.image_sha1,
            'reference_crc32': self.reference_crc32,
            'reference_md5': self.reference_md5,
            'reference_sha1': self.reference_sha1,
            'drive_state_observation_keys': {k: sorted(v.keys()) for k, v in sorted(self.drive_state_observations.items())},
            'payloads': {k: {'bytes': len(v), 'source': self.payload_files.get(k, '')} for k, v in sorted(self.payloads.items())},
            'validation_warnings': list(self.validation_warnings),
            'warnings': list(dict.fromkeys(self.warnings)),
        }

    def write_summary_json(self, path: str | Path) -> None:
        p = Path(path)
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_text(json.dumps(self.summary(), indent=2, sort_keys=True) + '\n', encoding='utf-8')


if __name__ == '__main__':
    import argparse
    ap = argparse.ArgumentParser(description='Print/validate GDR/XDVD media model summary')
    ap.add_argument('xdvd_json')
    ap.add_argument('iso', nargs='?')
    ap.add_argument('--out')
    ap.add_argument('--map', nargs=3, metavar=('STAGE','LBA','BLOCKS'), help='show mapped READ segments for auth stage/lba/blocks')
    args = ap.parse_args()
    doc = json.loads(Path(args.xdvd_json).read_text(encoding='utf-8'))
    model = GDRMediaModel.from_paths(args.xdvd_json, args.iso, doc)
    out = model.summary()
    if args.map:
        stage, lba, blocks = args.map
        out['map'] = {
            'stage': stage,
            'host_lba': int(lba, 0),
            'blocks': int(blocks, 0),
            'segments_note': model.read_segments_for_host(stage, int(lba, 0), int(blocks, 0), read_map='firmware_window'),
        }
    text = json.dumps(out, indent=2, sort_keys=True) + '\n'
    if args.out:
        Path(args.out).write_text(text, encoding='utf-8')
    else:
        print(text, end='')
