"""
Tests for the visualization overlay API router and pipeline integration.

Covers:
- OverlayRequest / VizJob model validation
- POST /generate endpoint: cache hits, force regeneration, missing files
- GET /status/{job_id}: found / not-found
- GET /check: cache existence, missing H5, missing overlay
- _output_path_for: path convention with various H5 depths
- Path traversal and security via validate_path
- Duplicate job detection bug (output_path never set on pending jobs)
- force=True not checking for already-running jobs
- check endpoint ignoring video_path entirely
"""

from __future__ import annotations

import os
import tempfile
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pytest

# Skip all tests if fastapi not installed
pytest.importorskip("fastapi")

# Mark all tests in this module as API tests
pytestmark = pytest.mark.api

from fastapi.testclient import TestClient  # noqa: E402

from sltk.api import dependencies as api_deps  # noqa: E402
from sltk.api.main import app  # noqa: E402
from sltk.api.routers import visualization as viz_module  # noqa: E402

# ============================================================================
# Fixtures
# ============================================================================


@pytest.fixture
def client():
    """Create a test client for the FastAPI app."""
    return TestClient(app)


@pytest.fixture
def temp_dir():
    """Create a temporary directory added to ALLOWED_ROOTS for safe testing."""
    with tempfile.TemporaryDirectory() as tmpdir:
        tmpdir_path = Path(tmpdir).resolve()
        original_roots = api_deps.ALLOWED_ROOTS.copy()
        api_deps.ALLOWED_ROOTS.append(tmpdir_path)
        yield tmpdir_path
        api_deps.ALLOWED_ROOTS.clear()
        api_deps.ALLOWED_ROOTS.extend(original_roots)


@pytest.fixture
def temp_video(temp_dir):
    """Create a dummy file that pretends to be a video."""
    video_path = temp_dir / "test_video.mp4"
    video_path.write_bytes(b"\x00" * 1024)
    return video_path


@pytest.fixture
def temp_h5(temp_dir):
    """Create a dummy H5 file with minimal structure."""
    import h5py

    # Create a nested structure like: temp_dir/features/mediapipe/video.h5
    h5_dir = temp_dir / "features" / "mediapipe"
    h5_dir.mkdir(parents=True, exist_ok=True)
    h5_path = h5_dir / "test_video.h5"
    with h5py.File(h5_path, "w") as f:
        rng = np.random.default_rng(42)
        f.create_dataset("poses", data=rng.standard_normal((10, 33, 3)).astype(np.float32))
        f.attrs["format"] = "mediapipe"
    return h5_path


@pytest.fixture
def temp_overlay(temp_h5):
    """Create a fake cached overlay file that is newer than the H5."""
    overlay_dir = temp_h5.parent / "overlays"
    overlay_dir.mkdir(parents=True, exist_ok=True)
    overlay_path = overlay_dir / f"{temp_h5.stem}_mediapipe_skeleton.mp4"
    overlay_path.write_bytes(b"\x00" * 512)
    # Ensure overlay mtime >= h5 mtime
    h5_mtime = temp_h5.stat().st_mtime
    os.utime(overlay_path, (h5_mtime + 1, h5_mtime + 1))
    return overlay_path


@pytest.fixture(autouse=True)
def _reset_jobs():
    """Clear the in-memory job store between tests."""
    viz_module._jobs.clear()
    yield
    viz_module._jobs.clear()


# ============================================================================
# Model Validation Tests
# ============================================================================


class TestOverlayRequestValidation:
    """Tests for OverlayRequest Pydantic model validation."""

    def test_valid_request(self):
        """Valid request with all required fields should construct successfully."""
        from sltk.api.models import OverlayRequest

        req = OverlayRequest(
            video_path="/tmp/video.mp4",
            h5_path="/tmp/data.h5",
            viz_type="mediapipe",
        )
        assert req.video_path == "/tmp/video.mp4"
        assert req.viz_type == "mediapipe"
        assert req.force is False, "force should default to False"

    def test_force_flag_default_false(self):
        """The force field should default to False when not provided."""
        from sltk.api.models import OverlayRequest

        req = OverlayRequest(
            video_path="/tmp/video.mp4",
            h5_path="/tmp/data.h5",
            viz_type="wilor",
        )
        assert req.force is False

    def test_invalid_viz_type_rejected_by_model(self):
        """OverlayRequest should reject viz_type values not in the Literal."""
        from pydantic import ValidationError

        from sltk.api.models import OverlayRequest

        with pytest.raises(ValidationError) as exc_info:
            OverlayRequest(
                video_path="/tmp/video.mp4",
                h5_path="/tmp/data.h5",
                viz_type="openpose",
            )
        errors = exc_info.value.errors()
        assert any(
            "viz_type" in str(e) for e in errors
        ), f"Expected viz_type validation error, got: {errors}"

    def test_missing_video_path_rejected(self):
        """OverlayRequest should reject missing video_path."""
        from pydantic import ValidationError

        from sltk.api.models import OverlayRequest

        with pytest.raises(ValidationError):
            OverlayRequest(h5_path="/tmp/data.h5", viz_type="mediapipe")

    def test_missing_h5_path_rejected(self):
        """OverlayRequest should reject missing h5_path."""
        from pydantic import ValidationError

        from sltk.api.models import OverlayRequest

        with pytest.raises(ValidationError):
            OverlayRequest(video_path="/tmp/video.mp4", viz_type="mediapipe")

    def test_all_valid_viz_types_accepted(self):
        """All three valid viz_type values should be accepted."""
        from sltk.api.models import OverlayRequest

        for vt in ("mediapipe", "wilor", "nlf"):
            req = OverlayRequest(
                video_path="/tmp/video.mp4",
                h5_path="/tmp/data.h5",
                viz_type=vt,
            )
            assert req.viz_type == vt


class TestVizJobModel:
    """Tests for VizJob Pydantic model defaults and field behavior."""

    def test_defaults(self):
        """VizJob should have sensible defaults for optional fields."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="abc123", status="pending")
        assert job.current_frame == 0
        assert job.total_frames == 0
        assert job.output_path is None
        assert job.error is None

    def test_completed_job_with_output(self):
        """A completed VizJob should carry an output_path."""
        from sltk.api.models import VizJob

        job = VizJob(
            job_id="abc123",
            status="completed",
            output_path="/tmp/overlay.mp4",
            total_frames=100,
            current_frame=100,
        )
        assert job.output_path == "/tmp/overlay.mp4"
        assert job.total_frames == 100

    def test_failed_job_with_error(self):
        """A failed VizJob should carry an error message."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="abc123", status="failed", error="out of memory")
        assert job.error == "out of memory"

    def test_invalid_status_rejected(self):
        """VizJob should reject status values not in the Literal."""
        from pydantic import ValidationError

        from sltk.api.models import VizJob

        with pytest.raises(ValidationError):
            VizJob(job_id="abc123", status="cancelled")


# ============================================================================
# _output_path_for Tests
# ============================================================================


class TestOutputPathFor:
    """Tests for the _output_path_for path convention function."""

    def test_standard_nested_h5(self):
        """Standard case: H5 in features/mediapipe/ should produce overlays/ sibling."""
        h5 = Path("/data/features/mediapipe/video.h5")
        result = viz_module._output_path_for(h5, "mediapipe")
        # parent = /data/features/mediapipe -> overlays_dir = /data/features/mediapipe/overlays
        assert result == Path("/data/features/mediapipe/overlays/video_mediapipe_skeleton.mp4")

    def test_different_viz_types_produce_different_names(self):
        """Each viz_type should produce a distinct output filename."""
        h5 = Path("/data/features/mediapipe/video.h5")
        paths = {viz_module._output_path_for(h5, vt) for vt in ("mediapipe", "wilor", "nlf")}
        assert len(paths) == 3, "Each viz_type should produce a unique output path"

    def test_shallow_h5_path_stays_in_parent(self):
        """Shallow H5 path should stay within its parent directory."""
        h5 = Path("/tmp/video.h5")
        result = viz_module._output_path_for(h5, "mediapipe")
        # parent = /tmp -> overlays_dir = /tmp/overlays
        assert result == Path(
            "/tmp/overlays/video_mediapipe_skeleton.mp4"
        ), f"Shallow H5 path produces unexpected output: {result}"

    def test_deeply_nested_h5(self):
        """Deep H5 paths should stay within the parent directory."""
        h5 = Path("/vol/research/project/data/features/nlf/some_video.h5")
        result = viz_module._output_path_for(h5, "nlf")
        expected = Path(
            "/vol/research/project/data/features/nlf/overlays/some_video_nlf_skeleton.mp4"
        )
        assert result == expected

    def test_h5_stem_with_dots_preserved(self):
        """H5 filenames with dots (e.g., 'video.01.h5') should have only .h5 stripped."""
        h5 = Path("/data/features/mediapipe/video.01.h5")
        result = viz_module._output_path_for(h5, "mediapipe")
        # Path.stem strips only the last suffix
        assert "video.01" in result.name


# ============================================================================
# POST /generate Endpoint Tests
# ============================================================================


class TestGenerateOverlayEndpoint:
    """Tests for POST /api/visualization/generate."""

    def test_missing_video_returns_404(self, client, temp_h5):
        """Requesting generation with a nonexistent video should return 404."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_h5.parent / "nonexistent.mp4"),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 404, f"Expected 404, got {resp.status_code}: {resp.text}"

    def test_missing_h5_returns_404(self, client, temp_video):
        """Requesting generation with a nonexistent H5 file should return 404."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video),
                "h5_path": str(temp_video.parent / "nonexistent.h5"),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 404, f"Expected 404, got {resp.status_code}: {resp.text}"

    def test_invalid_viz_type_returns_422(self, client, temp_video, temp_h5):
        """An invalid viz_type should be rejected at the Pydantic validation level (422)."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video),
                "h5_path": str(temp_h5),
                "viz_type": "openpose",
            },
        )
        assert resp.status_code == 422, f"Expected 422, got {resp.status_code}: {resp.text}"

    def test_cache_hit_returns_completed_immediately(
        self,
        client,
        temp_video,
        temp_h5,
        temp_overlay,
    ):
        """When a cached overlay exists and is fresh, return completed immediately."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200, f"Expected 200, got {resp.status_code}: {resp.text}"
        data = resp.json()
        assert data["status"] == "completed", f"Expected cached completed, got: {data}"
        assert data["job_id"] == "cached"
        assert data["output_path"] == str(temp_overlay)

    def test_force_bypasses_cache(self, client, temp_video, temp_h5, temp_overlay):
        """force=True should skip the cache and start a new job even when overlay exists."""
        with patch.object(viz_module, "_run_overlay_job"):
            resp = client.post(
                "/api/visualization/generate",
                json={
                    "video_path": str(temp_video),
                    "h5_path": str(temp_h5),
                    "viz_type": "mediapipe",
                    "force": True,
                },
            )
        assert resp.status_code == 200
        data = resp.json()
        assert data["job_id"] != "cached", "force=True should not return a cached result"
        assert data["status"] == "pending"

    def test_new_job_returns_pending_status(self, client, temp_video, temp_h5):
        """When no cache exists, the endpoint should create a pending job."""
        with patch.object(viz_module, "_run_overlay_job"):
            resp = client.post(
                "/api/visualization/generate",
                json={
                    "video_path": str(temp_video),
                    "h5_path": str(temp_h5),
                    "viz_type": "mediapipe",
                },
            )
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "pending"
        assert data["job_id"] != "cached"

    def test_path_traversal_blocked(self, client, temp_video, temp_h5):
        """Path traversal attempts in video_path should be rejected."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video.parent / ".." / ".." / "etc" / "passwd"),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert (
            resp.status_code == 403
        ), f"Path traversal should be blocked with 403, got {resp.status_code}: {resp.text}"

    def test_null_byte_in_path_blocked(self, client, temp_video, temp_h5):
        """Null bytes in paths should be rejected by validate_path."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video) + "\x00evil",
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert (
            resp.status_code == 403
        ), f"Null byte should be blocked with 403, got {resp.status_code}: {resp.text}"

    def test_duplicate_job_detection_works(self, client, temp_video, temp_h5):
        """Duplicate job detection returns the same pending job for repeated requests.

        VizJob now stores output_path at creation time, so the dedup check
        `job.output_path == str(output_path)` correctly matches pending jobs.
        """
        with patch.object(viz_module, "_run_overlay_job"):
            resp1 = client.post(
                "/api/visualization/generate",
                json={
                    "video_path": str(temp_video),
                    "h5_path": str(temp_h5),
                    "viz_type": "mediapipe",
                },
            )
            resp2 = client.post(
                "/api/visualization/generate",
                json={
                    "video_path": str(temp_video),
                    "h5_path": str(temp_h5),
                    "viz_type": "mediapipe",
                },
            )
        data1 = resp1.json()
        data2 = resp2.json()
        assert (
            data1["job_id"] == data2["job_id"]
        ), "Two requests for the same file should return the same pending job."

    def test_force_true_ignores_running_job_dedup(self, client, temp_video, temp_h5):
        """
        BUG PROBE: force=True skips the cache check but still goes through the
        running-job dedup check. However, since pending jobs have output_path=None,
        force=True will ALWAYS create a new job even if one is running.
        """
        from sltk.api.models import VizJob

        # Pre-populate a "running" job with the correct output_path
        output_path = viz_module._output_path_for(temp_h5, "mediapipe")
        running_job = VizJob(
            job_id="running-1",
            status="running",
            output_path=str(output_path),
        )
        viz_module._set_job("running-1", running_job)

        with patch.object(viz_module, "_run_overlay_job"):
            resp = client.post(
                "/api/visualization/generate",
                json={
                    "video_path": str(temp_video),
                    "h5_path": str(temp_h5),
                    "viz_type": "mediapipe",
                    "force": True,
                },
            )
        data = resp.json()
        # force=True skips cache, BUT the running-job dedup check still runs.
        # Since the running job DOES have output_path set, it should be returned.
        # But wait -- the code checks force ONLY for cache, not for dedup.
        # So the dedup check fires even with force=True and returns the running job.
        assert data["job_id"] == "running-1", (
            "force=True should still return an existing RUNNING job to avoid duplicates, "
            f"but got job_id={data['job_id']}"
        )


# ============================================================================
# GET /status/{job_id} Tests
# ============================================================================


class TestStatusEndpoint:
    """Tests for GET /api/visualization/status/{job_id}."""

    def test_unknown_job_returns_404(self, client):
        """Requesting status for a nonexistent job_id should return 404."""
        resp = client.get("/api/visualization/status/nonexistent-id")
        assert resp.status_code == 404

    def test_pending_job_status(self, client):
        """A pending job should return status=pending with zero progress."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="test-1", status="pending")
        viz_module._set_job("test-1", job)

        resp = client.get("/api/visualization/status/test-1")
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "pending"
        assert data["current_frame"] == 0
        assert data["total_frames"] == 0

    def test_running_job_with_progress(self, client):
        """A running job should report current progress."""
        from sltk.api.models import VizJob

        job = VizJob(
            job_id="test-2",
            status="running",
            current_frame=50,
            total_frames=100,
        )
        viz_module._set_job("test-2", job)

        resp = client.get("/api/visualization/status/test-2")
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "running"
        assert data["current_frame"] == 50
        assert data["total_frames"] == 100

    def test_completed_job_has_output_path(self, client):
        """A completed job should carry the output_path."""
        from sltk.api.models import VizJob

        job = VizJob(
            job_id="test-3",
            status="completed",
            output_path="/tmp/overlay.mp4",
        )
        viz_module._set_job("test-3", job)

        resp = client.get("/api/visualization/status/test-3")
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "completed"
        assert data["output_path"] == "/tmp/overlay.mp4"

    def test_failed_job_has_error(self, client):
        """A failed job should report the error message."""
        from sltk.api.models import VizJob

        job = VizJob(
            job_id="test-4",
            status="failed",
            error="GPU OOM",
        )
        viz_module._set_job("test-4", job)

        resp = client.get("/api/visualization/status/test-4")
        assert resp.status_code == 200
        data = resp.json()
        assert data["status"] == "failed"
        assert data["error"] == "GPU OOM"


# ============================================================================
# GET /check Tests
# ============================================================================


class TestCheckEndpoint:
    """Tests for GET /api/visualization/check."""

    def test_overlay_exists_and_fresh(self, client, temp_video, temp_h5, temp_overlay):
        """When cached overlay exists and is newer than H5, return exists=True."""
        resp = client.get(
            "/api/visualization/check",
            params={
                "video_path": str(temp_video),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["exists"] is True
        assert data["output_path"] == str(temp_overlay)

    def test_no_overlay_exists(self, client, temp_video, temp_h5):
        """When no overlay file exists, return exists=False."""
        resp = client.get(
            "/api/visualization/check",
            params={
                "video_path": str(temp_video),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["exists"] is False
        assert data["output_path"] is None

    def test_h5_not_found_returns_exists_false(self, client, temp_video):
        """When the H5 file doesn't exist, check returns exists=False."""
        resp = client.get(
            "/api/visualization/check",
            params={
                "video_path": str(temp_video),
                "h5_path": "/tmp/nonexistent_surely_not_here.h5",
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["exists"] is False

    def test_video_path_is_ignored_by_check(self, client, temp_h5, temp_overlay):
        """
        BUG PROBE: The check endpoint accepts video_path but never validates or uses it.
        Passing a completely bogus video_path should still return exists=True if
        the H5 and overlay are valid. This is likely a design oversight -- the
        endpoint signature implies video matters, but it doesn't.
        """
        resp = client.get(
            "/api/visualization/check",
            params={
                "video_path": "/totally/bogus/path.mp4",
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        # This will be True because video_path is never checked
        assert (
            data["exists"] is True
        ), "BUG CONFIRMED: video_path parameter is accepted but completely ignored by /check"

    def test_stale_overlay_returns_false(self, client, temp_video, temp_h5, temp_overlay):
        """When overlay is older than the H5 file, it should be considered stale."""
        # Make the overlay older than the H5
        h5_mtime = temp_h5.stat().st_mtime
        os.utime(temp_overlay, (h5_mtime - 100, h5_mtime - 100))

        resp = client.get(
            "/api/visualization/check",
            params={
                "video_path": str(temp_video),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["exists"] is False, "Stale overlay should not be considered valid"


# ============================================================================
# _run_overlay_job Worker Tests
# ============================================================================


class TestRunOverlayJob:
    """Tests for the background worker function _run_overlay_job."""

    def test_job_transitions_to_running(self):
        """The worker should set status to 'running' before calling generate."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="w-1", status="pending")
        viz_module._set_job("w-1", job)

        statuses_seen = []

        def mock_generate(**kwargs):
            # Capture status at the time generate is called
            with viz_module._jobs_lock:
                statuses_seen.append(viz_module._jobs["w-1"].status)

        with patch(
            "sltk.visualization.pipeline.generate_overlay_video",
            side_effect=mock_generate,
        ):
            viz_module._run_overlay_job(
                "w-1",
                Path("/tmp/video.mp4"),
                Path("/tmp/data.h5"),
                Path("/tmp/out.mp4"),
                "mediapipe",
            )

        assert (
            "running" in statuses_seen
        ), f"Expected 'running' status during generation, saw: {statuses_seen}"

    def test_job_transitions_to_completed(self):
        """After successful generation, status should be 'completed'."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="w-2", status="pending")
        viz_module._set_job("w-2", job)

        with patch(
            "sltk.visualization.pipeline.generate_overlay_video",
            return_value=Path("/tmp/out.mp4"),
        ):
            viz_module._run_overlay_job(
                "w-2",
                Path("/tmp/video.mp4"),
                Path("/tmp/data.h5"),
                Path("/tmp/out.mp4"),
                "mediapipe",
            )

        result = viz_module._get_job("w-2")
        assert result.status == "completed"
        assert result.output_path == "/tmp/out.mp4"

    def test_job_transitions_to_failed_on_exception(self):
        """If generation raises, the job should be marked 'failed' with error details."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="w-3", status="pending")
        viz_module._set_job("w-3", job)

        with patch(
            "sltk.visualization.pipeline.generate_overlay_video",
            side_effect=RuntimeError("GPU exploded"),
        ):
            viz_module._run_overlay_job(
                "w-3",
                Path("/tmp/video.mp4"),
                Path("/tmp/data.h5"),
                Path("/tmp/out.mp4"),
                "mediapipe",
            )

        result = viz_module._get_job("w-3")
        assert result.status == "failed"
        assert "GPU exploded" in result.error

    def test_progress_callback_updates_job(self):
        """The progress callback should update current_frame and total_frames on the job."""
        from sltk.api.models import VizJob

        job = VizJob(job_id="w-4", status="pending")
        viz_module._set_job("w-4", job)

        def mock_generate(progress_callback=None, **kwargs):
            if progress_callback:
                progress_callback(25, 100)
                progress_callback(50, 100)
                progress_callback(100, 100)

        with patch(
            "sltk.visualization.pipeline.generate_overlay_video",
            side_effect=mock_generate,
        ):
            viz_module._run_overlay_job(
                "w-4",
                Path("/tmp/video.mp4"),
                Path("/tmp/data.h5"),
                Path("/tmp/out.mp4"),
                "mediapipe",
            )

        result = viz_module._get_job("w-4")
        assert result.current_frame == 100
        assert result.total_frames == 100


# ============================================================================
# Pipeline Integration Tests
# ============================================================================


class TestPipelineValidation:
    """Tests for generate_overlay_video input validation (without real cv2/h5py)."""

    def test_pipeline_rejects_unknown_viz_type_when_files_exist(self, temp_video, temp_h5):
        """
        The pipeline should raise ValueError for unknown viz_type when both
        files exist and the viz_type check is actually reached.
        """
        from sltk.visualization.pipeline import generate_overlay_video

        with pytest.raises(ValueError, match="Unknown viz_type"):
            generate_overlay_video(
                video_path=temp_video,
                h5_path=temp_h5,
                output_path=Path("/tmp/out.mp4"),
                viz_type="openpose",
            )

    def test_pipeline_validation_order_bug(self):
        """
        BUG: The pipeline checks file existence BEFORE viz_type validation.
        So passing an invalid viz_type with a missing file raises FileNotFoundError
        instead of ValueError. The viz_type check should arguably come first since
        it's a cheaper, parameter-level validation.
        """
        from sltk.visualization.pipeline import generate_overlay_video

        # With a missing video AND an invalid viz_type, we get FileNotFoundError
        # (not ValueError) because file checks come first.
        with pytest.raises(FileNotFoundError, match="Video not found"):
            generate_overlay_video(
                video_path=Path("/tmp/surely_missing_video_xyzzy.mp4"),
                h5_path=Path("/tmp/surely_missing_data_xyzzy.h5"),
                output_path=Path("/tmp/out.mp4"),
                viz_type="openpose",  # Invalid, but never checked because video is missing
            )

    def test_pipeline_raises_for_missing_video(self, temp_h5):
        """The pipeline should raise FileNotFoundError for missing video."""
        from sltk.visualization.pipeline import generate_overlay_video

        with pytest.raises(FileNotFoundError, match="Video not found"):
            generate_overlay_video(
                video_path=Path("/tmp/surely_missing_video.mp4"),
                h5_path=temp_h5,
                output_path=Path("/tmp/out.mp4"),
                viz_type="mediapipe",
            )

    def test_pipeline_raises_for_missing_h5(self, temp_video):
        """The pipeline should raise FileNotFoundError for missing H5."""
        from sltk.visualization.pipeline import generate_overlay_video

        with pytest.raises(FileNotFoundError, match="H5 file not found"):
            generate_overlay_video(
                video_path=temp_video,
                h5_path=Path("/tmp/surely_missing_data.h5"),
                output_path=Path("/tmp/out.mp4"),
                viz_type="mediapipe",
            )


# ============================================================================
# Edge Case Tests
# ============================================================================


class TestEdgeCases:
    """Edge cases and boundary conditions."""

    def test_empty_h5_file(self, temp_dir):
        """An empty H5 file should not crash the check endpoint."""
        import h5py

        h5_path = temp_dir / "features" / "mediapipe" / "empty.h5"
        h5_path.parent.mkdir(parents=True, exist_ok=True)
        with h5py.File(h5_path, "w"):
            pass  # Empty file

        client = TestClient(app)
        resp = client.get(
            "/api/visualization/check",
            params={
                "video_path": "/tmp/video.mp4",
                "h5_path": str(h5_path),
                "viz_type": "mediapipe",
            },
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["exists"] is False

    def test_very_long_file_path(self, client, temp_dir):
        """A very long file path should not crash the server."""
        long_name = "a" * 200
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_dir / f"{long_name}.mp4"),
                "h5_path": str(temp_dir / f"{long_name}.h5"),
                "viz_type": "mediapipe",
            },
        )
        # Should return 404 (files don't exist), not 500
        assert (
            resp.status_code == 404
        ), f"Long paths should be handled gracefully, got {resp.status_code}: {resp.text}"

    def test_job_id_format_is_short_uuid(self, client, temp_video, temp_h5):
        """Job IDs should be 8-char truncated UUIDs."""
        with patch.object(viz_module, "_run_overlay_job"):
            resp = client.post(
                "/api/visualization/generate",
                json={
                    "video_path": str(temp_video),
                    "h5_path": str(temp_h5),
                    "viz_type": "mediapipe",
                },
            )
        data = resp.json()
        job_id = data["job_id"]
        assert len(job_id) == 8, f"Job ID should be 8 chars, got {len(job_id)}: {job_id}"

    def test_check_missing_query_params_returns_422(self, client):
        """Omitting required query params should return 422."""
        resp = client.get("/api/visualization/check")
        assert resp.status_code == 422

    def test_check_with_only_h5_path_returns_422(self, client, temp_h5):
        """Omitting video_path and viz_type should return 422."""
        resp = client.get(
            "/api/visualization/check",
            params={"h5_path": str(temp_h5)},
        )
        assert resp.status_code == 422

    def test_url_encoded_traversal_blocked(self, client, temp_video, temp_h5):
        """URL-encoded path traversal should be blocked by validate_path."""
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video),
                "h5_path": "/tmp/%2e%2e/etc/passwd",
                "viz_type": "mediapipe",
            },
        )
        assert (
            resp.status_code == 403
        ), f"URL-encoded traversal should be blocked, got {resp.status_code}: {resp.text}"

    def test_cached_job_has_zero_total_frames(self, client, temp_video, temp_h5, temp_overlay):
        """
        BUG PROBE: When returning a cached result, total_frames is hardcoded to 0.
        This means the frontend cannot display "100/100 frames" for cached results.
        """
        resp = client.post(
            "/api/visualization/generate",
            json={
                "video_path": str(temp_video),
                "h5_path": str(temp_h5),
                "viz_type": "mediapipe",
            },
        )
        data = resp.json()
        assert data["status"] == "completed"
        assert data["total_frames"] == 0, (
            "BUG CONFIRMED: cached results always have total_frames=0, "
            "losing the original frame count information"
        )
