"""
deps.py
-------
Dependency injection layer for the HIEROMEM FastAPI service.

This module loads configuration (YAML) and constructs core
components — memory engine, encoder, and persistence backend —
based on the current environment.

It ensures that the API layer remains agnostic to the actual
persistence implementation (SQLite, pgvector, Neo4j, FAISS, InMemoryGraph).

Author: Sai Rishi Kiran Mannava
"""

from __future__ import annotations

import os
from pathlib import Path
from functools import lru_cache
from typing import Any, Dict, Iterable, Optional, Tuple, cast

import yaml  # type: ignore[import-untyped]

from hieromem_core.memory import Memory
from hieromem_core.encoder import Encoder
from hieromem_core.metrics import RollingCounter
from hieromem_core.persistence.sqlite_store import SQLiteStore
from hieromem_core.persistence.pgvector_store import PGVectorStore
from hieromem_core.persistence.neo4j_store import Neo4jStore
from hieromem_core.persistence.in_memory_graph_store import InMemoryGraphStore
from hieromem_core.persistence.faiss_index import FaissIndex
from hieromem_core.persistence.memory_store import MemoryStore
from hieromem_api.settings import get_settings


# --------------------------------------------------------------------------- #
# Configuration Loader
# --------------------------------------------------------------------------- #

DEFAULT_CONFIG_PATH = Path("configs/default.yaml")

_BASE_REQUIRED_KEYS: Tuple[Tuple[str, ...], ...] = (
    ("memory", "Nmax"),
    ("memory", "lambda_decay"),
    ("memory", "tau_retrieval"),
    ("memory", "theta_min"),
    ("memory", "batch_size"),
    ("memory", "embedding_dim"),
    ("stores", "symbolic"),
)

_BACKEND_REQUIRED_KEYS: Dict[str, Tuple[Tuple[str, ...], ...]] = {
    "sqlite": (("stores", "paths", "sqlite_path"),),
    "pgvector": (("pgvector", "dsn"),),
    "in_memory_graph": (),
}


def _missing_keys(cfg: Dict[str, Any],
                  required: Iterable[Tuple[str, ...]]) -> list[str]:
    missing: list[str] = []
    for path in required:
        node: object = cfg
        for part in path:
            if isinstance(node, dict) and part in node:
                node = node[part]
            else:
                missing.append(".".join(path))
                break
    return missing


def load_config(path: Path = DEFAULT_CONFIG_PATH) -> Dict[str, Any]:
    """Load configuration (YAML or TOML) from path and validate required keys."""
    if not path.exists():
        raise FileNotFoundError(f"Configuration file not found: {path}")

    # Detect file extension
    if path.suffix.lower() == ".toml":
        try:
            import tomllib  # Python 3.11+
        except ImportError as exc:
            raise ImportError("tomllib is required to parse TOML config files") from exc
        try:
            cfg = tomllib.loads(path.read_text())
        except Exception as exc:
            raise ValueError(f"Failed to parse TOML configuration {path}: {exc}") from exc
    else:
        try:
            cfg = yaml.safe_load(path.read_text()) or {}
        except yaml.YAMLError as exc:  # pragma: no cover - defensive
            raise ValueError(
                f"Failed to parse configuration {path}: {exc}") from exc

    missing = _missing_keys(cfg, _BASE_REQUIRED_KEYS)
    if missing:
        missing_fmt = ", ".join(sorted(missing))
        raise ValueError(
            "Configuration is missing required settings: "
            f"{missing_fmt}. Please update {path.name}."
        )

    # Validate backend specific requirements as part of config loading so that
    # unit tests (and CI) can catch misconfigurations before the runtime tries
    # to instantiate a store.  We intentionally keep this check environment
    # agnostic — overrides such as HIEROMEM_MEMORY_BACKEND or
    # HIEROMEM_PGVECTOR_DSN are handled later when the memory engine is built.
    stores_cfg = cfg.get("stores") or {}
    backend = str(stores_cfg.get("symbolic", "")).strip().lower()
    if backend:
        backend_required = _BACKEND_REQUIRED_KEYS.get(backend, ())
        missing_backend = _missing_keys(cfg, backend_required)
        if missing_backend:
            missing_fmt = ", ".join(sorted(missing_backend))
            raise ValueError(
                "Configuration for backend "
                f"'{backend}' is missing required settings: {missing_fmt}. "
                f"Please update {path.name}."
            )

    return cfg


# --------------------------------------------------------------------------- #
# Memory Engine Factory
# --------------------------------------------------------------------------- #

def _attach_runtime_metrics(memory: Memory) -> None:
    metrics_attr = getattr(memory, "metrics", {}) or {}
    metrics: Dict[str, RollingCounter] = cast(
        Dict[str, RollingCounter], metrics_attr)
    for key in ("remember", "recall"):
        if key not in metrics:
            metrics[key] = RollingCounter(window_s=300)
    memory.metrics = metrics


@lru_cache(maxsize=1)
def get_memory_engine(config_path: Optional[Path] = None) -> Memory:
    """
    Instantiate and return a configured Memory object
    with its persistence and encoder layers.

    Reads backend type and parameters from the YAML config.
    """
    settings = get_settings()
    cfg = load_config(config_path or Path(settings.config_path))

    # Allow environment variables to override key sections of the config.
    if settings.pgvector_dsn:
        pg_cfg = cfg.setdefault("pgvector", {})
        pg_cfg["dsn"] = settings.pgvector_dsn

    if any((settings.neo4j_uri, settings.neo4j_user, settings.neo4j_password)):
        neo_cfg = cfg.setdefault("neo4j", {})
        if settings.neo4j_uri:
            neo_cfg["uri"] = settings.neo4j_uri
        if settings.neo4j_user:
            neo_cfg["user"] = settings.neo4j_user
        if settings.neo4j_password:
            neo_cfg["password"] = settings.neo4j_password

    if "HIEROMEM_DEBUG" in os.environ:
        cfg["debug"] = bool(settings.debug)

    memory_cfg = cfg["memory"]
    stores_cfg = cfg.setdefault("stores", {})
    governance_cfg = cfg.get("governance", {})

    backend_env = (settings.memory_backend or "").strip().lower()
    backend_from_cfg = str(stores_cfg.get("symbolic", "")).strip().lower()
    default_backend = "sqlite" if settings.debug else "pgvector"
    backend = backend_env or backend_from_cfg or default_backend
    stores_cfg.setdefault("symbolic", backend)

    # Load tenant configurations if present
    tenant_cfgs = cfg.get("tenants", {})
    # We'll pass this dict later to Memory
    backend_required = _BACKEND_REQUIRED_KEYS.get(backend, ())
    missing_backend = _missing_keys(cfg, backend_required)
    if backend == "pgvector" and settings.pgvector_dsn:
        missing_backend = [
            key for key in missing_backend if key != "pgvector.dsn"]
    if missing_backend:
        missing_fmt = ", ".join(sorted(missing_backend))
        raise ValueError(
            "Configuration for backend "
            f"'{backend}' is missing required settings: {missing_fmt}. "
            f"Please update {settings.config_path}."
        )
    debug = bool(cfg.get("debug", False))
    dim = int(memory_cfg.get("embedding_dim", 768))
    Nmax = int(memory_cfg.get("Nmax", 20))
    lam = float(memory_cfg.get("lambda_decay", 0.95))
    tau = float(memory_cfg.get("tau_retrieval", 0.1))
    theta = float(memory_cfg.get("theta_min", 0.1))
    b = int(memory_cfg.get("batch_size", 2))

    # Choose persistence backend
    store: MemoryStore
    if backend == "sqlite":
        sqlite_paths = stores_cfg.get("paths", {})
        path = sqlite_paths.get("sqlite_path", "memory.db")
        store = SQLiteStore(path, debug=debug)

    elif backend == "pgvector":
        pgcfg = cfg.setdefault("pgvector", {})
        dsn = settings.pgvector_dsn or pgcfg.get("dsn")
        if not dsn:
            raise ValueError(
                "pgvector DSN not provided. Configure configs/default.yaml or set HIEROMEM_PGVECTOR_DSN."
            )
        index_strategy = str(pgcfg.get("index_strategy", "ivfflat")).lower()
        store = PGVectorStore(
            dsn,
            dim=dim,
            debug=debug,
            index_strategy=index_strategy,
            ivfflat_lists=int(pgcfg.get("lists", 200)),
            ivfflat_probes=int(pgcfg.get("probes", 10)),
            hnsw_m=int(pgcfg.get("hnsw_m", 16)),
            hnsw_ef_construction=int(pgcfg.get("hnsw_ef_construction", 128)),
            hnsw_ef_search=int(pgcfg.get("hnsw_ef_search", 64)),
        )

    elif backend == "neo4j":
        neocfg = cfg.get("neo4j", {})
        enable_mt = bool(neocfg.get("enable_multi_tenancy", True))
        neo_uri = neocfg.get("uri") or os.environ.get("NEO4J_URI")
        neo_user = neocfg.get("user") or os.environ.get("NEO4J_USER")
        neo_password = neocfg.get("password") or os.environ.get("NEO4J_PASSWORD")

        if not all([neo_uri, neo_user, neo_password]):
            raise ValueError(
                "Neo4j backend requires uri, user, and password. "
                "Set via config or NEO4J_URI, NEO4J_USER, NEO4J_PASSWORD env vars."
            )

        # Type assertions for mypy (validated above)
        assert isinstance(neo_uri, str)  # nosec B101
        assert isinstance(neo_user, str)  # nosec B101
        assert isinstance(neo_password, str)  # nosec B101

        try:
            store = Neo4jStore(
                uri=neo_uri,
                user=neo_user,
                password=neo_password,
                enable_multi_tenancy=enable_mt,
                debug=debug,
            )
        except Exception as e:
            # Re-raise with a clear message that this backend failed
            # This ensures we don't silently fall back or hang
            raise RuntimeError(f"Failed to initialize Neo4j backend: {e}") from e

    elif backend == "in_memory_graph":
        store = InMemoryGraphStore(debug=debug)
        if debug:
            print("[deps] Using InMemoryGraphStore")

    else:
        raise ValueError(f"Unsupported backend: {backend}")

    # Optionally attach FAISS index for accelerated search
    index: Optional[FaissIndex] = None
    if cfg.get("use_faiss", False):
        faiss_cfg = cfg.get("faiss", {})
        pretrained_path = faiss_cfg.get("pretrained_path")
        if pretrained_path:
            index = FaissIndex.load(
                pretrained_path,
                debug=debug,
                index_type=faiss_cfg.get("index_type", "ivfflat"),
            )
        else:
            index = FaissIndex(
                dim=dim,
                debug=debug,
                index_type=faiss_cfg.get("index_type", "ivfflat"),
                nlist=int(faiss_cfg.get("nlist", 1024)),
                nprobe=int(faiss_cfg.get("nprobe", 10)),
                hnsw_m=int(faiss_cfg.get("hnsw_m", 32)),
                hnsw_ef_search=int(faiss_cfg.get("hnsw_ef_search", 64)),
                hnsw_ef_construction=int(faiss_cfg.get("hnsw_ef_construction", 200)),
            )
        if debug:
            print("[deps] FAISS index initialized")

    # Initialize memory engine
    allow_sensitive = bool(governance_cfg.get("allow_sensitive_storage", True))
    blocked_relations = governance_cfg.get("blocked_relations") or []
    forbidden_relations: list[str] | None
    if allow_sensitive:
        forbidden_relations = list(blocked_relations)
    else:
        forbidden_relations = list(
            blocked_relations) if blocked_relations else None

    memory = Memory(
        Nmax=Nmax,
        lam=lam,
        tau=tau,
        theta=theta,
        b=b,
        store=store,
        debug=debug,
        enable_multi_tenant=True,
        forbidden_relations=forbidden_relations,
    )

    # Attach encoder
    encoder_model = memory_cfg.get("encoder_model", "stub-hash")
    encoder_backend = memory_cfg.get("encoder_backend", "stub")
    memory.encoder = Encoder(
        model_name=encoder_model,
        backend=encoder_backend,
        embedding_dim=dim,
    )

    if index:
        memory._index = index  # optional attribute for fast retrieval

    _attach_runtime_metrics(memory)

    if debug:
        print(f"[deps] Memory initialized with backend={backend}, dim={dim}")

    # Attach tenant configs to memory for quota enforcement
    setattr(memory, '_tenant_configs', tenant_cfgs)
    return memory
