"""Tests for demo_bundle.py job discovery, bundle generation, and CLI entrypoint.

All tests use tmp_path-based fixtures with controlled test data so they are
fully isolated from any live .mcp-vm-blackbox/jobs/ directory in the repo.
"""

from __future__ import annotations

import json
import subprocess
import sys
from typing import TYPE_CHECKING

import pytest

from mcp_vm_blackbox.demo_bundle import discover_jobs, generate_bundle, main
from mcp_vm_blackbox.demo_manifest import MissingReason

if TYPE_CHECKING:
    from pathlib import Path

# ---------------------------------------------------------------------------
# Shared helpers / fixture factories
# ---------------------------------------------------------------------------

_MINIMAL_JOB: dict[str, str] = {
    "job_id": "PLACEHOLDER",
    "vm_name": "PLACEHOLDER",
    "scenario_name": "test-scenario",
    "status": "completed",
    "created_at": "2024-01-15T10:00:00+00:00",
}


def _make_job(jobs_root: Path, vm_name: str, job_id: str, extra: dict[str, str] | None = None) -> Path:
    """Create a minimal job JSON file under jobs_root/{vm_name}/{job_id}.json.

    Args:
        jobs_root: Root jobs directory.
        vm_name: VM name (directory name).
        job_id: Job ID (file stem).
        extra: Optional overrides merged into the job payload.

    Returns:
        Path to the created JSON file.
    """
    vm_dir = jobs_root / vm_name
    vm_dir.mkdir(parents=True, exist_ok=True)
    job_path = vm_dir / f"{job_id}.json"
    payload = {**_MINIMAL_JOB, "job_id": job_id, "vm_name": vm_name}
    if extra:
        payload.update(extra)
    job_path.write_text(json.dumps(payload), encoding="utf-8")
    return job_path


def _make_repo(tmp_path: Path) -> tuple[Path, Path]:
    """Build a minimal fake repo tree with a .git marker.

    Args:
        tmp_path: Pytest temporary directory.

    Returns:
        Tuple of (repo_root, jobs_root).
    """
    repo_root = tmp_path / "repo"
    repo_root.mkdir()
    (repo_root / ".git").mkdir()
    jobs_root = repo_root / ".mcp-vm-blackbox" / "jobs"
    jobs_root.mkdir(parents=True)
    return repo_root, jobs_root


# ---------------------------------------------------------------------------
# TestDiscoverJobs
# ---------------------------------------------------------------------------


class TestDiscoverJobs:
    """Tests for the discover_jobs function.

    All tests build controlled jobs in tmp_path so results are deterministic
    regardless of which live job files exist in the repository.
    """

    def test_discovers_all_controlled_jobs(self, tmp_path: Path) -> None:
        """discover_jobs finds all 4 jobs created in a tmp_path jobs directory.

        Tests: discover_jobs with a known set of job files.
        How: Create 4 job files across 3 VMs in tmp_path, run discover_jobs.
        Why: Count must be deterministic; live-repo count varies across environments.
        """
        # Arrange
        jobs_root = tmp_path / "jobs"
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")
        _make_job(jobs_root, "ubuntu-22-staging", "b2c3d4e5f6a7")
        _make_job(jobs_root, "ubuntu-22-staging", "c3d4e5f6a7b8")
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        # Act
        jobs = discover_jobs(jobs_root)

        # Assert
        assert len(jobs) == 4, f"Expected 4 jobs, found {len(jobs)}"
        job_ids = {job_id for _, job_id, _ in jobs}
        assert "a1b2c3d4e5f6" in job_ids
        assert "b2c3d4e5f6a7" in job_ids
        assert "c3d4e5f6a7b8" in job_ids
        assert "d4e5f6a7b8c9" in job_ids

    def test_returns_sorted_by_vm_name_then_job_id(self, tmp_path: Path) -> None:
        """Jobs are sorted by vm_name, then job_id within the same VM.

        Tests: discover_jobs sort order.
        How: Create jobs across multiple VMs, verify sort order.
        Why: Stable ordering is required for deterministic manifest generation.
        """
        # Arrange
        jobs_root = tmp_path / "jobs"
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")
        _make_job(jobs_root, "ubuntu-22-staging", "b2c3d4e5f6a7")
        _make_job(jobs_root, "ubuntu-22-staging", "c3d4e5f6a7b8")
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        # Act
        jobs = discover_jobs(jobs_root)

        # Assert
        vm_names = [vm_name for vm_name, _, _ in jobs]
        assert vm_names == sorted(vm_names)

        ubuntu_jobs = [(vm, jid) for vm, jid, _ in jobs if vm == "ubuntu-22-staging"]
        ubuntu_job_ids = [jid for _, jid in ubuntu_jobs]
        assert ubuntu_job_ids == sorted(ubuntu_job_ids)

    def test_returns_correct_paths(self, tmp_path: Path) -> None:
        """Each job tuple contains a valid Path to the JSON file.

        Tests: discover_jobs path correctness.
        How: Create jobs, verify returned paths exist and have correct names.
        Why: Callers rely on the returned Path objects to read job content.
        """
        # Arrange
        jobs_root = tmp_path / "jobs"
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        # Act
        jobs = discover_jobs(jobs_root)

        # Assert
        for vm_name, job_id, job_path in jobs:
            assert job_path.exists(), f"Job path does not exist: {job_path}"
            assert job_path.is_file(), f"Job path is not a file: {job_path}"
            assert job_path.name == f"{job_id}.json"
            assert job_path.parent.name == vm_name

    def test_nonexistent_root_returns_empty(self, tmp_path: Path) -> None:
        """discover_jobs returns empty list for nonexistent root.

        Tests: discover_jobs graceful handling of missing directory.
        How: Pass a path that does not exist.
        Why: Callers must not crash when no jobs have been created yet.
        """
        # Act
        jobs = discover_jobs(tmp_path / "nonexistent")

        # Assert
        assert jobs == []

    def test_empty_root_returns_empty(self, tmp_path: Path) -> None:
        """discover_jobs returns empty list for an empty directory.

        Tests: discover_jobs with an empty jobs root.
        How: Create directory with no subdirectories, call discover_jobs.
        Why: Must return empty list, not raise, when no VM subdirs exist.
        """
        # Arrange
        empty_dir = tmp_path / "empty"
        empty_dir.mkdir()

        # Act
        jobs = discover_jobs(empty_dir)

        # Assert
        assert jobs == []


# ---------------------------------------------------------------------------
# TestGenerateBundle
# ---------------------------------------------------------------------------


class TestGenerateBundle:
    """Tests for the generate_bundle function.

    Each test constructs a self-contained repo tree in tmp_path so it never
    reads from the live .mcp-vm-blackbox/jobs/ directory.
    """

    def test_macos_job_with_screenshots(self, tmp_path: Path) -> None:
        """macos-14-build job produces a valid ReplayBundle with present screenshots.

        Tests: generate_bundle with a job that has matching screenshot files.
        How: Create job JSON, place a screenshot matching the VM name pattern,
             call generate_bundle, inspect evidence.
        Why: screenshot evidence must be classified as present=True when files exist.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")
        screenshots_dir = repo_root / "scratch" / "screenshots"
        screenshots_dir.mkdir(parents=True)
        (screenshots_dir / "macos-14-build-20240115-100000-seed1.png").write_bytes(b"")

        # Act
        bundle = generate_bundle("macos-14-build", "d4e5f6a7b8c9", repo_root)

        # Assert
        assert bundle.primary_job_id == "d4e5f6a7b8c9"
        assert bundle.primary_vm_name == "macos-14-build"

        job_record = next((e for e in bundle.evidence if e.source_kind == "job_record"), None)
        assert job_record is not None
        assert job_record.present is True

        screenshots = [e for e in bundle.evidence if e.source_kind == "screenshot"]
        assert len(screenshots) > 0
        present_screenshots = [s for s in screenshots if s.present]
        assert len(present_screenshots) > 0, "Expected at least one present screenshot"

    def test_recordings_flagged_as_optional_missing(self, tmp_path: Path) -> None:
        """Recordings are flagged as optional, not omitted, when no recordings dir exists.

        Tests: generate_bundle recording evidence when recordings_dir is absent.
        How: Create job with no recordings directory, inspect recording evidence.
        Why: Missing recordings must appear in evidence with MissingReason.OPTIONAL,
             not be silently omitted.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")
        screenshots_dir = repo_root / "scratch" / "screenshots"
        screenshots_dir.mkdir(parents=True)

        # Act
        bundle = generate_bundle("macos-14-build", "d4e5f6a7b8c9", repo_root)

        # Assert
        recordings = [e for e in bundle.evidence if e.source_kind == "recording"]
        assert len(recordings) == 1, "Expected exactly one recording evidence item"

        recording = recordings[0]
        assert recording.present is False
        assert recording.missing_reason == MissingReason.OPTIONAL
        assert recording.path is None

    def test_win11_job_produces_valid_bundle(self, tmp_path: Path) -> None:
        """win11-dsr-prod job produces a valid ReplayBundle with no recordings.

        Tests: generate_bundle for a Windows VM job without recordings.
        How: Create job JSON, no screenshots or recordings, call generate_bundle.
        Why: Bundle must be valid even when all optional evidence is absent.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")

        # Act
        bundle = generate_bundle("win11-dsr-prod", "a1b2c3d4e5f6", repo_root)

        # Assert
        assert bundle.primary_job_id == "a1b2c3d4e5f6"
        assert bundle.primary_vm_name == "win11-dsr-prod"
        assert bundle.has_continuous_motion is False

        assert MissingReason.OPTIONAL.value in bundle.missing_evidence_summary
        assert bundle.missing_evidence_summary[MissingReason.OPTIONAL.value] >= 1

    def test_unknown_job_raises_not_found(self, tmp_path: Path) -> None:
        """Unknown job raises FileNotFoundError.

        Tests: generate_bundle error path for missing job file.
        How: Call generate_bundle with job ID that has no corresponding file.
        Why: Callers must receive a clear error, not a cryptic KeyError or None.
        """
        # Arrange
        repo_root, _ = _make_repo(tmp_path)

        # Act / Assert
        with pytest.raises(FileNotFoundError) as exc_info:
            generate_bundle("nonexistent-vm", "nonexistent-job", repo_root)

        assert "Job file not found" in str(exc_info.value)

    def test_invalid_json_raises_decode_error(self, tmp_path: Path) -> None:
        """Invalid JSON in job file raises JSONDecodeError.

        Tests: generate_bundle error path for corrupt job file.
        How: Write a non-JSON file as the job, call generate_bundle.
        Why: Callers must receive a clear parse error, not a generic crash.
        """
        # Arrange
        repo_root = tmp_path / "repo"
        repo_root.mkdir()
        (repo_root / ".git").mkdir()
        jobs_dir = repo_root / ".mcp-vm-blackbox" / "jobs" / "test-vm"
        jobs_dir.mkdir(parents=True)
        invalid_job = jobs_dir / "invalid.json"
        invalid_job.write_text("{ not valid json }", encoding="utf-8")

        # Act / Assert
        with pytest.raises(json.JSONDecodeError):
            generate_bundle("test-vm", "invalid", repo_root)

    def test_bundle_includes_gsd_context(self, tmp_path: Path) -> None:
        """generate_bundle passes optional gsd_context through to the bundle.

        Tests: generate_bundle gsd_context linkage.
        How: Provide a GsdContextLink, verify it appears in bundle.gsd_context.
        Why: GSD linkage is optional but must be preserved verbatim when supplied.
        """
        # Arrange
        from mcp_vm_blackbox.demo_manifest import GsdContextLink

        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        gsd_context = [
            GsdContextLink(
                artifact_type="task",
                artifact_id="T01",
                path=".gsd/milestones/M006/tasks/T01.md",
                narrative_fields={"summary": "Test task"},
            )
        ]

        # Act
        bundle = generate_bundle("macos-14-build", "d4e5f6a7b8c9", repo_root, gsd_context=gsd_context)

        # Assert
        assert len(bundle.gsd_context) == 1
        assert bundle.gsd_context[0].artifact_id == "T01"


# ---------------------------------------------------------------------------
# TestCliList
# ---------------------------------------------------------------------------


class TestCliList:
    """Tests for the CLI --list subcommand.

    Tests pass --repo-root pointing to a controlled tmp_path tree so the CLI
    never discovers live jobs from .mcp-vm-blackbox/jobs/.
    """

    def test_list_shows_all_jobs(self, tmp_path: Path) -> None:
        """CLI --list prints all jobs from a controlled tmp_path repo.

        Tests: CLI --list output for a known job set.
        How: Build 4 jobs in tmp_path, run CLI with --repo-root, assert output.
        Why: Output must reflect the controlled fixture set, not the live repo.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")
        _make_job(jobs_root, "ubuntu-22-staging", "b2c3d4e5f6a7")
        _make_job(jobs_root, "ubuntu-22-staging", "c3d4e5f6a7b8")
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        # Act
        result = subprocess.run(
            [sys.executable, "-m", "mcp_vm_blackbox.demo_bundle", "--list", "--repo-root", str(repo_root)],
            check=False,
            capture_output=True,
            text=True,
        )

        # Assert
        assert result.returncode == 0
        assert "Found 4 jobs" in result.stdout
        assert "macos-14-build/d4e5f6a7b8c9" in result.stdout
        assert "ubuntu-22-staging/b2c3d4e5f6a7" in result.stdout
        assert "ubuntu-22-staging/c3d4e5f6a7b8" in result.stdout
        assert "win11-dsr-prod/a1b2c3d4e5f6" in result.stdout

    def test_list_function_direct(self, tmp_path: Path) -> None:
        """main() --list returns 0 and finds jobs from a controlled tmp_path repo.

        Tests: main() --list exit code with controlled fixture jobs.
        How: Build jobs in tmp_path, call main() with --repo-root, verify exit code.
        Why: main() must return 0 when jobs are found in the specified root.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        # Act
        exit_code = main(["--list", "--repo-root", str(repo_root)])

        # Assert
        assert exit_code == 0


# ---------------------------------------------------------------------------
# TestCliGenerate
# ---------------------------------------------------------------------------


class TestCliGenerate:
    """Tests for the CLI --vm + --job subcommand.

    All tests use a tmp_path-based repo tree; none read the live repo.
    """

    def test_generates_manifest_for_win11_job(self, tmp_path: Path) -> None:
        """CLI --vm/--job creates a manifest file for a controlled job fixture.

        Tests: CLI generate path with --output.
        How: Build job in tmp_path, run CLI with --repo-root and --output, verify file.
        Why: Manifest must be written to the specified output path as valid JSON.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")
        output_path = tmp_path / "manifest.json"

        # Act
        result = subprocess.run(
            [
                sys.executable,
                "-m",
                "mcp_vm_blackbox.demo_bundle",
                "--vm",
                "win11-dsr-prod",
                "--job",
                "a1b2c3d4e5f6",
                "--repo-root",
                str(repo_root),
                "--output",
                str(output_path),
            ],
            check=False,
            capture_output=True,
            text=True,
        )

        # Assert
        assert result.returncode == 0, f"stderr: {result.stderr}"
        assert output_path.exists(), f"Manifest not created at {output_path}"

        manifest_data = json.loads(output_path.read_text(encoding="utf-8"))
        assert manifest_data["primary_job_id"] == "a1b2c3d4e5f6"
        assert manifest_data["primary_vm_name"] == "win11-dsr-prod"

    def test_default_output_path(self, tmp_path: Path) -> None:
        """CLI without --output writes manifest to the default path.

        Tests: CLI generate default output path resolution.
        How: Build a self-contained repo in tmp_path, run CLI without --output,
             verify manifest lands at .mcp-vm-blackbox/manifests/{vm}/{job}.json.
        Why: Default path must be deterministic and not require --output flag.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")
        screenshots_dir = repo_root / "scratch" / "screenshots"
        screenshots_dir.mkdir(parents=True)

        # Act
        result = subprocess.run(
            [
                sys.executable,
                "-m",
                "mcp_vm_blackbox.demo_bundle",
                "--vm",
                "macos-14-build",
                "--job",
                "d4e5f6a7b8c9",
                "--repo-root",
                str(repo_root),
            ],
            check=False,
            capture_output=True,
            text=True,
        )

        # Assert
        assert result.returncode == 0, f"stderr: {result.stderr}"
        expected_path = repo_root / ".mcp-vm-blackbox" / "manifests" / "macos-14-build" / "d4e5f6a7b8c9.json"
        assert expected_path.exists(), f"Manifest not at expected path: {expected_path}"

    def test_unknown_job_errors_cleanly(self, tmp_path: Path) -> None:
        """CLI for an unknown job exits with error code 1 and a clear message.

        Tests: CLI error handling for nonexistent job.
        How: Build repo with no matching job, run CLI, verify exit code and stderr.
        Why: Error messages must be actionable; exit code 1 signals failure to callers.
        """
        # Arrange
        repo_root, _ = _make_repo(tmp_path)

        # Act
        result = subprocess.run(
            [
                sys.executable,
                "-m",
                "mcp_vm_blackbox.demo_bundle",
                "--vm",
                "nonexistent",
                "--job",
                "nonexistent",
                "--repo-root",
                str(repo_root),
            ],
            check=False,
            capture_output=True,
            text=True,
        )

        # Assert
        assert result.returncode == 1
        assert "Job file not found" in result.stderr

    def test_missing_vm_or_job_arg_errors(self, tmp_path: Path) -> None:
        """CLI with only --vm (no --job) exits with error code 1.

        Tests: CLI argument validation for incomplete --vm/--job pair.
        How: Provide --vm without --job, verify error exit and message.
        Why: Both args are required; half-specified invocations must fail clearly.
        """
        # Arrange
        repo_root, _ = _make_repo(tmp_path)

        # Act
        result = subprocess.run(
            [sys.executable, "-m", "mcp_vm_blackbox.demo_bundle", "--vm", "test", "--repo-root", str(repo_root)],
            check=False,
            capture_output=True,
            text=True,
        )

        # Assert
        assert result.returncode == 1
        assert "Both --vm and --job are required" in result.stderr


# ---------------------------------------------------------------------------
# TestMissingEvidenceSemantics
# ---------------------------------------------------------------------------


class TestMissingEvidenceSemantics:
    """Tests for truthful present/missing evidence handling in ReplayBundle.

    Uses tmp_path-based repos so evidence counts are fully controlled.
    """

    def test_missing_recordings_not_omitted(self, tmp_path: Path) -> None:
        """Missing recordings appear in evidence with MissingReason.OPTIONAL.

        Tests: Recording evidence presence when no recordings dir exists.
        How: Build job in tmp_path with no recordings, verify recording evidence item.
        Why: Absent recordings must not be silently dropped — consumers need to
             know they are missing and why.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "win11-dsr-prod", "a1b2c3d4e5f6")

        # Act
        bundle = generate_bundle("win11-dsr-prod", "a1b2c3d4e5f6", repo_root)

        # Assert
        recordings = [e for e in bundle.evidence if e.source_kind == "recording"]
        assert len(recordings) == 1, "Recording evidence must be present (not omitted)"

        recording = recordings[0]
        assert recording.present is False
        assert recording.missing_reason is not None
        assert recording.missing_reason == MissingReason.OPTIONAL

    def test_missing_evidence_summary_accurate(self, tmp_path: Path) -> None:
        """missing_evidence_summary reflects actual missing items.

        Tests: missing_evidence_summary count accuracy.
        How: Build job with no recordings, verify OPTIONAL count >= 1 in summary.
        Why: Summary drives downstream reporting; it must match actual evidence.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")

        # Act
        bundle = generate_bundle("macos-14-build", "d4e5f6a7b8c9", repo_root)

        # Assert
        assert bundle.missing_evidence_summary.get(MissingReason.OPTIONAL.value, 0) >= 1

    def test_source_evidence_count_accurate(self, tmp_path: Path) -> None:
        """source_evidence_count matches count of actually-present source items.

        Tests: source_evidence_count field accuracy.
        How: Build job with a screenshot, count present source items manually,
             compare to bundle.source_evidence_count.
        Why: Count fields are consumed by downstream tools; they must be exact.
        """
        # Arrange
        repo_root, jobs_root = _make_repo(tmp_path)
        _make_job(jobs_root, "macos-14-build", "d4e5f6a7b8c9")
        screenshots_dir = repo_root / "scratch" / "screenshots"
        screenshots_dir.mkdir(parents=True)
        (screenshots_dir / "macos-14-build-20240115-100000-seed1.png").write_bytes(b"")

        # Act
        bundle = generate_bundle("macos-14-build", "d4e5f6a7b8c9", repo_root)

        # Assert
        present_source = sum(1 for e in bundle.evidence if e.present and str(e.kind) == "source")
        assert bundle.source_evidence_count == present_source
