#!/usr/bin/env python3
"""
Tests for Story 5.02: IntentClassifier — Gemini Prompt + Parser
AIVA RLM Nexus PRD v2 — Track A, Module 5 (Intent + Routing)

Black box tests (BB1-BB5): public behaviour from the outside.
White box tests (WB1-WB4): internal invariants and structural properties.
Package test (PKG1): __init__.py export.
No-SQLite test: confirm sqlite3 never imported.

All Gemini calls are mocked — no real API usage.
"""

import asyncio
import inspect
import json
import sys

import pytest

sys.path.insert(0, "/mnt/e/genesis-system")

from unittest.mock import MagicMock, patch
from core.intent import IntentClassifier, IntentSignal, IntentType


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _sync_client(text: str) -> MagicMock:
    """
    Build a synchronous mock Gemini client.
    IntentClassifier.classify() falls back to client.generate_content()
    when generate_content_async() raises AttributeError.
    """
    client = MagicMock()
    # Make the async method raise AttributeError so the sync fallback is used
    client.generate_content_async = MagicMock(side_effect=AttributeError("no async"))
    response = MagicMock()
    response.text = text
    client.generate_content.return_value = response
    return client


def _async_client(text: str) -> MagicMock:
    """Build an async mock Gemini client that returns `text` from generate_content_async."""
    client = MagicMock()
    response = MagicMock()
    response.text = text

    async def _fake_async(prompt):
        return response

    client.generate_content_async = _fake_async
    return client


def _run(coro):
    """Run a coroutine synchronously for test purposes."""
    return asyncio.get_event_loop().run_until_complete(coro)


def _valid_json(
    intent_type: str = "book_job",
    confidence: float = 0.92,
    entities: dict = None,
    requires_swarm: bool = True,
) -> str:
    return json.dumps({
        "intent_type": intent_type,
        "confidence": confidence,
        "extracted_entities": entities or {"service": "plumbing"},
        "requires_swarm": requires_swarm,
        "reasoning": "test",
    })


# ===========================================================================
# BB1: "I need a plumber" → BOOK_JOB, confidence > 0.8
# ===========================================================================

class TestBB1_BookJob:
    """BB1: Valid JSON for a plumbing request → BOOK_JOB with high confidence."""

    def test_intent_type_is_book_job(self):
        client = _sync_client(_valid_json("book_job", confidence=0.92))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I need a plumber", session_id="s1"))
        assert signal.intent_type is IntentType.BOOK_JOB

    def test_confidence_above_0_8(self):
        client = _sync_client(_valid_json("book_job", confidence=0.92))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I need a plumber", session_id="s1"))
        assert signal.confidence > 0.8

    def test_returns_intent_signal(self):
        client = _sync_client(_valid_json("book_job", confidence=0.92))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I need a plumber", session_id="s1"))
        assert isinstance(signal, IntentSignal)

    def test_session_id_propagated(self):
        client = _sync_client(_valid_json("book_job", confidence=0.92))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I need a plumber", session_id="sess-42"))
        assert signal.session_id == "sess-42"

    def test_utterance_propagated(self):
        client = _sync_client(_valid_json("book_job", confidence=0.92))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I need a plumber", session_id="s1"))
        assert signal.utterance == "I need a plumber"


# ===========================================================================
# BB2: Malformed JSON → UNKNOWN (no crash)
# ===========================================================================

class TestBB2_MalformedJSON:
    """BB2: When Gemini returns malformed JSON, classifier falls back to UNKNOWN."""

    def test_malformed_json_returns_unknown(self):
        client = _sync_client("{this is not json}")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I need a plumber", session_id="s1"))
        assert signal.intent_type is IntentType.UNKNOWN

    def test_malformed_json_no_crash(self):
        client = _sync_client("INVALID_GARBAGE_@#$!")
        classifier = IntentClassifier(client)
        # Must not raise
        signal = _run(classifier.classify("anything", session_id="s1"))
        assert signal is not None

    def test_malformed_confidence_is_zero(self):
        client = _sync_client("{broken}")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("anything", session_id="s1"))
        assert signal.confidence == 0.0

    def test_malformed_requires_swarm_is_false(self):
        client = _sync_client("not json at all")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("anything", session_id="s1"))
        # UNKNOWN is not in SWARM_REQUIRED_INTENTS, so remains False
        assert signal.requires_swarm is False


# ===========================================================================
# BB3: "What are your hours?" → ANSWER_FAQ
# ===========================================================================

class TestBB3_AnswerFaq:
    """BB3: FAQ utterance returns ANSWER_FAQ intent."""

    def test_intent_type_is_answer_faq(self):
        client = _sync_client(_valid_json("answer_faq", confidence=0.88, requires_swarm=False))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("What are your hours?", session_id="s2"))
        assert signal.intent_type is IntentType.ANSWER_FAQ

    def test_requires_swarm_is_false_for_faq(self):
        client = _sync_client(_valid_json("answer_faq", confidence=0.88, requires_swarm=False))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("What are your hours?", session_id="s2"))
        assert signal.requires_swarm is False

    def test_confidence_propagated(self):
        client = _sync_client(_valid_json("answer_faq", confidence=0.88, requires_swarm=False))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("What are your hours?", session_id="s2"))
        assert abs(signal.confidence - 0.88) < 1e-9


# ===========================================================================
# BB4: Gemini returns confidence=1.5 → clamped to 1.0
# ===========================================================================

class TestBB4_ConfidenceClamp_High:
    """BB4: Confidence values above 1.0 are clamped to exactly 1.0."""

    def test_confidence_1_5_clamped_to_1_0(self):
        client = _sync_client(_valid_json("qualify_lead", confidence=1.5))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Can I qualify?", session_id="s3"))
        assert signal.confidence == 1.0

    def test_confidence_99_clamped_to_1_0(self):
        client = _sync_client(_valid_json("qualify_lead", confidence=99.0))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Can I qualify?", session_id="s3"))
        assert signal.confidence == 1.0

    def test_confidence_1_0_exactly_is_1_0(self):
        client = _sync_client(_valid_json("qualify_lead", confidence=1.0))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Can I qualify?", session_id="s3"))
        assert signal.confidence == 1.0


# ===========================================================================
# BB5: Gemini returns empty string → UNKNOWN
# ===========================================================================

class TestBB5_EmptyResponse:
    """BB5: Empty string from Gemini → falls back to UNKNOWN."""

    def test_empty_string_returns_unknown(self):
        client = _sync_client("")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Hello", session_id="s4"))
        assert signal.intent_type is IntentType.UNKNOWN

    def test_whitespace_only_returns_unknown(self):
        client = _sync_client("   \n\t  ")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Hello", session_id="s4"))
        assert signal.intent_type is IntentType.UNKNOWN

    def test_empty_response_no_crash(self):
        client = _sync_client("")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Hello", session_id="s4"))
        assert signal is not None

    def test_raw_gemini_response_stored_even_when_empty(self):
        client = _sync_client("")
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("Hello", session_id="s4"))
        # raw_gemini_response should be the empty string (not None)
        assert signal.raw_gemini_response == ""


# ===========================================================================
# WB1: _build_prompt contains utterance and context strings
# ===========================================================================

class TestWB1_BuildPrompt:
    """WB1: _build_prompt must embed both utterance and context into the output."""

    def _classifier(self) -> IntentClassifier:
        return IntentClassifier(MagicMock())

    def test_prompt_contains_utterance(self):
        clf = self._classifier()
        prompt = clf._build_prompt("book a plumber", "previous turn")
        assert "book a plumber" in prompt

    def test_prompt_contains_context(self):
        clf = self._classifier()
        prompt = clf._build_prompt("utterance", "unique_context_xyz")
        assert "unique_context_xyz" in prompt

    def test_prompt_is_string(self):
        clf = self._classifier()
        prompt = clf._build_prompt("u", "c")
        assert isinstance(prompt, str)

    def test_prompt_contains_valid_intent_types(self):
        clf = self._classifier()
        prompt = clf._build_prompt("u", "c")
        # Spot-check a few intent type strings appear in the prompt
        assert "book_job" in prompt
        assert "answer_faq" in prompt
        assert "unknown" in prompt


# ===========================================================================
# WB2: json.loads is used (not eval)
# ===========================================================================

class TestWB2_JsonLoads:
    """WB2: _parse_response uses json.loads, not eval()."""

    def test_json_loads_in_source(self):
        import core.intent.intent_classifier as mod
        source = inspect.getsource(mod)
        assert "json.loads" in source, "_parse_response must use json.loads()"

    def test_eval_not_in_source(self):
        import core.intent.intent_classifier as mod
        source = inspect.getsource(mod)
        # Ensure raw eval() is not used for JSON parsing
        assert "eval(" not in source, "eval() must not be used for JSON parsing"


# ===========================================================================
# WB3: requires_swarm forced True for BOOK_JOB even if Gemini says false
# ===========================================================================

class TestWB3_RequiresSwarmForced:
    """WB3: BOOK_JOB and TASK_DISPATCH always have requires_swarm=True."""

    def test_book_job_swarm_forced_true_when_gemini_says_false(self):
        client = _sync_client(_valid_json("book_job", requires_swarm=False))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("book a job", session_id="s5"))
        assert signal.requires_swarm is True, (
            "BOOK_JOB must have requires_swarm=True regardless of Gemini response"
        )

    def test_task_dispatch_swarm_forced_true_when_gemini_says_false(self):
        client = _sync_client(_valid_json("task_dispatch", requires_swarm=False))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("dispatch a task", session_id="s6"))
        assert signal.requires_swarm is True, (
            "TASK_DISPATCH must have requires_swarm=True regardless of Gemini response"
        )

    def test_answer_faq_swarm_not_forced(self):
        client = _sync_client(_valid_json("answer_faq", requires_swarm=False))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("what are your hours", session_id="s7"))
        # ANSWER_FAQ is NOT in SWARM_REQUIRED_INTENTS, so Gemini's value is kept
        assert signal.requires_swarm is False


# ===========================================================================
# WB4: _clamp_confidence boundary tests
# ===========================================================================

class TestWB4_ClampConfidence:
    """WB4: _clamp_confidence handles edge cases correctly."""

    def _clf(self) -> IntentClassifier:
        return IntentClassifier(MagicMock())

    def test_clamp_negative_returns_zero(self):
        assert self._clf()._clamp_confidence(-0.5) == 0.0

    def test_clamp_large_returns_one(self):
        assert self._clf()._clamp_confidence(2.0) == 1.0

    def test_clamp_zero_returns_zero(self):
        assert self._clf()._clamp_confidence(0.0) == 0.0

    def test_clamp_one_returns_one(self):
        assert self._clf()._clamp_confidence(1.0) == 1.0

    def test_clamp_mid_value_unchanged(self):
        assert abs(self._clf()._clamp_confidence(0.75) - 0.75) < 1e-9

    def test_clamp_non_numeric_returns_zero(self):
        assert self._clf()._clamp_confidence("high") == 0.0

    def test_clamp_none_returns_zero(self):
        assert self._clf()._clamp_confidence(None) == 0.0

    def test_clamp_returns_float(self):
        result = self._clf()._clamp_confidence(0.5)
        assert isinstance(result, float)


# ===========================================================================
# PKG1: `from core.intent import IntentClassifier` works
# ===========================================================================

class TestPKG1_PackageExport:
    """PKG1: IntentClassifier is importable from core.intent package."""

    def test_intent_classifier_importable(self):
        from core.intent import IntentClassifier as IC
        assert IC is IntentClassifier

    def test_intent_classifier_in_all(self):
        import core.intent as pkg
        assert "IntentClassifier" in pkg.__all__

    def test_all_three_symbols_exported(self):
        import core.intent as pkg
        assert "IntentType" in pkg.__all__
        assert "IntentSignal" in pkg.__all__
        assert "IntentClassifier" in pkg.__all__

    def test_intent_classifier_is_class(self):
        from core.intent import IntentClassifier as IC
        assert isinstance(IC, type)


# ===========================================================================
# No-SQLite
# ===========================================================================

class TestNoSQLite:
    """Confirm sqlite3 is never imported in intent_classifier.py."""

    def test_no_sqlite3_in_classifier(self):
        import core.intent.intent_classifier as mod
        source = inspect.getsource(mod)
        assert "import sqlite3" not in source, (
            "sqlite3 is FORBIDDEN in intent_classifier.py (Rule 7)"
        )

    def test_no_sqlite3_in_init(self):
        import core.intent as pkg
        source = inspect.getsource(pkg)
        assert "import sqlite3" not in source, (
            "sqlite3 is FORBIDDEN in core/intent/__init__.py (Rule 7)"
        )


# ===========================================================================
# Additional: async client path
# ===========================================================================

class TestAsyncClientPath:
    """Verify that the async code path also works correctly."""

    def test_async_client_returns_intent_signal(self):
        client = _async_client(_valid_json("qualify_lead", confidence=0.85))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I want to qualify", session_id="async-1"))
        assert isinstance(signal, IntentSignal)
        assert signal.intent_type is IntentType.QUALIFY_LEAD

    def test_async_client_confidence_correct(self):
        client = _async_client(_valid_json("qualify_lead", confidence=0.85))
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I want to qualify", session_id="async-1"))
        assert abs(signal.confidence - 0.85) < 1e-9

    def test_async_client_markdown_fenced_json(self):
        """Gemini sometimes wraps JSON in markdown code fences — should still parse."""
        fenced = "```json\n" + _valid_json("escalate_human", confidence=0.7) + "\n```"
        client = _async_client(fenced)
        classifier = IntentClassifier(client)
        signal = _run(classifier.classify("I want to speak to someone", session_id="async-2"))
        assert signal.intent_type is IntentType.ESCALATE_HUMAN


# ===========================================================================
# GEMINI_MODEL constant
# ===========================================================================

class TestGeminiModel:
    """Verify the correct Gemini model is specified (Flash, not Pro)."""

    def test_model_is_flash(self):
        assert IntentClassifier.GEMINI_MODEL == "gemini-2.0-flash", (
            "Must use gemini-2.0-flash (speed required), not Pro/Ultra"
        )


# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------

if __name__ == "__main__":
    result = pytest.main([__file__, "-v", "--tb=short"])
    sys.exit(result)
