"""
tests/test_cli.py — Tests for the evaos CLI (cortex/cli.py).

Strategy:
  - Use Click's CliRunner for isolated invocations (no subprocess).
  - Mock CortexPlugin and all its async methods — no real DB, no LLM.
  - Test: help output, command routing, init file creation, error handling.

Coverage targets:
  - init: file creation, profile selection, error on double-init
  - health: OK path, DEGRADED path (exit code 1)
  - remember: success, plugin errors
  - recall: results display, no results
  - cornerstones list: results, empty
  - cornerstones seal: success
  - dream: wake + sleep path
  - stats: basic table output
  - export: json + sql format, stdout + file
  - backup: file copy, missing source error
  - serve: import error fallback
  - mcp: import error fallback
  - --help on root and subcommands
"""
from __future__ import annotations

import asyncio
import json
import os
import sys
import tempfile
import types
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, List, Optional
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner

from cortex.cli import main


# ---------------------------------------------------------------------------
# Minimal stub dataclasses mirroring plugin API
# ---------------------------------------------------------------------------

@dataclass
class _RememberResult:
    session_id: str = "test-session"
    claims_extracted: int = 3
    claims_stored: int = 2
    entities_resolved: int = 1
    noise_filtered: int = 0
    errors: List[str] = field(default_factory=list)
    ok: bool = True


@dataclass
class _HealthReport:
    ok: bool = True
    storage: str = "sqlite:ok"
    modules: dict = field(default_factory=lambda: {"extraction": "ok", "retrieval": "ok"})
    errors: List[str] = field(default_factory=list)
    timestamp: str = "2024-01-01T00:00:00"


@dataclass
class _WakeReport:
    session_id: str = "wake-session"
    cornerstones_loaded: int = 2
    errors: List[str] = field(default_factory=list)


@dataclass
class _SleepReport:
    session_id: str = "wake-session"
    memories_summarised: int = 5
    errors: List[str] = field(default_factory=list)


@dataclass
class _CornerstoneMemory:
    id: str = "cs-001"
    label: str = "identity"
    content: str = "I am Eva"
    sealed_at: str = "2024-01-01T00:00:00"
    drift_score: float = 0.0


@dataclass
class _RetrievalItem:
    content: str = "Andrew prefers dark mode"
    score: float = 0.95


@dataclass
class _RetrievalResult:
    memories: List[_RetrievalItem] = field(default_factory=lambda: [_RetrievalItem()])
    token_count: int = 120


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

def _mock_plugin(
    *,
    remember_result: Optional[_RememberResult] = None,
    health_result: Optional[_HealthReport] = None,
    wake_result: Optional[_WakeReport] = None,
    sleep_result: Optional[_SleepReport] = None,
    cornerstones: Optional[List[_CornerstoneMemory]] = None,
    retrieval_result: Optional[_RetrievalResult] = None,
    storage: Optional[Any] = None,
) -> MagicMock:
    plugin = MagicMock()
    plugin.storage = storage or MagicMock()
    plugin.storage.initialize = AsyncMock()

    plugin.remember = AsyncMock(return_value=remember_result or _RememberResult())
    plugin.health = AsyncMock(return_value=health_result or _HealthReport())
    plugin.wake = AsyncMock(return_value=wake_result or _WakeReport())
    plugin.sleep = AsyncMock(return_value=sleep_result or _SleepReport())
    plugin.get_cornerstones = AsyncMock(return_value=cornerstones if cornerstones is not None else [_CornerstoneMemory()])
    plugin.seal_cornerstone = AsyncMock(return_value=_CornerstoneMemory())
    plugin.retrieve = AsyncMock(return_value=retrieval_result or _RetrievalResult())

    return plugin


def _patch_plugin(plugin: MagicMock):
    """Return a context manager that patches _make_plugin in cortex.cli."""
    return patch("cortex.cli._make_plugin", return_value=plugin)


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

def run(args: list, input: Optional[str] = None, env: Optional[dict] = None) -> Any:
    """Run the CLI with the given args, return the result."""
    runner = CliRunner()
    return runner.invoke(main, args, input=input, env=env, catch_exceptions=False)


# ---------------------------------------------------------------------------
# --help
# ---------------------------------------------------------------------------

class TestHelp:
    def test_root_help(self):
        result = run(["--help"])
        assert result.exit_code == 0
        assert "evaos" in result.output
        assert "Cortex memory CLI" in result.output

    def test_init_help(self):
        result = run(["init", "--help"])
        assert result.exit_code == 0
        assert "profile" in result.output

    def test_remember_help(self):
        result = run(["remember", "--help"])
        assert result.exit_code == 0

    def test_recall_help(self):
        result = run(["recall", "--help"])
        assert result.exit_code == 0

    def test_cornerstones_help(self):
        result = run(["cornerstones", "--help"])
        assert result.exit_code == 0

    def test_cornerstones_list_help(self):
        result = run(["cornerstones", "list", "--help"])
        assert result.exit_code == 0

    def test_cornerstones_seal_help(self):
        result = run(["cornerstones", "seal", "--help"])
        assert result.exit_code == 0

    def test_dream_help(self):
        result = run(["dream", "--help"])
        assert result.exit_code == 0

    def test_stats_help(self):
        result = run(["stats", "--help"])
        assert result.exit_code == 0

    def test_export_help(self):
        result = run(["export", "--help"])
        assert result.exit_code == 0
        assert "json" in result.output
        assert "sql" in result.output

    def test_backup_help(self):
        result = run(["backup", "--help"])
        assert result.exit_code == 0

    def test_serve_help(self):
        result = run(["serve", "--help"])
        assert result.exit_code == 0

    def test_mcp_help(self):
        result = run(["mcp", "--help"])
        assert result.exit_code == 0


# ---------------------------------------------------------------------------
# init
# ---------------------------------------------------------------------------

class TestInit:
    def test_init_creates_toml_and_db(self):
        plugin = _mock_plugin()
        # Mock storage.connect()
        plugin.storage.initialize = AsyncMock()

        with tempfile.TemporaryDirectory() as tmpdir:
            db = str(Path(tmpdir) / "cortex.db")
            cfg = str(Path(tmpdir) / "cortex.toml")

            with _patch_plugin(plugin):
                result = run(["--db", db, "--config", cfg, "init"])

            assert result.exit_code == 0
            assert Path(cfg).exists(), "cortex.toml should be created"
            content = Path(cfg).read_text()
            assert "companion" in content  # default profile
            assert db in content

    def test_init_profile_coding(self):
        plugin = _mock_plugin()
        plugin.storage.initialize = AsyncMock()

        with tempfile.TemporaryDirectory() as tmpdir:
            db = str(Path(tmpdir) / "cortex.db")
            cfg = str(Path(tmpdir) / "cortex.toml")

            with _patch_plugin(plugin):
                result = run(["--db", db, "--config", cfg, "init", "--profile", "coding"])

            assert result.exit_code == 0
            content = Path(cfg).read_text()
            assert "coding" in content

    def test_init_profile_enterprise(self):
        plugin = _mock_plugin()
        plugin.storage.initialize = AsyncMock()

        with tempfile.TemporaryDirectory() as tmpdir:
            db = str(Path(tmpdir) / "cortex.db")
            cfg = str(Path(tmpdir) / "cortex.toml")

            with _patch_plugin(plugin):
                result = run(["--db", db, "--config", cfg, "init", "--profile", "enterprise"])

            assert result.exit_code == 0
            content = Path(cfg).read_text()
            assert "enterprise" in content

    def test_init_skips_existing_toml(self):
        plugin = _mock_plugin()
        plugin.storage.initialize = AsyncMock()

        with tempfile.TemporaryDirectory() as tmpdir:
            db = str(Path(tmpdir) / "cortex.db")
            cfg = str(Path(tmpdir) / "cortex.toml")
            Path(cfg).write_text("# existing config\n")

            with _patch_plugin(plugin):
                result = run(["--db", db, "--config", cfg, "init"])

            assert result.exit_code == 0
            # File unchanged
            assert Path(cfg).read_text() == "# existing config\n"
            assert "skipping" in result.output.lower() or "already" in result.output.lower()

    def test_init_invalid_profile(self):
        result = run(["init", "--profile", "invalid"])
        assert result.exit_code != 0


# ---------------------------------------------------------------------------
# health
# ---------------------------------------------------------------------------

class TestHealth:
    def test_health_ok(self):
        plugin = _mock_plugin()
        with _patch_plugin(plugin):
            result = run(["health"])
        assert result.exit_code == 0
        assert "OK" in result.output or "ok" in result.output.lower()

    def test_health_degraded_exits_1(self):
        plugin = _mock_plugin(
            health_result=_HealthReport(
                ok=False,
                storage="sqlite:error",
                errors=["storage init failed"],
            )
        )
        with _patch_plugin(plugin):
            result = run(["health"])
        assert result.exit_code == 1

    def test_health_shows_modules(self):
        plugin = _mock_plugin(
            health_result=_HealthReport(
                modules={"extraction": "ok", "retrieval": "ok", "llm": "degraded"}
            )
        )
        with _patch_plugin(plugin):
            result = run(["health"])
        assert "extraction" in result.output or "Module" in result.output


# ---------------------------------------------------------------------------
# remember
# ---------------------------------------------------------------------------

class TestRemember:
    def test_remember_success(self):
        plugin = _mock_plugin()
        with _patch_plugin(plugin):
            result = run(["remember", "Andrew prefers dark mode"])
        assert result.exit_code == 0
        plugin.remember.assert_awaited_once()
        # Verify text was passed as a user message
        call_args = plugin.remember.call_args
        messages = call_args[0][0] if call_args[0] else call_args[1].get("messages", [])
        user_msgs = [m for m in messages if m.get("role") == "user"]
        assert any("Andrew prefers dark mode" in m.get("content", "") for m in user_msgs)

    def test_remember_shows_claims_stored(self):
        plugin = _mock_plugin(
            remember_result=_RememberResult(claims_extracted=3, claims_stored=2)
        )
        with _patch_plugin(plugin):
            result = run(["remember", "some text"])
        assert "2" in result.output  # claims_stored

    def test_remember_with_errors_warns(self):
        plugin = _mock_plugin(
            remember_result=_RememberResult(
                ok=True,
                errors=["llm unavailable, used fallback"],
            )
        )
        with _patch_plugin(plugin):
            result = run(["remember", "some text"])
        assert result.exit_code == 0
        assert "llm unavailable" in result.output

    def test_remember_custom_session(self):
        plugin = _mock_plugin()
        with _patch_plugin(plugin):
            result = run(["remember", "--session", "my-session-42", "hello"])
        assert result.exit_code == 0
        call_args = plugin.remember.call_args
        session_id = call_args[0][1] if len(call_args[0]) > 1 else call_args[1].get("session_id")
        assert session_id == "my-session-42"


# ---------------------------------------------------------------------------
# recall
# ---------------------------------------------------------------------------

class TestRecall:
    def test_recall_shows_results(self):
        plugin = _mock_plugin(
            retrieval_result=_RetrievalResult(
                memories=[
                    _RetrievalItem(content="Andrew prefers dark mode", score=0.92),
                    _RetrievalItem(content="Andrew uses Bangkok timezone", score=0.85),
                ]
            )
        )
        with _patch_plugin(plugin):
            result = run(["recall", "Andrew preferences"])
        assert result.exit_code == 0
        assert "dark mode" in result.output or "preferences" in result.output.lower()

    def test_recall_no_results_warns(self):
        plugin = _mock_plugin(
            retrieval_result=_RetrievalResult(memories=[])
        )
        with _patch_plugin(plugin):
            result = run(["recall", "nonexistent query"])
        assert result.exit_code == 0
        assert "no memories" in result.output.lower() or "not found" in result.output.lower()

    def test_recall_limit_respected(self):
        plugin = _mock_plugin(
            retrieval_result=_RetrievalResult(
                memories=[_RetrievalItem(content=f"memory {i}") for i in range(10)]
            )
        )
        with _patch_plugin(plugin):
            result = run(["recall", "--limit", "3", "anything"])
        # Only first 3 should be shown — check at most 3 numbered items
        assert result.exit_code == 0
        # We can't easily count rows without parsing, but exit should succeed
        plugin.retrieve.assert_awaited_once()

    def test_recall_context_block_fallback(self):
        """Some retrieve() implementations return context_block instead of memories."""
        @dataclass
        class _ContextResult:
            memories: list = field(default_factory=list)
            context_block: str = "Andrew prefers dark mode. He uses Bangkok tz."
            token_count: int = 50

        plugin = _mock_plugin(retrieval_result=_ContextResult())
        with _patch_plugin(plugin):
            result = run(["recall", "preferences"])
        assert result.exit_code == 0
        assert "Andrew" in result.output or "preferences" in result.output.lower()


# ---------------------------------------------------------------------------
# cornerstones list
# ---------------------------------------------------------------------------

class TestCornerstonesList:
    def test_list_shows_cornerstones(self):
        plugin = _mock_plugin(
            cornerstones=[
                _CornerstoneMemory(id="cs-001", label="identity", content="I am Eva"),
                _CornerstoneMemory(id="cs-002", label="mission", content="Build 100Yen apps"),
            ]
        )
        with _patch_plugin(plugin):
            result = run(["cornerstones", "list"])
        assert result.exit_code == 0
        assert "identity" in result.output or "Eva" in result.output

    def test_list_empty_warns(self):
        plugin = _mock_plugin(cornerstones=[])
        with _patch_plugin(plugin):
            result = run(["cornerstones", "list"])
        assert result.exit_code == 0
        assert "no cornerstone" in result.output.lower()


# ---------------------------------------------------------------------------
# cornerstones seal
# ---------------------------------------------------------------------------

class TestCornerstonesSeal:
    def test_seal_success(self):
        cs = _CornerstoneMemory(id="cs-new", label="core", content="I build things")
        plugin = _mock_plugin()
        plugin.seal_cornerstone = AsyncMock(return_value=cs)

        with _patch_plugin(plugin):
            result = run(["cornerstones", "seal", "--label", "core",
                          "--content", "I build things"])
        assert result.exit_code == 0
        plugin.seal_cornerstone.assert_awaited_once_with(
            label="core", content="I build things"
        )
        assert "sealed" in result.output.lower() or "core" in result.output

    def test_seal_missing_label_fails(self):
        result = run(["cornerstones", "seal", "--content", "something"])
        assert result.exit_code != 0

    def test_seal_missing_content_fails(self):
        result = run(["cornerstones", "seal", "--label", "something"])
        assert result.exit_code != 0


# ---------------------------------------------------------------------------
# dream
# ---------------------------------------------------------------------------

class TestDream:
    def test_dream_calls_wake_and_sleep(self):
        plugin = _mock_plugin()
        with _patch_plugin(plugin):
            result = run(["dream"])
        assert result.exit_code == 0
        plugin.wake.assert_awaited_once()
        plugin.sleep.assert_awaited_once()

    def test_dream_shows_summary(self):
        plugin = _mock_plugin(
            wake_result=_WakeReport(cornerstones_loaded=3),
            sleep_result=_SleepReport(memories_summarised=7),
        )
        with _patch_plugin(plugin):
            result = run(["dream"])
        assert result.exit_code == 0
        assert "3" in result.output  # cornerstones_loaded
        assert "7" in result.output  # memories_summarised

    def test_dream_custom_session(self):
        plugin = _mock_plugin()
        with _patch_plugin(plugin):
            result = run(["dream", "--session", "sid-xyz"])
        assert result.exit_code == 0
        plugin.wake.assert_awaited_once_with(session_id="sid-xyz")


# ---------------------------------------------------------------------------
# stats
# ---------------------------------------------------------------------------

class TestStats:
    def test_stats_runs(self):
        plugin = _mock_plugin()
        # Minimal storage without SQL
        plugin.storage = MagicMock()
        plugin.storage.initialize = AsyncMock()
        plugin.storage.get_stats = None
        plugin.storage.stats = None
        plugin.storage._get_connection = None
        with _patch_plugin(plugin):
            result = run(["stats"])
        assert result.exit_code == 0

    def test_stats_shows_health_info(self):
        plugin = _mock_plugin(
            health_result=_HealthReport(ok=True, storage="sqlite:ok")
        )
        plugin.storage.get_stats = None
        plugin.storage.stats = None
        plugin.storage._get_connection = None
        with _patch_plugin(plugin):
            result = run(["stats"])
        assert "OK" in result.output or "sqlite" in result.output.lower()


# ---------------------------------------------------------------------------
# export
# ---------------------------------------------------------------------------

class _FakeCursor:
    """Async-iterable cursor mock for export tests."""
    def __init__(self, rows):
        self.rows = rows
        self.description = [(k,) for k in (rows[0].keys() if rows else [])]

    def __aiter__(self):
        return self._gen()

    async def _gen(self):
        for row in self.rows:
            yield tuple(row.values())

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        return False


class _FakeConn:
    """Async context manager connection with execute() returning a FakeCursor."""
    def __init__(self, rows):
        self.rows = rows

    def execute(self, sql):
        return _FakeCursor(self.rows)

    async def __aenter__(self):
        return self

    async def __aexit__(self, *args):
        return False


class _FakeConnCtx:
    """Returned by _get_connection() — itself an async context manager."""
    def __init__(self, rows):
        self._conn = _FakeConn(rows)

    def __call__(self):
        return self

    async def __aenter__(self):
        return self._conn

    async def __aexit__(self, *args):
        return False


class TestExport:
    _DEFAULT_ROWS = [
        {"id": "c1", "subject": "Andrew", "predicate": "prefers", "object_value": "dark mode"}
    ]

    def _make_export_plugin(self, rows=None):
        plugin = _mock_plugin()
        effective_rows = self._DEFAULT_ROWS if rows is None else rows
        ctx = _FakeConnCtx(effective_rows)
        plugin.storage._get_connection = ctx
        return plugin

    def test_export_json_stdout(self):
        plugin = self._make_export_plugin()
        with _patch_plugin(plugin):
            result = run(["export", "--format", "json"])
        assert result.exit_code == 0, result.output
        data = json.loads(result.output)
        assert isinstance(data, list)

    def test_export_sql_stdout(self):
        plugin = self._make_export_plugin()
        with _patch_plugin(plugin):
            result = run(["export", "--format", "sql"])
        assert result.exit_code == 0, result.output
        assert "INSERT INTO" in result.output or "Cortex export" in result.output

    def test_export_json_to_file(self):
        plugin = self._make_export_plugin()
        with tempfile.TemporaryDirectory() as tmpdir:
            out = str(Path(tmpdir) / "out.json")
            with _patch_plugin(plugin):
                result = run(["export", "--format", "json", "--output", out])
            assert result.exit_code == 0, result.output
            assert Path(out).exists()
            data = json.loads(Path(out).read_text())
            assert isinstance(data, list)

    def test_export_empty_table(self):
        plugin = self._make_export_plugin(rows=[])
        with _patch_plugin(plugin):
            result = run(["export", "--format", "json"])
        assert result.exit_code == 0
        data = json.loads(result.output)
        assert data == []

    def test_export_storage_error(self):
        plugin = _mock_plugin()
        plugin.storage._get_connection = None  # will fail attribute access
        with _patch_plugin(plugin):
            result = CliRunner().invoke(main, ["export"], catch_exceptions=True)
        assert result.exit_code != 0 or "error" in result.output.lower()


# ---------------------------------------------------------------------------
# backup
# ---------------------------------------------------------------------------

class TestBackup:
    def test_backup_creates_copy(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            src = Path(tmpdir) / "cortex.db"
            src.write_bytes(b"SQLite\x00fake\x00data")
            out = str(Path(tmpdir) / "backup.db")
            result = run(["--db", str(src), "backup", "--output", out])
            assert result.exit_code == 0
            assert Path(out).exists()
            assert Path(out).read_bytes() == src.read_bytes()

    def test_backup_default_filename(self):
        with tempfile.TemporaryDirectory() as tmpdir:
            src = Path(tmpdir) / "cortex.db"
            src.write_bytes(b"SQLite\x00data")
            orig_cwd = os.getcwd()
            os.chdir(tmpdir)
            try:
                result = run(["--db", str(src), "backup"])
                assert result.exit_code == 0
                backups = list(Path(tmpdir).glob("cortex-backup-*.db"))
                assert len(backups) == 1
            finally:
                os.chdir(orig_cwd)

    def test_backup_missing_db_exits_1(self):
        result = run(["--db", "/nonexistent/path/cortex.db", "backup"])
        assert result.exit_code == 1
        assert "not found" in result.output.lower()


# ---------------------------------------------------------------------------
# serve — just test the import-error path (no real uvicorn)
# ---------------------------------------------------------------------------

class TestServe:
    def test_serve_fails_without_uvicorn(self):
        """When uvicorn is not importable, evaos serve should exit with an error."""
        import builtins
        real_import = builtins.__import__

        def _no_uvicorn(name, *args, **kwargs):
            if name == "uvicorn":
                raise ImportError("No module named 'uvicorn'")
            return real_import(name, *args, **kwargs)

        with patch("builtins.__import__", side_effect=_no_uvicorn):
            result = CliRunner().invoke(main, ["serve"], catch_exceptions=True)
        assert result.exit_code != 0 or "uvicorn" in result.output.lower()


# ---------------------------------------------------------------------------
# mcp — just test the import-error path
# ---------------------------------------------------------------------------

class TestMcp:
    def test_mcp_fails_without_mcp(self):
        """When cortex.api.mcp_server is not importable, evaos mcp should exit with error."""
        with patch.dict(sys.modules, {"cortex.api.mcp_server": None}):  # type: ignore
            result = CliRunner().invoke(main, ["mcp"], catch_exceptions=True)
        # Either exit non-zero or show useful error
        assert result.exit_code != 0 or "mcp" in result.output.lower()


# ---------------------------------------------------------------------------
# Global flags
# ---------------------------------------------------------------------------

class TestGlobalFlags:
    def test_db_flag_passed_to_plugin(self):
        """--db flag should reach _make_plugin."""
        calls = []

        def _fake_make_plugin(db, config):
            calls.append((db, config))
            return _mock_plugin()

        with patch("cortex.cli._make_plugin", side_effect=_fake_make_plugin):
            run(["--db", "/tmp/test.db", "health"])

        assert calls and calls[0][0] == "/tmp/test.db"

    def test_config_flag_passed_to_plugin(self):
        calls = []

        def _fake_make_plugin(db, config):
            calls.append((db, config))
            return _mock_plugin()

        with patch("cortex.cli._make_plugin", side_effect=_fake_make_plugin):
            run(["--config", "/tmp/test.toml", "health"])

        assert calls and calls[0][1] == "/tmp/test.toml"

    def test_env_var_db(self):
        """CORTEX_DB env var should be picked up."""
        calls = []

        def _fake_make_plugin(db, config):
            calls.append((db, config))
            return _mock_plugin()

        runner = CliRunner()
        with patch("cortex.cli._make_plugin", side_effect=_fake_make_plugin):
            runner.invoke(main, ["health"], env={"CORTEX_DB": "/env/path.db"})

        assert calls and calls[0][0] == "/env/path.db"
