#!/usr/bin/env python3
"""Validate and extract the verified MODE2/2352 ISO9660 filesystem layout.

The read-only ``list`` and transactional ``extract`` commands share one strict
walker. Extraction starts only after the full directory tree is validated.
"""

from __future__ import annotations

import argparse
from dataclasses import dataclass
import hashlib
import os
from pathlib import Path
import re
import shutil
import sys
import tempfile
from typing import Sequence


RAW_SECTOR_SIZE = 2352
USER_DATA_OFFSET = 24
LOGICAL_BLOCK_SIZE = 2048
PVD_LBA = 16
VOLUME_DESCRIPTOR_TERMINATOR = 255
MAX_VOLUME_DESCRIPTORS = 64
DEFAULT_MAX_DIRECTORY_DEPTH = 64
MANIFEST_FILENAME = "MANIFEST.tsv"
MANIFEST_HEADER = "path\tlba\textents\tsize\tsha1\n"
PSX_EXE_MAGIC = b"PS-X EXE"
PSX_EXE_HEADER_SIZE = 0x800
PSX_EXE_INIT_PC_OFFSET = 0x10
PSX_EXE_INIT_GP_OFFSET = 0x14
PSX_EXE_TEXT_ADDRESS_OFFSET = 0x18
PSX_EXE_TEXT_SIZE_OFFSET = 0x1C
PSX_EXE_DATA_ADDRESS_OFFSET = 0x20
PSX_EXE_DATA_SIZE_OFFSET = 0x24
PSX_EXE_BSS_ADDRESS_OFFSET = 0x28
PSX_EXE_BSS_SIZE_OFFSET = 0x2C
PSX_EXE_SP_BASE_OFFSET = 0x30
PSX_EXE_SP_OFFSET_OFFSET = 0x34
UINT32_LIMIT = 1 << 32
MODE2_SYNC = b"\x00" + (b"\xff" * 10) + b"\x00"
SAFE_COMPONENT = re.compile(r"[A-Za-z0-9._;-]+\Z")

DIRECTORY_FLAG = 0x02
ASSOCIATED_FILE_FLAG = 0x04
MULTI_EXTENT_FLAG = 0x80
RESERVED_FILE_FLAGS = 0x60
SPECIAL_CURRENT_DIRECTORY = b"\x00"
SPECIAL_PARENT_DIRECTORY = b"\x01"
SPECIAL_IDENTIFIERS = {SPECIAL_CURRENT_DIRECTORY, SPECIAL_PARENT_DIRECTORY}


class IsoValidationError(ValueError):
    """Raised when the input cannot be safely treated as this ISO filesystem."""


class PsxExeValidationError(ValueError):
    """Raised when a manifest-tracked file is not a valid PS-X EXE."""


@dataclass(frozen=True)
class IsoExtent:
    """One non-interleaved file-data extent, measured in logical blocks."""

    lba: int
    size: int


@dataclass(frozen=True)
class IsoEntry:
    """A validated filesystem entry below the ISO root directory."""

    path: str
    is_directory: bool
    size: int
    extents: tuple[IsoExtent, ...]
    flags: int

    @property
    def lba(self) -> int:
        """Return the first file-data LBA for a single- or multi-extent entry."""
        return self.extents[0].lba


@dataclass(frozen=True)
class ExtractionResult:
    """The non-content result of one successful extraction."""

    output_root: Path
    manifest_path: Path
    file_count: int
    byte_count: int


@dataclass(frozen=True)
class ManifestEntry:
    """The validated manifest fields used to identify one extracted file."""

    path: str
    lba: int
    size: int
    sha1: str


@dataclass(frozen=True)
class PsxExeInfo:
    """Validated PS-X EXE metadata, not a runtime observation."""

    path: str
    sha1: str
    file_size: int
    text_load_address: int
    text_size: int
    entry_pc: int
    initial_gp: int
    sp_base: int
    sp_offset: int


@dataclass(frozen=True)
class DirectoryRecord:
    """The validated fields needed from one ISO9660 directory record."""

    identifier: bytes
    extent_lba: int
    data_lba: int
    data_length: int
    extended_attribute_blocks: int
    flags: int
    volume_sequence: int

    @property
    def is_directory(self) -> bool:
        return bool(self.flags & DIRECTORY_FLAG)

    @property
    def continues(self) -> bool:
        return bool(self.flags & MULTI_EXTENT_FLAG)


@dataclass(frozen=True)
class Volume:
    """Validated primary-volume information used by the walker."""

    space_size: int
    volume_sequence: int
    root: DirectoryRecord


class RawMode2Image:
    """Read fixed-size MODE2 raw sectors and expose their 2048-byte user data."""

    def __init__(self, path: str | Path) -> None:
        self.path = Path(path)
        try:
            self._file = self.path.open("rb")
            self.size = self._file.seek(0, 2)
            self._file.seek(0)
        except OSError as exc:
            raise IsoValidationError(f"cannot open image {self.path}: {exc}") from exc

        if self.size % RAW_SECTOR_SIZE:
            self.close()
            raise IsoValidationError(
                f"image size {self.size} is not a multiple of {RAW_SECTOR_SIZE} bytes"
            )

        self.raw_sector_count = self.size // RAW_SECTOR_SIZE
        if self.raw_sector_count <= PVD_LBA + 1:
            self.close()
            raise IsoValidationError("image is too small to contain an ISO9660 descriptor set")

    def __enter__(self) -> RawMode2Image:
        return self

    def __exit__(self, exc_type: object, exc_value: object, traceback: object) -> None:
        self.close()

    def close(self) -> None:
        file = getattr(self, "_file", None)
        if file is not None:
            file.close()
            self._file = None

    def ensure_raw_range(self, lba: int, blocks: int, context: str) -> None:
        if lba < 0 or blocks < 0:
            raise IsoValidationError(f"{context}: negative LBA or block count")
        if lba > self.raw_sector_count or blocks > self.raw_sector_count - lba:
            raise IsoValidationError(
                f"{context}: LBA range {lba}+{blocks} exceeds image sector count "
                f"{self.raw_sector_count}"
            )

    def read_user_block(self, lba: int) -> bytes:
        self.ensure_raw_range(lba, 1, "logical-block read")
        raw_offset = lba * RAW_SECTOR_SIZE
        try:
            self._file.seek(raw_offset)
            sector = self._file.read(RAW_SECTOR_SIZE)
        except OSError as exc:
            raise IsoValidationError(f"cannot read raw sector {lba}: {exc}") from exc

        if len(sector) != RAW_SECTOR_SIZE:
            raise IsoValidationError(f"raw sector {lba} is truncated")
        if sector[: len(MODE2_SYNC)] != MODE2_SYNC:
            raise IsoValidationError(f"raw sector {lba} has no MODE2 sync pattern")
        if sector[15] != 2:
            raise IsoValidationError(f"raw sector {lba} is not MODE2 (mode={sector[15]})")

        user_data = sector[USER_DATA_OFFSET : USER_DATA_OFFSET + LOGICAL_BLOCK_SIZE]
        if len(user_data) != LOGICAL_BLOCK_SIZE:
            raise IsoValidationError(f"raw sector {lba} has truncated MODE2 user data")
        return user_data


def _both_endian_u16(data: bytes, offset: int, context: str) -> int:
    if offset < 0 or offset + 4 > len(data):
        raise IsoValidationError(f"{context}: truncated both-endian 16-bit value")
    little = int.from_bytes(data[offset : offset + 2], "little")
    big = int.from_bytes(data[offset + 2 : offset + 4], "big")
    if little != big:
        raise IsoValidationError(
            f"{context}: little-endian value {little} disagrees with big-endian value {big}"
        )
    return little


def _both_endian_u32(data: bytes, offset: int, context: str) -> int:
    if offset < 0 or offset + 8 > len(data):
        raise IsoValidationError(f"{context}: truncated both-endian 32-bit value")
    little = int.from_bytes(data[offset : offset + 4], "little")
    big = int.from_bytes(data[offset + 4 : offset + 8], "big")
    if little != big:
        raise IsoValidationError(
            f"{context}: little-endian value {little} disagrees with big-endian value {big}"
        )
    return little


def _validate_descriptor_header(descriptor: bytes, lba: int) -> None:
    if len(descriptor) != LOGICAL_BLOCK_SIZE:
        raise IsoValidationError(f"volume descriptor at LBA {lba} is truncated")
    if descriptor[1:6] != b"CD001":
        raise IsoValidationError(f"volume descriptor at LBA {lba} has no CD001 identifier")
    if descriptor[6] != 1:
        raise IsoValidationError(
            f"volume descriptor at LBA {lba} has unsupported version {descriptor[6]}"
        )


def _parse_directory_record(
    record_data: bytes,
    *,
    context: str,
    volume_space: int,
    raw_sector_count: int,
    expected_volume_sequence: int,
) -> DirectoryRecord:
    if not record_data:
        raise IsoValidationError(f"{context}: missing directory-record length")

    record_length = record_data[0]
    if record_length == 0:
        raise IsoValidationError(f"{context}: zero-length directory record")
    if record_length > len(record_data):
        raise IsoValidationError(
            f"{context}: record length {record_length} exceeds available bytes {len(record_data)}"
        )
    if record_length < 34:
        raise IsoValidationError(f"{context}: record length {record_length} is shorter than 34")

    record = record_data[:record_length]
    identifier_length = record[32]
    if identifier_length == 0:
        raise IsoValidationError(f"{context}: directory record has an empty identifier")
    padding_length = 1 if identifier_length % 2 == 0 else 0
    minimum_length = 33 + identifier_length + padding_length
    if minimum_length > record_length:
        raise IsoValidationError(
            f"{context}: identifier length {identifier_length} exceeds record length {record_length}"
        )
    if padding_length and record[33 + identifier_length] != 0:
        raise IsoValidationError(f"{context}: identifier padding byte is nonzero")

    flags = record[25]
    if flags & RESERVED_FILE_FLAGS:
        raise IsoValidationError(f"{context}: directory record sets reserved file flags {flags:#04x}")
    if record[26] != 0 or record[27] != 0:
        raise IsoValidationError(f"{context}: interleaved files are not supported")

    extent_lba = _both_endian_u32(record, 2, f"{context} extent")
    data_length = _both_endian_u32(record, 10, f"{context} data length")
    volume_sequence = _both_endian_u16(record, 28, f"{context} volume sequence")
    if volume_sequence != expected_volume_sequence:
        raise IsoValidationError(
            f"{context}: volume sequence {volume_sequence} is not "
            f"{expected_volume_sequence}"
        )

    extended_attribute_blocks = record[1]
    data_lba = extent_lba + extended_attribute_blocks
    data_blocks = (data_length + LOGICAL_BLOCK_SIZE - 1) // LOGICAL_BLOCK_SIZE

    if extent_lba >= volume_space:
        raise IsoValidationError(
            f"{context}: extent LBA {extent_lba} is outside volume size {volume_space}"
        )
    if data_lba > volume_space or data_blocks > volume_space - data_lba:
        raise IsoValidationError(
            f"{context}: data range {data_lba}+{data_blocks} exceeds volume size {volume_space}"
        )
    if extent_lba >= raw_sector_count:
        raise IsoValidationError(
            f"{context}: extent LBA {extent_lba} is outside image sector count {raw_sector_count}"
        )
    if data_lba > raw_sector_count or data_blocks > raw_sector_count - data_lba:
        raise IsoValidationError(
            f"{context}: data range {data_lba}+{data_blocks} exceeds image sector count "
            f"{raw_sector_count}"
        )

    identifier = record[33 : 33 + identifier_length]
    if identifier in SPECIAL_IDENTIFIERS:
        if not (flags & DIRECTORY_FLAG):
            raise IsoValidationError(f"{context}: special directory identifier is not a directory")
        if flags & MULTI_EXTENT_FLAG:
            raise IsoValidationError(f"{context}: special directory identifier is multi-extent")

    return DirectoryRecord(
        identifier=identifier,
        extent_lba=extent_lba,
        data_lba=data_lba,
        data_length=data_length,
        extended_attribute_blocks=extended_attribute_blocks,
        flags=flags,
        volume_sequence=volume_sequence,
    )


def _read_volume(image: RawMode2Image) -> Volume:
    pvd = image.read_user_block(PVD_LBA)
    _validate_descriptor_header(pvd, PVD_LBA)
    if pvd[0] != 1:
        raise IsoValidationError(f"LBA {PVD_LBA} is not a primary volume descriptor")

    volume_space = _both_endian_u32(pvd, 80, "primary volume descriptor volume-space size")
    if volume_space <= PVD_LBA + 1:
        raise IsoValidationError(f"volume size {volume_space} cannot contain a descriptor set")
    if volume_space > image.raw_sector_count:
        raise IsoValidationError(
            f"volume size {volume_space} exceeds image sector count {image.raw_sector_count}"
        )

    volume_set_size = _both_endian_u16(pvd, 120, "primary volume descriptor volume-set size")
    volume_sequence = _both_endian_u16(
        pvd, 124, "primary volume descriptor volume sequence"
    )
    if volume_set_size != 1 or volume_sequence != 1:
        raise IsoValidationError(
            "multi-volume ISO9660 sets are not supported "
            f"(set size {volume_set_size}, sequence {volume_sequence})"
        )

    block_size = _both_endian_u16(pvd, 128, "primary volume descriptor logical block size")
    if block_size != LOGICAL_BLOCK_SIZE:
        raise IsoValidationError(
            f"logical block size {block_size} is not {LOGICAL_BLOCK_SIZE}"
        )

    terminator_found = False
    descriptor_limit = min(volume_space, PVD_LBA + 1 + MAX_VOLUME_DESCRIPTORS)
    for lba in range(PVD_LBA + 1, descriptor_limit):
        descriptor = image.read_user_block(lba)
        _validate_descriptor_header(descriptor, lba)
        descriptor_type = descriptor[0]
        if descriptor_type == VOLUME_DESCRIPTOR_TERMINATOR:
            terminator_found = True
            break
        if descriptor_type not in {0, 1, 2, 3}:
            raise IsoValidationError(
                f"volume descriptor at LBA {lba} has unsupported type {descriptor_type}"
            )
    if not terminator_found:
        raise IsoValidationError(
            f"no volume-descriptor terminator found within {MAX_VOLUME_DESCRIPTORS} descriptors"
        )

    root_record_length = pvd[156]
    if root_record_length == 0:
        raise IsoValidationError("primary volume descriptor has no root directory record")
    if 156 + root_record_length > len(pvd):
        raise IsoValidationError("primary volume descriptor root directory record is truncated")
    root = _parse_directory_record(
        pvd[156 : 156 + root_record_length],
        context="primary volume descriptor root directory record",
        volume_space=volume_space,
        raw_sector_count=image.raw_sector_count,
        expected_volume_sequence=volume_sequence,
    )
    if root.identifier != SPECIAL_CURRENT_DIRECTORY or not root.is_directory:
        raise IsoValidationError("primary volume descriptor root record is not a directory identifier")
    if root.continues:
        raise IsoValidationError("primary volume descriptor root directory is multi-extent")
    if root.data_length == 0:
        raise IsoValidationError("primary volume descriptor root directory has zero length")

    return Volume(space_size=volume_space, volume_sequence=volume_sequence, root=root)


def _safe_component(identifier: bytes, context: str) -> str:
    try:
        component = identifier.decode("ascii")
    except UnicodeDecodeError as exc:
        raise IsoValidationError(f"{context}: identifier is not ASCII") from exc

    if component in {"", ".", ".."}:
        raise IsoValidationError(f"{context}: unsafe empty or dot path component")
    if not SAFE_COMPONENT.fullmatch(component):
        raise IsoValidationError(f"{context}: unsafe path component {component!r}")
    if len(component.encode("ascii")) > 255:
        raise IsoValidationError(f"{context}: path component exceeds 255 bytes")
    return component


def _same_directory_target(left: DirectoryRecord, right: DirectoryRecord) -> bool:
    return left.data_lba == right.data_lba and left.data_length == right.data_length


class Iso9660Walker:
    """Walk one validated single-volume ISO9660 filesystem without writing files."""

    def __init__(self, image_path: str | Path, *, max_depth: int = DEFAULT_MAX_DIRECTORY_DEPTH) -> None:
        if max_depth < 0:
            raise ValueError("max_depth must not be negative")
        self.image_path = Path(image_path)
        self.max_depth = max_depth
        self._image: RawMode2Image | None = None
        self._volume: Volume | None = None
        self._seen_directories: set[tuple[int, int]] = set()
        self._seen_paths: set[str] = set()
        self._entries: list[IsoEntry] = []

    def walk(self) -> tuple[IsoEntry, ...]:
        with RawMode2Image(self.image_path) as image:
            return self._walk_open_image(image)

    def _walk_open_image(self, image: RawMode2Image) -> tuple[IsoEntry, ...]:
        """Walk an already-open image so extraction cannot switch inputs mid-run."""
        self._image = image
        self._volume = _read_volume(image)
        self._seen_directories.clear()
        self._seen_paths.clear()
        self._entries.clear()
        self._walk_directory(
            directory=self._volume.root,
            parent=self._volume.root,
            parts=(),
            depth=0,
        )
        return tuple(sorted(self._entries, key=lambda entry: entry.path))

    @property
    def image(self) -> RawMode2Image:
        if self._image is None:
            raise RuntimeError("ISO walker image is not open")
        return self._image

    @property
    def volume(self) -> Volume:
        if self._volume is None:
            raise RuntimeError("ISO walker volume has not been read")
        return self._volume

    def _walk_directory(
        self,
        *,
        directory: DirectoryRecord,
        parent: DirectoryRecord,
        parts: tuple[str, ...],
        depth: int,
    ) -> None:
        if depth > self.max_depth:
            raise IsoValidationError(
                f"directory {'/'.join(parts) or '<root>'} exceeds maximum depth {self.max_depth}"
            )

        directory_key = (directory.data_lba, directory.data_length)
        if directory_key in self._seen_directories:
            raise IsoValidationError(
                f"directory {'/'.join(parts) or '<root>'} reuses extent "
                f"{directory.data_lba}+{directory.data_length}; cycle or alias refused"
            )
        self._seen_directories.add(directory_key)

        records = self._read_directory_records(directory, parts)
        self._validate_dot_records(records, directory, parent, parts)

        for group in self._coalesce_entries(records, parts):
            first = group[0]
            component = _safe_component(
                first.identifier, f"directory {'/'.join(parts) or '<root>'}"
            )
            child_parts = (*parts, component)
            relative_path = "/".join(child_parts)
            if len(relative_path.encode("ascii")) > 4096:
                raise IsoValidationError(f"path {relative_path!r} exceeds 4096 bytes")
            if relative_path in self._seen_paths:
                raise IsoValidationError(f"duplicate filesystem path {relative_path!r}")
            self._seen_paths.add(relative_path)

            if first.flags & ASSOCIATED_FILE_FLAG:
                raise IsoValidationError(
                    f"path {relative_path!r} is an associated file, which is not supported"
                )

            extents = tuple(IsoExtent(record.data_lba, record.data_length) for record in group)
            entry = IsoEntry(
                path=relative_path,
                is_directory=first.is_directory,
                size=sum(extent.size for extent in extents),
                extents=extents,
                flags=first.flags & ~MULTI_EXTENT_FLAG,
            )
            self._entries.append(entry)

            if first.is_directory:
                if len(group) != 1:
                    raise IsoValidationError(f"directory {relative_path!r} is multi-extent")
                if first.data_length == 0:
                    raise IsoValidationError(f"directory {relative_path!r} has zero length")
                self._walk_directory(
                    directory=first,
                    parent=directory,
                    parts=child_parts,
                    depth=depth + 1,
                )

    def _read_directory_records(
        self, directory: DirectoryRecord, parts: tuple[str, ...]
    ) -> list[DirectoryRecord]:
        if not directory.is_directory:
            raise IsoValidationError("attempted to walk a non-directory record")
        if directory.data_length == 0:
            raise IsoValidationError(f"directory {'/'.join(parts) or '<root>'} has zero length")

        records: list[DirectoryRecord] = []
        block_count = (directory.data_length + LOGICAL_BLOCK_SIZE - 1) // LOGICAL_BLOCK_SIZE
        for block_index in range(block_count):
            block = self.image.read_user_block(directory.data_lba + block_index)
            remaining = directory.data_length - (block_index * LOGICAL_BLOCK_SIZE)
            valid_bytes = min(remaining, LOGICAL_BLOCK_SIZE)
            cursor = 0
            while cursor < valid_bytes:
                record_length = block[cursor]
                record_context = (
                    f"directory {'/'.join(parts) or '<root>'}, LBA "
                    f"{directory.data_lba + block_index}, byte {cursor}"
                )
                if record_length == 0:
                    if any(block[cursor:valid_bytes]):
                        raise IsoValidationError(
                            f"{record_context}: nonzero data follows directory padding"
                        )
                    break
                if record_length > LOGICAL_BLOCK_SIZE - cursor:
                    raise IsoValidationError(
                        f"{record_context}: directory record crosses a logical-block boundary"
                    )
                if record_length > valid_bytes - cursor:
                    raise IsoValidationError(
                        f"{record_context}: directory record exceeds declared directory length"
                    )
                records.append(
                    _parse_directory_record(
                        block[cursor : cursor + record_length],
                        context=record_context,
                        volume_space=self.volume.space_size,
                        raw_sector_count=self.image.raw_sector_count,
                        expected_volume_sequence=self.volume.volume_sequence,
                    )
                )
                cursor += record_length
        if not records:
            raise IsoValidationError(f"directory {'/'.join(parts) or '<root>'} has no records")
        return records

    def _validate_dot_records(
        self,
        records: Sequence[DirectoryRecord],
        directory: DirectoryRecord,
        parent: DirectoryRecord,
        parts: tuple[str, ...],
    ) -> None:
        current = [record for record in records if record.identifier == SPECIAL_CURRENT_DIRECTORY]
        previous = [record for record in records if record.identifier == SPECIAL_PARENT_DIRECTORY]
        display_path = "/".join(parts) or "<root>"
        if len(current) != 1 or len(previous) != 1:
            raise IsoValidationError(
                f"directory {display_path} must contain exactly one current and parent record"
            )
        if not _same_directory_target(current[0], directory):
            raise IsoValidationError(f"directory {display_path} current record does not point to itself")
        if not _same_directory_target(previous[0], parent):
            raise IsoValidationError(f"directory {display_path} parent record does not point to its parent")

    def _coalesce_entries(
        self, records: Sequence[DirectoryRecord], parts: tuple[str, ...]
    ) -> list[tuple[DirectoryRecord, ...]]:
        ordinary = [record for record in records if record.identifier not in SPECIAL_IDENTIFIERS]
        groups: list[tuple[DirectoryRecord, ...]] = []
        index = 0
        display_path = "/".join(parts) or "<root>"

        while index < len(ordinary):
            first = ordinary[index]
            if first.is_directory and first.continues:
                raise IsoValidationError(
                    f"directory {display_path}: directory identifier is multi-extent"
                )

            group = [first]
            index += 1
            while group[-1].continues:
                if index >= len(ordinary):
                    raise IsoValidationError(
                        f"directory {display_path}: unterminated multi-extent file"
                    )
                following = ordinary[index]
                if following.identifier != first.identifier:
                    raise IsoValidationError(
                        f"directory {display_path}: multi-extent file identifier changes"
                    )
                if following.is_directory:
                    raise IsoValidationError(
                        f"directory {display_path}: multi-extent file becomes a directory"
                    )
                if (following.flags & ~MULTI_EXTENT_FLAG) != (
                    first.flags & ~MULTI_EXTENT_FLAG
                ):
                    raise IsoValidationError(
                        f"directory {display_path}: multi-extent file flags change"
                    )
                group.append(following)
                index += 1

            groups.append(tuple(group))

        return groups


def walk_image(image_path: str | Path, *, max_depth: int = DEFAULT_MAX_DIRECTORY_DEPTH) -> tuple[IsoEntry, ...]:
    """Return every validated non-root entry in deterministic path order."""
    return Iso9660Walker(image_path, max_depth=max_depth).walk()


def _path_lexists(path: Path) -> bool:
    """Return whether a path exists, including a broken symbolic link."""
    return os.path.lexists(path)


def _prepare_output_directory(destination: str | Path) -> tuple[Path, Path]:
    """Create a private sibling directory; never overwrite a destination."""
    requested = Path(destination)
    if requested.name in {"", ".", ".."}:
        raise IsoValidationError("extraction destination must name a directory")

    parent = requested.parent
    try:
        parent.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        raise IsoValidationError(f"cannot create output parent {parent}: {exc}") from exc
    if not parent.is_dir() or parent.is_symlink():
        raise IsoValidationError(f"output parent {parent} is not a real directory")

    try:
        resolved_parent = parent.resolve(strict=True)
    except OSError as exc:
        raise IsoValidationError(f"cannot resolve output parent {parent}: {exc}") from exc
    final_root = resolved_parent / requested.name
    if _path_lexists(final_root):
        raise IsoValidationError(
            f"extraction destination {final_root} already exists; refusing to overwrite it"
        )

    try:
        temporary_root = Path(
            tempfile.mkdtemp(prefix=f".{final_root.name}.tmp-", dir=resolved_parent)
        ).resolve(strict=True)
    except OSError as exc:
        raise IsoValidationError(
            f"cannot create temporary extraction directory beside {final_root}: {exc}"
        ) from exc
    return final_root, temporary_root


def _entry_output_path(root: Path, iso_path: str) -> Path:
    """Convert a walker-validated ISO path into a path strictly below ``root``."""
    components = iso_path.split("/")
    if not components:
        raise IsoValidationError("empty extraction path")
    for component in components:
        try:
            encoded_component = component.encode("ascii")
        except UnicodeEncodeError as exc:
            raise IsoValidationError(f"non-ASCII extraction path component {component!r}") from exc
        if _safe_component(encoded_component, "extraction path") != component:
            raise IsoValidationError(f"unsafe extraction path component {component!r}")

    destination = root.joinpath(*components)
    try:
        destination.relative_to(root)
    except ValueError as exc:
        raise IsoValidationError(f"extraction path {iso_path!r} escapes its output root") from exc
    return destination


def _create_output_directories(root: Path, entries: Sequence[IsoEntry]) -> None:
    for entry in entries:
        if not entry.is_directory:
            continue
        destination = _entry_output_path(root, entry.path)
        try:
            destination.mkdir(parents=True, exist_ok=False)
        except OSError as exc:
            raise IsoValidationError(
                f"cannot create output directory for {entry.path!r}: {exc}"
            ) from exc


def _copy_file_entry(image: RawMode2Image, entry: IsoEntry, destination: Path) -> str:
    """Copy one validated file and return the SHA-1 of exactly written bytes."""
    if entry.is_directory:
        raise IsoValidationError(f"cannot copy directory entry {entry.path!r} as a file")

    digest = hashlib.sha1()
    copied = 0
    try:
        with destination.open("xb") as output:
            for extent in entry.extents:
                remaining = extent.size
                lba = extent.lba
                while remaining:
                    block = image.read_user_block(lba)
                    chunk_size = min(remaining, LOGICAL_BLOCK_SIZE)
                    chunk = block[:chunk_size]
                    written = output.write(chunk)
                    if written != chunk_size:
                        raise IsoValidationError(
                            f"short write while extracting {entry.path!r}: "
                            f"wrote {written} of {chunk_size} bytes"
                        )
                    digest.update(chunk)
                    copied += chunk_size
                    remaining -= chunk_size
                    lba += 1
    except OSError as exc:
        raise IsoValidationError(f"cannot write extracted file {entry.path!r}: {exc}") from exc

    if copied != entry.size:
        raise IsoValidationError(
            f"extracted byte count {copied} does not match declared size {entry.size} "
            f"for {entry.path!r}"
        )
    return digest.hexdigest()


def _manifest_extent_field(entry: IsoEntry) -> str:
    return ",".join(f"{extent.lba}:{extent.size}" for extent in entry.extents)


def _write_manifest(root: Path, records: Sequence[tuple[IsoEntry, str]]) -> Path:
    manifest_path = root / MANIFEST_FILENAME
    try:
        with manifest_path.open("x", encoding="ascii", newline="\n") as manifest:
            manifest.write(MANIFEST_HEADER)
            for entry, digest in records:
                manifest.write(
                    f"{entry.path}\t{entry.lba}\t{_manifest_extent_field(entry)}\t"
                    f"{entry.size}\t{digest}\n"
                )
    except OSError as exc:
        raise IsoValidationError(f"cannot write manifest {manifest_path}: {exc}") from exc
    return manifest_path


def _validate_manifest_path(entries: Sequence[IsoEntry]) -> None:
    for entry in entries:
        if entry.path == MANIFEST_FILENAME or entry.path.startswith(f"{MANIFEST_FILENAME}/"):
            raise IsoValidationError(
                f"ISO path {entry.path!r} collides with generated manifest {MANIFEST_FILENAME!r}"
            )


def extract_image(
    image_path: str | Path,
    output_root: str | Path,
    *,
    max_depth: int = DEFAULT_MAX_DIRECTORY_DEPTH,
) -> ExtractionResult:
    """Validate and atomically extract all regular files to a fresh output directory."""
    temporary_root: Path | None = None
    try:
        with RawMode2Image(image_path) as image:
            walker = Iso9660Walker(image_path, max_depth=max_depth)
            entries = walker._walk_open_image(image)
            _validate_manifest_path(entries)
            final_root, temporary_root = _prepare_output_directory(output_root)
            _create_output_directories(temporary_root, entries)

            records: list[tuple[IsoEntry, str]] = []
            for entry in entries:
                if entry.is_directory:
                    continue
                destination = _entry_output_path(temporary_root, entry.path)
                if not destination.parent.is_dir():
                    raise IsoValidationError(
                        f"output parent for {entry.path!r} was not created as a directory"
                    )
                records.append((entry, _copy_file_entry(image, entry, destination)))
            _write_manifest(temporary_root, records)

        if _path_lexists(final_root):
            raise IsoValidationError(
                f"extraction destination {final_root} appeared during extraction; refusing to overwrite it"
            )
        try:
            temporary_root.rename(final_root)
        except OSError as exc:
            raise IsoValidationError(
                f"cannot atomically install extraction at {final_root}: {exc}"
            ) from exc

        result = ExtractionResult(
            output_root=final_root,
            manifest_path=final_root / MANIFEST_FILENAME,
            file_count=len(records),
            byte_count=sum(entry.size for entry, _digest in records),
        )
        temporary_root = None
        return result
    except Exception:
        if temporary_root is not None:
            shutil.rmtree(temporary_root, ignore_errors=True)
        raise


def _validate_manifest_relative_path(path: str, context: str) -> None:
    components = path.split("/")
    if not components:
        raise PsxExeValidationError(f"{context}: empty relative path")
    for component in components:
        try:
            encoded_component = component.encode("ascii")
        except UnicodeEncodeError as exc:
            raise PsxExeValidationError(
                f"{context}: non-ASCII path component {component!r}"
            ) from exc
        if _safe_component(encoded_component, context) != component:
            raise PsxExeValidationError(f"{context}: unsafe path component {component!r}")


def _parse_manifest_extents(field: str, context: str) -> tuple[tuple[int, int], ...]:
    if not field:
        raise PsxExeValidationError(f"{context}: empty extent field")
    extents: list[tuple[int, int]] = []
    for encoded_extent in field.split(","):
        lba_text, separator, size_text = encoded_extent.partition(":")
        if not separator or ":" in size_text:
            raise PsxExeValidationError(f"{context}: malformed extent {encoded_extent!r}")
        if not re.fullmatch(r"[0-9]+", lba_text) or not re.fullmatch(r"[0-9]+", size_text):
            raise PsxExeValidationError(f"{context}: non-decimal extent {encoded_extent!r}")
        extents.append((int(lba_text), int(size_text)))
    return tuple(extents)


def _read_manifest_entry(manifest_path: str | Path, expected_path: str) -> ManifestEntry:
    manifest = Path(manifest_path)
    if manifest.is_symlink():
        raise PsxExeValidationError(f"manifest {manifest} must not be a symbolic link")
    try:
        manifest_text = manifest.read_text(encoding="ascii")
    except UnicodeDecodeError as exc:
        raise PsxExeValidationError(f"manifest {manifest} is not ASCII") from exc
    except OSError as exc:
        raise PsxExeValidationError(f"cannot read manifest {manifest}: {exc}") from exc

    lines = manifest_text.splitlines()
    if not lines or lines[0] != MANIFEST_HEADER.rstrip("\n"):
        raise PsxExeValidationError(f"manifest {manifest} has an unexpected header")

    candidate: ManifestEntry | None = None
    seen_paths: set[str] = set()
    for line_number, line in enumerate(lines[1:], start=2):
        fields = line.split("\t")
        if len(fields) != 5:
            raise PsxExeValidationError(
                f"manifest {manifest}, line {line_number}: expected five tab-separated fields"
            )
        path, lba_text, extent_text, size_text, sha1 = fields
        context = f"manifest {manifest}, line {line_number}"
        _validate_manifest_relative_path(path, context)
        if path in seen_paths:
            raise PsxExeValidationError(f"{context}: duplicate path")
        seen_paths.add(path)
        if not re.fullmatch(r"[0-9]+", lba_text) or not re.fullmatch(r"[0-9]+", size_text):
            raise PsxExeValidationError(f"{context}: non-decimal LBA or size")
        if not re.fullmatch(r"[0-9a-f]{40}", sha1):
            raise PsxExeValidationError(f"{context}: malformed SHA-1")
        extents = _parse_manifest_extents(extent_text, context)
        lba = int(lba_text)
        size = int(size_text)
        if extents[0][0] != lba:
            raise PsxExeValidationError(f"{context}: first extent does not match LBA field")
        if sum(extent_size for _extent_lba, extent_size in extents) != size:
            raise PsxExeValidationError(f"{context}: extents do not sum to file size")
        if path == expected_path:
            candidate = ManifestEntry(path=path, lba=lba, size=size, sha1=sha1)

    if candidate is None:
        raise PsxExeValidationError(
            f"manifest {manifest} has no entry for executable path {expected_path!r}"
        )
    return candidate


def _relative_path_from_manifest_root(executable_path: str | Path, manifest_path: str | Path) -> str:
    executable = Path(executable_path)
    manifest = Path(manifest_path)
    if executable.is_symlink():
        raise PsxExeValidationError(f"executable {executable} must not be a symbolic link")
    try:
        root = manifest.parent.resolve(strict=True)
        resolved_executable = executable.resolve(strict=True)
    except OSError as exc:
        raise PsxExeValidationError(f"cannot resolve executable or manifest root: {exc}") from exc
    if not resolved_executable.is_file():
        raise PsxExeValidationError(f"executable {executable} is not a regular file")
    try:
        relative_path = resolved_executable.relative_to(root).as_posix()
    except ValueError as exc:
        raise PsxExeValidationError(
            f"executable {executable} is outside manifest root {root}"
        ) from exc
    _validate_manifest_relative_path(relative_path, "executable path")
    return relative_path


def _read_psx_exe_and_hash(executable_path: str | Path) -> tuple[bytes, int, str]:
    executable = Path(executable_path)
    if executable.is_symlink():
        raise PsxExeValidationError(f"executable {executable} must not be a symbolic link")

    digest = hashlib.sha1()
    file_size = 0
    try:
        with executable.open("rb") as executable_file:
            header = executable_file.read(PSX_EXE_HEADER_SIZE)
            if len(header) != PSX_EXE_HEADER_SIZE:
                raise PsxExeValidationError(
                    f"executable {executable} is shorter than the {PSX_EXE_HEADER_SIZE:#x}-byte header"
                )
            digest.update(header)
            file_size += len(header)
            while chunk := executable_file.read(1024 * 1024):
                digest.update(chunk)
                file_size += len(chunk)
    except OSError as exc:
        raise PsxExeValidationError(f"cannot read executable {executable}: {exc}") from exc
    return header, file_size, digest.hexdigest()


def _header_u32(header: bytes, offset: int, field_name: str) -> int:
    if offset < 0 or offset + 4 > len(header):
        raise PsxExeValidationError(f"truncated PS-X EXE field {field_name}")
    return int.from_bytes(header[offset : offset + 4], "little")


def _validate_psx_address_range(address: int, size: int, field_name: str) -> None:
    if address + size > UINT32_LIMIT:
        raise PsxExeValidationError(f"PS-X EXE {field_name} range wraps 32-bit address space")


def _parse_psx_exe_header(
    header: bytes,
    *,
    path: str,
    sha1: str,
    file_size: int,
) -> PsxExeInfo:
    if len(header) != PSX_EXE_HEADER_SIZE:
        raise PsxExeValidationError("truncated PS-X EXE header")
    if header[: len(PSX_EXE_MAGIC)] != PSX_EXE_MAGIC:
        raise PsxExeValidationError("PS-X EXE magic is missing")

    entry_pc = _header_u32(header, PSX_EXE_INIT_PC_OFFSET, "initial PC")
    initial_gp = _header_u32(header, PSX_EXE_INIT_GP_OFFSET, "initial GP")
    text_load_address = _header_u32(header, PSX_EXE_TEXT_ADDRESS_OFFSET, "text address")
    text_size = _header_u32(header, PSX_EXE_TEXT_SIZE_OFFSET, "text size")
    data_address = _header_u32(header, PSX_EXE_DATA_ADDRESS_OFFSET, "data address")
    data_size = _header_u32(header, PSX_EXE_DATA_SIZE_OFFSET, "data size")
    bss_address = _header_u32(header, PSX_EXE_BSS_ADDRESS_OFFSET, "BSS address")
    bss_size = _header_u32(header, PSX_EXE_BSS_SIZE_OFFSET, "BSS size")
    sp_base = _header_u32(header, PSX_EXE_SP_BASE_OFFSET, "SP base")
    sp_offset = _header_u32(header, PSX_EXE_SP_OFFSET_OFFSET, "SP offset")

    if text_size == 0:
        raise PsxExeValidationError("PS-X EXE declared text size is zero")
    if text_load_address % 4 or entry_pc % 4:
        raise PsxExeValidationError("PS-X EXE text address or entry PC is not word-aligned")
    _validate_psx_address_range(text_load_address, text_size, "text")
    _validate_psx_address_range(data_address, data_size, "data")
    _validate_psx_address_range(bss_address, bss_size, "BSS")

    payload_size = file_size - PSX_EXE_HEADER_SIZE
    if text_size > payload_size:
        raise PsxExeValidationError(
            f"PS-X EXE declared text size {text_size} exceeds payload size {payload_size}"
        )
    if not text_load_address <= entry_pc < text_load_address + text_size:
        raise PsxExeValidationError("PS-X EXE entry PC is outside the declared text range")

    return PsxExeInfo(
        path=path,
        sha1=sha1,
        file_size=file_size,
        text_load_address=text_load_address,
        text_size=text_size,
        entry_pc=entry_pc,
        initial_gp=initial_gp,
        sp_base=sp_base,
        sp_offset=sp_offset,
    )


def characterize_psx_exe(
    executable_path: str | Path, manifest_path: str | Path
) -> PsxExeInfo:
    """Validate a manifest-tracked extracted file as a PS-X EXE and return metadata."""
    relative_path = _relative_path_from_manifest_root(executable_path, manifest_path)
    manifest_entry = _read_manifest_entry(manifest_path, relative_path)
    header, file_size, sha1 = _read_psx_exe_and_hash(executable_path)
    info = _parse_psx_exe_header(
        header,
        path=relative_path,
        sha1=sha1,
        file_size=file_size,
    )
    if file_size != manifest_entry.size:
        raise PsxExeValidationError(
            f"executable size {file_size} does not match manifest size {manifest_entry.size}"
        )
    if sha1 != manifest_entry.sha1:
        raise PsxExeValidationError("executable SHA-1 does not match manifest")
    return info


def _format_psx_exe_info(info: PsxExeInfo) -> str:
    return "\n".join(
        [
            f"path\t{info.path}",
            f"sha1\t{info.sha1}",
            f"file_size\t{info.file_size}",
            f"header_size\t{PSX_EXE_HEADER_SIZE}",
            f"text_load_address\t0x{info.text_load_address:08X}",
            f"text_size\t{info.text_size}",
            f"entry_pc\t0x{info.entry_pc:08X}",
            f"initial_gp\t0x{info.initial_gp:08X}",
            f"sp_base\t0x{info.sp_base:08X}",
            f"sp_offset\t0x{info.sp_offset:08X}",
        ]
    )


def _positive_depth(value: str) -> int:
    try:
        depth = int(value, 10)
    except ValueError as exc:
        raise argparse.ArgumentTypeError("must be an integer") from exc
    if depth < 0:
        raise argparse.ArgumentTypeError("must not be negative")
    return depth


def _format_entry(entry: IsoEntry) -> str:
    kind = "D" if entry.is_directory else "F"
    extents = ",".join(f"{extent.lba}:{extent.size}" for extent in entry.extents)
    return f"{kind}\t{entry.path}\t{entry.size}\t{extents}"


def main(argv: Sequence[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Validate, list, extract, or characterize a MODE2/2352 ISO9660 filesystem."
    )
    parser.add_argument(
        "--max-depth",
        type=_positive_depth,
        default=DEFAULT_MAX_DIRECTORY_DEPTH,
        help=f"maximum directory recursion depth (default: {DEFAULT_MAX_DIRECTORY_DEPTH})",
    )
    commands = parser.add_subparsers(dest="command", required=True)
    list_parser = commands.add_parser("list", help="validate and list the filesystem")
    list_parser.add_argument("image", type=Path, help="MODE2/2352 image to inspect")
    extract_parser = commands.add_parser(
        "extract", help="validate and extract files into a fresh destination"
    )
    extract_parser.add_argument("image", type=Path, help="MODE2/2352 image to extract")
    extract_parser.add_argument(
        "output",
        type=Path,
        help="new destination directory; an existing path is refused",
    )
    exe_info_parser = commands.add_parser(
        "psx-exe-info", help="validate a manifest-tracked extracted file as a PS-X EXE"
    )
    exe_info_parser.add_argument("executable", type=Path, help="extracted PS-X EXE candidate")
    exe_info_parser.add_argument("manifest", type=Path, help="manifest generated by extract")
    args = parser.parse_args(argv)

    try:
        if args.command == "list":
            for entry in walk_image(args.image, max_depth=args.max_depth):
                print(_format_entry(entry))
            return 0
        if args.command == "extract":
            result = extract_image(args.image, args.output, max_depth=args.max_depth)
            print(
                f"extracted {result.file_count} files ({result.byte_count} bytes) "
                f"to {result.output_root}"
            )
            print(f"manifest: {result.manifest_path}")
            return 0

        info = characterize_psx_exe(args.executable, args.manifest)
    except (IsoValidationError, PsxExeValidationError, OSError) as exc:
        print(f"sf3_extract: error: {exc}", file=sys.stderr)
        return 2

    print(_format_psx_exe_info(info))
    return 0


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