#!/usr/bin/env python3
"""Build and self-check fixtures for TC157-TC160."""

from __future__ import annotations

import argparse
import json
import os
from pathlib import Path
import shutil
import stat
import struct
import subprocess

from review_fixtures import (
    EROFS_INODE_COMPRESSED_FULL,
    ErofsImage,
    FixtureError,
    SUPER,
    align,
    normalize_times,
    run_mkfs,
    sha256,
)


FEATURE_INCOMPAT_48BIT = 0x80
EROFS_INODE_FLAT_PLAIN = 0
EROFS_I_DOT_OMITTED = 1 << 4
Z_EROFS_ADVISE_EXTENTS = 0x1
OFF_MAX = (1 << 63) - 1


def map_header_offset(inode) -> int:
    return align(inode.offset + inode.inode_size + inode.xattr_size, 8)


def record_size(advise: int) -> int:
    return 4 << ((advise >> 1) & 3)


def binary_search_indices(lstarts: list[int], logical: int) -> list[int]:
    visited: list[int] = []
    left = 0
    right = len(lstarts)
    while left < right:
        middle = left + (right - left) // 2
        visited.append(middle)
        if lstarts[middle] > logical:
            right = middle
        else:
            left = middle + 1
            if lstarts[middle] == logical:
                right = min(left + 1, right)
    return visited


def convert_extent_table(
    base: ErofsImage,
    inode,
    recsz: int,
    lstarts: list[int],
    payload: int,
) -> tuple[ErofsImage, int, int]:
    image = base.clone()
    header = map_header_offset(inode)
    table = align(header + 8, recsz)
    table_end = table + recsz * len(lstarts)
    if table_end > len(image.data):
        raise FixtureError("explicit extent table exceeds the base image")
    root = image.inode(image.root_nid)
    if not (
        table_end <= root.offset
        or root.offset + root.inode_size <= header
    ):
        raise FixtureError("explicit extent conversion overlaps the root inode")

    advise = Z_EROFS_ADVISE_EXTENTS | ({16: 2, 32: 3}[recsz] << 1)
    struct.pack_into("<IHH", image.data, header, len(lstarts), advise, 0)
    for index, lstart in enumerate(lstarts):
        offset = table + index * recsz
        image.data[offset : offset + recsz] = b"\0" * recsz
        struct.pack_into(
            "<IIII",
            image.data,
            offset,
            image.block_size,
            payload & 0xFFFFFFFF,
            payload >> 32,
            lstart & 0xFFFFFFFF,
        )
        if recsz == 32:
            struct.pack_into("<I", image.data, offset + 16, lstart >> 32)
    image.update_checksum()
    return image, header, payload


def verify_extent_fixture(
    image: ErofsImage,
    inode,
    recsz: int,
    expected: list[int],
    kind: str,
    logical_probe: int,
) -> tuple[int, list[int]]:
    header = map_header_offset(inode)
    count = image.u32(header) | (image.u16(header + 6) << 32)
    advise = image.u16(header + 4)
    if count != len(expected) or record_size(advise) != recsz:
        raise FixtureError("explicit extent header self-check failed")
    table = align(header + 8, recsz)
    observed: list[int] = []
    for index in range(count):
        offset = table + index * recsz
        value = image.u32(offset + 12)
        if recsz == 32:
            value |= image.u32(offset + 16) << 32
        observed.append(value)
    if observed != expected or any(value >= inode.size for value in observed):
        raise FixtureError("explicit extent lstart self-check failed")
    violations = [
        index
        for index in range(1, len(observed))
        if observed[index] <= observed[index - 1]
    ]
    if len(violations) != 1:
        raise FixtureError("fixture must contain exactly one ordering violation")
    violation = violations[0]
    if kind == "duplicate" and observed[violation] != observed[violation - 1]:
        raise FixtureError("duplicate fixture is not a duplicate")
    if kind != "duplicate" and observed[violation] >= observed[violation - 1]:
        raise FixtureError("descending fixture is not descending")
    visited = binary_search_indices(observed, logical_probe)
    if kind == "cross-branch" and violation - 1 in visited:
        raise FixtureError("cross-branch violation is visible to the old search")
    return table, visited


def make_extent_fixtures(output: Path, source: Path) -> tuple[list[str], dict]:
    target_source = source / "extent.bin"
    target_source.write_bytes(b"extent-ordering-payload\n" * 65536)
    normalize_times(source)
    base_path = output / ".extent-base.erofs"
    run_mkfs(
        source,
        base_path,
        "77777777-1111-4222-8333-000000000157",
        "-E",
        "legacy-compress,force-inode-extended",
        "-z",
        "lz4",
        "-C4096",
    )
    base = ErofsImage.load(base_path)
    inode = base.resolve_root_entry("extent.bin")
    if inode.inode_size != 64 or inode.layout != EROFS_INODE_COMPRESSED_FULL:
        raise FixtureError("extent target is not extended COMPRESSED_FULL")
    header = map_header_offset(inode)
    payload = base.u32(header + 20) << base.block_bits
    if payload == 0 or payload + base.block_size > header:
        raise FixtureError("original compressed payload is not isolated from metadata")

    cases = {
        "descending": ([0, 4096, 12288, 8192], 8192),
        "duplicate": ([0, 4096, 4096, 12288], 4096),
        "cross-branch": ([0, 8192, 4096, 12288], 4096),
    }
    evidence: list[str] = []
    manifest_cases: dict[str, object] = {}
    for recsz in (16, 32):
        for kind, (lstarts, logical_probe) in cases.items():
            image, header, payload = convert_extent_table(
                base, inode, recsz, lstarts, payload
            )
            table, visited = verify_extent_fixture(
                image, inode, recsz, lstarts, kind, logical_probe
            )
            name = f"extent-{recsz}-{kind}.erofs"
            path = output / name
            image.save(path)
            evidence.append(
                "extent-order "
                f"image={name} nid={inode.nid} recsize={recsz} "
                f"header={header} table={table} lstarts={lstarts} "
                f"probe={logical_probe} old_search_indices={visited} "
                f"payload_offset={payload} violation={kind}"
            )
            manifest_cases[name] = {
                "path": "/extent.bin",
                "nid": inode.nid,
                "record_size": recsz,
                "header_offset": header,
                "table_offset": table,
                "lstarts": lstarts,
                "logical_probe": logical_probe,
                "old_search_indices": visited,
                "payload_offset": payload,
                "payload_length": image.block_size,
                "expected_errno": 97,
            }
    base_path.unlink()
    return evidence, {"cases": manifest_cases}


def make_blocks_fixture(output: Path, source: Path) -> tuple[str, dict]:
    target_source = source / "compressed-blocks.bin"
    target_source.write_bytes(bytes(range(256)) * 4096)
    normalize_times(source)
    base_path = output / ".blocks-base.erofs"
    run_mkfs(
        source,
        base_path,
        "77777777-1111-4222-8333-000000000158",
        "-E",
        "legacy-compress,force-inode-extended",
        "-z",
        "lz4",
        "-C4096",
    )
    image = ErofsImage.load(base_path)
    inode = image.resolve_root_entry("compressed-blocks.bin")
    if inode.inode_size != 64 or inode.layout != EROFS_INODE_COMPRESSED_FULL:
        raise FixtureError("compressed block target has the wrong inode layout")
    root_nid = image.root_nid
    inode_blocks_lo = image.u32(inode.offset + 16)
    if inode_blocks_lo == 0:
        raise FixtureError("compressed target has zero blocks_lo")
    super_blocks_lo = image.u32(SUPER + 36)
    image.put_u64(SUPER + 112, root_nid)
    image.put_u32(
        SUPER + 80, image.u32(SUPER + 80) | FEATURE_INCOMPAT_48BIT
    )
    image.put_u16(SUPER + 14, 1)
    image.put_u16(inode.offset + 6, 1)
    image.update_checksum()
    path = output / "compressed-blocks-hi.erofs"
    image.save(path)
    base_path.unlink()

    data_blocks = (1 << 32) | inode_blocks_lo
    va_bytes = data_blocks << image.block_bits
    st_blocks = va_bytes // 512
    provider_blocks = (1 << 32) | super_blocks_lo
    provider_size = provider_blocks << image.block_bits
    if image.root_nid != root_nid or image.u16(inode.offset + 6) != 1:
        raise FixtureError("48-bit inode/superblock self-check failed")
    if provider_size <= 16 * 1024**4:
        raise FixtureError("sparse provider is not larger than 16 TiB")
    evidence = (
        "compressed-blocks-hi "
        f"nid={inode.nid} inode_offset={inode.offset} blocks_lo={inode_blocks_lo} "
        f"blocks_hi=1 data_blocks={data_blocks} block_size={image.block_size} "
        f"va_bytes={va_bytes} st_blocks={st_blocks} "
        f"provider_blocks={provider_blocks} provider_size={provider_size}"
    )
    manifest = {
        "image": path.name,
        "path": "/compressed-blocks.bin",
        "nid": inode.nid,
        "inode_offset": inode.offset,
        "blocks_lo": inode_blocks_lo,
        "blocks_hi": 1,
        "data_blocks": data_blocks,
        "block_size": image.block_size,
        "va_bytes": va_bytes,
        "st_blocks": st_blocks,
        "provider_size": provider_size,
        "seed_size": len(image.data),
    }
    return evidence, manifest


def make_special_fixture(output: Path, source: Path) -> tuple[str, dict]:
    fifo = source / "special.fifo"
    os.mkfifo(fifo, 0o640)
    normalize_times(source)
    path = output / "special-setattr.erofs"
    run_mkfs(
        source,
        path,
        "77777777-1111-4222-8333-000000000159",
        "-E",
        "force-inode-extended",
    )
    image = ErofsImage.load(path)
    inode = image.resolve_root_entry("special.fifo")
    mode = image.u16(inode.offset + 4)
    if not stat.S_ISFIFO(mode):
        raise FixtureError("special setattr target is not a FIFO")
    evidence = (
        f"special-setattr nid={inode.nid} inode_offset={inode.offset} "
        f"mode={mode:#o} type=fifo"
    )
    return evidence, {
        "image": path.name,
        "path": "/special.fifo",
        "nid": inode.nid,
        "mode": mode,
    }


def make_offmax_fixture(output: Path, source: Path) -> tuple[str, dict]:
    directory = source / "offmax-dir"
    directory.mkdir()
    for index in range(384):
        (directory / f"entry-{index:03d}").write_text(
            f"entry {index}\n", encoding="ascii"
        )
    normalize_times(source)
    base_path = output / ".offmax-base.erofs"
    run_mkfs(
        source,
        base_path,
        "77777777-1111-4222-8333-000000000160",
        "-E",
        "force-inode-extended",
    )
    image = ErofsImage.load(base_path)
    inode = image.resolve_root_entry("offmax-dir")
    mode = image.u16(inode.offset + 4)
    if inode.inode_size != 64 or not stat.S_ISDIR(mode) or inode.start_block == 0:
        raise FixtureError("OFF_MAX target is not an extended backed directory")
    inode_format = image.u16(inode.offset)
    inode_format &= ~0x0E
    inode_format |= EROFS_I_DOT_OMITTED
    image.put_u16(inode.offset, inode_format)
    image.put_u64(inode.offset + 8, OFF_MAX)
    image.update_checksum()
    path = output / "dot-omitted-offmax.erofs"
    image.save(path)
    base_path.unlink()
    mutated = image.resolve_root_entry("offmax-dir")
    if (
        mutated.inode_size != 64
        or mutated.layout != EROFS_INODE_FLAT_PLAIN
        or mutated.size != OFF_MAX
        or not image.u16(mutated.offset) & EROFS_I_DOT_OMITTED
    ):
        raise FixtureError("OFF_MAX directory field self-check failed")
    evidence = (
        f"dot-omitted-offmax nid={mutated.nid} inode_offset={mutated.offset} "
        f"format={image.u16(mutated.offset):#x} size={mutated.size} "
        f"start_block={mutated.start_block} expected_errno=97"
    )
    return evidence, {
        "image": path.name,
        "path": "/offmax-dir",
        "nid": mutated.nid,
        "inode_offset": mutated.offset,
        "size": mutated.size,
        "expected_errno": 97,
    }


def make_fixtures(output: Path) -> None:
    if shutil.which("mkfs.erofs") is None:
        raise FixtureError("mkfs.erofs is required")
    if output.exists() and any(output.iterdir()):
        raise FixtureError(f"output directory is not empty: {output}")
    output.mkdir(parents=True, exist_ok=True)
    sources = output / ".sources"
    extent_source = sources / "extent"
    blocks_source = sources / "blocks"
    special_source = sources / "special"
    offmax_source = sources / "offmax"
    for source in (extent_source, blocks_source, special_source, offmax_source):
        source.mkdir(parents=True)

    evidence, extent_manifest = make_extent_fixtures(output, extent_source)
    blocks_evidence, blocks_manifest = make_blocks_fixture(output, blocks_source)
    special_evidence, special_manifest = make_special_fixture(output, special_source)
    offmax_evidence, offmax_manifest = make_offmax_fixture(output, offmax_source)
    evidence.extend([blocks_evidence, special_evidence, offmax_evidence])
    manifest = {
        "extent_order": extent_manifest,
        "compressed_blocks_hi": blocks_manifest,
        "special_setattr": special_manifest,
        "dot_omitted_offmax": offmax_manifest,
    }
    shutil.rmtree(sources)
    (output / "fixture-evidence.txt").write_text(
        "\n".join(evidence) + "\n", encoding="ascii"
    )
    (output / "fixture-manifest.json").write_text(
        json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="ascii"
    )
    with (output / "SHA256SUMS").open("w", encoding="ascii") as sums:
        for path in sorted(output.glob("*.erofs")):
            sums.write(f"{sha256(path)}  {path.name}\n")
    print((output / "fixture-evidence.txt").read_text(encoding="ascii"), end="")
    print((output / "SHA256SUMS").read_text(encoding="ascii"), end="")


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    subparsers = parser.add_subparsers(dest="command", required=True)
    make_parser = subparsers.add_parser("make")
    make_parser.add_argument("--output", type=Path, required=True)
    return parser.parse_args()


def main() -> None:
    args = parse_args()
    if args.command == "make":
        make_fixtures(args.output)


if __name__ == "__main__":
    try:
        main()
    except (FixtureError, OSError, subprocess.CalledProcessError) as error:
        raise SystemExit(f"final_review_fixtures.py: {error}") from error
