#!/usr/bin/env python3
"""Generate and verify assertion-driven G6 chunk/multidevice fixtures."""

from __future__ import annotations

import argparse
import hashlib
import json
import math
import os
import shutil
import struct
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

from erofs_fixture import ErofsImage, SUPER


FEATURE_INCOMPAT_CHUNKED_FILE = 0x00000004
FEATURE_INCOMPAT_DEVICE_TABLE = 0x00000008
FEATURE_INCOMPAT_48BIT = 0x00000080
CHUNK_FORMAT_BLOCK_BITS_MASK = 0x001F
CHUNK_FORMAT_INDEXES = 0x0020
CHUNK_FORMAT_48BIT = 0x0040
DEVICE_SLOT_SIZE = 128
DEVICE_SLOT_FIELDS = 64
BLOCK_SIZE = 4096

CHUNK_UUID = "67360006-0093-0094-0095-000000000101"
FRAGMENT_UUID = "67360098-0000-0000-0000-000000000098"
PCLUSTER_UUID = "67360094-0101-0000-0000-000000000094"

CHUNK_FILES = {
    "tc006.bin": 24,
    "cross-device.bin": 96,
    "indexed.bin": 40,
    "cold-slot2.bin": 32,
    "unified-address.bin": 32,
}


class FixtureError(RuntimeError):
    pass


@dataclass(frozen=True)
class ChunkEntry:
    offset: int
    start_block_high: int
    device_id: int
    start_block_low: int

    @property
    def start_block(self) -> int:
        return self.start_block_low | (self.start_block_high << 32)


@dataclass(frozen=True)
class ChunkLayout:
    path: str
    nid: int
    inode_offset: int
    format_offset: int
    chunk_format: int
    chunk_bits: int
    chunk_size: int
    entry_size: int
    index_base: int
    entries: tuple[ChunkEntry, ...]


@dataclass(frozen=True)
class DeviceSlot:
    blocks: int
    uniaddr: int


@dataclass
class MultiFixture:
    primary: ErofsImage
    providers: list[bytearray]
    slots: list[DeviceSlot]


def align_up(value: int, alignment: int) -> int:
    return (value + alignment - 1) & ~(alignment - 1)


def sha256_bytes(data: bytes | bytearray) -> str:
    return hashlib.sha256(data).hexdigest()


def sha256_file(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def deterministic_block(path: str, block: int) -> bytes:
    seed = f"repo22-g6:{path}:{block}".encode("ascii")
    return b"".join(
        hashlib.sha256(seed + index.to_bytes(4, "little")).digest()
        for index in range(128)
    )[:BLOCK_SIZE]


def run(
    command: list[str], *, stdout: bool = False, cwd: Path | None = None
) -> str:
    result = subprocess.run(
        command,
        check=True,
        cwd=cwd,
        stdout=subprocess.PIPE if stdout else subprocess.DEVNULL,
        stderr=subprocess.STDOUT if stdout else None,
        text=True,
    )
    return result.stdout if stdout else ""


def tool_version(tool: str) -> str:
    output = run([tool, "-V"], stdout=True).strip().splitlines()
    if not output:
        raise FixtureError(f"{tool} returned no version")
    return output[0]


def write_chunk_sources(root: Path) -> None:
    root.mkdir(parents=True)
    for name, blocks in CHUNK_FILES.items():
        with (root / name).open("wb") as stream:
            for block in range(blocks):
                stream.write(deterministic_block(name, block))


def write_fragment_source(root: Path) -> None:
    root.mkdir(parents=True)
    seed = deterministic_block("fragment.dat", 0)
    data = (seed * math.ceil(100000 / len(seed)))[:100000]
    (root / "fragment.dat").write_bytes(data)


def write_pcluster_source(root: Path) -> None:
    root.mkdir(parents=True)
    seed = b"".join(
        hashlib.sha256(f"repo22-g6:pcluster:{index}".encode("ascii")).digest()
        for index in range(188)
    )[:6000]
    data = (seed * math.ceil(131072 / len(seed)))[:131072]
    (root / "external-pcluster.bin").write_bytes(data)


def make_image(
    mkfs: str,
    output: Path,
    source: Path,
    uuid: str,
    options: list[str],
) -> None:
    run(
        [
            mkfs,
            "-T0",
            "--all-time",
            "--all-root",
            "--workers=1",
            f"-U{uuid}",
            *options,
            str(output),
            str(source),
        ]
    )


def chunk_layout(image: ErofsImage, path: str) -> ChunkLayout:
    _, entry = image.resolve_root_entry(path)
    inode = image.inode(entry.nid)
    if inode.layout != 4:
        raise FixtureError(f"{path}: expected chunk layout, got {inode.layout}")
    chunk_format, reserved = struct.unpack_from(
        "<HH", image.data, inode.offset + 16
    )
    if reserved != 0:
        raise FixtureError(f"{path}: chunk reserved field is nonzero")
    chunk_bits = image.block_bits + (
        chunk_format & CHUNK_FORMAT_BLOCK_BITS_MASK
    )
    chunk_size = 1 << chunk_bits
    count = math.ceil(inode.size / chunk_size)
    entry_size = 8 if chunk_format & CHUNK_FORMAT_INDEXES else 4
    index_base = align_up(
        inode.offset + inode.inode_size + inode.xattr_size, entry_size
    )
    if index_base + count * entry_size > image.blocks * image.block_size:
        raise FixtureError(f"{path}: chunk index array exceeds declared image")
    entries = []
    for index in range(count):
        offset = index_base + index * entry_size
        if entry_size == 8:
            high, device_id, low = struct.unpack_from(
                "<HHI", image.data, offset
            )
        else:
            high, device_id = 0, 0
            low = struct.unpack_from("<I", image.data, offset)[0]
        entries.append(ChunkEntry(offset, high, device_id, low))
    return ChunkLayout(
        path=path,
        nid=inode.nid,
        inode_offset=inode.offset,
        format_offset=inode.offset + 16,
        chunk_format=chunk_format,
        chunk_bits=chunk_bits,
        chunk_size=chunk_size,
        entry_size=entry_size,
        index_base=index_base,
        entries=tuple(entries),
    )


def assert_chunk_baseline(image: ErofsImage, paths: list[str]) -> None:
    for path in paths:
        layout = chunk_layout(image, path)
        if layout.entry_size != 8:
            raise FixtureError(f"{path}: blob baseline has no 8-byte indexes")
        minimum = 3 if path in ("/cross-device.bin", "/indexed.bin") else 1
        if len(layout.entries) < minimum:
            raise FixtureError(
                f"{path}: need at least {minimum} chunk indexes"
            )
        for entry in layout.entries:
            if entry.start_block_high != 0 or entry.device_id != 1:
                raise FixtureError(f"{path}: unexpected mkfs chunk index {entry}")


def patch_chunk_entry(
    image: ErofsImage,
    entry: ChunkEntry,
    *,
    expected: tuple[int, int, int],
    replacement: tuple[int, int, int],
) -> None:
    current = struct.unpack_from("<HHI", image.data, entry.offset)
    if current != expected:
        raise FixtureError(
            f"chunk index at {entry.offset}: expected {expected}, got {current}"
        )
    struct.pack_into("<HHI", image.data, entry.offset, *replacement)


def set_chunk_48bit(image: ErofsImage, paths: list[str]) -> None:
    for path in paths:
        layout = chunk_layout(image, path)
        if layout.entry_size != 8:
            raise FixtureError(f"{path}: 48-bit conversion requires indexes")
        expected = layout.chunk_format
        if expected & CHUNK_FORMAT_48BIT:
            raise FixtureError(f"{path}: already has 48-bit indexes")
        current = image.u16(layout.format_offset)
        if current != expected:
            raise FixtureError(f"{path}: chunk format changed before patch")
        image.put_u16(layout.format_offset, expected | CHUNK_FORMAT_48BIT)


def enable_48bit(image: ErofsImage) -> None:
    if image.feature_incompat & FEATURE_INCOMPAT_48BIT:
        raise FixtureError("image already has 48-bit feature")
    root_nid = image.root_nid
    image.put_u32(
        SUPER + 80, image.feature_incompat | FEATURE_INCOMPAT_48BIT
    )
    image.put_u16(SUPER + 14, 0)
    image.put_u64(SUPER + 112, root_nid)


def decode_slots(image: ErofsImage) -> list[DeviceSlot]:
    extra, slot_offset = struct.unpack_from("<HH", image.data, SUPER + 86)
    table_offset = slot_offset * DEVICE_SLOT_SIZE
    if table_offset + extra * DEVICE_SLOT_SIZE > len(image.data):
        raise FixtureError("device table lies outside provider bytes")
    slots = []
    for index in range(extra):
        offset = table_offset + index * DEVICE_SLOT_SIZE + DEVICE_SLOT_FIELDS
        blocks_low, uniaddr_low, blocks_high, uniaddr_high = struct.unpack_from(
            "<IIHH", image.data, offset
        )
        if image.feature_incompat & FEATURE_INCOMPAT_48BIT:
            blocks = blocks_low | (blocks_high << 32)
            uniaddr = uniaddr_low | (uniaddr_high << 32)
        else:
            blocks = blocks_low
            uniaddr = uniaddr_low
        slots.append(DeviceSlot(blocks, uniaddr))
    return slots


def write_device_table(
    image: ErofsImage,
    slots: list[DeviceSlot],
    *,
    table_offset: int = BLOCK_SIZE,
    primary_blocks: int = 2,
) -> None:
    if not slots:
        raise FixtureError("device table needs at least one slot")
    if table_offset % DEVICE_SLOT_SIZE != 0:
        raise FixtureError("device table is not 128-byte aligned")
    required = max(
        primary_blocks * image.block_size,
        table_offset + len(slots) * DEVICE_SLOT_SIZE,
    )
    if len(image.data) > required:
        raise FixtureError("primary metadata exceeds requested primary size")
    image.data.extend(bytes(required - len(image.data)))
    image.put_u32(SUPER + 36, primary_blocks)
    image.put_u32(
        SUPER + 80, image.feature_incompat | FEATURE_INCOMPAT_DEVICE_TABLE
    )
    image.put_u16(SUPER + 86, len(slots))
    image.put_u16(SUPER + 88, table_offset // DEVICE_SLOT_SIZE)
    image.data[
        table_offset : table_offset + len(slots) * DEVICE_SLOT_SIZE
    ] = bytes(len(slots) * DEVICE_SLOT_SIZE)
    for index, slot in enumerate(slots):
        if slot.blocks <= 0 or slot.blocks >= 1 << 48:
            raise FixtureError(f"slot {index + 1}: invalid blocks {slot.blocks}")
        if slot.uniaddr < 0 or slot.uniaddr >= 1 << 48:
            raise FixtureError(f"slot {index + 1}: invalid uniaddr {slot.uniaddr}")
        offset = table_offset + index * DEVICE_SLOT_SIZE
        tag = f"repo22-g6-slot-{index + 1}".encode("ascii")
        image.data[offset : offset + len(tag)] = tag
        struct.pack_into(
            "<IIHH",
            image.data,
            offset + DEVICE_SLOT_FIELDS,
            slot.blocks & 0xFFFFFFFF,
            slot.uniaddr & 0xFFFFFFFF,
            slot.blocks >> 32,
            slot.uniaddr >> 32,
        )


def finalize_image(image: ErofsImage, path: Path) -> None:
    image.update_checksum()
    image.validate_superblock()
    image.save(path)


def build_single_indexed(
    baseline: ErofsImage, blob: bytes, paths: list[str]
) -> ErofsImage:
    image = baseline.clone()
    if len(image.data) != BLOCK_SIZE or len(blob) % BLOCK_SIZE != 0:
        raise FixtureError("single indexed folding requires aligned providers")
    for path in paths:
        layout = chunk_layout(image, path)
        for entry in layout.entries:
            patch_chunk_entry(
                image,
                entry,
                expected=(0, 1, entry.start_block_low),
                replacement=(0, 0, entry.start_block_low + 1),
            )
    image.data.extend(blob)
    image.put_u32(SUPER + 36, len(image.data) // BLOCK_SIZE)
    image.put_u32(
        SUPER + 80, image.feature_incompat & ~FEATURE_INCOMPAT_DEVICE_TABLE
    )
    image.put_u16(SUPER + 86, 0)
    image.put_u16(SUPER + 88, 0)
    return image


def build_multislot(
    baseline: ErofsImage,
    blob: bytes,
    paths: list[str],
    slot_count: int,
) -> MultiFixture:
    image = baseline.clone()
    providers = [bytearray() for _ in range(slot_count)]
    for path in paths:
        layout = chunk_layout(image, path)
        blocks_per_chunk = layout.chunk_size // BLOCK_SIZE
        for index, entry in enumerate(layout.entries):
            if entry.start_block_high != 0 or entry.device_id != 1:
                raise FixtureError(f"{path}: unexpected source index {entry}")
            source_start = entry.start_block * BLOCK_SIZE
            source_end = source_start + blocks_per_chunk * BLOCK_SIZE
            if source_end > len(blob):
                raise FixtureError(f"{path}: source chunk exceeds blob provider")
            slot = 1 if path == "/cold-slot2.bin" and slot_count >= 2 else (
                index % slot_count
            )
            local_block = len(providers[slot]) // BLOCK_SIZE
            providers[slot].extend(blob[source_start:source_end])
            patch_chunk_entry(
                image,
                entry,
                expected=(0, 1, entry.start_block_low),
                replacement=(0, slot + 1, local_block),
            )
    for index, provider in enumerate(providers):
        provider.extend(bytes((index + 1) * BLOCK_SIZE))
    slots = []
    uniaddr = 2
    for provider in providers:
        blocks = len(provider) // BLOCK_SIZE
        slots.append(DeviceSlot(blocks, uniaddr))
        uniaddr += blocks
    write_device_table(image, slots)
    return MultiFixture(image, providers, slots)


def clone_multifixture(fixture: MultiFixture) -> MultiFixture:
    return MultiFixture(
        fixture.primary.clone(),
        [bytearray(provider) for provider in fixture.providers],
        list(fixture.slots),
    )


def rewrite_slots(
    fixture: MultiFixture,
    slots: list[DeviceSlot],
    *,
    table_offset: int = BLOCK_SIZE,
) -> None:
    fixture.slots = slots
    write_device_table(fixture.primary, slots, table_offset=table_offset)


def build_flatdev(fixture: MultiFixture) -> bytes:
    end_block = max(
        [fixture.primary.blocks]
        + [slot.uniaddr + slot.blocks for slot in fixture.slots]
    )
    data = bytearray(end_block * BLOCK_SIZE)
    data[: len(fixture.primary.data)] = fixture.primary.data
    for slot, provider in zip(fixture.slots, fixture.providers, strict=True):
        if len(provider) != slot.blocks * BLOCK_SIZE:
            raise FixtureError("provider length does not match slot declaration")
        start = slot.uniaddr * BLOCK_SIZE
        data[start : start + len(provider)] = provider
    return bytes(data)


def first_entry(image: ErofsImage, path: str, index: int = 0) -> ChunkEntry:
    layout = chunk_layout(image, path)
    try:
        return layout.entries[index]
    except IndexError as error:
        raise FixtureError(f"{path}: missing chunk index {index}") from error


def patch_unified_entry(
    fixture: MultiFixture,
    path: str,
    *,
    index: int = 0,
) -> None:
    entry = first_entry(fixture.primary, path, index)
    if entry.device_id < 1 or entry.device_id > len(fixture.slots):
        raise FixtureError(f"{path}: source index has no external slot")
    slot = fixture.slots[entry.device_id - 1]
    global_block = slot.uniaddr + entry.start_block
    patch_chunk_entry(
        fixture.primary,
        entry,
        expected=(entry.start_block_high, entry.device_id, entry.start_block_low),
        replacement=(global_block >> 32, 0, global_block & 0xFFFFFFFF),
    )


def patch_table_field(
    image: ErofsImage,
    slot: int,
    field_offset: int,
    fmt: str,
    value: int,
) -> None:
    _, slot_offset = struct.unpack_from("<HH", image.data, SUPER + 86)
    offset = slot_offset * DEVICE_SLOT_SIZE + (slot - 1) * DEVICE_SLOT_SIZE
    offset += DEVICE_SLOT_FIELDS + field_offset
    struct.pack_into(fmt, image.data, offset, value)


def make_pcluster_external(lz4_image: ErofsImage) -> tuple[MultiFixture, int]:
    _, entry = lz4_image.resolve_root_entry("/external-pcluster.bin")
    inode = lz4_image.inode(entry.nid)
    if inode.layout != 1 or inode.size != 131072:
        raise FixtureError("LZ4 source is not a legacy full-index inode")
    header = align_up(inode.offset + inode.inode_size + inode.xattr_size, 8)
    index_offset = header + 16
    advise, cluster_offset, pblk = struct.unpack_from(
        "<HHI", lz4_image.data, index_offset
    )
    if advise & 3 not in (1, 3) or cluster_offset != 0 or pblk != 1:
        raise FixtureError(
            f"unexpected LZ4 HEAD index {(advise, cluster_offset, pblk)}"
        )
    if len(lz4_image.data) != 3 * BLOCK_SIZE:
        raise FixtureError("LZ4 source does not contain exactly two data blocks")
    provider = bytearray(lz4_image.data[BLOCK_SIZE : 3 * BLOCK_SIZE])
    image = lz4_image.clone()
    image.data = image.data[:BLOCK_SIZE]
    slot = DeviceSlot(2, 2)
    write_device_table(image, [slot])
    current = struct.unpack_from("<I", image.data, index_offset + 4)[0]
    if current != 1:
        raise FixtureError("LZ4 pcluster HEAD changed before relocation")
    struct.pack_into("<I", image.data, index_offset + 4, slot.uniaddr)
    return MultiFixture(image, [provider], [slot]), index_offset


def make_pcluster_crossing(
    positive: MultiFixture, index_offset: int
) -> MultiFixture:
    image = positive.primary.clone()
    providers = [
        bytearray(positive.providers[0][:BLOCK_SIZE]),
        bytearray(positive.providers[0][BLOCK_SIZE:]),
    ]
    slots = [DeviceSlot(1, 2), DeviceSlot(1, 3)]
    write_device_table(image, slots)
    current = struct.unpack_from("<I", image.data, index_offset + 4)[0]
    if current != 2:
        raise FixtureError("LZ4 crossing source HEAD changed")
    return MultiFixture(image, providers, slots)


def image_description(image: ErofsImage, paths: list[str]) -> dict[str, Any]:
    extra, slot_offset = struct.unpack_from("<HH", image.data, SUPER + 86)
    description: dict[str, Any] = {
        "provider_bytes": len(image.data),
        "block_size": image.block_size,
        "blocks": image.blocks,
        "root_nid": image.root_nid,
        "feature_compat": image.feature_compat,
        "feature_incompat": image.feature_incompat,
        "extra_devices": extra,
        "devt_slotoff": slot_offset,
        "checksum": image.u32(SUPER + 4),
        "checksum_valid": image.checksum_valid(),
        "slots": [slot.__dict__ for slot in decode_slots(image)] if extra else [],
        "chunks": {},
    }
    for path in paths:
        layout = chunk_layout(image, path)
        description["chunks"][path] = {
            "nid": layout.nid,
            "inode_offset": layout.inode_offset,
            "format_offset": layout.format_offset,
            "format": layout.chunk_format,
            "chunk_bits": layout.chunk_bits,
            "chunk_size": layout.chunk_size,
            "entry_size": layout.entry_size,
            "index_base": layout.index_base,
            "indexes": [
                {
                    "offset": entry.offset,
                    "startblk_hi": entry.start_block_high,
                    "device_id": entry.device_id,
                    "startblk_lo": entry.start_block_low,
                    "startblk": entry.start_block,
                }
                for entry in layout.entries
            ],
        }
    return description


def compare_trees(source: Path, extracted: Path) -> None:
    source_files = sorted(
        path.relative_to(source) for path in source.rglob("*") if path.is_file()
    )
    extracted_files = sorted(
        path.relative_to(extracted)
        for path in extracted.rglob("*")
        if path.is_file()
    )
    if source_files != extracted_files:
        raise FixtureError(
            f"extracted paths differ: {source_files} != {extracted_files}"
        )
    for relative in source_files:
        if sha256_file(source / relative) != sha256_file(extracted / relative):
            raise FixtureError(f"extracted checksum differs: {relative}")


def fsck_extract(
    fsck: str,
    primary: Path,
    devices: list[Path],
    source: Path,
    scratch_parent: Path,
) -> None:
    with tempfile.TemporaryDirectory(prefix="repo22-g6-fsck-", dir=scratch_parent) as tmp:
        extracted = Path(tmp) / "extracted"
        command = [fsck]
        command.extend(f"--device={device}" for device in devices)
        command.extend([f"--extract={extracted}", str(primary)])
        run(command)
        compare_trees(source, extracted)


def write_provider(path: Path, data: bytes | bytearray) -> None:
    if len(data) == 0 or len(data) % BLOCK_SIZE != 0:
        raise FixtureError(f"{path.name}: provider is not block aligned")
    path.write_bytes(data)


def save_multifixture(
    output: Path,
    name: str,
    fixture: MultiFixture,
    paths: list[str],
    manifest: dict[str, Any],
) -> tuple[Path, list[Path]]:
    primary_path = output / "images" / f"{name}-primary.erofs"
    finalize_image(fixture.primary, primary_path)
    provider_paths = []
    for index, provider in enumerate(fixture.providers, 1):
        provider_path = output / "images" / f"{name}-slot{index}.blob"
        write_provider(provider_path, provider)
        provider_paths.append(provider_path)
    manifest["fixtures"][name] = {
        "primary": str(primary_path.relative_to(output)),
        "providers": [str(path.relative_to(output)) for path in provider_paths],
        "image": image_description(fixture.primary, paths),
    }
    return primary_path, provider_paths


def save_image_fixture(
    output: Path,
    name: str,
    image: ErofsImage,
    paths: list[str],
    manifest: dict[str, Any],
) -> Path:
    path = output / "images" / f"{name}.erofs"
    finalize_image(image, path)
    manifest["fixtures"][name] = {
        "primary": str(path.relative_to(output)),
        "providers": [],
        "image": image_description(image, paths),
    }
    return path


def add_mutation(
    manifest: dict[str, Any],
    artifact: Path,
    output: Path,
    label: str,
    offset: int,
    fmt: str,
    value: int,
) -> None:
    manifest["mutations"].append(
        {
            "artifact": str(artifact.relative_to(output)),
            "label": label,
            "offset": offset,
            "format": fmt,
            "value": value,
        }
    )


def save_negative(
    output: Path,
    name: str,
    image: ErofsImage,
    manifest: dict[str, Any],
) -> Path:
    path = output / "images" / f"{name}.erofs"
    finalize_image(image, path)
    manifest["negative_images"][name] = str(path.relative_to(output))
    return path


def generate(args: argparse.Namespace) -> None:
    output: Path = args.output.resolve()
    if output.exists():
        raise FixtureError(f"output already exists: {output}")
    output.mkdir(parents=True)
    (output / "images").mkdir()
    chunk_source = output / "sources" / "chunk"
    fragment_source = output / "sources" / "fragment"
    pcluster_source = output / "sources" / "pcluster"
    write_chunk_sources(chunk_source)
    write_fragment_source(fragment_source)
    write_pcluster_source(pcluster_source)

    mkfs_version = tool_version(args.mkfs)
    fsck_version = tool_version(args.fsck)
    if "1.8.6" not in mkfs_version or "1.8.6" not in fsck_version:
        raise FixtureError(
            f"G6 requires erofs-utils 1.8.6, got {mkfs_version!r}, {fsck_version!r}"
        )

    base_primary_path = output / "images" / "mkfs-blob-primary.erofs"
    base_blob_path = output / "images" / "mkfs-blob-slot1.blob"
    base_blob_path.write_bytes(b"")
    make_image(
        args.mkfs,
        base_primary_path,
        chunk_source,
        CHUNK_UUID,
        ["--chunksize=4096", f"--blobdev={base_blob_path}"],
    )
    baseline = ErofsImage.load(base_primary_path)
    baseline.validate_superblock()
    paths = [f"/{name}" for name in CHUNK_FILES]
    assert_chunk_baseline(baseline, paths)
    base_blob = base_blob_path.read_bytes()
    if len(baseline.data) != BLOCK_SIZE or len(base_blob) % BLOCK_SIZE != 0:
        raise FixtureError("mkfs blob baseline has unexpected provider sizes")

    manifest: dict[str, Any] = {
        "schema": 1,
        "generator": "tests/g6_multidev_fixtures.py",
        "erofs_utils": {"mkfs": mkfs_version, "fsck": fsck_version},
        "fixtures": {},
        "negative_images": {},
        "mutations": [],
        "artifacts": {},
        "host_qualification": {
            "flatdev": (
                "kernel-only: erofs-utils 1.8.6 requires --device for "
                "nonzero chunk device IDs"
            ),
            "external_pcluster": (
                "kernel-only after relocation: erofs-utils 1.8.6 does not "
                "apply the device-ID-0 unified mapping used by the kernel"
            ),
        },
    }
    manifest["fixtures"]["mkfs-blob"] = {
        "primary": str(base_primary_path.relative_to(output)),
        "providers": [str(base_blob_path.relative_to(output))],
        "image": image_description(baseline, paths),
    }

    single = build_single_indexed(baseline, base_blob, paths)
    single_path = save_image_fixture(
        output, "single-indexed", single, paths, manifest
    )

    multi2 = build_multislot(baseline, base_blob, paths, 2)
    multi2_primary, multi2_providers = save_multifixture(
        output, "multi2", multi2, paths, manifest
    )
    flatdev_path = output / "images" / "multi2-flatdev.erofs"
    write_provider(flatdev_path, build_flatdev(multi2))
    manifest["fixtures"]["multi2-flatdev"] = {
        "primary": str(flatdev_path.relative_to(output)),
        "providers": [],
        "image": image_description(ErofsImage.load(flatdev_path), paths),
    }

    multi3 = build_multislot(baseline, base_blob, paths, 3)
    multi3_primary, multi3_providers = save_multifixture(
        output, "multi3", multi3, paths, manifest
    )

    slot_zero = clone_multifixture(multi2)
    zero_slots = [DeviceSlot(slot_zero.slots[0].blocks, 0), slot_zero.slots[1]]
    rewrite_slots(slot_zero, zero_slots)
    slot_zero_primary, slot_zero_providers = save_multifixture(
        output, "multi2-uniaddr0", slot_zero, paths, manifest
    )

    table_zero = clone_multifixture(multi2)
    table_bytes = bytes(
        table_zero.primary.data[
            BLOCK_SIZE : BLOCK_SIZE + 2 * DEVICE_SLOT_SIZE
        ]
    )
    table_zero.primary.data[: 2 * DEVICE_SLOT_SIZE] = table_bytes
    table_zero.primary.put_u16(SUPER + 88, 0)
    table_zero_primary, table_zero_providers = save_multifixture(
        output, "multi2-devt0", table_zero, paths, manifest
    )

    unified = clone_multifixture(multi2)
    patch_unified_entry(unified, "/unified-address.bin")
    unified_primary, unified_providers = save_multifixture(
        output, "multi2-unified", unified, paths, manifest
    )
    unified_flatdev_path = output / "images" / "multi2-unified-flatdev.erofs"
    write_provider(unified_flatdev_path, build_flatdev(unified))
    manifest["fixtures"]["multi2-unified-flatdev"] = {
        "primary": str(unified_flatdev_path.relative_to(output)),
        "providers": [],
        "image": image_description(
            ErofsImage.load(unified_flatdev_path), paths
        ),
    }

    unified48 = clone_multifixture(multi2)
    enable_48bit(unified48.primary)
    set_chunk_48bit(unified48.primary, paths)
    high_start = (1 << 32) + 2
    high_slots = []
    cursor = high_start
    for slot in unified48.slots:
        high_slots.append(DeviceSlot(slot.blocks, cursor))
        cursor += slot.blocks
    rewrite_slots(unified48, high_slots)
    patch_unified_entry(unified48, "/unified-address.bin")
    unified48_primary, unified48_providers = save_multifixture(
        output, "multi2-unified48", unified48, paths, manifest
    )

    unified48_oob = clone_multifixture(unified48)
    unified48_oob_entry = first_entry(
        unified48_oob.primary, "/unified-address.bin"
    )
    patch_chunk_entry(
        unified48_oob.primary,
        unified48_oob_entry,
        expected=(
            unified48_oob_entry.start_block_high,
            0,
            unified48_oob_entry.start_block_low,
        ),
        replacement=(0xFFFF, 0, 0xFFFFFFFE),
    )
    unified48_oob_primary, unified48_oob_providers = save_multifixture(
        output, "bad-unified48-out-of-range", unified48_oob, paths, manifest
    )
    add_mutation(
        manifest,
        unified48_oob_primary,
        output,
        "device-ID-0 chunk address is the largest non-NULL 48-bit block",
        unified48_oob_entry.offset,
        "<HHI",
        [0xFFFF, 0, 0xFFFFFFFE],
    )

    mapped_id = multi2.primary.clone()
    mapped_entry = first_entry(mapped_id, "/indexed.bin", 1)
    patch_chunk_entry(
        mapped_id,
        mapped_entry,
        expected=(0, mapped_entry.device_id, mapped_entry.start_block_low),
        replacement=(0, 3, mapped_entry.start_block_low),
    )
    mapped_id_path = save_negative(
        output, "bad-mapped-device3", mapped_id, manifest
    )
    add_mutation(
        manifest,
        mapped_id_path,
        output,
        "mapped device ID remains undeclared ID 3",
        mapped_entry.offset + 2,
        "<H",
        3,
    )

    gap = clone_multifixture(multi2)
    gap_slots = [
        gap.slots[0],
        DeviceSlot(
            gap.slots[1].blocks,
            gap.slots[0].uniaddr + gap.slots[0].blocks + 2,
        ),
    ]
    rewrite_slots(gap, gap_slots)
    gap_entry = first_entry(gap.primary, "/unified-address.bin")
    gap_block = gap.slots[0].uniaddr + gap.slots[0].blocks
    patch_chunk_entry(
        gap.primary,
        gap_entry,
        expected=(0, gap_entry.device_id, gap_entry.start_block_low),
        replacement=(0, 0, gap_block),
    )
    gap_primary, gap_providers = save_multifixture(
        output, "bad-unified-gap", gap, paths, manifest
    )
    gap_flatdev_path = output / "images" / "bad-unified-gap-flatdev.erofs"
    write_provider(gap_flatdev_path, build_flatdev(gap))
    add_mutation(
        manifest,
        gap_primary,
        output,
        "device-ID-0 chunk points at a declared-address gap",
        gap_entry.offset + 2,
        "<HI",
        [0, gap_block],
    )

    cross_explicit = clone_multifixture(multi2)
    cross_entry = first_entry(cross_explicit.primary, "/indexed.bin")
    cross_local = cross_explicit.slots[0].blocks - 1
    patch_chunk_entry(
        cross_explicit.primary,
        cross_entry,
        expected=(0, cross_entry.device_id, cross_entry.start_block_low),
        replacement=(0, 1, cross_local),
    )
    cross_explicit_primary, cross_explicit_providers = save_multifixture(
        output, "bad-cross-slot-explicit", cross_explicit, paths, manifest
    )

    cross_unified = clone_multifixture(multi2)
    cross_unified_entry = first_entry(cross_unified.primary, "/indexed.bin")
    cross_global = (
        cross_unified.slots[0].uniaddr + cross_unified.slots[0].blocks - 1
    )
    patch_chunk_entry(
        cross_unified.primary,
        cross_unified_entry,
        expected=(
            0,
            cross_unified_entry.device_id,
            cross_unified_entry.start_block_low,
        ),
        replacement=(0, 0, cross_global),
    )
    cross_unified_primary, cross_unified_providers = save_multifixture(
        output, "bad-cross-slot-unified", cross_unified, paths, manifest
    )
    cross_unified_flatdev_path = (
        output / "images" / "bad-cross-slot-unified-flatdev.erofs"
    )
    write_provider(cross_unified_flatdev_path, build_flatdev(cross_unified))

    invalid_builders: list[
        tuple[str, Callable[[ErofsImage], tuple[int, str, int, str]]]
    ] = []

    def table_oob(image: ErofsImage) -> tuple[int, str, int, str]:
        value = image.blocks * BLOCK_SIZE // DEVICE_SLOT_SIZE
        image.put_u16(SUPER + 88, value)
        return SUPER + 88, "<H", value, "device table starts at image end"

    invalid_builders.append(("bad-table-oob", table_oob))

    def zero_blocks(image: ErofsImage) -> tuple[int, str, int, str]:
        patch_table_field(image, 1, 0, "<I", 0)
        return BLOCK_SIZE + 64, "<I", 0, "slot 1 has zero blocks"

    invalid_builders.append(("bad-slot-zero-blocks", zero_blocks))

    def inside_primary(image: ErofsImage) -> tuple[int, str, int, str]:
        patch_table_field(image, 1, 4, "<I", 1)
        return BLOCK_SIZE + 68, "<I", 1, "slot 1 begins in primary range"

    invalid_builders.append(("bad-slot-inside-primary", inside_primary))

    def overlap(image: ErofsImage) -> tuple[int, str, int, str]:
        value = multi2.slots[0].uniaddr
        patch_table_field(image, 2, 4, "<I", value)
        return (
            BLOCK_SIZE + DEVICE_SLOT_SIZE + 68,
            "<I",
            value,
            "slot 2 overlaps slot 1",
        )

    invalid_builders.append(("bad-slot-overlap", overlap))

    def range32(image: ErofsImage) -> tuple[int, str, int, str]:
        patch_table_field(image, 1, 0, "<I", 2)
        patch_table_field(image, 1, 4, "<I", 0xFFFFFFFF)
        return (
            BLOCK_SIZE + 68,
            "<I",
            0xFFFFFFFF,
            "non-48-bit range ends above 2^32",
        )

    invalid_builders.append(("bad-slot-32bit-limit", range32))

    def range48(image: ErofsImage) -> tuple[int, str, int, str]:
        enable_48bit(image)
        patch_table_field(image, 1, 0, "<I", 2)
        patch_table_field(image, 1, 4, "<I", 0xFFFFFFFF)
        patch_table_field(image, 1, 10, "<H", 0xFFFF)
        return (
            BLOCK_SIZE + 74,
            "<H",
            0xFFFF,
            "48-bit range ends above 2^48",
        )

    invalid_builders.append(("bad-slot-48bit-limit", range48))

    for name, builder in invalid_builders:
        invalid = multi2.primary.clone()
        offset, fmt, value, label = builder(invalid)
        path = save_negative(output, name, invalid, manifest)
        add_mutation(manifest, path, output, label, offset, fmt, value)

    fragment_base_path = output / "images" / "fragment.erofs"
    make_image(
        args.mkfs,
        fragment_base_path,
        fragment_source,
        FRAGMENT_UUID,
        ["-zlz4", "-C65536", "-E", "all-fragments"],
    )
    fragment = ErofsImage.load(fragment_base_path)
    _, fragment_entry = fragment.resolve_root_entry("/fragment.dat")
    fragment_inode = fragment.inode(fragment_entry.nid)
    packed_nid = fragment.u64(SUPER + 96)
    if packed_nid == 0 or fragment_inode.layout not in (1, 3):
        raise FixtureError("mkfs fragment fixture lacks packed fragment inode")
    manifest["fixtures"]["fragment"] = {
        "primary": str(fragment_base_path.relative_to(output)),
        "providers": [],
        "packed_nid": packed_nid,
        "fragment_nid": fragment_inode.nid,
        "fragment_layout": fragment_inode.layout,
        "image": image_description(fragment, []),
    }

    lz4_base_path = output / "images" / "lz4-two-block-base.erofs"
    make_image(
        args.mkfs,
        lz4_base_path,
        pcluster_source,
        PCLUSTER_UUID,
        ["-zlz4", "-C65536", "-E", "legacy-compress"],
    )
    lz4 = ErofsImage.load(lz4_base_path)
    pcluster, pcluster_index_offset = make_pcluster_external(lz4)
    pcluster_primary, pcluster_providers = save_multifixture(
        output, "lz4-external-pcluster", pcluster, [], manifest
    )
    manifest["fixtures"]["lz4-external-pcluster"]["head_index_offset"] = (
        pcluster_index_offset
    )
    manifest["fixtures"]["lz4-external-pcluster"]["compressed_bytes"] = 8192
    add_mutation(
        manifest,
        pcluster_primary,
        output,
        "LZ4 HEAD pblk selects external unified range",
        pcluster_index_offset + 4,
        "<I",
        2,
    )
    pcluster_cross = make_pcluster_crossing(pcluster, pcluster_index_offset)
    pcluster_cross_primary, pcluster_cross_providers = save_multifixture(
        output, "bad-lz4-cross-slot", pcluster_cross, [], manifest
    )
    pcluster_cross_flatdev = output / "images" / "bad-lz4-cross-slot-flatdev.erofs"
    write_provider(pcluster_cross_flatdev, build_flatdev(pcluster_cross))

    short_slot2 = output / "images" / "multi2-slot2-short.blob"
    if len(multi2.providers[1]) <= BLOCK_SIZE:
        raise FixtureError("slot 2 is too short to create truncation fixture")
    write_provider(short_slot2, multi2.providers[1][:-BLOCK_SIZE])
    short_slot3 = output / "images" / "multi3-slot3-short.blob"
    if len(multi3.providers[2]) <= BLOCK_SIZE:
        raise FixtureError("slot 3 is too short to create truncation fixture")
    write_provider(short_slot3, multi3.providers[2][:-BLOCK_SIZE])

    fsck_extract(
        args.fsck,
        base_primary_path,
        [base_blob_path],
        chunk_source,
        output.parent,
    )
    fsck_extract(args.fsck, single_path, [], chunk_source, output.parent)
    fsck_extract(
        args.fsck,
        multi2_primary,
        multi2_providers,
        chunk_source,
        output.parent,
    )
    fsck_extract(
        args.fsck,
        multi3_primary,
        multi3_providers,
        chunk_source,
        output.parent,
    )
    fsck_extract(
        args.fsck,
        slot_zero_primary,
        slot_zero_providers,
        chunk_source,
        output.parent,
    )
    fsck_extract(
        args.fsck,
        table_zero_primary,
        table_zero_providers,
        chunk_source,
        output.parent,
    )
    fsck_extract(
        args.fsck,
        fragment_base_path,
        [],
        fragment_source,
        output.parent,
    )
    fsck_extract(
        args.fsck, lz4_base_path, [], pcluster_source, output.parent
    )

    for path in sorted(output.rglob("*")):
        if path.is_file() and path.name not in ("manifest.json", "SHA256SUMS"):
            relative = str(path.relative_to(output))
            manifest["artifacts"][relative] = {
                "bytes": path.stat().st_size,
                "sha256": sha256_file(path),
            }
    manifest_path = output / "manifest.json"
    manifest_path.write_text(
        json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="ascii"
    )
    with (output / "SHA256SUMS").open("w", encoding="ascii") as sums:
        for relative, record in sorted(manifest["artifacts"].items()):
            sums.write(f"{record['sha256']}  {relative}\n")
        sums.write(f"{sha256_file(manifest_path)}  manifest.json\n")
    verify_output(output)
    print(
        f"generated={output} artifacts={len(manifest['artifacts'])} "
        f"fixtures={len(manifest['fixtures'])} negatives={len(manifest['negative_images'])}"
    )


def unpack_assertion(path: Path, offset: int, fmt: str) -> Any:
    size = struct.calcsize(fmt)
    with path.open("rb") as stream:
        stream.seek(offset)
        data = stream.read(size)
    if len(data) != size:
        raise FixtureError(f"{path}: field at {offset} is truncated")
    values = struct.unpack(fmt, data)
    return values[0] if len(values) == 1 else list(values)


def verify_image_description(path: Path, expected: dict[str, Any]) -> None:
    image = ErofsImage.load(path)
    paths = list(expected["chunks"])
    actual = image_description(image, paths)
    if actual != expected:
        raise FixtureError(f"{path}: parsed image fields differ from manifest")


def verify_output(output: Path) -> None:
    manifest_path = output / "manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="ascii"))
    if manifest.get("schema") != 1:
        raise FixtureError("unsupported G6 manifest schema")
    for relative, expected in manifest["artifacts"].items():
        path = output / relative
        if not path.is_file():
            raise FixtureError(f"missing artifact: {relative}")
        if path.stat().st_size != expected["bytes"]:
            raise FixtureError(f"size differs: {relative}")
        if sha256_file(path) != expected["sha256"]:
            raise FixtureError(f"SHA256 differs: {relative}")
    for fixture in manifest["fixtures"].values():
        image = fixture.get("image")
        if image is not None:
            verify_image_description(output / fixture["primary"], image)
    for mutation in manifest["mutations"]:
        actual = unpack_assertion(
            output / mutation["artifact"],
            mutation["offset"],
            mutation["format"],
        )
        if actual != mutation["value"]:
            raise FixtureError(
                f"{mutation['label']}: expected {mutation['value']}, got {actual}"
            )
    run(["sha256sum", "-c", "SHA256SUMS"], stdout=True, cwd=output)
    print(
        f"verified={output} artifacts={len(manifest['artifacts'])} "
        f"field_assertions={len(manifest['mutations'])}"
    )


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)

    generate_parser = subparsers.add_parser("generate")
    generate_parser.add_argument("--output", required=True, type=Path)
    generate_parser.add_argument("--mkfs", default=shutil.which("mkfs.erofs"))
    generate_parser.add_argument("--fsck", default=shutil.which("fsck.erofs"))
    generate_parser.set_defaults(function=generate)

    verify_parser = subparsers.add_parser("verify")
    verify_parser.add_argument("output", type=Path)
    verify_parser.set_defaults(function=lambda args: verify_output(args.output.resolve()))
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.command == "generate" and (args.mkfs is None or args.fsck is None):
        raise FixtureError("mkfs.erofs and fsck.erofs are required")
    args.function(args)


if __name__ == "__main__":
    os.environ.setdefault("PYTHONDONTWRITEBYTECODE", "1")
    main()
