"""
Workspace router for simplified data ingestion with multi-workspace support.

Users point to a directory (or upload files, or provide URLs) containing videos
and optionally ELAN annotation files. The system auto-discovers and matches them
by filename stem.

Matching algorithm:
1. Apply manual overrides first (user-specified pairings).
2. Index ELAN files by stem (case-insensitive).
3. For each video, look up its stem in the ELAN index.
4. Handle ``_annotations`` suffix stripping so that
   ``video1_annotations.eaf`` matches ``video1.mp4``.
5. Same-directory matches are preferred on collision.

Multi-workspace model:
- Multiple named workspaces stored in ``~/.sltk/workspaces.json``
- One "active workspace" at a time; all existing endpoints operate on it
- Videos are referenced by path (never copied or deleted)
- Detaching a video hides it from the workspace without deleting the file
- Migration from old ``workspace.json`` to new format is automatic
"""

from __future__ import annotations

import asyncio
import json
import logging
import shutil
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Literal

from fastapi import APIRouter, File, HTTPException, UploadFile

from sltk.api.dependencies import safe_error_detail, validate_path
from sltk.api.models import (
    AddFolderRequest,
    AddVideoRefRequest,
    CreateWorkspaceRequest,
    DetachVideoRequest,
    DiscoverDirectoryRequest,
    DiscoverDirectoryResponse,
    RenameWorkspaceRequest,
    SwitchWorkspaceRequest,
    VideoRef,
    WorkspaceConfig,
    WorkspaceFetchUrlRequest,
    WorkspaceFileInfo,
    WorkspaceListItem,
    WorkspaceListResponse,
    WorkspaceManualMatchRequest,
    WorkspaceMatch,
    WorkspaceScanRequest,
    WorkspaceScanResponse,
    WorkspacesFile,
)
from sltk.api.security import sanitize_filename

logger = logging.getLogger(__name__)

router = APIRouter(prefix="/api/workspace", tags=["workspace"])

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

VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".avi", ".mkv", ".webm"})
ELAN_EXTENSIONS: frozenset[str] = frozenset({".eaf"})
FEATURE_EXTENSIONS: frozenset[str] = frozenset({".h5", ".hdf5"})

ALL_EXTENSIONS: frozenset[str] = VIDEO_EXTENSIONS | ELAN_EXTENSIONS | FEATURE_EXTENSIONS

MAX_SCAN_DEPTH = 10
MAX_UPLOAD_SIZE_MB = 500
MAX_WORKSPACES = 50

# Threading event used to cancel an in-progress scan
_scan_cancel = threading.Event()

# Known feature-type suffixes (order matters: longer suffixes first)
_FEATURE_SUFFIXES: tuple[tuple[str, str], ...] = (
    ("_mediapipe", "mediapipe"),
    ("_smplx", "smpl"),
    ("_smpl", "smpl"),
    ("_nlf", "smpl"),
    ("_wilor", "wilor"),
    ("_hamer", "wilor"),
    ("_angles", "mediapipe"),
)


def _detect_feature_type(filename: str) -> str | None:
    """Detect the feature type from a feature filename.

    Returns ``"wilor"``, ``"smpl"``, ``"mediapipe"``, or ``None``.
    """
    stem = Path(filename).stem.lower()
    for suffix, ftype in _FEATURE_SUFFIXES:
        if stem.endswith(suffix):
            return ftype
    return None


# ---------------------------------------------------------------------------
# Background ingestion status tracking
# ---------------------------------------------------------------------------

_ingestion_status: dict[str, Any] = {
    "running": False,
    "total": 0,
    "completed": 0,
    "failed": 0,
    "current_file": None,
}


# Persistence / upload paths (under user home)
_SLTK_DIR = Path.home() / ".sltk"
WORKSPACE_JSON = _SLTK_DIR / "workspace.json"  # old single-workspace file
WORKSPACES_JSON = _SLTK_DIR / "workspaces.json"  # new multi-workspace file
UPLOAD_DIR = _SLTK_DIR / "uploads"


# ---------------------------------------------------------------------------
# File-info builder
# ---------------------------------------------------------------------------


def _file_info(path: Path, file_type: Literal["video", "elan", "features"]) -> WorkspaceFileInfo:
    """Build a ``WorkspaceFileInfo`` from a path on disk."""
    try:
        size_mb = path.stat().st_size / (1024 * 1024)
    except OSError:
        size_mb = 0.0

    feature_type = _detect_feature_type(path.name) if file_type == "features" else None

    return WorkspaceFileInfo(
        path=str(path),
        name=path.name,
        stem=path.stem,
        extension=path.suffix.lower(),
        size_mb=round(size_mb, 2),
        type=file_type,
        feature_type=feature_type,
    )


# ---------------------------------------------------------------------------
# Directory scanning (sync, run via asyncio.to_thread)
# ---------------------------------------------------------------------------


class ScanCancelled(Exception):
    """Raised when a scan is cancelled by the user."""


def _scan_directory(root: Path, recursive: bool) -> tuple[
    list[WorkspaceFileInfo],
    list[WorkspaceFileInfo],
    list[WorkspaceFileInfo],
]:
    """Walk *root*, classify files, and return (videos, elan, features).

    When *recursive* is ``True`` the walk is bounded to ``MAX_SCAN_DEPTH``
    levels below *root* to avoid runaway traversal.

    Raises ``ScanCancelled`` if ``_scan_cancel`` is set during traversal.
    """
    videos: list[WorkspaceFileInfo] = []
    elan_files: list[WorkspaceFileInfo] = []
    feature_files: list[WorkspaceFileInfo] = []

    root_depth = len(root.parts)

    def _visit(directory: Path) -> None:
        if _scan_cancel.is_set():
            raise ScanCancelled()
        depth = len(directory.parts) - root_depth
        if recursive and depth > MAX_SCAN_DEPTH:
            return
        try:
            entries = sorted(directory.iterdir())
        except PermissionError:
            logger.warning("Permission denied: %s", directory)
            return

        for entry in entries:
            if _scan_cancel.is_set():
                raise ScanCancelled()
            if entry.name.startswith("."):
                continue
            if entry.is_dir():
                if recursive and depth < MAX_SCAN_DEPTH:
                    _visit(entry)
                continue
            if not entry.is_file():
                continue

            ext = entry.suffix.lower()
            if ext in VIDEO_EXTENSIONS:
                videos.append(_file_info(entry, "video"))
            elif ext in ELAN_EXTENSIONS:
                elan_files.append(_file_info(entry, "elan"))
            elif ext in FEATURE_EXTENSIONS:
                feature_files.append(_file_info(entry, "features"))

    _visit(root)
    return videos, elan_files, feature_files


# ---------------------------------------------------------------------------
# Auto-matching algorithm
# ---------------------------------------------------------------------------


def _auto_match(
    videos: list[WorkspaceFileInfo],
    elan_files: list[WorkspaceFileInfo],
    feature_files: list[WorkspaceFileInfo],
    manual_overrides: dict[str, str | None],
) -> tuple[
    list[WorkspaceMatch],
    list[WorkspaceFileInfo],
    list[WorkspaceFileInfo],
]:
    """Match videos to ELAN annotation files by filename stem.

    Returns ``(matches, unmatched_videos, unmatched_elan)``.
    """

    # Build ELAN index: normalised_stem -> list[WorkspaceFileInfo]
    elan_index: dict[str, list[WorkspaceFileInfo]] = {}
    for ef in elan_files:
        stem_lower = ef.stem.lower()
        elan_index.setdefault(stem_lower, []).append(ef)
        if stem_lower.endswith("_annotations"):
            stripped = stem_lower[: -len("_annotations")]
            elan_index.setdefault(stripped, []).append(ef)

    # Build feature index: exact stem + prefix-stripped stem
    feat_index: dict[str, list[WorkspaceFileInfo]] = {}
    for ff in feature_files:
        stem_lower = ff.stem.lower()
        feat_index.setdefault(stem_lower, []).append(ff)
        for suffix, _ in _FEATURE_SUFFIXES:
            if stem_lower.endswith(suffix):
                base_stem = stem_lower[: -len(suffix)]
                feat_index.setdefault(base_stem, []).append(ff)
                break

    used_elan_paths: set[str] = set()
    matches: list[WorkspaceMatch] = []
    unmatched_videos: list[WorkspaceFileInfo] = []

    for video in videos:
        matched_elan: WorkspaceFileInfo | None = None

        if video.path in manual_overrides:
            override_path = manual_overrides[video.path]
            if override_path is not None:
                for ef in elan_files:
                    if ef.path == override_path:
                        matched_elan = ef
                        break
        else:
            stem_lower = video.stem.lower()
            candidates = elan_index.get(stem_lower, [])
            if candidates:
                video_dir = str(Path(video.path).parent)
                same_dir = [c for c in candidates if str(Path(c.path).parent) == video_dir]
                chosen = same_dir[0] if same_dir else candidates[0]
                if chosen.path not in used_elan_paths:
                    matched_elan = chosen

        if matched_elan is not None:
            used_elan_paths.add(matched_elan.path)

        matched_features = feat_index.get(video.stem.lower(), [])

        match = WorkspaceMatch(
            video=video,
            elan=matched_elan,
            features=matched_features,
        )
        if matched_elan is not None or matched_features:
            matches.append(match)
        else:
            matches.append(match)

    unmatched_elan = [ef for ef in elan_files if ef.path not in used_elan_paths]

    return matches, unmatched_videos, unmatched_elan


# ---------------------------------------------------------------------------
# Serialise Pydantic lists for JSON persistence
# ---------------------------------------------------------------------------


def _serialise_list(items: list) -> list[dict]:
    """Convert a list of Pydantic models (or dicts) to plain dicts."""
    out: list[dict] = []
    for item in items:
        if hasattr(item, "model_dump"):
            out.append(item.model_dump())
        elif isinstance(item, dict):
            out.append(item)
        else:
            out.append(dict(item))
    return out


# ---------------------------------------------------------------------------
# WorkspacesManager -- multi-workspace persistence and state
# ---------------------------------------------------------------------------


def _now_iso() -> str:
    """Return current UTC time in ISO format."""
    return datetime.now(timezone.utc).isoformat()


class WorkspacesManager:
    """Manages multiple named workspaces with disk persistence.

    State is stored in ``~/.sltk/workspaces.json``. On first load, the
    manager checks for the old single-workspace file and migrates it.
    """

    def __init__(self) -> None:
        self._data: WorkspacesFile = WorkspacesFile()
        self._loaded = False
        # In-memory cache of scan results for the active workspace.
        # Keys: matches, unmatched_videos, unmatched_elan, features, upload_dir
        self._scan_cache: dict[str, Any] = self._empty_scan_cache()
        # Lazy-opened CorpusDB per workspace name
        self._corpus_dbs: dict[str, Any] = {}

    @staticmethod
    def _empty_scan_cache() -> dict[str, Any]:
        return {
            "matches": [],
            "unmatched_videos": [],
            "unmatched_elan": [],
            "features": [],
            "upload_dir": None,
        }

    # -- Persistence --------------------------------------------------------

    def load(self) -> None:
        """Load workspaces from disk (once)."""
        if self._loaded:
            return
        self._loaded = True

        # Try migration first
        self._migrate_v0_to_v1()

        if not WORKSPACES_JSON.exists():
            return
        try:
            with open(WORKSPACES_JSON) as fh:
                raw = json.load(fh)
            self._data = WorkspacesFile(**raw)
            logger.info(
                "Loaded %d workspace(s) from %s",
                len(self._data.workspaces),
                WORKSPACES_JSON,
            )
        except (OSError, json.JSONDecodeError, Exception):
            logger.exception("Failed to load workspaces; starting fresh")

    def save(self) -> None:
        """Persist all workspace configs to disk (blocking)."""
        try:
            _SLTK_DIR.mkdir(parents=True, exist_ok=True)
            with open(WORKSPACES_JSON, "w") as fh:
                json.dump(self._data.model_dump(), fh, indent=2, default=str)
        except OSError:
            logger.exception("Failed to persist workspaces")

    def _migrate_v0_to_v1(self) -> None:
        """Migrate old ``workspace.json`` to new multi-workspace format.

        The old workspace becomes the "Default" workspace.
        The old file is never deleted.
        """
        if WORKSPACES_JSON.exists() or not WORKSPACE_JSON.exists():
            return
        try:
            with open(WORKSPACE_JSON) as fh:
                old_data = json.load(fh)
            name = "Default"
            now = _now_iso()
            config = WorkspaceConfig(
                name=name,
                folder_path=old_data.get("path") or "",
                created_at=now,
                last_accessed=now,
                annotator_id=old_data.get("annotator_id", ""),
                video_refs=[],
                manual_overrides=old_data.get("manual_overrides", {}),
                scan_config={"recursive": True},
            )
            self._data = WorkspacesFile(
                version=1,
                active_workspace=name,
                workspaces={name: config},
            )
            self.save()
            logger.info("Migrated old workspace.json to workspaces.json")
        except (OSError, json.JSONDecodeError):
            logger.exception("Migration from v0 workspace failed")

    # -- Active workspace ---------------------------------------------------

    @property
    def active_name(self) -> str | None:
        return self._data.active_workspace

    @property
    def active(self) -> WorkspaceConfig | None:
        name = self._data.active_workspace
        if name is None:
            return None
        return self._data.workspaces.get(name)

    def _require_active(self) -> WorkspaceConfig:
        ws = self.active
        if ws is None:
            raise HTTPException(
                400,
                "No active workspace. Create or switch to one first.",
            )
        return ws

    # -- CRUD ---------------------------------------------------------------

    def list_workspaces(self) -> WorkspaceListResponse:
        items: list[WorkspaceListItem] = []
        for ws in self._data.workspaces.values():
            active_count = sum(1 for vr in ws.video_refs if not vr.detached)
            items.append(
                WorkspaceListItem(
                    name=ws.name,
                    folder_path=ws.folder_path,
                    video_count=active_count,
                    last_accessed=ws.last_accessed,
                )
            )
        return WorkspaceListResponse(
            workspaces=items,
            active_workspace=self._data.active_workspace,
        )

    def create_workspace(
        self, name: str, folder_path: str, recursive: bool = True
    ) -> WorkspaceConfig:
        if name in self._data.workspaces:
            raise HTTPException(409, f"Workspace '{name}' already exists")
        if len(self._data.workspaces) >= MAX_WORKSPACES:
            raise HTTPException(
                400,
                f"Maximum number of workspaces ({MAX_WORKSPACES}) reached",
            )
        now = _now_iso()
        config = WorkspaceConfig(
            name=name,
            folder_path=folder_path,
            created_at=now,
            last_accessed=now,
            scan_config={"recursive": recursive},
        )
        self._data.workspaces[name] = config
        self._data.active_workspace = name
        self._scan_cache = self._empty_scan_cache()
        return config

    def switch_workspace(self, name: str) -> WorkspaceConfig:
        if name not in self._data.workspaces:
            raise HTTPException(404, f"Workspace '{name}' not found")
        # Close old corpus DB before switching
        old_name = self._data.active_workspace
        if old_name and old_name != name:
            self.close_corpus_db(old_name)
        self._data.active_workspace = name
        ws = self._data.workspaces[name]
        ws.last_accessed = _now_iso()
        self._scan_cache = self._empty_scan_cache()
        return ws

    def delete_workspace(self, name: str) -> None:
        if name not in self._data.workspaces:
            raise HTTPException(404, f"Workspace '{name}' not found")
        # Close and remove corpus DB
        self.close_corpus_db(name)
        # Remove workspace data directory (contains corpus.db)
        ws_dir = _SLTK_DIR / "workspaces" / name
        if ws_dir.is_dir():
            try:
                shutil.rmtree(ws_dir, ignore_errors=True)
            except Exception:
                logger.exception("Failed to remove workspace dir: %s", ws_dir)
        del self._data.workspaces[name]
        if self._data.active_workspace == name:
            # Switch to another workspace if available
            remaining = list(self._data.workspaces.keys())
            self._data.active_workspace = remaining[0] if remaining else None
            self._scan_cache = self._empty_scan_cache()

    def rename_workspace(self, old_name: str, new_name: str) -> WorkspaceConfig:
        if old_name not in self._data.workspaces:
            raise HTTPException(404, f"Workspace '{old_name}' not found")
        if new_name in self._data.workspaces:
            raise HTTPException(409, f"Workspace '{new_name}' already exists")
        # Close old corpus DB, rename directory
        self.close_corpus_db(old_name)
        old_dir = _SLTK_DIR / "workspaces" / old_name
        new_dir = _SLTK_DIR / "workspaces" / new_name
        if old_dir.is_dir():
            try:
                old_dir.rename(new_dir)
            except OSError:
                logger.exception("Failed to rename workspace dir")
        ws = self._data.workspaces.pop(old_name)
        ws.name = new_name
        self._data.workspaces[new_name] = ws
        if self._data.active_workspace == old_name:
            self._data.active_workspace = new_name
        return ws

    # -- Video ref management -----------------------------------------------

    def detach_video(self, video_path: str) -> None:
        ws = self._require_active()
        for ref in ws.video_refs:
            if ref.path == video_path:
                ref.detached = True
                return
        raise HTTPException(404, "Video not found in active workspace")

    def reattach_video(self, video_path: str) -> None:
        ws = self._require_active()
        for ref in ws.video_refs:
            if ref.path == video_path:
                if not Path(video_path).exists():
                    raise HTTPException(
                        404,
                        "Cannot reattach: file no longer exists on disk",
                    )
                ref.detached = False
                return
        raise HTTPException(404, "Video not found in active workspace")

    def add_video_ref(self, video_path: str) -> None:
        ws = self._require_active()
        # Check if already referenced
        for ref in ws.video_refs:
            if ref.path == video_path:
                # If it was detached, reattach it
                ref.detached = False
                return
        ws.video_refs.append(VideoRef(path=video_path, detached=False))

    # -- Corpus DB management -----------------------------------------------

    def get_corpus_db(self):
        """Return (or lazy-open) the CorpusDB for the active workspace."""
        from sltk.db.corpus import CorpusDB

        ws = self._require_active()
        name = ws.name
        if name not in self._corpus_dbs:
            ws_dir = _SLTK_DIR / "workspaces" / name
            ws_dir.mkdir(parents=True, exist_ok=True)
            db_path = ws_dir / "corpus.db"
            self._corpus_dbs[name] = CorpusDB(db_path)
        return self._corpus_dbs[name]

    def get_corpus_db_by_name(self, name: str):
        """Return (or lazy-open) the CorpusDB for any workspace by name."""
        from sltk.db.corpus import CorpusDB

        if name not in self._corpus_dbs:
            ws_dir = _SLTK_DIR / "workspaces" / name
            db_path = ws_dir / "corpus.db"
            if not db_path.exists():
                raise ValueError(f"No corpus DB for workspace '{name}'")
            self._corpus_dbs[name] = CorpusDB(db_path)
        return self._corpus_dbs[name]

    def close_corpus_db(self, name: str) -> None:
        """Close and remove the CorpusDB for *name*."""
        db = self._corpus_dbs.pop(name, None)
        if db is not None:
            db.close()

    # -- Scan & match helpers -----------------------------------------------

    def merge_video_refs(
        self,
        ws: WorkspaceConfig,
        discovered_videos: list[WorkspaceFileInfo],
    ) -> list[WorkspaceFileInfo]:
        """Merge discovered videos with existing video_refs.

        - New videos get added as active refs.
        - Detached videos are excluded from the returned list.
        - Externally-added refs (not in scan results) are included
          if the file still exists.

        Returns the list of *active* videos for matching.
        """
        existing_paths = {ref.path for ref in ws.video_refs}
        {ref.path for ref in ws.video_refs if ref.detached}

        # Add newly discovered videos to refs
        for v in discovered_videos:
            if v.path not in existing_paths:
                ws.video_refs.append(VideoRef(path=v.path, detached=False))
                existing_paths.add(v.path)

        # Build active video list: discovered + external refs, minus detached
        discovered_map = {v.path: v for v in discovered_videos}
        active_videos: list[WorkspaceFileInfo] = []

        for ref in ws.video_refs:
            if ref.detached:
                continue
            if ref.path in discovered_map:
                active_videos.append(discovered_map[ref.path])
            else:
                # Externally-added video -- build file info if it exists
                p = Path(ref.path)
                if p.is_file():
                    active_videos.append(_file_info(p, "video"))

        return active_videos

    def update_scan_cache(
        self,
        matches: list[WorkspaceMatch],
        unmatched_videos: list[WorkspaceFileInfo],
        unmatched_elan: list[WorkspaceFileInfo],
        features: list[WorkspaceFileInfo],
    ) -> None:
        """Store scan results in the in-memory cache."""
        self._scan_cache["matches"] = _serialise_list(matches)
        self._scan_cache["unmatched_videos"] = _serialise_list(unmatched_videos)
        self._scan_cache["unmatched_elan"] = _serialise_list(unmatched_elan)
        self._scan_cache["features"] = _serialise_list(features)

    def build_response(self) -> WorkspaceScanResponse:
        """Build a ``WorkspaceScanResponse`` from current state."""
        ws = self.active

        matches_raw = self._scan_cache.get("matches", [])
        matches = [m if isinstance(m, WorkspaceMatch) else WorkspaceMatch(**m) for m in matches_raw]
        unmatched_videos_raw = self._scan_cache.get("unmatched_videos", [])
        unmatched_videos = [
            v if isinstance(v, WorkspaceFileInfo) else WorkspaceFileInfo(**v)
            for v in unmatched_videos_raw
        ]
        unmatched_elan_raw = self._scan_cache.get("unmatched_elan", [])
        unmatched_elan = [
            e if isinstance(e, WorkspaceFileInfo) else WorkspaceFileInfo(**e)
            for e in unmatched_elan_raw
        ]
        features_raw = self._scan_cache.get("features", [])
        features = [
            f if isinstance(f, WorkspaceFileInfo) else WorkspaceFileInfo(**f) for f in features_raw
        ]

        total_videos = len(matches) + len(unmatched_videos)
        total_elan = sum(1 for m in matches if m.elan is not None) + len(unmatched_elan)
        matched_count = sum(1 for m in matches if m.elan is not None)

        return WorkspaceScanResponse(
            workspace_name=ws.name if ws else "",
            path=ws.folder_path if ws else "",
            matches=matches,
            unmatched_videos=unmatched_videos,
            unmatched_elan=unmatched_elan,
            features=features,
            counts={
                "videos": total_videos,
                "elan": total_elan,
                "matched": matched_count,
                "features": len(features),
            },
            annotator_id=ws.annotator_id if ws else "",
        )


# Module-level manager instance
_manager = WorkspacesManager()

# Backward-compatibility aliases used by tests
_workspace_state: dict[str, Any] = {}
_state_loaded = False


def _ensure_loaded() -> None:
    """Ensure the workspaces manager has loaded from disk."""
    _manager.load()


# ============================================================================
# Helper: scan active workspace and rebuild matches
# ============================================================================


async def _scan_and_match_active(
    include_uploads: bool = True,
) -> WorkspaceScanResponse:
    """Scan the active workspace's folder, merge refs, run matching.

    This is the shared core logic for scan, rescan, upload, etc.
    """
    _scan_cancel.clear()

    ws = _manager._require_active()
    ws.last_accessed = _now_iso()

    folder = ws.folder_path
    recursive = ws.scan_config.get("recursive", True)

    try:
        if folder and Path(folder).is_dir():
            videos, elan_files, feature_files = await asyncio.to_thread(
                _scan_directory, Path(folder), recursive
            )
        else:
            videos, elan_files, feature_files = [], [], []

        # Scan additional folders
        for extra in getattr(ws, "additional_folders", []):
            extra_path = Path(extra)
            if extra_path.is_dir():
                ev, ee, ef = await asyncio.to_thread(_scan_directory, extra_path, recursive)
                videos.extend(ev)
                elan_files.extend(ee)
                feature_files.extend(ef)

        # Include uploads directory if present and different from root
        upload_dir = _manager._scan_cache.get("upload_dir")
        if include_uploads and upload_dir:
            upload_path = Path(upload_dir)
            if upload_path.is_dir() and str(upload_path) != folder:
                uv, ue, uf = await asyncio.to_thread(_scan_directory, upload_path, False)
                videos.extend(uv)
                elan_files.extend(ue)
                feature_files.extend(uf)
    except ScanCancelled:
        raise HTTPException(499, "Scan cancelled")

    # Merge discovered videos with stored refs (respects detach state)
    active_videos = _manager.merge_video_refs(ws, videos)

    matches, unmatched_videos, unmatched_elan = _auto_match(
        active_videos, elan_files, feature_files, ws.manual_overrides
    )

    _manager.update_scan_cache(matches, unmatched_videos, unmatched_elan, feature_files)
    await asyncio.to_thread(_manager.save)

    # Auto-ingest new/changed .eaf files into the corpus DB (background)
    asyncio.get_running_loop().create_task(_auto_ingest_eaf(elan_files))

    return _manager.build_response()


async def _auto_ingest_eaf(
    elan_files: list[WorkspaceFileInfo],
) -> None:
    """Ingest discovered .eaf files into the active workspace's corpus DB.

    Runs as a background task. Skips files whose mtime hasn't changed
    (handled inside CorpusDB).
    """
    if not elan_files:
        return

    _ingestion_status["running"] = True
    _ingestion_status["total"] = len(elan_files)
    _ingestion_status["completed"] = 0
    _ingestion_status["failed"] = 0
    _ingestion_status["current_file"] = None

    try:
        db = _manager.get_corpus_db()
        for ef in elan_files:
            _ingestion_status["current_file"] = ef.name
            try:
                await asyncio.to_thread(db.ingest_eaf, ef.path)
            except Exception:
                _ingestion_status["failed"] += 1
                logger.warning("Failed to ingest %s", ef.path, exc_info=True)
            _ingestion_status["completed"] += 1
    except Exception:
        logger.warning(
            "Auto-ingest of .eaf files into corpus DB failed",
            exc_info=True,
        )
    finally:
        _ingestion_status["running"] = False
        _ingestion_status["current_file"] = None


# ============================================================================
# Directory browsing
# ============================================================================


@router.get("/browse")
async def browse_directory(path: str = "/"):
    """List subdirectories and file counts at a given path for directory browsing."""
    target = validate_path(path, must_exist=True)
    if not target.is_dir():
        raise HTTPException(400, "Path must be a directory")

    dirs: list[dict[str, str | int]] = []
    file_counts = {"video": 0, "elan": 0, "other": 0}

    try:
        for entry in sorted(target.iterdir()):
            if entry.name.startswith("."):
                continue
            if entry.is_dir():
                dirs.append({"name": entry.name, "path": str(entry)})
            elif entry.is_file():
                ext = entry.suffix.lower()
                if ext in VIDEO_EXTENSIONS:
                    file_counts["video"] += 1
                elif ext in ELAN_EXTENSIONS:
                    file_counts["elan"] += 1
                else:
                    file_counts["other"] += 1
    except PermissionError:
        raise HTTPException(403, "Permission denied")

    return {"path": str(target), "dirs": dirs, "file_counts": file_counts}


# ============================================================================
# New multi-workspace endpoints
# ============================================================================


@router.get("/list", response_model=WorkspaceListResponse)
async def list_workspaces():
    """List all workspaces with summary info."""
    _ensure_loaded()
    return _manager.list_workspaces()


@router.post("/cancel-scan")
async def cancel_scan():
    """Cancel any in-progress directory scan."""
    _scan_cancel.set()
    return {"status": "cancelled"}


@router.post("/discover", response_model=DiscoverDirectoryResponse)
async def discover_directory(request: DiscoverDirectoryRequest):
    """Read-only scan of a directory. Returns discovered files without creating a workspace."""
    _scan_cancel.clear()
    root = validate_path(request.folder_path, must_exist=True)
    if not root.is_dir():
        raise HTTPException(400, "Path must be a directory")

    try:
        videos, elan_files, feature_files = await asyncio.to_thread(
            _scan_directory, root, request.recursive
        )
    except ScanCancelled:
        raise HTTPException(499, "Scan cancelled")
    matches, unmatched_videos, unmatched_elan = _auto_match(videos, elan_files, feature_files, {})

    total_videos = len(matches) + len(unmatched_videos)
    total_elan = sum(1 for m in matches if m.elan is not None) + len(unmatched_elan)
    matched_count = sum(1 for m in matches if m.elan is not None)

    return DiscoverDirectoryResponse(
        path=str(root),
        videos=videos,
        elan_files=elan_files,
        feature_files=feature_files,
        matches=matches,
        unmatched_videos=unmatched_videos,
        unmatched_elan=unmatched_elan,
        counts={
            "videos": total_videos,
            "elan": total_elan,
            "matched": matched_count,
            "features": len(feature_files),
        },
    )


@router.get("/{name}/matches")
async def get_workspace_matches(name: str):
    """Return video/ELAN matches for any workspace by name (read-only, no switch)."""
    _ensure_loaded()
    if name not in _manager._data.workspaces:
        raise HTTPException(404, f"Workspace '{name}' not found")

    ws = _manager._data.workspaces[name]
    folder = ws.folder_path
    recursive = ws.scan_config.get("recursive", True)

    if not folder or not Path(folder).is_dir():
        return {"matches": []}

    videos, elan_files, feature_files = await asyncio.to_thread(
        _scan_directory, Path(folder), recursive
    )

    # Filter out detached videos
    detached_paths = {ref.path for ref in ws.video_refs if ref.detached}
    active_videos = [v for v in videos if v.path not in detached_paths]

    matches, _, _ = _auto_match(active_videos, elan_files, feature_files, ws.manual_overrides)

    return {
        "matches": [m.model_dump() for m in matches],
    }


@router.post("/create", response_model=WorkspaceScanResponse)
async def create_workspace(request: CreateWorkspaceRequest):
    """Create a new named workspace from a folder path and scan it."""
    _ensure_loaded()

    root = validate_path(request.folder_path, must_exist=True)
    if not root.is_dir():
        raise HTTPException(400, "Path must be a directory")

    _manager.create_workspace(request.name, str(root), request.recursive)
    await asyncio.to_thread(_manager.save)

    response = await _scan_and_match_active()

    # If specific videos were selected, detach the rest
    if request.selected_video_paths is not None:
        selected = set(request.selected_video_paths)
        ws = _manager._require_active()
        for ref in ws.video_refs:
            if ref.path not in selected:
                ref.detached = True
        await asyncio.to_thread(_manager.save)
        response = await _scan_and_match_active()

    return response


@router.post("/switch", response_model=WorkspaceScanResponse)
async def switch_workspace(request: SwitchWorkspaceRequest):
    """Switch the active workspace by name and return its state."""
    _ensure_loaded()
    _manager.switch_workspace(request.name)
    await asyncio.to_thread(_manager.save)

    # Re-scan to get fresh data
    return await _scan_and_match_active()


@router.delete("/clear")
async def clear_workspace():
    """Clear ALL workspaces and optionally remove uploaded files."""
    _ensure_loaded()

    # Delete every workspace (not just the active one)
    all_names = list(_manager._data.workspaces.keys())
    for name in all_names:
        _manager.delete_workspace(name)

    _manager._scan_cache = _manager._empty_scan_cache()

    # Clean upload directory (non-blocking)
    if UPLOAD_DIR.exists():
        try:
            await asyncio.to_thread(shutil.rmtree, UPLOAD_DIR, True)
        except Exception:
            logger.exception("Failed to clean upload directory")

    await asyncio.to_thread(_manager.save)

    return {"status": "cleared"}


@router.delete("/{name}")
async def delete_workspace(name: str):
    """Delete a workspace config. Files on disk are NOT deleted."""
    _ensure_loaded()
    _manager.delete_workspace(name)
    await asyncio.to_thread(_manager.save)
    return {"status": "deleted", "name": name}


@router.put("/rename")
async def rename_workspace(request: RenameWorkspaceRequest):
    """Rename a workspace."""
    _ensure_loaded()
    _manager.rename_workspace(request.old_name, request.new_name)
    await asyncio.to_thread(_manager.save)
    return {"status": "renamed", "new_name": request.new_name}


@router.post("/detach-video", response_model=WorkspaceScanResponse)
async def detach_video(request: DetachVideoRequest):
    """Mark a video as detached (hidden) in the active workspace.

    The file is NOT deleted from disk.
    """
    _ensure_loaded()
    _manager.detach_video(request.video_path)
    return await _scan_and_match_active()


@router.post("/reattach-video", response_model=WorkspaceScanResponse)
async def reattach_video(request: DetachVideoRequest):
    """Reattach a previously detached video. File must still exist."""
    _ensure_loaded()
    _manager.reattach_video(request.video_path)
    return await _scan_and_match_active()


@router.post("/add-video", response_model=WorkspaceScanResponse)
async def add_video_ref(request: AddVideoRefRequest):
    """Add an external video reference to the active workspace."""
    _ensure_loaded()
    validate_path(request.video_path, must_exist=True)
    _manager.add_video_ref(request.video_path)
    return await _scan_and_match_active()


@router.post("/add-folder", response_model=WorkspaceScanResponse)
async def add_folder(request: AddFolderRequest):
    """Add an additional source folder to the active workspace."""
    _ensure_loaded()
    root = validate_path(request.folder_path, must_exist=True)
    if not root.is_dir():
        raise HTTPException(400, "Path must be a directory")

    ws = _manager._require_active()
    folder_str = str(root)

    # Don't add duplicates
    if folder_str != ws.folder_path and folder_str not in ws.additional_folders:
        ws.additional_folders.append(folder_str)
        await asyncio.to_thread(_manager.save)

    return await _scan_and_match_active()


# ============================================================================
# Existing endpoints (adapted to multi-workspace)
# ============================================================================


@router.post("/scan", response_model=WorkspaceScanResponse)
async def scan_workspace(request: WorkspaceScanRequest):
    """Scan a directory for videos, ELAN files, and features.

    If no active workspace exists, creates a "Default" workspace.
    Otherwise operates on the active workspace, updating its folder_path.
    """
    _ensure_loaded()

    root = validate_path(request.path, must_exist=True)
    if not root.is_dir():
        raise HTTPException(400, "Path must be a directory")

    ws = _manager.active
    if ws is None:
        # Create a default workspace
        _manager.create_workspace("Default", str(root), request.recursive)
    else:
        # Update the active workspace's folder path
        ws.folder_path = str(root)
        ws.scan_config["recursive"] = request.recursive
        ws.last_accessed = _now_iso()

    return await _scan_and_match_active()


@router.get("/status", response_model=WorkspaceScanResponse)
async def workspace_status():
    """Return the current workspace state (no re-scan)."""
    _ensure_loaded()
    return _manager.build_response()


@router.get("/ingestion-status")
async def ingestion_status():
    """Return the status of background EAF corpus ingestion."""
    return _ingestion_status


@router.patch("/match", response_model=WorkspaceScanResponse)
async def manual_match(request: WorkspaceManualMatchRequest):
    """Manually pair (or unpair) a video with an ELAN file.

    Set ``elan_path`` to ``null`` to explicitly unpair a video.
    """
    _ensure_loaded()

    validate_path(request.video_path, must_exist=True)

    if request.elan_path is not None:
        elan = validate_path(request.elan_path, must_exist=True)
        if elan.suffix.lower() not in ELAN_EXTENSIONS:
            raise HTTPException(
                400,
                f"File is not an ELAN annotation: {elan.suffix}",
            )

    ws = _manager.active
    if ws is None:
        raise HTTPException(400, "No active workspace to set match on")

    ws.manual_overrides[request.video_path] = request.elan_path

    # Re-scan and re-match to pick up the override
    if ws.folder_path and Path(ws.folder_path).is_dir():
        return await _scan_and_match_active()

    await asyncio.to_thread(_manager.save)
    return _manager.build_response()


async def _save_upload(file: UploadFile, dest: Path) -> int:
    """Save an uploaded file to *dest* without blocking the event loop.

    Returns the number of bytes written.  Raises ``HTTPException`` on
    size-limit violations or I/O errors.
    """
    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    max_bytes = MAX_UPLOAD_SIZE_MB * 1024 * 1024

    def _write_file() -> int:
        size = 0
        with open(dest, "wb") as out:
            while True:
                chunk = file.file.read(1024 * 1024)
                if not chunk:
                    break
                size += len(chunk)
                if size > max_bytes:
                    raise ValueError(f"File exceeds {MAX_UPLOAD_SIZE_MB} MB limit")
                out.write(chunk)
        return size

    try:
        return await asyncio.to_thread(_write_file)
    except ValueError as exc:
        dest.unlink(missing_ok=True)
        raise HTTPException(413, str(exc)) from exc
    except Exception as exc:
        dest.unlink(missing_ok=True)
        raise HTTPException(500, safe_error_detail("Upload failed", exc)) from exc


@router.post("/upload", response_model=WorkspaceScanResponse)
async def upload_file(file: UploadFile = File(...)):
    """Upload a video or ELAN file into the workspace upload directory.

    After saving the file, automatically rescans to include it.
    """
    if file.filename is None:
        raise HTTPException(400, "Filename is required")

    safe_name = sanitize_filename(file.filename)
    ext = Path(safe_name).suffix.lower()
    if ext not in ALL_EXTENSIONS:
        allowed = ", ".join(sorted(ALL_EXTENSIONS))
        raise HTTPException(
            400,
            f"Unsupported file extension: {ext}. Allowed: {allowed}",
        )

    dest = UPLOAD_DIR / safe_name
    await _save_upload(file, dest)

    _ensure_loaded()

    # Ensure an active workspace exists
    if _manager.active is None:
        _manager.create_workspace("Default", str(UPLOAD_DIR), recursive=True)

    _manager._scan_cache["upload_dir"] = str(UPLOAD_DIR)

    return await _scan_and_match_active()


@router.post("/upload-batch", response_model=WorkspaceScanResponse)
async def upload_batch(files: list[UploadFile] = File(...)):
    """Upload multiple files, save all, then rescan once.

    This avoids the N-rescans problem of calling ``/upload`` in a loop.
    """
    if not files:
        raise HTTPException(400, "No files provided")

    for file in files:
        if file.filename is None:
            raise HTTPException(400, "Filename is required")

        safe_name = sanitize_filename(file.filename)
        ext = Path(safe_name).suffix.lower()
        if ext not in ALL_EXTENSIONS:
            allowed = ", ".join(sorted(ALL_EXTENSIONS))
            raise HTTPException(
                400,
                f"Unsupported file extension: {ext}. " f"Allowed: {allowed}",
            )

        dest = UPLOAD_DIR / safe_name
        await _save_upload(file, dest)

    _ensure_loaded()

    if _manager.active is None:
        _manager.create_workspace("Default", str(UPLOAD_DIR), recursive=True)

    _manager._scan_cache["upload_dir"] = str(UPLOAD_DIR)

    return await _scan_and_match_active()


@router.post("/fetch-url", response_model=WorkspaceScanResponse)
async def fetch_url(request: WorkspaceFetchUrlRequest):
    """Download a file from a URL into the workspace upload directory."""
    try:
        import httpx  # lazy import -- optional dependency
    except ImportError:
        raise HTTPException(
            501,
            "httpx is not installed. Install it with: pip install httpx",
        )

    if request.filename:
        safe_name = sanitize_filename(request.filename)
    else:
        url_path = request.url.rsplit("/", 1)[-1].split("?")[0]
        safe_name = sanitize_filename(url_path) if url_path else "download"

    ext = Path(safe_name).suffix.lower()
    if ext not in ALL_EXTENSIONS:
        allowed = ", ".join(sorted(ALL_EXTENSIONS))
        raise HTTPException(
            400,
            f"Unsupported file extension: {ext}. Allowed: {allowed}",
        )

    UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
    dest = UPLOAD_DIR / safe_name

    try:
        async with httpx.AsyncClient(
            timeout=httpx.Timeout(30.0, read=120.0),
            follow_redirects=True,
        ) as client:
            async with client.stream("GET", request.url) as resp:
                resp.raise_for_status()
                size = 0
                max_bytes = MAX_UPLOAD_SIZE_MB * 1024 * 1024
                with open(dest, "wb") as out:
                    async for chunk in resp.aiter_bytes(chunk_size=1024 * 1024):
                        size += len(chunk)
                        if size > max_bytes:
                            out.close()
                            dest.unlink(missing_ok=True)
                            raise HTTPException(
                                413,
                                f"Download exceeds {MAX_UPLOAD_SIZE_MB} MB limit",
                            )
                        out.write(chunk)
    except HTTPException:
        raise
    except Exception as exc:
        dest.unlink(missing_ok=True)
        raise HTTPException(500, safe_error_detail("URL fetch failed", exc)) from exc

    _ensure_loaded()

    if _manager.active is None:
        _manager.create_workspace("Default", str(UPLOAD_DIR), recursive=True)

    _manager._scan_cache["upload_dir"] = str(UPLOAD_DIR)

    return await _scan_and_match_active()


@router.put("/annotator")
async def set_annotator_id(request: dict):
    """Set the annotator ID for ELAN exports."""
    _ensure_loaded()
    annotator_id = request.get("annotator_id", "").strip()
    ws = _manager.active
    if ws is not None:
        ws.annotator_id = annotator_id
    await asyncio.to_thread(_manager.save)
    return {"annotator_id": annotator_id}


@router.post("/rescan", response_model=WorkspaceScanResponse)
async def rescan_workspace():
    """Re-scan the current workspace directory for new files."""
    _ensure_loaded()

    ws = _manager.active
    upload_dir = _manager._scan_cache.get("upload_dir")

    if ws is None:
        if upload_dir and Path(upload_dir).is_dir():
            _manager.create_workspace("Default", upload_dir, recursive=True)
        else:
            raise HTTPException(
                400,
                "No workspace path configured. " "Scan a directory or upload files first.",
            )

    ws = _manager._require_active()
    folder = ws.folder_path

    if not folder:
        if upload_dir and Path(upload_dir).is_dir():
            ws.folder_path = upload_dir
        else:
            raise HTTPException(
                400,
                "No workspace path configured. " "Scan a directory or upload files first.",
            )

    root = Path(ws.folder_path)
    if not root.is_dir():
        raise HTTPException(
            404,
            "Previously configured workspace directory " "no longer exists.",
        )

    validate_path(ws.folder_path, must_exist=True)

    return await _scan_and_match_active()
