import pytest

import mempalace.embedding as embedding


@pytest.fixture(autouse=True)
def isolate_embedding_state(monkeypatch):
    monkeypatch.setattr(embedding, "_EF_CACHE", {})
    monkeypatch.setattr(embedding, "_WARNED", set())


def test_auto_picks_cuda(monkeypatch):
    monkeypatch.setattr(
        "onnxruntime.get_available_providers",
        lambda: ["CUDAExecutionProvider", "CPUExecutionProvider"],
    )

    assert embedding._resolve_providers("auto") == (
        ["CUDAExecutionProvider", "CPUExecutionProvider"],
        "cuda",
    )


def test_auto_falls_to_cpu(monkeypatch):
    monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])

    assert embedding._resolve_providers("auto") == (["CPUExecutionProvider"], "cpu")


def test_auto_skips_coreml_for_embeddinggemma(monkeypatch):
    """auto must not hand EmbeddingGemma to CoreML.

    CoreML supports only a fraction of that model's quantized graph and
    returns an all-NaN hidden state without erroring, so a Mac user with no
    explicit embedding_device would silently embed (and, under `repair
    rebuild-index`, persist) degenerate vectors.
    """
    monkeypatch.setattr(
        "onnxruntime.get_available_providers",
        lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
    )

    assert embedding._resolve_providers("auto", "embeddinggemma") == (
        ["CPUExecutionProvider"],
        "cpu",
    )


def test_auto_still_picks_coreml_for_other_models(monkeypatch):
    """The denylist is per-model — it must not disable CoreML globally."""
    monkeypatch.setattr(
        "onnxruntime.get_available_providers",
        lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
    )

    assert embedding._resolve_providers("auto", "minilm") == (
        ["CoreMLExecutionProvider", "CPUExecutionProvider"],
        "coreml",
    )


def test_auto_still_picks_cuda_for_embeddinggemma(monkeypatch):
    """Only CoreML is implicated; CUDA stays the preferred accelerator."""
    monkeypatch.setattr(
        "onnxruntime.get_available_providers",
        lambda: ["CUDAExecutionProvider", "CoreMLExecutionProvider", "CPUExecutionProvider"],
    )

    assert embedding._resolve_providers("auto", "embeddinggemma") == (
        ["CUDAExecutionProvider", "CPUExecutionProvider"],
        "cuda",
    )


def test_explicit_coreml_is_still_honored_for_embeddinggemma(monkeypatch):
    """An explicit embedding_device=coreml is a deliberate choice, so the
    denylist (which only guards *automatic* selection) leaves it alone. The
    witness probe in EmbeddinggemmaONNX._lazy_load is what keeps it safe."""
    monkeypatch.setattr(
        "onnxruntime.get_available_providers",
        lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
    )

    assert embedding._resolve_providers("coreml", "embeddinggemma") == (
        ["CoreMLExecutionProvider", "CPUExecutionProvider"],
        "coreml",
    )


def test_cuda_missing_warns_with_gpu_extra(monkeypatch, caplog):
    monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])

    assert embedding._resolve_providers("cuda") == (["CPUExecutionProvider"], "cpu")
    assert "mempalace[gpu]" in caplog.text


def test_coreml_missing_warns_with_coreml_extra(monkeypatch, caplog):
    monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])

    assert embedding._resolve_providers("coreml") == (["CPUExecutionProvider"], "cpu")
    assert "mempalace[coreml]" in caplog.text


def test_dml_missing_warns_with_dml_extra(monkeypatch, caplog):
    monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])

    assert embedding._resolve_providers("dml") == (["CPUExecutionProvider"], "cpu")
    assert "mempalace[dml]" in caplog.text


def test_unknown_device_warns_once(monkeypatch, caplog):
    monkeypatch.setattr("onnxruntime.get_available_providers", lambda: ["CPUExecutionProvider"])

    assert embedding._resolve_providers("bogus") == (["CPUExecutionProvider"], "cpu")
    assert embedding._resolve_providers("bogus") == (["CPUExecutionProvider"], "cpu")
    assert caplog.text.count("Unknown embedding_device") == 1


def test_onnxruntime_import_error_falls_back_to_cpu(monkeypatch):
    import builtins

    real_import = builtins.__import__

    def fake_import(name, *args, **kwargs):
        if name == "onnxruntime":
            raise ImportError("missing")
        return real_import(name, *args, **kwargs)

    monkeypatch.setattr(builtins, "__import__", fake_import)

    assert embedding._resolve_providers("cuda") == (["CPUExecutionProvider"], "cpu")


def test_get_embedding_function_caches_by_resolved_provider_tuple(monkeypatch):
    class DummyEF:
        def __init__(self, preferred_providers, intra_op_num_threads=0):
            self.preferred_providers = preferred_providers

    monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
    monkeypatch.setattr(
        embedding,
        "_resolve_providers",
        lambda device, model=None: (["CPUExecutionProvider"], "cpu"),
    )

    first = embedding.get_embedding_function("cpu", "minilm")
    second = embedding.get_embedding_function("auto", "minilm")

    assert first is second
    assert first.preferred_providers == ["CPUExecutionProvider"]


def test_intra_op_session_options_caps_threads():
    so = embedding._intra_op_session_options(3)
    assert so is not None
    assert so.intra_op_num_threads == 3


def test_intra_op_session_options_uncapped_returns_none():
    assert embedding._intra_op_session_options(0) is None
    assert embedding._intra_op_session_options(-1) is None


def test_get_embedding_function_threads_cap_passed_to_minilm_ef(monkeypatch):
    captured = {}

    class DummyEF:
        def __init__(self, preferred_providers, intra_op_num_threads=0):
            captured["threads"] = intra_op_num_threads

    monkeypatch.setattr(embedding, "_build_ef_class", lambda: DummyEF)
    monkeypatch.setattr(
        embedding,
        "_resolve_providers",
        lambda device, model=None: (["CPUExecutionProvider"], "cpu"),
    )
    monkeypatch.setattr(embedding, "_resolve_intra_op_threads", lambda: 2)

    embedding.get_embedding_function("cpu", "minilm")

    assert captured["threads"] == 2


def test_get_embedding_function_threads_cap_passed_to_embeddinggemma(monkeypatch):
    captured = {}

    class DummyGemma:
        def __init__(self, preferred_providers=None, intra_op_num_threads=0):
            captured["threads"] = intra_op_num_threads

    monkeypatch.setattr(embedding, "EmbeddinggemmaONNX", DummyGemma)
    monkeypatch.setattr(
        embedding,
        "_resolve_providers",
        lambda device, model=None: (["CPUExecutionProvider"], "cpu"),
    )
    monkeypatch.setattr(embedding, "_resolve_intra_op_threads", lambda: 4)

    embedding.get_embedding_function("cpu", "embeddinggemma")

    assert captured["threads"] == 4


def test_minilm_ef_model_override_applies_thread_cap(monkeypatch):
    """The ``_MempalaceONNX.model`` override must construct the ORT session
    with the configured ``intra_op_num_threads`` (#1068). We stub
    ``InferenceSession`` to capture the ``SessionOptions`` it receives, so the
    test never downloads or loads the real model."""
    import onnxruntime as ort

    captured = {}

    def fake_session(model_path, providers=None, sess_options=None):
        captured["sess_options"] = sess_options
        captured["providers"] = providers
        return object()

    monkeypatch.setattr(ort, "InferenceSession", fake_session)

    ef_cls = embedding._build_ef_class()
    ef = ef_cls(preferred_providers=["CPUExecutionProvider"], intra_op_num_threads=2)
    _ = ef.model  # triggers the cached_property build

    assert captured["sess_options"] is not None
    assert captured["sess_options"].intra_op_num_threads == 2
    assert "CoreMLExecutionProvider" not in captured["providers"]


def test_minilm_ef_model_override_falls_back_when_uncapped(monkeypatch):
    """With no cap (0), the override must defer to the parent build via
    ``super().model`` — not reach into ``cached_property`` internals (#1068
    review). Proves super() resolves the parent descriptor without error."""
    import onnxruntime as ort

    captured = {}

    def fake_session(model_path, providers=None, sess_options=None):
        captured["sess_options"] = sess_options
        return object()

    monkeypatch.setattr(ort, "InferenceSession", fake_session)

    ef_cls = embedding._build_ef_class()
    ef = ef_cls(preferred_providers=["CPUExecutionProvider"], intra_op_num_threads=0)
    session = ef.model  # cap <= 0 → super().model (upstream builder)

    assert session is not None
    # Upstream leaves intra_op at ORT's default (0 = unset), confirming we
    # deferred to it rather than applying our cap.
    assert captured["sess_options"].intra_op_num_threads == 0


def test_describe_device_uses_resolved_effective_device(monkeypatch):
    monkeypatch.setattr(
        embedding,
        "_resolve_providers",
        lambda device, model=None: (["CUDAExecutionProvider", "CPUExecutionProvider"], "cuda"),
    )

    assert embedding.describe_device("auto") == "cuda"


def test_describe_device_reports_the_model_aware_resolution(monkeypatch):
    """The status header must show the device that will actually be used —
    which now depends on the model, since CoreML is off the table for
    embeddinggemma."""
    monkeypatch.setattr(
        "onnxruntime.get_available_providers",
        lambda: ["CoreMLExecutionProvider", "CPUExecutionProvider"],
    )

    assert embedding.describe_device("auto", "minilm") == "coreml"
    assert embedding.describe_device("auto", "embeddinggemma") == "cpu"


# ---------------------------------------------------------------------------
# embedding -> backend handoff
#
# These live in this module on purpose: conftest's autouse
# ``_stable_embedding_function_for_tests`` replaces
# ``embedding_wrapper._embed_texts`` outright for every other test module, so a
# defect in the real function is invisible there. ``test_embedding`` is in
# ``_REAL_EMBEDDING_TEST_MODULES`` and runs unstubbed.
# ---------------------------------------------------------------------------


class _NumpyEmbeddingFunction:
    """Mimics the real EF contract: a list of float32 ``np.ndarray`` rows.

    Both shipped embedders (ChromaDB's ONNX MiniLM and EmbeddingGemma) return
    numpy arrays, not Python lists — that difference is the whole point here.
    """

    def __init__(self, dim: int = 8):
        self.dim = dim

    def __call__(self, input):
        import numpy as np

        return [np.full(self.dim, 0.1, dtype=np.float32) for _ in list(input or [])]


def test_embed_texts_returns_plain_python_floats(monkeypatch):
    """``list(ndarray)`` yields ``np.float32`` scalars, which ChromaDB rejects.

    Regression for the default (chroma) backend failing every write with
    "Expected embeddings to be a list of floats or ints, a list of lists, a
    numpy array, or a list of numpy arrays" once chroma began declaring
    ``requires_explicit_embeddings`` and routing through EmbeddingCollection.
    """
    from mempalace.backends import embedding_wrapper as ew

    monkeypatch.setattr(
        embedding, "get_embedding_function", lambda *_, **__: _NumpyEmbeddingFunction()
    )

    vectors = ew._embed_texts(["hello", "world"])

    assert len(vectors) == 2
    for row in vectors:
        assert isinstance(row, list)
        assert all(type(x) is float for x in row), f"got {type(row[0])}, not builtin float"


def test_embedding_collection_upsert_accepts_numpy_backed_vectors(tmp_path, monkeypatch):
    """End-to-end: a real Chroma collection must accept what the wrapper emits.

    Asserting on float types alone would not catch a future ChromaDB tightening
    its accepted shapes, so drive an actual upsert + read-back.
    """
    from mempalace.backends.chroma import ChromaBackend
    from mempalace.backends.base import PalaceRef
    from mempalace.backends.embedding_wrapper import EmbeddingCollection

    monkeypatch.setattr(
        embedding, "get_embedding_function", lambda *_, **__: _NumpyEmbeddingFunction()
    )

    backend = ChromaBackend()
    palace = tmp_path / "palace"
    ref = PalaceRef(id=str(palace), local_path=str(palace))
    try:
        inner = backend.get_collection(palace=ref, collection_name="mempalace_drawers", create=True)
        col = EmbeddingCollection(inner)

        col.upsert(documents=["verbatim drawer text"], ids=["drawer-1"], metadatas=[{"wing": "w"}])

        assert col.get(ids=["drawer-1"]).documents == ["verbatim drawer text"]
    finally:
        backend.close()


def test_embed_texts_handles_plain_sequence_embedders(monkeypatch):
    """The ``float(x)`` fallback must convert plain sequences, not just ndarrays.

    ``_embed_texts`` branches on ``hasattr(v, "tolist")``. The numpy side is
    covered above, but the fallback exists for embedders that hand back plain
    sequences (custom/BYO EFs, and rows that arrive as tuples), and nothing
    exercised it — so a regression there would surface only in the field, on a
    non-default embedder, as the same ChromaDB ``ValueError``.

    Yields ``Decimal`` rather than ``float`` so the assertion proves a real
    conversion happened rather than passing values through unchanged.
    """
    from decimal import Decimal

    from mempalace.backends import embedding_wrapper as ew

    class _PlainSequenceEmbeddingFunction:
        def __call__(self, input):
            return [(Decimal("0.5"), Decimal("0.25")) for _ in list(input or [])]

    monkeypatch.setattr(
        embedding, "get_embedding_function", lambda *_, **__: _PlainSequenceEmbeddingFunction()
    )

    vectors = ew._embed_texts(["a", "b"])

    assert vectors == [[0.5, 0.25], [0.5, 0.25]]
    for row in vectors:
        assert isinstance(row, list)
        assert all(type(x) is float for x in row), f"got {type(row[0])}, not builtin float"


def test_embed_texts_short_circuits_on_empty_input(monkeypatch):
    """Empty input must return ``[]`` without constructing an embedding function.

    Callers pass empty batches (a drawer set fully filtered by dedup), and
    loading the EF is the expensive part — on the ONNX default it spins up a
    native session. Guards the early return so it cannot be refactored away.
    """
    from mempalace.backends import embedding_wrapper as ew

    def _explode(*_, **__):
        raise AssertionError("get_embedding_function must not be called for an empty batch")

    monkeypatch.setattr(embedding, "get_embedding_function", _explode)

    assert ew._embed_texts([]) == []
