"""
tests/test_locomo_eval_audit.py — Tests for LoCoMo eval audit fixes.

Covers:
  B-LC1: try/finally cleanup on crash
  B-LC2: Cleanup error summary always printed
  B-LC3: Random entropy in owner_id prevents collisions
  B-LC4: --no-isolate confirmation gate
  B-LC5: Pre-flight dataset file validation
"""
from __future__ import annotations

import asyncio
import re
import sys
from io import StringIO
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from cortex.eval.run_eval import (
    _LOCOMO_EVAL_PREFIX,
    _build_parser,
    _cleanup_eval_owner,
    _run,
)


# ---------------------------------------------------------------------------
# B-LC1: try/finally — crash during eval still triggers cleanup
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_cleanup_runs_on_eval_crash():
    """If the eval benchmark crashes mid-run, cleanup must still execute."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo", "--sample"])

    cleanup_called = False
    original_cleanup = _cleanup_eval_owner

    async def _tracking_cleanup(plugin, owner_id, verbose=False):
        nonlocal cleanup_called
        cleanup_called = True

    # Patch _cleanup_eval_owner and make EvalHarness.run_locomo crash
    with patch("cortex.eval.run_eval._cleanup_eval_owner", side_effect=_tracking_cleanup), \
         patch("cortex.eval.run_eval._load_plugin") as mock_load, \
         patch("cortex.eval.harness.EvalHarness.run_locomo", side_effect=RuntimeError("boom")):
        mock_load.return_value = MagicMock(llm=None, storage=None)

        with pytest.raises(RuntimeError, match="boom"):
            await _run(args)

    assert cleanup_called, "Cleanup was NOT called after eval crash — try/finally missing"


@pytest.mark.asyncio
async def test_cleanup_not_called_when_no_isolate():
    """When --no-isolate --force, cleanup should not be called (no owner_id)."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo", "--sample", "--no-isolate", "--force"])

    cleanup_called = False

    async def _tracking_cleanup(plugin, owner_id, verbose=False):
        nonlocal cleanup_called
        cleanup_called = True

    with patch("cortex.eval.run_eval._cleanup_eval_owner", side_effect=_tracking_cleanup), \
         patch("cortex.eval.run_eval._load_plugin") as mock_load, \
         patch("cortex.eval.harness.EvalHarness.run_locomo", side_effect=RuntimeError("boom")):
        mock_load.return_value = MagicMock(llm=None, storage=None)

        with pytest.raises(RuntimeError, match="boom"):
            await _run(args)

    assert not cleanup_called, "Cleanup should NOT run when isolation is off"


# ---------------------------------------------------------------------------
# B-LC2: Cleanup error summary always printed
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_cleanup_error_summary_always_printed(capsys):
    """Cleanup errors must always show a count summary, even without --verbose."""
    mock_storage = MagicMock()
    mock_conn = MagicMock()

    _call_count = 0

    class _AsyncCMRows:
        """Async context manager for SELECT results."""
        async def __aenter__(self):
            return self
        async def __aexit__(self, *_):
            pass
        def __await__(self):
            return self.__aenter__().__await__()
        async def fetchall(self):
            return [{"id": "claim-1"}]

    class _FailOnDelete:
        """Fails when used as awaitable (bare `await conn.execute(DELETE ...)`)."""
        def __await__(self):
            raise Exception("db locked")
        async def __aenter__(self):
            raise Exception("db locked")
        async def __aexit__(self, *_):
            pass

    def _route_execute(sql, *args, **kwargs):
        """SELECT queries return rows; DELETE/other queries raise."""
        if isinstance(sql, str) and sql.strip().upper().startswith("SELECT"):
            return _AsyncCMRows()
        return _FailOnDelete()

    mock_conn.execute = MagicMock(side_effect=_route_execute)
    mock_conn.commit = AsyncMock()
    mock_storage._get_conn = AsyncMock(return_value=mock_conn)
    mock_storage._write_lock = asyncio.Lock()
    mock_storage._vec_loaded = False
    mock_storage.list_entities = AsyncMock(return_value=[])

    mock_plugin = MagicMock()
    mock_plugin.storage = mock_storage

    await _cleanup_eval_owner(mock_plugin, "locomo-eval-test", verbose=False)

    captured = capsys.readouterr()
    # The cleanup should report errors encountered during DELETE operations
    assert "Cleanup failed" in captured.err or "error" in captured.err.lower(), (
        f"Expected cleanup error in stderr, got: {captured.err!r}"
    )


@pytest.mark.asyncio
async def test_cleanup_error_details_with_verbose(capsys):
    """With verbose=True, individual error details should also appear."""
    mock_storage = MagicMock()
    mock_conn = MagicMock()

    class _AsyncCMRows:
        async def __aenter__(self):
            return self
        async def __aexit__(self, *_):
            pass
        def __await__(self):
            return self.__aenter__().__await__()
        async def fetchall(self):
            return [{"id": "claim-1"}]

    class _FailOnDelete:
        def __await__(self):
            raise Exception("db locked")
        async def __aenter__(self):
            raise Exception("db locked")
        async def __aexit__(self, *_):
            pass

    def _route_execute(sql, *args, **kwargs):
        if isinstance(sql, str) and sql.strip().upper().startswith("SELECT"):
            return _AsyncCMRows()
        return _FailOnDelete()

    mock_conn.execute = MagicMock(side_effect=_route_execute)
    mock_conn.commit = AsyncMock()
    mock_storage._get_conn = AsyncMock(return_value=mock_conn)
    mock_storage._write_lock = asyncio.Lock()
    mock_storage._vec_loaded = False
    mock_storage.list_entities = AsyncMock(return_value=[])

    mock_plugin = MagicMock()
    mock_plugin.storage = mock_storage

    await _cleanup_eval_owner(mock_plugin, "locomo-eval-test", verbose=True)

    captured = capsys.readouterr()
    assert "Cleanup failed" in captured.err or "db locked" in captured.err, (
        f"Expected error details with --verbose, got: {captured.err!r}"
    )


# ---------------------------------------------------------------------------
# B-LC3: Random entropy in owner_id
# ---------------------------------------------------------------------------

def test_owner_id_has_random_entropy():
    """Generated owner_id must include random suffix to avoid timestamp collisions."""
    import time
    import random

    ts = int(time.time())
    ids = set()
    # Generate many IDs at the "same timestamp" — they should differ
    for _ in range(100):
        oid = f"{_LOCOMO_EVAL_PREFIX}-{ts}-{random.randint(1000, 9999)}"
        ids.add(oid)

    # With 4-digit random, 100 attempts should produce many unique IDs
    assert len(ids) > 50, (
        f"Expected diverse owner_ids with random entropy, got only {len(ids)} unique out of 100"
    )


def test_owner_id_format_includes_random_suffix():
    """The owner_id format must match 'locomo-eval-<ts>-<random>'."""
    import time
    import random

    ts = int(time.time())
    oid = f"{_LOCOMO_EVAL_PREFIX}-{ts}-{random.randint(1000, 9999)}"
    # Must have 4 parts separated by hyphens: locomo, eval, <ts>, <rand>
    parts = oid.split("-")
    assert len(parts) == 4, f"Expected 4 parts in '{oid}', got {len(parts)}"
    assert parts[0] == "locomo"
    assert parts[1] == "eval"
    assert parts[2].isdigit()
    assert parts[3].isdigit() and 1000 <= int(parts[3]) <= 9999


# ---------------------------------------------------------------------------
# B-LC4: --no-isolate confirmation gate
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_no_isolate_without_force_in_non_interactive_fails():
    """--no-isolate without --force in non-interactive mode must return exit 1."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo", "--sample", "--no-isolate"])

    with patch("cortex.eval.run_eval._load_plugin") as mock_load, \
         patch("sys.stdin") as mock_stdin:
        mock_load.return_value = MagicMock(llm=None, storage=None)
        mock_stdin.isatty = MagicMock(return_value=False)

        result = await _run(args)

    assert result == 1, "Expected exit code 1 for --no-isolate without --force in non-interactive mode"


def test_no_isolate_with_force_accepted():
    """--no-isolate --force must proceed without prompting."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo", "--sample", "--no-isolate", "--force"])
    assert args.force is True
    assert args.isolate is False


def test_force_flag_default_false():
    """--force defaults to False."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo"])
    assert args.force is False


@pytest.mark.asyncio
async def test_no_isolate_interactive_user_declines():
    """In interactive mode, declining confirmation aborts with exit 1."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo", "--sample", "--no-isolate"])

    with patch("cortex.eval.run_eval._load_plugin") as mock_load, \
         patch("sys.stdin") as mock_stdin, \
         patch("builtins.input", return_value="n"):
        mock_load.return_value = MagicMock(llm=None, storage=None)
        mock_stdin.isatty = MagicMock(return_value=True)

        result = await _run(args)

    assert result == 1, "Expected exit code 1 when user declines confirmation"


# ---------------------------------------------------------------------------
# B-LC5: Pre-flight dataset file validation
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_preflight_missing_dataset_file():
    """Missing --data file must return exit 1 before any heavy work."""
    parser = _build_parser()
    args = parser.parse_args([
        "--benchmark", "locomo",
        "--data", "/nonexistent/path/to/dataset.json",
    ])

    # _load_plugin should NOT be called — we should fail before that
    with patch("cortex.eval.run_eval._load_plugin") as mock_load:
        mock_load.return_value = MagicMock(llm=None, storage=None)
        result = await _run(args)

    assert result == 1, "Expected exit code 1 for missing dataset file"


def test_preflight_existing_dataset_does_not_fail(tmp_path):
    """A valid --data file path should NOT trigger pre-flight failure."""
    data_file = tmp_path / "dataset.json"
    data_file.write_text('{"sessions": []}')

    parser = _build_parser()
    args = parser.parse_args([
        "--benchmark", "locomo",
        "--data", str(data_file),
    ])

    # Pre-flight should pass (file exists); the actual eval may fail later,
    # but that's not what we're testing
    import pathlib
    assert pathlib.Path(args.data).exists()


def test_preflight_no_data_flag_skips_check():
    """When --data is not provided, pre-flight validation should be skipped."""
    parser = _build_parser()
    args = parser.parse_args(["--benchmark", "locomo", "--sample"])
    assert args.data is None  # No data path = no pre-flight check needed
