"""
tests/test_http_admin_explain.py — Tests for admin + retrieval/explain endpoints.

Covers:
    POST   /api/v1/admin/backup        (#64)
    POST   /api/v1/admin/restore       (#64)
    GET    /api/v1/admin/stats         (#64)
    POST   /api/v1/retrieve/explain    (#65)

Strategy: same as test_http_server.py / test_http_endpoints.py — mock
CortexPlugin via patch so the lifespan startup injects our mock, then
use FastAPI TestClient for synchronous assertions.
"""
from __future__ import annotations

import os
import tempfile
import sqlite3
from contextlib import contextmanager
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock

import pytest

# ---------------------------------------------------------------------------
# Skip gracefully if FastAPI / httpx not installed
# ---------------------------------------------------------------------------

pytest.importorskip("fastapi", reason="fastapi not installed")
pytest.importorskip("httpx", reason="httpx not installed")

from fastapi.testclient import TestClient  # noqa: E402


# ---------------------------------------------------------------------------
# Stub types
# ---------------------------------------------------------------------------

@dataclass
class _Cornerstone:
    memory_id: str = "cs-1"
    content: str = "You are Eva."
    label: str = "identity"
    sealed: bool = True
    drift_score: float = 0.0
    injection_order: int = 0


@dataclass
class _RetrievalItem:
    item_type: str = "claim"
    item_id: str = "claim-1"
    content: str = "Test content"
    score: float = 0.8
    provenance: Optional[str] = None


@dataclass
class _RetrievalResult:
    items: List[Any] = field(default_factory=lambda: [_RetrievalItem()])
    retrieval_id: str = "ret-explain-1"
    mode: str = "lightweight"
    latency_ms: int = 42
    tokens_used: int = 120


@dataclass
class _FTSResult:
    item_type: str = "claim"
    item_id: str = "claim-fts-1"
    content: str = "FTS result"
    rank: float = -1.5


@dataclass
class _VectorResult:
    item_type: str = "claim"
    item_id: str = "claim-vec-1"
    content: str = "Vector result"
    score: float = 0.85


# ---------------------------------------------------------------------------
# Mock plugin factory
# ---------------------------------------------------------------------------

def _make_mock_plugin(**overrides):
    """Return a MagicMock that looks like CortexPlugin."""
    plugin = MagicMock()

    # Config
    plugin.config = MagicMock()
    plugin.config.owner_id = "test-owner"
    plugin.config.storage_db_path = overrides.get("storage_db_path", "cortex.db")
    plugin.config.retrieval_token_budget = overrides.get("retrieval_token_budget", 3000)
    plugin.config.db_path_resolved = MagicMock(
        return_value=overrides.get("storage_db_path", "cortex.db")
    )

    # Storage
    plugin.storage = MagicMock()
    plugin.storage.initialize = AsyncMock()
    plugin.storage.count_claims = AsyncMock(return_value=overrides.get("count_claims", 10))
    plugin.storage.count_sessions = AsyncMock(return_value=overrides.get("count_sessions", 5))
    plugin.storage.close = AsyncMock()
    plugin.storage._conn = None  # no real DB connection

    # Plugin methods
    plugin.get_cornerstones = AsyncMock(
        return_value=overrides.get("cornerstones", [_Cornerstone()])
    )
    plugin.retrieve = AsyncMock(
        return_value=overrides.get("retrieval_result", _RetrievalResult())
    )

    # Optional sub-components
    plugin.llm = None
    plugin.token_budget_manager = None
    plugin.cornerstone_guardian = None

    return plugin


@contextmanager
def _make_client(api_key: Optional[str] = None, **plugin_overrides):
    """Yield (TestClient, mock_plugin) with CortexPlugin patched."""
    from cortex.api.http_server import create_app

    mock_plugin = _make_mock_plugin(**plugin_overrides)
    app = create_app(api_key=api_key)

    with patch("cortex.api.plugin.CortexPlugin") as MockClass:
        MockClass.return_value = mock_plugin
        with TestClient(app, raise_server_exceptions=True) as tc:
            tc._mock_plugin = mock_plugin
            yield tc, mock_plugin


@pytest.fixture
def client():
    with _make_client() as (tc, _):
        yield tc


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

def _make_temp_db() -> str:
    """Create a minimal SQLite DB in a temp file and return its path."""
    fd, path = tempfile.mkstemp(suffix=".db")
    os.close(fd)
    conn = sqlite3.connect(path)
    conn.execute(
        "CREATE TABLE claims (claim_id TEXT PRIMARY KEY, content TEXT, claim_type TEXT, "
        "confidence REAL, created_at TEXT)"
    )
    conn.execute(
        "INSERT INTO claims VALUES ('c1', 'Hello world', 'fact', 0.9, '2026-01-01T00:00:00Z')"
    )
    conn.execute(
        "INSERT INTO claims VALUES ('c2', 'Test claim', 'preference', 0.7, '2026-01-02T00:00:00Z')"
    )
    conn.commit()
    conn.close()
    return path


# ===========================================================================
# Tests — POST /api/v1/admin/backup (#64)
# ===========================================================================

class TestAdminBackup:
    def test_backup_returns_200(self, tmp_path):
        """Backup with a real DB file should return path, size, timestamp."""
        src = _make_temp_db()
        src_dir = os.path.dirname(os.path.abspath(src))

        try:
            with _make_client(storage_db_path=src) as (tc, mock_plugin):
                mock_plugin.config.storage_db_path = src
                mock_plugin.config.db_path_resolved.return_value = src

                resp = tc.post("/api/v1/admin/backup", json={"output_path": "backup.db"})
        finally:
            os.unlink(src)
            out = os.path.join(src_dir, "backup.db")
            if os.path.exists(out):
                os.unlink(out)

        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["path"] == os.path.join(src_dir, "backup.db")
        assert data["size_bytes"] > 0
        assert "T" in data["timestamp"]  # ISO 8601

    def test_backup_creates_file(self, tmp_path):
        """Backup file should actually exist on disk after call."""
        src = _make_temp_db()
        src_dir = os.path.dirname(os.path.abspath(src))

        try:
            with _make_client(storage_db_path=src) as (tc, mock_plugin):
                mock_plugin.config.storage_db_path = src
                mock_plugin.config.db_path_resolved.return_value = src
                tc.post("/api/v1/admin/backup", json={"output_path": "output.db"})
        finally:
            os.unlink(src)

        out = os.path.join(src_dir, "output.db")
        assert os.path.exists(out), "Backup file not created"
        if os.path.exists(out):
            os.unlink(out)

    def test_backup_default_output_path(self, tmp_path):
        """When output_path is omitted, defaults to <src>.bak."""
        src = _make_temp_db()
        expected_out = src + ".bak"

        try:
            with _make_client(storage_db_path=src) as (tc, mock_plugin):
                mock_plugin.config.storage_db_path = src
                mock_plugin.config.db_path_resolved.return_value = src
                resp = tc.post("/api/v1/admin/backup", json={})
        finally:
            os.unlink(src)
            if os.path.exists(expected_out):
                os.unlink(expected_out)

        assert resp.status_code == 200
        assert resp.json()["path"] == expected_out

    def test_backup_no_storage_returns_503(self):
        """When storage is unavailable, expect 503."""
        with _make_client() as (tc, mock_plugin):
            mock_plugin.storage = None
            resp = tc.post("/api/v1/admin/backup", json={})
        assert resp.status_code == 503

    def test_backup_bad_src_returns_500(self):
        """When the DB path doesn't exist, shutil.copy2 raises → 500."""
        with _make_client() as (tc, mock_plugin):
            mock_plugin.config.storage_db_path = "/nonexistent/path/cortex.db"
            mock_plugin.config.db_path_resolved.return_value = "/nonexistent/path/cortex.db"
            resp = tc.post("/api/v1/admin/backup", json={"output_path": "out.db"})
        assert resp.status_code == 500


# ===========================================================================
# Tests — POST /api/v1/admin/restore (#64)
# ===========================================================================

class TestAdminRestore:
    def test_restore_returns_restored_true(self, tmp_path):
        """Restore from a valid backup DB should return restored=True and a count."""
        # Create target DB path first, then place backup in same directory
        target = str(tmp_path / "target.db")
        # Create backup in the same directory as target (path traversal protection)
        backup_name = "backup-restore-test.db"
        backup_path = str(tmp_path / backup_name)
        # Create the backup DB
        conn = sqlite3.connect(backup_path)
        conn.execute(
            "CREATE TABLE claims (claim_id TEXT PRIMARY KEY, content TEXT, claim_type TEXT, "
            "confidence REAL, created_at TEXT)"
        )
        conn.execute("INSERT INTO claims VALUES ('c1', 'Hello world', 'fact', 0.9, '2026-01-01T00:00:00Z')")
        conn.execute("INSERT INTO claims VALUES ('c2', 'Test claim', 'preference', 0.7, '2026-01-02T00:00:00Z')")
        conn.commit()
        conn.close()

        try:
            with _make_client(storage_db_path=target) as (tc, mock_plugin):
                mock_plugin.config.storage_db_path = target
                mock_plugin.config.db_path_resolved.return_value = target
                resp = tc.post("/api/v1/admin/restore", json={"backup_path": backup_name})
        finally:
            if os.path.exists(backup_path):
                os.unlink(backup_path)
            if os.path.exists(target):
                os.unlink(target)

        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert data["restored"] is True
        assert data["claims_count"] == 2

    def test_restore_copies_backup_file(self, tmp_path):
        """After restore the target DB should have the backup's data."""
        target = str(tmp_path / "target.db")
        backup_name = "backup-copy-test.db"
        backup_path = str(tmp_path / backup_name)
        # Create the backup DB in the same dir as target
        conn = sqlite3.connect(backup_path)
        conn.execute(
            "CREATE TABLE claims (claim_id TEXT PRIMARY KEY, content TEXT, claim_type TEXT, "
            "confidence REAL, created_at TEXT)"
        )
        conn.execute("INSERT INTO claims VALUES ('c1', 'Hello world', 'fact', 0.9, '2026-01-01T00:00:00Z')")
        conn.execute("INSERT INTO claims VALUES ('c2', 'Test claim', 'preference', 0.7, '2026-01-02T00:00:00Z')")
        conn.commit()
        conn.close()

        try:
            with _make_client(storage_db_path=target) as (tc, mock_plugin):
                mock_plugin.config.storage_db_path = target
                mock_plugin.config.db_path_resolved.return_value = target
                tc.post("/api/v1/admin/restore", json={"backup_path": backup_name})
        finally:
            if os.path.exists(backup_path):
                os.unlink(backup_path)

        # target should now be a valid DB with 2 claims
        conn = sqlite3.connect(target)
        count = conn.execute("SELECT COUNT(*) FROM claims").fetchone()[0]
        conn.close()
        os.unlink(target)
        assert count == 2

    def test_restore_missing_backup_returns_404(self):
        """Non-existent backup path → 404."""
        with _make_client() as (tc, _):
            resp = tc.post(
                "/api/v1/admin/restore",
                json={"backup_path": "nonexistent.db"},
            )
        assert resp.status_code == 404

    def test_restore_missing_body_field_returns_422(self, client):
        """Missing backup_path field → 422 Unprocessable Entity."""
        resp = client.post("/api/v1/admin/restore", json={})
        assert resp.status_code == 422


# ===========================================================================
# Tests — GET /api/v1/admin/stats (#64)
# ===========================================================================

class TestAdminStats:
    def test_stats_returns_all_top_level_keys(self, client):
        """Response must contain all six top-level stat sections."""
        resp = client.get("/api/v1/admin/stats")
        assert resp.status_code == 200, resp.text
        data = resp.json()
        for key in ("claims", "cornerstones", "entities", "sessions", "brain_graph", "storage"):
            assert key in data, f"Missing key: {key}"

    def test_claims_has_required_fields(self, client):
        resp = client.get("/api/v1/admin/stats")
        claims = resp.json()["claims"]
        assert "total" in claims
        assert "by_type" in claims
        assert "by_confidence" in claims

    def test_cornerstones_has_required_fields(self, client):
        resp = client.get("/api/v1/admin/stats")
        cs = resp.json()["cornerstones"]
        assert "count" in cs
        assert "drift_scores" in cs
        assert isinstance(cs["drift_scores"], list)

    def test_entities_has_count(self, client):
        resp = client.get("/api/v1/admin/stats")
        assert "count" in resp.json()["entities"]

    def test_sessions_has_total_and_active(self, client):
        resp = client.get("/api/v1/admin/stats")
        sessions = resp.json()["sessions"]
        assert "total" in sessions
        assert "active" in sessions

    def test_brain_graph_has_nodes_and_edges(self, client):
        resp = client.get("/api/v1/admin/stats")
        bg = resp.json()["brain_graph"]
        assert "nodes" in bg
        assert "edges" in bg

    def test_storage_has_db_size_and_embedding_count(self, client):
        resp = client.get("/api/v1/admin/stats")
        storage = resp.json()["storage"]
        assert "db_size_bytes" in storage
        assert "embedding_count" in storage

    def test_cornerstone_count_reflects_get_cornerstones(self):
        """cornerstone count should equal the mocked list length."""
        cs_list = [_Cornerstone(), _Cornerstone(memory_id="cs-2")]
        with _make_client(cornerstones=cs_list) as (tc, _):
            resp = tc.get("/api/v1/admin/stats")
        assert resp.json()["cornerstones"]["count"] == 2

    def test_drift_scores_extracted(self):
        """drift_scores list should contain each cornerstone's drift_score."""
        cs_list = [
            _Cornerstone(drift_score=0.1),
            _Cornerstone(memory_id="cs-2", drift_score=0.5),
        ]
        with _make_client(cornerstones=cs_list) as (tc, _):
            resp = tc.get("/api/v1/admin/stats")
        drift = resp.json()["cornerstones"]["drift_scores"]
        assert 0.1 in drift
        assert 0.5 in drift

    def test_stats_no_storage(self):
        """When storage is None, errors list is populated."""
        with _make_client() as (tc, mock_plugin):
            mock_plugin.storage = None
            resp = tc.get("/api/v1/admin/stats")
        assert resp.status_code == 200
        # Should still return all keys (with zeroed values)
        data = resp.json()
        for key in ("claims", "cornerstones", "entities", "sessions", "brain_graph", "storage"):
            assert key in data


# ===========================================================================
# Tests — POST /api/v1/retrieve/explain (#65)
# ===========================================================================

class TestRetrieveExplain:
    """Tests for POST /api/v1/retrieve/explain."""

    def _patch_pipeline(self, mock_plugin, bm25_results=None, vector_results=None):
        """
        Patch RetrievalPipeline on the mock plugin's storage so the
        endpoint can instantiate it without a real DB.
        """
        bm25_results = bm25_results or [_FTSResult()]
        vector_results = vector_results or [_VectorResult()]
        return bm25_results, vector_results

    def test_explain_returns_200(self):
        """Basic happy path — should return results + explanation."""
        with _make_client() as (tc, mock_plugin):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[_FTSResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[_VectorResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult()),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "What is my name?", "limit": 5},
                )
        assert resp.status_code == 200, resp.text
        data = resp.json()
        assert "results" in data
        assert "explanation" in data

    def test_explain_has_explanation_fields(self):
        """Explanation dict must contain all required keys."""
        with _make_client() as (tc, mock_plugin):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[_FTSResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[_VectorResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult()),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "test query"},
                )
        explanation = resp.json()["explanation"]
        assert "strategy" in explanation, "Missing 'strategy'"
        assert "fts_matches" in explanation, "Missing 'fts_matches'"
        assert "vector_matches" in explanation, "Missing 'vector_matches'"
        assert "rrf_scores" in explanation, "Missing 'rrf_scores'"
        assert "cornerstone_injected" in explanation, "Missing 'cornerstone_injected'"
        assert "token_budget" in explanation, "Missing 'token_budget'"

    def test_explain_token_budget_fields(self):
        """token_budget sub-dict must have total, used, remaining."""
        with _make_client() as (tc, _):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult(items=[], tokens_used=0)),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "budget test"},
                )
        budget = resp.json()["explanation"]["token_budget"]
        assert "total" in budget
        assert "used" in budget
        assert "remaining" in budget
        assert isinstance(budget["total"], int)
        assert isinstance(budget["used"], int)
        assert isinstance(budget["remaining"], int)

    def test_explain_fts_vector_counts(self):
        """fts_matches and vector_matches should reflect actual result counts."""
        bm25 = [_FTSResult(), _FTSResult(item_id="claim-fts-2")]
        vec = [_VectorResult()]

        with _make_client() as (tc, _):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=bm25),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=vec),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult()),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "count test"},
                )
        expl = resp.json()["explanation"]
        assert expl["fts_matches"] == 2
        assert expl["vector_matches"] == 1

    def test_explain_rrf_scores_list(self):
        """rrf_scores should be a list of {item_id, score} dicts."""
        with _make_client() as (tc, _):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[_FTSResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[_VectorResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult()),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "rrf test"},
                )
        rrf = resp.json()["explanation"]["rrf_scores"]
        assert isinstance(rrf, list)
        if rrf:
            assert "item_id" in rrf[0]
            assert "score" in rrf[0]

    def test_explain_missing_query_returns_422(self, client):
        """Missing required 'query' field → 422."""
        resp = client.post("/api/v1/retrieve/explain", json={"limit": 5})
        assert resp.status_code == 422

    def test_explain_results_are_serialised(self):
        """results list should contain dicts (not raw dataclass objects)."""
        with _make_client() as (tc, _):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[_FTSResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[_VectorResult()]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult()),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "serialise test"},
                )
        data = resp.json()
        assert isinstance(data["results"], list)
        if data["results"]:
            assert isinstance(data["results"][0], dict)
            assert "content" in data["results"][0]

    def test_explain_strategy_field_is_string(self):
        """strategy in explanation should be a string like 'lightweight'."""
        with _make_client() as (tc, _):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult(mode="lightweight")),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "strategy test"},
                )
        assert isinstance(resp.json()["explanation"]["strategy"], str)

    def test_explain_cornerstone_injected_bool(self):
        """cornerstone_injected should be a boolean."""
        with _make_client() as (tc, _):
            with patch(
                "cortex.core.retrieval.RetrievalPipeline._bm25_search",
                new=AsyncMock(return_value=[]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline._vector_search",
                new=AsyncMock(return_value=[]),
            ), patch(
                "cortex.core.retrieval.RetrievalPipeline.retrieve",
                new=AsyncMock(return_value=_RetrievalResult()),
            ):
                resp = tc.post(
                    "/api/v1/retrieve/explain",
                    json={"query": "cs test"},
                )
        assert isinstance(resp.json()["explanation"]["cornerstone_injected"], bool)


# ===========================================================================
# Tests — Endpoint registration
# ===========================================================================

class TestAdminExplainRegistration:
    def test_admin_backup_registered(self, client):
        openapi = client.get("/openapi.json").json()
        paths = openapi["paths"]
        assert "/api/v1/admin/backup" in paths

    def test_admin_restore_registered(self, client):
        openapi = client.get("/openapi.json").json()
        assert "/api/v1/admin/restore" in openapi["paths"]

    def test_admin_stats_registered(self, client):
        openapi = client.get("/openapi.json").json()
        assert "/api/v1/admin/stats" in openapi["paths"]

    def test_retrieve_explain_registered(self, client):
        openapi = client.get("/openapi.json").json()
        assert "/api/v1/retrieve/explain" in openapi["paths"]
