#!/usr/bin/env python3
from __future__ import annotations

import hashlib
import json
import os
from pathlib import Path
from typing import TypedDict

import pytest
import verify_retained_archives as verifier


class BindingRow(TypedDict):
    path: str
    sha256: str


class ArchiveRow(TypedDict):
    archive_id: str
    schema: str
    manifest_sha256: str
    receipt_sha256: str
    tasks: list[int]
    binding_files: list[BindingRow]


class FixtureInventory(TypedDict):
    schema: str
    archive_parent: str
    candidate: None
    archives: list[ArchiveRow]


MODULE_PATH = Path(__file__).with_name("verify_retained_archives.py")


def canonical(value: object) -> bytes:
    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()


def tree_digest(path: Path) -> tuple[str, int, int]:
    rows: list[bytes] = []
    files = directories = 0

    def visit(candidate: Path, relative: str) -> None:
        nonlocal files, directories
        if candidate.is_dir():
            directories += 1
            rows.append(b"d\0" + relative.encode())
            for child in sorted(candidate.iterdir(), key=lambda item: item.name):
                visit(child, child.name if relative == "." else f"{relative}/{child.name}")
        else:
            files += 1
            rows.append(
                b"f\0" + relative.encode() + b"\0" + hashlib.sha256(candidate.read_bytes()).hexdigest().encode()
            )

    visit(path, ".")
    return hashlib.sha256(b"\n".join(rows)).hexdigest(), files, directories


def fixture_archive(tmp_path: Path, schema: str) -> Path:
    tmp_path.mkdir(mode=0o700, parents=True, exist_ok=True)
    archive = tmp_path / ("a" * 32)
    archive.mkdir(mode=0o700)
    (archive / "payload").mkdir(mode=0o700)
    contract = verifier.SCHEMAS[schema]
    scopes: list[dict[str, object]] = []
    for index, scope in enumerate(contract):
        target = archive / "payload" / scope.relative_path
        if index == 0:
            target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
            if scope.kind == "file":
                _ = target.write_bytes(b"fixture")
                target.chmod(0o600)
            else:
                target.mkdir(mode=0o700)
                leaf = target / "leaf"
                _ = leaf.write_bytes(b"fixture")
                leaf.chmod(0o600)
            digest, files, directories = tree_digest(target)
            scopes.append({"name": scope.name, "present": True, "digest": digest, "files": files, "directories": directories})
        else:
            scopes.append({"name": scope.name, "present": False, "digest": None, "files": 0, "directories": 0})
    manifest = {"schema": schema, "archive_id": archive.name, "scope_count": len(scopes), "scopes": scopes}
    receipt = {"schema": schema, "archive_id": archive.name, "archive_digest": hashlib.sha256(canonical(manifest)).hexdigest(), "archived_scope_count": len(scopes)}
    for name, value in (("manifest.json", manifest), ("receipt.json", receipt)):
        path = archive / name
        _ = path.write_bytes(canonical(value) + b"\n")
        path.chmod(0o600)
    return archive


@pytest.mark.parametrize("schema", sorted(verifier.SCHEMAS))
def test_each_retained_schema_dispatches_and_verifies(tmp_path: Path, schema: str) -> None:
    archive = fixture_archive(tmp_path, schema)
    result = verifier.verify_archive_path(archive)
    assert result["schema"] == schema
    assert result["status"] == "PASS"


def test_duplicate_key_and_unknown_schema_fail_closed(tmp_path: Path) -> None:
    archive = fixture_archive(tmp_path, "dualcoach-rehearsal-archive-v1")
    manifest = (archive / "manifest.json")
    _ = manifest.write_bytes(manifest.read_bytes().replace(b'"schema":', b'"schema":"duplicate","schema":', 1))
    with pytest.raises(verifier.VerificationError, match="duplicate JSON key"):
        _ = verifier.verify_archive_path(archive)

    archive = fixture_archive(tmp_path / "unknown", "dualcoach-rehearsal-archive-v1")
    manifest = archive / "manifest.json"
    _ = manifest.write_bytes(manifest.read_bytes().replace(b"dualcoach-rehearsal-archive-v1", b"dualcoach-rehearsal-archive-v9"))
    with pytest.raises(verifier.VerificationError, match="unknown schema"):
        _ = verifier.verify_archive_path(archive)


@pytest.mark.parametrize("damage", ["payload", "count", "mode", "hardlink", "symlink", "unexpected"])
def test_archive_drift_and_unsafe_members_fail_closed(tmp_path: Path, damage: str) -> None:
    archive = fixture_archive(tmp_path, "dualcoach-rehearsal-archive-v1")
    target = archive / "payload/customers/registry.json"
    if damage == "payload":
        _ = target.write_bytes(b"drift")
    elif damage == "count":
        manifest = archive / "manifest.json"
        _ = manifest.write_bytes(manifest.read_bytes().replace(b'"scope_count":12', b'"scope_count":11'))
    elif damage == "mode":
        target.chmod(0o640)
    elif damage == "hardlink":
        alias = archive / "payload/alias"
        os.link(target, alias)
    elif damage == "symlink":
        target.unlink()
        target.symlink_to("/etc/passwd")
    else:
        extra = archive / "extra"
        _ = extra.write_bytes(b"x")
        extra.chmod(0o600)
    with pytest.raises(verifier.VerificationError):
        _ = verifier.verify_archive_path(archive)


def test_inventory_parent_binding_chain_rejects_drift(tmp_path: Path) -> None:
    archive = fixture_archive(tmp_path / "root", "dualcoach-rehearsal-archive-v1")
    manifest_raw = (archive / "manifest.json").read_bytes()
    receipt_raw = (archive / "receipt.json").read_bytes()
    binding = tmp_path / "binding.txt"
    _ = binding.write_text(f"archive {archive.name}\n", encoding="utf-8")
    binding.chmod(0o600)
    archive_row: ArchiveRow = {
        "archive_id": archive.name,
        "schema": "dualcoach-rehearsal-archive-v1",
        "manifest_sha256": hashlib.sha256(manifest_raw).hexdigest(),
        "receipt_sha256": hashlib.sha256(receipt_raw).hexdigest(),
        "tasks": [21],
        "binding_files": [{"path": str(binding), "sha256": hashlib.sha256(binding.read_bytes()).hexdigest()}],
    }
    inventory: FixtureInventory = {
        "schema": "task26-retained-archive-inventory-v1",
        "archive_parent": str(archive.parent),
        "candidate": None,
        "archives": [archive_row],
    }
    path = tmp_path / "inventory.json"
    _ = path.write_bytes(canonical(inventory) + b"\n")
    path.chmod(0o600)
    result = verifier.verify_inventory(path, verify_candidate=False)
    assert result["archive_count"] == 1
    archive_row["manifest_sha256"] = "0" * 64
    _ = path.write_bytes(canonical(inventory) + b"\n")
    with pytest.raises(verifier.VerificationError, match="raw manifest hash"):
        _ = verifier.verify_inventory(path, verify_candidate=False)


def test_sealed_file_under_private_sealed_parent_is_readable(tmp_path: Path) -> None:
    parent = tmp_path / "sealed"
    parent.mkdir(mode=0o700)
    leaf = parent / "control.json"
    _ = leaf.write_bytes(b"{}")
    leaf.chmod(0o400)
    parent.chmod(0o500)
    assert verifier.read_path(leaf, "fixture", sealed=True) == b"{}"


def test_source_has_no_restore_extract_or_network_surface() -> None:
    source = MODULE_PATH.read_text(encoding="utf-8")
    for forbidden in ("import shutil", ".extract", ".restore", "import socket", "import requests", "import httpx", "import urllib"):
        assert forbidden not in source
    assert "O_NOFOLLOW" in source
    assert "os.fstat" in source
