"""Configuration schema using Pydantic."""

from __future__ import annotations

from pathlib import Path
from typing import Literal

from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.alias_generators import to_camel
from pydantic_settings import BaseSettings

from tinybot.cron.types import CronSchedule


class Base(BaseModel):
    """Base model that accepts both camelCase and snake_case keys."""

    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

class ChannelsConfig(Base):
    """Configuration for chat channels.

    Built-in and plugin channel configs are stored as extra fields (dicts).
    Each channel parses its own config in __init__.
    Per-channel "streaming": true enables streaming output (requires send_delta impl).
    """

    model_config = ConfigDict(extra="allow")

    send_progress: bool = True  # stream agent's text progress to the channel
    send_tool_hints: bool = True  # stream tool-call hints (e.g. read_file("..."))
    send_max_retries: int = Field(default=3, ge=0, le=10)  # Max delivery attempts (initial send included)

    def is_enabled(self, name: str) -> bool:
        """Check whether a channel is enabled by name."""
        section = getattr(self, name, None)
        if section is None:
            return False
        if isinstance(section, dict):
            return section.get("enabled", False)
        return getattr(section, "enabled", False)


class DreamConfig(Base):
    """Dream memory consolidation configuration."""

    _HOUR_MS = 3_600_000

    interval_h: int = Field(default=2, ge=1)  # Every 2 hours by default
    cron: str | None = Field(default=None, exclude=True)  # Legacy compatibility override
    model_override: str | None = Field(
        default=None,
        validation_alias=AliasChoices("modelOverride", "model", "model_override"),
    )  # Optional Dream-specific model override
    max_batch_size: int = Field(default=20, ge=1)  # Max history entries per run
    max_iterations: int = Field(default=10, ge=1)  # Max tool calls per Phase 2
    extraction_every_n_turns: int = Field(default=6, ge=1)
    extraction_idle_seconds: int = Field(default=300, ge=1)

    def build_schedule(self, timezone: str) -> CronSchedule:
        """Build the runtime schedule, preferring the legacy cron override if present."""
        if self.cron:
            return CronSchedule(kind="cron", expr=self.cron, tz=timezone)
        return CronSchedule(kind="every", every_ms=self.interval_h * self._HOUR_MS)

    def describe_schedule(self) -> str:
        """Return a human-readable summary for logs and startup output."""
        if self.cron:
            return f"cron {self.cron} (legacy)"
        hours = self.interval_h
        return f"every {hours}h"


class EmbeddingConfig(Base):
    """Embedding model configuration for vector store.

    Supports both local sentence-transformers models and API-based embeddings
    (OpenAI, Azure OpenAI, or custom OpenAI-compatible endpoints).
    """

    provider: Literal["local", "openai", "azure", "custom"] = "local"
    model_name: str = "all-MiniLM-L6-v2"  # Local model name or API embedding model
    # API configuration (for openai/azure/custom providers)
    api_key: str = ""  # API key (or use api_key_env_var)
    api_key_env_var: str | None = None  # Environment variable name for API key (e.g. "OPENAI_API_KEY")
    api_base: str | None = None  # API base URL (e.g. "https://api.openai.com/v1" or Azure endpoint)
    api_type: str | None = None  # "azure" for Azure OpenAI
    api_version: str | None = None  # Azure API version (e.g. "2024-02-01")


class RecentContextConfig(Base):
    """Short-term read-only retrieval over Conversation Evidence."""

    enabled: bool = True
    recency_days: int = Field(default=7, ge=1)
    max_records: int = Field(default=3, ge=1, le=10)
    scan_limit: int = Field(default=200, ge=1)


class AgentDefaults(Base):
    """Default agent configuration."""

    workspace: str = "~/.tinybot/workspace"
    model: str = "deepseek-reasoner"
    active_profile: str | None = None
    provider: Literal["auto", "openai", "deepseek", "dashscope"] = (
        "auto"  # Provider name "auto" for auto-detection
    )
    max_tokens: int = 8192
    context_window_tokens: int = 65_536
    context_block_limit: int | None = None
    temperature: float = 0.1
    max_tool_iterations: int = 200
    max_tool_result_chars: int = 16_000
    provider_retry_mode: Literal["standard", "persistent"] = "standard"
    reasoning_effort: str | None = None  # low / medium / high - enables LLM thinking mode
    timezone: str = "UTC"  # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
    enable_vector_store: bool = False  # Feature flag: ChromaDB embedding storage for session summaries
    embedding: EmbeddingConfig = Field(default_factory=EmbeddingConfig)  # Embedding model configuration
    dream: DreamConfig = Field(default_factory=DreamConfig)
    recent_context: RecentContextConfig = Field(default_factory=RecentContextConfig)

    @field_validator("model")
    @classmethod
    def validate_model(cls, v: str) -> str:
        """Validate model is non-empty."""
        if not v or v.strip() == "":
            raise ValueError("model cannot be empty")
        return v.strip()

    @field_validator("provider", mode="before")
    @classmethod
    def normalize_provider(cls, v: str | None) -> str:
        """Map removed legacy providers back to auto routing."""
        if v is None:
            return "auto"
        normalized = str(v).strip().lower().replace("-", "_")
        if normalized in {"auto", "openai", "deepseek", "dashscope"}:
            return normalized
        return "auto"

    @field_validator("timezone")
    @classmethod
    def validate_timezone(cls, v: str) -> str:
        """Validate timezone is a valid IANA timezone."""
        if not v or v.strip() == "":
            raise ValueError("timezone cannot be empty")
        try:
            from zoneinfo import ZoneInfo
            ZoneInfo(v)
        except ImportError:
            # zoneinfo not available - skip validation
            pass
        except Exception:
            # ZoneInfoNotFoundError or other errors - skip validation on Windows
            # since tzdata may not be installed
            pass
        return v.strip()

    @field_validator("temperature")
    @classmethod
    def validate_temperature(cls, v: float) -> float:
        """Validate temperature is in valid range."""
        if v < 0 or v > 2:
            raise ValueError(f"temperature must be between 0 and 2, got {v}")
        return v

    @field_validator("max_tool_iterations")
    @classmethod
    def validate_max_iterations(cls, v: int) -> int:
        """Validate max_tool_iterations is positive."""
        if v < 1:
            raise ValueError(f"max_tool_iterations must be at least 1, got {v}")
        return v

    @field_validator("reasoning_effort")
    @classmethod
    def validate_reasoning_effort(cls, v: str | None) -> str | None:
        """Validate reasoning_effort is valid value."""
        if v is None:
            return v
        valid_values = ["low", "medium", "high"]
        if v.lower() not in valid_values:
            raise ValueError(f"reasoning_effort must be one of {valid_values}, got '{v}'")
        return v.lower()

    @model_validator(mode="after")
    def validate_context_limits(self) -> AgentDefaults:
        """Validate context_block_limit is less than context_window_tokens."""
        if self.context_block_limit is not None:
            if self.context_block_limit > self.context_window_tokens:
                raise ValueError(
                    f"context_block_limit ({self.context_block_limit}) must be less than "
                    f"context_window_tokens ({self.context_window_tokens})"
                )
        return self


class AgentsConfig(Base):
    """Agent configuration."""

    defaults: AgentDefaults = Field(default_factory=AgentDefaults)


class ProviderConfig(Base):
    """LLM provider configuration."""

    api_key: str = ""
    api_base: str | None = None
    enable_search: bool = False  # DashScope/Qwen online search switch via OpenAI-compatible extra_body


class ProviderProfileConfig(ProviderConfig):
    """A named provider endpoint/key/model set.

    Profiles allow the same provider to have multiple API bases or product
    plans, such as DashScope regular compatible mode and DashScope coding.
    """

    provider: Literal["openai", "deepseek", "dashscope"] = "openai"
    models: list[str] = Field(default_factory=list)
    supports_model_discovery: bool = True

    @field_validator("models", mode="before")
    @classmethod
    def normalize_models(cls, v: object) -> list[str]:
        if v is None:
            return []
        if isinstance(v, str):
            return [part.strip() for part in v.replace("\n", ",").split(",") if part.strip()]
        if isinstance(v, list):
            return [str(item).strip() for item in v if str(item).strip()]
        return []


class ProvidersConfig(Base):
    """Configuration for LLM providers."""

    openai: ProviderConfig = Field(default_factory=ProviderConfig)
    deepseek: ProviderConfig = Field(default_factory=ProviderConfig)
    dashscope: ProviderConfig = Field(default_factory=ProviderConfig)
    profiles: dict[str, ProviderProfileConfig] = Field(default_factory=dict)


class HeartbeatConfig(Base):
    """Heartbeat service configuration."""

    enabled: bool = True
    interval_s: int = 30 * 60  # 30 minutes
    keep_recent_messages: int = 8


class ApiConfig(Base):
    """OpenAI-compatible API server configuration."""

    host: str = "127.0.0.1"  # Safer default: local-only bind.
    port: int = 8900
    timeout: float = 120.0  # Per-request timeout in seconds.


class GatewayConfig(Base):
    """Gateway/server configuration."""

    host: str = "0.0.0.0"
    port: int = 18790
    heartbeat: HeartbeatConfig = Field(default_factory=HeartbeatConfig)


class WebSearchConfig(Base):
    """Web search tool configuration."""

    provider: str = "duckduckgo"  # brave, tavily, duckduckgo, searxng, jina
    api_key: str = ""
    base_url: str = ""  # SearXNG base URL
    max_results: int = 5


class WebToolsConfig(Base):
    """Web tools configuration."""

    enable: bool = True
    proxy: str | None = (
        None  # HTTP/SOCKS5 proxy URL, e.g. "http://127.0.0.1:7890" or "socks5://127.0.0.1:1080"
    )
    search: WebSearchConfig = Field(default_factory=WebSearchConfig)


class ExecToolConfig(Base):
    """Shell exec tool configuration."""

    enable: bool = True
    timeout: int = 60
    path_append: str = ""

class MCPServerConfig(Base):
    """MCP server connection configuration (stdio or HTTP)."""

    type: Literal["stdio", "sse", "streamableHttp"] | None = None  # auto-detected if omitted
    command: str = ""  # Stdio: command to run (e.g. "npx")
    args: list[str] = Field(default_factory=list)  # Stdio: command arguments
    env: dict[str, str] = Field(default_factory=dict)  # Stdio: extra env vars
    url: str = ""  # HTTP/SSE: endpoint URL
    headers: dict[str, str] = Field(default_factory=dict)  # HTTP/SSE: custom headers
    tool_timeout: int = 30  # seconds before a tool call is cancelled
    enabled_tools: list[str] = Field(default_factory=lambda: ["*"])  # Only register these tools; accepts raw MCP names or wrapped mcp_<server>_<tool> names; ["*"] = all tools; [] = no tools

    @model_validator(mode="after")
    def validate_connection_config(self) -> MCPServerConfig:
        """Validate that required fields are present based on type."""
        if self.type == "stdio":
            if not self.command:
                raise ValueError("stdio MCP server requires 'command' field")
        elif self.type in ("sse", "streamableHttp"):
            if not self.url:
                raise ValueError(f"{self.type} MCP server requires 'url' field")
        return self

    @field_validator("tool_timeout")
    @classmethod
    def validate_tool_timeout(cls, v: int) -> int:
        """Validate tool_timeout is positive."""
        if v < 1:
            raise ValueError(f"tool_timeout must be at least 1 second, got {v}")
        return v

class ToolsConfig(Base):
    """Tools configuration."""

    web: WebToolsConfig = Field(default_factory=WebToolsConfig)
    exec: ExecToolConfig = Field(default_factory=ExecToolConfig)
    restrict_to_workspace: bool = True  # Restrict tool access to workspace directory by default
    mcp_servers: dict[str, MCPServerConfig] = Field(default_factory=dict)
    ssrf_whitelist: list[str] = Field(default_factory=list)  # CIDR ranges to exempt from SSRF blocking (e.g. ["100.64.0.0/10"] for Tailscale)


class SkillsConfig(Base):
    """Skills configuration."""

    enabled: list[str] = Field(default_factory=lambda: ["*"])


class KnowledgeConfig(Base):
    """RAG knowledge base configuration."""

    enabled: bool = False  # Feature flag for RAG
    auto_retrieve: bool = True  # Auto-inject relevant knowledge on each query
    max_chunks: int = 5  # Max chunks to retrieve per query
    chunk_size: int = 500  # Parent chunk target size; kept for compatibility
    chunk_overlap: int = 0  # Parent chunks do not overlap by default
    child_chunk_size: int = 120  # Child chunk target size used for dense/BM25 matching
    child_chunk_overlap: int = 20  # Small overlap between adjacent child chunks
    retrieval_mode: str = "hybrid"  # "dense", "sparse", "hybrid"
    rrf_k: int = Field(default=60, ge=1)  # RRF constant
    bm25_k: float = Field(default=1.2, ge=0.0)  # BM25 k parameter
    bm25_b: float = Field(default=0.75, ge=0.0, le=1.0)  # BM25 b parameter
    dense_weight: float = Field(default=1.0, ge=0.0)  # Weight for dense retrieval in hybrid
    sparse_weight: float = Field(default=1.0, ge=0.0)  # Weight for sparse retrieval in hybrid
    rerank_enabled: bool = False  # Enable rerank stage after initial retrieval
    rerank_model: str = "qwen3-rerank"  # Rerank model name
    rerank_api_key: str | None = None  # Rerank API key; prefer env var in normal use
    rerank_api_key_env_var: str = "DASHSCOPE_API_KEY"  # Env var for rerank API key
    rerank_api_base: str = "https://dashscope.aliyuncs.com/compatible-api/v1"  # OpenAI-compatible base URL
    rerank_top_n: int = Field(default=0, ge=0)  # 0 means use query top_k
    generate_summary: bool = False  # Generate summary for each chunk during indexing
    semantic_extraction_mode: str = "rule"  # "rule", "llm", or "hybrid"
    semantic_llm_max_tokens: int = Field(default=1200, ge=100)  # Max tokens for semantic extraction
    semantic_llm_timeout: float = Field(default=30.0, ge=1.0)  # Timeout for semantic extraction API calls
    semantic_llm_concurrency: int = Field(default=4, ge=1, le=16)  # Parallel chunk LLM extraction workers
    graphrag_enabled: bool = True  # Build GraphRAG-style communities and reports
    graphrag_max_community_size: int = Field(default=12, ge=2)  # Soft cap when forming local graph communities
    graphrag_local_depth: int = Field(default=1, ge=0, le=3)  # Entity-neighborhood expansion depth for local search
    graphrag_community_top_k: int = Field(default=5, ge=1)  # Number of community reports considered by global/drift search
    graphrag_community_algorithm: str = "greedy"  # greedy fallback; reserved: leiden, louvain
    graphrag_community_level: int = Field(default=0, ge=0, le=3)  # Default community level for GraphRAG exports/query
    graphrag_report_llm_enabled: bool = False  # Generate community reports through the configured LLM when available
    graphrag_report_max_tokens: int = Field(default=1200, ge=100)  # Max tokens for LLM community report generation
    graphrag_entity_summary_enabled: bool = True  # Summarize merged entity and relationship descriptions after indexing


class Config(BaseSettings):
    """Root configuration for tinybot."""

    agents: AgentsConfig = Field(default_factory=AgentsConfig)
    channels: ChannelsConfig = Field(default_factory=ChannelsConfig)
    providers: ProvidersConfig = Field(default_factory=ProvidersConfig)
    api: ApiConfig = Field(default_factory=ApiConfig)
    gateway: GatewayConfig = Field(default_factory=GatewayConfig)
    tools: ToolsConfig = Field(default_factory=ToolsConfig)
    skills: SkillsConfig = Field(default_factory=SkillsConfig)
    knowledge: KnowledgeConfig = Field(default_factory=KnowledgeConfig)

    @property
    def workspace_path(self) -> Path:
        """Get expanded workspace path."""
        return Path(self.agents.defaults.workspace).expanduser()

    def _match_provider(
        self, model: str | None = None
    ) -> tuple[ProviderConfig | None, str | None]:
        """Match provider config and its registry name. Returns (config, spec_name)."""
        from tinybot.providers.registry import PROVIDERS, find_by_name

        active_profile = (self.agents.defaults.active_profile or "").strip()
        if active_profile:
            profile = self.providers.profiles.get(active_profile)
            if profile:
                return profile, profile.provider

        forced = self.agents.defaults.provider
        if forced != "auto":
            spec = find_by_name(forced)
            if spec:
                p = getattr(self.providers, spec.name, None)
                return (p, spec.name) if p else (None, None)
            return None, None

        model_lower = (model or self.agents.defaults.model).lower()
        model_normalized = model_lower.replace("-", "_")
        model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
        normalized_prefix = model_prefix.replace("-", "_")

        def _kw_matches(kw: str) -> bool:
            kw = kw.lower()
            return kw in model_lower or kw.replace("-", "_") in model_normalized

        # Explicit provider prefix wins.
        for spec in PROVIDERS:
            p = getattr(self.providers, spec.name, None)
            if p and model_prefix and normalized_prefix == spec.name:
                if spec.is_oauth or spec.is_local or p.api_key:
                    return p, spec.name

        # Match by keyword (order follows PROVIDERS registry)
        for spec in PROVIDERS:
            p = getattr(self.providers, spec.name, None)
            if p and any(_kw_matches(kw) for kw in spec.keywords):
                if spec.is_oauth or spec.is_local or p.api_key:
                    return p, spec.name

        # Fallback: configured local providers can route models without
        # provider-specific keywords.
        local_fallback: tuple[ProviderConfig, str] | None = None
        for spec in PROVIDERS:
            if not spec.is_local:
                continue
            p = getattr(self.providers, spec.name, None)
            if not (p and p.api_base):
                continue
            if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base:
                return p, spec.name
            if local_fallback is None:
                local_fallback = (p, spec.name)
        if local_fallback:
            return local_fallback

        # Fallback follows registry order.
        for spec in PROVIDERS:
            if spec.is_oauth:
                continue
            p = getattr(self.providers, spec.name, None)
            if p and p.api_key:
                return p, spec.name
        return None, None

    def get_provider(self, model: str | None = None) -> ProviderConfig | None:
        """Get matched provider config (api_key, api_base). Falls back to first available."""
        p, _ = self._match_provider(model)
        return p

    def get_provider_name(self, model: str | None = None) -> str | None:
        """Get the registry name of the matched provider (e.g. "deepseek", "openai")."""
        _, name = self._match_provider(model)
        return name

    def get_api_key(self, model: str | None = None) -> str | None:
        """Get API key for the given model. Falls back to first available key."""
        p = self.get_provider(model)
        return p.api_key if p else None

    def get_api_base(self, model: str | None = None) -> str | None:
        """Get API base URL for the given model. Applies default URLs for gateway/local providers."""
        from tinybot.providers.registry import find_by_name

        p, name = self._match_provider(model)
        if p and p.api_base:
            return p.api_base
        # Only gateways get a default api_base here. Standard providers
        # resolve their base URL from the registry in the provider constructor.
        if name:
            spec = find_by_name(name)
            if spec and (spec.is_gateway or spec.is_local) and spec.default_api_base:
                return spec.default_api_base
        return None

    model_config = ConfigDict(env_prefix="tinybot_", env_nested_delimiter="__")
