"""PRD governance tests — determinism, config invariants, and manifest guarantees.

Strengthens confidence that the DPE pipeline produces deterministic,
auditable runs regardless of execution environment.
"""

from __future__ import annotations

import json
import re
from pathlib import Path

import pytest
from pydantic import ValidationError

from dpe.engine.orchestrator import Orchestrator
from dpe.engine.states import (
    OPTIONAL_STAGES,
    STAGE_ARTIFACT_MAP,
    STAGE_ORDER,
    Stage,
)
from dpe.models.config import BudgetLimits, DPEConfig

_IDEA = "Governance test: build a compliance dashboard"
_HEX64 = re.compile(r"^[0-9a-f]{64}$")


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _sim_config(output_dir: str, **overrides: object) -> DPEConfig:
    """Build a simulation-mode config pointing at *output_dir*."""
    return DPEConfig(simulation_mode=True, output_dir=output_dir, **overrides)


def _run_simulation(tmp_path: Path, **cfg_overrides: object) -> tuple[Path, object]:
    """Run a full simulation and return (run_dir, manifest)."""
    cfg = _sim_config(str(tmp_path), **cfg_overrides)
    orch = Orchestrator(config=cfg)
    manifest = orch.run(_IDEA)
    run_dir = orch._registry.run_dir
    return run_dir, manifest


# ===================================================================
# 1. TestConfigInvariants — DPEConfig governance
# ===================================================================


class TestConfigInvariants:
    """DPEConfig must enforce strict Pydantic-v2 validation rules."""

    def test_default_simulation_mode_false(self) -> None:
        """Default config has simulation_mode=False (conservative default)."""
        cfg = DPEConfig()
        assert cfg.simulation_mode is False

    def test_config_hash_is_64_char_hex(self) -> None:
        """config_hash() returns a 64-character lowercase hex SHA-256."""
        cfg = DPEConfig()
        h = cfg.config_hash()
        assert len(h) == 64
        assert _HEX64.match(h), f"Not valid hex: {h}"

    def test_config_hash_deterministic(self) -> None:
        """Same config object must produce the same hash on every call."""
        cfg = DPEConfig(simulation_mode=True)
        assert cfg.config_hash() == cfg.config_hash()

    def test_same_config_same_hash(self) -> None:
        """Two independently-constructed identical configs share a hash."""
        a = DPEConfig(simulation_mode=True, output_dir="runs")
        b = DPEConfig(simulation_mode=True, output_dir="runs")
        assert a.config_hash() == b.config_hash()

    def test_different_output_dir_different_hash_normal_mode(self) -> None:
        """In normal mode, different output_dir => different hash."""
        a = DPEConfig(output_dir="alpha")
        b = DPEConfig(output_dir="beta")
        assert a.config_hash() != b.config_hash()

    def test_smoke_mode_normalizes_output_dir(self) -> None:
        """In smoke mode, output_dir is normalised so hash is stable."""
        a = DPEConfig(smoke_mode=True, output_dir="alpha")
        b = DPEConfig(smoke_mode=True, output_dir="beta")
        assert a.config_hash() == b.config_hash()

    def test_budget_rejects_zero_total_calls(self) -> None:
        """max_total_calls=0 violates ge=1 and must raise ValidationError."""
        with pytest.raises(ValidationError):
            BudgetLimits(max_total_calls=0)

    def test_budget_rejects_negative_wall_time(self) -> None:
        """max_wall_time_seconds=-1 violates ge=1 and must raise."""
        with pytest.raises(ValidationError):
            BudgetLimits(max_wall_time_seconds=-1)

    def test_budget_rejects_critique_rounds_above_cap(self) -> None:
        """max_critique_rounds=3 violates le=2 and must raise."""
        with pytest.raises(ValidationError):
            BudgetLimits(max_critique_rounds=3)


# ===================================================================
# 2. TestStageOrderingInvariant — FSM stage ordering
# ===================================================================


class TestStageOrderingInvariant:
    """The FSM stage graph must be exactly 12 stages in a fixed linear order."""

    def test_stage_order_has_12_entries(self) -> None:
        assert len(STAGE_ORDER) == 12

    def test_all_enum_members_in_stage_order(self) -> None:
        """Every Stage enum member must appear in STAGE_ORDER."""
        assert set(Stage) == set(STAGE_ORDER)

    def test_optional_stages_subset_of_stage_order(self) -> None:
        assert set(STAGE_ORDER) >= OPTIONAL_STAGES

    def test_stage_artifact_map_covers_all_stages(self) -> None:
        """Every stage in STAGE_ORDER must have an artifact filename."""
        for stage in STAGE_ORDER:
            assert stage in STAGE_ARTIFACT_MAP, f"No artifact mapping for {stage}"

    def test_critique_before_revision(self) -> None:
        """Critique stages must appear before their corresponding revision stages."""
        order = list(STAGE_ORDER)
        assert order.index(Stage.PRD_CRITIQUE_R1) < order.index(Stage.PRD_REVISION_R1)
        assert order.index(Stage.PRD_CRITIQUE_R2) < order.index(Stage.PRD_REVISION_R2)

    def test_finalize_is_last(self) -> None:
        assert STAGE_ORDER[-1] == Stage.FINALIZE


# ===================================================================
# 3. TestManifestAlwaysWritten — manifest robustness
# ===================================================================


class TestManifestAlwaysWritten:
    """run_manifest.json must be written regardless of run outcome."""

    def test_manifest_exists_after_full_run(self, tmp_path: Path) -> None:
        run_dir, _ = _run_simulation(tmp_path)
        assert (run_dir / "run_manifest.json").exists()

    def test_manifest_exists_after_budget_stop(self, tmp_path: Path) -> None:
        """Even when budget forces early stop, manifest must be written."""
        run_dir, manifest = _run_simulation(tmp_path, budget={"max_total_calls": 2})
        assert (run_dir / "run_manifest.json").exists()
        assert manifest.stop_reason == "budget_exceeded"

    def test_manifest_is_valid_json(self, tmp_path: Path) -> None:
        run_dir, _ = _run_simulation(tmp_path)
        raw = (run_dir / "run_manifest.json").read_text(encoding="utf-8")
        data = json.loads(raw)  # must not raise
        assert isinstance(data, dict)

    def test_manifest_has_required_fields(self, tmp_path: Path) -> None:
        run_dir, _ = _run_simulation(tmp_path)
        raw = (run_dir / "run_manifest.json").read_text(encoding="utf-8")
        data = json.loads(raw)
        for field in ("run_id", "config_hash", "stop_reason", "stages", "artifacts"):
            assert field in data, f"Missing required field: {field}"


# ===================================================================
# 4. TestEvidenceExpectations — post-run evidence integrity
# ===================================================================


class TestEvidenceExpectations:
    """After a successful simulation run, evidence files must be complete."""

    def test_artifact_index_exists_and_valid(self, tmp_path: Path) -> None:
        run_dir, _ = _run_simulation(tmp_path)
        index_path = run_dir / "artifact_index.json"
        assert index_path.exists()
        data = json.loads(index_path.read_text(encoding="utf-8"))
        assert isinstance(data, list)
        assert len(data) > 0

    def test_all_manifest_artifacts_exist_on_disk(self, tmp_path: Path) -> None:
        run_dir, manifest = _run_simulation(tmp_path)
        for artifact in manifest.artifacts:
            path = run_dir / artifact.filename
            assert path.exists(), f"Artifact missing: {artifact.filename}"

    def test_artifact_sha256_is_valid_hex(self, tmp_path: Path) -> None:
        _, manifest = _run_simulation(tmp_path)
        for artifact in manifest.artifacts:
            assert _HEX64.match(artifact.sha256), (
                f"Bad sha256 for {artifact.filename}: {artifact.sha256}"
            )

    def test_artifact_size_bytes_positive(self, tmp_path: Path) -> None:
        _, manifest = _run_simulation(tmp_path)
        for artifact in manifest.artifacts:
            assert artifact.size_bytes > 0, f"Zero-size artifact: {artifact.filename}"

    def test_manifest_has_timestamp(self, tmp_path: Path) -> None:
        run_dir, _ = _run_simulation(tmp_path)
        data = json.loads((run_dir / "run_manifest.json").read_text(encoding="utf-8"))
        assert "timestamp" in data

    def test_stop_reason_completed(self, tmp_path: Path) -> None:
        _, manifest = _run_simulation(tmp_path)
        assert manifest.stop_reason == "completed"
