from __future__ import annotations

import json
import os
import pathlib
import stat
import sys
import tempfile
import threading
import unittest

ROOT = pathlib.Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

import run_unlinkability_protocol as r


def publish(root: pathlib.Path, digit: str) -> str:
    generation = "generation-" + digit * 64
    r._atomic_generation(
        root,
        generation,
        ("events-" + digit).encode(),
        ("record-" + digit).encode(),
        f"channel/generations/{generation}/raw-events.jsonl",
        f"channel/generations/{generation}/execution-record.json",
    )
    return generation


class ThirteenthGenerationAuditRegressions(unittest.TestCase):
    def test_thirteenth_m1_hostile_ancestor_symlink_alias_rejected(self):
        with tempfile.TemporaryDirectory() as text:
            base = pathlib.Path(text)
            real = base / "real"
            tree = real / "stdlib"
            tree.mkdir(parents=True)
            (tree / "module.py").write_text("value = 1\n")
            alias = base / "alias"
            alias.symlink_to(real, target_is_directory=True)
            with self.assertRaises((OSError, ValueError)):
                r._tree_digest(alias / "stdlib")
            with self.assertRaises((OSError, ValueError)):
                r._canonical_regular_file(
                    alias, "stdlib/module.py", "canonical implementation"
                )

    def test_thirteenth_m2_hostile_parent_swap_is_detected_and_rolled_back(self):
        with tempfile.TemporaryDirectory() as text:
            parent = pathlib.Path(text)
            channel = parent / "channel"
            channel.mkdir()
            moved = parent / "detached"
            original_mkdir = r.os.mkdir
            swapped = {"done": False}

            def swap_parent(path, mode=0o777, *, dir_fd=None):
                if path == "generations" and dir_fd is not None and not swapped["done"]:
                    swapped["done"] = True
                    os.rename(channel, moved)
                    original_mkdir(channel)
                return original_mkdir(path, mode, dir_fd=dir_fd)

            r.os.mkdir = swap_parent
            try:
                with self.assertRaisesRegex(ValueError, "detached|replaced"):
                    publish(channel, "a")
            finally:
                r.os.mkdir = original_mkdir
            self.assertFalse((moved / "CURRENT.json").exists())
            self.assertFalse((channel / "CURRENT.json").exists())

    def test_thirteenth_m3_hostile_owner_write_blocked_by_finalized_modes(self):
        with tempfile.TemporaryDirectory() as text:
            channel = pathlib.Path(text)
            generation = publish(channel, "b")
            generation_path = channel / "generations" / generation
            artifacts = [
                generation_path / "raw-events.jsonl",
                generation_path / "execution-record.json",
                channel / "CURRENT.json",
            ]
            self.assertEqual(stat.S_IMODE(generation_path.stat().st_mode), 0o500)
            self.assertEqual(generation_path.stat().st_nlink, 2)
            for artifact in artifacts:
                metadata = artifact.stat()
                self.assertEqual(stat.S_IMODE(metadata.st_mode), 0o400)
                self.assertEqual(metadata.st_nlink, 1)
            if os.geteuid() != 0:
                with self.assertRaises(PermissionError):
                    artifacts[0].write_bytes(b"owner mutation")

    def test_thirteenth_concurrent_publications_are_complete_and_serialized(self):
        with tempfile.TemporaryDirectory() as text:
            channel = pathlib.Path(text)
            barrier = threading.Barrier(8)
            failures: list[BaseException] = []

            def worker(index: int) -> None:
                try:
                    barrier.wait(timeout=5)
                    publish(channel, format(index + 1, "x"))
                except BaseException as exc:
                    failures.append(exc)

            threads = [threading.Thread(target=worker, args=(index,)) for index in range(8)]
            for thread in threads:
                thread.start()
            for thread in threads:
                thread.join(timeout=20)
            self.assertFalse(failures, failures)
            self.assertTrue(all(not thread.is_alive() for thread in threads))
            generations = list((channel / "generations").iterdir())
            self.assertEqual(len(generations), 8)
            for generation_path in generations:
                self.assertEqual(stat.S_IMODE(generation_path.stat().st_mode), 0o500)
                self.assertEqual(
                    {entry.name for entry in generation_path.iterdir()},
                    {"raw-events.jsonl", "execution-record.json"},
                )
            current = json.loads((channel / "CURRENT.json").read_text())
            selected = channel / "generations" / current["generationId"]
            self.assertTrue(selected.is_dir())
            self.assertEqual(
                (selected / "raw-events.jsonl").read_bytes(),
                ("events-" + current["generationId"][-1]).encode(),
            )

    def test_thirteenth_concurrent_current_pointer_is_never_mixed(self):
        with tempfile.TemporaryDirectory() as text:
            channel = pathlib.Path(text)
            stop = threading.Event()
            observations: list[tuple[str, str, str]] = []
            failures: list[BaseException] = []

            def reader() -> None:
                while not stop.is_set():
                    pointer = channel / "CURRENT.json"
                    try:
                        record = json.loads(pointer.read_text())
                        raw = (
                            channel
                            / "generations"
                            / record["generationId"]
                            / "raw-events.jsonl"
                        ).read_bytes()
                        observations.append(
                            (
                                record["generationId"],
                                record["rawEventsSha256"],
                                r.hashlib.sha256(raw).hexdigest(),
                            )
                        )
                    except FileNotFoundError:
                        pass
                    except BaseException as exc:
                        failures.append(exc)
                        return

            thread = threading.Thread(target=reader)
            thread.start()
            try:
                for digit in "12345678":
                    publish(channel, digit)
            finally:
                stop.set()
                thread.join(timeout=10)
            self.assertFalse(failures, failures)
            self.assertTrue(observations)
            self.assertTrue(all(expected == actual for _, expected, actual in observations))


if __name__ == "__main__":
    unittest.main()
