"""SigLIP SO400M image-embedding wrapper (PyTorch CPU).

torch/transformers are imported lazily inside load() so this module can be
imported in the web container (which never runs inference) without paying the
torch import cost.
"""

import os
from pathlib import Path

import numpy as np
from PIL import Image, ImageFile

ImageFile.LOAD_TRUNCATED_IMAGES = True

# Cap torch's intra-op threads so each ml-worker replica is a bounded core
# consumer on a shared node (torch otherwise uses all cores). Keep
# N_replicas × this within the cores allotted to ML to avoid oversubscription.
_INTRA_OP_THREADS = 4

DEFAULT_MODEL_NAME = os.environ.get(
    "SIGLIP_MODEL_NAME", "google/siglip-so400m-patch14-384"
)
# Back-compat alias (api/gpu imported this name as the fallback embedder id).
MODEL_NAME = DEFAULT_MODEL_NAME
MODEL_VERSION = os.environ.get(
    "SIGLIP_MODEL_VERSION", "siglip-so400m-patch14-384"
)
EMBED_DIM = 1152
_LOCAL_DIR = Path(os.environ.get("ML_MODEL_DIR", "/models")) / "siglip"


class Embedder:
    """Loads whatever SigLIP-family model it's given by HF NAME. For the default
    model it prefers the pre-downloaded local dir (no re-download on existing
    deploys); any other name resolves as an HF repo id (downloaded + cached on
    first use), so an operator model swap (#1190) just works server-side."""

    def __init__(self, model_name: str | None = None, model_dir: Path | None = None):
        self.model_name = model_name or DEFAULT_MODEL_NAME
        self._explicit_dir = model_dir
        self._model = None
        self._processor = None
        self._torch = None

    def _source(self) -> str:
        if self._explicit_dir is not None:
            return str(self._explicit_dir)
        if self.model_name == DEFAULT_MODEL_NAME and _LOCAL_DIR.exists():
            return str(_LOCAL_DIR)
        return self.model_name

    def load(self) -> None:
        if self._model is not None:
            return
        import torch
        from transformers import AutoImageProcessor, AutoModel

        self._torch = torch
        # Bound torch's CPU thread pool (see _INTRA_OP_THREADS) so each replica
        # stays a predictable core consumer on a shared node.
        torch.set_num_threads(_INTRA_OP_THREADS)
        # IMAGE inference only — AutoImageProcessor loads just the image side
        # (preprocessor_config.json), skipping the SigLIP tokenizer + its
        # sentencepiece dep (operator hit that ImportError 2026-05-25). Works
        # for any SigLIP-family model, keeping the embedder model-agnostic.
        src = self._source()
        self._processor = AutoImageProcessor.from_pretrained(src)
        self._model = AutoModel.from_pretrained(src)
        self._model.eval()

    def infer(self, image_path: Path) -> np.ndarray:
        """Return a 1152-dim float32 embedding (SigLIP MAP-pooled output)."""
        self.load()
        img = Image.open(image_path).convert("RGB")
        with self._torch.no_grad():
            inputs = self._processor(images=img, return_tensors="pt")
            out = self._model.get_image_features(**inputs)
        pooled = out.pooler_output if hasattr(out, "pooler_output") else out
        return pooled[0].numpy().astype(np.float32)


_default_embedder: Embedder | None = None


def get_embedder(model_name: str | None = None) -> Embedder:
    """Cached embedder for `model_name` (default if None). Rebuilds the singleton
    when the requested name changes, so an operator model swap takes effect
    without restarting the worker."""
    global _default_embedder
    name = model_name or DEFAULT_MODEL_NAME
    if _default_embedder is None or _default_embedder.model_name != name:
        _default_embedder = Embedder(model_name=name)
    return _default_embedder
