from __future__ import annotations

import copy
import hashlib
import json
import math
import os
import stat
import subprocess
import sys
from collections.abc import Callable, Iterator
from pathlib import Path

import pytest

from ops import run_local_corpus_knowledge_rebuild as rebuild

REPO_ROOT = Path(__file__).resolve().parents[1]
CLI = REPO_ROOT / "ops" / "run_local_corpus_knowledge_rebuild.py"
FIXTURE_ROOT = REPO_ROOT / "tests" / "fixtures" / "teleo_corpus_rebuild"


def _sha256(value: bytes) -> str:
    return hashlib.sha256(value).hexdigest()


def _load_json(path: Path) -> dict:
    value = json.loads(path.read_text(encoding="utf-8"))
    assert isinstance(value, dict)
    return value


def _run_cli(source_root: Path, output: Path, *extra: str) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        [
            sys.executable,
            str(CLI),
            "--source-root",
            str(source_root),
            "--output",
            str(output),
            *extra,
        ],
        cwd=REPO_ROOT,
        check=False,
        capture_output=True,
        text=True,
    )


def _run_verify(bundle: Path) -> subprocess.CompletedProcess[str]:
    return subprocess.run(
        [sys.executable, str(CLI), "--verify-bundle", str(bundle)],
        cwd=REPO_ROOT,
        check=False,
        capture_output=True,
        text=True,
    )


def _tree_bytes(root: Path) -> dict[str, bytes]:
    return {path.relative_to(root).as_posix(): path.read_bytes() for path in sorted(root.rglob("*")) if path.is_file()}


def _all_keys(value: object) -> Iterator[str]:
    if isinstance(value, dict):
        for key, child in value.items():
            yield key
            yield from _all_keys(child)
    elif isinstance(value, list):
        for child in value:
            yield from _all_keys(child)


def _make_source(root: Path, name: str = "source.md", content: bytes = b"# Source\n\nExact text.\n") -> Path:
    root.mkdir(parents=True, exist_ok=True)
    source = root / name
    source.parent.mkdir(parents=True, exist_ok=True)
    source.write_bytes(content)
    return source


def test_cli_builds_deterministic_source_inventory_without_claims_or_proposals(tmp_path: Path) -> None:
    first_output = tmp_path / "bundle-a"
    second_output = tmp_path / "bundle-b"

    first = _run_cli(FIXTURE_ROOT, first_output, "--mode", "source-inventory-only")
    second = _run_cli(FIXTURE_ROOT, second_output)

    assert first.returncode == second.returncode == 0, (first.stderr, first.stdout, second.stderr, second.stdout)
    first_status = json.loads(first.stdout)
    second_status = json.loads(second.stdout)
    assert first_status["status"] == second_status["status"] == "pass"
    assert first_status["source_count"] == second_status["source_count"] == 2
    assert first_status["work_item_count"] == second_status["work_item_count"] == 3
    assert first_status["bundle_tree_sha256"] == second_status["bundle_tree_sha256"]
    assert _tree_bytes(first_output) == _tree_bytes(second_output)
    assert rebuild.INCOMPLETE_MARKER not in _tree_bytes(first_output)

    source_manifest = _load_json(first_output / rebuild.SOURCE_MANIFEST_PATH)
    work_index = _load_json(first_output / rebuild.EXTRACTION_WORK_INDEX_PATH)
    receipt = _load_json(first_output / rebuild.RECEIPT_PATH)
    assert source_manifest["schema"] == rebuild.SOURCE_MANIFEST_SCHEMA
    assert work_index["schema"] == rebuild.EXTRACTION_WORK_INDEX_SCHEMA
    assert receipt["schema"] == rebuild.RECEIPT_SCHEMA
    assert source_manifest["source_snapshot_semantics"] == {
        "concurrent_directory_additions_or_removals_may_be_absent": True,
        "directory_snapshot_atomic": False,
        "guarantee": "each listed path is bound to the exact bytes copied from one stable file descriptor",
    }
    assert [row["relative_path"] for row in source_manifest["sources"]] == [
        "forgejo/project-record.md",
        "operator-notes.txt",
    ]
    assert [row["section_count"] for row in source_manifest["sources"]] == [2, 1]
    assert work_index["work_type"] == rebuild.WORK_TYPE
    assert all(item["work_type"] == rebuild.WORK_TYPE for item in work_index["items"])

    forbidden_keys = {"candidate", "candidates", "claim", "claims", "proposal", "proposals", "proposition"}
    for document in (source_manifest, work_index, receipt):
        assert forbidden_keys.isdisjoint(_all_keys(document))
        for key, expected in rebuild.NO_WRITE_FLAGS.items():
            assert document[key] is expected

    sources_by_path = {row["relative_path"]: row for row in source_manifest["sources"]}
    for relative_path, source in sources_by_path.items():
        raw = (FIXTURE_ROOT / relative_path).read_bytes()
        snapshot = first_output / source["snapshot_path"]
        assert snapshot.read_bytes() == raw
        assert source["bytes"] == len(raw)
        assert source["artifact_sha256"] == _sha256(raw)
        assert source["format_syntax_validated"] is False
        for locator in source["sections"]:
            start = locator["utf8_byte_start"]
            end = locator["utf8_byte_end"]
            quote = raw[start:end]
            assert quote
            assert locator["quote_sha256"] == _sha256(quote)
            assert locator["line_start"] == raw[:start].decode("utf-8").count("\n") + 1
            assert locator["line_end"] == raw[: end - 1].decode("utf-8").count("\n") + 1

    artifacts = receipt["outputs"]["artifacts"]
    assert receipt["outputs"]["artifact_count"] == len(artifacts)
    assert receipt["hashes"]["bundle_tree_sha256"] == rebuild.canonical_sha256(artifacts)
    receipt_preimage = copy.deepcopy(receipt)
    receipt_preimage["hashes"].pop("receipt_content_sha256")
    assert receipt["hashes"]["receipt_content_sha256"] == rebuild.canonical_sha256(receipt_preimage)
    assert rebuild.verify_bundle(first_output)["status"] == "pass"

    persisted = b"".join(_tree_bytes(first_output).values())
    assert str(FIXTURE_ROOT).encode() not in persisted
    assert str(first_output).encode() not in persisted
    for path in first_output.rglob("*"):
        expected_mode = 0o600 if path.is_file() else 0o700
        assert stat.S_IMODE(path.stat().st_mode) == expected_mode


def test_receipt_is_published_last(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root)
    output = tmp_path / "bundle"
    original = rebuild._write_private_json
    writes: list[str] = []

    def record(path: Path, value: object) -> None:
        writes.append(path.name)
        if path.name == rebuild.RECEIPT_PATH:
            assert (output / rebuild.INCOMPLETE_MARKER).exists()
        original(path, value)

    monkeypatch.setattr(rebuild, "_write_private_json", record)
    rebuild.build_source_inventory_bundle(source_root, output)

    assert writes[-1] == rebuild.RECEIPT_PATH
    assert rebuild.INCOMPLETE_MARKER not in writes
    assert not (output / rebuild.INCOMPLETE_MARKER).exists()


def test_verifier_rejects_rehashed_metadata_overclaim(tmp_path: Path) -> None:
    output = tmp_path / "bundle"
    rebuild.build_source_inventory_bundle(FIXTURE_ROOT, output)
    receipt_path = output / rebuild.RECEIPT_PATH
    receipt = _load_json(receipt_path)
    receipt["canonical_database_rebuilt"] = True
    receipt["hashes"].pop("receipt_content_sha256")
    receipt["hashes"]["receipt_content_sha256"] = rebuild.canonical_sha256(receipt)
    receipt_path.write_text(json.dumps(receipt), encoding="utf-8")

    completed = _run_verify(output)

    assert completed.returncode == 2
    assert json.loads(completed.stdout)["reason"] == "invalid_bundle_schema"


METADATA_PATHS = (
    rebuild.SOURCE_MANIFEST_PATH,
    rebuild.EXTRACTION_WORK_INDEX_PATH,
    rebuild.RECEIPT_PATH,
)


def _inject_duplicate_top_level_key(raw: str, _metadata_path: str) -> str:
    return raw.replace("{\n", '{\n  "schema": "contradictory-shadow",\n', 1)


def _inject_duplicate_nested_key(raw: str, metadata_path: str) -> str:
    anchors = {
        rebuild.SOURCE_MANIFEST_PATH: (
            '"source_snapshot_semantics": {\n',
            '    "directory_snapshot_atomic": true,\n',
        ),
        rebuild.EXTRACTION_WORK_INDEX_PATH: (
            '"source_manifest": {\n',
            '    "path": "contradictory-shadow.json",\n',
        ),
        rebuild.RECEIPT_PATH: (
            '"hashes": {\n',
            f'    "bundle_tree_sha256": "{"0" * 64}",\n',
        ),
    }
    anchor, duplicate = anchors[metadata_path]
    assert anchor in raw
    return raw.replace(anchor, anchor + duplicate, 1)


def _inject_nonfinite(raw: str, constant: str) -> str:
    return raw.replace("{\n", f'{{\n  "nonfinite_shadow": {constant},\n', 1)


@pytest.mark.parametrize("metadata_path", METADATA_PATHS)
@pytest.mark.parametrize(
    ("mutator", "expected_reason"),
    (
        (_inject_duplicate_top_level_key, "duplicate_json_key"),
        (_inject_duplicate_nested_key, "duplicate_json_key"),
        (lambda raw, _path: _inject_nonfinite(raw, "NaN"), "invalid_json_value"),
        (lambda raw, _path: _inject_nonfinite(raw, "Infinity"), "invalid_json_value"),
        (lambda raw, _path: _inject_nonfinite(raw, "-Infinity"), "invalid_json_value"),
    ),
)
def test_verifier_rejects_ambiguous_or_nonfinite_metadata_before_verification(
    tmp_path: Path,
    metadata_path: str,
    mutator: Callable[[str, str], str],
    expected_reason: str,
) -> None:
    output = tmp_path / "bundle"
    rebuild.build_source_inventory_bundle(FIXTURE_ROOT, output)
    target = output / metadata_path
    target.write_text(mutator(target.read_text(encoding="utf-8"), metadata_path), encoding="utf-8")

    completed = _run_verify(output)

    assert completed.returncode == 2
    assert json.loads(completed.stdout)["reason"] == expected_reason


@pytest.mark.parametrize("value", (math.nan, math.inf, -math.inf))
def test_canonical_inventory_json_writers_reject_nonfinite_numbers(value: float) -> None:
    for writer in (rebuild.canonical_json_bytes, rebuild.rendered_json_bytes):
        with pytest.raises(rebuild.RebuildError) as error:
            writer({"value": value})
        assert error.value.code == "invalid_json_value"


def test_verify_mode_is_read_only(tmp_path: Path) -> None:
    output = tmp_path / "bundle"
    completed = _run_cli(FIXTURE_ROOT, output)
    assert completed.returncode == 0, completed.stdout
    before = {path: (path.stat().st_mtime_ns, path.read_bytes()) for path in output.rglob("*") if path.is_file()}

    verified = _run_verify(output)

    assert verified.returncode == 0, (verified.stdout, verified.stderr)
    assert json.loads(verified.stdout)["operation"] == "verify"
    after = {path: (path.stat().st_mtime_ns, path.read_bytes()) for path in output.rglob("*") if path.is_file()}
    assert after == before


def test_post_read_source_mutation_does_not_change_copied_snapshot_or_overclaim_atomicity(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    source_root = tmp_path / "sources"
    source = _make_source(source_root, content=b"# Original\n\nCopied bytes.\n")
    original = source.read_bytes()
    output = tmp_path / "bundle"

    def mutate(relative_path: str, _source_root: Path, _snapshot: Path) -> None:
        assert relative_path == "source.md"
        source.write_bytes(b"# Mutated\n\nLater bytes.\n")

    monkeypatch.setattr(rebuild, "_after_source_snapshot", mutate)
    result = rebuild.build_source_inventory_bundle(source_root, output)

    manifest = _load_json(output / rebuild.SOURCE_MANIFEST_PATH)
    row = manifest["sources"][0]
    assert (output / row["snapshot_path"]).read_bytes() == original
    assert source.read_bytes() != original
    assert row["artifact_sha256"] == _sha256(original)
    assert manifest["source_snapshot_semantics"]["directory_snapshot_atomic"] is False
    assert result["verification"]["status"] == "pass"


def test_destination_reservation_race_never_replaces_or_cleans_other_directory(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root)
    output = tmp_path / "bundle"
    original_mkdir = rebuild.os.mkdir
    raced = False

    def racing_mkdir(path: os.PathLike[str] | str, mode: int = 0o777, *, dir_fd: int | None = None) -> None:
        nonlocal raced
        if Path(path) == output and not raced and dir_fd is None:
            raced = True
            original_mkdir(output, mode)
            (output / "other-owner.txt").write_text("retain", encoding="utf-8")
        original_mkdir(path, mode, dir_fd=dir_fd)

    monkeypatch.setattr(rebuild.os, "mkdir", racing_mkdir)
    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(source_root, output)

    assert error.value.code == "output_exists"
    assert (output / "other-owner.txt").read_text(encoding="utf-8") == "retain"
    assert set(path.name for path in output.iterdir()) == {"other-owner.txt"}


def test_existing_destination_is_never_replaced(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root)
    output = tmp_path / "bundle"
    output.mkdir()
    marker = output / "retain.txt"
    marker.write_text("owned elsewhere", encoding="utf-8")

    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(source_root, output)

    assert error.value.code == "output_exists"
    assert marker.read_text(encoding="utf-8") == "owned elsewhere"


def test_output_identity_replacement_fails_before_writing_replacement(
    tmp_path: Path,
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root)
    output = tmp_path / "bundle"
    moved = tmp_path / "moved-reservation"

    def replace(reserved: rebuild.ReservedOutput) -> None:
        assert reserved.path == output
        output.rename(moved)
        output.mkdir()
        (output / "other-owner.txt").write_text("retain", encoding="utf-8")

    monkeypatch.setattr(rebuild, "_after_output_reservation", replace)
    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(source_root, output)

    assert error.value.code == "output_binding_changed"
    assert set(path.name for path in output.iterdir()) == {"other-owner.txt"}
    assert set(path.name for path in moved.iterdir()) == {rebuild.INCOMPLETE_MARKER}


def test_failure_after_reservation_removes_only_reserved_output_and_marker(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root, content=b"text\x00binary\n")
    output = tmp_path / "bundle"

    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(source_root, output)

    assert error.value.code == "binary_control_character"
    assert not output.exists()


def test_lf_and_crlf_locators_are_exact_and_cr_only_is_rejected(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    source_root.mkdir()
    lf = b"# One\nalpha\n# Two\nbeta\n"
    crlf = b"# One\r\nalpha\r\n# Two\r\nbeta\r\n"
    (source_root / "lf.md").write_bytes(lf)
    (source_root / "crlf.md").write_bytes(crlf)
    output = tmp_path / "bundle"

    rebuild.build_source_inventory_bundle(source_root, output)
    manifest = _load_json(output / rebuild.SOURCE_MANIFEST_PATH)
    by_path = {row["relative_path"]: row for row in manifest["sources"]}
    assert by_path["lf.md"]["newline_style"] == "lf"
    assert by_path["crlf.md"]["newline_style"] == "crlf"
    for name, raw in (("lf.md", lf), ("crlf.md", crlf)):
        assert [(row["line_start"], row["line_end"]) for row in by_path[name]["sections"]] == [(1, 2), (3, 4)]
        for locator in by_path[name]["sections"]:
            quote = raw[locator["utf8_byte_start"] : locator["utf8_byte_end"]]
            assert _sha256(quote) == locator["quote_sha256"]

    cr_root = tmp_path / "cr-only"
    _make_source(cr_root, content=b"# One\ralpha\r# Two\rbeta\r")
    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(cr_root, tmp_path / "cr-bundle")
    assert error.value.code == "unsupported_newline"
    assert not (tmp_path / "cr-bundle").exists()


@pytest.mark.parametrize("content", [b"nul\x00byte\n", b"escape\x1bbyte\n", b"delete\x7fbyte\n"])
def test_binary_control_characters_are_rejected(tmp_path: Path, content: bytes) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root, content=content)
    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(source_root, tmp_path / "bundle")
    assert error.value.code == "binary_control_character"
    assert not (tmp_path / "bundle").exists()


@pytest.mark.parametrize(
    ("content", "limits", "expected_code"),
    [
        (b"123456\n", rebuild.ResourceLimits(max_source_bytes=4), "source_byte_limit_exceeded"),
        (b"123456\n", rebuild.ResourceLimits(max_total_bytes=4, max_source_bytes=4), "source_byte_limit_exceeded"),
        (
            b"# One\na\n# Two\nb\n",
            rebuild.ResourceLimits(max_sections_per_source=1, max_work_items=1),
            "section_limit_exceeded",
        ),
    ],
)
def test_resource_limits_fail_closed(
    tmp_path: Path,
    content: bytes,
    limits: rebuild.ResourceLimits,
    expected_code: str,
) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root, content=content)
    with pytest.raises(rebuild.RebuildError) as error:
        rebuild.build_source_inventory_bundle(source_root, tmp_path / "bundle", limits=limits)
    assert error.value.code == expected_code
    assert not (tmp_path / "bundle").exists()


def test_total_source_count_and_work_item_limits_fail_closed(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root, "a.txt", b"a\n")
    _make_source(source_root, "b.txt", b"b\n")

    with pytest.raises(rebuild.RebuildError) as count_error:
        rebuild.build_source_inventory_bundle(
            source_root,
            tmp_path / "count-bundle",
            limits=rebuild.ResourceLimits(max_source_count=1),
        )
    assert count_error.value.code == "source_count_limit_exceeded"

    with pytest.raises(rebuild.RebuildError) as work_error:
        rebuild.build_source_inventory_bundle(
            source_root,
            tmp_path / "work-bundle",
            limits=rebuild.ResourceLimits(max_work_items=1, max_sections_per_source=1),
        )
    assert work_error.value.code == "work_item_limit_exceeded"

    with pytest.raises(rebuild.RebuildError) as total_error:
        rebuild.build_source_inventory_bundle(
            source_root,
            tmp_path / "total-bundle",
            limits=rebuild.ResourceLimits(max_source_bytes=2, max_total_bytes=3),
        )
    assert total_error.value.code == "total_byte_limit_exceeded"


def test_limit_overrides_remain_hard_bounded(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root)
    completed = _run_cli(
        source_root,
        tmp_path / "bundle",
        "--max-source-bytes",
        str(rebuild.HARD_MAX_SOURCE_BYTES + 1),
    )
    assert completed.returncode == 2
    assert json.loads(completed.stdout)["reason"] == "invalid_resource_limit"
    assert not (tmp_path / "bundle").exists()


def test_identical_source_bytes_share_one_content_addressed_snapshot(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    content = b"same exact bytes\n"
    _make_source(source_root, "a.txt", content)
    _make_source(source_root, "nested/b.txt", content)
    output = tmp_path / "bundle"

    rebuild.build_source_inventory_bundle(source_root, output)
    manifest = _load_json(output / rebuild.SOURCE_MANIFEST_PATH)
    assert manifest["source_count"] == 2
    assert manifest["unique_snapshot_count"] == 1
    assert manifest["duplicate_content_source_count"] == 1
    assert len({row["snapshot_path"] for row in manifest["sources"]}) == 1
    assert len(list((output / "snapshots" / "sha256").rglob("*.utf8"))) == 1
    assert _load_json(output / rebuild.EXTRACTION_WORK_INDEX_PATH)["work_item_count"] == 2


def test_source_rows_use_global_utf8_relative_path_order(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root, "a-z.txt", b"sibling\n")
    _make_source(source_root, "a/x.txt", b"nested\n")
    _make_source(source_root, "aa.txt", b"later\n")
    output = tmp_path / "bundle"

    rebuild.build_source_inventory_bundle(source_root, output)

    rows = _load_json(output / rebuild.SOURCE_MANIFEST_PATH)["sources"]
    assert [row["relative_path"] for row in rows] == ["a-z.txt", "a/x.txt", "aa.txt"]
    assert rebuild.verify_bundle(output)["status"] == "pass"


def test_mismatched_existing_content_addressed_snapshot_is_rejected(tmp_path: Path) -> None:
    output = tmp_path / "bundle"
    prefix = output / "snapshots" / "sha256"
    prefix.mkdir(parents=True)
    partial = output / "snapshots" / ".partial-00000001"
    content = b"expected\n"
    partial.write_bytes(content)
    digest = _sha256(content)
    destination = output / Path(rebuild._snapshot_path_for_hash(digest))
    destination.parent.mkdir()
    destination.write_bytes(b"mismatch\n")

    with pytest.raises(rebuild.RebuildError) as error:
        rebuild._install_snapshot(output, partial, digest, len(content))

    assert error.value.code == "snapshot_collision"
    assert destination.read_bytes() == b"mismatch\n"
    assert partial.read_bytes() == content


@pytest.mark.parametrize("target", ["snapshot", "manifest", "index", "receipt"])
def test_verifier_rejects_tampered_bundle(tmp_path: Path, target: str) -> None:
    output = tmp_path / "bundle"
    rebuild.build_source_inventory_bundle(FIXTURE_ROOT, output)
    manifest = _load_json(output / rebuild.SOURCE_MANIFEST_PATH)
    if target == "snapshot":
        path = output / manifest["sources"][0]["snapshot_path"]
        path.write_bytes(path.read_bytes() + b"tamper")
    elif target == "manifest":
        path = output / rebuild.SOURCE_MANIFEST_PATH
        document = _load_json(path)
        document["source_count"] += 1
        path.write_text(json.dumps(document), encoding="utf-8")
    elif target == "index":
        path = output / rebuild.EXTRACTION_WORK_INDEX_PATH
        document = _load_json(path)
        document["items"][0]["work_type"] = "semantic_claim"
        path.write_text(json.dumps(document), encoding="utf-8")
    else:
        path = output / rebuild.RECEIPT_PATH
        document = _load_json(path)
        document["database_write_performed"] = True
        path.write_text(json.dumps(document), encoding="utf-8")

    completed = _run_verify(output)

    assert completed.returncode == 2
    assert json.loads(completed.stdout)["status"] == "rejected"


def test_inventory_rejects_symlinks_unsupported_files_invalid_utf8_and_empty_text(tmp_path: Path) -> None:
    source_root = tmp_path / "symlinks"
    target = _make_source(source_root)
    link = source_root / "alias.md"
    try:
        link.symlink_to(target.name)
    except OSError as exc:  # pragma: no cover - platform capability
        pytest.skip(f"symlinks unavailable: {exc}")
    with pytest.raises(rebuild.RebuildError) as symlink_error:
        rebuild.build_source_inventory_bundle(source_root, tmp_path / "symlink-bundle")
    assert symlink_error.value.code == "symlink_rejected"

    cases = [
        ("unsupported", "unsafe.pdf", b"%PDF-1.7\n", "unsupported_source_file"),
        ("invalid", "invalid.md", b"invalid: \xff\n", "invalid_utf8_source"),
        ("empty", "empty.txt", b" \n\t\n", "empty_source_file"),
    ]
    for directory, filename, content, expected in cases:
        root = tmp_path / directory
        _make_source(root, filename, content)
        with pytest.raises(rebuild.RebuildError) as error:
            rebuild.build_source_inventory_bundle(root, tmp_path / f"{directory}-bundle")
        assert error.value.code == expected


def test_rejects_output_overlap_and_unsupported_mode(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root)
    with pytest.raises(rebuild.RebuildError) as overlap:
        rebuild.build_source_inventory_bundle(source_root, source_root / "bundle")
    assert overlap.value.code == "output_overlap"

    completed = _run_cli(source_root, tmp_path / "bundle", "--mode", "candidate-only")
    assert completed.returncode == 2
    assert json.loads(completed.stdout)["reason"] == "unsupported_mode"
    assert not (tmp_path / "bundle").exists()


def test_structured_extensions_are_inventoried_as_utf8_not_claimed_as_format_validated(tmp_path: Path) -> None:
    source_root = tmp_path / "sources"
    _make_source(source_root, "opaque.json", b"not actually json\n")
    output = tmp_path / "bundle"

    rebuild.build_source_inventory_bundle(source_root, output)

    row = _load_json(output / rebuild.SOURCE_MANIFEST_PATH)["sources"][0]
    assert row["sectioning_strategy"] == "whole_utf8_source"
    assert row["format_syntax_validated"] is False
    assert row["section_count"] == 1
