#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
"""Fixed, disposable filename fixtures. Transformations are NOT filesystem emulators."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import platform
import subprocess
import sys
import tempfile
import unicodedata as ud
from datetime import datetime, timezone

POLICIES = ("bytes", "utf8-strict", "nfc", "nfd", "casefold",
            "nfc-casefold", "nfkc-casefold", "utf8-replace")
FIXTURES = (
    ("canonical", ("caf\u00e9.txt".encode(), "cafe\u0301.txt".encode()), "nfc"),
    ("ascii-case", (b"Report.txt", b"report.txt"), "casefold"),
    ("case-expansion", ("Stra\u00dfe.txt".encode(), b"STRASSE.txt"), "casefold"),
    ("compatibility", ("\u2460.txt".encode(), b"1.txt"), "nfkc-casefold"),
    ("invalid-utf8", (b"\xff.txt", b"\xfe.txt"), "utf8-replace"),
    ("distinct-control", (b"alpha.txt", b"beta.txt"), "nfc-casefold"),
)

def digest(data):
    return hashlib.sha256(data).hexdigest()

def key(name, policy):
    if policy not in POLICIES:
        raise ValueError("unknown comparison policy")
    if not isinstance(name, bytes) or not name or name in (b".", b"..") or b"/" in name or b"\0" in name:
        raise ValueError("fixture must be a single nonempty POSIX filename component")
    if policy == "bytes":
        return name
    if policy == "utf8-replace":
        return name.decode("utf-8", "replace").encode("utf-8")
    text = name.decode("utf-8", "strict")
    if policy in ("nfc", "nfd"):
        text = ud.normalize(policy.upper(), text)
    elif policy == "casefold":
        text = text.casefold()
    elif policy == "nfc-casefold":
        text = ud.normalize("NFC", ud.normalize("NFC", text).casefold())
    elif policy == "nfkc-casefold":
        text = ud.normalize("NFKC", ud.normalize("NFKC", text).casefold())
    return text.encode("utf-8")

def inventory(directory):
    base = os.fsencode(directory)
    result = []
    for name in sorted(os.listdir(base)):
        with open(os.path.join(base, name), "rb") as stream:
            data = stream.read()
        result.append({"name_hex": name.hex(), "size": len(data), "sha256": digest(data)})
    return result

def mapping(names, policy):
    try:
        keys = [key(name, policy) for name in names]
    except UnicodeDecodeError:
        return {"status": "rejected-invalid-utf8", "keys_hex": [], "destinations": None}
    return {"status": "collision" if len(set(keys)) < len(keys) else "distinct",
            "keys_hex": [k.hex() for k in keys], "destinations": len(set(keys))}

def run_case(parent, fixture, policy, order, equal):
    ident, names, _ = fixture
    data = [b"same payload\n", b"same payload\n"] if equal else [b"payload A\n", b"payload B\n"]
    with tempfile.TemporaryDirectory(prefix="filename-case-", dir=parent) as root:
        source, dest, guarded = (os.path.join(root, n) for n in ("source", "dest", "guarded"))
        for directory in (source, dest, guarded):
            os.mkdir(directory)
        for name, payload in zip(names, data):
            with open(os.path.join(os.fsencode(source), name), "xb") as stream:
                stream.write(payload)
        before = inventory(source)
        if len(before) != 2:
            raise RuntimeError("source mount cannot represent the two fixture names")
        keys = [key(name, policy) for name in names]
        # Deliberately unsafe application-level rewrite, confined to fixed private fixtures.
        for i in order:
            with open(os.path.join(os.fsencode(source), names[i]), "rb") as stream:
                payload = stream.read()
            with open(os.path.join(os.fsencode(dest), keys[i]), "wb") as stream:
                stream.write(payload)
        after = inventory(dest)
        matches = []
        for i, target in enumerate(keys):
            with open(os.path.join(os.fsencode(dest), target), "rb") as stream:
                matches.append(stream.read() == data[i])
        collision = len(set(keys)) < len(keys)
        # Whole-plan rejection is before any output creation; not rollback or a general extractor.
        if not collision:
            for i in order:
                with open(os.path.join(os.fsencode(guarded), keys[i]), "xb") as stream:
                    stream.write(data[i])
        guard_inventory = inventory(guarded)
        if inventory(source) != before:
            raise RuntimeError("source fixture changed")
        return {"fixture": ident, "policy": policy, "order": list(order),
                "payload_mode": "equal" if equal else "distinct", "source": before,
                "output": after, "output_count": len(after),
                "source_checks_through_transformed_names": matches,
                "all_payload_checks_pass": all(matches),
                "exact_byte_name_and_content_inventory_preserved": before == after,
                "guard_refused": collision, "guard_output_count": len(guard_inventory),
                "guard_output": guard_inventory}

def run(directory):
    with tempfile.TemporaryDirectory(prefix="hesela-filenames-", dir=directory) as root:
        filesystem = subprocess.check_output(
            ["stat", "-f", "-c", "%T", root], text=True).strip()
        rows = [run_case(root, fixture, policy, order, equal)
                for fixture in FIXTURES
                for policy in ("bytes", fixture[2])
                for order in ((0, 1), (1, 0))
                for equal in (False, True)]
    matrix = [{"fixture": ident, "names_hex": [n.hex() for n in names],
               "policies": {p: mapping(names, p) for p in POLICIES}}
              for ident, names, _ in FIXTURES]
    return {"schema_version": 1, "generated_at": datetime.now(timezone.utc).isoformat(),
            "kind": "controlled-application-transform-observations",
            "environment": {"python": platform.python_version(),
                            "unicode_database": ud.unidata_version,
                            "kernel": platform.release(), "system": platform.system(),
                            "filesystem_type_from_stat": filesystem,
                            "filesystem_encoding": sys.getfilesystemencoding(),
                            "filesystem_errors": sys.getfilesystemencodeerrors()},
            "probe_sha256": digest(Path(__file__).read_bytes()),
            "license": "CC0-1.0",
            "scope": "Fixed synthetic names on one byte-preserving mount; no NTFS/APFS/ext4-casefold emulation, archive extraction, concurrent writer, metadata or durability test.",
            "policies": {"bytes": "Original bytes",
                         "utf8-strict": "Decode strict UTF-8 then encode UTF-8",
                         "nfc": "Strict UTF-8, NFC",
                         "nfd": "Strict UTF-8, NFD",
                         "casefold": "Strict UTF-8, Python str.casefold",
                         "nfc-casefold": "Strict UTF-8, NFC then casefold then NFC",
                         "nfkc-casefold": "Strict UTF-8, NFKC then casefold then NFKC; NOT Unicode NFKC_Casefold",
                         "utf8-replace": "UTF-8 decode errors=replace then UTF-8 encode"},
            "matrix": matrix, "runs": rows,
            "summary": {"case_executions": len(rows),
                        "collapsed_outputs": sum(r["output_count"] == 1 for r in rows),
                        "collapsed_with_all_payload_checks_passing": sum(
                            r["output_count"] == 1 and r["all_payload_checks_pass"] for r in rows),
                        "guard_refusals": sum(r["guard_refused"] for r in rows)}}

def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--directory", default=".")
    parser.add_argument("--output", required=True)
    args = parser.parse_args()
    # Refuse overwrite before running the experiment.
    with open(args.output, "x", encoding="utf-8") as stream:
        json.dump(run(args.directory), stream, indent=2, ensure_ascii=True)
        stream.write("\n")

if __name__ == "__main__":
    main()
