"""Repository indexing domain models for CleverAgents.

Supports repository indexing for 10K+ file projects with incremental refresh
and language detection.  Index metadata and per-file records are persisted
to enable fast incremental re-indexing (only changed files are re-processed).

## Models

- :class:`IndexStatus` — indexing lifecycle states
- :class:`FileRecord` — per-file index entry with content hash
- :class:`IndexMetadata` — per-resource index summary
- :class:`RepoIndex` — aggregate: metadata + all file records

Based on:

- ``docs/specification.md`` — ``index.*`` config keys (lines 28649-28664)
- ``docs/specification.md`` — ``project link-resource`` indexing output
  (lines 2829-2916)
- ``docs/specification.md`` — ``project show`` indexing_status
  (lines 3322-3395)
- ``docs/specification.md`` — project data model context configuration
  (lines 19719-19727)
- ``docs/specification.md`` — lazy sandboxing vs indexing
  (line 24687: "resources are indexed immediately when registered")
- Issue #195: feat(context): add repo indexing service
"""

from __future__ import annotations

from datetime import UTC, datetime
from enum import StrEnum

from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from ulid import ULID

# ULID is 26 characters, Crockford's base32 (matches plan.py pattern)
ULID_PATTERN = r"^[0-9A-HJKMNP-TV-Z]{26}$"

__all__ = [
    "ULID_PATTERN",
    "FileRecord",
    "IndexMetadata",
    "IndexStatus",
    "RepoIndex",
]

# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------


class IndexStatus(StrEnum):
    """Lifecycle states for a repository index.

    | State       | Description                                  |
    |-------------|----------------------------------------------|
    | ``pending`` | Index requested but not yet started           |
    | ``indexing``| Indexing in progress                          |
    | ``ready``   | Index complete and up-to-date                 |
    | ``stale``   | Files changed since last index; refresh needed|
    | ``error``   | Indexing failed                                |
    """

    PENDING = "pending"
    INDEXING = "indexing"
    READY = "ready"
    STALE = "stale"  # Planned: set when source files change post-index
    ERROR = "error"


# ---------------------------------------------------------------------------
# FileRecord — per-file index entry
# ---------------------------------------------------------------------------


class FileRecord(BaseModel):
    """A single indexed file within a repository resource.

    Stores the content hash to enable incremental refresh: on re-index,
    only files whose hash differs from the stored value are re-processed.

    Based on specification.md project data model (lines 19719-19727).
    """

    path: str = Field(
        ...,
        min_length=1,
        max_length=1024,
        description="Relative path within the resource root",
    )
    # Lowercase hex only — SHA-256 from hashlib is always lowercase.
    content_hash: str = Field(
        ...,
        min_length=64,
        max_length=64,
        pattern=r"^[0-9a-f]{64}$",
        description="SHA-256 hex digest of file content",
    )
    token_count: int = Field(
        ...,
        ge=0,
        description="Estimated token count for the file",
    )
    language: str = Field(
        default="unknown",
        max_length=50,
        description="Detected programming language (e.g., 'python', 'typescript')",
    )
    size_bytes: int = Field(
        ...,
        ge=0,
        description="File size in bytes",
    )
    last_modified: datetime = Field(
        ...,
        description="Last modification timestamp (UTC)",
    )

    @field_validator("path", mode="after")
    @classmethod
    def _validate_path(cls, v: str) -> str:
        """Reject absolute paths and directory-traversal sequences (C5 fix)."""
        if v.startswith("/") or v.startswith("\\"):
            raise ValueError("path must be relative, not absolute")
        if ".." in v.split("/"):
            raise ValueError("path must not contain '..' traversal segments")
        return v

    @field_validator("last_modified", mode="after")
    @classmethod
    def _ensure_utc(cls, v: datetime) -> datetime:
        """Ensure the timestamp is UTC-aware (convert if needed)."""
        if v.tzinfo is None:
            return v.replace(tzinfo=UTC)
        return v.astimezone(UTC)

    model_config = ConfigDict(frozen=True)


# ---------------------------------------------------------------------------
# IndexMetadata — per-resource index summary
# ---------------------------------------------------------------------------


class IndexMetadata(BaseModel):
    """Summary metadata for an indexed resource.

    Persisted alongside the :class:`FileRecord` entries.  Displayed by
    ``agents project show`` (spec lines 3322-3395) and used to determine
    whether incremental refresh is needed.

    Based on specification.md:
    - ``indexing_status`` output (lines 3322-3395): text_index, vector_index,
      graph_store, indexed_files, last_indexed_at
    - ``project link-resource`` output (lines 2829-2916): status, files_found,
      language, estimated_time
    """

    index_id: str = Field(
        default_factory=lambda: str(ULID()),
        pattern=ULID_PATTERN,
        description="Unique index identifier (ULID)",
    )
    resource_id: str = Field(
        ...,
        pattern=ULID_PATTERN,
        description="ULID of the indexed resource",
    )
    indexed_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="Timestamp when indexing completed (UTC)",
    )
    file_count: int = Field(
        ...,
        ge=0,
        description="Total number of indexed files",
    )
    token_estimate: int = Field(
        ...,
        ge=0,
        description="Total estimated tokens across all indexed files",
    )
    primary_language: str = Field(
        default="unknown",
        max_length=50,
        description=(
            "Primary detected language (e.g., 'python'). "
            "Based on spec line 2834: 'Language: Python (primary)'"
        ),
    )
    status: IndexStatus = Field(
        default=IndexStatus.PENDING,
        description="Current indexing lifecycle status",
    )
    error_message: str | None = Field(
        default=None,
        description="Error details when status is 'error'",
    )
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="Timestamp when the index row was first created (UTC)",
    )

    @field_validator("indexed_at", "created_at", mode="after")
    @classmethod
    def _ensure_utc(cls, v: datetime) -> datetime:
        """Ensure the timestamp is UTC-aware (convert if needed)."""
        if v.tzinfo is None:
            return v.replace(tzinfo=UTC)
        return v.astimezone(UTC)

    @model_validator(mode="after")
    def _check_error_message_consistency(self) -> IndexMetadata:
        """Ensure error_message is only set when status is ERROR (C4 fix)."""
        if self.error_message is not None and self.status != IndexStatus.ERROR:
            raise ValueError("error_message must be None when status is not 'error'")
        return self

    model_config = ConfigDict(frozen=True)


# ---------------------------------------------------------------------------
# RepoIndex — aggregate
# ---------------------------------------------------------------------------


class RepoIndex(BaseModel):
    """Complete index for a single repository resource.

    Aggregates :class:`IndexMetadata` with all :class:`FileRecord` entries.
    The ``files`` field uses a tuple for immutability (per implement.md NFR).

    This is the primary return type of
    :meth:`RepoIndexingService.index_resource` and
    :meth:`RepoIndexingService.refresh_index`.
    """

    metadata: IndexMetadata = Field(
        ...,
        description="Index summary metadata",
    )
    files: tuple[FileRecord, ...] = Field(
        default=(),
        description="All indexed file records (immutable tuple)",
    )

    @model_validator(mode="after")
    def _check_file_count_invariant(self) -> RepoIndex:
        """Ensure len(files) matches metadata.file_count (C6 fix)."""
        if len(self.files) != self.metadata.file_count:
            raise ValueError(
                f"len(files)={len(self.files)} != "
                f"metadata.file_count={self.metadata.file_count}"
            )
        return self

    @model_validator(mode="after")
    def _check_token_estimate_invariant(self) -> RepoIndex:
        """Ensure token_estimate == sum(token_count) across files."""
        actual = sum(f.token_count for f in self.files)
        if actual != self.metadata.token_estimate:
            raise ValueError(
                f"sum(token_count)={actual} != "
                f"metadata.token_estimate={self.metadata.token_estimate}"
            )
        return self

    model_config = ConfigDict(frozen=True)
