"""AC tests for AZ-280: Sha256Sidecar helper.

Verifies the `sha256_sidecar` contract v1.0.0 — atomic write, independent
verification, sidecar format strictness, aggregate determinism, and the
no-upward-imports invariant.
"""

from __future__ import annotations

import ast
import hashlib
from pathlib import Path

import pytest

from gps_denied_onboard.helpers import (
    SIDECAR_SUFFIX,
    Sha256Sidecar,
    Sha256SidecarError,
)


def _payload(seed: int, size: int = 1 << 20) -> bytes:
    """1 MiB random-ish payload derived from a seed for reproducibility."""
    import random

    rng = random.Random(seed)
    return bytes(rng.randint(0, 255) for _ in range(size))


def _sidecar(path: Path) -> Path:
    return Path(str(path) + SIDECAR_SUFFIX)


def test_ac1_roundtrip_write_and_verify(tmp_path: Path) -> None:
    # Arrange
    payload = _payload(seed=1)
    target = tmp_path / "artifact.bin"

    # Act
    written_digest = Sha256Sidecar.write_atomic_and_sidecar(target, payload)

    # Assert
    assert target.exists()
    assert _sidecar(target).exists()
    sidecar_text = _sidecar(target).read_text()
    expected_digest = hashlib.sha256(payload).hexdigest()
    assert sidecar_text == expected_digest
    assert written_digest == expected_digest
    assert Sha256Sidecar.verify(target) is True


def test_ac2_atomicity_no_partial_file_on_fault(tmp_path: Path, monkeypatch) -> None:
    # Arrange
    payload = b"hello"
    target = tmp_path / "atomic.bin"

    # Sabotage `os.replace` via the atomicwrites module so the rename step fails.
    import atomicwrites

    real_replace = atomicwrites.replace_atomic

    def _failing_replace(src: str, dst: str) -> None:
        raise OSError("simulated rename failure")

    monkeypatch.setattr(atomicwrites, "replace_atomic", _failing_replace)

    # Act + Assert — the write should raise; no half file should appear at the target.
    with pytest.raises(Sha256SidecarError):
        Sha256Sidecar.write_atomic(target, payload)
    assert not target.exists(), "no partial file may exist at the target name on fault"
    # Also confirm no orphaned `.tmp` siblings remain at the target name.
    siblings = [p for p in tmp_path.iterdir() if p.name.startswith("atomic.bin")]
    assert all(p == target for p in siblings if p.exists())

    # Cleanup
    monkeypatch.setattr(atomicwrites, "replace_atomic", real_replace)


def test_ac3_independent_verification_rejects_swapped_payload(tmp_path: Path) -> None:
    # Arrange
    payload = b"original payload"
    target = tmp_path / "swap.bin"
    Sha256Sidecar.write_atomic_and_sidecar(target, payload)

    # Act — replace the artifact bytes out-of-band without updating the sidecar.
    target.write_bytes(b"completely different bytes that are longer")

    # Assert
    assert Sha256Sidecar.verify(target) is False


def test_ac4_missing_sidecar_raises(tmp_path: Path) -> None:
    # Arrange
    payload = b"data"
    target = tmp_path / "missing_sidecar.bin"
    Sha256Sidecar.write_atomic_and_sidecar(target, payload)
    _sidecar(target).unlink()

    # Act + Assert
    with pytest.raises(Sha256SidecarError) as excinfo:
        Sha256Sidecar.verify(target)
    assert "sidecar" in str(excinfo.value).lower()


def test_ac5_malformed_sidecar_rejected(tmp_path: Path) -> None:
    # Arrange
    payload = b"data"
    target = tmp_path / "bad_sidecar.bin"
    Sha256Sidecar.write_atomic_and_sidecar(target, payload)
    _sidecar(target).write_text("not a hex digest at all")

    # Act + Assert
    with pytest.raises(Sha256SidecarError):
        Sha256Sidecar.verify(target)


def test_ac6_aggregate_hash_order_deterministic(tmp_path: Path) -> None:
    # Arrange
    paths = []
    for i in range(3):
        p = tmp_path / f"file_{i}.bin"
        Sha256Sidecar.write_atomic(p, f"payload-{i}".encode())
        paths.append(p)

    # Act
    a = Sha256Sidecar.aggregate_hash(paths)
    b = Sha256Sidecar.aggregate_hash(list(reversed(paths)))
    c = Sha256Sidecar.aggregate_hash([paths[1], paths[0], paths[2]])

    # Assert — same set, three permutations -> identical aggregate
    assert a == b == c


def test_ac7_aggregate_hash_missing_path_raises(tmp_path: Path) -> None:
    # Arrange
    real = tmp_path / "real.bin"
    Sha256Sidecar.write_atomic(real, b"data")
    ghost = tmp_path / "ghost.bin"  # never created

    # Act + Assert
    with pytest.raises(Sha256SidecarError) as excinfo:
        Sha256Sidecar.aggregate_hash([real, ghost])
    assert "ghost.bin" in str(excinfo.value)


def test_ac8_sidecar_format_strictness(tmp_path: Path) -> None:
    # Arrange
    payload = b"hello world"
    target = tmp_path / "strict.bin"
    Sha256Sidecar.write_atomic_and_sidecar(target, payload)

    # Act
    raw_bytes = _sidecar(target).read_bytes()
    text = _sidecar(target).read_text()

    # Assert — exactly 64 hex chars, no newline, no JSON wrapper, no whitespace.
    assert len(raw_bytes) == 64, f"sidecar must be 64 bytes; got {len(raw_bytes)}"
    assert not raw_bytes.endswith(b"\n"), "sidecar must NOT have a trailing newline"
    assert text == text.strip(), "sidecar must contain no whitespace"
    int(text, 16)  # raises if not pure hex
    assert text == hashlib.sha256(payload).hexdigest()


def test_ac9_no_upward_imports_to_components() -> None:
    # Arrange
    module_path = (
        Path(__file__).resolve().parents[2]
        / "src"
        / "gps_denied_onboard"
        / "helpers"
        / "sha256_sidecar.py"
    )
    tree = ast.parse(module_path.read_text())
    bad_imports: list[str] = []

    # Act
    for node in ast.walk(tree):
        if isinstance(node, ast.ImportFrom):
            module = node.module or ""
            if module.startswith("gps_denied_onboard.components"):
                bad_imports.append(module)
        elif isinstance(node, ast.Import):
            for alias in node.names:
                if alias.name.startswith("gps_denied_onboard.components"):
                    bad_imports.append(alias.name)

    # Assert
    assert not bad_imports, (
        f"helpers.sha256_sidecar must not import from components.*; found {bad_imports}"
    )


def test_verify_on_missing_path_returns_false(tmp_path: Path) -> None:
    """Contract: `verify` distinguishes 'missing path' (False) from 'missing sidecar' (raise)."""
    # Act + Assert
    assert Sha256Sidecar.verify(tmp_path / "never_created.bin") is False


def test_aggregate_includes_all_file_digests(tmp_path: Path) -> None:
    """The aggregate must be sensitive to file content, not only path names."""
    # Arrange
    a = tmp_path / "a.bin"
    b = tmp_path / "b.bin"
    Sha256Sidecar.write_atomic(a, b"alpha")
    Sha256Sidecar.write_atomic(b, b"beta")
    digest_initial = Sha256Sidecar.aggregate_hash([a, b])

    # Act — mutate b's content and recompute
    Sha256Sidecar.write_atomic(b, b"gamma")
    digest_after = Sha256Sidecar.aggregate_hash([a, b])

    # Assert
    assert digest_initial != digest_after
