#!/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=643b83c9cea6a99398fa3dd053f2e8f65e108ca0
artifacts=$PRE15_RUN_DIR/artifacts
action=${1:-callgraph}
shift || :

case "$action" in
callgraph|nm-allowlist) ;;
*) pre15_fail_usage "unknown B31 action: $action" ;;
esac

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

pre15_record_fixture b31-data "$PRE15_DUT/src/data.c"
pre15_record_fixture b31-dir "$PRE15_DUT/src/dir.c"
pre15_record_fixture b31-inode "$PRE15_DUT/src/inode.c"
pre15_record_fixture b31-internal "$PRE15_DUT/src/internal.h"
pre15_record_fixture b31-case \
	"$PRE15_DUT/tests/pre15/cases/B31-visibility.sh"
mkdir -p "$artifacts"

if ! git -C "$PRE15_ROOT" diff --quiet "$baseline" -- \
    repo-pre-15/src/data.c repo-pre-15/src/dir.c \
    repo-pre-15/src/inode.c repo-pre-15/src/internal.h; then
	pre15_target_reached
	pre15_dut_fail 'B31 production files differ from the B30 source baseline'
fi

if test "$action" = callgraph; then
	test "$#" -eq 0 || pre15_fail_usage 'B31 callgraph takes no arguments'
	for source in zdata.c super.c namei.c erofs_fs.h; do
		pre15_record_fixture "b31-$source" "$PRE15_DUT/src/$source"
	done
	pre15_record_fixture b31-linux-data "$PRE15_ROOT/src-linux/data.c"
	pre15_record_fixture b31-linux-internal "$PRE15_ROOT/src-linux/internal.h"
	pre15_record_fixture b31-linux-fileio "$PRE15_ROOT/src-linux/fileio.c"
	pre15_record_fixture b31-linux-fscache "$PRE15_ROOT/src-linux/fscache.c"
	pre15_record_fixture b31-linux-inode "$PRE15_ROOT/src-linux/inode.c"
	pre15_record_fixture b31-linux-xattr "$PRE15_ROOT/src-linux/xattr.c"
	pre15_record_fixture b31-linux-zmap "$PRE15_ROOT/src-linux/zmap.c"
	pre15_record_fixture b31-linux-zdata "$PRE15_ROOT/src-linux/zdata.c"

	pre15_target_reached
	if ! python3 -B - "$PRE15_ROOT" "$PRE15_DUT" \
	    "$artifacts/B31-callgraph.json" <<'PY'
from __future__ import annotations

from collections import Counter
import json
from pathlib import Path
import re
import subprocess
import sys


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

symbols = {
    "erofs_map_dev": "data.c",
    "erofs_map_blocks": "data.c",
    "erofs_dirent_namelen": "dir.c",
    "erofs_iloc": "inode.c",
}
expected_occurrences = {
    "erofs_map_dev": {"data.c": 2},
    "erofs_map_blocks": {"data.c": 3, "super.c": 1, "zdata.c": 1},
    "erofs_dirent_namelen": {"dir.c": 3, "namei.c": 1},
    "erofs_iloc": {"inode.c": 3},
}
expected_cross_tu = {
    "erofs_map_dev": [],
    "erofs_map_blocks": ["super.c", "zdata.c"],
    "erofs_dirent_namelen": ["namei.c"],
    "erofs_iloc": [],
}
introduced_by = {
    ("erofs_map_blocks", "super.c"): "55c609db",
    ("erofs_map_blocks", "zdata.c"): "55c609db",
    ("erofs_dirent_namelen", "namei.c"): "c7508502",
}


def strip_noncode(source: str) -> str:
    output = list(source)
    state = "code"
    index = 0
    while index < len(source):
        char = source[index]
        following = source[index + 1] if index + 1 < len(source) else ""
        if state == "code":
            if char == "/" and following == "*":
                output[index] = output[index + 1] = " "
                state = "block"
                index += 2
                continue
            if char == "/" and following == "/":
                output[index] = output[index + 1] = " "
                state = "line"
                index += 2
                continue
            if char == '"':
                output[index] = " "
                state = "string"
            elif char == "'":
                output[index] = " "
                state = "character"
        elif state == "block":
            if char == "*" and following == "/":
                output[index] = output[index + 1] = " "
                state = "code"
                index += 2
                continue
            if char != "\n":
                output[index] = " "
        elif state == "line":
            if char == "\n":
                state = "code"
            else:
                output[index] = " "
        else:
            if char == "\\" and following:
                output[index] = output[index + 1] = " "
                index += 2
                continue
            if (state == "string" and char == '"') or (
                state == "character" and char == "'"
            ):
                state = "code"
            if char != "\n":
                output[index] = " "
        index += 1
    if state == "block":
        raise SystemExit("unterminated block comment")
    return "".join(output)


sources = {
    path.name: strip_noncode(path.read_text(encoding="utf-8"))
    for path in sorted(src.glob("*.c"))
}
locations: dict[str, list[dict[str, object]]] = {}
cross_tu: dict[str, list[str]] = {}
history: dict[str, str] = {}

for symbol, owner in symbols.items():
    pattern = re.compile(r"\b" + re.escape(symbol) + r"\s*\(")
    sites = []
    counts = Counter()
    for filename, source in sources.items():
        for match in pattern.finditer(source):
            line = source.count("\n", 0, match.start()) + 1
            sites.append({"file": filename, "line": line})
            counts[filename] += 1
    actual_counts = dict(sorted(counts.items()))
    if actual_counts != expected_occurrences[symbol]:
        raise SystemExit(
            f"{symbol} call-token inventory changed: {actual_counts!r}"
        )
    consumers = sorted(filename for filename in counts if filename != owner)
    if consumers != expected_cross_tu[symbol]:
        raise SystemExit(f"{symbol} cross-TU consumers changed: {consumers!r}")
    locations[symbol] = sites
    cross_tu[symbol] = consumers

    for filename in consumers:
        for site in sites:
            if site["file"] != filename:
                continue
            blamed = subprocess.run(
                [
                    "git",
                    "-C",
                    str(root),
                    "blame",
                    "--line-porcelain",
                    f"-L{site['line']},{site['line']}",
                    "--",
                    f"repo-pre-15/src/{filename}",
                ],
                check=True,
                text=True,
                stdout=subprocess.PIPE,
            ).stdout.splitlines()[0].split()[0]
            expected = introduced_by[(symbol, filename)]
            if not blamed.startswith(expected):
                raise SystemExit(
                    f"{symbol} {filename} introducer changed: {blamed}"
                )
            history[f"{symbol}:{filename}"] = blamed

internal = (src / "internal.h").read_text(encoding="utf-8")
for symbol in symbols:
    prototype_count = len(
        re.findall(r"\b" + re.escape(symbol) + r"\s*\(", internal)
    )
    if prototype_count != 1:
        raise SystemExit(f"{symbol} prototype count changed: {prototype_count}")

all_source = "\n".join(
    path.read_text(encoding="utf-8") for path in sorted(src.glob("*.[ch]"))
)
mtime_helper_invocations = internal.count(
    "EROFS_FEATURE_FUNCS(mtime, compat, COMPAT_MTIME)"
)
mtime_explicit_references = len(re.findall(r"\berofs_sb_has_mtime\b", all_source))
if mtime_helper_invocations != 1 or mtime_explicit_references != 0:
    raise SystemExit(
        "mtime helper inventory changed: "
        f"generator={mtime_helper_invocations} explicit={mtime_explicit_references}"
    )

erofs_fs = (src / "erofs_fs.h").read_text(encoding="utf-8")
inode = (src / "inode.c").read_text(encoding="utf-8")
super_source = (src / "super.c").read_text(encoding="utf-8")
timestamp_anchors = {
    "ondisk_constant": "#define EROFS_FEATURE_COMPAT_MTIME" in erofs_fs,
    "compact_checked_add": "__builtin_add_overflow(sbi->epoch," in inode,
    "extended_timestamp": "(int64_t)le64toh(die->i_mtime)" in inode,
    "epoch_decode": "sbi->epoch = (int64_t)le64toh(dsb->epoch);" in super_source,
    "fixed_nsec_decode": "sbi->fixed_nsec = le32toh(dsb->fixed_nsec);" in super_source,
}
if not all(timestamp_anchors.values()):
    raise SystemExit(f"timestamp path changed: {timestamp_anchors!r}")

linux_src = root / "src-linux"
linux_text = {
    path.name: strip_noncode(path.read_text(encoding="utf-8"))
    for path in (
        linux_src / "data.c",
        linux_src / "fileio.c",
        linux_src / "fscache.c",
        linux_src / "inode.c",
        linux_src / "xattr.c",
        linux_src / "zmap.c",
        linux_src / "zdata.c",
        linux_src / "internal.h",
    )
}
linux_internal = linux_text["internal.h"]
if not re.search(r"\bint\s+erofs_map_dev\s*\(", linux_internal):
    raise SystemExit("Linux erofs_map_dev external prototype is absent")
if not re.search(r"\bint\s+erofs_map_blocks\s*\(", linux_internal):
    raise SystemExit("Linux erofs_map_blocks external prototype is absent")
if not re.search(
    r"static\s+inline\s+erofs_off_t\s+erofs_iloc\s*\(", linux_internal
):
    raise SystemExit("Linux erofs_iloc is no longer static inline")
if re.search(r"\berofs_dirent_namelen\s*\(", "\n".join(linux_text.values())):
    raise SystemExit("unexpected Linux erofs_dirent_namelen symbol")

linux_consumers = {}
for symbol in ("erofs_map_dev", "erofs_map_blocks", "erofs_iloc"):
    pattern = re.compile(r"\b" + re.escape(symbol) + r"\s*\(")
    linux_consumers[symbol] = sorted(
        filename for filename, source in linux_text.items() if pattern.search(source)
    )

report = {
    "decision": "STOP-NO-SOURCE",
    "freebsd_call_tokens": locations,
    "freebsd_cross_tu_consumers": cross_tu,
    "introduced_by": history,
    "linux_linkage": {
        "erofs_map_dev": "external",
        "erofs_map_blocks": "external",
        "erofs_dirent_namelen": "absent",
        "erofs_iloc": "static inline",
    },
    "linux_consumer_files_in_probe": linux_consumers,
    "mtime_helper": {
        "generator_invocations": mtime_helper_invocations,
        "explicit_references": mtime_explicit_references,
    },
    "timestamp_anchors": timestamp_anchors,
}
report_path.write_text(
    json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii"
)
PY
	then
		pre15_dut_fail 'B31 cross-TU visibility audit failed'
	fi
	printf '%s\n' \
		'B31 callgraph audit reached mandatory STOP' \
		'erofs_map_blocks: cross-TU consumers super.c,zdata.c introduced by B07c' \
		'erofs_dirent_namelen: cross-TU consumer namei.c introduced by B10' \
		'erofs_map_dev and erofs_iloc remain same-TU only; atomic B31 source edit is blocked' \
		'mtime helper remains generated with zero explicit references; ondisk constant and timestamp paths remain'
	pre15_gate_stop \
		'B31 blocked: B07c/B10 introduced cross-TU consumers for listed symbols'
fi

test "$#" -eq 2 || \
	pre15_fail_usage 'B31 nm-allowlist requires zstdio0 and zstdio1 modules'
for tool in awk diff nm; do
	command -v "$tool" >/dev/null 2>&1 || \
		pre15_infra_blocked "missing B31 nm tool: $tool"
done

module0=$1
module1=$2
pre15_record_fixture b31-zstdio0-module "$module0"
pre15_record_fixture b31-zstdio1-module "$module1"
pre15_target_reached

for config in 0 1; do
	eval module=\$module$config
	nm -g --defined-only "$module" | awk '{ print $NF }' | LC_ALL=C sort \
		>"$artifacts/B31-zstdio$config-global-names.txt"
	nm -u "$module" | awk '{ print $NF }' | LC_ALL=C sort \
		>"$artifacts/B31-zstdio$config-undefined-names.txt"
	for symbol in erofs_map_dev erofs_map_blocks erofs_dirent_namelen erofs_iloc; do
		if ! awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \
		    "$artifacts/B31-zstdio$config-global-names.txt"; then
			pre15_dut_fail "B31 zstdio$config lost required global $symbol"
		fi
	done
	if awk '$0 == "erofs_sb_has_mtime" { found = 1 } END { exit !found }' \
	    "$artifacts/B31-zstdio$config-global-names.txt" || \
	    awk '$0 == "erofs_sb_has_mtime" { found = 1 } END { exit !found }' \
	    "$artifacts/B31-zstdio$config-undefined-names.txt"; then
		pre15_dut_fail "B31 zstdio$config unexpectedly emits erofs_sb_has_mtime"
	fi
done

if ! diff -u "$artifacts/B31-zstdio0-global-names.txt" \
    "$artifacts/B31-zstdio1-global-names.txt" \
    >"$artifacts/B31-config-global.diff"; then
	pre15_dut_fail 'B31 defined globals differ by ZSTDIO configuration'
fi

printf '%s\n' \
	'B31 nm allowlist PASS' \
	'global delta from B30 source baseline: zero' \
	'four listed symbols remain global in both configurations after mandatory STOP' \
	'erofs_sb_has_mtime is not emitted or undefined in either configuration'
