"""Project domain model for CleverAgents.

Contains both the legacy Project model (for backward compatibility with
existing services) and the spec-aligned models:
- LinkedResource (project-resource link record)
- ContextConfig (project-level context configuration)
- NamespacedProject (spec-aligned project identified solely by namespaced name)

Spec references:
- Project Data Model (lines 6477-6511)
- Namespaces (lines 6524-6584)
- project_resources DDL (lines 26726-26733)
- Context configuration CLI flags (lines 94-114)
- ADR-004: Pydantic v2 Validation
"""

from __future__ import annotations

import re
from datetime import UTC, datetime
from enum import StrEnum

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

# ---------------------------------------------------------------------------
# Namespace parsing utilities
# ---------------------------------------------------------------------------

# Full namespaced name pattern: [[server:]namespace/]name
# Examples: "local/my-project", "dev:freemo/api-service",
# "my-project" (defaults to local/)
_SERVER_NS_NAME_RE = re.compile(
    r"^(?:(?P<server>[a-zA-Z][a-zA-Z0-9_-]*):)?"
    r"(?P<namespace>[a-zA-Z][a-zA-Z0-9_-]*)/"
    r"(?P<name>[a-zA-Z][a-zA-Z0-9_-]*)$"
)

# Name-only pattern (no namespace, no server)
_BARE_NAME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]*$")

RESERVED_NAMESPACES = frozenset(
    {
        "system",
        "internal",
        "admin",
        "root",
    }
)

# Built-in provider namespaces (reserved for LLM actors, cannot be used for projects)
PROVIDER_NAMESPACES = frozenset(
    {
        "openai",
        "anthropic",
        "google",
        "gemini",
        "deepseek",
        "mistral",
        "perplexity",
        "qwen",
        "amazon",
    }
)

DEFAULT_NAMESPACE = "local"


class ParsedName(BaseModel):
    """Result of parsing a [[server:]namespace/]name string.

    Spec reference: Namespace format (line 47 glossary, lines 6524-6584).
    """

    server: str | None = Field(
        default=None, description="Server prefix (e.g., 'dev', 'prod')"
    )
    namespace: str = Field(
        default=DEFAULT_NAMESPACE,
        description="Namespace (e.g., 'local', 'freemo', 'cleverthis')",
    )
    name: str = Field(..., min_length=1, description="Entity name")

    model_config = ConfigDict(frozen=True)

    @property
    def qualified_name(self) -> str:
        """Return the fully qualified name: [[server:]namespace/]name."""
        parts: list[str] = []
        if self.server is not None:
            parts.append(f"{self.server}:")
        parts.append(f"{self.namespace}/{self.name}")
        return "".join(parts)

    @property
    def namespaced_name(self) -> str:
        """Return namespace/name (without server prefix)."""
        return f"{self.namespace}/{self.name}"

    @property
    def is_local(self) -> bool:
        """Check if this is a local-only entity."""
        return self.namespace == DEFAULT_NAMESPACE and self.server is None

    @property
    def is_remote(self) -> bool:
        """Check if this entity lives on a server.

        True when namespace is non-local or server-qualified.
        """
        return self.server is not None or self.namespace != DEFAULT_NAMESPACE


def parse_namespaced_name(value: str) -> ParsedName:
    """Parse a [[server:]namespace/]name string.

    Spec reference: Namespace format (line 47, lines 6524-6584).

    Args:
        value: The name string to parse.

    Returns:
        ParsedName with server, namespace, and name components.

    Raises:
        ValueError: If the string is empty, has invalid characters,
            or uses a reserved/provider namespace.
    """
    if not value or not value.strip():
        raise ValueError("Name cannot be empty")

    value = value.strip()

    # Try full pattern: [[server:]namespace/]name
    match = _SERVER_NS_NAME_RE.match(value)
    if match:
        server = match.group("server")
        namespace = match.group("namespace")
        name = match.group("name")

        if namespace in RESERVED_NAMESPACES:
            raise ValueError(
                f"Namespace '{namespace}' is reserved. "
                f"Reserved: {', '.join(sorted(RESERVED_NAMESPACES))}"
            )
        if namespace in PROVIDER_NAMESPACES:
            raise ValueError(
                f"Namespace '{namespace}' is reserved for built-in LLM actors "
                f"and cannot be used for projects"
            )

        return ParsedName(server=server, namespace=namespace, name=name)

    # Try bare name (no namespace): defaults to local/
    if _BARE_NAME_RE.match(value):
        return ParsedName(server=None, namespace=DEFAULT_NAMESPACE, name=value)

    raise ValueError(
        f"Invalid name format: '{value}'. "
        f"Expected [[server:]namespace/]name where each part starts with "
        f"a letter and contains only letters, digits, hyphens, and underscores."
    )


# ---------------------------------------------------------------------------
# Spec-aligned models
# ---------------------------------------------------------------------------

DEFAULT_IGNORE_PATTERNS: list[str] = [
    ".git/",
    "node_modules/",
    "__pycache__/",
    ".venv/",
    "*.pyc",
    ".DS_Store",
]


class TemporalScope(StrEnum):
    """Temporal scope for context retrieval."""

    CURRENT = "current"
    RECENT = "recent"
    ALL = "all"


class ContextConfig(BaseModel):
    """Project-level context configuration.

    Spec reference: Project context configuration (lines 6503-6511)
    and ``agents project context set`` CLI flags (lines 94-114).
    """

    # Path filtering
    ignore_patterns: list[str] = Field(
        default_factory=lambda: list(DEFAULT_IGNORE_PATTERNS),
        description="File patterns to ignore during indexing (.gitignore semantics)",
    )
    include_patterns: list[str] = Field(
        default_factory=list,
        description="File patterns to include (empty means include all)",
    )

    # Size limits
    max_file_size: int = Field(
        default=1_000_000,
        gt=0,
        description="Maximum file size in bytes",
    )
    max_total_size: int = Field(
        default=52_428_800,
        gt=0,
        description="Maximum total context size in bytes (50MB default)",
    )

    # Indexing and chunking
    indexing_strategy: str = Field(
        default="full_text",
        description="Indexing strategy (e.g., 'full_text', 'semantic')",
    )
    chunking_policy: str = Field(
        default="smart",
        description="Chunking policy (e.g., 'smart', 'fixed')",
    )
    chunk_size: int = Field(
        default=1000,
        gt=0,
        description="Chunk size in tokens",
    )

    # Memory tier configuration
    hot_max_tokens: int | None = Field(
        default=None, description="Max tokens for hot tier context"
    )
    warm_max_decisions: int | None = Field(
        default=None, description="Max decisions for warm tier"
    )
    cold_max_decisions: int | None = Field(
        default=None, description="Max decisions for cold tier"
    )

    # Summarization
    summarize: bool = Field(default=True, description="Whether to enable summarization")
    summary_max_tokens: int | None = Field(
        default=None, description="Max tokens for summaries"
    )

    # Temporal and refresh
    temporal_scope: TemporalScope = Field(
        default=TemporalScope.CURRENT,
        description="Temporal scope for context retrieval",
    )
    auto_refresh: bool = Field(
        default=True, description="Auto-refresh context on changes"
    )

    # Retention policy (spec: "context retention policy")
    retention_policy: str | None = Field(
        default=None,
        description="Context retention policy",
    )

    # Execution environment default for this project
    execution_environment: str | None = Field(
        default=None,
        description=(
            "Default execution environment for tools in this project "
            "(host or container)"
        ),
    )
    # Execution environment priority (spec: precedence level 2/5)
    execution_env_priority: str | None = Field(
        default=None,
        description=(
            "Priority semantics for the project-level execution "
            "environment: 'override' always uses the specified "
            "environment; 'fallback' (default) defers to "
            "auto-detected devcontainers."
        ),
    )

    model_config = ConfigDict(
        frozen=True,
        str_strip_whitespace=True,
        use_enum_values=True,
    )

    @field_validator("ignore_patterns")
    @classmethod
    def merge_default_ignore_patterns(
        cls: type[ContextConfig], v: list[str]
    ) -> list[str]:
        """Ensure default ignore patterns are always present."""
        merged = list(DEFAULT_IGNORE_PATTERNS)
        for pattern in v:
            if pattern not in merged:
                merged.append(pattern)
        return merged


class LinkedResource(BaseModel):
    """A project-resource link record.

    Spec reference: Project Data Model, section 2 "Linked Resources"
    (lines 6488-6502) and project_resources DDL (lines 26726-26733).

    Projects link to resources from the Resource Registry rather than
    embedding them. A resource can be linked to multiple projects.
    """

    resource_id: str = Field(
        ...,
        description="ULID reference to a Resource Registry entry",
        pattern=r"^[0-9A-HJKMNP-TV-Z]{26}$",
    )
    project_read_only: bool = Field(
        default=False,
        description="Project-level read-only override",
    )
    alias: str | None = Field(
        default=None,
        description="Optional short name for referencing within the project",
    )
    linked_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
        description="When this resource was linked to the project",
    )

    model_config = ConfigDict(frozen=True)

    @field_validator("alias")
    @classmethod
    def validate_alias(cls: type[LinkedResource], v: str | None) -> str | None:
        """Validate alias is non-empty if provided."""
        if v is not None and not v.strip():
            raise ValueError("Alias cannot be empty (use None instead)")
        return v


class NamespacedProject(BaseModel):
    """Spec-aligned project model.

    Spec reference: Project Data Model (lines 6477-6511).

    A project is identified SOLELY by its namespaced name -- no ULID
    or integer ID is generated. The namespacing scheme
    ([[server:]namespace/]name) ensures global uniqueness.
    """

    # Identity -- namespaced name IS the identifier (no ULID)
    name: str = Field(
        ...,
        min_length=1,
        max_length=255,
        description="Project name (without namespace prefix)",
    )
    namespace: str = Field(
        default=DEFAULT_NAMESPACE,
        description="Project namespace (e.g., 'local', 'freemo', 'cleverthis')",
    )
    server: str | None = Field(
        default=None,
        description="Server prefix for multi-server disambiguation",
    )

    # Description
    description: str | None = Field(
        default=None, description="Human-readable project description"
    )

    # Linked resources (spec section 2)
    linked_resources: list[LinkedResource] = Field(
        default_factory=list,
        description="Resources linked to this project from the Resource Registry",
    )

    # Context configuration (spec section 3)
    context_config: ContextConfig = Field(
        default_factory=ContextConfig,
        description="Project-level context configuration",
    )

    # Timestamps
    created_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
    )
    updated_at: datetime = Field(
        default_factory=lambda: datetime.now(tz=UTC),
    )

    model_config = ConfigDict(
        str_strip_whitespace=True,
        validate_assignment=True,
    )

    # -- Validators ----------------------------------------------------------

    @field_validator("name")
    @classmethod
    def validate_name(cls: type[NamespacedProject], v: str) -> str:
        """Validate project name format.

        Must start with a letter, alphanumeric + hyphens + underscores.
        """
        if not _BARE_NAME_RE.match(v):
            raise ValueError(
                f"Project name '{v}' is invalid. Must start with a letter "
                f"and contain only letters, digits, hyphens, and underscores."
            )
        return v

    @field_validator("namespace")
    @classmethod
    def validate_namespace(cls: type[NamespacedProject], v: str) -> str:
        """Validate namespace: must be a valid namespace, not reserved."""
        if not _BARE_NAME_RE.match(v):
            raise ValueError(
                f"Namespace '{v}' is invalid. Must start with a letter "
                f"and contain only letters, digits, hyphens, and underscores."
            )
        if v in RESERVED_NAMESPACES:
            raise ValueError(
                f"Namespace '{v}' is reserved. "
                f"Reserved: {', '.join(sorted(RESERVED_NAMESPACES))}"
            )
        if v in PROVIDER_NAMESPACES:
            raise ValueError(f"Namespace '{v}' is reserved for built-in LLM actors.")
        return v

    @model_validator(mode="after")
    def validate_linked_resources_unique(self) -> NamespacedProject:
        """Ensure no duplicate resource_ids in linked_resources."""
        ids = [lr.resource_id for lr in self.linked_resources]
        if len(ids) != len(set(ids)):
            raise ValueError("Duplicate resource_id in linked_resources")
        return self

    # -- Properties ----------------------------------------------------------

    @property
    def namespaced_name(self) -> str:
        """Return namespace/name."""
        return f"{self.namespace}/{self.name}"

    @property
    def qualified_name(self) -> str:
        """Return [[server:]namespace/]name."""
        if self.server is not None:
            return f"{self.server}:{self.namespace}/{self.name}"
        return self.namespaced_name

    @property
    def is_local(self) -> bool:
        """Check if this is a local-only project."""
        return self.namespace == DEFAULT_NAMESPACE and self.server is None

    @property
    def is_remote(self) -> bool:
        """Check if this project lives on a server."""
        return self.server is not None or self.namespace != DEFAULT_NAMESPACE

    # -- Domain Methods ------------------------------------------------------

    def link_resource(
        self,
        resource_id: str,
        read_only: bool = False,
        alias: str | None = None,
    ) -> NamespacedProject:
        """Return a new project with the resource linked.

        Args:
            resource_id: ULID of the resource to link.
            read_only: Whether this resource is read-only in this project context.
            alias: Optional short name for the resource within the project.

        Raises:
            ValueError: If resource_id is empty or already linked.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id cannot be empty")
        if any(lr.resource_id == resource_id for lr in self.linked_resources):
            raise ValueError(
                f"Resource '{resource_id}' is already linked to this project"
            )

        link = LinkedResource(
            resource_id=resource_id,
            project_read_only=read_only,
            alias=alias,
        )
        new_resources = [*self.linked_resources, link]
        return self.model_copy(
            update={
                "linked_resources": new_resources,
                "updated_at": datetime.now(tz=UTC),
            }
        )

    def unlink_resource(self, resource_id: str) -> NamespacedProject:
        """Return a new project with the resource unlinked.

        Args:
            resource_id: ULID of the resource to unlink.

        Raises:
            ValueError: If resource_id is empty or not linked.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id cannot be empty")
        if not any(lr.resource_id == resource_id for lr in self.linked_resources):
            raise ValueError(f"Resource '{resource_id}' is not linked to this project")

        new_resources = [
            lr for lr in self.linked_resources if lr.resource_id != resource_id
        ]
        return self.model_copy(
            update={
                "linked_resources": new_resources,
                "updated_at": datetime.now(tz=UTC),
            }
        )

    def get_linked_resource(self, resource_id: str) -> LinkedResource | None:
        """Get a linked resource by resource_id.

        Args:
            resource_id: ULID to look up.

        Raises:
            ValueError: If resource_id is empty.
        """
        if not resource_id or not resource_id.strip():
            raise ValueError("resource_id cannot be empty")
        for lr in self.linked_resources:
            if lr.resource_id == resource_id:
                return lr
        return None

    def get_linked_resource_by_alias(self, alias: str) -> LinkedResource | None:
        """Get a linked resource by alias.

        Args:
            alias: Alias to look up.

        Raises:
            ValueError: If alias is empty.
        """
        if not alias or not alias.strip():
            raise ValueError("alias cannot be empty")
        for lr in self.linked_resources:
            if lr.alias == alias:
                return lr
        return None
