#!/usr/bin/env python3
from __future__ import annotations

import argparse
import hashlib
import json
import math
from pathlib import Path
import re
import stat
import struct
import subprocess
import sys
from typing import Any


SCRIPT_DIR = Path(__file__).resolve().parent
TESTS_DIR = SCRIPT_DIR.parent.parent
sys.path.insert(0, str(TESTS_DIR))

from erofs_fixture import ErofsImage, FEATURE_INCOMPAT_48BIT, SUPER


BLOCK_SIZE = 4096
CHUNK_FORMAT_INDEXES = 0x0020
CHUNK_FORMAT_48BIT = 0x0040
CHUNK_FORMAT_ALL = 0x007F
FEATURE_INCOMPAT_CHUNKED_FILE = 0x00000004
EROFS_I_DOT_OMITTED_BIT = 4
EROFS_I_NLINK_1_BIT = 5
EROFS_FT_REG_FILE = 1
EROFS_FT_DIR = 2
EROFS_FT_CHRDEV = 3
EROFS_FT_SYMLINK = 7
UUID = "67360174-0014-0000-0000-000000000174"
TARGET = b"target.txt"
TARGET_CONTENT = b"B14 target content\n"
ENTRY_CONTENT = b"B14 directory entry\n"


class FixtureError(RuntimeError):
    pass


def run(command: list[str], *, capture: bool = False) -> str:
    result = subprocess.run(
        command,
        check=False,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
    )
    if result.returncode != 0:
        raise FixtureError(
            f"command failed ({result.returncode}): {' '.join(command)}\n{result.stdout}"
        )
    return result.stdout if capture else ""


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


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


def source_bytes(label: str, size: int) -> bytes:
    chunks = []
    for index in range(math.ceil(size / 32)):
        chunks.append(hashlib.sha256(f"B14:{label}:{index}".encode("ascii")).digest())
    return b"".join(chunks)[:size]


def make_source(root: Path) -> None:
    real_dir = root / "real-dir"
    real_dir.mkdir(parents=True)
    (real_dir / "entry.txt").write_bytes(ENTRY_CONTENT)
    (root / "target.txt").write_bytes(TARGET_CONTENT)
    (root / "chunk-dir").write_bytes(source_bytes("chunk-dir", 8192))
    link_data = TARGET + source_bytes("chunk-link", 8192 - len(TARGET))
    (root / "chunk-link").write_bytes(link_data)
    (root / "chunk-char").write_bytes(source_bytes("chunk-char", 8192))


def make_image(mkfs: str, primary: Path, blob: Path, source: Path) -> None:
    run(
        [
            mkfs,
            "-T0",
            "--all-time",
            "--all-root",
            "--workers=1",
            f"-U{UUID}",
            "--chunksize=4096",
            f"--blobdev={blob}",
            str(primary),
            str(source),
        ]
    )


def root_entry(image: ErofsImage, path: str):
    _, entry = image.resolve_root_entry(path)
    return entry


def inode_data(image: ErofsImage, inode) -> bytes:
    if inode.layout == 0:
        offset = inode.start_block << image.block_bits
    elif inode.layout == 2:
        offset = inode.offset + inode.inode_size + inode.xattr_size
    else:
        raise FixtureError(f"unsupported source data layout {inode.layout}")
    end = offset + inode.size
    if end > len(image.data):
        raise FixtureError("source inode data exceeds primary image")
    return bytes(image.data[offset:end])


def chunk_info(image: ErofsImage, path: str) -> dict[str, int]:
    entry = root_entry(image, 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)
    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 entry_size == 8:
        high, device_id, low = struct.unpack_from("<HHI", image.data, index_base)
    else:
        high, device_id = 0, 0
        low = struct.unpack_from("<I", image.data, index_base)[0]
    return {
        "nid": inode.nid,
        "inode_offset": inode.offset,
        "inode_size": inode.inode_size,
        "format": chunk_format,
        "reserved": reserved,
        "index_base": index_base,
        "entry_size": entry_size,
        "high": high,
        "device_id": device_id,
        "low": low,
        "size": inode.size,
        "mode": inode.mode,
    }


def patch_root_type(image: ErofsImage, path: str, file_type: int) -> None:
    entry = root_entry(image, path)
    offset = entry.offset + 10
    if image.data[offset] != EROFS_FT_REG_FILE:
        raise FixtureError(f"{path}: root entry is not a regular file")
    image.data[offset] = file_type


def patch_mode(image: ErofsImage, path: str, mode_type: int) -> None:
    info = chunk_info(image, path)
    if stat.S_IFMT(info["mode"]) != stat.S_IFREG:
        raise FixtureError(f"{path}: carrier inode is not regular")
    image.put_u16(info["inode_offset"] + 4, mode_type | (info["mode"] & 0o7777))


def patch_size(image: ErofsImage, path: str, size: int) -> None:
    info = chunk_info(image, path)
    if info["inode_size"] == 32:
        image.put_u32(info["inode_offset"] + 8, size)
    else:
        image.put_u64(info["inode_offset"] + 8, size)


def patch_directory(image: ErofsImage, blob: bytearray) -> None:
    real_entry = root_entry(image, "/real-dir")
    real_inode = image.inode(real_entry.nid)
    directory_data = bytearray(inode_data(image, real_inode))
    info = chunk_info(image, "/chunk-dir")
    if info["device_id"] != 1 or info["high"] != 0:
        raise FixtureError("chunk directory carrier is not on external slot 1")
    blob_offset = info["low"] << image.block_bits
    if blob_offset + image.block_size > len(blob):
        raise FixtureError("chunk directory carrier exceeds blob")
    first_name_offset = struct.unpack_from("<H", directory_data, 8)[0]
    for index in range(first_name_offset // 12):
        entry_offset = index * 12
        name_offset = struct.unpack_from("<H", directory_data, entry_offset + 8)[0]
        end_offset = (
            struct.unpack_from("<H", directory_data, entry_offset + 20)[0]
            if index + 1 < first_name_offset // 12
            else len(directory_data)
        )
        name = bytes(directory_data[name_offset:end_offset]).split(b"\0", 1)[0]
        if name == b".":
            struct.pack_into("<Q", directory_data, entry_offset, info["nid"])
        elif name == b"..":
            struct.pack_into("<Q", directory_data, entry_offset, image.root_nid)
    blob[blob_offset : blob_offset + image.block_size] = b"\0" * image.block_size
    blob[blob_offset : blob_offset + len(directory_data)] = directory_data
    patch_mode(image, "/chunk-dir", stat.S_IFDIR)
    patch_size(image, "/chunk-dir", len(directory_data))
    inode_offset = info["inode_offset"]
    inode_format = image.u16(inode_offset)
    inode_format &= ~((1 << EROFS_I_DOT_OMITTED_BIT) | (1 << EROFS_I_NLINK_1_BIT))
    inode_format |= real_inode.inode_format & (1 << EROFS_I_DOT_OMITTED_BIT)
    image.put_u16(inode_offset, inode_format)
    if info["inode_size"] == 32:
        image.put_u16(inode_offset + 6, 2)
    else:
        image.put_u32(inode_offset + 40, 2)
    patch_root_type(image, "/chunk-dir", EROFS_FT_DIR)


def patch_symlink(image: ErofsImage) -> None:
    patch_mode(image, "/chunk-link", stat.S_IFLNK)
    patch_size(image, "/chunk-link", len(TARGET))
    patch_root_type(image, "/chunk-link", EROFS_FT_SYMLINK)


def patch_character(image: ErofsImage) -> None:
    patch_mode(image, "/chunk-char", stat.S_IFCHR)
    patch_size(image, "/chunk-char", 0)
    patch_root_type(image, "/chunk-char", EROFS_FT_CHRDEV)


def enable_48bit(image: ErofsImage) -> None:
    if image.feature_incompat & FEATURE_INCOMPAT_48BIT:
        raise FixtureError("48-bit feature already enabled")
    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)
    for path in ("/chunk-dir", "/chunk-link", "/chunk-char"):
        info = chunk_info(image, path)
        if info["entry_size"] != 8 or info["high"] != 0:
            raise FixtureError(f"{path}: cannot promote chunk index to 48-bit")
        image.put_u16(info["inode_offset"] + 16, info["format"] | CHUNK_FORMAT_48BIT)


def save_image(image: ErofsImage, path: Path) -> None:
    if image.feature_compat & 1:
        image.update_checksum()
    image.validate_superblock()
    image.save(path)


def patch_index_low(image: ErofsImage, path: str, value: int) -> None:
    info = chunk_info(image, path)
    if info["entry_size"] != 8:
        raise FixtureError("bad-index fixture requires an indexed chunk")
    image.put_u32(info["index_base"] + 4, value)


def patch_index48(image: ErofsImage, path: str, high: int, low: int) -> None:
    info = chunk_info(image, path)
    if info["entry_size"] != 8 or not info["format"] & CHUNK_FORMAT_48BIT:
        raise FixtureError("bad-index48 fixture requires a 48-bit chunk index")
    image.put_u16(info["index_base"], high)
    image.put_u32(info["index_base"] + 4, low)


def load_spec(path: Path) -> dict[str, Any]:
    spec = json.loads(path.read_text(encoding="ascii"))
    if spec.get("schema") != 1 or spec.get("batch") != "B14":
        raise FixtureError("invalid B14 fixture schema")
    if spec.get("candidate") != "P15-056" or spec.get("test") != "TC174-chunk-nonregular":
        raise FixtureError("invalid B14 fixture identity")
    cases = spec.get("cases")
    if not isinstance(cases, list) or len(cases) != 10:
        raise FixtureError("B14 fixture case denominator changed")
    identifiers = [case.get("id") for case in cases]
    if len(set(identifiers)) != len(identifiers) or not all(isinstance(value, str) for value in identifiers):
        raise FixtureError("invalid or duplicate B14 case ID")
    return spec


def write_sums(output: Path, names: list[str]) -> dict[str, str]:
    hashes = {name: sha256(output / name) for name in sorted(names)}
    with (output / "SHA256SUMS").open("w", encoding="ascii") as stream:
        for name, digest in hashes.items():
            stream.write(f"{digest}  {name}\n")
    return hashes


def generate(args: argparse.Namespace) -> None:
    output = args.output
    if output.exists():
        raise FixtureError(f"output already exists: {output}")
    spec = load_spec(args.spec)
    output.mkdir(parents=True)
    source = output / "source"
    source.mkdir()
    make_source(source)
    raw_primary = output / "raw.erofs"
    blob_path = output / "B14-slot1.blob"
    blob_path.write_bytes(b"")
    make_image(args.mkfs, raw_primary, blob_path, source)

    image = ErofsImage.load(raw_primary)
    image.validate_superblock()
    if not image.feature_incompat & FEATURE_INCOMPAT_CHUNKED_FILE:
        raise FixtureError("mkfs image lacks CHUNKED_FILE")
    blob = bytearray(blob_path.read_bytes())
    for path in ("/chunk-dir", "/chunk-link", "/chunk-char"):
        info = chunk_info(image, path)
        if info["entry_size"] != 8 or info["device_id"] != 1 or info["high"] != 0:
            raise FixtureError(f"{path}: unexpected mkfs chunk carrier")

    patch_directory(image, blob)
    patch_symlink(image)
    patch_character(image)
    blob_path.write_bytes(blob)
    good32 = image.clone()
    save_image(good32, output / "B14-good32.erofs")

    good48 = image.clone()
    enable_48bit(good48)
    save_image(good48, output / "B14-good48.erofs")

    blob_blocks = len(blob) // BLOCK_SIZE
    bad_index = good32.clone()
    patch_index_low(bad_index, "/chunk-link", blob_blocks + 1)
    save_image(bad_index, output / "B14-bad-index.erofs")

    bad_index48 = good48.clone()
    patch_index48(bad_index48, "/chunk-link", 0xFFFF, 0xFFFFFFFE)
    save_image(bad_index48, output / "B14-bad-index48.erofs")

    bad_reserved = good32.clone()
    directory = chunk_info(bad_reserved, "/chunk-dir")
    bad_reserved.put_u16(directory["inode_offset"] + 18, 1)
    save_image(bad_reserved, output / "B14-bad-reserved.erofs")

    bad_format = good32.clone()
    link = chunk_info(bad_format, "/chunk-link")
    bad_format.put_u16(link["inode_offset"] + 16, link["format"] | (CHUNK_FORMAT_ALL + 1))
    save_image(bad_format, output / "B14-bad-format.erofs")

    bad_no_index = good48.clone()
    link48 = chunk_info(bad_no_index, "/chunk-link")
    bad_no_index.put_u16(
        link48["inode_offset"] + 16,
        (link48["format"] | CHUNK_FORMAT_48BIT) & ~CHUNK_FORMAT_INDEXES,
    )
    save_image(bad_no_index, output / "B14-bad-48-no-index.erofs")
    raw_primary.unlink()

    names = [
        "B14-slot1.blob",
        "B14-good32.erofs",
        "B14-good48.erofs",
        "B14-bad-index.erofs",
        "B14-bad-index48.erofs",
        "B14-bad-reserved.erofs",
        "B14-bad-format.erofs",
        "B14-bad-48-no-index.erofs",
    ]
    hashes = write_sums(output, names)
    manifest = {
        "schema": 1,
        "batch": "B14",
        "candidate": "P15-056",
        "test": "TC174-chunk-nonregular",
        "mkfs_version": run([args.mkfs, "-V"], capture=True).splitlines()[0],
        "hashes": hashes,
        "blob_blocks": blob_blocks,
        "target": TARGET.decode("ascii"),
        "qualification": {
            "good32": "fsck.erofs plus independent parser",
            "good48": "independent parser; erofs-utils 1.8.6 lacks 48-bit support",
        },
        "cases": [case["id"] for case in spec["cases"]],
        "good32": {
            path: chunk_info(good32, path)
            for path in ("/chunk-dir", "/chunk-link", "/chunk-char")
        },
        "good48": {
            path: chunk_info(good48, path)
            for path in ("/chunk-dir", "/chunk-link", "/chunk-char")
        },
    }
    (output / "manifest.json").write_text(
        json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="ascii"
    )


def verify(args: argparse.Namespace) -> None:
    spec = load_spec(args.spec)
    output = args.output
    manifest = json.loads((output / "manifest.json").read_text(encoding="ascii"))
    if manifest.get("cases") != [case["id"] for case in spec["cases"]]:
        raise FixtureError("manifest case list differs from B14 spec")
    for name, expected in manifest.get("hashes", {}).items():
        if sha256(output / name) != expected:
            raise FixtureError(f"fixture hash mismatch: {name}")

    good32 = ErofsImage.load(output / "B14-good32.erofs")
    good48 = ErofsImage.load(output / "B14-good48.erofs")
    blob = (output / "B14-slot1.blob").read_bytes()
    for image, width in ((good32, 32), (good48, 48)):
        image.validate_superblock()
        directory = chunk_info(image, "/chunk-dir")
        link = chunk_info(image, "/chunk-link")
        character = chunk_info(image, "/chunk-char")
        for info in (directory, link, character):
            if info["device_id"] != 1 or info["entry_size"] != 8:
                raise FixtureError(f"{width}-bit external chunk contract changed")
            has_48bit = bool(info["format"] & CHUNK_FORMAT_48BIT)
            if has_48bit != (width == 48) or info["high"] != 0:
                raise FixtureError(f"{width}-bit chunk format contract changed")
        if stat.S_IFMT(directory["mode"]) != stat.S_IFDIR:
            raise FixtureError("chunk directory mode changed")
        if stat.S_IFMT(link["mode"]) != stat.S_IFLNK or link["size"] != len(TARGET):
            raise FixtureError("chunk symlink mode or size changed")
        if stat.S_IFMT(character["mode"]) != stat.S_IFCHR or character["size"] != 0:
            raise FixtureError("chunk character mode or size changed")
        link_offset = link["low"] << image.block_bits
        if blob[link_offset : link_offset + len(TARGET)] != TARGET:
            raise FixtureError("chunk symlink payload changed")
        directory_offset = directory["low"] << image.block_bits
        directory_data = blob[directory_offset : directory_offset + directory["size"]]
        first_name_offset = struct.unpack_from("<H", directory_data, 8)[0]
        names = []
        nids = {}
        for index in range(first_name_offset // 12):
            entry_offset = index * 12
            name_offset = struct.unpack_from("<H", directory_data, entry_offset + 8)[0]
            end_offset = (
                struct.unpack_from("<H", directory_data, entry_offset + 20)[0]
                if index + 1 < first_name_offset // 12
                else len(directory_data)
            )
            name = bytes(directory_data[name_offset:end_offset]).split(b"\0", 1)[0]
            names.append(name)
            nids[name] = struct.unpack_from("<Q", directory_data, entry_offset)[0]
        if b"entry.txt" not in names or nids.get(b".", directory["nid"]) != directory["nid"]:
            raise FixtureError("chunk directory payload identity changed")

    bad_index = ErofsImage.load(output / "B14-bad-index.erofs")
    if chunk_info(bad_index, "/chunk-link")["low"] <= manifest["blob_blocks"]:
        raise FixtureError("32-bit bad index remains inside external device")
    bad_index48 = chunk_info(ErofsImage.load(output / "B14-bad-index48.erofs"), "/chunk-link")
    if bad_index48["high"] != 0xFFFF or bad_index48["low"] != 0xFFFFFFFE:
        raise FixtureError("48-bit bad index boundary changed")
    if chunk_info(ErofsImage.load(output / "B14-bad-reserved.erofs"), "/chunk-dir")["reserved"] != 1:
        raise FixtureError("reserved-field mutation changed")
    if not chunk_info(ErofsImage.load(output / "B14-bad-format.erofs"), "/chunk-link")["format"] & 0x80:
        raise FixtureError("unsupported-format mutation changed")
    no_index = chunk_info(ErofsImage.load(output / "B14-bad-48-no-index.erofs"), "/chunk-link")
    if not no_index["format"] & CHUNK_FORMAT_48BIT or no_index["format"] & CHUNK_FORMAT_INDEXES:
        raise FixtureError("48-bit-without-indexes mutation changed")

    run(
        [
            args.fsck,
            f"--device={output / 'B14-slot1.blob'}",
            str(output / "B14-good32.erofs"),
        ]
    )


def extract_function(source: str, name: str) -> str:
    match = re.search(
        r"^(?:[A-Za-z_][^\n;{}]*\s+)?" + re.escape(name) + r"\s*\(",
        source,
        re.MULTILINE,
    )
    if not match:
        raise FixtureError(f"missing function: {name}")
    start = source.rfind("\n\n", 0, match.start()) + 2
    brace = source.find("{", match.end())
    depth = 0
    state = "code"
    index = brace
    while index < len(source):
        char = source[index]
        following = source[index + 1] if index + 1 < len(source) else ""
        if state == "code":
            if char == "/" and following == "*":
                state = "block"
                index += 2
                continue
            if char == "/" and following == "/":
                state = "line"
                index += 2
                continue
            if char == '"':
                state = "string"
            elif char == "'":
                state = "character"
            elif char == "{":
                depth += 1
            elif char == "}":
                depth -= 1
                if depth == 0:
                    return source[start : index + 1]
        elif state == "block" and char == "*" and following == "/":
            state = "code"
            index += 2
            continue
        elif state == "line" and char == "\n":
            state = "code"
        elif state in {"string", "character"}:
            if char == "\\":
                index += 2
                continue
            if (state == "string" and char == '"') or (state == "character" and char == "'"):
                state = "code"
        index += 1
    raise FixtureError(f"unterminated function: {name}")


def committed(root: Path, baseline: str, path: str) -> str:
    result = subprocess.run(
        ["git", "-C", str(root), "show", f"{baseline}:repo-pre-15/{path}"],
        check=False,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
    )
    if result.returncode != 0:
        raise FixtureError(f"cannot read B14 baseline {path}: {result.stderr}")
    return result.stdout


def audit(args: argparse.Namespace) -> None:
    load_spec(args.spec)
    root = args.root
    dut = args.dut
    inode = (dut / "src/inode.c").read_text(encoding="utf-8")
    data = (dut / "src/data.c").read_text(encoding="utf-8")
    vnops = (dut / "src/erofs_vnops.c").read_text(encoding="utf-8")
    linux_inode = (root / "src-linux/inode.c").read_text(encoding="utf-8")
    base_inode = committed(root, args.baseline, "src/inode.c")
    base_data = committed(root, args.baseline, "src/data.c")
    old_gate = "\t\tif (!erofs_sb_has_chunked_file(sbi) || vi->vtype != VREG) {"
    new_gate = "\t\tif (!erofs_sb_has_chunked_file(sbi)) {"
    if base_inode.count(old_gate) != 1:
        raise FixtureError("B14 baseline type gate changed")
    expected_inode = base_inode.replace(old_gate, new_gate, 1)
    if inode != expected_inode:
        raise FixtureError("inode.c differs from the single declared B14 transform")
    if data != base_data:
        raise FixtureError("data.c changed although the chunk mapper is already type-neutral")

    read_inode = extract_function(inode, "erofs_read_inode")
    chunk_mapper = extract_function(data, "erofs_map_blocks_chunk")
    map_device = extract_function(data, "erofs_map_dev")
    open_vnode = extract_function(vnops, "erofs_open")
    read_vnode = extract_function(vnops, "erofs_read")
    readdir_vnode = extract_function(vnops, "erofs_readdir")
    readlink_vnode = extract_function(vnops, "erofs_readlink")
    linux_read_inode = extract_function(linux_inode, "erofs_read_inode")

    required_inode = (
        "!erofs_sb_has_chunked_file(sbi)",
        "le16toh(chunk_info.reserved) != 0",
        "vi->chunkformat & ~EROFS_CHUNK_FORMAT_ALL",
        "EROFS_CHUNK_FORMAT_48BIT",
        "EROFS_CHUNK_FORMAT_INDEXES",
        "vi->chunkbits >= 64",
        "case VREG:",
        "case VDIR:",
        "case VLNK:",
    )
    for marker in required_inode:
        if marker not in read_inode:
            raise FixtureError(f"B14 removed inode invariant: {marker}")
    if "vi->vtype != VREG" in read_inode:
        raise FixtureError("nonregular chunk type gate remains")
    if read_inode.count("return (EINTEGRITY);") < 8 or "return (EOPNOTSUPP);" not in read_inode:
        raise FixtureError("FreeBSD inode errno contract changed")

    required_mapper = (
        "vi->chunkbits",
        "vi->chunkformat",
        "vi->inode_off",
        "vi->inode_isize",
        "vi->xattr_isize",
        "map->m_deviceid = raw_device_id & sbi->device_id_mask",
        "map->m_flags |= EROFS_MAP_MAPPED",
    )
    for marker in required_mapper:
        if marker not in chunk_mapper:
            raise FixtureError(f"chunk mapper contract changed: {marker}")
    if "vtype" in chunk_mapper or "VREG" in chunk_mapper or "VLNK" in chunk_mapper or "VDIR" in chunk_mapper:
        raise FixtureError("chunk mapper acquired an OS vnode type dependency")
    if re.search(r"return\s*\(\s*-E[A-Z0-9_]+", chunk_mapper + map_device):
        raise FixtureError("negative Linux errno entered the FreeBSD map path")
    for marker in ("ENODEV", "dif->devvp", "dif->cp", "erofs_check_device_range"):
        if marker not in map_device:
            raise FixtureError(f"FreeBSD device/GEOM boundary changed: {marker}")

    if "VN_ISDEV(vp)" not in open_vnode or "return (EOPNOTSUPP);" not in open_vnode:
        raise FixtureError("special vnode open safety changed")
    if "case VREG:" not in read_vnode or "case VDIR:" not in read_vnode:
        raise FixtureError("FreeBSD VOP_READ type boundary changed")
    if "v_type != VDIR" not in readdir_vnode or "v_type != VLNK" not in readlink_vnode:
        raise FixtureError("FreeBSD readdir/readlink type boundary changed")

    for marker in ("case S_IFDIR:", "case S_IFREG:", "case S_IFLNK:"):
        if marker not in linux_read_inode:
            raise FixtureError(f"Linux common data-layout type marker changed: {marker}")
    linux_chunk = linux_read_inode[linux_read_inode.index("if (vi->datalayout == EROFS_INODE_CHUNK_BASED)") :]
    if "vi->chunkformat" not in linux_chunk or "S_ISREG" in linux_chunk.split("inode_set_atime", 1)[0]:
        raise FixtureError("Linux chunk summary is no longer type-neutral")

    report = {
        "schema": 1,
        "batch": "B14",
        "candidate": "P15-056",
        "baseline": args.baseline,
        "source_transform_count": 1,
        "data_c_unchanged": True,
        "linux_common_types": ["S_IFDIR", "S_IFREG", "S_IFLNK"],
        "freebsd_vnode_types": ["VDIR", "VREG", "VLNK"],
        "freebsd_boundaries": [
            "positive errno",
            "explicit GEOM device slots",
            "special vnode open EOPNOTSUPP",
            "typed readdir/readlink VOPs",
        ],
    }
    args.report.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers(dest="command", required=True)
    generate_parser = subparsers.add_parser("generate")
    generate_parser.add_argument("--output", type=Path, required=True)
    generate_parser.add_argument("--spec", type=Path, required=True)
    generate_parser.add_argument("--mkfs", default="mkfs.erofs")
    verify_parser = subparsers.add_parser("verify")
    verify_parser.add_argument("--output", type=Path, required=True)
    verify_parser.add_argument("--spec", type=Path, required=True)
    verify_parser.add_argument("--fsck", default="fsck.erofs")
    audit_parser = subparsers.add_parser("audit")
    audit_parser.add_argument("--root", type=Path, required=True)
    audit_parser.add_argument("--dut", type=Path, required=True)
    audit_parser.add_argument("--baseline", required=True)
    audit_parser.add_argument("--spec", type=Path, required=True)
    audit_parser.add_argument("--report", type=Path, required=True)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.command == "generate":
        generate(args)
    elif args.command == "verify":
        verify(args)
    else:
        audit(args)


if __name__ == "__main__":
    main()
