"""
test_critics.py — Golden test cases for all 4 critic implementations.

Tests:
  - CriticLoop base class behavior (graceful degradation, regression detection)
  - IP3: KeyframeRewriteCritic (5 deterministic dimensions)
  - IP1: PlanPassCritic (5 deterministic dimensions)
  - IP2: BatchBoundaryCritic (import + structure only, no API call)
"""

import json
import sys
import tempfile
from pathlib import Path

import pytest

# Ensure pipeline root is on the path
PIPELINE_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PIPELINE_ROOT))

from recoil.core.critic import CriticLoop, CriticResult, Dimension, Outcome, Severity


# ══════════════════════════════════════════════════════════════════════
# CriticLoop Base Class Tests
# ══════════════════════════════════════════════════════════════════════


class AlwaysPassCritic(CriticLoop):
    def evaluate(self, artifact, context):
        return [Dimension(name="TEST_DIM", severity=Severity.HARD, passed=True)]


class AlwaysFailCritic(CriticLoop):
    def evaluate(self, artifact, context):
        return [
            Dimension(
                name="TEST_DIM",
                severity=Severity.HARD,
                passed=False,
                message="always fails",
            )
        ]

    def auto_fix(self, artifact, failed_dims, context):
        return artifact  # No actual fix


class ExplodingCritic(CriticLoop):
    def evaluate(self, artifact, context):
        raise RuntimeError("Kaboom!")


class RegressionCritic(CriticLoop):
    """On first eval: DIM_A fails, DIM_B passes.
    On second eval (after fix): DIM_A passes, DIM_B fails (regression).
    """

    def __init__(self):
        super().__init__(name="regression_test", max_attempts=2)
        self._call_count = 0

    def evaluate(self, artifact, context):
        self._call_count += 1
        if self._call_count <= 1:
            return [
                Dimension(
                    name="DIM_A",
                    severity=Severity.HARD,
                    passed=False,
                    message="fail first time",
                ),
                Dimension(name="DIM_B", severity=Severity.HARD, passed=True),
            ]
        else:
            return [
                Dimension(name="DIM_A", severity=Severity.HARD, passed=True),
                Dimension(
                    name="DIM_B",
                    severity=Severity.HARD,
                    passed=False,
                    message="regressed!",
                ),
            ]

    def auto_fix(self, artifact, failed_dims, context):
        return "fixed_" + artifact


class TestCriticLoopBase:
    def test_always_pass(self):
        critic = AlwaysPassCritic(name="pass_test", max_attempts=1)
        artifact, result = critic.run("hello")
        assert result.passed is True
        assert artifact == "hello"
        assert len(result.dimensions) == 1

    def test_graceful_degradation_on_error(self):
        """After Phase 2.5 Task 2: crashed critics return ERROR outcome (fail-closed),
        not silent pass. Legacy fail-open was a silent QC bypass."""
        critic = ExplodingCritic(name="boom_test", max_attempts=1)
        artifact, result = critic.run("hello")
        assert result.outcome == Outcome.ERROR
        assert result.passed is False  # ERROR is not passed
        assert result.errored is True
        assert artifact == "hello"  # Original returned
        assert "Kaboom" in result.error

    def test_regression_detection(self):
        critic = RegressionCritic()
        artifact, result = critic.run("original")
        # Should return the pre-fix artifact because DIM_B regressed
        assert artifact == "original"  # NOT "fixed_original"

    def test_experience_pool_logging(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            critic = AlwaysPassCritic(
                name="log_test",
                max_attempts=1,
                experience_pool_dir=Path(tmpdir),
                shot_id="EP001_SH001",
            )
            critic.run("test")
            pool_path = Path(tmpdir) / "experience_pool.jsonl"
            assert pool_path.exists()
            entry = json.loads(pool_path.read_text().strip())
            assert entry["critic"] == "log_test"
            assert entry["shot_id"] == "EP001_SH001"
            assert entry["passed"] is True

    def test_experience_pool_no_truncation(self):
        """Phase 2.5 Task 10: the cap-at-500 truncation was removed (data loss + race hazard).
        Experience pool grows unbounded; compaction is a separate job."""
        with tempfile.TemporaryDirectory() as tmpdir:
            pool_dir = Path(tmpdir)
            # Seed with 510 entries
            pool_path = pool_dir / "experience_pool.jsonl"
            pool_path.write_text(
                "\n".join(json.dumps({"i": i}) for i in range(510)) + "\n"
            )
            # Run critic (will append only, no truncation)
            critic = AlwaysPassCritic(
                name="cap_test",
                max_attempts=1,
                experience_pool_dir=pool_dir,
            )
            critic.run("test")
            lines = pool_path.read_text().strip().split("\n")
            # 510 seed + 1 new append = 511, no truncation
            assert len(lines) == 511

    def test_critic_result_to_dict(self):
        result = CriticResult(
            critic_name="test",
            outcome=Outcome.PASS,
            dimensions=[
                Dimension(name="D1", severity=Severity.HARD, passed=True),
                Dimension(
                    name="D2", severity=Severity.SOFT, passed=False, message="oops"
                ),
            ],
        )
        d = result.to_dict()
        assert d["critic_name"] == "test"
        assert d["dimensions"][0]["severity"] == "hard"
        assert d["dimensions"][1]["severity"] == "soft"

    def test_soft_failure_does_not_block(self):
        """A critic with only SOFT failures should still pass."""

        class SoftFailCritic(CriticLoop):
            def evaluate(self, artifact, context):
                return [
                    Dimension(
                        name="SOFT_DIM",
                        severity=Severity.SOFT,
                        passed=False,
                        message="advisory",
                    ),
                    Dimension(name="HARD_DIM", severity=Severity.HARD, passed=True),
                ]

        critic = SoftFailCritic(name="soft_test", max_attempts=1)
        _, result = critic.run("test")
        assert result.passed is True
        assert len(result.soft_failures) == 1
        assert len(result.hard_failures) == 0


# IP5 TestVideoEnhancementCritic RETIRED 2026-06-09 with the critic
# (enrichment superseded by prose_author).


# ══════════════════════════════════════════════════════════════════════
# IP3: KeyframeRewriteCritic Tests
# ══════════════════════════════════════════════════════════════════════


class TestKeyframeRewriteCritic:
    @pytest.fixture
    def bible(self):
        return {
            "characters": {
                "JINX": {
                    "display_name": "JINX",
                    "visual_description": "tall woman, dark hair, sharp features",
                },
            },
            "locations": {
                "corridor_a": {
                    "visual_description": "dimly lit industrial corridor with exposed pipes and flickering overhead lights"
                },
            },
        }

    @pytest.fixture
    def shot(self):
        return {
            "shot_id": "EP001_SH003",
            "asset_data": {
                "characters": [{"char_id": "JINX"}],
                "location_id": "corridor_a",
            },
            "prompt_data": {
                "spatial_data": {
                    "camera_side": "left",
                    "subject_position": "center-right",
                },
            },
        }

    def test_clean_prompt_passes(self, bible, shot):
        from recoil.pipeline._lib.critics.keyframe_rewrite_critic import (
            KeyframeRewriteCritic,
        )

        critic = KeyframeRewriteCritic(bible=bible, shot=shot)
        prompt = (
            "JINX stands in the dimly lit industrial corridor, flickering lights "
            "casting sharp shadows across exposed pipes. Camera positioned from left, "
            "she occupies center-right of frame."
        )
        _, result = critic.run(prompt)
        assert result.passed

    def test_archetype_trigger_auto_fixed(self, bible, shot):
        from recoil.pipeline._lib.critics.keyframe_rewrite_critic import (
            KeyframeRewriteCritic,
        )

        critic = KeyframeRewriteCritic(bible=bible, shot=shot)
        prompt = (
            "The tactical operator surveys the corridor with military-grade equipment."
        )
        fixed, result = critic.run(prompt)
        assert "tactical" not in fixed.lower()
        assert "military-grade" not in fixed.lower()

    def test_vfx_blacklist_auto_fixed(self, bible, shot):
        from recoil.pipeline._lib.critics.keyframe_rewrite_critic import (
            KeyframeRewriteCritic,
        )

        critic = KeyframeRewriteCritic(bible=bible, shot=shot)
        prompt = (
            "JINX morphing between states, face swap dissolving into glitching overlay."
        )
        fixed, result = critic.run(prompt)
        assert "morphing" not in fixed.lower()
        assert "face swap" not in fixed.lower()

    def test_subject_primacy_soft_failure(self, bible, shot):
        from recoil.pipeline._lib.critics.keyframe_rewrite_critic import (
            KeyframeRewriteCritic,
        )

        critic = KeyframeRewriteCritic(bible=bible, shot=shot)
        prompt = "A corridor stretches into darkness, pipes overhead, flickering lights, exposed brickwork."
        _, result = critic.run(prompt)
        assert result.passed  # SOFT
        sp_dim = next(d for d in result.dimensions if d.name == "SUBJECT_PRIMACY")
        assert not sp_dim.passed

    def test_env_shot_skips_subject_check(self, bible):
        """ENV shots (no characters) should pass subject primacy."""
        from recoil.pipeline._lib.critics.keyframe_rewrite_critic import (
            KeyframeRewriteCritic,
        )

        env_shot = {
            "shot_id": "EP001_ENV01",
            "asset_data": {"characters": [], "location_id": "corridor_a"},
            "prompt_data": {"spatial_data": {}},
        }
        critic = KeyframeRewriteCritic(bible=bible, shot=env_shot)
        prompt = "A corridor stretches into darkness."
        _, result = critic.run(prompt)
        sp_dim = next(d for d in result.dimensions if d.name == "SUBJECT_PRIMACY")
        assert sp_dim.passed  # No characters = auto-pass


# ══════════════════════════════════════════════════════════════════════
# IP1: PlanPassCritic Tests
# ══════════════════════════════════════════════════════════════════════


class TestPlanPassCritic:
    @pytest.fixture
    def bible(self):
        return {
            "characters": {
                "MARCUS": {
                    "display_name": "MARCUS",
                    "visual_description": "tall man, dark skin",
                },
                "ELENA": {
                    "display_name": "ELENA",
                    "visual_description": "short woman, red hair",
                },
            },
            "locations": {
                "lab_main": {
                    "visual_description": "sterile laboratory with white walls"
                },
            },
        }

    def test_clean_shot_passes(self, bible):
        from recoil.pipeline._lib.critics.plan_pass_critic import PlanPassCritic

        shot = {
            "shot_id": "EP001_SH001",
            "scene_index": 0,
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "{char_MARCUS} reaches for the vial",
                    "environment_line": "sterile laboratory with white walls and fluorescent lights",
                    "action_line": "lunges forward, snatching the vial from the counter",
                    "emotion_line": "desperate determination with trembling hands",
                },
                "spatial_data": {"camera_side": "left"},
            },
            "asset_data": {
                "characters": [{"char_id": "MARCUS"}],
                "location_id": "lab_main",
            },
        }
        critic = PlanPassCritic(bible=bible, all_shots=[shot])
        _, result = critic.run(shot)
        assert result.passed

    def test_sterility_violation_auto_fixed(self, bible):
        from recoil.pipeline._lib.critics.plan_pass_critic import PlanPassCritic

        shot = {
            "shot_id": "EP001_SH002",
            "scene_index": 0,
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "MARCUS stands tall",
                    "environment_line": "MARCUS watches from the sterile lab",
                    "action_line": "turns slowly toward the door",
                    "emotion_line": "resigned acceptance, shoulders dropping",
                },
                "spatial_data": {},
            },
            "asset_data": {
                "characters": [{"char_id": "MARCUS"}],
                "location_id": "lab_main",
            },
        }
        critic = PlanPassCritic(bible=bible, all_shots=[shot])
        fixed_shot, result = critic.run(shot)
        assert result.auto_fixed

    def test_vague_emotion_soft_failure(self, bible):
        from recoil.pipeline._lib.critics.plan_pass_critic import PlanPassCritic

        shot = {
            "shot_id": "EP001_SH003",
            "scene_index": 0,
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "{char_ELENA} backs away",
                    "environment_line": "dark corridor",
                    "action_line": "steps backward, hands raised defensively",
                    "emotion_line": "scared",
                },
                "spatial_data": {},
            },
            "asset_data": {
                "characters": [{"char_id": "ELENA"}],
                "location_id": "lab_main",
            },
        }
        critic = PlanPassCritic(bible=bible, all_shots=[shot])
        _, result = critic.run(shot)
        assert result.passed  # SOFT
        em_dim = next(d for d in result.dimensions if d.name == "EMOTION_SPECIFICITY")
        assert not em_dim.passed

    def test_camera_flip_detected(self, bible):
        from recoil.pipeline._lib.critics.plan_pass_critic import PlanPassCritic

        shot_a = {
            "shot_id": "EP001_SH004",
            "scene_index": 1,
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "{char_MARCUS} argues",
                    "environment_line": "lab main",
                    "action_line": "gestures emphatically",
                    "emotion_line": "tense fury",
                },
            },
            "spatial_data": {
                "camera_side": "A",
                "cut_relation": "consistent",
                "axis_segment_id": 1,
            },
            "asset_data": {
                "characters": [{"char_id": "MARCUS"}],
                "location_id": "lab_main",
            },
        }
        shot_b = {
            "shot_id": "EP001_SH005",
            "scene_index": 1,
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "{char_ELENA} responds",
                    "environment_line": "lab main reverse",
                    "action_line": "crosses arms defiantly",
                    "emotion_line": "cold resolve",
                },
            },
            "spatial_data": {
                "camera_side": "B",
                "cut_relation": "consistent",
                "axis_segment_id": 1,
            },
            "asset_data": {
                "characters": [{"char_id": "ELENA"}],
                "location_id": "lab_main",
            },
        }
        critic = PlanPassCritic(bible=bible, all_shots=[shot_a, shot_b])
        _, result = critic.run(shot_b)
        assert result.passed  # SOFT
        sp_dim = next(d for d in result.dimensions if d.name == "SPATIAL_COHERENCE")
        assert not sp_dim.passed

    def test_missing_required_field(self, bible):
        from recoil.pipeline._lib.critics.plan_pass_critic import PlanPassCritic

        shot = {
            "shot_id": "EP001_SH006",
            "scene_index": 0,
            "prompt_data": {
                "prompt_skeleton": {
                    "subject_line": "",  # Missing!
                    "environment_line": "lab main",
                    "action_line": "stands still",
                    "emotion_line": "neutral",
                },
                "spatial_data": {},
            },
            "asset_data": {
                "characters": [{"char_id": "MARCUS"}],
                "location_id": "lab_main",
            },
        }
        critic = PlanPassCritic(bible=bible, all_shots=[shot])
        _, result = critic.run(shot)
        gram_dim = next(d for d in result.dimensions if d.name == "SHOT_GRAMMAR")
        assert not gram_dim.passed


# ══════════════════════════════════════════════════════════════════════
# IP2: BatchBoundaryCritic Tests (import + structure only)
# ══════════════════════════════════════════════════════════════════════


def _import_batch_critic():
    """Import batch_critic from recoil/tools/ using importlib to avoid
    pipeline/tools/ package shadowing."""
    import importlib.util

    batch_critic_path = PIPELINE_ROOT.parent / "tools" / "batch_critic.py"
    spec = importlib.util.spec_from_file_location("batch_critic", batch_critic_path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


class TestBatchBoundaryCritic:
    def test_import(self):
        """BatchBoundaryCritic can be imported from recoil/tools/."""
        mod = _import_batch_critic()
        assert len(mod.DIMENSIONS) == 6
        assert mod.CHECKPOINT_BATCHES == {3, 6, 9, 12}

    def test_non_checkpoint_skipped(self):
        mod = _import_batch_critic()
        import tempfile

        with tempfile.TemporaryDirectory() as tmpdir:
            result = mod.run_batch_critic(Path(tmpdir), batch_num=2)
            assert result.get("skipped") is True

    def test_checkpoint_no_episodes(self):
        mod = _import_batch_critic()
        import tempfile

        with tempfile.TemporaryDirectory() as tmpdir:
            project_dir = Path(tmpdir)
            (project_dir / "episodes").mkdir()
            result = mod.run_batch_critic(project_dir, batch_num=3)
            assert "error" in result
