"""
Tests for the embedding visualization API endpoints.

Tests cover:
- Thread-safe SignRep initialization
- Embedding cache operations
- API endpoint responses
- Dimensionality reduction (PCA, t-SNE, UMAP)
- Clustering (kmeans, hdbscan)
- Error handling (missing dataset, invalid params, SignRep unavailable)
- Rate limiting (if slowapi is available)
"""

from __future__ import annotations

import threading
import time
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 import HTTPException
from fastapi.testclient import TestClient

from sltk.api import dependencies as api_deps
from sltk.api.main import app

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


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


@pytest.fixture
def mock_embeddings():
    """Create mock embeddings for testing."""
    np.random.seed(42)
    return np.random.randn(50, 768).astype(np.float32)


@pytest.fixture
def mock_metadata():
    """Create mock metadata matching the mock embeddings."""
    return {
        "sample_ids": [f"sample_{i:03d}" for i in range(50)],
        "glosses": [f"GLOSS_{i % 10}" for i in range(50)],
        "signer_ids": [f"signer_{i % 5}" for i in range(50)],
        "video_paths": [f"/path/to/video_{i:03d}.mp4" for i in range(50)],
    }


@pytest.fixture
def mock_signrep_embedder():
    """Create a mock SignRep embedder."""
    embedder = MagicMock()
    embedder.is_loaded.return_value = True
    embedder.embedding_dim = 768
    embedder.config = MagicMock()
    embedder.config.checkpoint_path = "/mock/path/to/ckpt.pt"

    def mock_embed_video(video_path, aggregation="mean"):
        np.random.seed(hash(str(video_path)) % (2**32))
        return np.random.randn(768).astype(np.float32)

    embedder.embed_video.side_effect = mock_embed_video
    return embedder


@pytest.fixture
def mock_dataset():
    """Create a mock dataset for testing."""
    dataset = MagicMock()
    dataset._index = [f"sample_{i:03d}" for i in range(100)]

    def mock_load_sample(sample_id):
        sample = MagicMock()
        sample.id = sample_id
        sample.video_path = f"/path/to/{sample_id}.mp4"
        sample.glosses = [f"GLOSS_{hash(sample_id) % 10}"]
        sample.signer_id = f"signer_{hash(sample_id) % 5}"
        return sample

    dataset._load_sample.side_effect = mock_load_sample
    return dataset


@pytest.fixture
def patched_cache(mock_embeddings, mock_metadata):
    """Patch the embedding cache with test data."""
    from sltk.api.routers import embeddings as emb_router

    # Save original cache state
    original_cache = emb_router._embedding_cache.copy()

    # Add test data to cache
    emb_router._embedding_cache["test_dataset"] = (
        mock_embeddings,
        mock_metadata,
        time.time(),
    )

    yield

    # Restore original cache
    emb_router._embedding_cache.clear()
    emb_router._embedding_cache.update(original_cache)


@pytest.fixture
def clear_signrep_state():
    """Clear SignRep embedder state before and after test."""
    from sltk.api.routers import embeddings as emb_router

    # Save original state
    original_embedder = emb_router._signrep_embedder
    original_error = emb_router._signrep_init_error

    # Clear state
    emb_router._signrep_embedder = None
    emb_router._signrep_init_error = None

    yield

    # Restore original state
    emb_router._signrep_embedder = original_embedder
    emb_router._signrep_init_error = original_error


@pytest.fixture
def force_signrep_unavailable():
    """Force SignRep to be unavailable for testing error handling."""
    from sltk.api.routers import embeddings as emb_router

    # Save original state
    original_embedder = emb_router._signrep_embedder
    original_error = emb_router._signrep_init_error

    # Force unavailable state
    emb_router._signrep_embedder = None
    emb_router._signrep_init_error = "SignRep not installed (test mock)"

    yield

    # Restore original state
    emb_router._signrep_embedder = original_embedder
    emb_router._signrep_init_error = original_error


@pytest.fixture
def clear_embedding_cache():
    """Clear embedding cache before and after test."""
    from sltk.api.routers import embeddings as emb_router

    original_cache = emb_router._embedding_cache.copy()
    emb_router._embedding_cache.clear()

    yield

    emb_router._embedding_cache.clear()
    emb_router._embedding_cache.update(original_cache)


# ============================================================================
# Thread-safe SignRep initialization tests
# ============================================================================


class TestSignRepInitialization:
    """Tests for thread-safe SignRep initialization."""

    def test_signrep_lazy_initialization_with_forced_error(self, clear_signrep_state):
        """Test that SignRep is lazily initialized on first access and errors are handled."""
        from sltk.api.routers import embeddings as emb_router

        # Clear state
        assert emb_router._signrep_embedder is None

        # Force an import error by patching the import
        with patch(
            "sltk.embedding.signrep.SignRepEmbedder",
            side_effect=ImportError("SignRep not installed (test)"),
        ):
            # Also need to clear cached modules to force re-import
            import sys

            # Remove cached import if exists
            emb_router._signrep_init_error = None

            # Attempting to get embedder should try to initialize and fail
            with pytest.raises(HTTPException) as exc_info:
                emb_router._get_signrep_embedder()

            # The actual error may vary depending on environment, just verify 503
            assert exc_info.value.status_code == 503

    def test_signrep_init_error_cached_behavior(self, clear_signrep_state):
        """Test that initialization errors are cached and reused."""
        from sltk.api.routers import embeddings as emb_router

        # Manually set an error to test caching behavior
        emb_router._signrep_init_error = "Test error message"

        # Should immediately raise without attempting initialization
        with pytest.raises(HTTPException) as exc_info:
            emb_router._get_signrep_embedder()

        assert exc_info.value.status_code == 503
        assert "unavailable" in exc_info.value.detail.lower()
        assert "Test error message" in exc_info.value.detail

    def test_signrep_double_check_locking_pattern(self, clear_signrep_state, mock_signrep_embedder):
        """Test the double-check locking pattern for thread safety."""
        from sltk.api.routers import embeddings as emb_router

        # Set up the embedder directly to test caching
        emb_router._signrep_embedder = mock_signrep_embedder

        # Fast path should return cached embedder
        result = emb_router._get_signrep_embedder()
        assert result is mock_signrep_embedder


class TestSignRepMocked:
    """Tests with mocked SignRep embedder."""

    def test_get_signrep_embedder_cached(self, clear_signrep_state, mock_signrep_embedder):
        """Test that embedder is cached after first access."""
        from sltk.api.routers import embeddings as emb_router

        # Set up cache directly
        emb_router._signrep_embedder = mock_signrep_embedder

        # Should return cached instance without trying to import
        embedder = emb_router._get_signrep_embedder()
        assert embedder is mock_signrep_embedder

    def test_get_signrep_embedder_properties(self, clear_signrep_state, mock_signrep_embedder):
        """Test that cached embedder has expected properties."""
        from sltk.api.routers import embeddings as emb_router

        emb_router._signrep_embedder = mock_signrep_embedder

        embedder = emb_router._get_signrep_embedder()
        assert embedder.is_loaded() is True
        assert embedder.embedding_dim == 768


# ============================================================================
# Embedding cache tests
# ============================================================================


class TestEmbeddingCache:
    """Tests for embedding cache operations."""

    def test_cache_embeddings(self, clear_embedding_cache, mock_embeddings, mock_metadata):
        """Test caching embeddings."""
        from sltk.api.routers import embeddings as emb_router

        emb_router._cache_embeddings("test_ds", mock_embeddings, mock_metadata)

        # Verify cache
        assert "test_ds" in emb_router._embedding_cache
        cached_emb, cached_meta, cached_time = emb_router._embedding_cache["test_ds"]
        np.testing.assert_array_equal(cached_emb, mock_embeddings)
        assert cached_meta == mock_metadata

    def test_get_cached_embeddings(self, clear_embedding_cache, mock_embeddings, mock_metadata):
        """Test retrieving cached embeddings."""
        from sltk.api.routers import embeddings as emb_router

        # Cache embeddings
        emb_router._cache_embeddings("test_ds", mock_embeddings, mock_metadata)

        # Retrieve
        result = emb_router._get_cached_embeddings("test_ds")
        assert result is not None
        emb, meta = result
        np.testing.assert_array_equal(emb, mock_embeddings)

    def test_get_cached_embeddings_expired(
        self, clear_embedding_cache, mock_embeddings, mock_metadata
    ):
        """Test that expired cache entries are removed."""
        from sltk.api.routers import embeddings as emb_router

        # Cache with expired timestamp
        expired_time = time.time() - emb_router.EMBEDDING_CACHE_TTL - 100
        emb_router._embedding_cache["expired_ds"] = (
            mock_embeddings,
            mock_metadata,
            expired_time,
        )

        # Should return None for expired entry
        result = emb_router._get_cached_embeddings("expired_ds")
        assert result is None

        # Entry should be removed from cache
        assert "expired_ds" not in emb_router._embedding_cache

    def test_get_cached_embeddings_not_found(self, clear_embedding_cache):
        """Test retrieving non-existent cache entry."""
        from sltk.api.routers import embeddings as emb_router

        result = emb_router._get_cached_embeddings("nonexistent")
        assert result is None

    def test_cache_thread_safety(self, clear_embedding_cache, mock_embeddings, mock_metadata):
        """Test that cache operations are thread-safe."""
        from sltk.api.routers import embeddings as emb_router

        results = []

        def cache_and_get(dataset_name):
            emb_router._cache_embeddings(dataset_name, mock_embeddings, mock_metadata)
            result = emb_router._get_cached_embeddings(dataset_name)
            results.append(result is not None)

        threads = [threading.Thread(target=cache_and_get, args=(f"ds_{i}",)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()

        assert all(results)


# ============================================================================
# API endpoint tests
# ============================================================================


class TestEmbeddingStatusEndpoint:
    """Tests for GET /api/embeddings/status/{dataset}."""

    def test_embedding_status_not_cached(
        self, client, clear_embedding_cache, force_signrep_unavailable
    ):
        """Test status endpoint when dataset is not cached."""
        response = client.get("/api/embeddings/status/uncached_dataset")
        assert response.status_code == 200

        data = response.json()
        assert data["dataset_name"] == "uncached_dataset"
        assert data["cached"] is False
        assert data["num_embeddings"] == 0

    def test_embedding_status_cached(self, client, patched_cache):
        """Test status endpoint when dataset is cached."""
        response = client.get("/api/embeddings/status/test_dataset")
        assert response.status_code == 200

        data = response.json()
        assert data["dataset_name"] == "test_dataset"
        assert data["cached"] is True
        assert data["num_embeddings"] == 50
        assert data["embedding_dim"] == 768
        assert data["cached_at"] is not None
        assert data["expires_in_sec"] is not None

    def test_embedding_status_signrep_availability(self, client, force_signrep_unavailable):
        """Test that status endpoint reports SignRep availability."""
        response = client.get("/api/embeddings/status/any_dataset")
        assert response.status_code == 200

        data = response.json()
        # SignRep should not be available when forced unavailable
        assert "signrep_available" in data
        assert "signrep_loaded" in data
        # With force_signrep_unavailable, signrep should not be available
        assert data["signrep_available"] is False


class TestComputeEmbeddingsEndpoint:
    """Tests for POST /api/embeddings/compute."""

    def test_compute_embeddings_returns_cached(self, client, patched_cache):
        """Test that compute endpoint returns cached results if available."""
        response = client.post(
            "/api/embeddings/compute",
            json={"dataset_name": "test_dataset", "max_samples": 50},
        )
        assert response.status_code == 200

        data = response.json()
        assert data["dataset_name"] == "test_dataset"
        assert data["cached"] is True
        assert data["num_embeddings"] == 50

    def test_compute_embeddings_force_recompute_without_signrep(
        self, client, patched_cache, force_signrep_unavailable, clear_embedding_cache
    ):
        """Test force recompute fails gracefully without SignRep."""
        response = client.post(
            "/api/embeddings/compute",
            json={
                "dataset_name": "test_dataset",
                "max_samples": 10,
                "force_recompute": True,
            },
        )
        # Should fail since SignRep is not available
        assert response.status_code == 503

    def test_compute_embeddings_invalid_max_samples(self, client):
        """Test that invalid max_samples is rejected."""
        # Too high
        response = client.post(
            "/api/embeddings/compute",
            json={"dataset_name": "test", "max_samples": 10000},
        )
        assert response.status_code == 422  # Validation error

        # Too low
        response = client.post(
            "/api/embeddings/compute",
            json={"dataset_name": "test", "max_samples": 0},
        )
        assert response.status_code == 422


class TestVisualizeEmbeddingsEndpoint:
    """Tests for POST /api/embeddings/visualize."""

    def test_visualize_requires_cached_embeddings(self, client, clear_embedding_cache):
        """Test that visualize fails without cached embeddings."""
        response = client.post(
            "/api/embeddings/visualize",
            json={"dataset_name": "uncached_dataset"},
        )
        assert response.status_code == 400
        assert "No embeddings cached" in response.json()["detail"]

    def test_visualize_pca_returns_explained_variance(self, client, patched_cache):
        """Test that PCA projection includes explained variance."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "projection_method": "pca",
                "n_components": 2,
            },
        )
        assert response.status_code == 200

        data = response.json()
        assert data["projection_method"] == "pca"
        assert data["explained_variance"] is not None
        assert len(data["explained_variance"]) == 2
        # Explained variance should be between 0 and 1
        for ev in data["explained_variance"]:
            assert 0 <= ev <= 1

    def test_visualize_pca_3d(self, client, patched_cache):
        """Test 3D PCA projection."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "projection_method": "pca",
                "n_components": 3,
            },
        )
        assert response.status_code == 200

        data = response.json()
        assert len(data["explained_variance"]) == 3
        # All points should have z coordinate
        for point in data["points"]:
            assert point["z"] is not None

    def test_visualize_tsne_perplexity_auto_adjusted(self, client):
        """Test that t-SNE perplexity is auto-adjusted when > n_samples."""
        from sltk.api.routers import embeddings as emb_router

        # Create small dataset (less than default perplexity of 30)
        np.random.seed(42)
        small_embeddings = np.random.randn(15, 768).astype(np.float32)
        small_metadata = {
            "sample_ids": [f"s_{i}" for i in range(15)],
            "glosses": [f"G_{i}" for i in range(15)],
            "signer_ids": ["signer_0"] * 15,
            "video_paths": [f"/path/{i}.mp4" for i in range(15)],
        }
        emb_router._cache_embeddings("small_dataset", small_embeddings, small_metadata)

        try:
            response = client.post(
                "/api/embeddings/visualize",
                json={
                    "dataset_name": "small_dataset",
                    "projection_method": "tsne",
                    "perplexity": 50,  # Larger than n_samples
                },
            )
            assert response.status_code == 200
            # Should succeed with auto-adjusted perplexity
            data = response.json()
            assert data["num_points"] == 15
        finally:
            # Cleanup
            with emb_router._embedding_cache_lock:
                if "small_dataset" in emb_router._embedding_cache:
                    del emb_router._embedding_cache["small_dataset"]

    def test_visualize_max_samples_limit(self, client):
        """Test that max_samples limit is enforced."""
        from sltk.api.routers import embeddings as emb_router

        # Create larger dataset
        np.random.seed(42)
        large_embeddings = np.random.randn(100, 768).astype(np.float32)
        large_metadata = {
            "sample_ids": [f"s_{i}" for i in range(100)],
            "glosses": [f"G_{i % 10}" for i in range(100)],
            "signer_ids": [f"signer_{i % 5}" for i in range(100)],
            "video_paths": [f"/path/{i}.mp4" for i in range(100)],
        }
        emb_router._cache_embeddings("large_dataset", large_embeddings, large_metadata)

        try:
            response = client.post(
                "/api/embeddings/visualize",
                json={
                    "dataset_name": "large_dataset",
                    "max_samples": 30,
                },
            )
            assert response.status_code == 200
            data = response.json()
            assert data["num_points"] == 30  # Should be limited
        finally:
            # Cleanup
            with emb_router._embedding_cache_lock:
                if "large_dataset" in emb_router._embedding_cache:
                    del emb_router._embedding_cache["large_dataset"]

    def test_visualize_point_structure(self, client, patched_cache):
        """Test that visualization points have correct structure."""
        response = client.post(
            "/api/embeddings/visualize",
            json={"dataset_name": "test_dataset"},
        )
        assert response.status_code == 200

        data = response.json()
        assert len(data["points"]) > 0

        point = data["points"][0]
        assert "sample_id" in point
        assert "x" in point
        assert "y" in point
        assert "gloss" in point
        assert "signer_id" in point
        assert "cluster_id" in point
        assert "label" in point

    def test_visualize_returns_unique_labels(self, client, patched_cache):
        """Test that unique labels are returned."""
        response = client.post(
            "/api/embeddings/visualize",
            json={"dataset_name": "test_dataset"},
        )
        assert response.status_code == 200

        data = response.json()
        assert "unique_labels" in data
        assert isinstance(data["unique_labels"], list)

    def test_visualize_returns_statistics(self, client, patched_cache):
        """Test that statistics are returned."""
        response = client.post(
            "/api/embeddings/visualize",
            json={"dataset_name": "test_dataset"},
        )
        assert response.status_code == 200

        data = response.json()
        assert "statistics" in data
        assert "total_samples" in data["statistics"]


class TestClusteringEndpoints:
    """Tests for clustering in visualize endpoint."""

    def test_kmeans_clustering(self, client, patched_cache):
        """Test k-means clustering."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "cluster_method": "kmeans",
                "n_clusters": 5,
            },
        )
        assert response.status_code == 200

        data = response.json()
        assert "num_clusters" in data["statistics"]
        assert data["statistics"]["num_clusters"] == 5

        # All points should have cluster_id
        cluster_ids = {p["cluster_id"] for p in data["points"]}
        assert len(cluster_ids) == 5

    def test_kmeans_requires_n_clusters(self, client, patched_cache):
        """Test that k-means requires n_clusters parameter."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "cluster_method": "kmeans",
                # No n_clusters
            },
        )
        assert response.status_code == 400
        assert "n_clusters required" in response.json()["detail"]

    def test_hdbscan_clustering(self, client, patched_cache):
        """Test HDBSCAN clustering (if available)."""
        try:
            import hdbscan  # noqa: F401
        except ImportError:
            pytest.skip("hdbscan not installed")

        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "cluster_method": "hdbscan",
            },
        )
        assert response.status_code == 200

        data = response.json()
        assert "num_clusters" in data["statistics"]
        assert "num_noise_points" in data["statistics"]

    def test_no_clustering(self, client, patched_cache):
        """Test visualization without clustering."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "cluster_method": "none",
            },
        )
        assert response.status_code == 200

        data = response.json()
        # All points should have cluster_id = -1
        for point in data["points"]:
            assert point["cluster_id"] == -1


class TestClearCacheEndpoint:
    """Tests for DELETE /api/embeddings/cache/{dataset}."""

    def test_clear_cache_existing(self, client, patched_cache):
        """Test clearing existing cache entry."""
        response = client.delete("/api/embeddings/cache/test_dataset")
        assert response.status_code == 200

        data = response.json()
        assert data["status"] == "cleared"
        assert data["dataset_name"] == "test_dataset"

    def test_clear_cache_nonexistent(self, client, clear_embedding_cache):
        """Test clearing non-existent cache entry."""
        response = client.delete("/api/embeddings/cache/nonexistent")
        assert response.status_code == 200

        data = response.json()
        assert data["status"] == "not_cached"

    def test_clear_all_cache(self, client, patched_cache):
        """Test clearing all cache entries."""
        # First verify cache has entries
        from sltk.api.routers import embeddings as emb_router

        assert len(emb_router._embedding_cache) > 0

        response = client.delete("/api/embeddings/cache")
        assert response.status_code == 200

        data = response.json()
        assert data["status"] == "cleared"
        assert data["datasets_cleared"] >= 1


class TestSignRepStatusEndpoint:
    """Tests for GET /api/embeddings/signrep/status."""

    def test_signrep_status_unavailable(self, client, force_signrep_unavailable):
        """Test SignRep status when not available."""
        response = client.get("/api/embeddings/signrep/status")
        assert response.status_code == 200

        data = response.json()
        assert data["available"] is False
        assert data["loaded"] is False
        assert data["error"] is not None

    def test_signrep_status_available(self, client, clear_signrep_state, mock_signrep_embedder):
        """Test SignRep status when available."""
        from sltk.api.routers import embeddings as emb_router

        # Set up mocked embedder
        emb_router._signrep_embedder = mock_signrep_embedder
        emb_router._signrep_init_error = None

        try:
            response = client.get("/api/embeddings/signrep/status")
            assert response.status_code == 200

            data = response.json()
            assert data["available"] is True
            assert data["loaded"] is True
            assert data["embedding_dim"] == 768
            assert data["error"] is None
        finally:
            # Cleanup
            emb_router._signrep_embedder = None


# ============================================================================
# Error handling tests
# ============================================================================


class TestErrorHandling:
    """Tests for error handling in embedding endpoints."""

    def test_signrep_unavailable_returns_503(
        self, client, force_signrep_unavailable, clear_embedding_cache
    ):
        """Test that 503 is returned when SignRep is unavailable."""
        response = client.post(
            "/api/embeddings/compute",
            json={
                "dataset_name": "test",
                "max_samples": 10,
                "force_recompute": True,
            },
        )
        assert response.status_code == 503
        assert "SignRep" in response.json()["detail"]

    def test_invalid_projection_method(self, client, patched_cache):
        """Test that invalid projection method is rejected."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "projection_method": "invalid_method",
            },
        )
        assert response.status_code == 422  # Validation error

    def test_invalid_cluster_method(self, client, patched_cache):
        """Test that invalid cluster method is rejected."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "cluster_method": "invalid_method",
            },
        )
        assert response.status_code == 422  # Validation error

    def test_invalid_n_components(self, client, patched_cache):
        """Test that invalid n_components is rejected."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "n_components": 4,  # Only 2 or 3 allowed
            },
        )
        assert response.status_code == 422  # Validation error

    def test_perplexity_out_of_range(self, client, patched_cache):
        """Test that perplexity out of range is rejected."""
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "test_dataset",
                "projection_method": "tsne",
                "perplexity": 100,  # Max is 50
            },
        )
        assert response.status_code == 422  # Validation error


# ============================================================================
# Dimensionality reduction helper tests
# ============================================================================


class TestDimensionalityReduction:
    """Tests for dimensionality reduction helper functions."""

    def test_pca_reduction(self, mock_embeddings):
        """Test PCA dimensionality reduction."""
        from sltk.api.routers.embeddings import _reduce_dimensions_sync

        projections, explained_variance = _reduce_dimensions_sync(mock_embeddings, "pca", 2)

        assert projections.shape == (50, 2)
        assert explained_variance is not None
        assert len(explained_variance) == 2
        assert sum(explained_variance) <= 1.0

    def test_tsne_reduction(self, mock_embeddings):
        """Test t-SNE dimensionality reduction."""
        from sltk.api.routers.embeddings import _reduce_dimensions_sync

        projections, explained_variance = _reduce_dimensions_sync(
            mock_embeddings[:30], "tsne", 2, perplexity=10
        )

        assert projections.shape == (30, 2)
        assert explained_variance is None  # t-SNE doesn't provide this

    def test_umap_reduction(self, mock_embeddings):
        """Test UMAP dimensionality reduction."""
        try:
            import umap  # noqa: F401
        except ImportError:
            pytest.skip("umap-learn not installed")

        from sltk.api.routers.embeddings import _reduce_dimensions_sync

        projections, explained_variance = _reduce_dimensions_sync(
            mock_embeddings, "umap", 2, n_neighbors=10
        )

        assert projections.shape == (50, 2)
        assert explained_variance is None  # UMAP doesn't provide this


class TestClustering:
    """Tests for clustering helper functions."""

    def test_kmeans_clustering_sync(self, mock_embeddings):
        """Test k-means clustering."""
        from sltk.api.routers.embeddings import _cluster_sync

        cluster_ids = _cluster_sync(mock_embeddings, "kmeans", n_clusters=5)

        assert len(cluster_ids) == 50
        assert set(cluster_ids) == {0, 1, 2, 3, 4}

    def test_hdbscan_clustering_sync(self, mock_embeddings):
        """Test HDBSCAN clustering."""
        try:
            import hdbscan  # noqa: F401
        except ImportError:
            pytest.skip("hdbscan not installed")

        from sltk.api.routers.embeddings import _cluster_sync

        cluster_ids = _cluster_sync(mock_embeddings, "hdbscan")

        assert len(cluster_ids) == 50
        # HDBSCAN may have -1 for noise points

    def test_no_clustering_sync(self, mock_embeddings):
        """Test no clustering."""
        from sltk.api.routers.embeddings import _cluster_sync

        cluster_ids = _cluster_sync(mock_embeddings, "none")

        assert len(cluster_ids) == 50
        assert all(c == -1 for c in cluster_ids)


# ============================================================================
# Pydantic model validation tests
# ============================================================================


class TestPydanticModels:
    """Tests for Pydantic model validation."""

    def test_compute_embeddings_request_defaults(self):
        """Test ComputeEmbeddingsRequest defaults."""
        from sltk.api.routers.embeddings import ComputeEmbeddingsRequest

        req = ComputeEmbeddingsRequest(dataset_name="test")
        assert req.max_samples == 500
        assert req.aggregation == "mean"
        assert req.force_recompute is False

    def test_visualize_embeddings_request_defaults(self):
        """Test VisualizeEmbeddingsRequest defaults."""
        from sltk.api.routers.embeddings import VisualizeEmbeddingsRequest

        req = VisualizeEmbeddingsRequest(dataset_name="test")
        assert req.projection_method == "pca"
        assert req.n_components == 2
        assert req.cluster_method == "none"
        assert req.max_samples == 500
        assert req.n_neighbors == 15
        assert req.min_dist == 0.1

    def test_embedding_point_model(self):
        """Test EmbeddingPoint model."""
        from sltk.api.routers.embeddings import EmbeddingPoint

        point = EmbeddingPoint(
            sample_id="test_001",
            x=1.0,
            y=2.0,
            z=3.0,
            gloss="TEST",
            signer_id="signer_01",
            cluster_id=0,
            label="TEST",
        )
        assert point.sample_id == "test_001"
        assert point.z == 3.0

    def test_embedding_status_response_model(self):
        """Test EmbeddingStatusResponse model."""
        from sltk.api.routers.embeddings import EmbeddingStatusResponse

        resp = EmbeddingStatusResponse(
            dataset_name="test",
            cached=True,
            num_embeddings=100,
            embedding_dim=768,
        )
        assert resp.cached is True
        assert resp.expires_in_sec is None  # Optional


# ============================================================================
# Integration tests
# ============================================================================


class TestIntegration:
    """Integration tests for the embedding workflow."""

    def test_full_workflow_with_mock(self, client, clear_embedding_cache):
        """Test the full embedding workflow with mocked components."""
        from sltk.api.routers import embeddings as emb_router

        # Step 1: Check status (should be empty)
        response = client.get("/api/embeddings/status/workflow_test")
        assert response.status_code == 200
        assert response.json()["cached"] is False

        # Step 2: Manually cache embeddings (simulating compute)
        np.random.seed(42)
        embeddings = np.random.randn(20, 768).astype(np.float32)
        metadata = {
            "sample_ids": [f"s_{i}" for i in range(20)],
            "glosses": [f"G_{i % 5}" for i in range(20)],
            "signer_ids": [f"signer_{i % 3}" for i in range(20)],
            "video_paths": [f"/path/{i}.mp4" for i in range(20)],
        }
        emb_router._cache_embeddings("workflow_test", embeddings, metadata)

        # Step 3: Check status again (should be cached)
        response = client.get("/api/embeddings/status/workflow_test")
        assert response.status_code == 200
        assert response.json()["cached"] is True

        # Step 4: Visualize with PCA
        response = client.post(
            "/api/embeddings/visualize",
            json={
                "dataset_name": "workflow_test",
                "projection_method": "pca",
                "cluster_method": "kmeans",
                "n_clusters": 3,
            },
        )
        assert response.status_code == 200
        data = response.json()
        assert data["num_points"] == 20
        assert data["statistics"]["num_clusters"] == 3

        # Step 5: Clear cache
        response = client.delete("/api/embeddings/cache/workflow_test")
        assert response.status_code == 200
        assert response.json()["status"] == "cleared"

        # Step 6: Verify cache is cleared
        response = client.get("/api/embeddings/status/workflow_test")
        assert response.status_code == 200
        assert response.json()["cached"] is False

    def test_compute_returns_cached_without_recompute(self, client, patched_cache):
        """Test that compute endpoint returns cached without recomputing."""
        response = client.post(
            "/api/embeddings/compute",
            json={"dataset_name": "test_dataset"},
        )
        assert response.status_code == 200

        data = response.json()
        assert data["cached"] is True
        assert data["compute_time_sec"] is None  # No computation
