#!/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"

artifacts=$PRE15_RUN_DIR/artifacts
action=${1:-callgraph}
shift || :

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

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

for source in data.c dir.c inode.c internal.h namei.c super.c zdata.c zmap.c; do
	pre15_record_fixture "b31r-$source" "$PRE15_DUT/src/$source"
done
pre15_record_fixture b31r-case \
	"$PRE15_DUT/tests/pre15/cases/B31r-visibility.sh"
mkdir -p "$artifacts"

if test "$action" = callgraph; then
	test "$#" -eq 0 || pre15_fail_usage 'B31r callgraph takes no arguments'
	pre15_target_reached
	if ! python3 -B - "$PRE15_ROOT" "$PRE15_DUT" \
	    "$artifacts/B31r-callgraph.json" <<'PY'
from __future__ import annotations

from collections import Counter
import hashlib
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)
    )
    expected = 1 if symbol in {"erofs_map_blocks", "erofs_dirent_namelen"} else 0
    if prototype_count != expected:
        raise SystemExit(
            f"{symbol} prototype count changed: {prototype_count}, expected {expected}"
        )

data = (src / "data.c").read_text(encoding="utf-8")
inode = (src / "inode.c").read_text(encoding="utf-8")
if not re.search(r"\bstatic\s+int\s+erofs_map_dev\s*\(", data):
    raise SystemExit("erofs_map_dev is not static in data.c")
if not re.search(r"\bstatic\s+erofs_off_t\s+erofs_iloc\s*\(", inode):
    raise SystemExit("erofs_iloc is not static in inode.c")

all_source = "\n".join(
    path.read_text(encoding="utf-8") for path in sorted(src.glob("*.[ch]"))
)
mtime_helper_invocations = all_source.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 != 0 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")
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}")

source_hashes = {}
for path in sorted(src.glob("*.[ch]")):
    source_hashes[path.relative_to(dut).as_posix()] = hashlib.sha256(
        path.read_bytes()
    ).hexdigest()

report = {
    "decision": "PASS",
    "freebsd_call_tokens": locations,
    "freebsd_cross_tu_consumers": cross_tu,
    "introduced_by": history,
    "linkage": {
        "erofs_map_dev": "static",
        "erofs_map_blocks": "external",
        "erofs_dirent_namelen": "external",
        "erofs_iloc": "static",
    },
    "internal_prototypes": {
        symbol: len(re.findall(r"\b" + re.escape(symbol) + r"\s*\(", internal))
        for symbol in symbols
    },
    "mtime_helper": {
        "generator_invocations": mtime_helper_invocations,
        "explicit_references": mtime_explicit_references,
    },
    "timestamp_anchors": timestamp_anchors,
    "source_hashes": source_hashes,
}
report_path.write_text(
    json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="ascii"
)
(report_path.parent / "B31r-source-sha256.txt").write_text(
    "".join(f"{digest}  {path}\n" for path, digest in source_hashes.items()),
    encoding="ascii",
)
PY
	then
		pre15_dut_fail 'B31r independent callgraph audit failed'
	fi
	printf '%s\n' \
		'B31r independent cross-TU callgraph PASS' \
		'erofs_map_dev and erofs_iloc are same-TU and static' \
		'erofs_map_blocks and erofs_dirent_namelen retain external linkage' \
		'erofs_sb_has_mtime helper is absent; timestamp anchors remain' \
		'source hashes and exact consumers recorded in B31r-callgraph.json'
	exit 0
	fi

test "$#" -eq 2 || \
	pre15_fail_usage 'B31r nm-allowlist requires zstdio0 and zstdio1 modules'
for module in "$@"; do
	pre15_record_fixture "b31r-module-$module" "$module"
done
pre15_target_reached

for config in 0 1; do
	case "$config" in
	0) module=$1 ;;
	1) module=$2 ;;
	esac
	nm -an "$module" >"$artifacts/B31r-zstdio$config-nm-an.txt"
	awk '$NF ~ /^(erofs_map_dev|erofs_map_blocks|erofs_dirent_namelen|erofs_iloc|erofs_sb_has_mtime)$/' \
	    "$artifacts/B31r-zstdio$config-nm-an.txt" \
		>"$artifacts/B31r-zstdio$config-target-bindings.txt"
	nm -g --defined-only "$module" | awk '{ print $NF }' | LC_ALL=C sort \
		>"$artifacts/B31r-zstdio$config-global-names.txt"
	nm -u "$module" | awk '{ print $NF }' | LC_ALL=C sort \
		>"$artifacts/B31r-zstdio$config-undefined-names.txt"
	for symbol in erofs_map_blocks erofs_dirent_namelen; do
		if ! awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \
		    "$artifacts/B31r-zstdio$config-global-names.txt"; then
			pre15_dut_fail "B31r zstdio$config lost required global $symbol"
		fi
	done
	for symbol in erofs_map_dev erofs_iloc erofs_sb_has_mtime; do
		if awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \
		    "$artifacts/B31r-zstdio$config-global-names.txt" || \
		    awk -v symbol="$symbol" '$0 == symbol { found = 1 } END { exit !found }' \
		    "$artifacts/B31r-zstdio$config-undefined-names.txt"; then
			pre15_dut_fail "B31r zstdio$config unexpectedly exposes $symbol"
		fi
	done
	for symbol in erofs_map_dev erofs_iloc; do
		if ! awk -v symbol="$symbol" \
		    '$NF == symbol && $(NF - 1) !~ /^[a-z]$/ { bad = 1 } END { exit bad }' \
		    "$artifacts/B31r-zstdio$config-nm-an.txt"; then
			pre15_dut_fail "B31r zstdio$config has non-local binding for $symbol"
		fi
	done
	if awk '$NF == "erofs_sb_has_mtime" { found = 1 } END { exit !found }' \
	    "$artifacts/B31r-zstdio$config-nm-an.txt"; then
		pre15_dut_fail "B31r zstdio$config emits erofs_sb_has_mtime"
	fi
done

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

printf '%s\n' \
	'B31r nm allowlist PASS' \
	'cross-TU symbols remain global in both configurations' \
	'same-TU symbols are absent or locally bound and never global/undefined' \
	'erofs_sb_has_mtime is absent from all symbol bindings' \
	'zstdio0/zstdio1 defined-global sets match'
