#!/bin/sh
set -eu

: "${PRE15_DUT:?PRE15_DUT is required}"
: "${PRE15_ROOT:?PRE15_ROOT is required}"
: "${PRE15_RUN_DIR:?PRE15_RUN_DIR is required}"
: "${PRE15_LIB_DIR:?PRE15_LIB_DIR is required}"
. "$PRE15_LIB_DIR/runner.sh"

baseline=5cb420221ab0a4da7838c68db55280999474038b
makefile=$PRE15_DUT/src/Makefile
linux_makefile=$PRE15_ROOT/src-linux/Makefile
case_file=$PRE15_DUT/tests/pre15/cases/B34-build-groups.sh
artifacts=$PRE15_RUN_DIR/artifacts

for tool in git python3 sha256sum; do
	command -v "$tool" >/dev/null 2>&1 || \
		pre15_infra_blocked "missing B34 host tool: $tool"
done

pre15_record_fixture b34-makefile "$makefile"
pre15_record_fixture b34-linux-makefile "$linux_makefile"
pre15_record_fixture b34-case "$case_file"

mkdir -p "$artifacts"
pre15_target_reached

if python3 -B - "$PRE15_ROOT" "$PRE15_DUT" "$baseline" \
    "$artifacts/B34-object-list.json" <<'PY'
from __future__ import annotations

from collections import Counter
from dataclasses import dataclass
from fnmatch import fnmatchcase
import json
from pathlib import Path
import re
import subprocess
import sys


root = Path(sys.argv[1])
dut = Path(sys.argv[2])
baseline = sys.argv[3]
report_path = Path(sys.argv[4])
makefile_path = dut / "src/Makefile"


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


def changed_paths() -> list[str]:
    changed = subprocess.run(
        [
            "git",
            "-C",
            str(root),
            "diff",
            "--name-only",
            baseline,
            "--",
            "repo-pre-15",
        ],
        check=True,
        text=True,
        stdout=subprocess.PIPE,
    ).stdout.splitlines()
    changed.extend(
        subprocess.run(
            [
                "git",
                "-C",
                str(root),
                "ls-files",
                "--others",
                "--exclude-standard",
                "--",
                "repo-pre-15",
            ],
            check=True,
            text=True,
            stdout=subprocess.PIPE,
        ).stdout.splitlines()
    )
    return changed


expected_changed = [
    "repo-pre-15/src/Makefile",
    "repo-pre-15/tests/pre15/cases/B34-build-groups.sh",
]
changed = changed_paths()
if sorted(changed) != expected_changed or len(changed) != len(expected_changed):
    raise SystemExit(f"B34 repo-pre-15 write set mismatch: {changed!r}")


@dataclass
class Evaluation:
    variables: dict[str, str]
    includes: list[str]
    src_operations: list[tuple[str, list[str]]]


class MakeError(Exception):
    pass


def logical_lines(source: str) -> list[tuple[int, str]]:
    result: list[tuple[int, str]] = []
    parts: list[str] = []
    start_line = 0
    for line_number, raw_line in enumerate(source.splitlines(), 1):
        content = raw_line.split("#", 1)[0].rstrip()
        if not parts and not content.strip():
            continue
        if not parts:
            start_line = line_number
        continued = content.endswith("\\")
        if continued:
            content = content[:-1].rstrip()
        parts.append(content.strip())
        if not continued:
            result.append((start_line, " ".join(part for part in parts if part)))
            parts = []
    if parts:
        raise SystemExit(f"unterminated Makefile continuation at line {start_line}")
    return result


def expand(value: str, variables: dict[str, str]) -> str:
    pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_.]*)\}")
    previous = None
    while value != previous:
        previous = value
        value = pattern.sub(lambda match: variables.get(match.group(1), ""), value)
    return value


def condition_value(expression: str, variables: dict[str, str], line: int) -> bool:
    terms = [term.strip() for term in expression.split("&&")]
    values: list[bool] = []
    for term in terms:
        empty_match = re.fullmatch(
            r"empty\(([A-Za-z_][A-Za-z0-9_.]*):M([^()]*)\)", term
        )
        if empty_match:
            variable, pattern = empty_match.groups()
            words = variables.get(variable, "").split()
            values.append(not any(fnmatchcase(word, pattern) for word in words))
            continue
        compare_match = re.fullmatch(
            r'\$\{([A-Za-z_][A-Za-z0-9_.]*)\}\s*(==|!=)\s*"?([^" ]+)"?',
            term,
        )
        if compare_match:
            variable, operator, expected = compare_match.groups()
            equal = variables.get(variable, "") == expected
            values.append(equal if operator == "==" else not equal)
            continue
        raise SystemExit(f"unsupported Makefile condition at line {line}: {term}")
    return all(values)


def evaluate(source: str, initial: dict[str, str]) -> Evaluation:
    variables = dict(initial)
    includes: list[str] = []
    src_operations: list[tuple[str, list[str]]] = []
    active_stack = [True]
    for line_number, line in logical_lines(source):
        if line.startswith(".if "):
            parent_active = active_stack[-1]
            active_stack.append(
                parent_active
                and condition_value(line.removeprefix(".if "), variables, line_number)
            )
            continue
        if line == ".endif":
            if len(active_stack) == 1:
                raise SystemExit(f"unmatched .endif at line {line_number}")
            active_stack.pop()
            continue
        if not active_stack[-1]:
            continue
        if line.startswith(".error "):
            raise MakeError(line.removeprefix(".error "))
        if line.startswith(".include "):
            includes.append(line.removeprefix(".include ").strip())
            continue
        if line.startswith("."):
            raise SystemExit(f"unsupported Makefile directive at line {line_number}: {line}")
        assignment = re.fullmatch(
            r"([A-Za-z_][A-Za-z0-9_.]*)\s*(\?=|\+=|=)\s*(.*)", line
        )
        if assignment is None:
            raise SystemExit(f"unsupported Makefile statement at line {line_number}: {line}")
        variable, operator, raw_value = assignment.groups()
        value = expand(raw_value, variables)
        if operator == "?=" and variable in variables:
            continue
        if operator == "+=":
            old_value = variables.get(variable, "")
            variables[variable] = " ".join(part for part in (old_value, value) if part)
        else:
            variables[variable] = value
        if variable == "SRCS":
            src_operations.append((operator, value.split()))
    if len(active_stack) != 1:
        raise SystemExit("unterminated Makefile condition")
    return Evaluation(variables, includes, src_operations)


def evaluate_error(source: str, initial: dict[str, str]) -> str:
    try:
        evaluate(source, initial)
    except MakeError as error:
        return str(error)
    raise SystemExit(f"Makefile unexpectedly accepted variables: {initial!r}")


def multiset_list(items: Counter[str]) -> list[str]:
    return sorted(items.elements())


def object_multiset(sources: list[str]) -> Counter[str]:
    objects: Counter[str] = Counter()
    for source in sources:
        if source.endswith((".c", ".cc", ".cpp", ".S", ".s")):
            objects[str(Path(source).with_suffix(".o"))] += 1
    return objects


before = committed("src/Makefile")
current = makefile_path.read_text(encoding="utf-8")
linux = (root / "src-linux/Makefile").read_text(encoding="utf-8")

expected_groups = [
    (
        "=",
        [
            "super.c",
            "inode.c",
            "data.c",
            "namei.c",
            "dir.c",
            "erofs_vnops.c",
            "vnode_if.h",
        ],
    ),
    ("+=", ["xattr.c"]),
    ("+=", ["decompressor.c", "zmap.c", "zdata.c"]),
    (
        "+=",
        [
            "decompressor_lz4.c",
            "decompressor_lzma.c",
            "decompressor_deflate.c",
            "decompressor_zstd.c",
        ],
    ),
]

scenario_reports: dict[str, object] = {}
for config in ("0", "1"):
    initial = {
        "MACHINE_ARCH": "amd64",
        "SYSDIR": "/freebsd/sys",
        "WITH_ZSTDIO": config,
    }
    before_eval = evaluate(before, initial)
    current_eval = evaluate(current, initial)
    before_sources = before_eval.variables.get("SRCS", "").split()
    current_sources = current_eval.variables.get("SRCS", "").split()
    before_source_multiset = Counter(before_sources)
    current_source_multiset = Counter(current_sources)
    before_objects = object_multiset(before_sources)
    current_objects = object_multiset(current_sources)
    before_metadata = Counter(source for source in before_sources if not source.endswith(".c"))
    current_metadata = Counter(source for source in current_sources if not source.endswith(".c"))

    if current_eval.src_operations != expected_groups:
        raise SystemExit(
            f"B34 semantic SRCS groups differ for zstdio{config}: "
            f"{current_eval.src_operations!r}"
        )
    if current_source_multiset != before_source_multiset:
        raise SystemExit(f"B34 SRCS multiset changed for zstdio{config}")
    if current_objects != before_objects:
        raise SystemExit(f"B34 object multiset changed for zstdio{config}")
    if current_metadata != before_metadata or current_metadata != Counter({"vnode_if.h": 1}):
        raise SystemExit(f"B34 vnode_if handling changed for zstdio{config}")
    if current_eval.includes != before_eval.includes or current_eval.includes != ["<bsd.kmod.mk>"]:
        raise SystemExit(f"B34 bsd.kmod.mk ownership changed for zstdio{config}")
    if (
        current_eval.variables.get("KMOD") != before_eval.variables.get("KMOD")
        or current_eval.variables.get("KMOD") != "erofs"
    ):
        raise SystemExit(f"B34 KMOD ownership changed for zstdio{config}")
    cflags_name = "CFLAGS.decompressor_zstd.c"
    if current_eval.variables.get(cflags_name) != before_eval.variables.get(cflags_name):
        raise SystemExit(f"B34 Zstd flags changed for zstdio{config}")
    expected_flags = "-I/freebsd/sys/contrib/zstd/lib/freebsd"
    if config == "1":
        expected_flags += " -DZSTDIO"
    if current_eval.variables.get(cflags_name) != expected_flags:
        raise SystemExit(f"B34 ZSTDIO expansion changed for zstdio{config}")

    scenario_reports[f"zstdio{config}"] = {
        "sources": multiset_list(current_source_multiset),
        "objects": multiset_list(current_objects),
        "metadata": multiset_list(current_metadata),
        "zstd_cflags": current_eval.variables[cflags_name].split(),
        "includes": current_eval.includes,
    }

default_initial = {"MACHINE_ARCH": "amd64", "SYSDIR": "/freebsd/sys"}
before_default = evaluate(before, default_initial)
current_default = evaluate(current, default_initial)
if before_default.variables.get("WITH_ZSTDIO") != "0":
    raise SystemExit("B34 baseline default is not WITH_ZSTDIO=0")
if current_default.variables.get("WITH_ZSTDIO") != "0":
    raise SystemExit("B34 changed the WITH_ZSTDIO default")
before_default_variables = dict(before_default.variables)
current_default_variables = dict(current_default.variables)
del before_default_variables["SRCS"]
del current_default_variables["SRCS"]
if current_default_variables != before_default_variables:
    raise SystemExit("B34 changed a default-config Makefile variable expansion")
if current_default.includes != before_default.includes:
    raise SystemExit("B34 changed a default-config Makefile include")

invalid_message = "WITH_ZSTDIO must be 0 or 1"
for source, label in ((before, "baseline"), (current, "current")):
    message = evaluate_error(
        source,
        {"MACHINE_ARCH": "amd64", "SYSDIR": "/freebsd/sys", "WITH_ZSTDIO": "2"},
    )
    if message != invalid_message:
        raise SystemExit(f"B34 {label} invalid-value contract drifted: {message!r}")

arch_message = "erofs supports only MACHINE_ARCH=amd64"
for config in ("0", "1"):
    initial = {
        "MACHINE_ARCH": "arm64",
        "SYSDIR": "/freebsd/sys",
        "WITH_ZSTDIO": config,
    }
    before_message = evaluate_error(before, initial)
    current_message = evaluate_error(current, initial)
    if before_message != arch_message or current_message != arch_message:
        raise SystemExit(f"B34 amd64 gate changed for zstdio{config}")

for forbidden in ("CONFIG_", "obj-", "erofs-objs", "stub"):
    if forbidden in current:
        raise SystemExit(f"B34 introduced forbidden Linux/config stub surface: {forbidden}")

linux_categories = {
    "core": "erofs-objs :=" in linux,
    "xattr": "CONFIG_EROFS_FS_XATTR" in linux,
    "compression": "CONFIG_EROFS_FS_ZIP" in linux,
    "algorithms": all(
        token in linux
        for token in (
            "CONFIG_EROFS_FS_ZIP_LZMA",
            "CONFIG_EROFS_FS_ZIP_DEFLATE",
            "CONFIG_EROFS_FS_ZIP_ZSTD",
        )
    ),
}
if not all(linux_categories.values()):
    raise SystemExit(f"B34 Linux semantic grouping reference drifted: {linux_categories!r}")

report = {
    "baseline": baseline,
    "write_set": changed,
    "groups": [
        {"operator": operator, "sources": sources}
        for operator, sources in expected_groups
    ],
    "configurations": scenario_reports,
    "default_with_zstdio": current_default.variables["WITH_ZSTDIO"],
    "invalid_with_zstdio_error": invalid_message,
    "non_amd64_error": arch_message,
    "linux_reference_categories": linux_categories,
}
report_path.write_text(
    json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii"
)

for config in ("zstdio0", "zstdio1"):
    details = scenario_reports[config]
    print(f"B34 {config} SRCS multiset: {' '.join(details['sources'])}")
    print(f"B34 {config} object multiset: {' '.join(details['objects'])}")
    print(f"B34 {config} Zstd flags: {' '.join(details['zstd_cflags'])}")
print("B34 default/invalid/amd64 gates: PASS")
print("B34 bsd.kmod.mk and vnode_if ownership: PASS")
print("B34 object-list equivalence: PASS")
PY
then
	:
else
	pre15_dut_fail 'B34 object-list or conditional expansion audit failed'
fi
