"""
Pydantic models for SLTK API request/response schemas.

All request and response models are defined here for reuse across routers.
"""

from __future__ import annotations

import math
import uuid
from datetime import datetime
from typing import Any, Literal

from pydantic import BaseModel, Field, field_validator, model_validator

# ============================================================================
# Video Models
# ============================================================================


class VideoInfo(BaseModel):
    """Information about a video file."""

    path: str
    name: str
    size_mb: float
    duration_sec: float | None = None
    fps: float | None = None
    width: int | None = None
    height: int | None = None
    num_frames: int | None = None


class VideoDiscoveryRequest(BaseModel):
    """Request to discover videos in a directory."""

    root_path: str
    recursive: bool = True
    extensions: list[str] = Field(default_factory=lambda: [".mp4", ".mov", ".avi", ".mkv"])


class VideoDiscoveryResponse(BaseModel):
    """Response with discovered videos."""

    root_path: str
    videos: list[VideoInfo]
    total_count: int
    total_size_mb: float


# ============================================================================
# Extraction Models
# ============================================================================


class ExtractionConfig(BaseModel):
    """Configuration for extraction pipeline."""

    # Extractors to run
    enable_mediapipe: bool = True
    enable_wilor: bool = True
    enable_nlf: bool = False
    enable_segmentation: bool = True

    # MediaPipe options
    mediapipe_hands: bool = True
    mediapipe_face: bool = False

    # WiLoR options
    wilor_checkpoint: str | None = None
    wilor_detector: str | None = None
    wilor_img_batch_size: int = 64  # Frames to process at once (reduce for smaller GPUs)
    wilor_det_batch_size: int = 128  # Detections to process at once (reduce for smaller GPUs)

    # NLF options
    nlf_checkpoint: str | None = None
    nlf_smplx_path: str | None = None

    # Segmentation options
    segmentor_path: str | None = None  # DEPRECATED: Code is now bundled (kept for API compat)
    segmentor_checkpoint: str | None = None  # Path to segmentation model checkpoint (.ckpt)

    # Processing options
    device: str = "cuda:0"
    batch_size: int = 32
    skip_existing: bool = True

    # Output options
    output_format: str = "h5"
    compress: bool = True


class ExtractionRequest(BaseModel):
    """Request to start extraction pipeline."""

    input_path: str  # Single video or directory (used if video_paths not provided)
    output_root: str
    config: ExtractionConfig = Field(default_factory=ExtractionConfig)
    recursive: bool = True
    video_paths: list[str] | None = None  # Specific video files to process


class StageStatus(BaseModel):
    """Status of a single extraction stage."""

    name: str
    status: Literal["pending", "running", "completed", "failed", "skipped"] = "pending"
    error: str | None = None
    start_time: str | None = None
    end_time: str | None = None


class ExtractionJob(BaseModel):
    """Status of an extraction job."""

    job_id: str
    status: Literal["pending", "running", "completed", "failed", "cancelled"]
    created_at: str
    started_at: str | None = None
    completed_at: str | None = None

    # Progress - video level
    total_videos: int = 0
    processed_videos: int = 0
    current_video: str | None = None
    current_stage: str | None = None

    # Progress - frame level (for current video)
    current_frame: int = 0
    total_frames: int = 0

    # Progress - stage level
    stages: list[str] = Field(
        default_factory=lambda: ["mediapipe", "wilor", "features", "segmentation"]
    )
    stage_index: int = 0
    stage_statuses: dict[str, StageStatus] = Field(default_factory=dict)

    # Timing
    video_start_time: str | None = None
    estimated_remaining_sec: float | None = None

    # Results
    successful: list[str] = Field(default_factory=list)
    failed: list[dict[str, str]] = Field(default_factory=list)

    # Stage-level errors for current video
    stage_errors: dict[str, str] = Field(default_factory=dict)

    # Config
    input_path: str = ""
    output_root: str = ""
    config: ExtractionConfig | None = None


# ============================================================================
# Pose Models
# ============================================================================


class PoseDataRequest(BaseModel):
    """Request for pose data."""

    h5_path: str
    start_frame: int = 0
    end_frame: int | None = None
    format: str = "json"  # json, binary


# ============================================================================
# Segment Data Model
# ============================================================================


class SegmentData(BaseModel):
    """
    Segment information with validation.

    Attributes:
        id: Unique identifier for the segment (auto-generated UUID if not provided)
        start: Start time in seconds (must be >= 0)
        end: End time in seconds (must be > start)
        label: Optional label/gloss for the segment
        confidence: Optional confidence score in [0, 1]
        tier: ELAN tier name (default: "default")
    """

    id: str = Field(
        default_factory=lambda: str(uuid.uuid4()),
        description="Unique identifier for the segment",
    )
    start: float = Field(..., ge=0, description="Start time in seconds (must be >= 0)")
    end: float = Field(..., description="End time in seconds (must be > start)")
    label: str | None = Field(default=None, description="Optional segment label/gloss")
    confidence: float | None = Field(
        default=None,
        ge=0,
        le=1,
        description="Optional confidence score in [0, 1]",
    )
    tier: str = Field(default="default", min_length=1, description="ELAN tier name")
    annotation_id: str | None = Field(
        default=None,
        description="ELAN annotation ID (for round-trip editing)",
    )

    @field_validator("start")
    @classmethod
    def validate_start(cls, v: float) -> float:
        """Validate start time is finite and non-negative."""
        if not math.isfinite(v):
            raise ValueError("Start time must be a finite number")
        return v

    @field_validator("end")
    @classmethod
    def validate_end(cls, v: float) -> float:
        """Validate end time is not NaN (can be inf for unknown duration)."""
        if math.isnan(v):
            raise ValueError("End time must not be NaN")
        return v

    @field_validator("label")
    @classmethod
    def validate_label(cls, v: str | None) -> str | None:
        """Validate label is not empty or whitespace-only if provided."""
        if v is not None and len(v.strip()) == 0:
            raise ValueError("Label cannot be empty or whitespace-only")
        return v

    @model_validator(mode="after")
    def validate_timing(self) -> SegmentData:
        """Validate that end > start."""
        if self.end <= self.start:
            raise ValueError(
                f"End time ({self.end}) must be greater than start time ({self.start})"
            )
        return self

    @property
    def duration(self) -> float:
        """Duration of the segment in seconds."""
        return self.end - self.start


# ============================================================================
# Segment Editing Models (Merge, Split, Export)
# ============================================================================


class SegmentMergeRequest(BaseModel):
    """
    Request to merge multiple segments into one.

    Segments should be adjacent (close in time) and typically on the same tier.
    """

    segments: list[SegmentData] = Field(
        ...,
        min_length=2,
        description="List of segments to merge (at least 2 required)",
    )
    gap_threshold: float = Field(
        default=0.5,
        ge=0,
        description="Maximum gap between segments in seconds",
    )
    merged_label: str | None = Field(
        default=None,
        description="Label for the merged segment (uses first segment's label if not provided)",
    )
    merged_tier: str | None = Field(
        default=None,
        description="Tier for the merged segment (uses first segment's tier if not provided)",
    )


class SegmentSplitRequest(BaseModel):
    """
    Request to split a segment at a specific time point.

    The split_time must be within the segment bounds (start < split_time < end).
    """

    segment: SegmentData
    split_time: float = Field(
        ...,
        ge=0,
        description="Time at which to split the segment (must be within segment bounds)",
    )
    min_duration: float = Field(
        default=0.01,
        ge=0,
        description="Minimum duration for resulting segments",
    )

    @model_validator(mode="after")
    def validate_split_within_bounds(self) -> SegmentSplitRequest:
        """Validate that split_time is within segment bounds."""
        if self.split_time <= self.segment.start:
            raise ValueError(
                f"Split time ({self.split_time}) must be greater than segment start ({self.segment.start})"
            )
        if self.split_time >= self.segment.end:
            raise ValueError(
                f"Split time ({self.split_time}) must be less than segment end ({self.segment.end})"
            )
        # Check minimum duration
        left_duration = self.split_time - self.segment.start
        right_duration = self.segment.end - self.split_time
        if left_duration < self.min_duration:
            raise ValueError(
                f"Left segment would be too short ({left_duration:.3f}s < {self.min_duration}s)"
            )
        if right_duration < self.min_duration:
            raise ValueError(
                f"Right segment would be too short ({right_duration:.3f}s < {self.min_duration}s)"
            )
        return self


class SegmentExportRequest(BaseModel):
    """
    Request to export segments to a file.

    Validates the output path and segments before export.
    """

    segments: list[SegmentData] = Field(
        ...,
        min_length=1,
        description="List of segments to export",
    )
    output_path: str = Field(
        ...,
        min_length=1,
        description="Path for the output file",
    )
    video_path: str | None = Field(
        default=None,
        description="Optional video path to link in the output file",
    )
    format: str = Field(
        default="eaf",
        description="Output format (eaf, json, csv)",
    )

    @field_validator("format")
    @classmethod
    def validate_format(cls, v: str) -> str:
        """Validate export format."""
        allowed = {"eaf", "json", "csv"}
        if v.lower() not in allowed:
            raise ValueError(f"Format must be one of {allowed}")
        return v.lower()


# ============================================================================
# Annotation Models
# ============================================================================


class UnifiedDatasetInfo(BaseModel):
    """Information about a dataset's unified annotations."""

    name: str
    total_samples: int
    unique_glosses: int = 0
    unique_signers: int = 0
    tiers: list[str] = Field(default_factory=list)
    samples_by_split: dict[str, int] = Field(default_factory=dict)
    source_format: str = "unknown"
    version: str = "1.0.0"


class UnifiedAnnotationsResponse(BaseModel):
    """Response for listing available unified annotations."""

    datasets: list[UnifiedDatasetInfo]
    annotation_root: str
    total_datasets: int


class AnnotationSampleSummary(BaseModel):
    """Summary of a sample in unified annotations."""

    sample_id: str
    split: str | None = None
    num_segments: int = 0
    duration_sec: float | None = None
    signer_id: str | None = None


class UnifiedAnnotationResponse(BaseModel):
    """Full unified annotation for a sample."""

    sample_id: str
    dataset: str
    schema_version: str = "1.0.0"
    split: str | None = None
    signer_id: str | None = None
    duration_sec: float | None = None
    fps: float | None = None
    video_relative_path: str | None = None
    glosses: list[str] | None = None
    text: str | None = None
    segments: list[SegmentData]
    tiers: list[str]
    metadata: dict[str, Any] = Field(default_factory=dict)


# ============================================================================
# Dataset Models
# ============================================================================


class DatasetInfo(BaseModel):
    """Dataset information."""

    name: str
    root: str
    splits: list[str]
    total_samples: int
    languages: list[str]
    tasks: list[str]


class DatasetConnection(BaseModel):
    """A user-connected dataset configuration."""

    name: str
    language: str = "unknown"
    video_root: str
    annotation_root: str | None = None
    feature_root: str | None = None
    video_count: int = 0
    annotation_count: int = 0
    feature_count: int = 0
    created_at: str = Field(default_factory=lambda: datetime.now().isoformat())


class ConnectDatasetRequest(BaseModel):
    """Request to connect a new dataset."""

    name: str
    language: str = "unknown"
    video_root: str
    annotation_root: str | None = None
    feature_root: str | None = None


class ConnectDatasetResponse(BaseModel):
    """Response after connecting a dataset."""

    name: str
    status: str
    video_count: int
    annotation_count: int
    feature_count: int


class SampleInfo(BaseModel):
    """Sample information for listing."""

    id: str
    video_path: str | None = None
    has_video: bool = False
    has_poses: bool = False
    num_segments: int = 0
    num_glosses: int = 0
    signer_id: str | None = None
    duration_sec: float | None = None
    metadata: dict[str, Any] = Field(default_factory=dict)


class GlossExample(BaseModel):
    """Example of a gloss occurrence."""

    sample_id: str
    video_path: str | None = None
    start: float
    end: float
    label: str
    tier: str
    context: list[str] = Field(default_factory=list)  # Surrounding glosses


# ============================================================================
# Analysis Models
# ============================================================================


class QualityMetricsRequest(BaseModel):
    """Request for extraction quality metrics."""

    h5_paths: list[str] = Field(
        ...,
        description="List of H5 pose file paths to analyze",
        max_length=50,  # Cap at 50 files to prevent resource exhaustion
    )
    check_confidence: bool = Field(default=True, description="Compute confidence statistics")
    check_coverage: bool = Field(default=True, description="Compute detection coverage")
    check_temporal: bool = Field(default=True, description="Check for temporal gaps")


class VideoQualityMetrics(BaseModel):
    """Quality metrics for a single video's extracted poses."""

    path: str
    format: str | None = None
    num_frames: int = 0
    fps: float | None = None
    duration_sec: float | None = None

    # Detection coverage
    frames_with_detection: int = 0
    detection_rate: float = 0.0  # Percentage of frames with valid detections

    # Confidence scores
    mean_confidence: float | None = None
    min_confidence: float | None = None
    max_confidence: float | None = None
    low_confidence_frames: int = 0  # Frames with confidence below threshold

    # Temporal quality
    max_gap_frames: int = 0  # Longest gap in detections
    num_gaps: int = 0  # Number of detection gaps
    gap_locations: list[tuple[int, int]] = Field(default_factory=list)  # Start/end of gaps

    # Per-keypoint quality
    keypoint_detection_rates: dict[str, float] | None = None

    # Error info
    error: str | None = None


class QualityMetricsResponse(BaseModel):
    """Response with quality metrics for multiple videos."""

    num_files: int
    num_successful: int
    num_failed: int
    metrics: list[VideoQualityMetrics]
    summary: dict[str, Any] = Field(default_factory=dict)


class DatasetStatistics(BaseModel):
    """Statistics for a dataset or collection of files."""

    num_samples: int
    total_segments: int
    total_duration_sec: float
    vocabulary_size: int
    avg_segments_per_sample: float
    avg_segment_duration_sec: float
    tier_counts: dict[str, int]
    top_glosses: dict[str, int]


class BatchExportRequest(BaseModel):
    """Request for batch export."""

    elan_paths: list[str]
    output_format: Literal["csv", "json", "parquet"] = "csv"
    output_path: str
    include_metadata: bool = True


# ============================================================================
# Feature Models
# ============================================================================


class DetectedFeatures(BaseModel):
    """Detected features for a video."""

    video_name: str
    video_path: str | None = None
    mediapipe: str | None = None
    wilor: str | None = None
    nlf: str | None = None
    smpl: str | None = None
    segments: str | None = None
    angles: str | None = None
    hamer: str | None = None


class FeatureSourceModel(BaseModel):
    """A feature source directory with naming pattern."""

    path: str
    feature_types: list[str] = []
    naming_pattern: str = "{sample_id}_{feature_type}.h5"


class FeatureConnectionConfigModel(BaseModel):
    """Configuration for connecting features to a dataset."""

    dataset_name: str
    feature_sources: list[FeatureSourceModel]
    last_scan: str | None = None


class FeatureAvailabilityModel(BaseModel):
    """Feature availability for a single sample."""

    sample_id: str
    available: dict[str, str]  # feature_type -> file path
    missing: list[str]


class FeatureSummaryModel(BaseModel):
    """Summary of feature availability across a dataset."""

    dataset_name: str
    total_samples: int
    feature_counts: dict[str, int]  # feature_type -> count
    last_scan: str | None = None


class ExtractionStepModel(BaseModel):
    """A single step in an extraction plan."""

    order: int
    feature_type: str
    sample_count: int
    sample_ids: list[str]
    depends_on: list[str]
    is_extraction: bool


class ExtractionPlanModel(BaseModel):
    """Plan for extracting missing features."""

    target_features: list[str]
    steps: list[ExtractionStepModel]
    total_samples: int
    total_extractions: int


class ConnectFeaturesRequest(BaseModel):
    """Request to connect feature sources to a dataset."""

    sources: list[FeatureSourceModel]


class ScanFeaturesRequest(BaseModel):
    """Request to scan for feature availability."""

    sample_ids: list[str] | None = None  # None = scan all samples
    feature_types: list[str] | None = None  # None = scan all types


class ExtractFeaturesRequest(BaseModel):
    """Request to extract missing features."""

    target_features: list[str]
    output_path: str
    sample_ids: list[str] | None = None  # None = all samples
    mode: str = "fill_missing"  # fill_missing | overwrite | selected_only


class NamingPatternDetectionModel(BaseModel):
    """Detected naming pattern in a directory."""

    pattern: str
    example: str
    count: int


class FeatureScanJobModel(BaseModel):
    """Status of a feature scan job."""

    job_id: str
    dataset_name: str
    status: str  # pending | running | completed | failed
    progress: int
    total_samples: int
    scanned_samples: int
    error: str | None = None


class FeatureAvailabilityResponseModel(BaseModel):
    """Paginated feature availability response."""

    dataset_name: str
    samples: list[dict]
    total: int
    offset: int
    limit: int
    error: str | None = None


class FeatureExtractionResponseModel(BaseModel):
    """Response from starting feature extraction."""

    status: str  # created | no_extraction_needed
    job_id: str | None = None
    plan: dict | None = None
    message: str | None = None


# ============================================================================
# Linguistic Analysis Models
# ============================================================================


class ConcordanceRequest(BaseModel):
    """Request for concordance (KWIC) view."""

    elan_paths: list[str]
    target_gloss: str
    context_window: int = 5
    case_sensitive: bool = False


class CooccurrenceRequest(BaseModel):
    """Request for co-occurrence analysis."""

    elan_paths: list[str]
    target_gloss: str
    window_size: int = 3


class NGramRequest(BaseModel):
    """Request for n-gram extraction."""

    elan_paths: list[str]
    n: int = 2
    min_frequency: int = 2
    max_ngrams: int = 50


class DurationAnalysisRequest(BaseModel):
    """Request for duration pattern analysis."""

    elan_paths: list[str]
    gloss: str | None = None
    min_occurrences: int = 5


class MinimalPairsRequest(BaseModel):
    """Request for minimal pairs detection."""

    elan_paths: list[str]
    similarity_threshold: float = 0.7
    max_pairs: int = 50


class CooccurrenceMatrixRequest(BaseModel):
    """Request for co-occurrence matrix."""

    elan_paths: list[str]
    top_n_glosses: int = 30
    window_size: int = 2


# ============================================================================
# Similarity Search Models
# ============================================================================


class SimilaritySearchRequest(BaseModel):
    """Request for cross-dataset similarity search."""

    video_path: str
    top_k: int = 10
    target_datasets: list[str] | None = None
    exclude_source: bool = True


class IndexVideoRequest(BaseModel):
    """Request to index a video for similarity search."""

    video_path: str
    video_id: str | None = None
    dataset: str
    gloss: str | None = None


class IndexDatasetRequest(BaseModel):
    """Request to index an entire dataset."""

    dataset_name: str
    dataset_root: str
    max_samples: int | None = None


# ============================================================================
# Research Utilities Models
# ============================================================================


class VocabularyMappingRequest(BaseModel):
    """Request for vocabulary mapping creation."""

    elan_paths: list[str]
    min_frequency: int = 1
    special_tokens: list[str] | None = Field(
        default_factory=lambda: ["<PAD>", "<UNK>", "<BOS>", "<EOS>"]
    )


class DatasetComparisonRequest(BaseModel):
    """Request for dataset comparison."""

    dataset1_paths: list[str]
    dataset2_paths: list[str]
    dataset1_name: str = "Dataset 1"
    dataset2_name: str = "Dataset 2"


class GlossSearchRequest(BaseModel):
    """Request for gloss search."""

    gloss: str
    elan_paths: list[str]
    max_examples: int = 20
    context_window: int = 2


class ExportTrainingDataRequest(BaseModel):
    """Request for training data export."""

    elan_paths: list[str]
    output_path: str
    format: Literal["csv", "json", "jsonl"] = "csv"
    include_context: bool = True
    context_window: int = 2


# ============================================================================
# Settings Models
# ============================================================================


class UserSettings(BaseModel):
    """User settings that persist across sessions."""

    # Model paths (override bundled weights)
    wilor_checkpoint: str | None = None
    wilor_detector: str | None = None
    nlf_checkpoint: str | None = None
    segmentor_checkpoint: str | None = None

    # Output paths
    default_output_dir: str | None = None

    # Processing defaults
    default_device: str = "cuda:0"
    batch_size: int = 32
    compress_output: bool = True
    skip_existing: bool = True

    # UI preferences (synced from frontend)
    theme: str = "dark"
    sidebar_collapsed: bool = False

    # AI / LLM settings
    ai_provider: str = "openai"  # "openai" or "anthropic"
    openai_api_key: str | None = None
    openai_model: str = "gpt-4o"
    anthropic_api_key: str | None = None
    anthropic_model: str = "claude-sonnet-4-5-20250929"


class WeightStatus(BaseModel):
    """Status of a model weight file."""

    path: str
    exists: bool
    size_mb: float
    source: str  # 'bundled', 'env', or 'missing'
    description: str


# ============================================================================
# Cross-Dataset Comparison Models
# ============================================================================


class GlossMatchResponse(BaseModel):
    """A single gloss match from cross-dataset search."""

    dataset: str
    sample_id: str
    segment_idx: int
    start: float
    end: float
    duration: float
    context_before: list[str] = Field(default_factory=list)
    context_after: list[str] = Field(default_factory=list)
    signer_id: str | None = None
    video_path: str | None = None


class CrossDatasetSearchResponse(BaseModel):
    """Response for cross-dataset gloss search."""

    gloss: str
    total_matches: int
    matches_by_dataset: dict[str, list[GlossMatchResponse]] = Field(default_factory=dict)
    dataset_frequencies: dict[str, int] = Field(default_factory=dict)


class VocabularyComparisonRequest(BaseModel):
    """Request for vocabulary comparison across datasets."""

    datasets: list[str] = Field(..., min_length=2, description="At least 2 datasets required")
    tier: str = "gloss"
    min_frequency: int = Field(default=1, ge=1)


class VocabularyComparisonResponse(BaseModel):
    """Response for vocabulary comparison."""

    datasets: list[str]
    shared_vocabulary: list[str] = Field(default_factory=list)
    unique_by_dataset: dict[str, list[str]] = Field(default_factory=dict)
    vocabulary_sizes: dict[str, int] = Field(default_factory=dict)
    overlap_matrix: dict[str, dict[str, int]] = Field(default_factory=dict)
    jaccard_similarity: dict[str, dict[str, float]] = Field(default_factory=dict)


class ParallelExamplesRequest(BaseModel):
    """Request for finding parallel examples across datasets."""

    glosses: list[str] = Field(..., min_length=1)
    datasets: list[str] = Field(..., min_length=1)
    tier: str = "gloss"
    max_examples_per_dataset: int = Field(default=5, ge=1, le=50)


class ParallelExampleMatch(BaseModel):
    """A parallel example match."""

    sample_id: str
    start: float
    end: float
    duration: float
    context_before: list[str] = Field(default_factory=list)
    context_after: list[str] = Field(default_factory=list)
    signer_id: str | None = None
    video_path: str | None = None


class ParallelExamplesResponse(BaseModel):
    """Response for parallel examples search."""

    glosses: list[str]
    examples_by_dataset: dict[str, dict[str, list[ParallelExampleMatch]]] = Field(
        default_factory=dict
    )
    coverage: dict[str, list[str]] = Field(
        default_factory=dict, description="Which glosses are found in each dataset"
    )


class DurationStatistics(BaseModel):
    """Duration statistics for a gloss."""

    count: int
    mean: float
    std: float
    min: float
    max: float
    median: float
    quartiles: tuple[float, float, float] | None = None


class DurationComparisonResponse(BaseModel):
    """Response for duration comparison across datasets."""

    gloss: str
    statistics_by_dataset: dict[str, DurationStatistics] = Field(default_factory=dict)
    cross_dataset_stats: DurationStatistics | None = None


class ContextPattern(BaseModel):
    """A context pattern with frequency."""

    pattern: str
    frequency: int
    datasets: list[str] = Field(default_factory=list)


class ContextPatternsResponse(BaseModel):
    """Response for context pattern analysis."""

    gloss: str
    left_contexts: list[ContextPattern] = Field(default_factory=list)
    right_contexts: list[ContextPattern] = Field(default_factory=list)
    bigrams_with_target: list[ContextPattern] = Field(default_factory=list)
    datasets_analyzed: list[str] = Field(default_factory=list)


class BatchSearchRequest(BaseModel):
    """Request for batch gloss search."""

    glosses: list[str] = Field(..., min_length=1, max_length=100)
    datasets: list[str] | None = None
    tier: str = "gloss"
    case_sensitive: bool = False
    max_results_per_gloss: int = Field(default=50, ge=1, le=500)


class BatchSearchResultItem(BaseModel):
    """Single gloss result in batch search."""

    gloss: str
    total_matches: int
    dataset_frequencies: dict[str, int] = Field(default_factory=dict)


class BatchSearchResponse(BaseModel):
    """Response for batch gloss search."""

    results: list[BatchSearchResultItem] = Field(default_factory=list)
    total_glosses_searched: int
    glosses_found: int
    glosses_not_found: list[str] = Field(default_factory=list)


class CrossDatasetInfo(BaseModel):
    """Information about a dataset available for cross-dataset comparison."""

    name: str
    total_samples: int
    vocabulary_size: int
    unique_signers: int = 0
    language: str = "unknown"
    tiers: list[str] = Field(default_factory=list)
    has_video: bool = False
    has_poses: bool = False


# ============================================================================
# Workspace Models
# ============================================================================


class WorkspaceScanRequest(BaseModel):
    """Request to scan a directory for videos and ELAN files."""

    path: str
    recursive: bool = True


class WorkspaceFileInfo(BaseModel):
    """Information about a discovered file."""

    path: str
    name: str
    stem: str
    extension: str
    size_mb: float
    type: Literal["video", "elan", "features"]
    feature_type: str | None = None  # "wilor", "smpl", "mediapipe", etc.
    fps: float | None = None
    duration_sec: float | None = None


class WorkspaceMatch(BaseModel):
    """A matched video-annotation pair."""

    id: str = Field(default_factory=lambda: str(uuid.uuid4()))
    video: WorkspaceFileInfo
    elan: WorkspaceFileInfo | None = None
    features: list[WorkspaceFileInfo] = Field(default_factory=list)


class WorkspaceScanResponse(BaseModel):
    """Response from workspace scan."""

    workspace_name: str = ""
    path: str
    matches: list[WorkspaceMatch]
    unmatched_videos: list[WorkspaceFileInfo]
    unmatched_elan: list[WorkspaceFileInfo]
    features: list[WorkspaceFileInfo]
    counts: dict[str, int] = Field(default_factory=dict)
    annotator_id: str = ""


class WorkspaceManualMatchRequest(BaseModel):
    """Request to manually pair or unpair a video and ELAN file."""

    video_path: str
    elan_path: str | None = None


class WorkspaceFetchUrlRequest(BaseModel):
    """Request to download a file from URL into workspace."""

    url: str
    filename: str | None = None


# ============================================================================
# Multi-Workspace Models
# ============================================================================


class VideoRef(BaseModel):
    """Reference to a video file (by path, no copying)."""

    path: str
    detached: bool = False


class WorkspaceConfig(BaseModel):
    """Configuration for a single named workspace."""

    name: str
    folder_path: str
    created_at: str
    last_accessed: str
    annotator_id: str = ""
    video_refs: list[VideoRef] = Field(default_factory=list)
    manual_overrides: dict[str, str | None] = Field(default_factory=dict)
    scan_config: dict[str, Any] = Field(default_factory=lambda: {"recursive": True})
    additional_folders: list[str] = Field(default_factory=list)


class WorkspacesFile(BaseModel):
    """Top-level structure for ~/.sltk/workspaces.json."""

    version: int = 1
    active_workspace: str | None = None
    workspaces: dict[str, WorkspaceConfig] = Field(default_factory=dict)


class CreateWorkspaceRequest(BaseModel):
    """Request to create a new workspace."""

    name: str = Field(..., min_length=1, max_length=100)
    folder_path: str
    recursive: bool = True
    selected_video_paths: list[str] | None = None  # None = all (backward compat)


class AddFolderRequest(BaseModel):
    """Request to add a folder to the active workspace."""

    folder_path: str
    recursive: bool = True


class SwitchWorkspaceRequest(BaseModel):
    """Request to switch to a different workspace."""

    name: str


class DetachVideoRequest(BaseModel):
    """Request to detach (hide) a video from a workspace."""

    video_path: str


class AddVideoRefRequest(BaseModel):
    """Request to add a video reference to the workspace."""

    video_path: str


class RenameWorkspaceRequest(BaseModel):
    """Request to rename a workspace."""

    old_name: str
    new_name: str = Field(..., min_length=1, max_length=100)


class WorkspaceListItem(BaseModel):
    """Summary of a workspace for the list endpoint."""

    name: str
    folder_path: str
    video_count: int
    last_accessed: str


class WorkspaceListResponse(BaseModel):
    """Response listing all workspaces."""

    workspaces: list[WorkspaceListItem]
    active_workspace: str | None = None


class DiscoverDirectoryRequest(BaseModel):
    """Request to scan a directory without creating a workspace."""

    folder_path: str
    recursive: bool = True


class DiscoverDirectoryResponse(BaseModel):
    """Response with discovered files from a directory scan."""

    path: str
    videos: list[WorkspaceFileInfo]
    elan_files: list[WorkspaceFileInfo]
    feature_files: list[WorkspaceFileInfo]
    matches: list[WorkspaceMatch]
    unmatched_videos: list[WorkspaceFileInfo]
    unmatched_elan: list[WorkspaceFileInfo]
    counts: dict[str, int] = Field(default_factory=dict)


# ============================================================================
# Filename Annotation Models
# ============================================================================


class FilenameAnnotationEntry(BaseModel):
    """A single video path + pre-computed gloss for synthetic annotation."""

    video_path: str
    gloss: str


class FilenameAnnotationRequest(BaseModel):
    """Request to create synthetic annotations from pre-computed glosses."""

    entries: list[FilenameAnnotationEntry] = Field(..., min_length=1)
    tier_name: str = "Gloss"


class FilenameAnnotationResult(BaseModel):
    """Result for a single video in filename annotation."""

    video_path: str
    stem: str
    gloss: str
    status: str  # "ok", "truncated", "empty", "error"


class FilenameAnnotationResponse(BaseModel):
    """Response from filename annotation ingestion."""

    created: int = 0
    skipped: int = 0
    results: list[FilenameAnnotationResult] = Field(default_factory=list)


# ============================================================================
# Visualization Overlay Models
# ============================================================================


class OverlayRequest(BaseModel):
    """Request to generate a skeleton overlay video."""

    video_path: str
    h5_path: str
    viz_type: Literal["mediapipe", "wilor", "nlf"]
    force: bool = False


class VizJob(BaseModel):
    """Status of a visualization overlay generation job."""

    job_id: str
    status: Literal["pending", "running", "completed", "failed"]
    current_frame: int = 0
    total_frames: int = 0
    output_path: str | None = None
    error: str | None = None


# ============================================================================
# Audio Waveform Models
# ============================================================================


class WaveformResponse(BaseModel):
    """Waveform peak data extracted from a video's audio track."""

    peaks: list[list[float]]
    duration: float
    sample_rate: int = 16000
    num_samples: int


# ============================================================================
# Corpus Database Models
# ============================================================================


class CorpusStatusResponse(BaseModel):
    """Status of the corpus database."""

    file_count: int = 0
    segment_count: int = 0
    last_updated: str | None = None


class CorpusSummaryResponse(BaseModel):
    """Summary statistics for the corpus."""

    total_files: int = 0
    total_segments: int = 0
    vocabulary_size: int = 0
    signer_count: int = 0
    total_tiers: int = 0


class GlossFrequency(BaseModel):
    """A gloss with its frequency count."""

    gloss: str
    frequency: int


class GlossOccurrence(BaseModel):
    """A single occurrence of a gloss in the corpus."""

    label: str
    start_ms: int
    end_ms: int
    duration_ms: int
    annotation_id: str
    confidence: float | None = None
    source: str = "file"
    tier: str
    participant: str = ""
    file_path: str
    file_name: str
    file_stem: str = ""


class GlossDurationStats(BaseModel):
    """Duration statistics for a gloss."""

    count: int = 0
    mean_ms: float = 0
    std_ms: float = 0
    min_ms: int = 0
    max_ms: int = 0
    median_ms: float = 0


class SignerDurationStats(BaseModel):
    """Per-signer duration stats for a gloss."""

    participant: str
    count: int
    mean_ms: float
    min_ms: int
    max_ms: int


class CorpusSearchRequest(BaseModel):
    """Request for FTS search over the corpus."""

    query: str = Field(..., min_length=1)
    limit: int = Field(default=100, ge=1, le=1000)


class CorpusConcordanceRequest(BaseModel):
    """Request for KWIC concordance."""

    target: str = Field(..., min_length=1)
    context_window: int = Field(default=5, ge=1, le=20)
    limit: int = Field(default=0, ge=0, le=5000, description="Max results (0 = unlimited)")


class ConcordanceResult(BaseModel):
    """A single KWIC concordance line."""

    left: list[str]
    target: str
    right: list[str]
    start_ms: int
    end_ms: int
    tier: str
    file_path: str
    file_name: str


class CorpusNgramRequest(BaseModel):
    """Request for n-gram frequency analysis."""

    n: int = Field(default=2, ge=2, le=5)
    limit: int = Field(default=50, ge=1, le=5000)


class NgramResult(BaseModel):
    """An n-gram with its frequency."""

    ngram: list[str]
    frequency: int


class CorpusCooccurrenceRequest(BaseModel):
    """Request for co-occurrence analysis."""

    target: str = Field(..., min_length=1)
    window: int = Field(default=3, ge=1, le=20)
    limit: int = Field(default=50, ge=1, le=5000)


class CooccurrenceResult(BaseModel):
    """A co-occurring label with count."""

    label: str
    count: int


class CorpusVocabularyResponse(BaseModel):
    """Vocabulary frequencies and aggregate stats."""

    vocabulary_size: int = 0
    total_tokens: int = 0
    frequencies: dict[str, int] = Field(default_factory=dict)


class CorpusCooccurrenceMatrixRequest(BaseModel):
    """Request for co-occurrence matrix."""

    top_n: int = Field(default=30, ge=2, le=500)
    window: int = Field(default=2, ge=1, le=20)


class CorpusCooccurrenceMatrixResponse(BaseModel):
    """Co-occurrence matrix for top glosses."""

    glosses: list[str] = Field(default_factory=list)
    matrix: list[list[int]] = Field(default_factory=list)
    frequencies: dict[str, int] = Field(default_factory=dict)


class CorpusDurationStatsItem(BaseModel):
    """Per-gloss duration aggregates."""

    gloss: str
    count: int
    mean_ms: float
    std_ms: float
    min_ms: int
    max_ms: int
    median_ms: float


class CorpusDurationAnalysisRequest(BaseModel):
    """Request for corpus-wide duration analysis."""

    limit: int = Field(default=50, ge=1, le=5000)
    min_count: int = Field(default=5, ge=1, le=100)


class CorpusDurationAnalysisResponse(BaseModel):
    """Response for corpus-wide duration analysis."""

    items: list[CorpusDurationStatsItem] = Field(default_factory=list)
    total_glosses: int = 0


class CorpusMinimalPairItem(BaseModel):
    """A pair of similar glosses."""

    gloss1: str
    gloss2: str
    similarity: float


class CorpusMinimalPairsRequest(BaseModel):
    """Request for minimal pairs analysis."""

    threshold: float = Field(default=0.7, ge=0.1, le=0.99)
    max_pairs: int = Field(default=50, ge=1, le=5000)


class CorpusMinimalPairsResponse(BaseModel):
    """Response for minimal pairs analysis."""

    vocabulary_size: int = 0
    total_pairs: int = 0
    similarity_threshold: float = 0.7
    pairs: list[CorpusMinimalPairItem] = Field(default_factory=list)


class CompareWorkspacesRequest(BaseModel):
    """Request to compare a gloss across workspaces."""

    gloss: str = Field(..., min_length=1)
    workspace_names: list[str] = Field(
        ..., min_length=2, description="Names of workspaces to compare"
    )


class WorkspaceGlossComparison(BaseModel):
    """Gloss stats from a single workspace."""

    workspace: str
    db_path: str
    count: int = 0
    mean_ms: float = 0
    std_ms: float = 0
    min_ms: int = 0
    max_ms: int = 0
    median_ms: float = 0


# ============================================================================
# Dashboard Models
# ============================================================================


class SignerTokenCount(BaseModel):
    """Token count for a single signer."""

    signer: str
    tokens: int


class DashboardStatsResponse(BaseModel):
    """Comprehensive dashboard statistics."""

    total_files: int = 0
    total_segments: int = 0
    vocabulary_size: int = 0
    signer_count: int = 0
    unique_tier_names: int = 0
    total_tokens: int = 0
    hapax_count: int = 0
    dis_legomena_count: int = 0
    avg_signs_per_file: float = 0
    avg_duration_ms: int = 0
    median_duration_ms: int = 0
    total_duration_sec: float = 0
    ttr: float = 0
    top_signers: list[SignerTokenCount] = Field(default_factory=list)


class FrequencyDistributionItem(BaseModel):
    """A single item in the frequency distribution."""

    rank: int
    gloss: str
    frequency: int


class DurationHistogramBin(BaseModel):
    """A single bin in the duration histogram."""

    bin_start: int
    bin_end: int
    count: int


# ============================================================================
# Lexical Richness Models
# ============================================================================


class LexicalRichnessResponse(BaseModel):
    """Lexical richness metrics for the corpus."""

    total_tokens: int = 0
    vocabulary_size: int = 0
    ttr: float = 0
    hapax_count: int = 0
    hapax_pct: float = 0
    dis_legomena_count: int = 0
    dis_legomena_pct: float = 0
    yules_k: float = 0
    simpsons_d: float = 0
    brunets_w: float = 0
    honores_r: float = 0
    mattr: float = 0
    mattr_window: int = 50


class SignerRichnessItem(BaseModel):
    """Lexical richness metrics for a single signer."""

    participant: str
    total_tokens: int
    vocabulary_size: int
    ttr: float
    hapax_count: int
    mattr: float
    yules_k: float


# ============================================================================
# Transition Models
# ============================================================================


class SignEntropyItem(BaseModel):
    """Entropy and predictability for a sign."""

    sign: str
    entropy: float
    predictability: float


class TransitionsResponse(BaseModel):
    """Bigram transition probabilities."""

    signs: list[str] = Field(default_factory=list)
    matrix: list[list[float]] = Field(default_factory=list)
    entropy_scores: list[SignEntropyItem] = Field(default_factory=list)
    frequencies: dict[str, int] = Field(default_factory=dict)


# ============================================================================
# Fluency Models
# ============================================================================


class FileFlencyStats(BaseModel):
    """Fluency metrics for a single file."""

    file_name: str
    file_stem: str
    sign_count: int
    span_sec: float
    signs_per_min: float
    avg_sign_duration_ms: float
    pause_count: int
    avg_pause_ms: float
    median_pause_ms: float


class DurationFrequencyItem(BaseModel):
    """Duration vs frequency for scatter plot."""

    gloss: str
    frequency: int
    mean_duration_ms: float


class FluencyResponse(BaseModel):
    """Fluency analysis results."""

    file_stats: list[FileFlencyStats] = Field(default_factory=list)
    pause_histogram: list[DurationHistogramBin] = Field(default_factory=list)
    duration_vs_frequency: list[DurationFrequencyItem] = Field(default_factory=list)
    global_avg_pause_ms: float = 0
    global_median_pause_ms: float = 0


class SignerFluencyItem(BaseModel):
    """Fluency metrics for a single signer."""

    participant: str
    sign_count: int
    signs_per_min: float
    avg_sign_duration_ms: float
    avg_pause_ms: float


# ============================================================================
# Processing Job Models (stub)
# ============================================================================


class ProcessingSubmitRequest(BaseModel):
    """Request to submit videos for processing."""

    video_paths: list[str] = Field(..., min_length=1)
    type: Literal["segments", "spots", "both"] = "segments"
    model: str = "SIGNREP"
    merge_into_existing: bool = False
    workspace: str | None = None
    dictionary_dirs: list[str] = Field(default_factory=list)
    top_k: int = Field(default=5, ge=1, le=20)
    fps: float = Field(default=25.0, gt=0, le=120)


class ProcessingJobStatus(BaseModel):
    """Status of a processing job."""

    job_id: str
    status: Literal["pending", "running", "completed", "failed"] = "pending"
    created_at: str
    started_at: str | None = None
    completed_at: str | None = None
    progress_percent: float = 0.0
    current_video: str | None = None
    total_videos: int = 0
    processed_videos: int = 0
    type: str = "segments"
    model: str = "SIGNREP"
    error: str | None = None
    output_files: list[str] = Field(default_factory=list)
    workspace: str | None = None


class ProcessingJobListResponse(BaseModel):
    """Response listing processing jobs."""

    jobs: list[ProcessingJobStatus]
    total: int


# ============================================================================
# AI Evaluation Models
# ============================================================================


class SegmentComparisonPair(BaseModel):
    """A matched pair of human and AI segments."""

    human: dict
    model: dict
    start_error_ms: int
    end_error_ms: int


class ComparisonResult(BaseModel):
    """Result of comparing AI vs human annotations for a video."""

    human_file: dict
    model_file: dict
    precision: float
    recall: float
    f1: float
    mean_start_error_ms: float
    mean_end_error_ms: float
    over_segmentation_ratio: float
    tolerance_ms: int
    matched_pairs: list[SegmentComparisonPair]
    unmatched_human: list[dict]
    unmatched_model: list[dict]


class BatchComparisonItem(BaseModel):
    """Summary comparison for a single video in batch evaluation."""

    file_stem: str
    precision: float
    recall: float
    f1: float
    human_count: int
    model_count: int
    mean_boundary_error_ms: float


class SpottingAccuracyResult(BaseModel):
    """Spotting accuracy evaluation against human labels."""

    file_stem: str
    top_1_accuracy: float
    top_5_accuracy: float
    total_segments: int
    confusion_pairs: list[dict]


# ============================================================================
# Path Metadata Extraction Models
# ============================================================================


class PathExtractionPreviewItem(BaseModel):
    """A single file's path extraction preview."""

    file_id: int
    path: str
    extracted_value: str | None = None


class PathExtractionPreviewResponse(BaseModel):
    """Preview of path extraction results."""

    segment_index: int
    items: list[PathExtractionPreviewItem] = Field(default_factory=list)
    unique_values: list[str] = Field(default_factory=list)
    total_files: int = 0
    empty_count: int = 0


class PathExtractionApplyRequest(BaseModel):
    """Request to apply path extraction."""

    segment_index: int = Field(..., ge=0, le=20)
    mode: Literal["participant", "tier"] = "participant"
    tier_name: str = "PathMeta"
    only_empty: bool = True


class PathExtractionApplyResponse(BaseModel):
    """Response from applying path extraction."""

    mode: str
    updated_files: int = 0
    updated_tiers: int = 0
    created_tiers: int = 0
    created_segments: int = 0


# ============================================================================
# Metadata & Substring Extraction Models
# ============================================================================


class SubstringFieldDef(BaseModel):
    """A named character range within a path segment."""

    key: str
    start: int
    end: int


class SubstringExtractionPreviewRequest(BaseModel):
    """Request to preview substring extraction from path segments."""

    segment_index: int
    definitions: list[SubstringFieldDef]


class SubstringExtractionPreviewFile(BaseModel):
    """One file row in a substring extraction preview."""

    file_id: int
    path: str
    segment_value: str | None
    fields: dict[str, str | None]


class SubstringExtractionPreviewResponse(BaseModel):
    """Response from previewing substring extraction."""

    files: list[SubstringExtractionPreviewFile]
    unique_per_key: dict[str, list[str]]
    total_files: int
    empty_count: int


class SubstringExtractionApplyRequest(BaseModel):
    """Request to apply substring extraction and store as metadata."""

    segment_index: int
    definitions: list[SubstringFieldDef]
    set_participant_key: str | None = None


class SubstringExtractionApplyResponse(BaseModel):
    """Response from applying substring extraction."""

    applied_files: int
    fields_created: int
    participant_updated: int


class SegmentFieldApplyRequest(BaseModel):
    """Request to store a whole path segment as metadata."""

    segment_index: int = Field(..., ge=0, le=20)
    field_name: str
    set_participant_key: bool = False


class DelimiterFieldAssignment(BaseModel):
    """Maps a token position (from delimiter split) to a field name."""

    token_index: int = Field(..., ge=0)
    field_name: str


class DelimiterExtractionPreviewRequest(BaseModel):
    """Preview delimiter-based extraction from filename tokens."""

    segment_index: int = Field(..., ge=0, le=20)
    delimiter: str = Field(default="_", min_length=1, max_length=5)
    assignments: list[DelimiterFieldAssignment] = Field(..., min_length=1)


class DelimiterExtractionApplyRequest(BaseModel):
    """Apply delimiter-based extraction and store as file metadata."""

    segment_index: int = Field(..., ge=0, le=20)
    delimiter: str = Field(default="_", min_length=1, max_length=5)
    assignments: list[DelimiterFieldAssignment] = Field(..., min_length=1)
    set_participant_key: str | None = None


class MetadataKeyInfo(BaseModel):
    """Summary info for a single metadata key."""

    key: str
    unique_values: int
    file_count: int
    sources: list[str]


class MetadataStatusResponse(BaseModel):
    """Overall metadata status for a corpus."""

    keys: list[MetadataKeyInfo]
    elan_participants: int
    total_files: int
    files_with_metadata: int


class MetadataValueItem(BaseModel):
    """A distinct value for a metadata key with file count."""

    value: str
    file_count: int


class TierRoleInfo(BaseModel):
    """A unique tier ID with its segment/file counts and assigned role."""

    tier_id: str
    linguistic_type: str | None
    segment_count: int
    file_count: int
    role: str
    is_explicit: bool = False


class TierAutoDetectItem(BaseModel):
    """A tier with its auto-detected suggested role."""

    tier_id: str
    linguistic_type: str | None
    segment_count: int
    file_count: int
    suggested_role: str
    current_role: str | None


class TierRolesUpdateRequest(BaseModel):
    """Request to bulk-update tier role assignments."""

    assignments: dict[str, str]


# ============================================================================
# Group Comparison Models
# ============================================================================


class GroupComparisonItem(BaseModel):
    """Metrics for a single group (signer, region, etc.)."""

    group_value: str
    vocabulary_size: int
    total_tokens: int
    avg_duration_ms: float
    file_count: int


class GroupComparisonResponse(BaseModel):
    """Response for grouped comparison analysis."""

    group_by: str
    metadata_key: str | None = None
    groups: list[GroupComparisonItem] = Field(default_factory=list)


class GroupTopGlossItem(BaseModel):
    """A gloss with frequency in a group."""

    gloss: str
    freq: int


class GroupTopGlossesResponse(BaseModel):
    """Top glosses for a specific group."""

    group_by: str
    group_value: str
    glosses: list[GroupTopGlossItem] = Field(default_factory=list)


# ============================================================================
# Hand Comparison Models
# ============================================================================


class HandStats(BaseModel):
    """Aggregate stats for one hand."""

    total_tokens: int = 0
    vocabulary_size: int = 0
    avg_duration_ms: float = 0
    file_count: int = 0


class HandVocabItem(BaseModel):
    """A gloss with its frequency on a hand."""

    gloss: str
    freq: int


class SimultaneousSignItem(BaseModel):
    """A pair of signs produced simultaneously on both hands."""

    left_gloss: str
    right_gloss: str
    freq: int


class HandComparisonResponse(BaseModel):
    """Response for left vs right hand comparison."""

    left_hand: HandStats = Field(default_factory=HandStats)
    right_hand: HandStats = Field(default_factory=HandStats)
    left_vocab: list[HandVocabItem] = Field(default_factory=list)
    right_vocab: list[HandVocabItem] = Field(default_factory=list)
    shared_count: int = 0
    left_only_count: int = 0
    right_only_count: int = 0
    simultaneous: list[SimultaneousSignItem] = Field(default_factory=list)


# ============================================================================
# Universal Search Models
# ============================================================================


class SearchResultFile(BaseModel):
    """A file matching the search query."""

    file_id: int
    path: str
    name: str
    stem: str
    segment_count: int
    signers: list[str] = Field(default_factory=list)


class SearchResultGloss(BaseModel):
    """A gloss matching the search query."""

    gloss: str
    count: int
    file_count: int
    sample_file_path: str | None = None
    sample_file_name: str | None = None


class SearchResultSigner(BaseModel):
    """A signer/participant matching the search query."""

    participant: str
    file_count: int
    segment_count: int


class SearchResultMetadata(BaseModel):
    """A metadata value matching the search query."""

    key: str
    value: str
    file_count: int
    source: str


class UniversalSearchRequest(BaseModel):
    """Request for universal corpus search."""

    query: str = Field(..., min_length=1)
    limit: int = Field(default=20, ge=1, le=200)
    tier_id: str | None = Field(default=None, description="Restrict results to a specific tier")


class UniversalSearchResponse(BaseModel):
    """Response for universal corpus search."""

    query: str
    files: list[SearchResultFile] = Field(default_factory=list)
    glosses: list[SearchResultGloss] = Field(default_factory=list)
    signers: list[SearchResultSigner] = Field(default_factory=list)
    metadata: list[SearchResultMetadata] = Field(default_factory=list)


# ============================================================================
# Available Tiers
# ============================================================================


class AvailableTier(BaseModel):
    """A tier with segment data and suggested category."""

    tier_id: str
    linguistic_type: str | None = None
    segment_count: int
    file_count: int
    suggested_category: str


# ============================================================================
# Multi-Tier Concordance Models
# ============================================================================


class OverlappingAnnotation(BaseModel):
    """An annotation overlapping with the anchor hit."""

    tier: str
    label: str
    start_ms: int
    end_ms: int
    overlap_pct: float = 0


class MultiTierHit(BaseModel):
    """A single concordance hit with all overlapping annotations."""

    anchor_label: str
    anchor_tier: str
    start_ms: int
    end_ms: int
    file_name: str
    file_path: str
    participant: str = ""
    overlapping: list[OverlappingAnnotation] = Field(default_factory=list)


class MultiTierConcordanceRequest(BaseModel):
    """Request for multi-tier concordance."""

    target: str = Field(..., min_length=1)
    anchor_tier_ids: list[str] | None = None
    display_tier_ids: list[str] | None = None
    context_window_ms: int = Field(default=2000, ge=0, le=10000)
    limit: int = Field(default=200, ge=1, le=2000)


class MultiTierConcordanceResponse(BaseModel):
    """Response for multi-tier concordance."""

    target: str
    hits: list[MultiTierHit] = Field(default_factory=list)
    total_hits: int = 0


# ============================================================================
# Non-Manual Feature Co-occurrence Models
# ============================================================================


class NonManualForm(BaseModel):
    """A non-manual form with its count."""

    tier_label: str
    count: int


class SignNonManualStats(BaseModel):
    """Non-manual co-occurrence stats for a single sign."""

    sign: str
    total_count: int
    with_nonmanual: int
    nonmanual_rate: float
    forms: list[NonManualForm] = Field(default_factory=list)


class NonManualCooccurrenceRequest(BaseModel):
    """Request for non-manual co-occurrence analysis."""

    manual_tier_ids: list[str] | None = None
    nonmanual_tier_ids: list[str] | None = None
    target_sign: str | None = None
    min_count: int = Field(default=5, ge=1)
    top_n: int = Field(default=100, ge=1, le=500)


class NonManualCooccurrenceResponse(BaseModel):
    """Response for non-manual co-occurrence analysis."""

    signs: list[SignNonManualStats] = Field(default_factory=list)
    total_signs_analyzed: int = 0
    overall_nm_rate: float = 0


# ============================================================================
# Constructed Action Models
# ============================================================================


class SignFrequency(BaseModel):
    """A sign with frequency count."""

    sign: str
    freq: int


class CAVocabSummary(BaseModel):
    """Vocabulary summary for inside/outside CA."""

    total_tokens: int = 0
    vocabulary_size: int = 0
    top_signs: list[SignFrequency] = Field(default_factory=list)
    avg_sign_duration_ms: float = 0


class DistinctiveSign(BaseModel):
    """A sign with distinctive frequency in/outside CA."""

    sign: str
    inside_freq: int
    outside_freq: int
    ratio: float
    direction: str


class ConstructedActionRequest(BaseModel):
    """Request for constructed action analysis."""

    ca_tier_ids: list[str] | None = None
    gloss_tier_ids: list[str] | None = None
    top_n: int = Field(default=50, ge=1, le=500)


class ConstructedActionResponse(BaseModel):
    """Response for constructed action analysis."""

    ca_spans: int = 0
    files_with_ca: int = 0
    total_ca_duration_ms: int = 0
    inside: CAVocabSummary = Field(default_factory=CAVocabSummary)
    outside: CAVocabSummary = Field(default_factory=CAVocabSummary)
    distinctive_signs: list[DistinctiveSign] = Field(default_factory=list)


# ============================================================================
# Clause Structure Models
# ============================================================================


class ClauseTypeStats(BaseModel):
    """Stats for a single clause type."""

    type: str
    tier: str
    count: int
    avg_duration_ms: float = 0
    avg_signs_per_clause: float = 0
    vocabulary_size: int = 0
    top_signs: list[SignFrequency] = Field(default_factory=list)


class ClauseStructureRequest(BaseModel):
    """Request for clause structure analysis."""

    clause_tier_ids: list[str] | None = None
    gloss_tier_ids: list[str] | None = None
    top_n: int = Field(default=50, ge=1, le=500)


class ClauseStructureResponse(BaseModel):
    """Response for clause structure analysis."""

    clause_types: list[ClauseTypeStats] = Field(default_factory=list)
    total_clauses: int = 0
    avg_signs_per_clause: float = 0


# ============================================================================
# Semantic Similarity Models
# ============================================================================


class SemanticCluster(BaseModel):
    """A group of glosses sharing a semantic category."""

    category: str
    glosses: list[str] = Field(default_factory=list)
    synset_name: str = ""


class SimilarPair(BaseModel):
    """A pair of semantically similar glosses."""

    gloss1: str
    gloss2: str
    similarity: float
    relation_type: str


class SemanticSimilarityRequest(BaseModel):
    """Request for semantic similarity analysis."""

    top_n: int = Field(default=200, ge=1, le=1000)
    min_count: int = Field(default=5, ge=1)


class SemanticSimilarityResponse(BaseModel):
    """Response for semantic similarity analysis."""

    clusters: list[SemanticCluster] = Field(default_factory=list)
    pairs: list[SimilarPair] = Field(default_factory=list)
    wordnet_matched: int = 0
    unmatched: int = 0


# ============================================================================
# Cross-Tier Co-occurrence Models
# ============================================================================


class CrossTierCooccurrenceRequest(BaseModel):
    """Request for cross-tier temporal co-occurrence matrix."""

    row_tier_ids: list[str] = Field(..., min_length=1)
    col_tier_ids: list[str] = Field(..., min_length=1)
    top_n_rows: int = Field(default=30, ge=2, le=200)
    top_n_cols: int = Field(default=30, ge=2, le=200)
    min_overlap_ms: int = Field(default=0, ge=0)


class CrossTierCooccurrenceResponse(BaseModel):
    """Asymmetric co-occurrence matrix based on temporal overlap."""

    row_labels: list[str] = Field(default_factory=list)
    col_labels: list[str] = Field(default_factory=list)
    matrix: list[list[int]] = Field(default_factory=list)
    row_frequencies: dict[str, int] = Field(default_factory=dict)
    col_frequencies: dict[str, int] = Field(default_factory=dict)
    total_overlaps: int = 0
    row_tier_ids: list[str] = Field(default_factory=list)
    col_tier_ids: list[str] = Field(default_factory=list)


# ============================================================================
# NM Annotator Models
# ============================================================================


class FaceBboxResponse(BaseModel):
    """Normalized face bounding box (0-1 range)."""

    x: float = Field(ge=0.0, le=1.0)
    y: float = Field(ge=0.0, le=1.0)
    width: float = Field(ge=0.0, le=1.0)
    height: float = Field(ge=0.0, le=1.0)
    confidence: float = Field(default=0.0, ge=0.0, le=1.0)


class NMLabelVocabEntry(BaseModel):
    """A single NM label with its frequency count."""

    label: str
    count: int = Field(ge=0)


class NMLabelVocabResponse(BaseModel):
    """NM label vocabulary grouped by tier."""

    tiers: dict[str, list[NMLabelVocabEntry]] = Field(default_factory=dict)


class NMAnnotatorSaveRequest(BaseModel):
    """Request to save NM annotations."""

    session_id: str
    save_mode: Literal["overwrite", "save_as"] = "overwrite"
    save_as_path: str | None = None

    @model_validator(mode="after")
    def _validate_save_as(self) -> NMAnnotatorSaveRequest:
        if self.save_mode == "save_as" and not self.save_as_path:
            raise ValueError("save_as_path is required when save_mode is 'save_as'")
        return self


class NMAnnotatorSaveResponse(BaseModel):
    """Response after saving NM annotations."""

    status: str
    saved_path: str
    segments_saved: int = 0
    re_ingested: bool = False
