"""High-level user-facing encoder API."""

from __future__ import annotations

from dataclasses import replace

import numpy as np

from .composer import VectorComposer
from .explain import overlap_explain
from .features import extract_feature_weights
from .profiles import get_profile_params
from .ri import RIContextParams, RIVectorSpec, ri_vector_context_dense
from .similarity import cosine_similarity as cosine_similarity_fn
from .types import FeatureMap
from .utils import resolve_family, simple_normalize


class IdentifierEncoder:
    """Deterministic approximate vectorizer for short identifiers and labels.

    ``IdentifierEncoder`` turns short noisy strings into fixed-size dense
    vectors using weighted random indexing over word and optional char n-gram
    features. The output vectors are L2-normalized and suitable for cosine
    similarity or ANN indexing.
    """

    def __init__(
        self,
        dim: int = 2048,
        nonzeros: int = 8,
        seed: int = 17,
        profile: str = "word_ngram",
        family_weight: float = 2.0,
        word_weight: float = 1.0,
        ngram_weight: float = 0.3,
        ngram_range: tuple[int, int] = (3, 5),
        position_decay: float = 0.0,
        max_grams_per_word: int = 40,
        normalize_text: bool = True,
    ) -> None:
        """Initialize an encoder with RI basis settings and feature weights."""
        self.spec = RIVectorSpec(dim=int(dim), nonzeros=int(nonzeros), seed=int(seed))
        self.profile = profile
        self.normalize_text = bool(normalize_text)

        base = get_profile_params(profile)
        self.params = self._resolve_params(
            base=base,
            profile=profile,
            family_weight=float(family_weight),
            word_weight=float(word_weight),
            ngram_weight=float(ngram_weight),
            ngram_range=ngram_range,
            position_decay=float(position_decay),
            max_grams_per_word=int(max_grams_per_word),
        )

    @staticmethod
    def _resolve_params(
        *,
        base: RIContextParams,
        profile: str,
        family_weight: float,
        word_weight: float,
        ngram_weight: float,
        ngram_range: tuple[int, int],
        position_decay: float,
        max_grams_per_word: int,
    ) -> RIContextParams:
        """Merge profile defaults with explicit constructor overrides."""
        # Preserve profile presets when users only switch profile, while still
        # allowing explicit argument values to override defaults.
        use_defaults = profile != "word_ngram"

        default_family = 2.0
        default_word = 1.0
        default_ngram = 0.3
        default_range = (3, 5)
        default_pos_decay = 0.0
        default_max_grams = 40

        w_family = base.w_family if (use_defaults and family_weight == default_family) else family_weight
        w_word = base.w_word if (use_defaults and word_weight == default_word) else word_weight
        w_chargram = base.w_chargram if (use_defaults and ngram_weight == default_ngram) else ngram_weight

        gram_min, gram_max = ngram_range
        if use_defaults and ngram_range == default_range:
            gram_min = base.gram_min
            gram_max = base.gram_max

        pos_decay = base.position_decay if (use_defaults and position_decay == default_pos_decay) else position_decay
        grams_cap = (
            base.max_grams_per_word
            if (use_defaults and max_grams_per_word == default_max_grams)
            else max_grams_per_word
        )

        return replace(
            base,
            gram_min=int(gram_min),
            gram_max=int(gram_max),
            max_grams_per_word=int(grams_cap),
            w_family=float(w_family),
            w_word=float(w_word),
            w_chargram=float(w_chargram),
            position_decay=float(pos_decay),
        )

    @property
    def dim(self) -> int:
        """Return the dimensionality of encoded dense vectors."""
        return self.spec.dim

    def _prep_text(self, text: str) -> str:
        """Normalize a raw input string when normalization is enabled."""
        raw = text or ""
        return simple_normalize(raw) if self.normalize_text else raw

    def _encode_with_features(self, text: str, family: str | None = None) -> tuple[np.ndarray, FeatureMap, str]:
        """Encode text and return the dense vector, feature map, and family."""
        family_norm = resolve_family(family)
        text_norm = self._prep_text(text)

        if not text_norm:
            return np.zeros(self.dim, dtype=np.float32), {}, family_norm

        vec, _ = ri_vector_context_dense(
            family=family_norm,
            text_norm=text_norm,
            spec=self.spec,
            params=self.params,
        )
        feat_map = extract_feature_weights(
            family=family_norm,
            text_norm=text_norm,
            params=self.params,
            spec=self.spec,
        )
        return vec, feat_map, family_norm

    def encode(self, text: str, family: str | None = None) -> np.ndarray:
        """Encode one text input into a dense normalized vector."""
        vec, _, _ = self._encode_with_features(text, family)
        return vec

    def batch_encode(self, texts: list[str], family: str | None = None) -> np.ndarray:
        """Encode many texts into a 2D dense float32 array."""
        if not texts:
            return np.zeros((0, self.dim), dtype=np.float32)
        rows = [self.encode(text, family=family) for text in texts]
        return np.asarray(rows, dtype=np.float32)

    def similarity(self, a: str, b: str, family: str | None = None) -> float:
        """Encode two texts and return their cosine similarity."""
        a_norm = self._prep_text(a)
        b_norm = self._prep_text(b)
        if not a_norm and not b_norm:
            return 0.0

        va = self.encode(a, family=family)
        vb = self.encode(b, family=family)
        return cosine_similarity_fn(va, vb, assume_normalized=True)

    def start_composer(self, normalize_output: bool = True) -> VectorComposer:
        """Create an incremental composer tied to this encoder."""
        return VectorComposer(self, normalize_output=normalize_output)

    def explain(self, a: str, b: str, family: str | None = None, top_k: int = 10) -> dict:
        """Explain overlapping features and score for two text inputs."""
        va, feats_a, fam = self._encode_with_features(a, family)
        vb, feats_b, _ = self._encode_with_features(b, family)
        score = cosine_similarity_fn(va, vb, assume_normalized=True)
        return overlap_explain(
            features_a=feats_a,
            features_b=feats_b,
            score=score,
            profile=self.profile,
            family=fam,
            top_k=top_k,
        )
