"""Pydantic models for Tale RAG API."""

import datetime as dt
from typing import Annotated, Any, Literal

from pydantic import BaseModel, BeforeValidator, Field, field_validator

from .utils.document_metadata import MAX_METADATA_KEYS, validate_metadata_object
from .utils.folder_path import MAX_FOLDER_PATH_LENGTH, normalize_folder_path


def _normalize_folder_path_value(value: object) -> object:
    """Pre-validator: normalize string folder paths, pass everything else
    through so pydantic's own type/constraint validation reports it."""
    if isinstance(value, str):
        return normalize_folder_path(value)
    return value


NormalizedFolderPath = Annotated[str | None, BeforeValidator(_normalize_folder_path_value)]

MetadataScalar = str | int | float | bool


# ============================================================================
# Health & Status Models
# ============================================================================


class HealthResponse(BaseModel):
    """Health check response."""

    status: str = Field(..., description="Service health status")
    version: str = Field(..., description="Service version")
    initialized: bool = Field(..., description="Whether the RAG service is initialized")


class ConfigResponse(BaseModel):
    """Configuration response (non-sensitive values only).

    LLM-specific fields (model name, embedding model) require an
    `org_slug` to resolve in the multi-org world and are intentionally
    omitted from this endpoint; query the per-org config separately.
    """

    host: str
    port: int
    log_level: str
    chunk_size: int
    chunk_overlap: int
    top_k: int
    similarity_threshold: float


# ============================================================================
# Document Management Models
# ============================================================================


class DocumentAddResponse(BaseModel):
    """Response after adding a document.

    The "success" field indicates whether the request was accepted and, for
    synchronous flows, whether ingestion completed without error. For
    asynchronous ingestion, "success" refers to the enqueue operation and
    "queued" will be set to True.
    """

    success: bool = Field(..., description="Whether the operation (or enqueue) succeeded")
    file_id: str = Field(..., description="ID of the added document")
    chunks_created: int = Field(..., description="Number of chunks created (0 when queued)")
    message: str = Field(..., description="Status message")
    queued: bool = Field(
        default=False,
        description="Whether ingestion was queued for background processing",
    )
    skipped: bool = Field(
        default=False,
        description="Whether ingestion was skipped due to content being unchanged",
    )
    skip_reason: str | None = Field(
        default=None,
        description="Reason for skipping ingestion (e.g., 'content_unchanged')",
    )


class ChunkRange(BaseModel):
    """Range of chunks returned in a content response."""

    start: int = Field(..., description="First chunk returned (1-indexed)")
    end: int = Field(..., description="Last chunk returned (1-indexed, inclusive)")


class DocumentChunk(BaseModel):
    """A single document chunk."""

    index: int = Field(..., description="Chunk index (1-indexed)")
    content: str = Field(..., description="Chunk text content")


class DocumentContentResponse(BaseModel):
    """Response containing reassembled document content."""

    file_id: str = Field(..., description="File identifier")
    title: str | None = Field(default=None, description="Original filename")
    content: str = Field(..., description="Reassembled text content")
    chunk_range: ChunkRange = Field(..., description="Range of chunks returned (1-indexed, inclusive)")
    total_chunks: int = Field(..., description="Total number of chunks in the document")
    total_chars: int = Field(..., description="Total character count of returned content")
    source_created_at: dt.datetime | None = Field(
        default=None, description="Original file creation date (from file metadata)"
    )
    source_modified_at: dt.datetime | None = Field(
        default=None, description="Original file modification date (from file metadata)"
    )
    chunks: list[DocumentChunk] | None = Field(
        default=None,
        description="Individual chunks (only when return_chunks=true)",
    )


class DocumentDeleteRequest(BaseModel):
    """Request to delete a document by ID."""

    file_id: str = Field(..., description="ID of the file to delete")


class DocumentDeleteResponse(BaseModel):
    """Response after deleting a document."""

    success: bool = Field(..., description="Whether the operation succeeded")
    message: str = Field(..., description="Status message")
    deleted_count: int = Field(
        default=0,
        description="Number of documents deleted",
    )
    deleted_data_ids: list[str] = Field(
        default_factory=list,
        description="List of document IDs that were deleted",
    )
    processing_time_ms: float | None = Field(
        default=None,
        description="Processing time in milliseconds",
    )


# ============================================================================
# Document Status Models
# ============================================================================


class DocumentStatusInfo(BaseModel):
    """Status info for a single document."""

    status: str = Field(..., description="Document status: processing, completed, or failed")
    error: str | None = Field(default=None, description="Error message when status is failed")
    progress_phase: str | None = Field(
        default=None, description="Current processing phase: extracting, embedding, storing"
    )
    progress_detail: str | None = Field(default=None, description="Phase detail, e.g. '12/50' for page progress")
    source_created_at: dt.datetime | None = Field(
        default=None, description="Original file creation date (from file metadata)"
    )
    source_modified_at: dt.datetime | None = Field(
        default=None, description="Original file modification date (from file metadata)"
    )
    ocr_applied: bool | None = Field(default=None, description="Whether OCR was applied during text extraction")


class DocumentStatusRequest(BaseModel):
    """Request to check statuses of multiple documents."""

    file_ids: list[str] = Field(
        ...,
        min_length=1,
        max_length=200,
        description="List of file IDs to check (max 200)",
    )


class DocumentStatusResponse(BaseModel):
    """Response with document statuses."""

    statuses: dict[str, DocumentStatusInfo | None] = Field(
        ...,
        description="Map of file_id to status info (null if not found)",
    )


# ============================================================================
# Folder Path Models
# ============================================================================


class FolderPathUpdate(BaseModel):
    """A single folder-path assignment for an indexed document."""

    file_id: str = Field(..., min_length=1, description="File ID whose folder_path to update")
    folder_path: NormalizedFolderPath = Field(
        default=None,
        max_length=MAX_FOLDER_PATH_LENGTH,
        description="New folder path, or null to clear it (document moved to the root)",
    )


class FolderPathUpdateRequest(BaseModel):
    """Batch folder-path update for already-indexed documents.

    Lets the platform keep the denormalized `documents.folder_path` fresh
    on folder moves/renames without re-extraction or re-embedding.
    """

    updates: list[FolderPathUpdate] = Field(
        ...,
        min_length=1,
        max_length=200,
        description="Folder-path assignments (max 200 per call)",
    )


class FolderPathUpdateResponse(BaseModel):
    """Response after a batch folder-path update."""

    success: bool = Field(..., description="Whether the operation succeeded")
    updated_count: int = Field(..., description="Number of document rows updated")


# ============================================================================
# Document Metadata Models
# ============================================================================


class DocumentMetadataUpdate(BaseModel):
    """A single metadata assignment for an indexed document.

    REPLACES the stored metadata object (no merge) so the platform's
    canonical record always wins.
    """

    file_id: str = Field(..., min_length=1, description="File ID whose metadata to update")
    metadata: dict[str, MetadataScalar] = Field(
        ...,
        description=(
            f"Flat scalar metadata map (max {MAX_METADATA_KEYS} keys). "
            "Replaces the stored object; pass {} to clear it."
        ),
    )

    @field_validator("metadata")
    @classmethod
    def _validate_metadata(cls, value: dict[str, Any]) -> dict[str, Any]:
        return validate_metadata_object(value, allow_lists=False)


class DocumentMetadataUpdateRequest(BaseModel):
    """Batch metadata update for already-indexed documents (#1517).

    Lets the platform stamp filterable metadata onto documents indexed
    before the metadata column existed — no re-extraction/re-embedding.
    """

    updates: list[DocumentMetadataUpdate] = Field(
        ...,
        min_length=1,
        max_length=200,
        description="Metadata assignments (max 200 per call)",
    )


class DocumentMetadataUpdateResponse(BaseModel):
    """Response after a batch metadata update."""

    success: bool = Field(..., description="Whether the operation succeeded")
    updated_count: int = Field(..., description="Number of document rows updated")


# ============================================================================
# Query Models
# ============================================================================


class SearchFilters(BaseModel):
    """Structured pre-retrieval filters for /search (#1517).

    Applied inside the org-scoped documents subquery so non-matching
    documents never enter ranking. All filters are narrowing-only within
    the authorized `file_ids` scope — never an authorization boundary.
    """

    folder_path: NormalizedFolderPath = Field(
        default=None,
        max_length=MAX_FOLDER_PATH_LENGTH,
        description=(
            "Folder path prefix filter (hierarchical, boundary-safe). "
            "Equivalent to the top-level `folder_path`; this form wins "
            "when both are set."
        ),
    )
    metadata: dict[str, MetadataScalar | list[MetadataScalar]] | None = Field(
        default=None,
        description=(
            f"Flat metadata filters (max {MAX_METADATA_KEYS} keys). A scalar "
            "value is an equality test; a list is an IN-test "
            '(e.g. {"department": "legal", "year": [2023, 2024]}). '
            "Operators beyond equality/IN (ranges, negation) are not supported."
        ),
    )

    @field_validator("metadata")
    @classmethod
    def _validate_metadata(cls, value: dict[str, Any] | None) -> dict[str, Any] | None:
        if value is None:
            return None
        return validate_metadata_object(value, allow_lists=True)


class QueryRequest(BaseModel):
    """Request to query the knowledge base."""

    query: str = Field(..., min_length=1, max_length=10_000, description="Query text")
    top_k: int | None = Field(
        default=None, ge=1, le=1000, description="Number of results to return (overrides default)"
    )
    similarity_threshold: float = Field(
        default=0.3, ge=0.0, le=1.0, description="Minimum cosine similarity for vector results (pre-RRF filtering)"
    )
    include_metadata: bool = Field(default=True, description="Whether to include metadata in results")
    file_ids: list[str] = Field(
        ...,
        min_length=1,
        max_length=500,
        description="File IDs to restrict search to.",
    )
    folder_path: NormalizedFolderPath = Field(
        default=None,
        max_length=MAX_FOLDER_PATH_LENGTH,
        description=(
            "Optional folder path prefix filter (e.g. 'contracts/2024'). "
            "Hierarchical: matches documents in the folder and all of its "
            "subfolders, never sibling folders sharing the prefix. Narrows "
            "within the file_ids scope; it is not an authorization boundary."
        ),
    )
    filters: SearchFilters | None = Field(
        default=None,
        description=(
            "Structured pre-retrieval filters (folder_path + metadata). "
            "Supersedes the flat `folder_path` field, which remains "
            "supported for backward compatibility."
        ),
    )

    @property
    def effective_folder_path(self) -> str | None:
        """`filters.folder_path` wins over the legacy flat field.

        Precedence is presence-based, not truthiness-based: when the caller
        explicitly provides `filters.folder_path` it supersedes the legacy
        field even if it normalizes to ``None`` (an explicit "no folder
        prefix" override), per the documented "supersedes" contract.
        """
        if self.filters is not None and "folder_path" in self.filters.model_fields_set:
            return self.filters.folder_path
        return self.folder_path

    @property
    def metadata_filters(self) -> dict[str, Any] | None:
        if self.filters and self.filters.metadata:
            return self.filters.metadata
        return None


class SearchResult(BaseModel):
    """A single search result."""

    content: str = Field(..., description="Content of the result")
    score: float = Field(..., description="Similarity score")
    file_id: str | None = Field(default=None, description="Source file ID")
    filename: str | None = Field(default=None, description="Source document filename")
    source_created_at: dt.datetime | None = Field(
        default=None, description="Original file creation date (from file metadata)"
    )
    source_modified_at: dt.datetime | None = Field(
        default=None, description="Original file modification date (from file metadata)"
    )
    metadata: dict[str, Any] | None = Field(default=None, description="Result metadata")
    reranking_score: float | None = Field(default=None, description="Cross-encoder re-ranking score")
    cached: bool = Field(default=False, description="Whether this result was served from cache")


class UsageInfo(BaseModel):
    """Token usage metadata for AI API calls."""

    input_tokens: int = Field(default=0, description="Input/prompt tokens consumed")
    output_tokens: int = Field(default=0, description="Output/completion tokens consumed")
    total_tokens: int = Field(default=0, description="Total tokens consumed")
    model: str | None = Field(default=None, description="Model used for this operation")


class QueryResponse(BaseModel):
    """Response to a query."""

    success: bool = Field(..., description="Whether the query succeeded")
    query: str = Field(..., description="Original query")
    results: list[SearchResult] = Field(..., description="Search results")
    total_results: int = Field(..., description="Total number of results")
    processing_time_ms: float = Field(..., description="Query processing time in milliseconds")
    cached: bool = Field(default=False, description="Whether results were served from semantic cache")
    usage: UsageInfo | None = Field(default=None, description="Embedding token usage for this query")


# ============================================================================
# RAG Generation Models
# ============================================================================


class GenerateRequest(BaseModel):
    """Request to generate a response using RAG.

    This is a simplified API that uses optimized defaults for RAG:
    - top_k: 30 (~15k chars context for comprehensive answers)
    - temperature: 0.3 (low randomness for factual responses)
    - max_tokens: 2000 (sufficient for detailed answers)

    These parameters are hardcoded for consistency and simplicity.
    """

    query: str = Field(..., min_length=1, max_length=10_000, description="User query")
    file_ids: list[str] = Field(
        ...,
        min_length=1,
        max_length=500,
        description="File IDs to retrieve context from.",
    )


class GenerateResponse(BaseModel):
    """Response from RAG generation."""

    success: bool = Field(..., description="Whether generation succeeded")
    query: str = Field(..., description="Original query")
    response: str = Field(..., description="Generated response")
    sources: list[SearchResult] = Field(..., description="Source documents used")
    processing_time_ms: float = Field(..., description="Total processing time in milliseconds")
    usage: UsageInfo | None = Field(default=None, description="Combined embedding + LLM token usage")


# ============================================================================
# Document Comparison Models
# ============================================================================


class DocumentCompareRequest(BaseModel):
    """Request to compare two documents."""

    base_file_id: str = Field(..., description="File ID of the base document")
    comparison_file_id: str = Field(..., description="File ID of the comparison document")
    max_changes: int = Field(default=500, ge=1, le=2000, description="Maximum number of change items to return")


class ComparisonDiffItem(BaseModel):
    """A single diff item within a change block."""

    type: Literal["added", "deleted", "modified", "context"] = Field(..., description="Type of change")
    base_content: str | None = Field(default=None, description="Content from base document")
    comparison_content: str | None = Field(default=None, description="Content from comparison document")
    content: str | None = Field(default=None, description="Context content (for type='context' only)")
    inline_diff: str | None = Field(
        default=None, description="Word-level inline diff with [-deleted-] and {+added+} markers"
    )
    clause_ref: str | None = Field(default=None, description="Extracted clause/section reference")
    base_page: int | None = Field(default=None, description="Page number in the base document")
    comparison_page: int | None = Field(default=None, description="Page number in the comparison document")


class ComparisonChangeBlock(BaseModel):
    """A group of adjacent changes with surrounding context."""

    context_before: str | None = Field(default=None, description="Truncated paragraph before the change block")
    items: list[ComparisonDiffItem] = Field(..., description="Diff items in this block")
    context_after: str | None = Field(default=None, description="Truncated paragraph after the change block")


class ComparisonDiffStats(BaseModel):
    """Statistics about the comparison."""

    total_paragraphs_base: int = Field(..., description="Total paragraphs in base document")
    total_paragraphs_comparison: int = Field(..., description="Total paragraphs in comparison document")
    unchanged: int = Field(default=0, description="Number of unchanged paragraphs")
    modified: int = Field(default=0, description="Number of modified paragraphs")
    added: int = Field(default=0, description="Number of added paragraphs")
    deleted: int = Field(default=0, description="Number of deleted paragraphs")
    high_divergence: bool = Field(
        default=False,
        description="True if >70% of paragraphs differ (documents may be structurally incomparable)",
    )


class ComparisonDocumentInfo(BaseModel):
    """Minimal document info in comparison response."""

    file_id: str | None = Field(default=None, description="File identifier")
    title: str | None = Field(default=None, description="Document filename")


class DocumentCompareResponse(BaseModel):
    """Response from document comparison."""

    success: bool = Field(..., description="Whether the comparison succeeded")
    base_document: ComparisonDocumentInfo = Field(..., description="Base document info")
    comparison_document: ComparisonDocumentInfo = Field(..., description="Comparison document info")
    change_blocks: list[ComparisonChangeBlock] = Field(..., description="Grouped change blocks")
    stats: ComparisonDiffStats = Field(..., description="Comparison statistics")
    truncated: bool = Field(default=False, description="Whether changes were truncated due to max_changes limit")


# ============================================================================
# Error Models
# ============================================================================


class ErrorResponse(BaseModel):
    """Error response."""

    error: str = Field(..., description="Error type")
    message: str = Field(..., description="Error message")
    details: dict[str, Any] | None = Field(default=None, description="Additional error details")
