# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Embedding Providers for Semantic Memory

Provides vector embeddings for memory entries to enable semantic
similarity search. Three-tier cascading fallback:

    1. Ollama (local, zero external deps)
    2. Cloud API (Voyage/OpenAI, if key available)
    3. TF-IDF bag-of-words (pure Python, always available)

Usage:
    provider = get_embedding_provider()
    vector = provider.embed("grant deadline March 15")
    vectors = provider.embed_batch(["text1", "text2"])
    similarity = cosine_similarity(vec_a, vec_b)
"""

import json
import logging
import math
import os
import re
import urllib.error
import urllib.request
from abc import ABC, abstractmethod
from collections import Counter
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

logger = logging.getLogger(__name__)


# ════════════════════════════════════════════════════════════════
# Vector Math (no numpy required)
# ════════════════════════════════════════════════════════════════


def cosine_similarity(a: List[float], b: List[float]) -> float:
    """Compute cosine similarity between two vectors."""
    if len(a) != len(b):
        return 0.0
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(x * x for x in b))
    if norm_a == 0 or norm_b == 0:
        return 0.0
    return dot / (norm_a * norm_b)


def top_k_similar(
    query_vec: List[float],
    vectors: List[Tuple[str, List[float]]],
    k: int = 10,
) -> List[Tuple[str, float]]:
    """
    Find the k most similar vectors to the query.

    Args:
        query_vec: Query embedding
        vectors: List of (key, vector) pairs
        k: Number of results

    Returns:
        List of (key, similarity_score) sorted by score descending
    """
    scores = []
    for key, vec in vectors:
        sim = cosine_similarity(query_vec, vec)
        scores.append((key, sim))
    scores.sort(key=lambda x: x[1], reverse=True)
    return scores[:k]


# ════════════════════════════════════════════════════════════════
# Abstract Provider
# ════════════════════════════════════════════════════════════════


class EmbeddingProvider(ABC):
    """Abstract base class for embedding providers."""

    @property
    @abstractmethod
    def name(self) -> str:
        """Provider name for logging."""
        ...

    @property
    @abstractmethod
    def dimensions(self) -> int:
        """Embedding vector dimensions."""
        ...

    @abstractmethod
    def embed(self, text: str) -> List[float]:
        """Embed a single text string."""
        ...

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        """Embed multiple texts. Override for batch-optimized providers."""
        return [self.embed(t) for t in texts]


# ════════════════════════════════════════════════════════════════
# Ollama Embeddings (Local)
# ════════════════════════════════════════════════════════════════


class OllamaEmbeddings(EmbeddingProvider):
    """
    Local embeddings via Ollama.

    Uses nomic-embed-text (137M params) by default — fast, good quality,
    runs on CPU. Zero external API dependencies.
    """

    DEFAULT_MODEL = "nomic-embed-text"

    def __init__(
        self,
        model: str = DEFAULT_MODEL,
        base_url: str = "http://localhost:11434",
    ):
        self.model = model
        self.base_url = base_url.rstrip("/")
        self._dimensions: Optional[int] = None

    @property
    def name(self) -> str:
        return f"ollama/{self.model}"

    @property
    def dimensions(self) -> int:
        if self._dimensions is None:
            # Probe with a test embedding
            vec = self.embed("test")
            self._dimensions = len(vec)
        return self._dimensions

    def embed(self, text: str) -> List[float]:
        """Get embedding from Ollama API."""
        url = f"{self.base_url}/api/embed"
        payload = json.dumps(
            {
                "model": self.model,
                "input": text,
            }
        ).encode()

        req = urllib.request.Request(
            url,
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                data = json.loads(resp.read().decode())
                # Ollama returns {"embeddings": [[...]]}
                embeddings = data.get("embeddings", [])
                if embeddings and len(embeddings) > 0:
                    return embeddings[0]
                raise ValueError(f"Empty embedding response: {data}")
        except (urllib.error.URLError, ValueError, KeyError) as e:
            raise ConnectionError(f"Ollama embedding failed: {e}")

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        """Ollama supports batch via input list."""
        if not texts:
            return []

        url = f"{self.base_url}/api/embed"
        payload = json.dumps(
            {
                "model": self.model,
                "input": texts,
            }
        ).encode()

        req = urllib.request.Request(
            url,
            data=payload,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=60) as resp:
                data = json.loads(resp.read().decode())
                embeddings = data.get("embeddings", [])
                if len(embeddings) == len(texts):
                    return embeddings
                # Fallback to individual
                return [self.embed(t) for t in texts]
        except (urllib.error.URLError, ValueError) as e:
            logger.warning(f"Ollama batch embed failed, falling back to individual: {e}")
            return [self.embed(t) for t in texts]


# ════════════════════════════════════════════════════════════════
# Cloud API Embeddings
# ════════════════════════════════════════════════════════════════


class VoyageEmbeddings(EmbeddingProvider):
    """
    Cloud embeddings via Voyage AI (Anthropic's embedding partner).

    Requires VOYAGE_API_KEY environment variable.
    """

    DEFAULT_MODEL = "voyage-3-lite"

    def __init__(self, model: str = DEFAULT_MODEL, api_key: Optional[str] = None):
        self.model = model
        self.api_key = api_key or os.environ.get("VOYAGE_API_KEY", "")
        if not self.api_key:
            raise ValueError("VOYAGE_API_KEY not set")

    @property
    def name(self) -> str:
        return f"voyage/{self.model}"

    @property
    def dimensions(self) -> int:
        return 512 if "lite" in self.model else 1024

    def embed(self, text: str) -> List[float]:
        return self.embed_batch([text])[0]

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        url = "https://api.voyageai.com/v1/embeddings"
        payload = json.dumps(
            {
                "model": self.model,
                "input": texts,
                "input_type": "document",
            }
        ).encode()

        req = urllib.request.Request(
            url,
            data=payload,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {self.api_key}",
            },
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                data = json.loads(resp.read().decode())
                # Sort by index to maintain order
                results = sorted(data["data"], key=lambda x: x["index"])
                return [r["embedding"] for r in results]
        except (urllib.error.URLError, ValueError, KeyError) as e:
            raise ConnectionError(f"Voyage embedding failed: {e}")


class OpenAIEmbeddings(EmbeddingProvider):
    """
    Cloud embeddings via OpenAI.

    Requires OPENAI_API_KEY environment variable.
    """

    DEFAULT_MODEL = "text-embedding-3-small"

    def __init__(self, model: str = DEFAULT_MODEL, api_key: Optional[str] = None):
        self.model = model
        self.api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
        if not self.api_key:
            raise ValueError("OPENAI_API_KEY not set")

    @property
    def name(self) -> str:
        return f"openai/{self.model}"

    @property
    def dimensions(self) -> int:
        return 1536 if "small" in self.model else 3072

    def embed(self, text: str) -> List[float]:
        return self.embed_batch([text])[0]

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        url = "https://api.openai.com/v1/embeddings"
        payload = json.dumps(
            {
                "model": self.model,
                "input": texts,
            }
        ).encode()

        req = urllib.request.Request(
            url,
            data=payload,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {self.api_key}",
            },
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=30) as resp:
                data = json.loads(resp.read().decode())
                results = sorted(data["data"], key=lambda x: x["index"])
                return [r["embedding"] for r in results]
        except (urllib.error.URLError, ValueError, KeyError) as e:
            raise ConnectionError(f"OpenAI embedding failed: {e}")


# ════════════════════════════════════════════════════════════════
# ════════════════════════════════════════════════════════════════
# ONNX Local Embeddings (Pi-friendly)
# ════════════════════════════════════════════════════════════════


class ONNXEmbeddings(EmbeddingProvider):
    """
    Local neural embeddings via ONNX Runtime.

    Runs all-MiniLM-L6-v2 (or similar) on CPU with no GPU required.
    ~80MB model, ~50ms per embedding on a Raspberry Pi 4.

    Requires:
        pip install onnxruntime tokenizers

    Setup:
        python -m familiar.core.embeddings --setup-onnx

    This downloads the model (~80MB) to DATA_DIR/models/minilm/.
    """

    DEFAULT_MODEL_ID = "sentence-transformers/all-MiniLM-L6-v2"

    def __init__(self, model_dir: Optional[str] = None):
        self._session = None
        self._tokenizer = None
        self._dims: Optional[int] = None

        # Resolve model directory
        if model_dir:
            self._model_dir = Path(model_dir)
        else:
            # Default: DATA_DIR/models/minilm
            try:
                from .config import DATA_DIR

                self._model_dir = DATA_DIR / "models" / "minilm"
            except ImportError:
                self._model_dir = Path.home() / ".familiar" / "models" / "minilm"

        self._load()

    def _load(self):
        """Load ONNX model and tokenizer."""
        try:
            import onnxruntime as ort
        except ImportError:
            raise ImportError("onnxruntime required for ONNX embeddings.")

        try:
            from tokenizers import Tokenizer
        except ImportError:
            raise ImportError("tokenizers required for ONNX embeddings.")

        model_path = self._model_dir / "onnx" / "model.onnx"
        tokenizer_path = self._model_dir / "tokenizer.json"

        if not model_path.exists():
            raise FileNotFoundError(
                f"ONNX model not found at {model_path}. "
                f"Run: python -m familiar.core.embeddings --setup-onnx"
            )
        if not tokenizer_path.exists():
            raise FileNotFoundError(
                f"Tokenizer not found at {tokenizer_path}. "
                f"Run: python -m familiar.core.embeddings --setup-onnx"
            )

        # Load with minimal memory options for Pi
        opts = ort.SessionOptions()
        opts.inter_op_num_threads = 1
        opts.intra_op_num_threads = 2  # Pi has 4 cores, leave 2 free
        opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL

        self._session = ort.InferenceSession(str(model_path), opts)
        self._tokenizer = Tokenizer.from_file(str(tokenizer_path))

        # Configure tokenizer for model's max length
        self._tokenizer.enable_truncation(max_length=128)
        self._tokenizer.enable_padding(length=128)

    @property
    def name(self) -> str:
        return "onnx/all-MiniLM-L6-v2"

    @property
    def dimensions(self) -> int:
        if self._dims is None:
            vec = self.embed("test")
            self._dims = len(vec)
        return self._dims

    def embed(self, text: str) -> List[float]:
        """Embed text using ONNX model."""
        encoded = self._tokenizer.encode(text)

        import numpy as np

        input_ids = np.array([encoded.ids], dtype=np.int64)
        attention_mask = np.array([encoded.attention_mask], dtype=np.int64)
        token_type_ids = np.zeros_like(input_ids)

        outputs = self._session.run(
            None,
            {
                "input_ids": input_ids,
                "attention_mask": attention_mask,
                "token_type_ids": token_type_ids,
            },
        )

        # Mean pooling over token embeddings (masked)
        token_embeddings = outputs[0][0]  # (seq_len, hidden_dim)
        mask = attention_mask[0].astype(float)
        mask_expanded = mask[:, None]  # (seq_len, 1)
        summed = (token_embeddings * mask_expanded).sum(axis=0)
        count = mask.sum()
        if count > 0:
            pooled = summed / count
        else:
            pooled = summed

        # L2 normalize
        norm = float(np.linalg.norm(pooled))
        if norm > 0:
            pooled = pooled / norm

        return pooled.tolist()

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        """Batch embed — tokenize all, run in one pass if possible."""
        if not texts:
            return []

        encoded_batch = self._tokenizer.encode_batch(texts)

        import numpy as np

        input_ids = np.array([e.ids for e in encoded_batch], dtype=np.int64)
        attention_mask = np.array([e.attention_mask for e in encoded_batch], dtype=np.int64)
        token_type_ids = np.zeros_like(input_ids)

        outputs = self._session.run(
            None,
            {
                "input_ids": input_ids,
                "attention_mask": attention_mask,
                "token_type_ids": token_type_ids,
            },
        )

        # Mean pooling per item
        results = []
        all_embeddings = outputs[0]  # (batch, seq_len, hidden_dim)
        for i in range(len(texts)):
            token_emb = all_embeddings[i]
            mask = attention_mask[i].astype(float)[:, None]
            summed = (token_emb * mask).sum(axis=0)
            count = attention_mask[i].sum()
            pooled = summed / count if count > 0 else summed
            norm = float(np.linalg.norm(pooled))
            if norm > 0:
                pooled = pooled / norm
            results.append(pooled.tolist())

        return results

    @staticmethod
    def setup(model_dir: Optional[str] = None):
        """Download the ONNX model and tokenizer from HuggingFace."""
        try:
            from huggingface_hub import hf_hub_download
        except ImportError:
            print("huggingface_hub not available.")
            return False

        if model_dir:
            target = Path(model_dir)
        else:
            try:
                from .config import DATA_DIR

                target = DATA_DIR / "models" / "minilm"
            except ImportError:
                target = Path.home() / ".familiar" / "models" / "minilm"

        target.mkdir(parents=True, exist_ok=True)

        repo = ONNXEmbeddings.DEFAULT_MODEL_ID
        files = ["onnx/model.onnx", "tokenizer.json"]

        print(f"Downloading {repo} to {target}...")
        for f in files:
            print(f"  {f}...")
            hf_hub_download(repo_id=repo, filename=f, local_dir=str(target))

        print(f"Done. Model ready at {target}")
        return True


# ════════════════════════════════════════════════════════════════
# Generic HTTP Embeddings (Self-hosted / any endpoint)
# ════════════════════════════════════════════════════════════════


class HTTPEmbeddings(EmbeddingProvider):
    """
    Embeddings from any HTTP endpoint that accepts POST with JSON.

    Works with:
        - Self-hosted sentence-transformers (via FastAPI/Flask)
        - llama.cpp server (POST /embedding)
        - Qwen embedding models behind any HTTP server
        - Any OpenAI-compatible embedding endpoint

    Configure via environment:
        FAMILIAR_EMBEDDING_URL=http://192.168.1.50:8080/embed
        FAMILIAR_EMBEDDING_DIM=384  (optional, auto-detected)

    Expected request format:
        POST {"input": "text"} or {"input": ["text1", "text2"]}

    Expected response format (auto-detected):
        OpenAI-style: {"data": [{"embedding": [...]}]}
        Simple:       {"embedding": [...]} or {"embeddings": [[...]]}
        Raw array:    [[...]] or [...]
    """

    def __init__(
        self,
        url: Optional[str] = None,
        dimensions: Optional[int] = None,
        api_key: Optional[str] = None,
        timeout: int = 30,
    ):
        self._url = url or os.environ.get("FAMILIAR_EMBEDDING_URL", "")
        if not self._url:
            raise ValueError(
                "HTTP embedding URL required. Set FAMILIAR_EMBEDDING_URL or pass url= parameter."
            )
        self._dims = dimensions
        if not self._dims:
            dim_str = os.environ.get("FAMILIAR_EMBEDDING_DIM", "")
            if dim_str.isdigit():
                self._dims = int(dim_str)
        self._api_key = api_key or os.environ.get("FAMILIAR_EMBEDDING_KEY", "")
        self._timeout = timeout

    @property
    def name(self) -> str:
        # Extract host for display
        from urllib.parse import urlparse

        host = urlparse(self._url).hostname or self._url
        return f"http/{host}"

    @property
    def dimensions(self) -> int:
        if self._dims is None:
            vec = self.embed("test")
            self._dims = len(vec)
        return self._dims

    def _make_request(self, payload: dict) -> dict:
        """Send POST request and return parsed JSON response."""
        data = json.dumps(payload).encode()
        headers = {"Content-Type": "application/json"}
        if self._api_key:
            headers["Authorization"] = f"Bearer {self._api_key}"

        req = urllib.request.Request(
            self._url,
            data=data,
            headers=headers,
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=self._timeout) as resp:
                return json.loads(resp.read().decode())
        except (urllib.error.URLError, ValueError) as e:
            raise ConnectionError(f"HTTP embedding request failed: {e}")

    def _extract_vectors(self, response: Any, expected_count: int = 1) -> List[List[float]]:
        """
        Extract embedding vectors from various response formats.

        Handles:
            {"data": [{"embedding": [...]}]}     (OpenAI-style)
            {"embedding": [...]}                  (single)
            {"embeddings": [[...]]}               (batch)
            [[...]]                               (raw array of arrays)
            [...]                                 (raw single vector)
        """
        if isinstance(response, dict):
            # OpenAI-style: {"data": [{"embedding": [...]}]}
            if "data" in response:
                items = sorted(response["data"], key=lambda x: x.get("index", 0))
                return [item["embedding"] for item in items]

            # {"embeddings": [[...]]}
            if "embeddings" in response:
                embs = response["embeddings"]
                if isinstance(embs, list) and embs:
                    if isinstance(embs[0], list):
                        return embs
                    return [embs]  # Single vector wrapped

            # {"embedding": [...]}
            if "embedding" in response:
                emb = response["embedding"]
                if isinstance(emb, list):
                    if emb and isinstance(emb[0], list):
                        return emb
                    return [emb]

        # Raw array response
        if isinstance(response, list):
            if response and isinstance(response[0], list):
                return response  # [[...], [...]]
            if response and isinstance(response[0], (int, float)):
                return [response]  # [...] single vector

        raise ValueError(f"Could not extract embeddings from response: {type(response)}")

    def embed(self, text: str) -> List[float]:
        """Embed a single text."""
        resp = self._make_request({"input": text})
        vectors = self._extract_vectors(resp, expected_count=1)
        return vectors[0]

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        """Embed multiple texts in one request."""
        if not texts:
            return []
        resp = self._make_request({"input": texts})
        vectors = self._extract_vectors(resp, expected_count=len(texts))
        if len(vectors) != len(texts):
            # Fallback to individual
            return [self.embed(t) for t in texts]
        return vectors


# ════════════════════════════════════════════════════════════════
# TF-IDF Fallback (Zero Dependencies)
# ════════════════════════════════════════════════════════════════


class TFIDFEmbeddings(EmbeddingProvider):
    """
    TF-IDF bag-of-words embeddings. No external dependencies.

    Not as good as neural embeddings but dramatically better than
    substring matching. Handles synonyms through shared vocabulary
    overlap and term weighting.


    The vocabulary is built incrementally from all embedded texts.
    """

    def __init__(self, max_features: int = 512):
        self.max_features = max_features
        self._vocab: Dict[str, int] = {}  # term -> index
        self._idf: Dict[str, float] = {}  # term -> IDF weight
        self._doc_count = 0
        self._doc_freq: Counter = Counter()  # term -> doc frequency

    @property
    def name(self) -> str:
        return "tfidf"

    @property
    def dimensions(self) -> int:
        return self.max_features

    @staticmethod
    def _tokenize(text: str) -> List[str]:
        """Simple whitespace + punctuation tokenizer."""
        text = text.lower()
        # Split on non-alphanumeric, keep meaningful tokens
        tokens = re.findall(r"[a-z0-9]+", text)
        # Remove very short tokens
        return [t for t in tokens if len(t) > 1]

    def _update_vocab(self, texts: List[str]):
        """Update vocabulary and IDF from new texts."""
        for text in texts:
            tokens = set(self._tokenize(text))
            self._doc_count += 1
            for token in tokens:
                self._doc_freq[token] += 1

        # Rebuild IDF
        for term, df in self._doc_freq.items():
            self._idf[term] = math.log((self._doc_count + 1) / (df + 1)) + 1

        # Keep top features by IDF * frequency
        if len(self._doc_freq) > self.max_features:
            top_terms = sorted(
                self._doc_freq.keys(),
                key=lambda t: self._idf.get(t, 0) * self._doc_freq[t],
                reverse=True,
            )[: self.max_features]
        else:
            top_terms = list(self._doc_freq.keys())

        self._vocab = {term: i for i, term in enumerate(sorted(top_terms))}

    def embed(self, text: str) -> List[float]:
        """Compute TF-IDF vector for text."""
        tokens = self._tokenize(text)
        if not tokens:
            return [0.0] * self.max_features

        # Update vocab if we see new terms
        new_terms = set(tokens) - set(self._doc_freq.keys())
        if new_terms:
            self._update_vocab([text])

        return self._embed_with_current_vocab(text)

    def embed_batch(self, texts: List[str]) -> List[List[float]]:
        """Batch embed with vocab update — updates IDF from ALL texts first."""
        # Update vocabulary and IDF from all texts before embedding any
        self._update_vocab(texts)
        return [self._embed_with_current_vocab(t) for t in texts]

    def _embed_with_current_vocab(self, text: str) -> List[float]:
        """Embed using current vocabulary without updating it."""
        tokens = self._tokenize(text)
        if not tokens:
            return [0.0] * self.max_features

        # Compute TF
        tf = Counter(tokens)
        max_tf = max(tf.values()) if tf else 1

        # Build vector
        vec = [0.0] * self.max_features
        for token, count in tf.items():
            if token in self._vocab:
                idx = self._vocab[token]
                if idx < self.max_features:
                    vec[idx] = (count / max_tf) * self._idf.get(token, 1.0)

        # L2 normalize
        norm = math.sqrt(sum(x * x for x in vec))
        if norm > 0:
            vec = [x / norm for x in vec]

        return vec

    def get_state(self) -> dict:
        """Serialize state for persistence."""
        return {
            "vocab": self._vocab,
            "idf": {k: v for k, v in self._idf.items()},
            "doc_count": self._doc_count,
            "doc_freq": dict(self._doc_freq),
            "max_features": self.max_features,
        }

    def load_state(self, state: dict):
        """Restore from serialized state."""
        self._vocab = state.get("vocab", {})
        self._idf = state.get("idf", {})
        self._doc_count = state.get("doc_count", 0)
        self._doc_freq = Counter(state.get("doc_freq", {}))
        self.max_features = state.get("max_features", self.max_features)


# ════════════════════════════════════════════════════════════════
# Provider Detection & Cascading Selection
# ════════════════════════════════════════════════════════════════


def _check_ollama(base_url: str = "http://localhost:11434") -> bool:
    """Check if Ollama is running and reachable."""
    try:
        req = urllib.request.Request(
            f"{base_url}/api/tags",
            method="GET",
        )
        with urllib.request.urlopen(req, timeout=3) as resp:
            return resp.status == 200
    except Exception:
        return False


def _check_ollama_model(
    model: str = "nomic-embed-text",
    base_url: str = "http://localhost:11434",
) -> bool:
    """Check if a specific model is available in Ollama."""
    try:
        req = urllib.request.Request(
            f"{base_url}/api/tags",
            method="GET",
        )
        with urllib.request.urlopen(req, timeout=3) as resp:
            data = json.loads(resp.read().decode())
            models = [m.get("name", "").split(":")[0] for m in data.get("models", [])]
            return model in models
    except Exception:
        return False


def get_embedding_provider(
    prefer_local: bool = False,
) -> EmbeddingProvider:
    """
    Auto-detect and return the best available embedding provider.

    Cascading selection (best → fallback):
        1. Ollama (if running locally with embedding model)
        2. ONNX local (if model downloaded — ideal for Pi)
        3. HTTP endpoint (if FAMILIAR_EMBEDDING_URL set — self-hosted)
        4. Voyage AI (if VOYAGE_API_KEY set)
        5. OpenAI (if OPENAI_API_KEY set)
        6. TF-IDF (always available, zero deps)

    Args:
        prefer_local: If True, skip cloud providers even if available.
                      Used for HIPAA tenants.

    Returns:
        An EmbeddingProvider instance
    """
    # 1. Try Ollama
    if _check_ollama():
        model = os.environ.get("FAMILIAR_EMBEDDING_MODEL", OllamaEmbeddings.DEFAULT_MODEL)
        if _check_ollama_model(model):
            logger.info(f"Using Ollama embeddings: {model}")
            return OllamaEmbeddings(model=model)
        else:
            # Auto-pull the embedding model if Ollama is available
            logger.info(f"Ollama running but {model} not found. Attempting: ollama pull {model}")
            try:
                import subprocess

                result = subprocess.run(
                    ["ollama", "pull", model],
                    stdout=subprocess.PIPE,
                    stderr=subprocess.PIPE,
                    text=True,
                    timeout=120,
                )
                if result.returncode == 0 and _check_ollama_model(model):
                    logger.info(f"Successfully pulled {model}")
                    return OllamaEmbeddings(model=model)
                else:
                    err = result.stderr.strip()
                    logger.info(
                        f"Auto-pull failed for {model}. "
                        f"Run manually: ollama pull {model}"
                        + (f"\n  ollama error: {err}" if err else "")
                    )
            except (FileNotFoundError, subprocess.TimeoutExpired, Exception) as e:
                logger.debug(f"Auto-pull failed: {e}. Run: ollama pull {model}")

    # 2. Try ONNX local (Pi-friendly, no server needed)
    try:
        provider = ONNXEmbeddings()
        logger.info(f"Using ONNX embeddings: {provider.name}")
        return provider
    except (ImportError, FileNotFoundError) as e:
        logger.debug(f"ONNX embeddings not available: {e}")
    except Exception as e:
        logger.warning(f"ONNX embeddings failed to load: {e}")

    # 3. Try HTTP endpoint (self-hosted Qwen, llama.cpp, etc.)
    if os.environ.get("FAMILIAR_EMBEDDING_URL"):
        try:
            provider = HTTPEmbeddings()
            # Quick health check
            provider.embed("test")
            logger.info(f"Using HTTP embeddings: {provider.name}")
            return provider
        except Exception as e:
            logger.warning(f"HTTP embeddings unavailable: {e}")

    if not prefer_local:
        # 4. Try Voyage
        if os.environ.get("VOYAGE_API_KEY"):
            try:
                provider = VoyageEmbeddings()
                logger.info(f"Using Voyage embeddings: {provider.model}")
                return provider
            except Exception as e:
                logger.warning(f"Voyage embeddings unavailable: {e}")

        # 5. Try OpenAI
        if os.environ.get("OPENAI_API_KEY"):
            try:
                provider = OpenAIEmbeddings()
                logger.info(f"Using OpenAI embeddings: {provider.model}")
                return provider
            except Exception as e:
                logger.warning(f"OpenAI embeddings unavailable: {e}")

    # 6. TF-IDF fallback (always works)
    logger.info("Using TF-IDF embeddings (no external provider available)")
    return TFIDFEmbeddings()
