"""
Tests for the workspace API router (sltk/api/routers/workspace.py).

Covers:
- Directory scanning and file discovery (videos, ELAN, features)
- Auto-matching algorithm (stem-based, case-insensitive, _annotations suffix)
- Manual pair/unpair operations
- File upload (video, ELAN, features, invalid extensions)
- URL fetch endpoint
- Workspace clear and rescan lifecycle
- State persistence across status calls
- Error handling for nonexistent paths, invalid inputs
- Multi-workspace: create, list, switch, delete, rename
- Video management: detach, reattach, add external video
- Migration from v0 single-workspace to v1 multi-workspace
"""

from __future__ import annotations

import io
import json
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

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

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

from fastapi.testclient import TestClient

from sltk.api import dependencies as api_deps
from sltk.api.main import app
from sltk.api.routers import workspace as ws_module

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


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


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


@pytest.fixture(autouse=True)
def reset_workspace_state():
    """Reset workspace manager state between every test."""
    yield

    # Reset the manager to a fresh state
    mgr = ws_module._manager
    mgr._data = ws_module.WorkspacesFile()
    mgr._loaded = False
    mgr._scan_cache = mgr._empty_scan_cache()

    # Remove any persisted workspaces.json created during tests
    for json_path in (ws_module.WORKSPACES_JSON, ws_module.WORKSPACE_JSON):
        if json_path.exists():
            try:
                json_path.unlink()
            except OSError:
                pass


# ============================================================================
# Helpers
# ============================================================================


def _create_files(directory: Path, filenames: list[str]) -> list[Path]:
    """Create empty files in *directory* and return their paths."""
    paths = []
    for name in filenames:
        p = directory / name
        p.parent.mkdir(parents=True, exist_ok=True)
        p.touch()
        paths.append(p)
    return paths


# ============================================================================
# TestWorkspaceScan
# ============================================================================


class TestWorkspaceScan:
    """Tests for POST /api/workspace/scan."""

    def test_scan_empty_directory(self, client, temp_dir):
        """Scanning an empty directory should return zero matches and zero counts."""
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200, f"Unexpected status: {response.text}"
        data = response.json()
        assert data["counts"]["videos"] == 0
        assert data["counts"]["matched"] == 0
        assert data["counts"]["elan"] == 0
        assert data["counts"]["features"] == 0
        assert data["matches"] == []
        assert data["unmatched_videos"] == []
        assert data["unmatched_elan"] == []

    def test_scan_only_videos(self, client, temp_dir):
        """Directory with only video files should list them all as unmatched matches."""
        _create_files(temp_dir, ["clip1.mp4", "clip2.mov", "clip3.avi"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert data["counts"]["videos"] == 3
        assert data["counts"]["matched"] == 0
        # All videos are still in matches (as entries with elan=None)
        assert len(data["matches"]) == 3
        for m in data["matches"]:
            assert m["elan"] is None, "Expected no ELAN match for video-only scan"

    def test_scan_matching_video_and_elan_by_stem(self, client, temp_dir):
        """Video and ELAN file with the same stem should auto-match."""
        _create_files(temp_dir, ["interview.mp4", "interview.eaf"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert data["counts"]["videos"] == 1
        assert data["counts"]["matched"] == 1
        assert len(data["matches"]) == 1
        match = data["matches"][0]
        assert match["video"]["stem"] == "interview"
        assert match["elan"] is not None
        assert match["elan"]["stem"] == "interview"
        assert data["unmatched_elan"] == []

    def test_scan_case_insensitive_matching(self, client, temp_dir):
        """Matching should be case-insensitive (Video1.mp4 pairs with video1.eaf)."""
        _create_files(temp_dir, ["Video1.mp4", "video1.eaf"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert (
            data["counts"]["matched"] == 1
        ), f"Case-insensitive match expected; got counts={data['counts']}"
        assert data["matches"][0]["elan"] is not None

    def test_scan_annotations_suffix_stripping(self, client, temp_dir):
        """video1_annotations.eaf should match video1.mp4 via suffix stripping."""
        _create_files(temp_dir, ["video1.mp4", "video1_annotations.eaf"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert (
            data["counts"]["matched"] == 1
        ), f"_annotations suffix stripping failed; counts={data['counts']}"
        assert data["matches"][0]["elan"]["stem"] == "video1_annotations"

    def test_scan_recursive_finds_subdirectory_files(self, client, temp_dir):
        """Recursive scan should discover files in subdirectories."""
        _create_files(
            temp_dir,
            [
                "top.mp4",
                "sub1/deep.mp4",
                "sub1/deep.eaf",
                "sub1/sub2/deeper.mp4",
            ],
        )
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": True}
        )
        assert response.status_code == 200
        data = response.json()
        assert (
            data["counts"]["videos"] >= 3
        ), f"Recursive scan should find >= 3 videos, got {data['counts']['videos']}"

    def test_scan_nonexistent_path_returns_error(self, client, temp_dir):
        """Scanning a nonexistent path should return 404."""
        fake = str(temp_dir / "does_not_exist")
        response = client.post("/api/workspace/scan", json={"path": fake, "recursive": False})
        assert (
            response.status_code == 404
        ), f"Expected 404 for nonexistent path, got {response.status_code}: {response.text}"

    def test_scan_path_outside_allowed_roots_returns_403(self, client):
        """Scanning a path outside ALLOWED_ROOTS should return 403."""
        response = client.post(
            "/api/workspace/scan",
            json={"path": "/nonexistent/forbidden/path", "recursive": False},
        )
        assert response.status_code == 403

    def test_scan_file_path_instead_of_directory_returns_400(self, client, temp_dir):
        """Scanning a file (not a directory) should return 400."""
        f = temp_dir / "afile.txt"
        f.touch()
        response = client.post("/api/workspace/scan", json={"path": str(f), "recursive": False})
        assert response.status_code == 400
        assert "directory" in response.json()["detail"].lower()

    def test_scan_discovers_feature_files(self, client, temp_dir):
        """Scan should discover .h5 / .hdf5 feature files."""
        _create_files(temp_dir, ["sample.mp4", "sample.h5", "other.hdf5"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert (
            data["counts"]["features"] >= 2
        ), f"Expected >= 2 feature files, got {data['counts']['features']}"

    def test_scan_ignores_hidden_files(self, client, temp_dir):
        """Scan should skip hidden files (starting with '.')."""
        _create_files(temp_dir, [".hidden.mp4", "visible.mp4"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert (
            data["counts"]["videos"] == 1
        ), f"Hidden files should be ignored; got {data['counts']['videos']}"

    def test_scan_multiple_video_extensions(self, client, temp_dir):
        """Scan should find .mp4, .mov, .avi, .mkv, .webm files."""
        _create_files(temp_dir, ["a.mp4", "b.mov", "c.avi", "d.mkv", "e.webm", "f.txt"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert (
            data["counts"]["videos"] == 5
        ), f"Expected 5 video files across all extensions, got {data['counts']['videos']}"

    def test_scan_unmatched_elan_reported(self, client, temp_dir):
        """ELAN files without matching videos should appear in unmatched_elan."""
        _create_files(temp_dir, ["orphan.eaf"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert len(data["unmatched_elan"]) == 1
        assert data["unmatched_elan"][0]["stem"] == "orphan"

    def test_scan_same_directory_preferred_on_collision(self, client, temp_dir):
        """When an ELAN file exists in both root and subdirectory, same-dir is preferred."""
        _create_files(
            temp_dir,
            [
                "clip.mp4",
                "clip.eaf",  # same directory as video
                "sub/clip.eaf",  # different directory
            ],
        )
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": True}
        )
        assert response.status_code == 200
        data = response.json()
        for m in data["matches"]:
            if m["video"]["stem"] == "clip" and m["elan"] is not None:
                elan_dir = str(Path(m["elan"]["path"]).parent)
                video_dir = str(Path(m["video"]["path"]).parent)
                assert (
                    elan_dir == video_dir
                ), f"Same-dir preference violated: video in {video_dir}, elan in {elan_dir}"

    def test_scan_creates_default_workspace(self, client, temp_dir):
        """First scan should create a 'Default' workspace when none exists."""
        _create_files(temp_dir, ["v.mp4"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert data["workspace_name"] == "Default"

    def test_scan_returns_workspace_name(self, client, temp_dir):
        """Scan response should include workspace_name field."""
        _create_files(temp_dir, ["v.mp4"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        data = response.json()
        assert "workspace_name" in data


# ============================================================================
# TestWorkspaceStatus
# ============================================================================


class TestWorkspaceStatus:
    """Tests for GET /api/workspace/status."""

    def test_status_empty_before_scan(self, client):
        """Status should return empty/default data when no scan has been done."""
        response = client.get("/api/workspace/status")
        assert response.status_code == 200
        data = response.json()
        assert data["matches"] == []
        assert data["counts"]["videos"] == 0

    def test_status_returns_data_after_scan(self, client, temp_dir):
        """After a scan, status should reflect the scan results."""
        _create_files(temp_dir, ["a.mp4", "a.eaf", "b.mp4"])
        scan_resp = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert scan_resp.status_code == 200

        status_resp = client.get("/api/workspace/status")
        assert status_resp.status_code == 200
        data = status_resp.json()
        assert data["counts"]["videos"] == 2
        assert data["counts"]["matched"] == 1
        assert data["path"] == str(temp_dir)

    def test_status_path_preserved(self, client, temp_dir):
        """Status should return the originally scanned path."""
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        data = client.get("/api/workspace/status").json()
        assert data["path"] == str(temp_dir)

    def test_status_returns_active_workspace_name(self, client, temp_dir):
        """Status should return workspace_name for the active workspace."""
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        data = client.get("/api/workspace/status").json()
        assert data["workspace_name"] == "Default"


# ============================================================================
# TestWorkspaceMatching
# ============================================================================


class TestWorkspaceMatching:
    """Tests for PATCH /api/workspace/match."""

    def test_manual_pair_video_with_elan(self, client, temp_dir):
        """Manually pairing a video with an ELAN file should override auto-matching."""
        _create_files(temp_dir, ["v1.mp4", "v2.mp4", "v1.eaf", "v2.eaf", "extra.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        video_path = str(temp_dir / "v1.mp4")
        elan_path = str(temp_dir / "extra.eaf")
        response = client.patch(
            "/api/workspace/match",
            json={"video_path": video_path, "elan_path": elan_path},
        )
        assert response.status_code == 200
        data = response.json()

        v1_match = None
        for m in data["matches"]:
            if m["video"]["name"] == "v1.mp4":
                v1_match = m
                break
        assert v1_match is not None, "v1.mp4 should be in matches"
        assert v1_match["elan"] is not None
        assert v1_match["elan"]["name"] == "extra.eaf"

    def test_manual_unpair_sets_elan_to_null(self, client, temp_dir):
        """Setting elan_path to null should unpair the video."""
        _create_files(temp_dir, ["v1.mp4", "v1.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        video_path = str(temp_dir / "v1.mp4")
        response = client.patch(
            "/api/workspace/match",
            json={"video_path": video_path, "elan_path": None},
        )
        assert response.status_code == 200
        data = response.json()

        v1_match = None
        for m in data["matches"]:
            if m["video"]["name"] == "v1.mp4":
                v1_match = m
                break
        assert v1_match is not None
        assert v1_match["elan"] is None, "After unpairing, elan should be None"

    def test_manual_pair_persists_across_status_calls(self, client, temp_dir):
        """Manual overrides should persist when querying status."""
        _create_files(temp_dir, ["v1.mp4", "v1.eaf", "alt.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        video_path = str(temp_dir / "v1.mp4")
        elan_path = str(temp_dir / "alt.eaf")
        client.patch(
            "/api/workspace/match",
            json={"video_path": video_path, "elan_path": elan_path},
        )

        status_data = client.get("/api/workspace/status").json()
        v1_match = None
        for m in status_data["matches"]:
            if m["video"]["name"] == "v1.mp4":
                v1_match = m
                break
        assert v1_match is not None
        assert v1_match["elan"]["name"] == "alt.eaf"

    def test_manual_match_nonexistent_video_returns_404(self, client, temp_dir):
        """Trying to match a nonexistent video path should return 404."""
        fake_video = str(temp_dir / "nonexistent.mp4")
        fake_elan = str(temp_dir / "nonexistent.eaf")
        response = client.patch(
            "/api/workspace/match",
            json={"video_path": fake_video, "elan_path": fake_elan},
        )
        assert response.status_code == 404

    def test_manual_match_non_elan_extension_returns_400(self, client, temp_dir):
        """Pairing with a non-.eaf file should return 400."""
        _create_files(temp_dir, ["v1.mp4", "v1.txt"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        video_path = str(temp_dir / "v1.mp4")
        bad_elan = str(temp_dir / "v1.txt")
        response = client.patch(
            "/api/workspace/match",
            json={"video_path": video_path, "elan_path": bad_elan},
        )
        assert response.status_code == 400
        assert "ELAN" in response.json()["detail"] or "elan" in response.json()["detail"].lower()


# ============================================================================
# TestWorkspaceUpload
# ============================================================================


class TestWorkspaceUpload:
    """Tests for POST /api/workspace/upload."""

    def test_upload_video_file(self, client):
        """Uploading a .mp4 file should succeed and return workspace scan response."""
        content = b"fake video content"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("test_clip.mp4", io.BytesIO(content), "video/mp4")},
        )
        assert response.status_code == 200, f"Upload failed: {response.text}"
        data = response.json()
        assert "matches" in data
        assert "counts" in data
        assert data["counts"]["videos"] >= 1

    def test_upload_elan_file(self, client):
        """Uploading a .eaf file should succeed and return workspace scan response."""
        content = b"<?xml version='1.0'?><ANNOTATION_DOCUMENT/>"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("annotation.eaf", io.BytesIO(content), "application/xml")},
        )
        assert response.status_code == 200
        data = response.json()
        assert "matches" in data
        assert data["counts"]["elan"] >= 1

    def test_upload_feature_file(self, client):
        """Uploading a .h5 file should succeed and return workspace scan response."""
        content = b"fake hdf5 content"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("features.h5", io.BytesIO(content), "application/octet-stream")},
        )
        assert response.status_code == 200
        data = response.json()
        assert "matches" in data
        assert data["counts"]["features"] >= 1

    def test_upload_invalid_extension_returns_400(self, client):
        """Uploading a file with an unsupported extension should return 400."""
        content = b"some text"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("notes.txt", io.BytesIO(content), "text/plain")},
        )
        assert response.status_code == 400
        assert "Unsupported" in response.json()["detail"]

    def test_upload_no_filename_returns_error(self, client):
        """Uploading with an empty filename should return an error (400 or 422)."""
        content = b"some content"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("", io.BytesIO(content), "application/octet-stream")},
        )
        assert response.status_code in (400, 422)


# ============================================================================
# TestWorkspaceFetchUrl
# ============================================================================


class TestWorkspaceFetchUrl:
    """Tests for POST /api/workspace/fetch-url."""

    def test_fetch_url_unsupported_extension_returns_400(self, client):
        response = client.post(
            "/api/workspace/fetch-url",
            json={"url": "https://example.com/readme.txt"},
        )
        assert response.status_code == 400
        assert "Unsupported" in response.json()["detail"]

    def test_fetch_url_with_explicit_filename_bad_ext_returns_400(self, client):
        response = client.post(
            "/api/workspace/fetch-url",
            json={"url": "https://example.com/data", "filename": "data.pdf"},
        )
        assert response.status_code == 400


# ============================================================================
# TestWorkspaceClear
# ============================================================================


class TestWorkspaceClear:
    """Tests for DELETE /api/workspace/clear."""

    def test_clear_resets_state(self, client, temp_dir):
        """After clearing, status should return empty state."""
        _create_files(temp_dir, ["v.mp4", "v.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        pre = client.get("/api/workspace/status").json()
        assert pre["counts"]["videos"] == 1

        clear_resp = client.delete("/api/workspace/clear")
        assert clear_resp.status_code == 200
        assert clear_resp.json()["status"] == "cleared"

        post = client.get("/api/workspace/status").json()
        assert post["counts"]["videos"] == 0
        assert post["matches"] == []

    def test_clear_removes_manual_overrides(self, client, temp_dir):
        """Clear should remove the active workspace (including overrides)."""
        _create_files(temp_dir, ["v.mp4", "v.eaf", "alt.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.patch(
            "/api/workspace/match",
            json={"video_path": str(temp_dir / "v.mp4"), "elan_path": str(temp_dir / "alt.eaf")},
        )

        client.delete("/api/workspace/clear")

        # After clear, workspace should be gone
        assert ws_module._manager.active is None

    def test_clear_idempotent(self, client):
        """Clearing an already empty workspace should succeed without error."""
        resp1 = client.delete("/api/workspace/clear")
        assert resp1.status_code == 200
        resp2 = client.delete("/api/workspace/clear")
        assert resp2.status_code == 200


# ============================================================================
# TestWorkspaceRescan
# ============================================================================


class TestWorkspaceRescan:
    """Tests for POST /api/workspace/rescan."""

    def test_rescan_without_prior_scan_returns_400(self, client):
        """Rescanning without a prior scan should return 400."""
        response = client.post("/api/workspace/rescan")
        assert response.status_code == 400
        assert "No workspace path" in response.json()["detail"]

    def test_rescan_picks_up_new_files(self, client, temp_dir):
        """After adding files, rescan should discover them."""
        _create_files(temp_dir, ["v1.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": True})
        pre = client.get("/api/workspace/status").json()
        assert pre["counts"]["videos"] == 1

        _create_files(temp_dir, ["v2.mp4", "v2.eaf"])

        rescan_resp = client.post("/api/workspace/rescan")
        assert rescan_resp.status_code == 200
        data = rescan_resp.json()
        assert data["counts"]["videos"] == 2
        assert data["counts"]["matched"] == 1

    def test_rescan_preserves_manual_overrides(self, client, temp_dir):
        """Manual overrides should survive a rescan."""
        _create_files(temp_dir, ["v.mp4", "v.eaf", "alt.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": True})

        video_path = str(temp_dir / "v.mp4")
        elan_path = str(temp_dir / "alt.eaf")
        client.patch(
            "/api/workspace/match",
            json={"video_path": video_path, "elan_path": elan_path},
        )

        rescan_data = client.post("/api/workspace/rescan").json()

        v_match = None
        for m in rescan_data["matches"]:
            if m["video"]["name"] == "v.mp4":
                v_match = m
                break
        assert v_match is not None
        assert v_match["elan"]["name"] == "alt.eaf", "Manual override should survive rescan"


# ============================================================================
# TestWorkspacePersistence
# ============================================================================


class TestWorkspacePersistence:
    """Tests for workspace state persistence and lifecycle."""

    def test_state_persists_across_status_calls(self, client, temp_dir):
        _create_files(temp_dir, ["a.mp4", "a.eaf", "b.mp4"])
        scan_data = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        ).json()
        status_data = client.get("/api/workspace/status").json()

        assert scan_data["counts"] == status_data["counts"]
        assert len(scan_data["matches"]) == len(status_data["matches"])

    def test_clear_then_status_is_empty(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.delete("/api/workspace/clear")

        data = client.get("/api/workspace/status").json()
        assert data["counts"]["videos"] == 0
        assert data["matches"] == []


# ============================================================================
# TestScanDirectoryInternal
# ============================================================================


class TestScanDirectoryInternal:
    """Unit tests for the internal _scan_directory function."""

    def test_scan_directory_basic(self, temp_dir):
        _create_files(
            temp_dir,
            ["v1.mp4", "v2.mov", "a1.eaf", "f1.h5", "readme.txt"],
        )
        videos, elan, features = ws_module._scan_directory(temp_dir, recursive=False)
        assert len(videos) == 2
        assert len(elan) == 1
        assert len(features) == 1

    def test_scan_directory_depth_limit(self, temp_dir):
        # MAX_SCAN_DEPTH is 10, so build levels 0..10 and verify level 11 is excluded
        files = ["level0.mp4"] + [
            "/".join(f"d{i}" for i in range(1, depth + 1)) + f"/level{depth}.mp4"
            for depth in range(1, 12)
        ]
        _create_files(temp_dir, files)
        videos, _, _ = ws_module._scan_directory(temp_dir, recursive=True)
        found_names = {v.name for v in videos}
        # Levels 0-10 should be found (depth <= MAX_SCAN_DEPTH=10)
        for i in range(11):
            assert f"level{i}.mp4" in found_names, f"level{i}.mp4 should be found"
        # Level 11 should be excluded (depth > MAX_SCAN_DEPTH)
        assert "level11.mp4" not in found_names

    def test_scan_directory_non_recursive_only_top(self, temp_dir):
        _create_files(temp_dir, ["top.mp4", "sub/nested.mp4"])
        videos, _, _ = ws_module._scan_directory(temp_dir, recursive=False)
        assert len(videos) == 1
        assert videos[0].name == "top.mp4"


# ============================================================================
# TestAutoMatchInternal
# ============================================================================


class TestAutoMatchInternal:
    """Unit tests for the internal _auto_match function."""

    def _make_file_info(self, path: str, file_type: str) -> ws_module.WorkspaceFileInfo:
        from sltk.api.models import WorkspaceFileInfo

        p = Path(path)
        return WorkspaceFileInfo(
            path=path,
            name=p.name,
            stem=p.stem,
            extension=p.suffix.lower(),
            size_mb=0.0,
            type=file_type,
        )

    def test_auto_match_empty_lists(self):
        matches, uv, ue = ws_module._auto_match([], [], [], {})
        assert matches == []
        assert uv == []
        assert ue == []

    def test_auto_match_exact_stem(self):
        v = self._make_file_info("/dir/clip.mp4", "video")
        e = self._make_file_info("/dir/clip.eaf", "elan")
        matches, _, unmatched_elan = ws_module._auto_match([v], [e], [], {})
        assert len(matches) == 1
        assert matches[0].elan is not None
        assert matches[0].elan.path == "/dir/clip.eaf"
        assert unmatched_elan == []

    def test_auto_match_case_insensitive(self):
        v = self._make_file_info("/dir/MyClip.mp4", "video")
        e = self._make_file_info("/dir/myclip.eaf", "elan")
        matches, _, _ = ws_module._auto_match([v], [e], [], {})
        assert len(matches) == 1
        assert matches[0].elan is not None

    def test_auto_match_annotations_suffix(self):
        v = self._make_file_info("/dir/demo.mp4", "video")
        e = self._make_file_info("/dir/demo_annotations.eaf", "elan")
        matches, _, unmatched = ws_module._auto_match([v], [e], [], {})
        assert len(matches) == 1
        assert matches[0].elan is not None
        assert matches[0].elan.stem == "demo_annotations"

    def test_auto_match_manual_override_pair(self):
        v = self._make_file_info("/dir/v.mp4", "video")
        e1 = self._make_file_info("/dir/v.eaf", "elan")
        e2 = self._make_file_info("/dir/custom.eaf", "elan")
        overrides = {"/dir/v.mp4": "/dir/custom.eaf"}
        matches, _, _ = ws_module._auto_match([v], [e1, e2], [], overrides)
        assert len(matches) == 1
        assert matches[0].elan is not None
        assert matches[0].elan.path == "/dir/custom.eaf"

    def test_auto_match_manual_override_unpair(self):
        v = self._make_file_info("/dir/v.mp4", "video")
        e = self._make_file_info("/dir/v.eaf", "elan")
        overrides = {"/dir/v.mp4": None}
        matches, _, unmatched = ws_module._auto_match([v], [e], [], overrides)
        assert len(matches) == 1
        assert matches[0].elan is None
        assert len(unmatched) == 1

    def test_auto_match_features_associated(self):
        v = self._make_file_info("/dir/sample.mp4", "video")
        f = self._make_file_info("/dir/sample.h5", "features")
        matches, _, _ = ws_module._auto_match([v], [], [f], {})
        assert len(matches) == 1
        assert len(matches[0].features) == 1
        assert matches[0].features[0].path == "/dir/sample.h5"

    def test_auto_match_same_dir_preferred(self):
        v = self._make_file_info("/root/clip.mp4", "video")
        e_same = self._make_file_info("/root/clip.eaf", "elan")
        e_other = self._make_file_info("/other/clip.eaf", "elan")
        matches, _, _ = ws_module._auto_match([v], [e_same, e_other], [], {})
        assert len(matches) == 1
        assert matches[0].elan is not None
        assert matches[0].elan.path == "/root/clip.eaf"


# ============================================================================
# TestFileInfoBuilder
# ============================================================================


class TestFileInfoBuilder:
    """Tests for the internal _file_info helper."""

    def test_file_info_existing_file(self, temp_dir):
        p = temp_dir / "test.mp4"
        p.write_bytes(b"x" * 1024)
        info = ws_module._file_info(p, "video")
        assert info.name == "test.mp4"
        assert info.stem == "test"
        assert info.extension == ".mp4"
        assert info.type == "video"
        assert info.size_mb == pytest.approx(1024 / (1024 * 1024), abs=0.01)

    def test_file_info_nonexistent_file_size_zero(self, temp_dir):
        p = temp_dir / "ghost.eaf"
        info = ws_module._file_info(p, "elan")
        assert info.size_mb == 0.0
        assert info.name == "ghost.eaf"


# ============================================================================
# TestSerialiseList
# ============================================================================


class TestSerialiseList:
    """Tests for the _serialise_list helper."""

    def test_serialise_pydantic_models(self):
        from sltk.api.models import WorkspaceFileInfo

        items = [
            WorkspaceFileInfo(
                path="/a.mp4",
                name="a.mp4",
                stem="a",
                extension=".mp4",
                size_mb=1.0,
                type="video",
            )
        ]
        result = ws_module._serialise_list(items)
        assert len(result) == 1
        assert isinstance(result[0], dict)
        assert result[0]["path"] == "/a.mp4"

    def test_serialise_dicts_passthrough(self):
        items = [{"key": "value"}]
        result = ws_module._serialise_list(items)
        assert result == [{"key": "value"}]

    def test_serialise_empty_list(self):
        assert ws_module._serialise_list([]) == []


# ============================================================================
# TestWorkspaceResponseShape
# ============================================================================


class TestWorkspaceResponseShape:
    """Tests that verify the response shape/schema from workspace endpoints."""

    def test_scan_response_has_required_fields(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        data = resp.json()
        required_keys = {
            "path",
            "matches",
            "unmatched_videos",
            "unmatched_elan",
            "features",
            "counts",
            "annotator_id",
            "workspace_name",
        }
        assert required_keys.issubset(data.keys()), f"Missing keys: {required_keys - data.keys()}"

    def test_match_entry_has_video_and_optional_elan(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        data = resp.json()
        assert len(data["matches"]) == 1
        m = data["matches"][0]
        assert "id" in m
        assert "video" in m
        assert m["video"]["type"] == "video"
        assert "elan" in m
        assert "features" in m

    def test_file_info_fields(self, client, temp_dir):
        _create_files(temp_dir, ["test.mp4"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        data = resp.json()
        video_info = data["matches"][0]["video"]
        for field in ["path", "name", "stem", "extension", "size_mb", "type"]:
            assert field in video_info, f"Missing field '{field}' in WorkspaceFileInfo"

    def test_counts_dict_has_expected_keys(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        counts = resp.json()["counts"]
        expected_keys = {"videos", "elan", "matched", "features"}
        assert expected_keys.issubset(counts.keys())

    def test_counts_dict_does_not_use_legacy_key_names(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4", "v.eaf", "orphan.eaf"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        counts = resp.json()["counts"]
        legacy_keys = {"total_videos", "unmatched_videos", "unmatched_elan", "total_elan"}
        found_legacy = legacy_keys & counts.keys()
        assert not found_legacy


# ============================================================================
# TestUploadThenUseFlow (Regression)
# ============================================================================


class TestUploadThenUseFlow:
    """Regression tests for the upload-first workflow."""

    def test_upload_without_prior_scan_returns_200(self, client):
        content = b"fake video content"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("first_upload.mp4", io.BytesIO(content), "video/mp4")},
        )
        assert response.status_code == 200

    def test_upload_without_prior_scan_sets_workspace_path(self, client):
        content = b"fake video content"
        client.post(
            "/api/workspace/upload",
            files={"file": ("video1.mp4", io.BytesIO(content), "video/mp4")},
        )
        status = client.get("/api/workspace/status").json()
        assert status["path"] != "", "After upload, workspace path should be set"

    def test_upload_response_is_workspace_scan_response(self, client):
        content = b"fake video content"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("sample.mp4", io.BytesIO(content), "video/mp4")},
        )
        assert response.status_code == 200
        data = response.json()
        required_keys = {
            "path",
            "matches",
            "unmatched_videos",
            "unmatched_elan",
            "features",
            "counts",
        }
        assert required_keys.issubset(data.keys())
        assert "stem" not in data
        assert "type" not in data

    def test_upload_response_counts_reflect_uploaded_file(self, client):
        content = b"fake video content"
        response = client.post(
            "/api/workspace/upload",
            files={"file": ("counted.mp4", io.BytesIO(content), "video/mp4")},
        )
        data = response.json()
        assert data["counts"]["videos"] >= 1

    def test_upload_then_rescan_succeeds(self, client):
        content = b"fake video data"
        upload_resp = client.post(
            "/api/workspace/upload",
            files={"file": ("rescan_test.mp4", io.BytesIO(content), "video/mp4")},
        )
        assert upload_resp.status_code == 200

        rescan_resp = client.post("/api/workspace/rescan")
        assert rescan_resp.status_code == 200
        data = rescan_resp.json()
        assert data["counts"]["videos"] >= 1

    def test_upload_then_status_reflects_upload(self, client):
        content = b"fake elan content"
        client.post(
            "/api/workspace/upload",
            files={"file": ("anno.eaf", io.BytesIO(content), "application/xml")},
        )
        status = client.get("/api/workspace/status").json()
        assert status["counts"]["elan"] >= 1

    def test_upload_multiple_files_then_rescan(self, client):
        client.post(
            "/api/workspace/upload",
            files={"file": ("paired.mp4", io.BytesIO(b"video"), "video/mp4")},
        )
        client.post(
            "/api/workspace/upload",
            files={"file": ("paired.eaf", io.BytesIO(b"elan"), "application/xml")},
        )
        rescan_resp = client.post("/api/workspace/rescan")
        assert rescan_resp.status_code == 200
        data = rescan_resp.json()
        assert data["counts"]["videos"] >= 1
        assert data["counts"]["elan"] >= 1
        assert data["counts"]["matched"] >= 1


# ============================================================================
# TestFetchUrlResponseType (Regression)
# ============================================================================


class TestFetchUrlResponseType:
    """Regression tests for /fetch-url response format."""

    def test_fetch_url_error_path_still_returns_proper_status(self, client):
        response = client.post(
            "/api/workspace/fetch-url",
            json={"url": "https://example.com/readme.txt"},
        )
        assert response.status_code == 400
        assert "Unsupported" in response.json()["detail"]


# ============================================================================
# TestRescanFallbackToUploadDir (Regression)
# ============================================================================


class TestRescanFallbackToUploadDir:
    """Regression: /rescan should fall back to upload directory."""

    def test_rescan_with_only_uploads_uses_upload_dir(self, client):
        content = b"test video"
        upload_resp = client.post(
            "/api/workspace/upload",
            files={"file": ("fallback.mp4", io.BytesIO(content), "video/mp4")},
        )
        assert upload_resp.status_code == 200

        status = client.get("/api/workspace/status").json()
        assert status["path"] != ""

        rescan_resp = client.post("/api/workspace/rescan")
        assert rescan_resp.status_code == 200

    def test_rescan_without_any_prior_action_returns_400(self, client):
        response = client.post("/api/workspace/rescan")
        assert response.status_code == 400
        assert "No workspace path" in response.json()["detail"]


# ============================================================================
# TestCountKeyNaming (Regression)
# ============================================================================


class TestCountKeyNaming:
    """Regression tests for count key naming convention."""

    def test_scan_count_keys_are_frontend_compatible(self, client, temp_dir):
        _create_files(temp_dir, ["v1.mp4", "v1.eaf", "v2.mp4", "orphan.eaf", "data.h5"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        counts = resp.json()["counts"]
        assert "videos" in counts
        assert "elan" in counts
        assert "matched" in counts
        assert "features" in counts
        assert counts["videos"] == 2
        assert counts["matched"] == 1
        assert counts["features"] >= 1

    def test_status_count_keys_are_frontend_compatible(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4", "v.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        counts = client.get("/api/workspace/status").json()["counts"]
        for key in ("videos", "elan", "matched", "features"):
            assert key in counts

    def test_rescan_count_keys_are_frontend_compatible(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4", "v.eaf"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        counts = client.post("/api/workspace/rescan").json()["counts"]
        for key in ("videos", "elan", "matched", "features"):
            assert key in counts

    def test_upload_count_keys_are_frontend_compatible(self, client):
        content = b"video bytes"
        resp = client.post(
            "/api/workspace/upload",
            files={"file": ("count_test.mp4", io.BytesIO(content), "video/mp4")},
        )
        counts = resp.json()["counts"]
        for key in ("videos", "elan", "matched", "features"):
            assert key in counts

    def test_no_legacy_count_keys_in_scan(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        counts = resp.json()["counts"]
        for legacy_key in ("total_videos", "total_elan", "unmatched_videos", "unmatched_elan"):
            assert legacy_key not in counts


# ============================================================================
# TestAnnotatorId
# ============================================================================


class TestAnnotatorId:
    """Tests for the annotator_id field and PUT /workspace/annotator endpoint."""

    def test_annotator_id_present_in_scan_response(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        resp = client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        assert "annotator_id" in resp.json()

    def test_annotator_id_present_in_status_response(self, client):
        data = client.get("/api/workspace/status").json()
        assert "annotator_id" in data

    def test_annotator_id_defaults_to_empty_string(self, client):
        data = client.get("/api/workspace/status").json()
        assert data["annotator_id"] == ""

    def test_set_annotator_id(self, client, temp_dir):
        # Need a workspace first
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        response = client.put(
            "/api/workspace/annotator",
            json={"annotator_id": "researcher_1"},
        )
        assert response.status_code == 200
        assert response.json()["annotator_id"] == "researcher_1"

    def test_set_annotator_id_persists_in_status(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.put("/api/workspace/annotator", json={"annotator_id": "dr_smith"})
        status = client.get("/api/workspace/status").json()
        assert status["annotator_id"] == "dr_smith"

    def test_set_annotator_id_strips_whitespace(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        response = client.put("/api/workspace/annotator", json={"annotator_id": "  spaced  "})
        assert response.json()["annotator_id"] == "spaced"

    def test_set_annotator_id_empty_clears_it(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.put("/api/workspace/annotator", json={"annotator_id": "something"})
        response = client.put("/api/workspace/annotator", json={"annotator_id": ""})
        assert response.json()["annotator_id"] == ""

    def test_annotator_id_survives_rescan(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.put("/api/workspace/annotator", json={"annotator_id": "persistent_annotator"})
        rescan_data = client.post("/api/workspace/rescan").json()
        assert rescan_data["annotator_id"] == "persistent_annotator"

    def test_clear_resets_annotator_id(self, client, temp_dir):
        _create_files(temp_dir, ["v.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.put("/api/workspace/annotator", json={"annotator_id": "will_be_cleared"})
        client.delete("/api/workspace/clear")
        status = client.get("/api/workspace/status").json()
        assert status["annotator_id"] == ""


# ============================================================================
# TestFeatureTypeDetection
# ============================================================================


class TestFeatureTypeDetection:
    """Tests for feature type detection and prefix-based feature matching."""

    def test_detect_feature_type_wilor(self):
        assert ws_module._detect_feature_type("video1_wilor.h5") == "wilor"

    def test_detect_feature_type_mediapipe(self):
        assert ws_module._detect_feature_type("video1_mediapipe.h5") == "mediapipe"

    def test_detect_feature_type_smpl(self):
        assert ws_module._detect_feature_type("video1_smpl.h5") == "smpl"
        assert ws_module._detect_feature_type("video1_smplx.h5") == "smpl"
        assert ws_module._detect_feature_type("video1_nlf.h5") == "smpl"

    def test_detect_feature_type_hamer(self):
        assert ws_module._detect_feature_type("video1_hamer.h5") == "wilor"

    def test_detect_feature_type_none_for_plain(self):
        assert ws_module._detect_feature_type("video1.h5") is None

    def test_detect_feature_type_case_insensitive(self):
        assert ws_module._detect_feature_type("Video1_Wilor.h5") == "wilor"
        assert ws_module._detect_feature_type("Video1_MEDIAPIPE.h5") == "mediapipe"

    def test_file_info_sets_feature_type_for_features(self, temp_dir):
        p = temp_dir / "clip_wilor.h5"
        p.touch()
        info = ws_module._file_info(p, "features")
        assert info.feature_type == "wilor"

    def test_file_info_no_feature_type_for_videos(self, temp_dir):
        p = temp_dir / "clip_wilor.mp4"
        p.touch()
        info = ws_module._file_info(p, "video")
        assert info.feature_type is None


# ============================================================================
# TestFeaturePrefixMatching
# ============================================================================


class TestFeaturePrefixMatching:
    """Tests for prefix-based feature matching in _auto_match."""

    def _make_file_info(self, path: str, file_type: str, feature_type: str | None = None):
        from sltk.api.models import WorkspaceFileInfo

        p = Path(path)
        return WorkspaceFileInfo(
            path=path,
            name=p.name,
            stem=p.stem,
            extension=p.suffix.lower(),
            size_mb=0.0,
            type=file_type,
            feature_type=feature_type,
        )

    def test_prefix_match_wilor(self):
        v = self._make_file_info("/dir/video1.mp4", "video")
        f = self._make_file_info("/dir/video1_wilor.h5", "features", "wilor")
        matches, _, _ = ws_module._auto_match([v], [], [f], {})
        assert len(matches) == 1
        assert len(matches[0].features) == 1
        assert matches[0].features[0].path == "/dir/video1_wilor.h5"

    def test_prefix_match_mediapipe(self):
        v = self._make_file_info("/dir/video1.mp4", "video")
        f = self._make_file_info("/dir/video1_mediapipe.h5", "features", "mediapipe")
        matches, _, _ = ws_module._auto_match([v], [], [f], {})
        assert len(matches[0].features) == 1

    def test_prefix_match_smplx(self):
        v = self._make_file_info("/dir/video1.mp4", "video")
        f = self._make_file_info("/dir/video1_smplx.h5", "features", "smpl")
        matches, _, _ = ws_module._auto_match([v], [], [f], {})
        assert len(matches[0].features) == 1

    def test_prefix_match_multiple_features(self):
        v = self._make_file_info("/dir/video1.mp4", "video")
        f1 = self._make_file_info("/dir/video1_wilor.h5", "features", "wilor")
        f2 = self._make_file_info("/dir/video1_mediapipe.h5", "features", "mediapipe")
        f3 = self._make_file_info("/dir/video1_nlf.h5", "features", "smpl")
        matches, _, _ = ws_module._auto_match([v], [], [f1, f2, f3], {})
        assert len(matches) == 1
        assert len(matches[0].features) == 3

    def test_exact_stem_fallback(self):
        v = self._make_file_info("/dir/video1.mp4", "video")
        f = self._make_file_info("/dir/video1.h5", "features")
        matches, _, _ = ws_module._auto_match([v], [], [f], {})
        assert len(matches[0].features) == 1

    def test_no_false_match_different_prefix(self):
        v = self._make_file_info("/dir/video1.mp4", "video")
        f = self._make_file_info("/dir/video2_wilor.h5", "features", "wilor")
        matches, _, _ = ws_module._auto_match([v], [], [f], {})
        assert len(matches[0].features) == 0

    def test_scan_returns_feature_type(self, client, temp_dir):
        _create_files(temp_dir, ["video1.mp4", "video1_wilor.h5", "video1_mediapipe.h5"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        assert len(data["matches"]) == 1
        features = data["matches"][0]["features"]
        assert len(features) == 2
        feature_types = {f["feature_type"] for f in features}
        assert "wilor" in feature_types
        assert "mediapipe" in feature_types

    def test_scan_prefix_match_integration(self, client, temp_dir):
        _create_files(temp_dir, ["video1.mp4", "video1_wilor.h5"])
        response = client.post(
            "/api/workspace/scan", json={"path": str(temp_dir), "recursive": False}
        )
        assert response.status_code == 200
        data = response.json()
        match = data["matches"][0]
        assert match["video"]["stem"] == "video1"
        assert len(match["features"]) == 1
        assert match["features"][0]["stem"] == "video1_wilor"
        assert match["features"][0]["feature_type"] == "wilor"


# ============================================================================
# TestMultiWorkspace - CRUD operations
# ============================================================================


class TestMultiWorkspace:
    """Tests for multi-workspace CRUD operations."""

    def test_list_workspaces_empty(self, client):
        """Listing workspaces when none exist should return empty list."""
        resp = client.get("/api/workspace/list")
        assert resp.status_code == 200
        data = resp.json()
        assert data["workspaces"] == []
        assert data["active_workspace"] is None

    def test_create_workspace(self, client, temp_dir):
        """Creating a workspace should scan the folder and return results."""
        _create_files(temp_dir, ["v1.mp4", "v1.eaf"])
        resp = client.post(
            "/api/workspace/create",
            json={"name": "test-ws", "folder_path": str(temp_dir)},
        )
        assert resp.status_code == 200
        data = resp.json()
        assert data["workspace_name"] == "test-ws"
        assert data["counts"]["videos"] == 1
        assert data["counts"]["matched"] == 1

    def test_create_workspace_duplicate_name_error(self, client, temp_dir):
        """Creating a workspace with an existing name should return 409."""
        _create_files(temp_dir, ["v.mp4"])
        client.post(
            "/api/workspace/create",
            json={"name": "dup", "folder_path": str(temp_dir)},
        )
        resp = client.post(
            "/api/workspace/create",
            json={"name": "dup", "folder_path": str(temp_dir)},
        )
        assert resp.status_code == 409

    def test_list_workspaces_multiple(self, client, temp_dir):
        """After creating multiple workspaces, list should return all."""
        _create_files(temp_dir, ["v.mp4"])
        sub = temp_dir / "sub"
        sub.mkdir()
        _create_files(sub, ["w.mp4"])

        # Add sub to ALLOWED_ROOTS
        api_deps.ALLOWED_ROOTS.append(sub.resolve())

        client.post(
            "/api/workspace/create",
            json={"name": "ws1", "folder_path": str(temp_dir)},
        )
        client.post(
            "/api/workspace/create",
            json={"name": "ws2", "folder_path": str(sub)},
        )

        resp = client.get("/api/workspace/list")
        data = resp.json()
        assert len(data["workspaces"]) == 2
        names = {ws["name"] for ws in data["workspaces"]}
        assert names == {"ws1", "ws2"}
        assert data["active_workspace"] == "ws2"  # last created is active

    def test_switch_workspace(self, client, temp_dir):
        """Switching workspace should change the active workspace."""
        _create_files(temp_dir, ["v.mp4"])
        sub = temp_dir / "sub"
        sub.mkdir()
        _create_files(sub, ["w.mp4", "w.eaf"])
        api_deps.ALLOWED_ROOTS.append(sub.resolve())

        client.post(
            "/api/workspace/create",
            json={"name": "first", "folder_path": str(temp_dir)},
        )
        client.post(
            "/api/workspace/create",
            json={"name": "second", "folder_path": str(sub)},
        )

        # Switch to first
        resp = client.post("/api/workspace/switch", json={"name": "first"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["workspace_name"] == "first"
        assert data["path"] == str(temp_dir)

    def test_switch_workspace_nonexistent_error(self, client):
        """Switching to a nonexistent workspace should return 404."""
        resp = client.post("/api/workspace/switch", json={"name": "nonexistent"})
        assert resp.status_code == 404

    def test_delete_workspace(self, client, temp_dir):
        """Deleting a workspace should remove its config."""
        _create_files(temp_dir, ["v.mp4"])
        client.post(
            "/api/workspace/create",
            json={"name": "to-delete", "folder_path": str(temp_dir)},
        )
        resp = client.delete("/api/workspace/to-delete")
        assert resp.status_code == 200
        assert resp.json()["status"] == "deleted"

        # Should no longer appear in list
        list_data = client.get("/api/workspace/list").json()
        names = {ws["name"] for ws in list_data["workspaces"]}
        assert "to-delete" not in names

    def test_delete_workspace_does_not_delete_files(self, client, temp_dir):
        """Deleting workspace config should NOT delete files on disk."""
        files = _create_files(temp_dir, ["v.mp4"])
        client.post(
            "/api/workspace/create",
            json={"name": "nodelete", "folder_path": str(temp_dir)},
        )
        client.delete("/api/workspace/nodelete")
        # Files must still exist
        assert files[0].exists()

    def test_rename_workspace(self, client, temp_dir):
        """Renaming a workspace should update its name."""
        _create_files(temp_dir, ["v.mp4"])
        client.post(
            "/api/workspace/create",
            json={"name": "old-name", "folder_path": str(temp_dir)},
        )
        resp = client.put(
            "/api/workspace/rename",
            json={"old_name": "old-name", "new_name": "new-name"},
        )
        assert resp.status_code == 200
        assert resp.json()["new_name"] == "new-name"

        list_data = client.get("/api/workspace/list").json()
        names = {ws["name"] for ws in list_data["workspaces"]}
        assert "new-name" in names
        assert "old-name" not in names
        assert list_data["active_workspace"] == "new-name"


# ============================================================================
# TestVideoManagement - detach, reattach, add external
# ============================================================================


class TestVideoManagement:
    """Tests for video detach/reattach/add operations."""

    def test_detach_video_hides_from_results(self, client, temp_dir):
        """Detaching a video should hide it from matches."""
        _create_files(temp_dir, ["v1.mp4", "v2.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        v1_path = str(temp_dir / "v1.mp4")
        resp = client.post("/api/workspace/detach-video", json={"video_path": v1_path})
        assert resp.status_code == 200
        data = resp.json()
        video_paths = [m["video"]["path"] for m in data["matches"]]
        assert v1_path not in video_paths
        assert data["counts"]["videos"] == 1  # only v2 remains

    def test_detach_video_does_not_delete_file(self, client, temp_dir):
        """Detaching should NOT delete the file from disk."""
        files = _create_files(temp_dir, ["v1.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        client.post("/api/workspace/detach-video", json={"video_path": str(files[0])})
        assert files[0].exists()

    def test_reattach_video_shows_in_results(self, client, temp_dir):
        """Reattaching a video should make it visible again."""
        _create_files(temp_dir, ["v1.mp4", "v2.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        v1_path = str(temp_dir / "v1.mp4")
        client.post("/api/workspace/detach-video", json={"video_path": v1_path})

        resp = client.post("/api/workspace/reattach-video", json={"video_path": v1_path})
        assert resp.status_code == 200
        data = resp.json()
        video_paths = [m["video"]["path"] for m in data["matches"]]
        assert v1_path in video_paths

    def test_add_video_ref_external_path(self, client, temp_dir):
        """Adding an external video ref should include it in results."""
        _create_files(temp_dir, ["v1.mp4"])
        # Create a second dir with an external video
        sub = temp_dir / "external"
        sub.mkdir()
        ext_video = sub / "ext.mp4"
        ext_video.touch()
        api_deps.ALLOWED_ROOTS.append(sub.resolve())

        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        resp = client.post("/api/workspace/add-video", json={"video_path": str(ext_video)})
        assert resp.status_code == 200
        data = resp.json()
        video_paths = [m["video"]["path"] for m in data["matches"]]
        assert str(ext_video) in video_paths

    def test_scan_respects_detached_flag(self, client, temp_dir):
        """After detaching, rescan should still exclude the video."""
        _create_files(temp_dir, ["v1.mp4", "v2.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})

        v1_path = str(temp_dir / "v1.mp4")
        client.post("/api/workspace/detach-video", json={"video_path": v1_path})

        rescan_data = client.post("/api/workspace/rescan").json()
        video_paths = [m["video"]["path"] for m in rescan_data["matches"]]
        assert v1_path not in video_paths

    def test_rescan_discovers_new_videos(self, client, temp_dir):
        """Rescan should pick up newly added files."""
        _create_files(temp_dir, ["v1.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        assert client.get("/api/workspace/status").json()["counts"]["videos"] == 1

        _create_files(temp_dir, ["v2.mp4"])
        rescan_data = client.post("/api/workspace/rescan").json()
        assert rescan_data["counts"]["videos"] == 2

    def test_rescan_preserves_detached_state(self, client, temp_dir):
        """Detached state should persist across rescans."""
        _create_files(temp_dir, ["v1.mp4", "v2.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        v1_path = str(temp_dir / "v1.mp4")
        client.post("/api/workspace/detach-video", json={"video_path": v1_path})

        # Rescan twice
        client.post("/api/workspace/rescan")
        data = client.post("/api/workspace/rescan").json()
        video_paths = [m["video"]["path"] for m in data["matches"]]
        assert v1_path not in video_paths

    def test_add_video_ref_duplicate_ignored(self, client, temp_dir):
        """Adding an already-referenced video should not duplicate it."""
        _create_files(temp_dir, ["v1.mp4"])
        client.post("/api/workspace/scan", json={"path": str(temp_dir), "recursive": False})
        v1_path = str(temp_dir / "v1.mp4")

        # Add same video again
        resp = client.post("/api/workspace/add-video", json={"video_path": v1_path})
        assert resp.status_code == 200
        data = resp.json()
        assert data["counts"]["videos"] == 1  # not duplicated


# ============================================================================
# TestMigration
# ============================================================================


class TestMigration:
    """Tests for v0 -> v1 workspace migration."""

    def test_migrate_v0_to_v1_creates_default_workspace(self, temp_dir):
        """Old workspace.json should be migrated to a Default workspace."""
        old_data = {
            "path": str(temp_dir),
            "matches": [],
            "unmatched_videos": [],
            "unmatched_elan": [],
            "features": [],
            "manual_overrides": {"/some/video.mp4": "/some/video.eaf"},
            "upload_dir": None,
            "annotator_id": "test_annotator",
        }
        ws_module._SLTK_DIR.mkdir(parents=True, exist_ok=True)
        with open(ws_module.WORKSPACE_JSON, "w") as f:
            json.dump(old_data, f)

        # Force reload
        mgr = ws_module._manager
        mgr._loaded = False
        mgr._data = ws_module.WorkspacesFile()
        mgr.load()

        assert "Default" in mgr._data.workspaces
        ws = mgr._data.workspaces["Default"]
        assert ws.folder_path == str(temp_dir)
        assert ws.annotator_id == "test_annotator"
        assert mgr._data.active_workspace == "Default"

    def test_migrate_v0_to_v1_preserves_manual_overrides(self, temp_dir):
        """Migration should preserve manual overrides."""
        old_data = {
            "path": str(temp_dir),
            "manual_overrides": {"/a.mp4": "/b.eaf"},
        }
        ws_module._SLTK_DIR.mkdir(parents=True, exist_ok=True)
        with open(ws_module.WORKSPACE_JSON, "w") as f:
            json.dump(old_data, f)

        mgr = ws_module._manager
        mgr._loaded = False
        mgr._data = ws_module.WorkspacesFile()
        mgr.load()

        ws = mgr._data.workspaces["Default"]
        assert ws.manual_overrides == {"/a.mp4": "/b.eaf"}

    def test_migrate_v0_to_v1_noop_if_already_migrated(self, temp_dir):
        """If workspaces.json already exists, migration should be a no-op."""
        # Create both files
        ws_module._SLTK_DIR.mkdir(parents=True, exist_ok=True)
        with open(ws_module.WORKSPACE_JSON, "w") as f:
            json.dump({"path": "/old"}, f)
        new_data = {
            "version": 1,
            "active_workspace": "Custom",
            "workspaces": {
                "Custom": {
                    "name": "Custom",
                    "folder_path": "/new",
                    "created_at": "2026-01-01",
                    "last_accessed": "2026-01-01",
                }
            },
        }
        with open(ws_module.WORKSPACES_JSON, "w") as f:
            json.dump(new_data, f)

        mgr = ws_module._manager
        mgr._loaded = False
        mgr._data = ws_module.WorkspacesFile()
        mgr.load()

        # Should load the new file, not migrate the old one
        assert "Custom" in mgr._data.workspaces
        assert "Default" not in mgr._data.workspaces

    def test_migrate_v0_to_v1_noop_if_no_old_file(self):
        """If no old workspace.json exists, migration should be a no-op."""
        mgr = ws_module._manager
        mgr._loaded = False
        mgr._data = ws_module.WorkspacesFile()
        mgr.load()

        assert len(mgr._data.workspaces) == 0
