"""Programmatic access to lilbee's retrieval pipeline.

Retrieval only -- no LLM chat. Search your indexed documents from Python.
Optional features (concept graph, reranker) activate automatically when
their dependencies are installed.

Usage::

    from lilbee import Lilbee

    bee = Lilbee("./docs")
    bee.sync()
    results = bee.search("authentication")
"""

from __future__ import annotations

import asyncio
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING

# app.ingest stays at module top: it is a thin wrapper over shutil + the
# config singleton (~50ms cumulative beyond core.config). data.ingest is
# deferred at each callsite below because it transitively imports spaCy via
# the wiki package and adds ~3s on first touch.
from lilbee.app.ingest import copy_files
from lilbee.app.services import reset_services
from lilbee.core.config import Config, cfg
from lilbee.data.store import MemoryKind, MemoryRow, Store
from lilbee.providers.factory import create_provider
from lilbee.retrieval.concepts import ConceptGraph
from lilbee.retrieval.embedder import Embedder
from lilbee.retrieval.query import Searcher
from lilbee.retrieval.reranker import Reranker

if TYPE_CHECKING:
    from lilbee.data.ingest import SyncResult
    from lilbee.data.store import SearchChunk
    from lilbee.providers.base import LLMProvider


@contextmanager
def _swap_config(target: Config) -> Iterator[None]:
    """Temporarily replace the global cfg fields with *target*'s values.
    Not thread-safe -- sequential use only.
    """
    snapshot = {name: getattr(cfg, name) for name in type(cfg).model_fields}
    for name in type(target).model_fields:
        setattr(cfg, name, getattr(target, name))
    reset_services()
    try:
        yield
    finally:
        reset_services()
        for name, val in snapshot.items():
            setattr(cfg, name, val)


class Lilbee:
    """Programmatic access to lilbee's retrieval pipeline.

    Usage::

        from lilbee import Lilbee

        bee = Lilbee("./docs")
        bee.sync()
        results = bee.search("authentication")
    """

    def __init__(
        self,
        documents_dir: str | Path | None = None,
        *,
        config: Config | None = None,
        provider: LLMProvider | None = None,
    ) -> None:
        """Create a lilbee instance.
        Args:
            documents_dir: Path to documents folder. Creates a default Config
                with derived data and lancedb directories.
            config: Full Config instance for complete control.
            provider: LLM provider instance. If not given, creates one from config.

        Pass documents_dir or config, not both. If neither is given, uses
        ``Config()`` (same defaults as the CLI).
        """
        if documents_dir is not None and config is not None:
            raise ValueError("Pass documents_dir or config, not both")

        if config is not None:
            self._config = config
        elif documents_dir is not None:
            root = Path(documents_dir).resolve()
            self._config = cfg.model_copy(
                update={
                    "data_root": root,
                    "documents_dir": root / "documents",
                    "data_dir": root / "data",
                    "lancedb_dir": root / "data" / "lancedb",
                },
            )
        else:
            self._config = Config()

        self._config.documents_dir.mkdir(parents=True, exist_ok=True)
        self._config.data_dir.mkdir(parents=True, exist_ok=True)

        self._provider = provider or create_provider(self._config)
        self._store = Store(self._config)
        self._embedder = Embedder(self._config, self._provider)
        self._reranker = Reranker(self._config)
        self._concepts = ConceptGraph(self._config, self._store)
        self._searcher = Searcher(
            self._config,
            self._provider,
            self._store,
            self._embedder,
            self._reranker,
            self._concepts,
        )

    @property
    def config(self) -> Config:
        """The Config instance backing this Lilbee."""
        return self._config

    @property
    def store(self) -> Store:
        """The Store component."""
        return self._store

    @property
    def embedder(self) -> Embedder:
        """The Embedder component."""
        return self._embedder

    @property
    def searcher(self) -> Searcher:
        """The Searcher component."""
        return self._searcher

    def sync(self, *, quiet: bool = True) -> SyncResult:
        """Sync documents to the vector store. Returns what changed."""
        # heavy: data.ingest transitively imports spaCy via wiki
        from lilbee.data.ingest import sync as _sync

        with _swap_config(self._config):
            return asyncio.run(_sync(quiet=quiet))

    def search(self, query: str, *, top_k: int = 0) -> list[SearchChunk]:
        """Search indexed documents. Returns ranked chunks."""
        with _swap_config(self._config):
            return self._searcher.search(query, top_k=top_k)

    def add(self, paths: list[str | Path]) -> SyncResult:
        """Add files to the knowledge base and sync.
        Copies each path into the documents directory, then syncs.
        """
        # heavy: data.ingest transitively imports spaCy via wiki
        from lilbee.data.ingest import sync as _sync

        resolved = [Path(p).resolve() for p in paths]
        with _swap_config(self._config):
            copy_files(resolved, force=True)
            return asyncio.run(_sync(quiet=True))

    def remove(self, name: str) -> None:
        """Remove a document from the index by source name."""
        with _swap_config(self._config):
            self._store.remove_documents([name], delete_files=True)

    def status(self) -> dict[str, object]:
        """Return index stats (document count, data directory, etc.)."""
        with _swap_config(self._config):
            sources = self._store.get_sources()
            return {
                "documents_dir": str(self._config.documents_dir),
                "data_dir": str(self._config.data_dir),
                "document_count": len(sources),
                "sources": [s["filename"] for s in sources],
            }

    def rebuild(self) -> SyncResult:
        """Rebuild the entire index from scratch."""
        # heavy: data.ingest transitively imports spaCy via wiki
        from lilbee.data.ingest import sync as _sync

        with _swap_config(self._config):
            return asyncio.run(_sync(force_rebuild=True, quiet=True))

    def remember(
        self,
        text: str,
        *,
        kind: MemoryKind = MemoryKind.FACT,
        shared: bool = False,
    ) -> str:
        """Store a fact or preference in long-term memory; returns its id.

        This library primitive does not consult ``memory_enabled``: that flag
        gates the interactive surfaces (TUI/CLI/MCP/REST) and the chat-prompt
        injection, not direct programmatic access. ``remember`` and ``recall``
        operate as a pair regardless of the flag.
        """
        from lilbee.app.memory import make_memory_row

        with _swap_config(self._config):
            record = make_memory_row(text, self._embedder.embed, kind=kind, shared=shared)
            return self._store.add_memory(record)

    def recall(self, query: str, *, top_k: int | None = None) -> list[MemoryRow]:
        """Recall facts relevant to *query* from long-term memory."""
        from lilbee.data.store import local_owner_predicate

        with _swap_config(self._config):
            return self._store.search_memories(
                self._embedder.embed(query),
                owner_predicate=local_owner_predicate(),
                top_k=self._config.memory_top_k if top_k is None else top_k,
                max_distance=self._config.memory_max_distance,
            )

    def memories(self) -> list[MemoryRow]:
        """List all stored memories, newest first."""
        from lilbee.data.store import local_owner_predicate

        with _swap_config(self._config):
            return self._store.get_memories(owner_predicate=local_owner_predicate())

    def forget(self, memory_id: str) -> None:
        """Delete a memory by id."""
        with _swap_config(self._config):
            self._store.delete_memory(memory_id)
