"""Unit tests for proof_demo module.

Tests verify that build_proof_demo() correctly gates on evidence validation
and produces truthful manifests on valid evidence.
"""

from __future__ import annotations

import json
import unittest.mock as mock
from typing import TYPE_CHECKING

import pytest

from mcp_vm_blackbox.coordination_db import _reset_db_instance_for_testing
from mcp_vm_blackbox.demo_manifest import ReplayBundle
from mcp_vm_blackbox.proof_demo import build_proof_demo

if TYPE_CHECKING:
    from collections.abc import Generator
    from pathlib import Path


@pytest.fixture(autouse=True)
def reset_db_singleton() -> Generator[None, None, None]:
    """Reset the CoordinationDB singleton before and after each test to prevent xdist state leakage."""
    _reset_db_instance_for_testing()
    yield
    _reset_db_instance_for_testing()


@pytest.fixture
def minimal_evidence(tmp_path: Path) -> dict[str, Path | str]:
    """Create minimal valid evidence structure.

    Returns dict with paths to: jobs_root, evidence_root, screenshots_dir,
    proof_file_path, job_path.
    """
    # JobStore adds "jobs" internally, so jobs_root is the parent
    jobs_root = tmp_path
    evidence_root = tmp_path / "evidence"

    job_id = "test123abc"
    vm_name = "Win11-Test"

    # Create job record - JobStore path is {root}/jobs/{vm_name}/{job_id}.json
    job_dir = jobs_root / "jobs" / vm_name
    job_dir.mkdir(parents=True)
    job_path = job_dir / f"{job_id}.json"
    job_record = {
        "job_id": job_id,
        "vm_name": vm_name,
        "scenario_name": "test-scenario",
        "created_by": "test-agent",
        "last_agent_id": "test-agent",
        "status": "completed",
        "steps": [],
        "created_at": "2025-01-01T00:00:00Z",
        "updated_at": "2025-01-01T00:00:00Z",
    }
    job_path.write_text(json.dumps(job_record), encoding="utf-8")

    # Create proof file with required content
    proof_dir = evidence_root / vm_name / job_id
    proof_dir.mkdir(parents=True)
    proof_file_path = proof_dir / "proof.txt"
    proof_content = """Proof Run Results
================

Calculated value: 42.5
Status: 👍

hostname: WIN11-TEST
username: TestUser
timestamp: 2025-01-01T12:00:00Z
"""
    proof_file_path.write_text(proof_content, encoding="utf-8")

    # Create screenshots directory with one screenshot
    screenshots_dir = proof_dir / "screenshots"
    screenshots_dir.mkdir(parents=True)
    screenshot_path = screenshots_dir / f"{vm_name}-001.png"
    screenshot_path.write_bytes(b"fake png content")

    return {
        "jobs_root": jobs_root,
        "evidence_root": evidence_root,
        "screenshots_dir": screenshots_dir,
        "proof_file_path": proof_file_path,
        "job_path": job_path,
        "job_id": job_id,
        "vm_name": vm_name,
    }


class TestGateRejectsInvalidEvidence:
    """Tests for the evidence validation gate."""

    async def test_gate_rejects_missing_job(self, tmp_path: Path) -> None:
        """No job record should raise ValueError.

        Tests: build_proof_demo raises ValueError when no job record exists.
        How: Create proof file and screenshots but no job JSON, then call build_proof_demo.
        Why: The gate must reject fabricated evidence with no corresponding job record.
        """
        # JobStore adds "jobs" internally, so jobs_root is the parent
        jobs_root = tmp_path
        evidence_root = tmp_path / "evidence"

        job_id = "nonexistent"
        vm_name = "Win11-Test"

        # Create proof file and screenshots but NO job record
        proof_dir = evidence_root / vm_name / job_id
        proof_dir.mkdir(parents=True)
        proof_file = proof_dir / "proof.txt"
        proof_file.write_text(
            "value: 42\n👍\nhostname: X\nusername: Y\ntimestamp: 2025-01-01T00:00:00Z", encoding="utf-8"
        )

        screenshots_dir = proof_dir / "screenshots"
        screenshots_dir.mkdir(parents=True)
        (screenshots_dir / f"{vm_name}-001.png").write_bytes(b"fake")

        with pytest.raises(ValueError) as exc_info:
            await build_proof_demo(job_id=job_id, vm_name=vm_name, evidence_root=evidence_root, jobs_root=jobs_root)

        assert "Invalid proof evidence" in str(exc_info.value)
        assert "job_record" in str(exc_info.value)

    async def test_gate_rejects_missing_proof_file(self, minimal_evidence: dict) -> None:
        """Job exists but no proof file should raise ValueError.

        Tests: build_proof_demo raises ValueError when proof.txt is absent.
        How: Remove proof file from minimal evidence, then call build_proof_demo.
        Why: Proof file is mandatory evidence; missing it means the run cannot be verified.
        """
        # Remove the proof file
        minimal_evidence["proof_file_path"].unlink()

        with pytest.raises(ValueError) as exc_info:
            await build_proof_demo(
                job_id=minimal_evidence["job_id"],
                vm_name=minimal_evidence["vm_name"],
                evidence_root=minimal_evidence["evidence_root"],
                jobs_root=minimal_evidence["jobs_root"],
            )

        assert "Invalid proof evidence" in str(exc_info.value)
        assert "proof_file" in str(exc_info.value)

    async def test_gate_rejects_missing_screenshots(self, minimal_evidence: dict) -> None:
        """Job and proof exist but no screenshots should raise ValueError.

        Tests: build_proof_demo raises ValueError when screenshots directory is empty.
        How: Delete all PNGs from screenshots dir, then call build_proof_demo.
        Why: Screenshots are required visual evidence; absence means the run was not observed.
        """
        # Remove all screenshots
        for screenshot in minimal_evidence["screenshots_dir"].glob("*.png"):
            screenshot.unlink()

        with pytest.raises(ValueError) as exc_info:
            await build_proof_demo(
                job_id=minimal_evidence["job_id"],
                vm_name=minimal_evidence["vm_name"],
                evidence_root=minimal_evidence["evidence_root"],
                jobs_root=minimal_evidence["jobs_root"],
            )

        assert "Invalid proof evidence" in str(exc_info.value)
        assert "screenshots" in str(exc_info.value)

    async def test_gate_rejects_incomplete_proof_file(self, minimal_evidence: dict) -> None:
        """Proof file missing required content should raise ValueError.

        Tests: build_proof_demo raises ValueError when proof.txt lacks required fields.
        How: Overwrite proof file without timestamp field, then call build_proof_demo.
        Why: Incomplete proof files indicate a partial or corrupted run.
        """
        # Write incomplete proof file (missing timestamp)
        minimal_evidence["proof_file_path"].write_text("value: 42\n👍\nhostname: X\nusername: Y\n", encoding="utf-8")

        with pytest.raises(ValueError) as exc_info:
            await build_proof_demo(
                job_id=minimal_evidence["job_id"],
                vm_name=minimal_evidence["vm_name"],
                evidence_root=minimal_evidence["evidence_root"],
                jobs_root=minimal_evidence["jobs_root"],
            )

        assert "Invalid proof evidence" in str(exc_info.value)
        assert "proof_file" in str(exc_info.value)


class TestHappyPath:
    """Tests for valid evidence producing correct manifests."""

    async def test_happy_path_returns_manifest(self, minimal_evidence: dict) -> None:
        """All evidence present should return valid ReplayBundle.

        Tests: build_proof_demo returns a ReplayBundle on valid evidence.
        How: Call build_proof_demo with complete minimal evidence.
        Why: The primary happy path must produce a usable manifest.
        """
        manifest = await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
        )

        assert isinstance(manifest, ReplayBundle)
        assert manifest.primary_job_id == minimal_evidence["job_id"]
        assert manifest.primary_vm_name == minimal_evidence["vm_name"]

    async def test_no_hallucinated_motion(self, minimal_evidence: dict) -> None:
        """No recordings should result in has_continuous_motion=False.

        Tests: build_proof_demo sets has_continuous_motion=False without recordings.
        How: Call build_proof_demo with evidence that has no recording files.
        Why: Motion must not be hallucinated; only real recordings qualify.
        """
        manifest = await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
        )

        assert manifest.has_continuous_motion is False

    async def test_source_evidence_count_includes_job_and_screenshots(self, minimal_evidence: dict) -> None:
        """Source evidence count should include job record and screenshots.

        Tests: build_proof_demo counts at least job + screenshot in evidence.
        How: Call build_proof_demo with one job record and one screenshot.
        Why: Evidence count drives confidence scoring; must not undercount.
        """
        manifest = await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
        )

        # At minimum: 1 job record + 1 screenshot = 2
        assert manifest.source_evidence_count >= 2

    async def test_missing_evidence_summary_no_recordings(self, minimal_evidence: dict) -> None:
        """Missing recordings should be recorded in missing_evidence_summary.

        Tests: build_proof_demo reports optional recordings as missing.
        How: Call build_proof_demo with no recording files present.
        Why: Missing evidence summary must accurately report what was not captured.
        """
        manifest = await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
        )

        # Recordings are optional, so they should show as missing with appropriate reason
        assert "optional" in manifest.missing_evidence_summary or "not_captured" in manifest.missing_evidence_summary


class TestSaveManifest:
    """Tests for manifest persistence."""

    async def test_saves_manifest_when_path_given(self, minimal_evidence: dict) -> None:
        """Manifest should be saved to disk when save_path is provided.

        Tests: build_proof_demo writes manifest JSON when save_path is given.
        How: Pass a save_path, then read back and verify contents match.
        Why: Persisted manifests are consumed by downstream tools; correctness is required.
        """
        save_path = minimal_evidence["evidence_root"] / "output" / "manifest.json"

        manifest = await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
            save_path=save_path,
        )

        assert save_path.exists()

        # Verify the saved content matches the returned manifest
        saved_data = json.loads(save_path.read_text(encoding="utf-8"))
        assert saved_data["primary_job_id"] == manifest.primary_job_id
        assert saved_data["primary_vm_name"] == manifest.primary_vm_name

    async def test_save_path_creates_parent_directories(self, minimal_evidence: dict, tmp_path: Path) -> None:
        """Save should create parent directories if they don't exist.

        Tests: build_proof_demo creates intermediate directories for save_path.
        How: Provide a deeply nested save_path whose parent does not exist.
        Why: Callers should not need to pre-create directories.
        """
        save_path = tmp_path / "deeply" / "nested" / "dir" / "manifest.json"
        assert not save_path.parent.exists()

        await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
            save_path=save_path,
        )

        assert save_path.exists()


class TestRenderStagingPath:
    """Tests for render_staging_path field population."""

    async def test_build_proof_demo_render_staging_path_when_video_enabled(self, minimal_evidence: dict) -> None:
        """When video_enabled=True and render_staging exists, path should be populated.

        Tests: render_staging_path is set when video is enabled and directory exists.
        How: Create render_staging dir, mock render_video, call build_proof_demo.
        Why: render_staging_path drives the Remotion renderer; it must be correct.
        """
        # Create render_staging directory
        job_id = minimal_evidence["job_id"]
        vm_name = minimal_evidence["vm_name"]
        render_staging_dir = minimal_evidence["evidence_root"] / vm_name / job_id / "render_staging"
        render_staging_dir.mkdir(parents=True, exist_ok=True)

        # Mock render_video since we're testing render_staging_path, not actual rendering
        expected_mp4 = render_staging_dir / "proof_video.mp4"
        with mock.patch("mcp_vm_blackbox.proof_demo.render_video") as mock_render_video:
            mock_render_video.return_value = expected_mp4

            manifest = await build_proof_demo(
                job_id=job_id,
                vm_name=vm_name,
                evidence_root=minimal_evidence["evidence_root"],
                jobs_root=minimal_evidence["jobs_root"],
                video_enabled=True,
            )

            assert manifest.render_staging_path is not None
            assert job_id in manifest.render_staging_path
            assert "render_staging" in manifest.render_staging_path

    async def test_build_proof_demo_render_staging_path_when_video_disabled(self, minimal_evidence: dict) -> None:
        """When video_enabled=False (default), render_staging_path should be None.

        Tests: render_staging_path is None when video is not requested.
        How: Call build_proof_demo with default video_enabled=False.
        Why: render_staging_path must not be populated when no video was requested.
        """
        manifest = await build_proof_demo(
            job_id=minimal_evidence["job_id"],
            vm_name=minimal_evidence["vm_name"],
            evidence_root=minimal_evidence["evidence_root"],
            jobs_root=minimal_evidence["jobs_root"],
        )

        assert manifest.render_staging_path is None

    async def test_build_proof_demo_render_staging_missing_when_video_enabled_fails(
        self, minimal_evidence: dict
    ) -> None:
        """When video_enabled=True but render_staging missing, validation should fail.

        Tests: build_proof_demo raises ValueError when render_staging dir is absent with video enabled.
        How: Do not create render_staging, call with video_enabled=True.
        Why: Validation must reject requests to render without staging frames.
        """
        # Don't create render_staging directory

        with pytest.raises(ValueError) as exc_info:
            await build_proof_demo(
                job_id=minimal_evidence["job_id"],
                vm_name=minimal_evidence["vm_name"],
                evidence_root=minimal_evidence["evidence_root"],
                jobs_root=minimal_evidence["jobs_root"],
                video_enabled=True,
            )

        assert "Invalid proof evidence" in str(exc_info.value)
        assert "render_staging" in str(exc_info.value)


class TestVideoCompletionGate:
    """Tests for video rendering completion gate."""

    async def test_video_gate_success(self, minimal_evidence: dict) -> None:
        """When video_enabled=True and render succeeds, video_output_path populated.

        Tests: build_proof_demo populates video_output_path on successful render.
        How: Create render_staging dir, mock render_video to return mp4 path, verify field.
        Why: video_output_path drives downstream consumers; must be set on success.
        """
        job_id = minimal_evidence["job_id"]
        vm_name = minimal_evidence["vm_name"]
        evidence_root = minimal_evidence["evidence_root"]

        # Create render_staging directory with a dummy frame
        render_staging_dir = evidence_root / vm_name / job_id / "render_staging"
        render_staging_dir.mkdir(parents=True, exist_ok=True)
        (render_staging_dir / "frame-001.png").write_bytes(b"fake frame")

        # Mock render_video to return a path and create the MP4 file
        expected_mp4 = render_staging_dir / "proof_video.mp4"

        with mock.patch("mcp_vm_blackbox.proof_demo.render_video") as mock_render_video:
            mock_render_video.return_value = expected_mp4

            # Create the MP4 file that the mock "produced"
            expected_mp4.write_bytes(b"fake mp4 content")

            manifest = await build_proof_demo(
                job_id=job_id,
                vm_name=vm_name,
                evidence_root=evidence_root,
                jobs_root=minimal_evidence["jobs_root"],
                video_enabled=True,
            )

            assert manifest.video_output_path is not None
            assert "proof_video.mp4" in manifest.video_output_path
            mock_render_video.assert_called_once()

    async def test_video_gate_failure(self, minimal_evidence: dict) -> None:
        """When video_enabled=True and render fails, ValueError raised with detail.

        Tests: build_proof_demo raises ValueError with job context on render failure.
        How: Mock render_video to raise RuntimeError, verify ValueError wraps it.
        Why: Render failures must surface job context for diagnostics.
        """
        job_id = minimal_evidence["job_id"]
        vm_name = minimal_evidence["vm_name"]
        evidence_root = minimal_evidence["evidence_root"]

        # Create render_staging directory
        render_staging_dir = evidence_root / vm_name / job_id / "render_staging"
        render_staging_dir.mkdir(parents=True, exist_ok=True)

        with mock.patch("mcp_vm_blackbox.proof_demo.render_video") as mock_render_video:
            mock_render_video.side_effect = RuntimeError("Renderer failed (exit 1): something went wrong")

            with pytest.raises(ValueError) as exc_info:
                await build_proof_demo(
                    job_id=job_id,
                    vm_name=vm_name,
                    evidence_root=evidence_root,
                    jobs_root=minimal_evidence["jobs_root"],
                    video_enabled=True,
                )

            assert "Video rendering failed" in str(exc_info.value)
            assert job_id in str(exc_info.value)
            assert "exit 1" in str(exc_info.value)

    async def test_video_disabled_no_render(self, minimal_evidence: dict) -> None:
        """When video_enabled=False, render_video should not be called.

        Tests: build_proof_demo skips render entirely when video_enabled=False.
        How: Mock render_video, call with video_enabled=False, assert not called.
        Why: Render is expensive; must not be invoked when not requested.
        """
        job_id = minimal_evidence["job_id"]
        vm_name = minimal_evidence["vm_name"]
        evidence_root = minimal_evidence["evidence_root"]

        with mock.patch("mcp_vm_blackbox.proof_demo.render_video") as mock_render_video:
            manifest = await build_proof_demo(
                job_id=job_id,
                vm_name=vm_name,
                evidence_root=evidence_root,
                jobs_root=minimal_evidence["jobs_root"],
                video_enabled=False,
            )

            mock_render_video.assert_not_called()
            assert manifest.video_output_path is None
