"""Corpus database router — queryable annotation data from SQLite."""

from __future__ import annotations

import asyncio
import json
import logging
from pathlib import Path as _Path

from fastapi import APIRouter, HTTPException, Query

from sltk.api.models import (
    AvailableTier,
    CAVocabSummary,
    ClauseStructureRequest,
    ClauseStructureResponse,
    ClauseTypeStats,
    CompareWorkspacesRequest,
    ConcordanceResult,
    ConstructedActionRequest,
    ConstructedActionResponse,
    CooccurrenceResult,
    CorpusConcordanceRequest,
    CorpusCooccurrenceMatrixRequest,
    CorpusCooccurrenceMatrixResponse,
    CorpusCooccurrenceRequest,
    CorpusDurationAnalysisRequest,
    CorpusDurationAnalysisResponse,
    CorpusDurationStatsItem,
    CorpusMinimalPairItem,
    CorpusMinimalPairsRequest,
    CorpusMinimalPairsResponse,
    CorpusNgramRequest,
    CorpusSearchRequest,
    CorpusStatusResponse,
    CorpusSummaryResponse,
    CorpusVocabularyResponse,
    CrossTierCooccurrenceRequest,
    CrossTierCooccurrenceResponse,
    DashboardStatsResponse,
    DelimiterExtractionApplyRequest,
    DelimiterExtractionPreviewRequest,
    DistinctiveSign,
    DurationHistogramBin,
    FileFlencyStats,
    FilenameAnnotationRequest,
    FilenameAnnotationResponse,
    FilenameAnnotationResult,
    FluencyResponse,
    FrequencyDistributionItem,
    GlossDurationStats,
    GlossFrequency,
    GlossOccurrence,
    GroupComparisonItem,
    GroupComparisonResponse,
    GroupTopGlossesResponse,
    GroupTopGlossItem,
    HandComparisonResponse,
    HandStats,
    HandVocabItem,
    LexicalRichnessResponse,
    MetadataKeyInfo,
    MetadataStatusResponse,
    MetadataValueItem,
    MultiTierConcordanceRequest,
    MultiTierConcordanceResponse,
    MultiTierHit,
    NgramResult,
    NonManualCooccurrenceRequest,
    NonManualCooccurrenceResponse,
    NonManualForm,
    OverlappingAnnotation,
    PathExtractionApplyRequest,
    PathExtractionApplyResponse,
    PathExtractionPreviewItem,
    PathExtractionPreviewResponse,
    SegmentFieldApplyRequest,
    SemanticCluster,
    SemanticSimilarityRequest,
    SemanticSimilarityResponse,
    SignEntropyItem,
    SignerDurationStats,
    SignerFluencyItem,
    SignerRichnessItem,
    SignFrequency,
    SignNonManualStats,
    SimilarPair,
    SimultaneousSignItem,
    SubstringExtractionApplyRequest,
    SubstringExtractionApplyResponse,
    SubstringExtractionPreviewFile,
    SubstringExtractionPreviewRequest,
    SubstringExtractionPreviewResponse,
    TierAutoDetectItem,
    TierRoleInfo,
    TierRolesUpdateRequest,
    TransitionsResponse,
    UniversalSearchRequest,
    UniversalSearchResponse,
    WorkspaceGlossComparison,
)

logger = logging.getLogger(__name__)

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

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_WORKSPACES_DIR_NAME = "workspaces"


def _get_corpus_db(workspace: str | None = None):
    """Return the CorpusDB for *workspace* or the active workspace."""
    from sltk.api.routers.workspace import _ensure_loaded, _manager

    _ensure_loaded()
    if workspace:
        try:
            return _manager.get_corpus_db_by_name(workspace)
        except ValueError as exc:
            raise HTTPException(404, str(exc)) from exc

    ws = _manager.active
    if ws is None:
        raise HTTPException(
            400,
            "No active workspace. Create or switch to one first.",
        )
    return _manager.get_corpus_db()


def _workspace_db_path(name: str):
    """Return the corpus.db path for a given workspace name."""
    from pathlib import Path

    return Path.home() / ".sltk" / _WORKSPACES_DIR_NAME / name / "corpus.db"


def _parse_metadata_filter(raw: str | None) -> dict[str, str] | None:
    if not raw:
        return None
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return None


def _parse_tier_roles(raw: str | None) -> list[str] | None:
    if not raw:
        return None
    try:
        return json.loads(raw)
    except json.JSONDecodeError:
        return None


# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------


@router.post("/rebuild")
async def rebuild_corpus():
    """Drop and re-ingest all .eaf files in the active workspace."""
    from sltk.api.routers.workspace import _ensure_loaded, _manager

    _ensure_loaded()
    ws = _manager.active
    if ws is None:
        raise HTTPException(400, "No active workspace.")
    folder = ws.folder_path
    if not folder:
        raise HTTPException(400, "Workspace has no folder path configured.")

    db = _manager.get_corpus_db()
    count = await asyncio.to_thread(db.rebuild, folder)
    return {"status": "rebuilt", "files_ingested": count}


@router.get("/status", response_model=CorpusStatusResponse)
async def corpus_status(
    workspace: str | None = Query(default=None),
):
    """Database stats: file count, segment count, last updated."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.status)
    return CorpusStatusResponse(**data)


@router.get("/summary", response_model=CorpusSummaryResponse)
async def corpus_summary(
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Vocabulary size, tier counts, signer counts."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.corpus_summary, source)
    return CorpusSummaryResponse(**data)


@router.get("/glosses", response_model=list[GlossFrequency])
async def list_glosses(
    top_n: int = Query(default=100, ge=1, le=5000),
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Top glosses by frequency."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    rows = await asyncio.to_thread(db.top_glosses, top_n, mf, tr, source)
    return [GlossFrequency(**r) for r in rows]


@router.get(
    "/gloss/{gloss}",
    response_model=list[GlossOccurrence],
)
async def find_gloss(
    gloss: str,
    signer_id: str | None = Query(default=None),
    tier_id: str | None = Query(default=None),
    workspace: str | None = Query(default=None),
):
    """All occurrences of a gloss with timestamps and file info."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.find_gloss, gloss, signer_id, tier_id)
    return [GlossOccurrence(**r) for r in rows]


@router.get(
    "/gloss/{gloss}/stats",
    response_model=GlossDurationStats,
)
async def gloss_stats(
    gloss: str,
    signer_id: str | None = Query(default=None),
    tier_id: str | None = Query(default=None),
    workspace: str | None = Query(default=None),
):
    """Duration statistics for a gloss."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.gloss_duration_stats, gloss, signer_id, tier_id)
    return GlossDurationStats(**data)


@router.get(
    "/gloss/{gloss}/compare-signers",
    response_model=list[SignerDurationStats],
)
async def compare_signers(
    gloss: str,
    tier_id: str | None = Query(default=None),
    workspace: str | None = Query(default=None),
):
    """Per-signer duration comparison for a gloss."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.compare_gloss_duration_by_signer, gloss, tier_id)
    return [SignerDurationStats(**r) for r in rows]


@router.post("/search", response_model=list[GlossOccurrence])
async def search_corpus(
    request: CorpusSearchRequest,
    workspace: str | None = Query(default=None),
):
    """Full-text search over segment labels."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.search_glosses, request.query, request.limit)
    return [GlossOccurrence(**r) for r in rows]


@router.post("/concordance", response_model=list[ConcordanceResult])
async def concordance(
    request: CorpusConcordanceRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """KWIC concordance for a target gloss."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    rows = await asyncio.to_thread(
        db.concordance, request.target, request.context_window, request.limit, mf, tr, source
    )
    return [ConcordanceResult(**r) for r in rows]


@router.post("/ngrams", response_model=list[NgramResult])
async def ngrams(
    request: CorpusNgramRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """N-gram frequency analysis."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    rows = await asyncio.to_thread(db.ngram_frequency, request.n, request.limit, mf, tr, source)
    return [NgramResult(**r) for r in rows]


@router.post("/cooccurrence", response_model=list[CooccurrenceResult])
async def cooccurrence(
    request: CorpusCooccurrenceRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Co-occurrence analysis for a target gloss."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    rows = await asyncio.to_thread(
        db.cooccurrence,
        request.target,
        request.window,
        request.limit,
        mf,
        tr,
        source,
    )
    return [CooccurrenceResult(**r) for r in rows]


# ---------------------------------------------------------------------------
# New corpus analysis endpoints
# ---------------------------------------------------------------------------


@router.get("/vocabulary", response_model=CorpusVocabularyResponse)
async def corpus_vocabulary(
    limit: int | None = Query(default=None, ge=1),
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Vocabulary frequencies and aggregate stats. Omit limit to return all glosses."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    data = await asyncio.to_thread(db.vocabulary_stats, limit, mf, tr, source)
    return CorpusVocabularyResponse(**data)


@router.post(
    "/cooccurrence-matrix",
    response_model=CorpusCooccurrenceMatrixResponse,
)
async def cooccurrence_matrix(
    request: CorpusCooccurrenceMatrixRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Co-occurrence matrix for top glosses."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    data = await asyncio.to_thread(
        db.cooccurrence_matrix, request.top_n, request.window, mf, tr, source
    )
    return CorpusCooccurrenceMatrixResponse(**data)


@router.post(
    "/duration-analysis",
    response_model=CorpusDurationAnalysisResponse,
)
async def duration_analysis(
    request: CorpusDurationAnalysisRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Per-gloss duration statistics across the corpus."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    items = await asyncio.to_thread(
        db.all_duration_stats, request.limit, request.min_count, mf, tr, source
    )
    return CorpusDurationAnalysisResponse(
        items=[CorpusDurationStatsItem(**i) for i in items],
        total_glosses=len(items),
    )


@router.post(
    "/minimal-pairs",
    response_model=CorpusMinimalPairsResponse,
)
async def minimal_pairs(
    request: CorpusMinimalPairsRequest,
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Find glosses that are similar in form."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.minimal_pairs, request.threshold, request.max_pairs, source)
    return CorpusMinimalPairsResponse(
        vocabulary_size=data["vocabulary_size"],
        total_pairs=data["total_pairs"],
        similarity_threshold=data["similarity_threshold"],
        pairs=[CorpusMinimalPairItem(**p) for p in data["pairs"]],
    )


# ---------------------------------------------------------------------------
# Dashboard endpoints
# ---------------------------------------------------------------------------


@router.get("/dashboard", response_model=DashboardStatsResponse)
async def dashboard_stats(
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Comprehensive dashboard metrics."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.dashboard_stats, source)
    return DashboardStatsResponse(**data)


@router.get(
    "/frequency-distribution",
    response_model=list[FrequencyDistributionItem],
)
async def frequency_distribution(
    limit: int = Query(default=500, ge=1, le=5000),
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Frequency distribution for Zipf's law curve."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.frequency_distribution, limit, source)
    return [FrequencyDistributionItem(**r) for r in rows]


@router.get(
    "/duration-histogram",
    response_model=list[DurationHistogramBin],
)
async def duration_histogram(
    bins: int = Query(default=30, ge=5, le=100),
    max_ms: int = Query(default=3000, ge=100, le=30000),
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Duration histogram bins for sign durations."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.duration_histogram, bins, max_ms, source)
    return [DurationHistogramBin(**r) for r in rows]


# ---------------------------------------------------------------------------
# Lexical richness endpoints
# ---------------------------------------------------------------------------


@router.get("/lexical-richness", response_model=LexicalRichnessResponse)
async def lexical_richness(
    mattr_window: int = Query(default=50, ge=5, le=500),
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
):
    """Lexical richness metrics (TTR, Yule's K, MATTR, etc.)."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    data = await asyncio.to_thread(db.lexical_richness, mattr_window, mf, tr)
    return LexicalRichnessResponse(**data)


@router.get(
    "/per-signer-richness",
    response_model=list[SignerRichnessItem],
)
async def per_signer_richness(
    mattr_window: int = Query(default=50, ge=5, le=500),
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
):
    """Lexical richness per signer."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    rows = await asyncio.to_thread(db.per_signer_richness, mattr_window, mf, tr)
    return [SignerRichnessItem(**r) for r in rows]


# ---------------------------------------------------------------------------
# Transition endpoints
# ---------------------------------------------------------------------------


@router.get("/transitions", response_model=TransitionsResponse)
async def sign_transitions(
    top_n: int = Query(default=30, ge=5, le=100),
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
):
    """Bigram transition probabilities with entropy scores."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    data = await asyncio.to_thread(db.sign_transitions, top_n, mf, tr)
    return TransitionsResponse(
        signs=data["signs"],
        matrix=data["matrix"],
        entropy_scores=[SignEntropyItem(**e) for e in data["entropy_scores"]],
        frequencies=data["frequencies"],
    )


# ---------------------------------------------------------------------------
# Fluency endpoints
# ---------------------------------------------------------------------------


@router.get("/fluency", response_model=FluencyResponse)
async def fluency_stats(
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
):
    """Per-file signing rate and pause analysis."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    data = await asyncio.to_thread(db.fluency_stats, mf, tr)
    return FluencyResponse(
        file_stats=[FileFlencyStats(**f) for f in data["file_stats"]],
        pause_histogram=[DurationHistogramBin(**b) for b in data["pause_histogram"]],
        duration_vs_frequency=data["duration_vs_frequency"],
        global_avg_pause_ms=data["global_avg_pause_ms"],
        global_median_pause_ms=data["global_median_pause_ms"],
    )


@router.get(
    "/per-signer-fluency",
    response_model=list[SignerFluencyItem],
)
async def per_signer_fluency(
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
):
    """Signing rate comparison across signers."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    rows = await asyncio.to_thread(db.per_signer_fluency, mf, tr)
    return [SignerFluencyItem(**r) for r in rows]


# ---------------------------------------------------------------------------
# Cross-workspace endpoints
# ---------------------------------------------------------------------------


@router.post(
    "/compare-workspaces",
    response_model=list[WorkspaceGlossComparison],
)
async def compare_workspaces(request: CompareWorkspacesRequest):
    """Compare a gloss across multiple workspace databases."""
    from sltk.db.corpus import CorpusDB

    db_paths = []
    for name in request.workspace_names:
        p = _workspace_db_path(name)
        if p.exists():
            db_paths.append(str(p))
        else:
            logger.warning("Workspace DB not found: %s", name)

    if len(db_paths) < 2:
        raise HTTPException(
            400,
            "Need at least 2 existing workspace databases " "to compare.",
        )

    results = await asyncio.to_thread(
        CorpusDB.compare_gloss_across_dbs,
        request.gloss,
        db_paths,
    )
    return [WorkspaceGlossComparison(**r) for r in results]


# ---------------------------------------------------------------------------
# Metadata endpoints
# ---------------------------------------------------------------------------


@router.get("/metadata/status", response_model=MetadataStatusResponse)
async def metadata_status(
    workspace: str | None = Query(default=None),
):
    """Overall metadata summary for the corpus."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.metadata_status)
    return MetadataStatusResponse(
        keys=[MetadataKeyInfo(**k) for k in data["keys"]],
        elan_participants=data["elan_participants"],
        total_files=data["total_files"],
        files_with_metadata=data["files_with_metadata"],
    )


@router.get("/metadata/keys", response_model=list[MetadataKeyInfo])
async def metadata_keys(
    workspace: str | None = Query(default=None),
):
    """List all metadata keys with summary stats."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.list_metadata_keys)
    return [MetadataKeyInfo(**r) for r in rows]


@router.get("/metadata/values/{key}", response_model=list[MetadataValueItem])
async def metadata_values(
    key: str,
    workspace: str | None = Query(default=None),
):
    """Distinct values for a metadata key."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.get_metadata_values, key)
    return [MetadataValueItem(**r) for r in rows]


# ---------------------------------------------------------------------------
# Tier role endpoints
# ---------------------------------------------------------------------------


@router.get("/tier-roles", response_model=list[TierRoleInfo])
async def tier_roles(
    workspace: str | None = Query(default=None),
):
    """All unique tier IDs with segment counts and assigned roles."""
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.list_unique_tier_ids)
    return [TierRoleInfo(**r) for r in rows]


@router.post("/tier-roles")
async def set_tier_roles(
    request: TierRolesUpdateRequest,
    workspace: str | None = Query(default=None),
):
    """Bulk-update tier role assignments."""
    db = _get_corpus_db(workspace)
    count = await asyncio.to_thread(db.set_tier_roles_bulk, request.assignments)
    return {"updated": count}


@router.post(
    "/tier-roles/auto-detect",
    response_model=list[TierAutoDetectItem],
)
async def auto_detect_tier_roles(
    workspace: str | None = Query(default=None),
):
    """Auto-detect roles for all tiers based on name patterns.

    Returns suggestions without saving. Use POST /tier-roles to persist.
    """
    db = _get_corpus_db(workspace)
    rows = await asyncio.to_thread(db.auto_detect_tier_roles)
    return [TierAutoDetectItem(**r) for r in rows]


@router.delete("/tier-roles/{pattern}")
async def delete_tier_role(
    pattern: str,
    workspace: str | None = Query(default=None),
):
    """Delete a single tier role assignment (resets to default 'other')."""
    db = _get_corpus_db(workspace)
    deleted = await asyncio.to_thread(db.delete_tier_role, pattern)
    return {"deleted": deleted}


@router.delete("/tier-roles")
async def delete_all_tier_roles(
    workspace: str | None = Query(default=None),
):
    """Delete all tier role assignments (reset all to default 'other')."""
    db = _get_corpus_db(workspace)
    count = await asyncio.to_thread(db.delete_all_tier_roles)
    return {"deleted": count}


# ---------------------------------------------------------------------------
# Group comparison endpoints
# ---------------------------------------------------------------------------


@router.get("/group-comparison", response_model=GroupComparisonResponse)
async def group_comparison(
    group_by: str = Query(default="participant", pattern="^(participant|metadata)$"),
    metadata_key: str | None = Query(default=None),
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Compare linguistic metrics grouped by signer or metadata key."""
    if group_by == "metadata" and not metadata_key:
        raise HTTPException(400, "metadata_key is required when group_by=metadata")
    db = _get_corpus_db(workspace)
    groups = await asyncio.to_thread(db.grouped_comparison, group_by, metadata_key, source)
    return GroupComparisonResponse(
        group_by=group_by,
        metadata_key=metadata_key,
        groups=[GroupComparisonItem(**g) for g in groups],
    )


@router.get("/group-top-glosses", response_model=GroupTopGlossesResponse)
async def group_top_glosses(
    group_by: str = Query(default="participant", pattern="^(participant|metadata)$"),
    group_value: str = Query(...),
    metadata_key: str | None = Query(default=None),
    limit: int = Query(default=30, ge=1, le=500),
    workspace: str | None = Query(default=None),
    source: str | None = Query(default=None, pattern="^(human|model)$"),
):
    """Top glosses for a specific group (signer or metadata value)."""
    db = _get_corpus_db(workspace)
    glosses = await asyncio.to_thread(
        db.top_glosses_for_group, group_by, group_value, metadata_key, limit, source
    )
    return GroupTopGlossesResponse(
        group_by=group_by,
        group_value=group_value,
        glosses=[GroupTopGlossItem(**g) for g in glosses],
    )


@router.get("/hand-comparison", response_model=HandComparisonResponse)
async def hand_comparison(
    top_n: int = Query(default=50, ge=1, le=500),
    workspace: str | None = Query(default=None),
    left_tier_ids: str | None = Query(default=None),
    right_tier_ids: str | None = Query(default=None),
):
    """Compare left vs right hand tiers.

    When left_tier_ids / right_tier_ids are provided as JSON arrays,
    uses those explicit tier IDs instead of tier_roles assignments.
    """
    db = _get_corpus_db(workspace)
    lt = json.loads(left_tier_ids) if left_tier_ids else None
    rt = json.loads(right_tier_ids) if right_tier_ids else None
    data = await asyncio.to_thread(db.hand_comparison, top_n, lt, rt)
    return HandComparisonResponse(
        left_hand=HandStats(**data["left_hand"]),
        right_hand=HandStats(**data["right_hand"]),
        left_vocab=[HandVocabItem(**v) for v in data["left_vocab"]],
        right_vocab=[HandVocabItem(**v) for v in data["right_vocab"]],
        shared_count=data["shared_count"],
        left_only_count=data["left_only_count"],
        right_only_count=data["right_only_count"],
        simultaneous=[SimultaneousSignItem(**s) for s in data["simultaneous"]],
    )


# ---------------------------------------------------------------------------
# Substring extraction endpoints
# ---------------------------------------------------------------------------


@router.post(
    "/extraction/substring/preview",
    response_model=SubstringExtractionPreviewResponse,
)
async def substring_extraction_preview(
    request: SubstringExtractionPreviewRequest,
    workspace: str | None = Query(default=None),
):
    """Preview character-level substring extraction from path segments."""
    db = _get_corpus_db(workspace)
    defs = [{"key": d.key, "start": d.start, "end": d.end} for d in request.definitions]
    data = await asyncio.to_thread(db.preview_substring_extraction, request.segment_index, defs)
    return SubstringExtractionPreviewResponse(
        files=[SubstringExtractionPreviewFile(**f) for f in data["files"]],
        unique_per_key=data["unique_per_key"],
        total_files=data["total_files"],
        empty_count=data["empty_count"],
    )


@router.post(
    "/extraction/substring/apply",
    response_model=SubstringExtractionApplyResponse,
)
async def substring_extraction_apply(
    request: SubstringExtractionApplyRequest,
    workspace: str | None = Query(default=None),
):
    """Apply substring extraction and store as file metadata."""
    db = _get_corpus_db(workspace)
    defs = [{"key": d.key, "start": d.start, "end": d.end} for d in request.definitions]
    data = await asyncio.to_thread(
        db.apply_substring_extraction,
        request.segment_index,
        defs,
        request.set_participant_key,
    )
    return SubstringExtractionApplyResponse(**data)


@router.post(
    "/extraction/segment/apply",
    response_model=SubstringExtractionApplyResponse,
)
async def segment_extraction_apply(
    request: SegmentFieldApplyRequest,
    workspace: str | None = Query(default=None),
):
    """Store the whole path segment value as file metadata."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(
        db.apply_segment_as_metadata,
        request.segment_index,
        request.field_name,
        request.set_participant_key,
    )
    return SubstringExtractionApplyResponse(**data)


@router.post(
    "/extraction/delimiter/preview",
    response_model=SubstringExtractionPreviewResponse,
)
async def delimiter_extraction_preview(
    request: DelimiterExtractionPreviewRequest,
    workspace: str | None = Query(default=None),
):
    """Preview delimiter-based token extraction from path segments."""
    db = _get_corpus_db(workspace)
    assignments = [
        {"token_index": a.token_index, "field_name": a.field_name} for a in request.assignments
    ]
    data = await asyncio.to_thread(
        db.preview_delimiter_extraction, request.segment_index, request.delimiter, assignments
    )
    return SubstringExtractionPreviewResponse(
        files=[SubstringExtractionPreviewFile(**f) for f in data["files"]],
        unique_per_key=data["unique_per_key"],
        total_files=data["total_files"],
        empty_count=data["empty_count"],
    )


@router.post(
    "/extraction/delimiter/apply",
    response_model=SubstringExtractionApplyResponse,
)
async def delimiter_extraction_apply(
    request: DelimiterExtractionApplyRequest,
    workspace: str | None = Query(default=None),
):
    """Apply delimiter-based token extraction and store as file metadata."""
    db = _get_corpus_db(workspace)
    assignments = [
        {"token_index": a.token_index, "field_name": a.field_name} for a in request.assignments
    ]
    data = await asyncio.to_thread(
        db.apply_delimiter_extraction,
        request.segment_index,
        request.delimiter,
        assignments,
        request.set_participant_key,
    )
    return SubstringExtractionApplyResponse(**data)


# ---------------------------------------------------------------------------
# Path metadata extraction endpoints
# ---------------------------------------------------------------------------


@router.get(
    "/path-extraction/preview",
    response_model=PathExtractionPreviewResponse,
)
async def path_extraction_preview(
    segment_index: int = Query(..., ge=0, le=20),
    workspace: str | None = Query(default=None),
):
    """Preview path segment extraction for all files."""
    db = _get_corpus_db(workspace)
    items = await asyncio.to_thread(db.preview_path_extraction, segment_index)
    unique = sorted({i["extracted_value"] for i in items if i["extracted_value"] is not None})
    empty = sum(1 for i in items if i["extracted_value"] is None)
    return PathExtractionPreviewResponse(
        segment_index=segment_index,
        items=[PathExtractionPreviewItem(**i) for i in items],
        unique_values=unique,
        total_files=len(items),
        empty_count=empty,
    )


@router.post(
    "/path-extraction/apply",
    response_model=PathExtractionApplyResponse,
)
async def path_extraction_apply(
    request: PathExtractionApplyRequest,
    workspace: str | None = Query(default=None),
):
    """Apply path extraction as participant or tier."""
    db = _get_corpus_db(workspace)
    if request.mode == "participant":
        result = await asyncio.to_thread(
            db.apply_path_as_participant,
            request.segment_index,
            request.only_empty,
        )
        return PathExtractionApplyResponse(
            mode="participant",
            updated_files=result["updated_files"],
            updated_tiers=result["updated_tiers"],
        )
    else:
        result = await asyncio.to_thread(
            db.apply_path_as_tier,
            request.segment_index,
            request.tier_name,
        )
        return PathExtractionApplyResponse(
            mode="tier",
            created_tiers=result["created_tiers"],
            created_segments=result["created_segments"],
        )


@router.get("/signers-per-file")
async def signers_per_file(
    workspace: str | None = Query(default=None),
):
    """Return mapping of EAF file path to list of signer/participant IDs."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.signers_per_file)
    return data


@router.post("/universal-search", response_model=UniversalSearchResponse)
async def universal_search(
    request: UniversalSearchRequest,
    workspace: str | None = Query(default=None),
):
    """Search files, glosses, signers, and metadata in one call."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(
        db.universal_search, request.query, request.limit, request.tier_id
    )
    return UniversalSearchResponse(query=request.query, **data)


@router.post(
    "/ingest-from-filenames",
    response_model=FilenameAnnotationResponse,
)
async def ingest_from_filenames(request: FilenameAnnotationRequest):
    """Create synthetic annotations from pre-computed glosses."""
    db = _get_corpus_db()
    results: list[FilenameAnnotationResult] = []
    created = 0
    skipped = 0

    for entry in request.entries:
        stem = _Path(entry.video_path).stem
        gloss = entry.gloss.strip()

        if not gloss:
            results.append(
                FilenameAnnotationResult(
                    video_path=entry.video_path,
                    stem=stem,
                    gloss="",
                    status="empty",
                )
            )
            skipped += 1
            continue

        try:
            await asyncio.to_thread(
                db.ingest_synthetic,
                entry.video_path,
                gloss,
                request.tier_name,
            )
            created += 1
            results.append(
                FilenameAnnotationResult(
                    video_path=entry.video_path,
                    stem=stem,
                    gloss=gloss,
                    status="ok",
                )
            )
        except Exception as exc:
            logger.warning(
                "Failed to ingest synthetic for %s: %s",
                entry.video_path,
                exc,
            )
            results.append(
                FilenameAnnotationResult(
                    video_path=entry.video_path,
                    stem=stem,
                    gloss=gloss,
                    status="error",
                )
            )
            skipped += 1

    return FilenameAnnotationResponse(
        created=created,
        skipped=skipped,
        results=results,
    )


# ---------------------------------------------------------------------------
# Cross-tier analysis endpoints
# ---------------------------------------------------------------------------


@router.get("/available-tiers", response_model=list[AvailableTier])
async def available_tiers(
    workspace: str | None = Query(default=None),
):
    """Return non-empty tiers with suggested categories."""
    db = _get_corpus_db(workspace)
    data = await asyncio.to_thread(db.available_tiers)
    return [AvailableTier(**t) for t in data]


@router.post("/multi-tier-concordance", response_model=MultiTierConcordanceResponse)
async def multi_tier_concordance(
    request: MultiTierConcordanceRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
):
    """Multi-tier KWIC concordance — show all tier overlaps for a target gloss."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    data = await asyncio.to_thread(
        db.multi_tier_concordance,
        target=request.target,
        anchor_tier_ids=request.anchor_tier_ids,
        display_tier_ids=request.display_tier_ids,
        context_window_ms=request.context_window_ms,
        limit=request.limit,
        metadata_filter=mf,
    )
    hits = [
        MultiTierHit(
            **{k: v for k, v in h.items() if k != "overlapping"},
            overlapping=[OverlappingAnnotation(**o) for o in h.get("overlapping", [])],
        )
        for h in data
    ]
    return MultiTierConcordanceResponse(
        target=request.target,
        hits=hits,
        total_hits=len(hits),
    )


@router.post("/nonmanual-cooccurrence", response_model=NonManualCooccurrenceResponse)
async def nonmanual_cooccurrence(
    request: NonManualCooccurrenceRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
):
    """Non-manual feature co-occurrence with manual signs."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    data = await asyncio.to_thread(
        db.nonmanual_cooccurrence,
        manual_tier_ids=request.manual_tier_ids,
        nonmanual_tier_ids=request.nonmanual_tier_ids,
        target_sign=request.target_sign,
        min_count=request.min_count,
        top_n=request.top_n,
        metadata_filter=mf,
    )
    signs = [
        SignNonManualStats(
            **{k: v for k, v in s.items() if k != "forms"},
            forms=[NonManualForm(**f) for f in s.get("forms", [])],
        )
        for s in data.get("signs", [])
    ]
    return NonManualCooccurrenceResponse(
        signs=signs,
        total_signs_analyzed=data.get("total_signs_analyzed", 0),
        overall_nm_rate=data.get("overall_nm_rate", 0),
    )


@router.post(
    "/cross-tier-cooccurrence",
    response_model=CrossTierCooccurrenceResponse,
)
async def cross_tier_cooccurrence(
    request: CrossTierCooccurrenceRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
):
    """Asymmetric co-occurrence matrix from temporal overlap between two tier sets."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    data = await asyncio.to_thread(
        db.cross_tier_cooccurrence,
        row_tier_ids=request.row_tier_ids,
        col_tier_ids=request.col_tier_ids,
        top_n_rows=request.top_n_rows,
        top_n_cols=request.top_n_cols,
        min_overlap_ms=request.min_overlap_ms,
        metadata_filter=mf,
    )
    return CrossTierCooccurrenceResponse(**data)


@router.post("/constructed-action", response_model=ConstructedActionResponse)
async def constructed_action(
    request: ConstructedActionRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
):
    """Compare vocabulary inside vs outside Constructed Action spans."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    data = await asyncio.to_thread(
        db.constructed_action_analysis,
        ca_tier_ids=request.ca_tier_ids,
        gloss_tier_ids=request.gloss_tier_ids,
        top_n=request.top_n,
        metadata_filter=mf,
    )

    def _build_vocab(d: dict) -> CAVocabSummary:
        return CAVocabSummary(
            total_tokens=d.get("total_tokens", 0),
            vocabulary_size=d.get("vocabulary_size", 0),
            top_signs=[SignFrequency(**s) for s in d.get("top_signs", [])],
            avg_sign_duration_ms=d.get("avg_sign_duration_ms", 0),
        )

    return ConstructedActionResponse(
        ca_spans=data.get("ca_spans", 0),
        files_with_ca=data.get("files_with_ca", 0),
        total_ca_duration_ms=data.get("total_ca_duration_ms", 0),
        inside=_build_vocab(data.get("inside", {})),
        outside=_build_vocab(data.get("outside", {})),
        distinctive_signs=[DistinctiveSign(**s) for s in data.get("distinctive_signs", [])],
    )


@router.post("/clause-structure", response_model=ClauseStructureResponse)
async def clause_structure(
    request: ClauseStructureRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
):
    """Analyze internal structure of different clause types."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    data = await asyncio.to_thread(
        db.clause_structure_analysis,
        clause_tier_ids=request.clause_tier_ids,
        gloss_tier_ids=request.gloss_tier_ids,
        top_n=request.top_n,
        metadata_filter=mf,
    )
    clause_types = [
        ClauseTypeStats(
            **{k: v for k, v in ct.items() if k != "top_signs"},
            top_signs=[SignFrequency(**s) for s in ct.get("top_signs", [])],
        )
        for ct in data.get("clause_types", [])
    ]
    return ClauseStructureResponse(
        clause_types=clause_types,
        total_clauses=data.get("total_clauses", 0),
        avg_signs_per_clause=data.get("avg_signs_per_clause", 0),
    )


@router.post("/semantic-similarity", response_model=SemanticSimilarityResponse)
async def semantic_similarity(
    request: SemanticSimilarityRequest,
    workspace: str | None = Query(default=None),
    metadata_filter: str | None = Query(default=None),
    tier_roles: str | None = Query(default=None),
):
    """Find semantically related glosses using WordNet and distributional similarity."""
    db = _get_corpus_db(workspace)
    mf = _parse_metadata_filter(metadata_filter)
    tr = _parse_tier_roles(tier_roles)
    data = await asyncio.to_thread(
        db.semantic_similarity,
        top_n=request.top_n,
        min_count=request.min_count,
        metadata_filter=mf,
        tier_roles=tr,
    )
    return SemanticSimilarityResponse(
        clusters=[SemanticCluster(**c) for c in data.get("clusters", [])],
        pairs=[SimilarPair(**p) for p in data.get("pairs", [])],
        wordnet_matched=data.get("wordnet_matched", 0),
        unmatched=data.get("unmatched", 0),
    )


# ---------------------------------------------------------------------------
# Source tagging endpoints
# ---------------------------------------------------------------------------


@router.get("/files/sources")
async def file_sources(
    workspace: str | None = Query(default=None),
):
    """List all files with their source tags (human/model/unknown)."""
    db = _get_corpus_db(workspace)
    result = await asyncio.to_thread(db.get_file_sources)
    return result


@router.put("/files/{file_id}/source")
async def set_file_source(
    file_id: int,
    source: str = Query(..., pattern="^(human|model|unknown)$"),
    workspace: str | None = Query(default=None),
):
    """Tag a file as human or AI (model) generated."""
    db = _get_corpus_db(workspace)
    await asyncio.to_thread(db.tag_file_source, file_id, source)
    return {"ok": True}


@router.get("/files/by-source/{source}")
async def files_by_source(
    source: str,
    workspace: str | None = Query(default=None),
):
    """List files filtered by source type."""
    if source not in ("human", "model", "unknown"):
        raise HTTPException(
            400,
            f"Invalid source: {source}. " "Must be human, model, or unknown.",
        )
    db = _get_corpus_db(workspace)
    result = await asyncio.to_thread(db.files_by_source, source)
    return result


# ---------------------------------------------------------------------------
# Evaluation endpoints
# ---------------------------------------------------------------------------


@router.get("/evaluation/compare/{file_stem}")
async def compare_annotations(
    file_stem: str,
    tolerance_ms: int = Query(default=100, ge=0, le=2000),
    workspace: str | None = Query(default=None),
):
    """Compare AI vs human annotations for a video."""
    db = _get_corpus_db(workspace)
    try:
        result = await asyncio.to_thread(db.compare_sources, file_stem, tolerance_ms)
    except ValueError as exc:
        raise HTTPException(404, str(exc)) from exc
    return result


@router.get("/evaluation/batch-compare")
async def batch_compare(
    tolerance_ms: int = Query(default=100, ge=0, le=2000),
    workspace: str | None = Query(default=None),
):
    """Compare all AI vs human annotation pairs in the corpus."""
    db = _get_corpus_db(workspace)
    result = await asyncio.to_thread(db.batch_compare_sources, tolerance_ms)
    return result


@router.get("/evaluation/spotting-accuracy/{file_stem}")
async def spotting_accuracy(
    file_stem: str,
    rank_threshold: int = Query(default=5, ge=1, le=20),
    workspace: str | None = Query(default=None),
):
    """Evaluate spotting accuracy against human labels."""
    db = _get_corpus_db(workspace)
    try:
        result = await asyncio.to_thread(db.spotting_accuracy, file_stem, rank_threshold)
    except ValueError as exc:
        raise HTTPException(404, str(exc)) from exc
    return result
