"""Configuration and dependency wiring.

Reads config TOML (or env vars) and constructs the Pipeline
with the requested backends. This is the only module that knows
about concrete implementations.

Sources (highest → lowest priority):
  1. Init kwargs
  2. Environment variables (prefix ``DOMESDAY_``, nested delimiter ``__``)
  3. Config TOML (or the path passed to ``Config.load``)
  4. Field defaults
"""

from __future__ import annotations

import logging
from pathlib import Path
from typing import Any, TypeVar

import pydantic
from pydantic_settings import (
    BaseSettings,
    PydanticBaseSettingsSource,
    SettingsConfigDict,
    TomlConfigSettingsSource,
)

from domesday import chunking, embedders, generators
from domesday.core import pipeline as core_pipeline
from domesday.core import protocols
from domesday.stores import chroma_store, sqlite_store

_T = TypeVar("_T")

logger = logging.getLogger(__name__)

DEFAULT_CONFIG_PATH = Path(".domesday/config.toml")
DEFAULT_DATA_DIR = Path("./domesday/data")

# ---------------------------------------------------------------------------
# Nested section models
# ---------------------------------------------------------------------------


class _SectionBase(pydantic.BaseModel):
    """Base for all config sections. ``extra="allow"`` lets backends accept
    arbitrary parameters (e.g. ``host``, ``api_key``) without schema changes."""

    model_config = pydantic.ConfigDict(extra="allow")


class SnippetStoreConfig(_SectionBase):
    backend: str = "sqlite"


class VectorStoreConfig(_SectionBase):
    backend: str = "chroma"


class EmbedderConfig(_SectionBase):
    backend: str = "voyage"
    model: str | None = None  # None → use the embedder class's own default
    dimension: int | None = None


class GeneratorConfig(_SectionBase):
    backend: str = "claude"
    model: str = "claude-haiku-4-5"


class ChunkerConfig(_SectionBase):
    max_tokens: int = 400
    overlap_tokens: int = 50


class RetrievalConfig(_SectionBase):
    min_score: float = 0.3


class RerankerConfig(_SectionBase):
    enabled: bool = False
    model: str = "claude-haiku-4-5"
    relevance_threshold: float = 0.5


class ProjectConfig(pydantic.BaseModel):
    """Per-project overrides. All fields are optional; ``None`` means
    "inherit from the top-level (global) defaults"."""

    snippet_store: SnippetStoreConfig | None = None
    vector_store: VectorStoreConfig | None = None
    embedder: EmbedderConfig | None = None
    generator: GeneratorConfig | None = None
    chunker: ChunkerConfig | None = None
    retrieval: RetrievalConfig | None = None
    reranker: RerankerConfig | None = None


# ---------------------------------------------------------------------------
# Top-level settings
# ---------------------------------------------------------------------------


class Config(BaseSettings):
    """Parsed configuration.

    Sources (highest → lowest priority): init kwargs → env vars → TOML file → defaults.

    Env vars use prefix ``DOMESDAY_`` and ``__`` as the nested delimiter, e.g.::

        DOMESDAY_EMBEDDER__BACKEND=local
        DOMESDAY_EMBEDDER__MODEL=all-MiniLM-L6-v2
    """

    model_config = SettingsConfigDict(
        env_prefix="DOMESDAY_",
        env_nested_delimiter="__",
    )

    default_project: str = "main"
    snippet_store: SnippetStoreConfig = SnippetStoreConfig()
    vector_store: VectorStoreConfig = VectorStoreConfig()
    embedder: EmbedderConfig = EmbedderConfig()
    generator: GeneratorConfig = GeneratorConfig()
    chunker: ChunkerConfig = ChunkerConfig()
    retrieval: RetrievalConfig = RetrievalConfig()
    reranker: RerankerConfig = RerankerConfig()
    projects: dict[str, ProjectConfig] = {}

    @classmethod
    def settings_customise_sources(
        cls,
        settings_cls: type[BaseSettings],
        init_settings: PydanticBaseSettingsSource,
        env_settings: PydanticBaseSettingsSource,
        dotenv_settings: PydanticBaseSettingsSource,
        file_secret_settings: PydanticBaseSettingsSource,
    ) -> tuple[PydanticBaseSettingsSource, ...]:
        return (init_settings, env_settings)

    @pydantic.field_validator("default_project")
    def _validate_default_project(cls, v: str) -> str:
        """Prevent the special value "all" from being used as the configured default.

        The value "all" is reserved as a sentinel to mean "no project filter"
        for search/list operations and must never be the configured default
        project name.
        """
        if v == "all":
            raise ValueError('default_project cannot use reserved name "all"')
        return v

    _SECTION_NAMES = (
        "snippet_store",
        "vector_store",
        "embedder",
        "generator",
        "chunker",
        "retrieval",
        "reranker",
    )

    def resolve_for_project(self, project: str | None = None) -> Config:
        """Return a Config with per-project overrides merged onto global defaults.

        Only fields explicitly set in the ``[projects.<name>]`` TOML section
        override the globals; unset fields are inherited.  If the project has
        no overrides (or doesn't appear in ``projects``), *self* is returned
        unchanged.
        """
        if project is None:
            project = self.default_project

        project_cfg = self.projects.get(project)
        if project_cfg is None:
            return self

        update: dict[str, object] = {}
        for section_name in self._SECTION_NAMES:
            section_override = getattr(project_cfg, section_name, None)
            if section_override is None:
                continue
            global_section = getattr(self, section_name)
            override_fields = section_override.model_dump(exclude_unset=True)
            update[section_name] = global_section.model_copy(update=override_fields)

        if not update:
            return self

        # Drop projects dict on the resolved copy to avoid recursion.
        update["projects"] = {}
        return self.model_copy(update=update)

    @classmethod
    def load(cls, path: Path | None = None) -> Config:
        """Load config, optionally from a non-default TOML path."""
        import dotenv

        dotenv.load_dotenv()
        toml_path = path or DEFAULT_CONFIG_PATH
        if not toml_path.exists():
            if path is not None:
                logger.warning(
                    "Config file %s not found, falling back to defaults and env vars",
                    path,
                )
            else:
                logger.info(
                    "Tip: run `domes init` to create a config file at %s",
                    DEFAULT_CONFIG_PATH,
                )
            return cls()
        # Inject a TOML source for the resolved path
        toml_source = TomlConfigSettingsSource(cls, toml_file=toml_path)

        class _WithToml(cls):  # type: ignore[valid-type]
            @classmethod
            def settings_customise_sources(
                cls2,
                settings_cls: type[BaseSettings],
                init_settings: PydanticBaseSettingsSource,
                env_settings: PydanticBaseSettingsSource,
                dotenv_settings: PydanticBaseSettingsSource,
                file_secret_settings: PydanticBaseSettingsSource,
            ) -> tuple[PydanticBaseSettingsSource, ...]:
                return (init_settings, env_settings, toml_source)

        return _WithToml()


# -------------------------------------------------------------------
# Factory functions
# -------------------------------------------------------------------


def _get_extra(section: _SectionBase, key: str, expected_type: type[_T]) -> _T | None:
    """Retrieve and validate an extra (non-schema) field from a config section."""
    value = getattr(section, key, None)
    if value is None:
        return None
    if not isinstance(value, expected_type):
        try:
            return expected_type(value)
        except (TypeError, ValueError) as exc:
            raise TypeError(
                f"Config field {type(section).__name__}.{key}: "
                f"expected {expected_type.__name__}, got {type(value).__name__} ({value!r})"
            ) from exc
    return value


def _build_snippet_store(cfg: Config) -> sqlite_store.SQLiteSnippetStore:

    if cfg.snippet_store.backend == "sqlite":
        path = _get_extra(cfg.snippet_store, "path", Path)
        if path is None:
            path = DEFAULT_DATA_DIR / "snippets.db"
            logger.debug("Snippet store: no path configured, using default %s", path)
        logger.debug("Snippet store: SQLite at %s", path)
        return sqlite_store.SQLiteSnippetStore(path=path)

    raise ValueError(f"Unknown snippet_store backend: {cfg.snippet_store.backend}")


def _normalize_vec_store_collection_name(model: str) -> str:
    """Some share the same embedding space, so we normalize to the
    base name for storage collection."""
    if model.startswith("voyage-4"):
        return "voyage-4"
    return model


def _build_vec_store(cfg: Config, *, embedding_model: str) -> protocols.VectorStore:

    if cfg.vector_store.backend == "chroma":
        path = _get_extra(cfg.vector_store, "path", Path)
        if path is None:
            path = DEFAULT_DATA_DIR / "vectors"
            logger.debug("Vector store: no path configured, using default %s", path)
        collection_name = _normalize_vec_store_collection_name(embedding_model)
        logger.debug(
            "Vector store: Chroma at %s (collection_name=%s)",
            path,
            collection_name,
        )
        return chroma_store.ChromaVectorStore(
            path=path,
            collection_name=collection_name,
        )

    raise ValueError(f"Unknown vector_store backend: {cfg.vector_store.backend}")


def _build_embedder(cfg: Config) -> protocols.Embedder:
    kwargs: dict[str, Any] = {}
    if cfg.embedder.model is not None:
        kwargs["model"] = cfg.embedder.model
    if cfg.embedder.dimension is not None:
        kwargs["dimension"] = cfg.embedder.dimension
    match backend := cfg.embedder.backend:
        case "voyage":
            logger.debug("Embedder: Voyage (%s)", kwargs or "defaults")
            return embedders.VoyageEmbedder(**kwargs)
        case "openai":
            logger.debug("Embedder: OpenAI (%s)", kwargs or "defaults")
            return embedders.OpenAIEmbedder(**kwargs)
        case "local":
            logger.debug(
                "Embedder: local sentence-transformers (%s)", kwargs or "defaults"
            )
            return embedders.SentenceTransformerEmbedder(**kwargs)

    raise ValueError(f"Unknown embedder backend: {backend}")


def _build_generator(cfg: Config) -> protocols.Generator:
    if cfg.generator.backend == "claude":
        logger.debug("Generator: Claude (model=%s)", cfg.generator.model)
        return generators.ClaudeGenerator(model=cfg.generator.model)

    raise ValueError(f"Unknown generator backend: {cfg.generator.backend}")


def _build_chunker(cfg: Config) -> protocols.Chunker:
    section = cfg.chunker
    max_tokens = section.max_tokens
    overlap = section.overlap_tokens
    logger.debug("Chunker: max_tokens=%d, overlap=%d", max_tokens, overlap)
    return chunking.SimpleChunker(max_tokens=max_tokens, overlap_tokens=overlap)


def _build_reranker(cfg: Config) -> protocols.Reranker | None:
    if not cfg.reranker.enabled:
        logger.debug("Reranker: disabled")
        return None

    from domesday.eval import llm_judge

    model = str(cfg.reranker.model)
    threshold = float(cfg.reranker.relevance_threshold)
    logger.debug("Reranker: enabled (model=%s, threshold=%.2f)", model, threshold)
    return llm_judge.LLMReranker(model=model, relevance_threshold=threshold)


def build_pipeline(
    config_path: Path | None = None,
    *,
    project: str | None = None,
    cfg: Config | None = None,
) -> core_pipeline.Pipeline:
    """Build a fully wired Pipeline from config.

    This is the main entry point for CLI, MCP server, and web app.
    Stores lazily initialize on first use, so construction is cheap.

    If *project* is given, per-project config overrides are merged on
    top of the global defaults before constructing backends.  Pass a
    pre-loaded *cfg* to avoid reading the TOML file twice.
    """
    if cfg is None:
        cfg = Config.load(config_path)
    resolved_cfg = cfg.resolve_for_project(project)
    logger.info(
        "Building pipeline (default_project=%s)",
        cfg.default_project,
    )

    embedder = _build_embedder(resolved_cfg)
    pipeline = core_pipeline.Pipeline(
        snippet_store=_build_snippet_store(resolved_cfg),
        vec_store=_build_vec_store(resolved_cfg, embedding_model=embedder.model),
        embedder=embedder,
        generator=_build_generator(resolved_cfg),
        chunker=_build_chunker(resolved_cfg),
        default_project=cfg.default_project,
        reranker=_build_reranker(resolved_cfg),
        min_score=resolved_cfg.retrieval.min_score,
    )

    logger.info(
        "Pipeline ready (min_score=%.2f, reranker=%s)",
        resolved_cfg.retrieval.min_score,
        "enabled" if pipeline.reranker else "disabled",
    )
    return pipeline
