#!/usr/bin/env python3
"""Validate RenderWare DFF and TXD chunk boundaries.

This checks the structural validation of assets, used by MTA.
"""

from __future__ import annotations

import argparse
import struct
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable

HEADER_SIZE = 12

DFF_TYPES = {
    0x02: "rwSTRING",
    0x03: "rwEXTENSION",
    0x06: "rwTEXTURE",
    0x07: "rwMATERIAL",
    0x08: "rwMATERIALLIST",
    0x0E: "rwFRAMELIST",
    0x0F: "rwGEOMETRY",
    0x10: "rwCLUMP",
    0x14: "rwATOMIC",
    0x1A: "rwGEOMETRYLIST",
    0x2B: "rwUVANIMDICT",
}

TXD_TYPES = {
    0x03: "rwEXTENSION",
    0x15: "rwTEXTURE",
    0x16: "rwTEXDICTIONARY",
}
TXD_RECURSE_TYPES = {0x03, 0x15, 0x16}

RW_DATA = 0x01
DXT1 = 0x31545844
DXT3 = 0x33545844
DXT5 = 0x35545844
ARGB8888 = 21

@dataclass
class Finding:
    severity: str
    offset: int
    message: str

    def __str__(self) -> str:
        return f"{self.severity}: 0x{self.offset:08X}: {self.message}"

@dataclass
class Chunk:
    offset: int
    type_id: int
    size: int
    version: int
    end: int


def read_header(data: bytes, offset: int) -> Chunk | None:
    if offset < 0 or offset + HEADER_SIZE > len(data):
        return None
    type_id, size, version = struct.unpack_from("<III", data, offset)
    return Chunk(offset, type_id, size, version, offset + HEADER_SIZE + size)


def describe_type(type_id: int, names: dict[int, str]) -> str:
    return f"{names.get(type_id, 'unknown')} (0x{type_id:08X})"


def hex_bytes(data: bytes, limit: int = 16) -> str:
    shown = data[:limit].hex(" ")
    return shown + (" ..." if len(data) > limit else "")


def expected_mip_bytes(width: int, height: int, levels: int, format_id: int) -> int | None:
    if format_id in (DXT1, DXT3, DXT5):
        block_size = 8 if format_id == DXT1 else 16
        total = 0
        for _ in range(max(levels, 1)):
            total += max((width + 3) // 4, 1) * max((height + 3) // 4, 1) * block_size
            width = max(width // 2, 1)
            height = max(height // 2, 1)
        return total
    if format_id == ARGB8888:
        total = 0
        for _ in range(max(levels, 1)):
            total += width * height * 4
            width = max(width // 2, 1)
            height = max(height // 2, 1)
        return total
    return None


def validate_txd_texture(data: bytes, chunk: Chunk, findings: list[Finding]) -> None:
    offset = chunk.offset + HEADER_SIZE
    end = chunk.end
    while offset + HEADER_SIZE <= end:
        child = read_header(data, offset)
        assert child is not None
        if child.end > end:
            return
        if child.type_id == RW_DATA:
            payload_size = child.size
            if payload_size < 92:
                findings.append(Finding(
                    "WARNING", child.offset,
                    f"rwTEXTURE rwDATA payload is {payload_size} bytes; "
                    "native texture header plus pixel size needs at least 92",
                ))
                return

            payload = child.offset + HEADER_SIZE
            platform_id, filter_address = struct.unpack_from("<II", data, payload)
            width, height, depth, levels, raster_type, flags = struct.unpack_from(
                "<HHBBBB", data, payload + 80
            )
            format_id = struct.unpack_from("<I", data, payload + 76)[0]
            pixel_size = struct.unpack_from("<I", data, payload + 88)[0]
            available = payload_size - 92
            if pixel_size != available:
                findings.append(Finding(
                    "WARNING", child.offset,
                    f"texture raster declares {pixel_size} pixel bytes, "
                    f"but rwDATA contains {available}",
                ))
            expected = expected_mip_bytes(width, height, levels, format_id)
            if expected is not None and expected != pixel_size:
                findings.append(Finding(
                    "WARNING", child.offset,
                    f"texture raster is {width}x{height}, {levels} mip level(s), "
                    f"format 0x{format_id:08X}, which requires {expected} pixel "
                    f"bytes, not {pixel_size}",
                ))
            if platform_id != 9:
                findings.append(Finding(
                    "WARNING", child.offset,
                    f"texture platform id is {platform_id}, expected PC id 9",
                ))
            if width == 0 or height == 0 or levels == 0:
                findings.append(Finding(
                    "ERROR", child.offset,
                    f"texture raster has invalid dimensions or mip count: "
                    f"{width}x{height}, {levels} level(s)",
                ))
            return
        offset = child.end


def repair_dff(data: bytes) -> tuple[bytes | None, list[str], list[str]]:
    """Apply only deterministic DFF repairs and return data, changes, errors."""
    changes: list[str] = []
    errors: list[str] = []
    if len(data) < HEADER_SIZE:
        return None, changes, ["file is shorter than a RenderWare chunk header"]

    root = read_header(data, 0)
    assert root is not None
    prefix_end = 0
    while root is not None and root.type_id != 0x10:
        if root.end > len(data):
            return None, changes, [
                f"top-level chunk at 0x{root.offset:08X} ends at "
                f"0x{root.end:08X}, beyond file size 0x{len(data):08X}"
            ]
        prefix_end = root.end
        root = read_header(data, prefix_end)
    if root is None:
        return None, changes, ["no rwCLUMP found in the top-level chunk stream"]
    prefix = data[:prefix_end]
    if prefix:
        changes.append(
            f"0x00000000: preserved {prefix_end} byte(s) before rwCLUMP"
        )
    if root.end > len(data):
        repaired_size = len(data) - root.offset - HEADER_SIZE
        changes.append(
            f"0x{root.offset:08X}: changed oversized rwCLUMP size "
            f"0x{root.size:08X} to 0x{repaired_size:08X} at file boundary"
        )
        root = Chunk(
            root.offset, root.type_id, repaired_size, root.version, len(data)
        )
    if root.end < len(data):
        candidate_offset = None
        candidate = None
        for offset in range(max(HEADER_SIZE, root.end - HEADER_SIZE + 1), root.end):
            possible = read_header(data, offset)
            if (possible is not None and possible.end == len(data)
                    and possible.type_id in set(DFF_TYPES) | {RW_DATA}):
                candidate_offset = offset
                candidate = possible
                break
        if candidate_offset is not None:
            assert candidate is not None
            changes.append(
                f"0x{root.offset:08X}: extended rwCLUMP size through final "
                f"{describe_type(candidate.type_id, DFF_TYPES)} child at "
                f"0x{candidate_offset:08X}"
            )
            root = Chunk(
                root.offset, root.type_id, len(data) - HEADER_SIZE,
                root.version, len(data)
            )

    def recover_size(offset: int, declared_size: int, parent_end: int) -> int | None:
        if declared_size <= parent_end - offset - HEADER_SIZE:
            return declared_size
        candidate = declared_size & 0x00FFFFFF
        if candidate != declared_size:
            next_offset = offset + HEADER_SIZE + candidate
            if next_offset <= parent_end and next_offset == parent_end:
                return candidate
            next_chunk = read_header(data, next_offset)
            if next_chunk is not None and next_offset < parent_end and next_chunk.type_id in set(DFF_TYPES) | {RW_DATA}:
                return candidate
        remaining = parent_end - offset - HEADER_SIZE
        if remaining >= 0 and offset + HEADER_SIZE + remaining == parent_end:
            return remaining
        return None

    def repair_region(start: int, end: int, parent_offset: int) -> bytes | None:
        offset = start
        output = bytearray()
        while offset < end:
            remaining = end - offset
            if remaining < HEADER_SIZE:
                errors.append(
                    f"0x{offset:08X}: {remaining} trailing byte(s) inside "
                    f"parent 0x{parent_offset:08X}"
                )
                return None

            chunk = read_header(data, offset)
            assert chunk is not None
            repaired_size = recover_size(offset, chunk.size, end)
            if repaired_size is None:
                errors.append(
                    f"0x{offset:08X}: chunk ends at 0x{chunk.end:08X}, "
                    f"beyond parent end 0x{end:08X}"
                )
                return None
            if repaired_size != chunk.size:
                changes.append(
                    f"0x{offset:08X}: changed oversized chunk size "
                    f"0x{chunk.size:08X} to 0x{repaired_size:08X}"
                )
                chunk = Chunk(
                    chunk.offset, chunk.type_id, repaired_size, chunk.version,
                    offset + HEADER_SIZE + repaired_size,
                )

            payload = data[offset + HEADER_SIZE:chunk.end]
            if chunk.type_id in DFF_TYPES and chunk.type_id != 0x02:
                repaired = repair_region(
                    offset + HEADER_SIZE, chunk.end, chunk.offset
                )
                if repaired is None:
                    return None
                payload = repaired

            output += struct.pack("<III", chunk.type_id, len(payload), chunk.version)
            output += payload
            offset = chunk.end
        return bytes(output)

    body = repair_region(root.offset + HEADER_SIZE, root.end, root.offset)
    if body is None:
        return None, changes, errors
    if root.end < len(data):
        changes.append(
            f"0x{root.end:08X}: removed {len(data) - root.end} bytes after rwCLUMP"
        )
    repaired_root = struct.pack("<III", root.type_id, len(body), root.version) + body
    return prefix + repaired_root, changes, errors


def repair_txd(data: bytes) -> tuple[bytes | None, list[str], list[str]]:
    """Apply only deterministic TXD chunk-boundary repairs."""
    changes: list[str] = []
    errors: list[str] = []
    if len(data) < HEADER_SIZE:
        return None, changes, ["file is shorter than a RenderWare chunk header"]

    root = read_header(data, 0)
    assert root is not None
    if root.type_id != 0x16:
        return None, changes, [
            f"root is {describe_type(root.type_id, TXD_TYPES)}, "
            "expected rwTEXDICTIONARY (0x00000016)"
        ]
    if root.end > len(data):
        repaired_size = len(data) - HEADER_SIZE
        changes.append(
            f"0x{root.offset:08X}: changed oversized rwTEXDICTIONARY size "
            f"0x{root.size:08X} to 0x{repaired_size:08X} at file boundary"
        )
        root = Chunk(root.offset, root.type_id, repaired_size, root.version, len(data))

    def recover_size(offset: int, declared_size: int, parent_end: int) -> int | None:
        if declared_size <= parent_end - offset - HEADER_SIZE:
            return declared_size
        candidate = declared_size & 0x00FFFFFF
        if candidate != declared_size:
            next_offset = offset + HEADER_SIZE + candidate
            if next_offset == parent_end:
                return candidate
            next_chunk = read_header(data, next_offset)
            if (next_chunk is not None and next_offset < parent_end
                    and next_chunk.type_id in set(TXD_TYPES) | {RW_DATA}):
                return candidate
        remaining = parent_end - offset - HEADER_SIZE
        if remaining >= 0 and offset + HEADER_SIZE + remaining == parent_end:
            return remaining
        return None

    def repair_region(start: int, end: int, parent_offset: int) -> bytes | None:
        offset = start
        output = bytearray()
        while offset < end:
            remaining = end - offset
            if remaining < HEADER_SIZE:
                errors.append(
                    f"0x{offset:08X}: {remaining} trailing byte(s) inside "
                    f"parent 0x{parent_offset:08X}"
                )
                return None
            chunk = read_header(data, offset)
            assert chunk is not None
            repaired_size = recover_size(offset, chunk.size, end)
            if chunk.type_id == 0x15 and repaired_size is not None:
                child = read_header(data, offset + HEADER_SIZE)
                if (child is not None and child.type_id == RW_DATA
                        and child.end > offset + HEADER_SIZE + repaired_size
                        and child.end <= end):
                    following = read_header(data, child.end)
                    if (child.end == end or (following is not None
                            and following.type_id in TXD_TYPES)):
                        repaired_size = child.end - offset - HEADER_SIZE
                        changes.append(
                            f"0x{offset:08X}: expanded rwTEXTURE size through "
                            f"its complete rwDATA child to 0x{repaired_size:08X}"
                        )
            if repaired_size is None:
                errors.append(
                    f"0x{offset:08X}: chunk ends at 0x{chunk.end:08X}, "
                    f"beyond parent end 0x{end:08X}"
                )
                return None
            if repaired_size != chunk.size:
                changes.append(
                    f"0x{offset:08X}: changed oversized chunk size "
                    f"0x{chunk.size:08X} to 0x{repaired_size:08X}"
                )
                chunk = Chunk(
                    chunk.offset, chunk.type_id, repaired_size, chunk.version,
                    offset + HEADER_SIZE + repaired_size,
                )
            payload = data[offset + HEADER_SIZE:chunk.end]
            if chunk.type_id in TXD_RECURSE_TYPES:
                repaired = repair_region(offset + HEADER_SIZE, chunk.end, chunk.offset)
                if repaired is None:
                    return None
                payload = repaired
            output += struct.pack("<III", chunk.type_id, len(payload), chunk.version)
            output += payload
            offset = chunk.end
        return bytes(output)

    body = repair_region(root.offset + HEADER_SIZE, root.end, root.offset)
    if body is None:
        return None, changes, errors
    if root.end < len(data):
        changes.append(
            f"0x{root.end:08X}: removed {len(data) - root.end} bytes after "
            "rwTEXDICTIONARY"
        )
    repaired_root = struct.pack("<III", root.type_id, len(body), root.version) + body
    return repaired_root, changes, errors


def validate_texture_structure(data: bytes, parent: Chunk, findings: list[Finding],
                              tree: list[str], depth: int) -> bool:
    """Validate rigid rwTEXTURE child structure: rwDATA, rwSTRING(name), rwSTRING(mask=4), rwEXTENSION."""
    offset = parent.offset + HEADER_SIZE
    parent_end = parent.end
    child_idx = 0

    def next_chunk(expected_type: int, min_size: int = 0, max_size: int | None = None) -> Chunk | None:
        nonlocal offset
        if offset + HEADER_SIZE > parent_end:
            findings.append(Finding("ERROR", parent.offset,
                f"rwTEXTURE child {child_idx + 1} missing; expected {describe_type(expected_type, DFF_TYPES)}"))
            return None
        chunk = read_header(data, offset)
        assert chunk is not None
        tree.append("  " * (depth + 1) +
                    f"0x{chunk.offset:08X} {describe_type(chunk.type_id, DFF_TYPES)} size={chunk.size} "
                    f"end=0x{chunk.end:08X}")
        if chunk.end > parent_end:
            findings.append(Finding("ERROR", offset,
                f"child ends at 0x{chunk.end:08X}, beyond parent end 0x{parent_end:08X}"))
            return None
        if chunk.type_id != expected_type:
            findings.append(Finding("ERROR", offset,
                f"expected {describe_type(expected_type, DFF_TYPES)}, got {describe_type(chunk.type_id, DFF_TYPES)}"))
            return None
        if chunk.size < min_size:
            findings.append(Finding("ERROR", offset,
                f"{describe_type(chunk.type_id, DFF_TYPES)} size {chunk.size} below minimum {min_size}"))
            return None
        if max_size is not None and chunk.size > max_size:
            findings.append(Finding("ERROR", offset,
                f"{describe_type(chunk.type_id, DFF_TYPES)} declares {chunk.size} bytes; maximum is {max_size}"))
            return None
        if chunk.type_id == 0:
            findings.append(Finding("ERROR", offset, "chunk type 0 is invalid"))
            return None
        offset = chunk.end
        return chunk

    # Child 1: rwDATA
    if not next_chunk(0x01):
        return False
    child_idx = 1

    # Child 2: rwSTRING (texture name, 1..64 bytes)
    if not next_chunk(0x02, min_size=1, max_size=64):
        return False
    child_idx = 2

    # Child 3: rwSTRING (texture mask, typically 4 bytes)
    mask = next_chunk(0x02, min_size=1, max_size=64)
    if not mask:
        return False
    if mask.size != 4:
        findings.append(Finding("WARNING", mask.offset,
            f"rwTEXTURE mask is {mask.size} bytes; typically 4"))
    child_idx = 3

    # Child 4: rwEXTENSION (optional, may be zero-size)
    if offset < parent_end:
        ext = next_chunk(0x03)
        if not ext:
            return False
        if ext.size > 0:
            walk_children(data, ext.offset + HEADER_SIZE, ext.end,
                          DFF_TYPES, {0x03, 0x06, 0x07, 0x08, 0x0E, 0x0F, 0x10, 0x14, 0x1A},
                          findings, tree, depth + 2)
        child_idx = 4

    if offset != parent_end:
        findings.append(Finding("ERROR", offset,
            f"{parent_end - offset} unexpected byte(s) after rwTEXTURE children"))
        return False
    return True


def validate_material_structure(data: bytes, parent: Chunk, findings: list[Finding],
                                 tree: list[str], depth: int) -> bool:
    """Validate rwMATERIAL child structure: rwDATA, [rwTEXTURE], rwEXTENSION."""
    offset = parent.offset + HEADER_SIZE
    parent_end = parent.end

    def next_chunk() -> Chunk | None:
        nonlocal offset
        if offset + HEADER_SIZE > parent_end:
            return None
        chunk = read_header(data, offset)
        assert chunk is not None
        tree.append("  " * (depth + 1) +
                    f"0x{chunk.offset:08X} {describe_type(chunk.type_id, DFF_TYPES)} size={chunk.size} "
                    f"end=0x{chunk.end:08X}")
        if chunk.end > parent_end:
            findings.append(Finding("ERROR", offset,
                f"child ends at 0x{chunk.end:08X}, beyond parent end 0x{parent_end:08X}"))
            return None
        if chunk.type_id == 0:
            findings.append(Finding("ERROR", offset, "chunk type 0 is invalid"))
            return None
        if chunk.type_id == 0x02 and chunk.size > 64:
            findings.append(Finding("ERROR", offset,
                f"rwSTRING declares {chunk.size} bytes; maximum is 64"))
            return None
        offset = chunk.end
        return chunk

    # Child 1: rwDATA
    child = next_chunk()
    if not child or child.type_id != 0x01:
        findings.append(Finding("ERROR", parent.offset,
            f"rwMATERIAL first child must be rwDATA, got {describe_type(child.type_id if child else 0, DFF_TYPES)}"))
        return False

    # Child 2 (optional): one or more rwTEXTURE chunks
    while offset < parent_end:
        child = read_header(data, offset)
        if child is None:
            findings.append(Finding("ERROR", offset,
                "material child header is truncated"))
            return False
        if child.type_id != 0x06:
            break
        child = next_chunk()
        assert child is not None
        if not validate_texture_structure(data, child, findings, tree, depth + 1):
            return False

    # Final child (if not already consumed): rwEXTENSION
    if offset < parent_end:
        child = next_chunk()
        if not child:
            return False
        if child.type_id != 0x03:
            findings.append(Finding("ERROR", child.offset,
                f"expected rwEXTENSION, got {describe_type(child.type_id, DFF_TYPES)}"))
            return False
        if child.size > 0:
            walk_children(data, child.offset + HEADER_SIZE, child.end,
                          DFF_TYPES, {0x03, 0x06, 0x07, 0x08, 0x0E, 0x0F, 0x10, 0x14, 0x1A},
                          findings, tree, depth + 2)

    if offset != parent_end:
        findings.append(Finding("ERROR", offset,
            f"{parent_end - offset} unexpected byte(s) after rwMATERIAL children"))
        return False
    return True


def walk_children(
    data: bytes,
    start: int,
    parent_end: int,
    names: dict[int, str],
    recurse_types: set[int],
    findings: list[Finding],
    tree: list[str],
    depth: int,
) -> None:
    offset = start
    while offset < parent_end:
        remaining = parent_end - offset
        if remaining < HEADER_SIZE:
            findings.append(Finding(
                "ERROR", offset,
                f"{remaining} trailing byte(s) remain inside parent ending at "
                f"0x{parent_end:08X}; a chunk header needs {HEADER_SIZE} bytes "
                f"(bytes: {hex_bytes(data[offset:parent_end])})",
            ))
            return

        chunk = read_header(data, offset)
        assert chunk is not None
        type_name = describe_type(chunk.type_id, names)
        tree.append("  " * depth +
                    f"0x{chunk.offset:08X} {type_name} size={chunk.size} "
                    f"end=0x{chunk.end:08X}")

        if chunk.end > parent_end:
            findings.append(Finding(
                "ERROR", offset,
                f"{type_name} declares size {chunk.size}, ending at "
                f"0x{chunk.end:08X}, beyond parent end 0x{parent_end:08X}",
            ))
            return

        if chunk.type_id == 0:
            findings.append(Finding(
                "ERROR", offset,
                "chunk type 0 is invalid",
            ))
            return

        if chunk.type_id == 0x02 and chunk.size > 64:
            findings.append(Finding(
                "ERROR", offset,
                f"rwSTRING declares {chunk.size} bytes; maximum is 64",
            ))
            return

        if chunk.type_id == 0x15:
            validate_txd_texture(data, chunk, findings)
        if chunk.type_id == 0x06:
            validate_texture_structure(data, chunk, findings, tree, depth)
        elif chunk.type_id == 0x07:
            validate_material_structure(data, chunk, findings, tree, depth)
        elif chunk.type_id in recurse_types:
            walk_children(data, offset + HEADER_SIZE, chunk.end, names,
                          recurse_types, findings, tree, depth + 1)
        offset = chunk.end


def validate_file(path: Path, dump_tree: bool) -> tuple[list[Finding], list[str]]:
    data = path.read_bytes()
    findings: list[Finding] = []
    tree: list[str] = []
    suffix = path.suffix.lower()

    if suffix == ".dff":
        root_type = 0x10
        names = DFF_TYPES
        recurse_types = {0x03, 0x08, 0x0E, 0x0F, 0x10, 0x14, 0x1A}
    elif suffix == ".txd":
        root_type = 0x16
        names = TXD_TYPES
        recurse_types = set(TXD_TYPES)
    else:
        findings.append(Finding("ERROR", 0, "unsupported extension; expected .dff or .txd"))
        return findings, tree

    if len(data) < HEADER_SIZE:
        findings.append(Finding("ERROR", 0,
                                f"file is only {len(data)} bytes; a chunk header needs {HEADER_SIZE}"))
        return findings, tree

    root = read_header(data, 0)
    assert root is not None
    if suffix == ".dff":
        while root.type_id != root_type:
            if root.end > len(data):
                findings.append(Finding(
                    "ERROR", root.offset,
                    f"top-level chunk ends at 0x{root.end:08X}, beyond file size "
                    f"0x{len(data):08X}",
                ))
                return findings, tree
            root = read_header(data, root.end)
            if root is None:
                findings.append(Finding(
                    "ERROR", 0,
                    "no rwCLUMP found in the top-level chunk stream",
                ))
                return findings, tree
    if root.type_id != root_type:
        findings.append(Finding("ERROR", 0,
                                f"root is {describe_type(root.type_id, names)}, "
                                f"expected {describe_type(root_type, names)}"))
        return findings, tree
    if root.offset:
        walk_children(data, 0, root.offset, names, recurse_types,
                      findings, tree, 0)
    tree.append(f"0x{root.offset:08X} {describe_type(root.type_id, names)} "
                f"size={root.size} end=0x{root.end:08X}")
    if root.end > len(data):
        findings.append(Finding("ERROR", 0,
                                f"root declares size {root.size}, ending at "
                                f"0x{root.end:08X}, beyond file size 0x{len(data):08X}"))
        return findings, tree
    if root.end < len(data):
        findings.append(Finding("WARNING", root.end,
                    f"file has {len(data) - root.end} trailing byte(s) after "
                    f"the model root (ends at 0x{root.end:08X}); "
                    "the trailing data is ignored"))

    walk_children(data, root.offset + HEADER_SIZE, root.end, names, recurse_types,
                  findings, tree, 1)
    return findings, tree


def iter_inputs(inputs: Iterable[str]) -> Iterable[Path]:
    for value in inputs:
        path = Path(value)
        if path.is_dir():
            yield from sorted(path.rglob("*.dff"))
            yield from sorted(path.rglob("*.txd"))
        elif path.is_file():
            yield path
        else:
            print(f"ERROR: input does not exist: {path}", file=sys.stderr)


def repaired_path(path: Path, output_dir: Path | None) -> Path:
    name = f"{path.stem}.repaired{path.suffix}"
    return (output_dir / name) if output_dir else path.with_name(name)


def repair_path(
    path: Path, output_dir: Path | None, overwrite: bool, verbose: bool
) -> bool:
    suffix = path.suffix.lower()
    if suffix not in {".dff", ".txd"}:
        return False
    try:
        repair_function = repair_dff if suffix == ".dff" else repair_txd
        original = path.read_bytes()
    except OSError as exc:
        print(f"{path} REPAIR FAILED")
        if verbose:
            print(f"{path}: ERROR: cannot read file: {exc}")
        return False

    try:
        findings, _ = validate_file(path, False)
    except OSError as exc:
        print(f"{path} REPAIR FAILED")
        if verbose:
            print(f"{path}: ERROR: cannot validate original file: {exc}")
        return False
    validation_errors = [
        finding for finding in findings if finding.severity == "ERROR"
    ]
    if not validation_errors:
        print(f"{path} REPAIR SKIPPED (already valid)")
        if verbose:
            print("  RESULT: VALID")
        return True

    repaired, changes, errors = repair_function(original)
    if repaired is None:
        print(f"{path} REPAIR FAILED")
        if verbose:
            for error in errors:
                print(f"  ERROR: {error}")
        return False

    destination = path if overwrite else repaired_path(path, output_dir)
    temporary_path: Path | None = None
    try:
        destination.parent.mkdir(parents=True, exist_ok=True)
        with tempfile.NamedTemporaryFile(
            mode="wb", suffix=path.suffix, dir=destination.parent, delete=False
        ) as temporary:
            temporary.write(repaired)
            temporary_path = Path(temporary.name)
        findings, _ = validate_file(temporary_path, False)
    except OSError as exc:
        print(f"{path} REPAIR FAILED")
        if verbose:
            print(f"{path}: ERROR: cannot validate repaired output: {exc}")
        if temporary_path:
            temporary_path.unlink(missing_ok=True)
        return False

    errors = [finding for finding in findings if finding.severity == "ERROR"]
    if errors:
        print(f"{path} REPAIR FAILED")
        if verbose:
            print(f"  RESULT: INVALID ({len(errors)} error(s))")
            for finding in findings:
                print(f"    {finding}")
        temporary_path.unlink(missing_ok=True)
        return False
    try:
        destination.write_bytes(repaired)
    except OSError as exc:
        print(f"{path} REPAIR FAILED")
        if verbose:
            print(f"{path}: ERROR: cannot write {destination}: {exc}")
        temporary_path.unlink(missing_ok=True)
        return False
    temporary_path.unlink(missing_ok=True)
    print(f"{path} REPAIR SUCCESS")
    if verbose:
        print(f"  repaired -> {destination} ({len(changes)} change(s))")
        for change in changes:
            print(f"  FIX: {change}")
        print("  RESULT: VALID")
    return True


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("inputs", nargs="+", help="DFF/TXD files or directories")
    parser.add_argument("--dump-tree", action="store_true", help="print parsed chunk tree")
    parser.add_argument(
        "-v", "--verbose", action="store_true",
        help="print detailed findings, repairs, and errors",
    )
    parser.add_argument(
        "--repair", action="store_true",
        help="write safely repaired DFFs/TXDs beside the originals and validate the result",
    )
    parser.add_argument(
        "--output-dir", type=Path,
        help="directory for repaired files; only used with --repair",
    )
    parser.add_argument(
        "--overwrite", action="store_true",
        help="replace each original DFF when used with --repair",
    )
    args = parser.parse_args()

    if args.overwrite and args.output_dir:
        parser.error("--overwrite cannot be combined with --output-dir")

    verbose = args.verbose or args.dump_tree
    exit_code = 0
    scanned_count = 0
    invalid_count = 0
    processed_count = 0
    failed_count = 0
    for path in iter_inputs(args.inputs):
        if args.repair:
            if path.suffix.lower() not in {".dff", ".txd"}:
                continue
            processed_count += 1
            if not repair_path(path, args.output_dir, args.overwrite, verbose):
                failed_count += 1
                exit_code = 1
            continue
        scanned_count += 1
        try:
            findings, tree = validate_file(path, args.dump_tree)
        except OSError as exc:
            print(f"{path} INVALID")
            if verbose:
                print(f"  ERROR: cannot read file: {exc}")
            invalid_count += 1
            exit_code = 1
            continue

        errors = [finding for finding in findings if finding.severity == "ERROR"]
        status = "INVALID" if errors else ("VALID WITH WARNINGS" if findings else "VALID")
        print(f"{path} {'INVALID' if errors else 'VALID'}")
        if verbose:
            print(f"  ({path.stat().st_size} bytes): {status}")
            for finding in findings:
                print(f"  {finding}")
        if args.dump_tree:
            print("  Chunk tree:")
            print("  " + "\\n  ".join(tree))
        if errors:
            invalid_count += 1
            exit_code = 1
    print()
    if args.repair:
        processed_label = "file" if processed_count == 1 else "files"
        failed_label = "file" if failed_count == 1 else "files"
        print(f"{processed_count} {processed_label} processed")
        print(f"{failed_count} {failed_label} failed to repair")
    else:
        scanned_label = "file" if scanned_count == 1 else "files"
        invalid_label = "file" if invalid_count == 1 else "files"
        print(f"{scanned_count} {scanned_label} scanned")
        repair_verb = "needs" if invalid_count == 1 else "need"
        print(f"{invalid_count} {invalid_label} {repair_verb} repair")
    return exit_code


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

