"""Unit tests for Phase-28: Embedding, Perception, and Reasoning Python bindings.

Tests cover:
  T28.1 — embed(), embed_batch()
  T28.2 — embed_multimodal()
  T28.3 — remember_with_embedding()
  T28.4 — numpy integration (embed_numpy, embed_batch_numpy)
  T28.5 — ReasoningEngine (construction only — live LLM not required)
  T28.6 — PerceptionConfig presets / EmbeddingEngine constructors
  T28.7 — This file itself
"""

import math
import sys
import unittest

import numpy as np

from aifastdb import EmbeddingEngine, PerceptionPipeline

# ReasoningEngine requires http-client feature; import may fail gracefully
try:
    from aifastdb import ReasoningEngine
    HAS_REASONING = True
except ImportError:
    HAS_REASONING = False


class TestEmbeddingEngineBasic(unittest.TestCase):
    """T28.1 & T28.6: Basic embedding and configuration."""

    def setUp(self):
        self.engine = EmbeddingEngine()  # dummy, 384d

    def test_default_constructor(self):
        """Default engine is dummy with 384 dimensions."""
        self.assertEqual(self.engine.dimension, 384)
        self.assertEqual(self.engine.model_name, "dummy")
        self.assertIn("text", self.engine.supported_modalities)

    def test_dummy_preset(self):
        e = EmbeddingEngine.dummy(128)
        self.assertEqual(e.dimension, 128)
        self.assertEqual(e.model_name, "dummy")

    def test_embed_single(self):
        """embed() returns a list[float] of correct length."""
        vec = self.engine.embed("Hello, world!")
        self.assertIsInstance(vec, list)
        self.assertEqual(len(vec), 384)
        # All elements are floats
        self.assertTrue(all(isinstance(v, float) for v in vec))

    def test_embed_deterministic(self):
        """Same input produces same embedding (dummy engine is deterministic)."""
        v1 = self.engine.embed("test input")
        v2 = self.engine.embed("test input")
        self.assertEqual(v1, v2)

    def test_embed_different_inputs(self):
        """Different inputs produce different embeddings."""
        v1 = self.engine.embed("hello")
        v2 = self.engine.embed("world")
        self.assertNotEqual(v1, v2)

    def test_embed_normalized(self):
        """Dummy engine produces L2-normalized embeddings."""
        vec = self.engine.embed("test")
        norm = math.sqrt(sum(x * x for x in vec))
        self.assertAlmostEqual(norm, 1.0, places=4)

    def test_embed_batch(self):
        """embed_batch() returns list of embedding vectors."""
        texts = ["Hello", "World", "Foo"]
        vecs = self.engine.embed_batch(texts)
        self.assertIsInstance(vecs, list)
        self.assertEqual(len(vecs), 3)
        for v in vecs:
            self.assertEqual(len(v), 384)

    def test_embed_batch_empty(self):
        """embed_batch([]) returns empty list."""
        vecs = self.engine.embed_batch([])
        self.assertEqual(vecs, [])

    def test_custom_dimension(self):
        """EmbeddingEngine with custom dimension."""
        e = EmbeddingEngine(dimension=64)
        vec = e.embed("test")
        self.assertEqual(len(vec), 64)
        self.assertEqual(e.dimension, 64)

    def test_repr(self):
        """__repr__ is human-readable."""
        r = repr(self.engine)
        self.assertIn("EmbeddingEngine", r)
        self.assertIn("dummy", r)
        self.assertIn("384", r)


class TestEmbeddingNumpy(unittest.TestCase):
    """T28.4: numpy integration."""

    def setUp(self):
        self.engine = EmbeddingEngine()

    def test_embed_numpy_type(self):
        """embed_numpy() returns numpy.ndarray."""
        arr = self.engine.embed_numpy("Hello")
        self.assertIsInstance(arr, np.ndarray)
        self.assertEqual(arr.dtype, np.float32)
        self.assertEqual(arr.shape, (384,))

    def test_embed_numpy_values_match(self):
        """embed_numpy() and embed() produce the same values."""
        vec = self.engine.embed("test")
        arr = self.engine.embed_numpy("test")
        np.testing.assert_allclose(arr, np.array(vec, dtype=np.float32), atol=1e-6)

    def test_embed_batch_numpy(self):
        """embed_batch_numpy() returns list of ndarrays."""
        arrs = self.engine.embed_batch_numpy(["a", "b", "c"])
        self.assertIsInstance(arrs, list)
        self.assertEqual(len(arrs), 3)
        for arr in arrs:
            self.assertIsInstance(arr, np.ndarray)
            self.assertEqual(arr.dtype, np.float32)
            self.assertEqual(arr.shape, (384,))

    def test_embed_batch_numpy_empty(self):
        arrs = self.engine.embed_batch_numpy([])
        self.assertEqual(len(arrs), 0)


class TestCosimeSimilarity(unittest.TestCase):
    """T28.1: cosine_similarity utility."""

    def test_identical_vectors(self):
        a = [1.0, 0.0, 0.0]
        self.assertAlmostEqual(EmbeddingEngine.cosine_similarity(a, a), 1.0, places=5)

    def test_orthogonal_vectors(self):
        a = [1.0, 0.0]
        b = [0.0, 1.0]
        self.assertAlmostEqual(EmbeddingEngine.cosine_similarity(a, b), 0.0, places=5)

    def test_opposite_vectors(self):
        a = [1.0, 0.0]
        b = [-1.0, 0.0]
        self.assertAlmostEqual(EmbeddingEngine.cosine_similarity(a, b), -1.0, places=5)

    def test_dimension_mismatch_raises(self):
        with self.assertRaises(ValueError):
            EmbeddingEngine.cosine_similarity([1.0], [1.0, 2.0])

    def test_embed_similarity(self):
        """Similar texts have higher similarity than different ones."""
        engine = EmbeddingEngine()
        v_hello = engine.embed("hello")
        v_hi = engine.embed("hi")
        v_unrelated = engine.embed("zzzzzzzzzzzzz something completely different")
        sim_similar = EmbeddingEngine.cosine_similarity(v_hello, v_hi)
        sim_diff = EmbeddingEngine.cosine_similarity(v_hello, v_unrelated)
        # We can't guarantee ordering with dummy engine, but both should be valid floats
        self.assertIsInstance(sim_similar, float)
        self.assertIsInstance(sim_diff, float)


class TestMultimodal(unittest.TestCase):
    """T28.2: embed_multimodal (dummy engine doesn't support it, should error)."""

    def setUp(self):
        self.engine = EmbeddingEngine()

    def test_multimodal_unsupported(self):
        """Dummy engine raises RuntimeError for multimodal embedding."""
        with self.assertRaises(RuntimeError):
            self.engine.embed_multimodal(b"\x89PNG\r\n\x1a\n", "image/png")

    def test_multimodal_numpy_unsupported(self):
        with self.assertRaises(RuntimeError):
            self.engine.embed_multimodal_numpy(b"\xff\xd8\xff", "image/jpeg")


class TestPerceptionPipeline(unittest.TestCase):
    """T28.3 & T28.6: PerceptionPipeline and remember_with_embedding."""

    def setUp(self):
        self.pipe = PerceptionPipeline()

    def test_default_pipeline(self):
        self.assertEqual(self.pipe.dimension, 384)
        self.assertEqual(self.pipe.model_name, "dummy")
        self.assertFalse(self.pipe.has_reasoning)

    def test_embed_delegate(self):
        vec = self.pipe.embed("hello")
        self.assertEqual(len(vec), 384)

    def test_embed_batch_delegate(self):
        vecs = self.pipe.embed_batch(["a", "b"])
        self.assertEqual(len(vecs), 2)

    def test_embed_numpy_delegate(self):
        arr = self.pipe.embed_numpy("hello")
        self.assertIsInstance(arr, np.ndarray)
        self.assertEqual(arr.shape, (384,))

    def test_remember_with_embedding(self):
        """remember_with_embedding returns dict with embedding."""
        result = self.pipe.remember_with_embedding("Hello world", "doc-001", tags=["greet"])
        self.assertIsInstance(result, dict)
        self.assertEqual(result["id"], "doc-001")
        self.assertEqual(result["content"], "Hello world")
        self.assertEqual(result["tags"], ["greet"])
        self.assertEqual(result["doc_type"], "text")
        self.assertIsInstance(result["embedding"], list)
        self.assertEqual(len(result["embedding"]), 384)
        self.assertEqual(result["dimension"], 384)

    def test_remember_with_embedding_custom_type(self):
        result = self.pipe.remember_with_embedding(
            "Some code", "code-001", doc_type="code", tags=["python"]
        )
        self.assertEqual(result["doc_type"], "code")
        self.assertEqual(result["tags"], ["python"])

    def test_remember_with_embedding_no_tags(self):
        result = self.pipe.remember_with_embedding("Hello", "doc-002")
        self.assertEqual(result["tags"], [])

    def test_remember_with_embedding_numpy(self):
        """remember_with_embedding_numpy returns dict with ndarray embedding."""
        result = self.pipe.remember_with_embedding_numpy("Test", "doc-003")
        self.assertIsInstance(result, dict)
        self.assertEqual(result["id"], "doc-003")
        emb = result["embedding"]
        self.assertIsInstance(emb, np.ndarray)
        self.assertEqual(emb.dtype, np.float32)
        self.assertEqual(len(emb), 384)

    def test_reasoning_not_configured(self):
        """Reasoning methods raise when no reasoning engine is configured."""
        with self.assertRaises(RuntimeError):
            self.pipe.check_logic("A", "B")
        with self.assertRaises(RuntimeError):
            self.pipe.understand_intent("Hello")
        with self.assertRaises(RuntimeError):
            self.pipe.summarize("Some text")

    def test_pipeline_custom_dimension(self):
        pipe = PerceptionPipeline(dimension=128)
        self.assertEqual(pipe.dimension, 128)
        vec = pipe.embed("test")
        self.assertEqual(len(vec), 128)

    def test_repr(self):
        r = repr(self.pipe)
        self.assertIn("PerceptionPipeline", r)
        self.assertIn("dummy", r)


class TestReasoningEngineConstruction(unittest.TestCase):
    """T28.5: ReasoningEngine construction (no live LLM required).

    NOTE: The http-client feature is required to actually instantiate
    a reasoning engine. If not enabled, the constructor will raise
    RuntimeError. We test both cases gracefully.
    """

    def _try_create_ollama(self):
        """Try to create Ollama engine, return (engine, error)."""
        try:
            engine = ReasoningEngine.ollama("llama3.2")
            return engine, None
        except RuntimeError as e:
            return None, e

    def test_ollama_constructor(self):
        """Ollama constructor creates engine or raises feature error."""
        engine, err = self._try_create_ollama()
        if engine is not None:
            self.assertEqual(engine.model_name, "llama3.2")
        else:
            self.assertIn("http-client", str(err).lower())

    def test_openai_constructor(self):
        try:
            engine = ReasoningEngine.openai("sk-test-key", "gpt-4o-mini")
            self.assertEqual(engine.model_name, "gpt-4o-mini")
        except RuntimeError as e:
            self.assertIn("http-client", str(e).lower())

    def test_repr(self):
        engine, err = self._try_create_ollama()
        if engine is not None:
            r = repr(engine)
            self.assertIn("ReasoningEngine", r)
            self.assertIn("llama3.2", r)
        else:
            # Skip if engine can't be created
            self.skipTest("http-client feature not enabled")

    def test_invalid_provider_raises(self):
        with self.assertRaises(ValueError):
            ReasoningEngine("invalid_provider", "model")

    def test_reasoning_import(self):
        """ReasoningEngine can be imported (feature-gated)."""
        from aifastdb._native import ReasoningEngine as RE
        self.assertTrue(hasattr(RE, "ollama"))


class TestEmbeddingEnginePresets(unittest.TestCase):
    """T28.6: Engine configuration presets."""

    def test_minilm_requires_candle(self):
        """MiniLM preset requires candle feature; should raise or succeed."""
        try:
            e = EmbeddingEngine.minilm()
            # If candle is enabled, it should work
            self.assertEqual(e.model_name, "all-MiniLM-L6-v2")
        except RuntimeError as ex:
            # Feature not enabled — expected on builds without candle
            self.assertIn("candle", str(ex).lower())

    def test_bge_small_requires_candle(self):
        try:
            e = EmbeddingEngine.bge_small()
        except RuntimeError as ex:
            self.assertIn("candle", str(ex).lower())

    def test_gemma_requires_candle(self):
        try:
            e = EmbeddingEngine.gemma()
        except RuntimeError as ex:
            self.assertIn("candle", str(ex).lower())

    def test_invalid_engine_type_raises(self):
        with self.assertRaises(ValueError):
            EmbeddingEngine(engine_type="nonexistent")


# =========================================================================
# Runner
# =========================================================================

if __name__ == "__main__":
    print("=" * 70)
    print("Phase-28: Embedding / Perception / Reasoning — Unit Tests")
    print("=" * 70)

    loader = unittest.TestLoader()
    suite = unittest.TestSuite()
    suite.addTests(loader.loadTestsFromTestCase(TestEmbeddingEngineBasic))
    suite.addTests(loader.loadTestsFromTestCase(TestEmbeddingNumpy))
    suite.addTests(loader.loadTestsFromTestCase(TestCosimeSimilarity))
    suite.addTests(loader.loadTestsFromTestCase(TestMultimodal))
    suite.addTests(loader.loadTestsFromTestCase(TestPerceptionPipeline))
    suite.addTests(loader.loadTestsFromTestCase(TestReasoningEngineConstruction))
    suite.addTests(loader.loadTestsFromTestCase(TestEmbeddingEnginePresets))

    runner = unittest.TextTestRunner(verbosity=2)
    result = runner.run(suite)

    sys.exit(0 if result.wasSuccessful() else 1)
