"""
Unit tests for the SignRep inference pipeline (sltk.embedding.pipeline).

Covers:
- Data classes: DictionaryResult, ContinuousResult, DictionaryIndex,
  SegmentMatch, SpottingResult (serialization roundtrips, field shapes)
- Pooling strategies: all 6 dictionary methods + 3 segment methods
  with synthetic tensors (no GPU required)
- Helper functions: parse_gloss_name, find_overlapping_windows,
  frame_to_ms, load_segments
- FrameTransform: output shape and dtype
- VideoSegmentDataset: start index computation without real video
- SignRepPipeline constructor: checkpoint resolution, device fallback
- get_pipeline: thread-safe singleton behavior
- Backward compatibility: SignRepEmbedder delegation to pipeline

Run with:
    pytest tests/test_signrep_pipeline.py -v
"""

from __future__ import annotations

import json
import threading
from pathlib import Path
from unittest.mock import MagicMock, patch

import numpy as np
import pytest

torch = pytest.importorskip("torch")

# ---------------------------------------------------------------------------
# Data class tests
# ---------------------------------------------------------------------------


class TestDictionaryResult:
    """Test DictionaryResult data class, save/load, and field constraints."""

    def _make_result(self, n_windows=10, dim=768, fps=25.0, total_frames=100):
        from sltk.embedding.pipeline import DictionaryResult

        rng = np.random.default_rng(42)
        feature = rng.standard_normal(dim).astype(np.float32)
        all_features = rng.standard_normal((n_windows, dim)).astype(np.float32)
        active_probs = rng.random(n_windows).astype(np.float32)
        frame_indices = np.arange(n_windows, dtype=np.int64)
        return DictionaryResult(
            feature=feature,
            all_features=all_features,
            active_probs=active_probs,
            frame_indices=frame_indices,
            fps=fps,
            total_frames=total_frames,
            pooling_method="activity",
            source_path="/tmp/test.mp4",
        )

    def test_feature_is_1d_768(self):
        result = self._make_result()
        assert result.feature.shape == (768,), f"Expected (768,), got {result.feature.shape}"

    def test_all_features_shape(self):
        result = self._make_result(n_windows=5)
        assert result.all_features.shape == (
            5,
            768,
        ), f"Expected (5, 768), got {result.all_features.shape}"

    def test_save_load_npz_roundtrip(self, tmp_path):
        result = self._make_result()
        out = tmp_path / "test_dict.npz"
        result.save_npz(out)
        assert out.exists(), "NPZ file was not created"

        loaded = np.load(str(out), allow_pickle=True)
        np.testing.assert_array_almost_equal(loaded["best_feature"], result.feature)
        np.testing.assert_array_almost_equal(loaded["best_latent"], result.feature)
        np.testing.assert_array_almost_equal(loaded["features"], result.all_features)
        np.testing.assert_array_almost_equal(loaded["active_probs"], result.active_probs)
        np.testing.assert_array_almost_equal(loaded["frame_indices"], result.frame_indices)
        assert float(loaded["fps"]) == pytest.approx(25.0)
        assert int(loaded["total_frames"]) == 100
        assert str(loaded["mode"]) == "dictionary"
        assert str(loaded["pooling_method"]) == "activity"

    def test_save_npz_creates_parent_directories(self, tmp_path):
        result = self._make_result()
        deep_path = tmp_path / "a" / "b" / "c" / "test.npz"
        # np.savez does NOT create parents -- verify current behavior
        with pytest.raises(FileNotFoundError):
            result.save_npz(deep_path)

    def test_save_npz_segment_length_constant(self, tmp_path):
        from sltk.embedding.pipeline import SEGMENT_LENGTH

        result = self._make_result()
        out = tmp_path / "test.npz"
        result.save_npz(out)
        loaded = np.load(str(out), allow_pickle=True)
        assert int(loaded["segment_length"]) == SEGMENT_LENGTH


class TestContinuousResult:
    """Test ContinuousResult data class and save/load."""

    def _make_result(self, n_windows=20, dim=768, stride=4):
        from sltk.embedding.pipeline import ContinuousResult

        rng = np.random.default_rng(42)
        features = rng.standard_normal((n_windows, dim)).astype(np.float32)
        # L2-normalize to match real pipeline output
        norms = np.linalg.norm(features, axis=1, keepdims=True)
        features = features / norms
        active_probs = rng.random(n_windows).astype(np.float32)
        frame_indices = np.arange(0, n_windows * stride, stride, dtype=np.int64)
        return ContinuousResult(
            features=features,
            active_probs=active_probs,
            frame_indices=frame_indices,
            fps=25.0,
            total_frames=n_windows * stride + 16,
            stride=stride,
        )

    def test_features_shape(self):
        result = self._make_result(n_windows=20)
        assert result.features.shape == (20, 768)

    def test_save_load_npz_roundtrip(self, tmp_path):
        result = self._make_result()
        out = tmp_path / "test_cont.npz"
        result.save_npz(out)

        loaded = np.load(str(out), allow_pickle=True)
        np.testing.assert_array_almost_equal(loaded["features"], result.features)
        np.testing.assert_array_almost_equal(loaded["latents"], result.features)
        assert int(loaded["stride"]) == result.stride
        assert str(loaded["mode"]) == "continuous"

    def test_features_l2_normalized(self):
        result = self._make_result()
        norms = np.linalg.norm(result.features, axis=1)
        np.testing.assert_allclose(norms, 1.0, atol=1e-5)


class TestDictionaryIndex:
    """Test DictionaryIndex data class."""

    def _make_index(self, n=100, dim=768):
        from sltk.embedding.pipeline import DictionaryIndex

        features = torch.randn(n, dim)
        glosses = [f"GLOSS_{i}" for i in range(n)]
        filenames = [f"file_{i}" for i in range(n)]
        source_dirs = ["dir_a"] * n
        file_paths = [f"dir_a/file_{i}.npz" for i in range(n)]
        return DictionaryIndex(
            features=features,
            glosses=glosses,
            filenames=filenames,
            source_dirs=source_dirs,
            file_paths=file_paths,
        )

    def test_len(self):
        idx = self._make_index(n=50)
        assert len(idx) == 50, f"Expected 50, got {len(idx)}"

    def test_len_empty(self):
        idx = self._make_index(n=0)
        assert len(idx) == 0

    def test_to_device_returns_self(self):
        idx = self._make_index(n=10)
        result = idx.to("cpu")
        assert result is idx, "to() should return self for in-place mutation"

    def test_to_device_moves_features(self):
        idx = self._make_index(n=10)
        idx.to("cpu")
        assert idx.features.device == torch.device("cpu")

    def test_features_shape(self):
        idx = self._make_index(n=25, dim=768)
        assert idx.features.shape == (25, 768)


class TestSegmentMatch:
    """Test SegmentMatch data class serialization."""

    def _make_match(self):
        from sltk.embedding.pipeline import SegmentMatch

        return SegmentMatch(
            segment_id=1,
            video_id="test_video",
            start_frame=0,
            end_frame=50,
            start_ms=0,
            end_ms=2000,
            start_timestamp="00:00:00.000",
            end_timestamp="00:00:02.000",
            num_windows=5,
            top_glosses=[
                {"rank": 1, "gloss": "HELLO", "similarity": 0.95},
                {"rank": 2, "gloss": "HI", "similarity": 0.88},
            ],
        )

    def test_to_dict_has_all_fields(self):
        match = self._make_match()
        d = match.to_dict()
        expected_keys = {
            "segment_id",
            "video_id",
            "start_frame",
            "end_frame",
            "start_ms",
            "end_ms",
            "start_timestamp",
            "end_timestamp",
            "num_windows",
            "top_glosses",
        }
        assert set(d.keys()) == expected_keys

    def test_to_dict_values_match(self):
        match = self._make_match()
        d = match.to_dict()
        assert d["segment_id"] == 1
        assert d["video_id"] == "test_video"
        assert d["start_frame"] == 0
        assert d["end_frame"] == 50
        assert d["num_windows"] == 5
        assert len(d["top_glosses"]) == 2
        assert d["top_glosses"][0]["rank"] == 1

    def test_to_dict_empty_top_glosses(self):
        from sltk.embedding.pipeline import SegmentMatch

        match = SegmentMatch(
            segment_id=99,
            video_id="v",
            start_frame=0,
            end_frame=10,
            start_ms=0,
            end_ms=400,
            start_timestamp="",
            end_timestamp="",
            num_windows=0,
            top_glosses=[],
        )
        d = match.to_dict()
        assert d["top_glosses"] == []
        assert d["num_windows"] == 0


class TestSpottingResult:
    """Test SpottingResult serialization to JSON and dict."""

    def _make_result(self):
        from sltk.embedding.pipeline import SegmentMatch, SpottingResult

        seg = SegmentMatch(
            segment_id=1,
            video_id="video_A",
            start_frame=0,
            end_frame=50,
            start_ms=0,
            end_ms=2000,
            start_timestamp="00:00:00",
            end_timestamp="00:00:02",
            num_windows=3,
            top_glosses=[
                {"rank": 1, "gloss": "HELLO", "similarity": 0.9},
            ],
        )
        return SpottingResult(
            segments=[seg],
            metadata={"dictionary_size": 1000, "top_k": 5},
        )

    def test_save_load_json_roundtrip(self, tmp_path):
        result = self._make_result()
        out = tmp_path / "results.json"
        result.save_json(out)
        assert out.exists()

        with open(out) as f:
            loaded = json.load(f)

        assert "metadata" in loaded
        assert "segments" in loaded
        assert loaded["metadata"]["dictionary_size"] == 1000
        assert len(loaded["segments"]) == 1
        assert loaded["segments"][0]["video_id"] == "video_A"

    def test_save_json_creates_parent_directories(self, tmp_path):
        result = self._make_result()
        deep_path = tmp_path / "nested" / "dir" / "results.json"
        result.save_json(deep_path)
        assert deep_path.exists()

    def test_to_dict_structure(self):
        result = self._make_result()
        d = result.to_dict()
        assert "metadata" in d
        assert "segments" in d
        assert isinstance(d["segments"], list)
        assert d["segments"][0]["segment_id"] == 1

    def test_empty_segments(self, tmp_path):
        from sltk.embedding.pipeline import SpottingResult

        result = SpottingResult(segments=[], metadata={"top_k": 10})
        out = tmp_path / "empty.json"
        result.save_json(out)
        with open(out) as f:
            loaded = json.load(f)
        assert loaded["segments"] == []


# ---------------------------------------------------------------------------
# Pooling method tests
# ---------------------------------------------------------------------------


class TestPoolingMethods:
    """Test all dictionary pooling strategies with synthetic tensors."""

    @pytest.fixture
    def features_10x768(self):
        """10 windows of 768-dim features, seeded for reproducibility."""
        torch.manual_seed(42)
        return torch.randn(10, 768)

    @pytest.fixture
    def active_probs_10(self):
        """Activity probabilities for 10 windows."""
        torch.manual_seed(42)
        return torch.rand(10)

    def test_pool_middle_shape(self, features_10x768):
        from sltk.embedding.pipeline import pool_middle

        result = pool_middle(features_10x768)
        assert result.shape == (768,), f"Expected (768,), got {result.shape}"

    def test_pool_middle_selects_center(self, features_10x768):
        from sltk.embedding.pipeline import pool_middle

        result = pool_middle(features_10x768)
        expected = features_10x768[5]  # 10 // 2 = 5
        assert torch.equal(result, expected), "pool_middle did not select index 5"

    def test_pool_middle_odd_count(self):
        from sltk.embedding.pipeline import pool_middle

        features = torch.randn(7, 768)
        result = pool_middle(features)
        expected = features[3]  # 7 // 2 = 3
        assert torch.equal(result, expected)

    def test_pool_middle_single_window(self):
        from sltk.embedding.pipeline import pool_middle

        features = torch.randn(1, 768)
        result = pool_middle(features)
        assert torch.equal(result, features[0])

    def test_pool_max_shape(self, features_10x768):
        from sltk.embedding.pipeline import pool_max

        result = pool_max(features_10x768)
        assert result.shape == (768,)

    def test_pool_max_values(self, features_10x768):
        from sltk.embedding.pipeline import pool_max

        result = pool_max(features_10x768)
        expected = features_10x768.max(dim=0).values
        assert torch.allclose(result, expected)

    def test_pool_mean_shape(self, features_10x768):
        from sltk.embedding.pipeline import pool_mean

        result = pool_mean(features_10x768)
        assert result.shape == (768,)

    def test_pool_mean_values(self, features_10x768):
        from sltk.embedding.pipeline import pool_mean

        result = pool_mean(features_10x768)
        expected = features_10x768.mean(dim=0)
        assert torch.allclose(result, expected)

    def test_all_pooling_methods_registered(self):
        from sltk.embedding.pipeline import POOLING_METHODS

        expected = {
            "middle",
            "max_pool",
            "mean_pool",
        }
        assert (
            set(POOLING_METHODS.keys()) == expected
        ), f"POOLING_METHODS keys mismatch: {set(POOLING_METHODS.keys())}"


class TestSegmentPooling:
    """Test segment-level pooling for spotting mode."""

    @pytest.fixture
    def sims_5x100(self):
        """Similarity matrix: 5 windows x 100 dictionary entries."""
        torch.manual_seed(42)
        return torch.randn(5, 100)

    def test_segment_pool_max_shape(self, sims_5x100):
        from sltk.embedding.pipeline import segment_pool_max

        result = segment_pool_max(sims_5x100)
        assert result.shape == (100,)

    def test_segment_pool_max_values(self, sims_5x100):
        from sltk.embedding.pipeline import segment_pool_max

        result = segment_pool_max(sims_5x100)
        expected = sims_5x100.max(dim=0).values
        assert torch.allclose(result, expected)

    def test_segment_pool_mean_shape(self, sims_5x100):
        from sltk.embedding.pipeline import segment_pool_mean

        result = segment_pool_mean(sims_5x100)
        assert result.shape == (100,)

    def test_segment_pool_mean_values(self, sims_5x100):
        from sltk.embedding.pipeline import segment_pool_mean

        result = segment_pool_mean(sims_5x100)
        expected = sims_5x100.mean(dim=0)
        assert torch.allclose(result, expected)

    def test_segment_pool_softmax_shape(self, sims_5x100):
        from sltk.embedding.pipeline import segment_pool_softmax

        result = segment_pool_softmax(sims_5x100)
        assert result.shape == (100,)

    def test_segment_pool_softmax_single_window(self):
        from sltk.embedding.pipeline import segment_pool_softmax

        sims = torch.randn(1, 50)
        result = segment_pool_softmax(sims)
        # single window: softmax weight is 1.0, so result = sims * 1.0
        assert torch.allclose(result, sims[0])

    def test_all_segment_pooling_registered(self):
        from sltk.embedding.pipeline import SEGMENT_POOLING

        expected = {"max", "mean", "softmax_weighted"}
        assert set(SEGMENT_POOLING.keys()) == expected


# ---------------------------------------------------------------------------
# Helper function tests
# ---------------------------------------------------------------------------


class TestParseGlossName:
    """Test the parse_gloss_name utility."""

    def test_with_numeric_suffix(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("ADDRESS2-5675") == "ADDRESS2"

    def test_without_suffix(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("SIMPLE") == "SIMPLE"

    def test_all_numeric(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("100") == "100"

    def test_hyphenated_gloss_with_suffix(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("LOOK-AT-1234") == "LOOK-AT"

    def test_single_char_gloss(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("A-99") == "A"

    def test_empty_string(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("") == ""

    def test_suffix_is_zero(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("HELLO-0") == "HELLO"

    def test_no_digits_after_hyphen(self):
        from sltk.embedding.pipeline import parse_gloss_name

        assert parse_gloss_name("HELLO-WORLD") == "HELLO-WORLD"

    def test_mixed_letters_digits_after_hyphen(self):
        from sltk.embedding.pipeline import parse_gloss_name

        # "X2" is not all digits -> no strip
        assert parse_gloss_name("FOO-X2") == "FOO-X2"


class TestFindOverlappingWindows:
    """Test vectorized window overlap detection."""

    def test_full_overlap(self):
        from sltk.embedding.pipeline import find_overlapping_windows

        frame_indices = np.array([0, 4, 8, 12, 16])
        segment_length = 16
        # Segment covers entire range
        result = find_overlapping_windows(frame_indices, segment_length, 0, 100)
        np.testing.assert_array_equal(result, np.array([0, 1, 2, 3, 4]))

    def test_no_overlap(self):
        from sltk.embedding.pipeline import find_overlapping_windows

        frame_indices = np.array([0, 4, 8, 12])
        segment_length = 16
        # Segment starts after all windows end
        result = find_overlapping_windows(frame_indices, segment_length, 100, 200)
        assert len(result) == 0

    def test_partial_overlap(self):
        from sltk.embedding.pipeline import find_overlapping_windows

        frame_indices = np.array([0, 4, 8, 12, 16, 20])
        segment_length = 16
        # Segment from frame 10 to 20: overlaps windows starting at 0..16
        result = find_overlapping_windows(frame_indices, segment_length, 10, 20)
        # Window [0, 16) overlaps [10,20): yes (0 < 20 and 16 > 10)
        # Window [4, 20) overlaps [10,20): yes
        # Window [8, 24) overlaps [10,20): yes
        # Window [12, 28) overlaps: yes
        # Window [16, 32) overlaps: yes (16 < 20 and 32 > 10)
        # Window [20, 36) overlaps: no (20 < 20 is False)
        expected = np.array([0, 1, 2, 3, 4])
        np.testing.assert_array_equal(result, expected)

    def test_single_frame_segment(self):
        from sltk.embedding.pipeline import find_overlapping_windows

        frame_indices = np.array([0, 4, 8])
        segment_length = 16
        # seg_start=5, seg_end=6: a single-frame segment
        # [0,16) overlaps [5,6): yes
        # [4,20) overlaps [5,6): yes
        # [8,24) overlaps [5,6): no (8 < 6 is False)
        result = find_overlapping_windows(frame_indices, segment_length, 5, 6)
        np.testing.assert_array_equal(result, np.array([0, 1]))

    def test_empty_frame_indices(self):
        from sltk.embedding.pipeline import find_overlapping_windows

        frame_indices = np.array([], dtype=np.int64)
        result = find_overlapping_windows(frame_indices, 16, 0, 100)
        assert len(result) == 0


class TestFrameToMs:
    """Test frame index to milliseconds conversion."""

    def test_zero_frame(self):
        from sltk.embedding.pipeline import frame_to_ms

        assert frame_to_ms(0, 25.0) == 0

    def test_one_second_at_25fps(self):
        from sltk.embedding.pipeline import frame_to_ms

        assert frame_to_ms(25, 25.0) == 1000

    def test_one_second_at_30fps(self):
        from sltk.embedding.pipeline import frame_to_ms

        assert frame_to_ms(30, 30.0) == 1000

    def test_fractional_result_rounds(self):
        from sltk.embedding.pipeline import frame_to_ms

        # frame 1 at 30fps = 33.33... ms -> rounds to 33
        assert frame_to_ms(1, 30.0) == 33

    def test_large_frame_index(self):
        from sltk.embedding.pipeline import frame_to_ms

        # 90000 frames at 25 fps = 3600000 ms (1 hour)
        assert frame_to_ms(90000, 25.0) == 3600000


class TestLoadSegments:
    """Test load_segments JSON loader and grouping."""

    def test_load_and_group_by_video_id(self, tmp_path):
        from sltk.embedding.pipeline import load_segments

        segments = [
            {
                "segment_id": 1,
                "original_video_id": "video_A",
                "start_frame": 0,
                "end_frame": 50,
            },
            {
                "segment_id": 2,
                "original_video_id": "video_B",
                "start_frame": 0,
                "end_frame": 100,
            },
            {
                "segment_id": 3,
                "original_video_id": "video_A",
                "start_frame": 50,
                "end_frame": 120,
            },
        ]
        path = tmp_path / "segments.json"
        with open(path, "w") as f:
            json.dump(segments, f)

        result = load_segments(str(path))
        assert set(result.keys()) == {"video_A", "video_B"}
        assert len(result["video_A"]) == 2
        assert len(result["video_B"]) == 1

    def test_segments_sorted_by_id(self, tmp_path):
        from sltk.embedding.pipeline import load_segments

        segments = [
            {
                "segment_id": 3,
                "original_video_id": "v",
                "start_frame": 100,
                "end_frame": 150,
            },
            {
                "segment_id": 1,
                "original_video_id": "v",
                "start_frame": 0,
                "end_frame": 50,
            },
        ]
        path = tmp_path / "segs.json"
        with open(path, "w") as f:
            json.dump(segments, f)

        result = load_segments(str(path))
        assert result["v"][0]["segment_id"] == 1
        assert result["v"][1]["segment_id"] == 3

    def test_empty_segments_file(self, tmp_path):
        from sltk.embedding.pipeline import load_segments

        path = tmp_path / "empty.json"
        with open(path, "w") as f:
            json.dump([], f)

        result = load_segments(str(path))
        assert len(result) == 0


# ---------------------------------------------------------------------------
# FrameTransform tests
# ---------------------------------------------------------------------------


class TestFrameTransform:
    """Test FrameTransform preprocessing."""

    def test_output_shape_standard(self):
        from sltk.embedding.pipeline import FrameTransform

        ft = FrameTransform()
        frame = np.random.default_rng(42).integers(0, 255, (480, 640, 3), dtype=np.uint8)
        result = ft(frame)
        assert result.shape == (3, 224, 224), f"Expected (3, 224, 224), got {result.shape}"

    def test_output_dtype_float32(self):
        from sltk.embedding.pipeline import FrameTransform

        ft = FrameTransform()
        frame = np.random.default_rng(42).integers(0, 255, (480, 640, 3), dtype=np.uint8)
        result = ft(frame)
        assert result.dtype == torch.float32

    def test_output_normalized_range(self):
        from sltk.embedding.pipeline import FrameTransform

        ft = FrameTransform()
        # All-white frame -> after ImageNet norm, values should be finite
        frame = np.full((100, 100, 3), 255, dtype=np.uint8)
        result = ft(frame)
        assert torch.isfinite(result).all()

    def test_square_input(self):
        from sltk.embedding.pipeline import FrameTransform

        ft = FrameTransform()
        frame = np.zeros((224, 224, 3), dtype=np.uint8)
        result = ft(frame)
        assert result.shape == (3, 224, 224)

    def test_small_input_upscaled(self):
        from sltk.embedding.pipeline import FrameTransform

        ft = FrameTransform()
        frame = np.zeros((32, 32, 3), dtype=np.uint8)
        result = ft(frame)
        assert result.shape == (3, 224, 224)


# ---------------------------------------------------------------------------
# VideoSegmentDataset start indices (no real video needed)
# ---------------------------------------------------------------------------


class TestVideoSegmentDatasetStartIndices:
    """Test _compute_start_indices logic via a minimal mock."""

    def test_normal_video(self):
        """100 frames, stride=4, segment_length=16: indices from 0 to 84."""
        from sltk.embedding.pipeline import VideoSegmentDataset

        with patch.object(VideoSegmentDataset, "__init__", lambda self: None):
            ds = VideoSegmentDataset.__new__(VideoSegmentDataset)
            ds.total_frames = 100
            ds.stride = 4
            ds.segment_length = 16
            ds.pad_short = True
            indices = ds._compute_start_indices()

        # Range: 0, 4, 8, ..., 84
        assert indices[0] == 0
        assert indices[-1] == 84  # last_valid = 100 - 16 = 84
        # Check monotonically increasing
        assert all(a < b for a, b in zip(indices, indices[1:]))

    def test_short_video_padded(self):
        """Video shorter than segment_length with pad_short=True."""
        from sltk.embedding.pipeline import VideoSegmentDataset

        with patch.object(VideoSegmentDataset, "__init__", lambda self: None):
            ds = VideoSegmentDataset.__new__(VideoSegmentDataset)
            ds.total_frames = 10
            ds.stride = 4
            ds.segment_length = 16
            ds.pad_short = True
            indices = ds._compute_start_indices()

        assert indices == [0], f"Expected [0], got {indices}"

    def test_short_video_no_pad(self):
        """Video shorter than segment_length with pad_short=False."""
        from sltk.embedding.pipeline import VideoSegmentDataset

        with patch.object(VideoSegmentDataset, "__init__", lambda self: None):
            ds = VideoSegmentDataset.__new__(VideoSegmentDataset)
            ds.total_frames = 10
            ds.stride = 4
            ds.segment_length = 16
            ds.pad_short = False
            indices = ds._compute_start_indices()

        assert indices == [], f"Expected [], got {indices}"

    def test_zero_frames(self):
        from sltk.embedding.pipeline import VideoSegmentDataset

        with patch.object(VideoSegmentDataset, "__init__", lambda self: None):
            ds = VideoSegmentDataset.__new__(VideoSegmentDataset)
            ds.total_frames = 0
            ds.stride = 4
            ds.segment_length = 16
            ds.pad_short = True
            indices = ds._compute_start_indices()

        assert indices == []

    def test_exact_segment_length(self):
        """Video exactly segment_length frames."""
        from sltk.embedding.pipeline import VideoSegmentDataset

        with patch.object(VideoSegmentDataset, "__init__", lambda self: None):
            ds = VideoSegmentDataset.__new__(VideoSegmentDataset)
            ds.total_frames = 16
            ds.stride = 4
            ds.segment_length = 16
            ds.pad_short = True
            indices = ds._compute_start_indices()

        assert indices == [0]

    def test_stride1_includes_last(self):
        """Stride=1, 20 frames: should include index 4 as last_valid."""
        from sltk.embedding.pipeline import VideoSegmentDataset

        with patch.object(VideoSegmentDataset, "__init__", lambda self: None):
            ds = VideoSegmentDataset.__new__(VideoSegmentDataset)
            ds.total_frames = 20
            ds.stride = 1
            ds.segment_length = 16
            ds.pad_short = True
            indices = ds._compute_start_indices()

        # last_valid = 20 - 16 = 4
        assert indices[-1] == 4
        assert len(indices) == 5  # 0, 1, 2, 3, 4


# ---------------------------------------------------------------------------
# Pipeline constructor tests (mocked model)
# ---------------------------------------------------------------------------


class TestSignRepPipelineInit:
    """Test pipeline initialization with mocked model loading."""

    @patch("sltk.embedding.pipeline.load_model")
    def test_cuda_fallback_to_cpu(self, mock_load_model):
        """When CUDA unavailable, should fall back to CPU."""
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        with patch("torch.cuda.is_available", return_value=False):
            from sltk.embedding.pipeline import SignRepPipeline

            pipe = SignRepPipeline(checkpoint="/fake/ckpt.pt", device="cuda")

        assert str(pipe.device) == "cpu"
        assert pipe.amp is False  # AMP disabled on CPU

    @patch("sltk.embedding.pipeline.load_model")
    def test_explicit_cpu(self, mock_load_model):
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        from sltk.embedding.pipeline import SignRepPipeline

        pipe = SignRepPipeline(checkpoint="/fake/ckpt.pt", device="cpu")
        assert str(pipe.device) == "cpu"
        assert pipe.amp is False

    @patch("sltk.embedding.pipeline.load_model")
    def test_default_params(self, mock_load_model):
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        from sltk.embedding.pipeline import SignRepPipeline

        pipe = SignRepPipeline(checkpoint="/fake/ckpt.pt", device="cpu")
        assert pipe.batch_size == 8
        assert pipe.num_workers == 4
        assert pipe.preload is False
        assert pipe.active_index == 1

    @patch("sltk.embedding.pipeline.load_model")
    def test_checkpoint_none_requires_weights(self, mock_load_model):
        """When checkpoint=None, should use get_weight_path; FileNotFoundError if missing."""
        from sltk.embedding.pipeline import SignRepPipeline

        with patch("sltk.extraction.weights.get_weight_path", return_value=None):
            with pytest.raises(FileNotFoundError, match="SignRep checkpoint not found"):
                SignRepPipeline(checkpoint=None, device="cpu")

    @patch("sltk.embedding.pipeline.load_model")
    def test_checkpoint_none_resolves_from_weights(self, mock_load_model):
        """When checkpoint=None, should use path from get_weight_path."""
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        from sltk.embedding.pipeline import SignRepPipeline

        fake_path = Path("/resolved/ckpt.pt")
        with patch("sltk.extraction.weights.get_weight_path", return_value=fake_path):
            pipe = SignRepPipeline(checkpoint=None, device="cpu")

        mock_load_model.assert_called_once_with(str(fake_path), pipe.device)


# ---------------------------------------------------------------------------
# get_pipeline thread-safe singleton tests
# ---------------------------------------------------------------------------


class TestGetPipeline:
    """Test the thread-safe get_pipeline singleton."""

    def _reset_singleton(self):
        """Reset the module-level singleton state."""
        import sltk.embedding.pipeline as mod

        mod._pipeline = None
        mod._pipeline_error = None

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_returns_same_instance(self, mock_cls):
        self._reset_singleton()
        mock_instance = MagicMock()
        mock_cls.return_value = mock_instance

        from sltk.embedding.pipeline import get_pipeline

        try:
            p1 = get_pipeline(device="cpu")
            p2 = get_pipeline(device="cpu")
            assert p1 is p2, "Singleton should return same instance"
        finally:
            self._reset_singleton()

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_construction_failure_cached(self, mock_cls):
        self._reset_singleton()
        mock_cls.side_effect = RuntimeError("GPU on fire")

        from sltk.embedding.pipeline import get_pipeline

        try:
            with pytest.raises(RuntimeError, match="GPU on fire"):
                get_pipeline(device="cpu")
            # Subsequent calls should raise immediately from cached error
            with pytest.raises(RuntimeError, match="Pipeline init failed"):
                get_pipeline(device="cpu")
        finally:
            self._reset_singleton()

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_thread_safety(self, mock_cls):
        """Verify only one pipeline instance is created from multiple threads."""
        self._reset_singleton()
        mock_instance = MagicMock()
        mock_cls.return_value = mock_instance

        from sltk.embedding.pipeline import get_pipeline

        results = []
        errors = []

        def worker():
            try:
                results.append(get_pipeline(device="cpu"))
            except Exception as e:
                errors.append(e)

        try:
            threads = [threading.Thread(target=worker) for _ in range(10)]
            for t in threads:
                t.start()
            for t in threads:
                t.join()

            assert not errors, f"Threads raised errors: {errors}"
            assert all(r is results[0] for r in results), "All threads should get the same instance"
            assert (
                mock_cls.call_count == 1
            ), f"Expected 1 construction call, got {mock_cls.call_count}"
        finally:
            self._reset_singleton()


# ---------------------------------------------------------------------------
# Model loading / checkpoint remapping
# ---------------------------------------------------------------------------


class TestRemapClassifierCheckpoint:
    """Test _remap_classifier_checkpoint key remapping."""

    def test_strips_backbone_prefix(self):
        from sltk.embedding.pipeline import _remap_classifier_checkpoint

        state = {
            "backbone.backbone.layer1.weight": torch.tensor([1.0]),
            "backbone.fc.weight": torch.tensor([2.0]),
            "classifier.weight": torch.tensor([3.0]),
        }
        remapped = _remap_classifier_checkpoint(state)
        assert "backbone.layer1.weight" in remapped
        assert "fc.weight" in remapped
        assert "classifier.weight" not in remapped

    def test_skips_non_backbone_keys(self):
        from sltk.embedding.pipeline import _remap_classifier_checkpoint

        state = {
            "head.weight": torch.tensor([1.0]),
            "head.bias": torch.tensor([0.0]),
        }
        remapped = _remap_classifier_checkpoint(state)
        assert len(remapped) == 0


# ---------------------------------------------------------------------------
# DictionaryIndex loading from .pt and .npz (mocked filesystem)
# ---------------------------------------------------------------------------


class TestLoadDictionary:
    """Test SignRepPipeline.load_dictionary with synthetic data."""

    @patch("sltk.embedding.pipeline.load_model")
    def _make_pipeline(self, mock_load_model):
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        from sltk.embedding.pipeline import SignRepPipeline

        return SignRepPipeline(checkpoint="/fake/ckpt.pt", device="cpu")

    def test_load_npz_directory(self, tmp_path):
        """Load dictionary from a directory of .npz files."""
        pipe = self._make_pipeline()

        rng = np.random.default_rng(42)
        npz_dir = tmp_path / "dict_npz"
        npz_dir.mkdir()
        for i in range(5):
            feat = rng.standard_normal(768).astype(np.float32)
            np.savez(npz_dir / f"GLOSS{i}-{1000+i}.npz", best_latent=feat)

        index = pipe.load_dictionary([str(npz_dir)])
        assert len(index) == 5
        assert index.features.shape == (5, 768)
        # Verify gloss name parsing stripped suffix
        assert index.glosses[0] == "GLOSS0"

    def test_load_pt_file(self, tmp_path):
        """Load dictionary from a .pt consolidated file."""
        pipe = self._make_pipeline()

        feats = torch.randn(10, 768)
        names = [f"SIGN_{i}" for i in range(10)]
        pt_path = tmp_path / "dict.pt"
        torch.save(
            {"features": feats, "filenames": names, "source_dir": "test_src"},
            pt_path,
        )

        index = pipe.load_dictionary([str(pt_path)])
        assert len(index) == 10
        assert index.features.shape == (10, 768)
        assert all(sd == "test_src" for sd in index.source_dirs)

    def test_load_mixed_sources(self, tmp_path):
        """Load from both .pt and .npz directory."""
        pipe = self._make_pipeline()

        # .pt file with 3 entries
        pt_path = tmp_path / "consolidated.pt"
        torch.save(
            {
                "features": torch.randn(3, 768),
                "filenames": ["A", "B", "C"],
            },
            pt_path,
        )

        # .npz directory with 2 entries
        npz_dir = tmp_path / "loose"
        npz_dir.mkdir()
        for name in ["D-100", "E-200"]:
            np.savez(npz_dir / f"{name}.npz", best_latent=np.random.randn(768))

        index = pipe.load_dictionary([str(pt_path), str(npz_dir)])
        assert len(index) == 5

    def test_load_empty_raises(self, tmp_path):
        """Loading from empty directory should raise FileNotFoundError."""
        pipe = self._make_pipeline()
        empty_dir = tmp_path / "empty"
        empty_dir.mkdir()

        with pytest.raises(FileNotFoundError, match="No valid dictionary entries"):
            pipe.load_dictionary([str(empty_dir)])

    def test_features_l2_normalized(self, tmp_path):
        """Dictionary features should be L2-normalized after loading."""
        pipe = self._make_pipeline()

        npz_dir = tmp_path / "dict"
        npz_dir.mkdir()
        # Create features with varying norms
        for i in range(3):
            feat = np.random.randn(768).astype(np.float32) * (i + 1)
            np.savez(npz_dir / f"G{i}.npz", best_latent=feat)

        index = pipe.load_dictionary([str(npz_dir)])
        norms = torch.norm(index.features, dim=1)
        assert torch.allclose(norms, torch.ones(3), atol=1e-5)

    def test_npz_2d_feature_averaged(self, tmp_path):
        """When .npz has 2D feature array, it should be mean-reduced to 1D."""
        pipe = self._make_pipeline()

        npz_dir = tmp_path / "dict"
        npz_dir.mkdir()
        feat_2d = np.random.randn(5, 768).astype(np.float32)
        np.savez(npz_dir / "MULTI.npz", best_latent=feat_2d)

        index = pipe.load_dictionary([str(npz_dir)])
        assert len(index) == 1
        assert index.features.shape == (1, 768)

    def test_npz_fallback_keys(self, tmp_path):
        """Should try multiple keys: best_latent, best_feature, features."""
        pipe = self._make_pipeline()

        npz_dir = tmp_path / "dict"
        npz_dir.mkdir()
        feat = np.random.randn(768).astype(np.float32)
        # Save with a non-standard key plus the fallback key "features"
        np.savez(npz_dir / "G.npz", features=feat)

        index = pipe.load_dictionary([str(npz_dir)])
        assert len(index) == 1


# ---------------------------------------------------------------------------
# spot() method with synthetic data
# ---------------------------------------------------------------------------


class TestSpotMethod:
    """Test spot() with in-memory features and dictionary."""

    @patch("sltk.embedding.pipeline.load_model")
    def _make_pipeline(self, mock_load_model):
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        from sltk.embedding.pipeline import SignRepPipeline

        return SignRepPipeline(checkpoint="/fake/ckpt.pt", device="cpu")

    def _make_continuous(self, n_windows=20, fps=25.0, stride=4):
        from sltk.embedding.pipeline import ContinuousResult

        rng = np.random.default_rng(42)
        features = rng.standard_normal((n_windows, 768)).astype(np.float32)
        # L2-normalize
        norms = np.linalg.norm(features, axis=1, keepdims=True)
        features = features / norms
        return ContinuousResult(
            features=features,
            active_probs=rng.random(n_windows).astype(np.float32),
            frame_indices=np.arange(0, n_windows * stride, stride, dtype=np.int64),
            fps=fps,
            total_frames=n_windows * stride + 16,
            stride=stride,
        )

    def _make_dictionary(self, n_dict=50, dim=768):
        from sltk.embedding.pipeline import DictionaryIndex

        feats = torch.randn(n_dict, dim)
        feats = torch.nn.functional.normalize(feats, dim=1)
        return DictionaryIndex(
            features=feats,
            glosses=[f"GLOSS_{i}" for i in range(n_dict)],
            filenames=[f"file_{i}" for i in range(n_dict)],
            source_dirs=["src"] * n_dict,
            file_paths=[f"src/file_{i}.npz" for i in range(n_dict)],
        )

    def test_spot_with_continuous_result(self):
        pipe = self._make_pipeline()
        continuous = self._make_continuous()
        dictionary = self._make_dictionary()

        segments = [
            {
                "segment_id": 1,
                "start_frame": 0,
                "end_frame": 40,
                "original_video_id": "test_vid",
            },
            {
                "segment_id": 2,
                "start_frame": 40,
                "end_frame": 80,
                "original_video_id": "test_vid",
            },
        ]

        result = pipe.spot(continuous, segments, dictionary, top_k=5)
        assert len(result.segments) == 2
        assert result.metadata["dictionary_size"] == 50
        assert result.metadata["top_k"] == 5

    def test_spot_topk_results(self):
        pipe = self._make_pipeline()
        continuous = self._make_continuous()
        dictionary = self._make_dictionary()

        segments = [
            {
                "segment_id": 1,
                "start_frame": 0,
                "end_frame": 80,
                "original_video_id": "v",
            },
        ]

        result = pipe.spot(continuous, segments, dictionary, top_k=3)
        seg = result.segments[0]
        assert len(seg.top_glosses) <= 3
        if seg.top_glosses:
            assert seg.top_glosses[0]["rank"] == 1
            # Similarities should be descending
            sims = [g["similarity"] for g in seg.top_glosses]
            assert sims == sorted(sims, reverse=True)

    def test_spot_no_overlap_segment(self):
        pipe = self._make_pipeline()
        continuous = self._make_continuous(n_windows=5, stride=4)
        dictionary = self._make_dictionary(n_dict=10)

        # Segment far beyond the extracted range
        segments = [
            {
                "segment_id": 99,
                "start_frame": 10000,
                "end_frame": 20000,
                "original_video_id": "v",
            },
        ]

        result = pipe.spot(continuous, segments, dictionary, top_k=5)
        assert len(result.segments) == 1
        assert result.segments[0].num_windows == 0
        assert result.segments[0].top_glosses == []

    def test_spot_invalid_segment_pooling(self):
        pipe = self._make_pipeline()
        continuous = self._make_continuous()
        dictionary = self._make_dictionary()

        with pytest.raises(ValueError, match="Unknown segment pooling"):
            pipe.spot(
                continuous,
                [{"segment_id": 1, "start_frame": 0, "end_frame": 50}],
                dictionary,
                segment_pooling="nonexistent",
            )

    def test_spot_with_numpy_features(self):
        """spot() should accept raw numpy array as features."""
        pipe = self._make_pipeline()
        dictionary = self._make_dictionary(n_dict=10)
        features = np.random.randn(10, 768).astype(np.float32)

        segments = [
            {"segment_id": 1, "start_frame": 0, "end_frame": 40},
        ]

        result = pipe.spot(features, segments, dictionary, top_k=3)
        assert len(result.segments) == 1

    def test_spot_from_npz_file(self, tmp_path):
        """spot() should accept a path to .npz file as features."""
        pipe = self._make_pipeline()
        dictionary = self._make_dictionary(n_dict=10)

        rng = np.random.default_rng(42)
        feats = rng.standard_normal((10, 768)).astype(np.float32)
        frame_indices = np.arange(0, 40, 4, dtype=np.int64)
        npz_path = tmp_path / "features.npz"
        np.savez(npz_path, latents=feats, frame_indices=frame_indices, fps=25.0)

        segments = [
            {"segment_id": 1, "start_frame": 0, "end_frame": 40},
        ]

        result = pipe.spot(str(npz_path), segments, dictionary, top_k=3)
        assert len(result.segments) == 1

    def test_spot_invalid_features_type(self):
        pipe = self._make_pipeline()
        dictionary = self._make_dictionary()

        with pytest.raises(TypeError, match="Unsupported features type"):
            pipe.spot(12345, [], dictionary)

    def test_spot_all_segment_pooling_methods(self):
        """All registered segment pooling methods should produce valid results."""
        from sltk.embedding.pipeline import SEGMENT_POOLING

        pipe = self._make_pipeline()
        continuous = self._make_continuous()
        dictionary = self._make_dictionary(n_dict=10)
        segments = [
            {
                "segment_id": 1,
                "start_frame": 0,
                "end_frame": 80,
                "original_video_id": "v",
            },
        ]

        for method_name in SEGMENT_POOLING:
            result = pipe.spot(
                continuous, segments, dictionary, top_k=3, segment_pooling=method_name
            )
            assert len(result.segments) == 1, f"Segment pooling '{method_name}' failed"


# ---------------------------------------------------------------------------
# extract_dictionary error paths (mocked)
# ---------------------------------------------------------------------------


class TestExtractDictionaryErrors:
    """Test error handling in extract_dictionary with mocked internals."""

    @patch("sltk.embedding.pipeline.load_model")
    def _make_pipeline(self, mock_load_model):
        mock_model = MagicMock()
        mock_load_model.return_value = mock_model

        from sltk.embedding.pipeline import SignRepPipeline

        return SignRepPipeline(checkpoint="/fake/ckpt.pt", device="cpu")

    def test_invalid_pooling_method(self):
        pipe = self._make_pipeline()
        # Mock _extract_batched to return valid data
        pipe._extract_batched = MagicMock(
            return_value=(
                torch.randn(5, 768),
                torch.rand(5),
                torch.arange(5),
                25.0,
                100,
            )
        )

        with pytest.raises(ValueError, match="Unknown pooling method"):
            pipe.extract_dictionary("/fake/video.mp4", method="nonexistent")

    def test_empty_video_raises(self):
        pipe = self._make_pipeline()
        # Mock _extract_batched to return None (no segments)
        pipe._extract_batched = MagicMock(return_value=None)

        with pytest.raises(RuntimeError, match="No valid segments"):
            pipe.extract_dictionary("/fake/video.mp4")

    def test_default_pooling_method(self):
        """Default pooling should be 'middle'."""
        pipe = self._make_pipeline()

        features = torch.randn(10, 768)
        pipe._extract_batched = MagicMock(
            return_value=(features, None, torch.arange(10), 25.0, 100)
        )

        result = pipe.extract_dictionary("/fake/video.mp4")
        assert result.pooling_method == "middle"


# ---------------------------------------------------------------------------
# _build_metadata
# ---------------------------------------------------------------------------


class TestBuildMetadata:
    """Test metadata builder for spotting results."""

    def test_metadata_structure(self):
        from sltk.embedding.pipeline import DictionaryIndex, SignRepPipeline

        idx = DictionaryIndex(
            features=torch.randn(10, 768),
            glosses=["A"] * 10,
            filenames=["f"] * 10,
            source_dirs=["dir1", "dir2", "dir1"] + ["dir2"] * 7,
            file_paths=["p"] * 10,
        )
        meta = SignRepPipeline._build_metadata(idx, top_k=5, segment_pooling="max")
        assert meta["dictionary_size"] == 10
        assert meta["top_k"] == 5
        assert meta["segment_pooling"] == "max"
        assert meta["feature_dim"] == 768
        # source dirs should be deduplicated preserving order
        assert meta["dictionary_dirs"] == ["dir1", "dir2"]


# ---------------------------------------------------------------------------
# Backward compatibility: SignRepEmbedder (delegates to SignRepPipeline)
# ---------------------------------------------------------------------------


class TestSignRepEmbedderBackwardCompat:
    """Test SignRepEmbedder which delegates to SignRepPipeline.

    embed_video() maps old aggregation names to pipeline pooling methods.
    embed_frames() runs frame-level transform + model.inference_forward.
    """

    def test_lazy_load_default(self):
        """Pipeline should not be loaded until first use."""
        from sltk.embedding.signrep import SignRepEmbedder

        embedder = SignRepEmbedder(lazy_load=True)
        assert not embedder.is_loaded()

    def test_embedding_dim(self):
        from sltk.embedding.signrep import SignRepEmbedder

        embedder = SignRepEmbedder(lazy_load=True)
        assert embedder.embedding_dim == 768

    def test_config_defaults(self):
        from sltk.embedding.signrep import SignRepConfig

        config = SignRepConfig()
        assert config.out_dim == 768
        assert config.segment_length == 16
        assert config.stride == 2
        assert config.mask_ratio == 0.0
        assert config.device == "cuda"
        assert config.batch_size == 8
        assert config.num_workers == 4
        assert config.amp is True
        assert config.preload is False

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_video_returns_768_array(self, mock_pipeline_cls):
        from sltk.embedding.pipeline import DictionaryResult
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline_cls.return_value = mock_pipeline

        feature = np.random.default_rng(42).standard_normal(768).astype(np.float32)
        mock_pipeline.extract_dictionary.return_value = DictionaryResult(
            feature=feature,
            all_features=np.random.default_rng(42).standard_normal((5, 768)).astype(np.float32),
            active_probs=np.random.default_rng(42).random(5).astype(np.float32),
            frame_indices=np.arange(5),
            fps=25.0,
            total_frames=100,
            pooling_method="mean_pool",
            source_path="/test.mp4",
        )

        embedder = SignRepEmbedder(lazy_load=True)
        result = embedder.embed_video("/test.mp4", aggregation="mean")
        assert isinstance(result, np.ndarray)
        assert result.shape == (768,)
        np.testing.assert_array_equal(result, feature)

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_aggregation_mapping(self, mock_pipeline_cls):
        from sltk.embedding.pipeline import DictionaryResult
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline_cls.return_value = mock_pipeline
        mock_pipeline.extract_dictionary.return_value = DictionaryResult(
            feature=np.zeros(768, dtype=np.float32),
            all_features=np.zeros((1, 768), dtype=np.float32),
            active_probs=np.zeros(1, dtype=np.float32),
            frame_indices=np.array([0]),
            fps=25.0,
            total_frames=50,
            pooling_method="mean_pool",
            source_path="/test.mp4",
        )

        embedder = SignRepEmbedder(lazy_load=True)

        # Test all mappings
        mapping = {
            "mean": "mean_pool",
            "max": "max_pool",
            "first": "middle",
            "last": "middle",
        }
        for old_name, expected_method in mapping.items():
            embedder.embed_video("/test.mp4", aggregation=old_name)
            call_args = mock_pipeline.extract_dictionary.call_args
            actual_method = call_args.kwargs.get("method", call_args[1].get("method"))
            assert actual_method == expected_method, (
                f"Aggregation '{old_name}' should map to '{expected_method}', "
                f"got '{actual_method}'"
            )

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_video_return_all_segments(self, mock_pipeline_cls):
        from sltk.embedding.pipeline import DictionaryResult
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline_cls.return_value = mock_pipeline

        all_feats = np.random.default_rng(42).standard_normal((5, 768)).astype(np.float32)
        mock_pipeline.extract_dictionary.return_value = DictionaryResult(
            feature=np.random.default_rng(42).standard_normal(768).astype(np.float32),
            all_features=all_feats,
            active_probs=np.random.default_rng(42).random(5).astype(np.float32),
            frame_indices=np.arange(5),
            fps=25.0,
            total_frames=100,
            pooling_method="mean_pool",
            source_path="/test.mp4",
        )

        embedder = SignRepEmbedder(lazy_load=True)
        result = embedder.embed_video("/test.mp4", aggregation="mean", return_all_segments=True)
        assert isinstance(result, dict)
        assert "embedding" in result
        assert "segments" in result
        assert "latents" in result
        assert "num_segments" in result
        assert result["num_segments"] == 5
        np.testing.assert_array_equal(result["segments"], all_feats)

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_pipeline_property(self, mock_pipeline_cls):
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline_cls.return_value = mock_pipeline

        embedder = SignRepEmbedder(lazy_load=True)
        assert not embedder.is_loaded()

        p = embedder.pipeline
        assert p is mock_pipeline
        assert embedder.is_loaded()

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_videos_batch(self, mock_pipeline_cls):
        from sltk.embedding.pipeline import DictionaryResult
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline_cls.return_value = mock_pipeline

        mock_pipeline.extract_dictionary.return_value = DictionaryResult(
            feature=np.zeros(768, dtype=np.float32),
            all_features=np.zeros((1, 768), dtype=np.float32),
            active_probs=np.zeros(1, dtype=np.float32),
            frame_indices=np.array([0]),
            fps=25.0,
            total_frames=50,
            pooling_method="mean_pool",
            source_path="",
        )

        embedder = SignRepEmbedder(lazy_load=True)
        results = embedder.embed_videos(["/a.mp4", "/b.mp4"], show_progress=False)
        assert len(results) == 2
        assert "/a.mp4" in results
        assert "/b.mp4" in results

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_videos_handles_failure(self, mock_pipeline_cls):
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline_cls.return_value = mock_pipeline
        mock_pipeline.extract_dictionary.side_effect = RuntimeError("corrupt video")

        embedder = SignRepEmbedder(lazy_load=True)
        results = embedder.embed_videos(["/bad.mp4"], show_progress=False)
        assert results["/bad.mp4"] is None

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_frames_with_mock_pipeline(self, mock_pipeline_cls):
        """Test embed_frames by injecting mock pipeline and model."""
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline.device = torch.device("cpu")
        mock_pipeline.amp = False
        mock_pipeline_cls.return_value = mock_pipeline

        # Mock inference_forward to return latent vectors
        def mock_inference_forward(segment, need_active=True):
            batch = segment.shape[0]
            return {"latent": torch.randn(batch, 768)}

        mock_pipeline.model.inference_forward.side_effect = mock_inference_forward

        embedder = SignRepEmbedder(lazy_load=True)
        # Trigger pipeline load
        embedder._load_pipeline()

        frames = np.random.default_rng(42).integers(0, 255, (20, 480, 640, 3), dtype=np.uint8)
        result = embedder.embed_frames(frames, aggregation="mean")
        assert isinstance(result, np.ndarray)
        assert result.shape == (768,)

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_frames_too_short_raises(self, mock_pipeline_cls):
        """Frames fewer than segment_length should raise ValueError."""
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline.device = torch.device("cpu")
        mock_pipeline.amp = False
        mock_pipeline_cls.return_value = mock_pipeline

        embedder = SignRepEmbedder(lazy_load=True)
        embedder._load_pipeline()

        # Only 5 frames -- SEGMENT_LENGTH=16, stride=2, no valid segments
        frames = np.random.default_rng(42).integers(0, 255, (5, 100, 100, 3), dtype=np.uint8)
        with pytest.raises(ValueError, match="Video too short"):
            embedder.embed_frames(frames)

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_frames_return_all_segments(self, mock_pipeline_cls):
        """return_all_segments=True returns dict with expected keys."""
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline.device = torch.device("cpu")
        mock_pipeline.amp = False
        mock_pipeline_cls.return_value = mock_pipeline

        def mock_inference_forward(segment, need_active=True):
            batch = segment.shape[0]
            return {"latent": torch.randn(batch, 768)}

        mock_pipeline.model.inference_forward.side_effect = mock_inference_forward

        embedder = SignRepEmbedder(lazy_load=True)
        embedder._load_pipeline()

        frames = np.random.default_rng(42).integers(0, 255, (30, 100, 100, 3), dtype=np.uint8)
        result = embedder.embed_frames(frames, aggregation="mean", return_all_segments=True)
        assert isinstance(result, dict)
        assert "embedding" in result
        assert "segments" in result
        assert "latents" in result
        assert "num_segments" in result
        assert result["embedding"].shape == (768,)
        assert result["num_segments"] > 0

    @patch("sltk.embedding.pipeline.SignRepPipeline")
    def test_embed_frames_invalid_aggregation(self, mock_pipeline_cls):
        """Invalid aggregation should raise ValueError."""
        from sltk.embedding.signrep import SignRepEmbedder

        mock_pipeline = MagicMock()
        mock_pipeline.device = torch.device("cpu")
        mock_pipeline.amp = False
        mock_pipeline_cls.return_value = mock_pipeline

        def mock_inference_forward(segment, need_active=True):
            batch = segment.shape[0]
            return {"latent": torch.randn(batch, 768)}

        mock_pipeline.model.inference_forward.side_effect = mock_inference_forward

        embedder = SignRepEmbedder(lazy_load=True)
        embedder._load_pipeline()

        frames = np.random.default_rng(42).integers(0, 255, (30, 100, 100, 3), dtype=np.uint8)
        with pytest.raises(ValueError, match="Unknown aggregation"):
            embedder.embed_frames(frames, aggregation="nonexistent")


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


class TestConstants:
    """Verify public constants have expected values."""

    def test_feature_dim(self):
        from sltk.embedding.pipeline import FEATURE_DIM

        assert FEATURE_DIM == 768

    def test_segment_length(self):
        from sltk.embedding.pipeline import SEGMENT_LENGTH

        assert SEGMENT_LENGTH == 16

    def test_video_extensions(self):
        from sltk.embedding.pipeline import VIDEO_EXTENSIONS

        assert ".mp4" in VIDEO_EXTENSIONS
        assert ".avi" in VIDEO_EXTENSIONS
        assert ".mov" in VIDEO_EXTENSIONS
