"""
tests/test_dialectic.py — Tests for the Dialectic Engine (M14).

Coverage:
    - DialecticEngine.ask() with mocked retrieval + LLM (happy path)
    - Empty retrieval returns "not enough information"
    - Sources are included in the response
    - Confidence calculation (mean of retrieval scores)
    - ask_with_history() passes history to LLM
    - ask() with no retrieval pipeline raises gracefully
    - DialecticResponse dataclass fields
    - prompts.format_memories() formatting
    - prompts.format_history() formatting
    - CortexPlugin.ask() delegation
    - POST /api/v1/ask HTTP endpoint (mock plugin)
    - POST /api/v1/ask returns 503 on engine error
    - confidence = 0 when no scores present
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

# ---------------------------------------------------------------------------
# Helpers / stubs
# ---------------------------------------------------------------------------


def _make_memory(
    subject: str = "user",
    predicate: str = "prefers",
    obj: str = "dark mode",
    category: str = "preferences",
    score: float = 0.85,
) -> Dict[str, Any]:
    return {
        "subject": subject,
        "predicate": predicate,
        "object_value": obj,
        "category": category,
        "score": score,
    }


@dataclass
class FakeRetrievalResult:
    """Minimal RetrievalResult stand-in."""
    items: List[Dict[str, Any]] = field(default_factory=list)


@dataclass
class FakeCompletionResponse:
    """Minimal CompletionResponse stand-in."""
    text: str = "This is the synthesised answer."


class FakeRetrievalEngine:
    """Mock RetrievalPipeline that returns configurable items."""

    def __init__(self, items: List[Dict[str, Any]]):
        self._items = items

    async def retrieve(
        self,
        query: str,
        owner_id: str = "test-owner",
        **kwargs: Any,
    ) -> FakeRetrievalResult:
        return FakeRetrievalResult(items=self._items)


class FakeLLMProvider:
    """Mock LLMProvider that records calls."""

    def __init__(self, answer: str = "Synthesised answer."):
        self._answer = answer
        self.calls: List[Any] = []

    async def complete(self, request: Any = None, **kwargs: Any) -> FakeCompletionResponse:
        self.calls.append(request or kwargs)
        return FakeCompletionResponse(text=self._answer)


class FakeConfig:
    """Minimal CortexConfig stand-in."""
    owner_id: str = "test-owner"
    extraction_model: str = "gpt-4.1-nano-2025-04-14"


# ---------------------------------------------------------------------------
# DialecticEngine unit tests
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_ask_happy_path():
    """ask() with mocked retrieval + LLM returns a DialecticResponse."""
    from cortex.dialectic.engine import DialecticEngine, DialecticResponse

    memories = [_make_memory(score=0.90), _make_memory(obj="light mode", score=0.80)]
    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine(memories),
        llm_provider=FakeLLMProvider("The user prefers dark mode."),
        config=FakeConfig(),
    )

    response = await engine.ask("What does the user prefer?", owner_id="test-owner")

    assert isinstance(response, DialecticResponse)
    assert "The user prefers dark mode." in response.answer
    assert len(response.sources) == 2
    assert response.confidence > 0.0


@pytest.mark.asyncio
async def test_ask_empty_retrieval_returns_no_info():
    """ask() with no retrieved memories returns the 'not enough information' message."""
    from cortex.dialectic.engine import DialecticEngine, _NO_INFO

    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine([]),
        llm_provider=FakeLLMProvider(),
        config=FakeConfig(),
    )

    response = await engine.ask("What does the user prefer?", owner_id="test-owner")

    assert response.answer == _NO_INFO
    assert response.sources == []
    assert response.confidence == 0.0


@pytest.mark.asyncio
async def test_ask_sources_in_response():
    """ask() includes the retrieved memories as sources."""
    from cortex.dialectic.engine import DialecticEngine

    memories = [
        _make_memory("user", "works-with", "Python", "engineering", 0.75),
        _make_memory("user", "prefers", "vim", "preferences", 0.65),
    ]
    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine(memories),
        llm_provider=FakeLLMProvider("The user works with Python and prefers vim."),
        config=FakeConfig(),
    )

    response = await engine.ask("What does the user work with?", owner_id="owner-1")

    assert len(response.sources) == 2
    subjects = [s.get("subject") for s in response.sources]
    assert "user" in subjects


@pytest.mark.asyncio
async def test_ask_confidence_calculation():
    """Confidence is the mean of retrieval scores, clamped to [0, 1]."""
    from cortex.dialectic.engine import DialecticEngine

    memories = [
        _make_memory(score=0.60),
        _make_memory(score=0.80),
        _make_memory(score=1.00),
    ]
    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine(memories),
        llm_provider=FakeLLMProvider("Answer."),
        config=FakeConfig(),
    )

    response = await engine.ask("What?", owner_id="owner-1")

    expected = (0.60 + 0.80 + 1.00) / 3
    assert abs(response.confidence - expected) < 1e-6


@pytest.mark.asyncio
async def test_confidence_zero_when_no_scores():
    """Confidence is 0.0 when memory dicts have no score or confidence field."""
    from cortex.dialectic.engine import DialecticEngine

    memories = [{"subject": "user", "predicate": "likes", "object_value": "tea", "category": "misc"}]
    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine(memories),
        llm_provider=FakeLLMProvider("Tea."),
        config=FakeConfig(),
    )

    response = await engine.ask("What does the user like?", owner_id="owner-1")

    assert response.confidence == 0.0


@pytest.mark.asyncio
async def test_ask_with_history_passes_history_to_llm():
    """ask_with_history() records the history and passes it to the LLM."""
    from cortex.dialectic.engine import DialecticEngine

    memories = [_make_memory(score=0.90)]
    llm = FakeLLMProvider("Follow-up answer.")
    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine(memories),
        llm_provider=llm,
        config=FakeConfig(),
    )

    history = [
        {"role": "user", "content": "Tell me about the user."},
        {"role": "assistant", "content": "The user prefers dark mode."},
    ]
    response = await engine.ask_with_history(
        query="What else do they prefer?",
        owner_id="owner-1",
        history=history,
    )

    assert "Follow-up answer." in response.answer
    # Verify the LLM was called (history formatted into user message)
    assert len(llm.calls) == 1
    # The call argument should contain history content
    call_arg = llm.calls[0]
    # call_arg is a CompletionRequest-like; check user field contains history text
    user_text = getattr(call_arg, "user", "") or str(call_arg)
    assert "Tell me about the user" in user_text


@pytest.mark.asyncio
async def test_ask_with_history_empty_retrieval():
    """ask_with_history() with no memories also returns no-info message."""
    from cortex.dialectic.engine import DialecticEngine, _NO_INFO

    engine = DialecticEngine(
        retrieval_engine=FakeRetrievalEngine([]),
        llm_provider=FakeLLMProvider(),
        config=FakeConfig(),
    )

    response = await engine.ask_with_history(
        query="Follow-up?",
        owner_id="owner-1",
        history=[{"role": "user", "content": "Hello."}],
    )

    assert response.answer == _NO_INFO
    assert response.sources == []


# ---------------------------------------------------------------------------
# prompts module tests
# ---------------------------------------------------------------------------


def test_format_memories_numbered_list():
    """format_memories() returns a numbered list with category annotations."""
    from cortex.dialectic.prompts import format_memories

    memories = [
        {"subject": "user", "predicate": "prefers", "object_value": "dark mode",
         "category": "preferences", "score": 0.90},
        {"subject": "user", "predicate": "works with", "object_value": "Python",
         "category": "engineering", "score": 0.75},
    ]
    result = format_memories(memories)

    assert "1." in result
    assert "2." in result
    assert "[preferences]" in result
    assert "[engineering]" in result
    assert "dark mode" in result
    assert "Python" in result


def test_format_memories_empty():
    """format_memories() with empty list returns the no-memories sentinel."""
    from cortex.dialectic.prompts import format_memories

    result = format_memories([])
    assert "no memories" in result.lower() or "no" in result.lower()


def test_format_history_transcript():
    """format_history() produces a readable transcript."""
    from cortex.dialectic.prompts import format_history

    history = [
        {"role": "user", "content": "Hello, who am I?"},
        {"role": "assistant", "content": "You are Andrew."},
    ]
    result = format_history(history)

    assert "User:" in result
    assert "Assistant:" in result
    assert "Hello, who am I?" in result
    assert "You are Andrew." in result


# ---------------------------------------------------------------------------
# CortexPlugin.ask() delegation
# ---------------------------------------------------------------------------


@pytest.mark.asyncio
async def test_plugin_ask_delegates_to_dialectic_engine():
    """CortexPlugin.ask() uses DialecticEngine and returns AskResult."""
    from cortex.dialectic.engine import DialecticResponse
    from cortex.api.plugin import AskResult

    fake_response = DialecticResponse(
        answer="The user prefers dark mode.",
        sources=[_make_memory()],
        confidence=0.85,
        reasoning="Retrieved 1 memory.",
    )

    with patch("cortex.dialectic.engine.DialecticEngine") as MockEngine:
        instance = MagicMock()
        instance.ask = AsyncMock(return_value=fake_response)
        MockEngine.return_value = instance

        plugin = MagicMock()
        plugin.retrieval = MagicMock()
        plugin.llm = MagicMock()
        plugin.config = FakeConfig()

        # Call the real ask() method via the plugin class with our mock objects
        from cortex.api.plugin import CortexPlugin
        result = await CortexPlugin.ask(
            plugin,
            query="What does the user prefer?",
            owner_id="owner-1",
        )

    assert isinstance(result, AskResult)
    assert result.answer == "The user prefers dark mode."
    assert result.confidence == 0.85
    assert result.ok is True


@pytest.mark.asyncio
async def test_plugin_ask_returns_error_when_retrieval_unavailable():
    """CortexPlugin.ask() returns ok=False when retrieval is None."""
    from cortex.api.plugin import CortexPlugin, AskResult

    plugin = MagicMock()
    plugin.retrieval = None
    plugin.llm = MagicMock()
    plugin.config = FakeConfig()

    result = await CortexPlugin.ask(plugin, query="What?", owner_id="owner-1")

    assert isinstance(result, AskResult)
    assert result.ok is False
    assert len(result.errors) > 0


# ---------------------------------------------------------------------------
# HTTP endpoint tests
# ---------------------------------------------------------------------------


def _make_fastapi_test_client():
    """Create a FastAPI test client with a mocked CortexPlugin."""
    try:
        from fastapi.testclient import TestClient
        from cortex.api.http_server import create_app
    except ImportError:
        return None, None

    from cortex.api.plugin import AskResult

    mock_result = AskResult(
        answer="The user likes Python.",
        sources=[_make_memory()],
        confidence=0.88,
        reasoning="1 memory retrieved.",
        ok=True,
    )

    mock_plugin = MagicMock()
    mock_plugin.ask = AsyncMock(return_value=mock_result)
    mock_plugin.config = FakeConfig()

    app = create_app()

    # Override the plugin dependency
    from cortex.api.http_server import get_plugin
    app.dependency_overrides[get_plugin] = lambda: mock_plugin

    return TestClient(app), mock_plugin


def _make_mock_plugin_for_http(ask_result: Any) -> MagicMock:
    """Build a mock CortexPlugin with .ask() returning ask_result."""
    mock_plugin = MagicMock()
    mock_plugin.ask = AsyncMock(return_value=ask_result)
    mock_plugin.config = FakeConfig()
    mock_plugin.storage = None  # Skip storage.initialize()
    return mock_plugin


def test_http_ask_endpoint_returns_answer():
    """POST /api/v1/ask returns answer, sources, confidence.

    Uses FastAPI TestClient (sync) with lifespan — the correct way to test
    http_server endpoints that use the lifespan-initialised plugin.
    """
    try:
        from fastapi.testclient import TestClient
        from cortex.api.http_server import create_app
        from cortex.api.plugin import AskResult
    except ImportError:
        pytest.skip("fastapi not installed")

    mock_result = AskResult(
        answer="The user likes Python.",
        sources=[_make_memory()],
        confidence=0.88,
        reasoning="1 memory retrieved.",
        ok=True,
    )
    mock_plugin = _make_mock_plugin_for_http(mock_result)

    app = create_app()

    # Patch must wrap TestClient.__enter__ so lifespan picks up the mock
    with patch("cortex.api.plugin.CortexPlugin") as MockClass:
        MockClass.return_value = mock_plugin
        with TestClient(app, raise_server_exceptions=True) as tc:
            resp = tc.post(
                "/api/v1/ask",
                json={"query": "What does the user like?", "owner_id": "owner-1", "history": []},
            )

    assert resp.status_code == 200
    data = resp.json()
    assert data["answer"] == "The user likes Python."
    assert data["confidence"] == pytest.approx(0.88, abs=0.01)
    assert data["ok"] is True


def test_http_ask_endpoint_503_on_engine_error():
    """POST /api/v1/ask returns 503 when the engine reports ok=False."""
    try:
        from fastapi.testclient import TestClient
        from cortex.api.http_server import create_app
        from cortex.api.plugin import AskResult
    except ImportError:
        pytest.skip("fastapi not installed")

    mock_result = AskResult(
        answer="Error occurred.",
        errors=["Dialectic requires retrieval pipeline and LLM — one or both unavailable"],
        ok=False,
    )
    mock_plugin = _make_mock_plugin_for_http(mock_result)

    app = create_app()

    with patch("cortex.api.plugin.CortexPlugin") as MockClass:
        MockClass.return_value = mock_plugin
        with TestClient(app, raise_server_exceptions=False) as tc:
            resp = tc.post("/api/v1/ask", json={"query": "What?"})

    assert resp.status_code == 503
