"""Tests for the chat API and LLM tool dispatch."""

from __future__ import annotations

import json
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from sltk.db.corpus import CorpusDB
from sltk.llm.agent import (
    _SYSTEM_PROMPT_BASE as SYSTEM_PROMPT,
)
from sltk.llm.agent import (
    _dispatch_tool_call,
    _truncate_result,
)
from sltk.llm.tools import CORPUS_TOOLS

# ------------------------------------------------------------------
# Fixtures
# ------------------------------------------------------------------


@pytest.fixture
def corpus_db(tmp_path):
    """Create a CorpusDB with test data."""
    db = CorpusDB(tmp_path / "test.db")

    # Create a minimal .eaf for testing
    eaf_content = """<?xml version="1.0" encoding="UTF-8"?>
<ANNOTATION_DOCUMENT>
  <HEADER MEDIA_FILE="" TIME_UNITS="milliseconds">
    <MEDIA_DESCRIPTOR MEDIA_URL="file:///test.mp4" MIME_TYPE="video/mp4"/>
  </HEADER>
  <TIME_ORDER>
    <TIME_SLOT TIME_SLOT_ID="ts1" TIME_VALUE="0"/>
    <TIME_SLOT TIME_SLOT_ID="ts2" TIME_VALUE="500"/>
    <TIME_SLOT TIME_SLOT_ID="ts3" TIME_VALUE="600"/>
    <TIME_SLOT TIME_SLOT_ID="ts4" TIME_VALUE="1200"/>
    <TIME_SLOT TIME_SLOT_ID="ts5" TIME_VALUE="1300"/>
    <TIME_SLOT TIME_SLOT_ID="ts6" TIME_VALUE="1800"/>
    <TIME_SLOT TIME_SLOT_ID="ts7" TIME_VALUE="1900"/>
    <TIME_SLOT TIME_SLOT_ID="ts8" TIME_VALUE="2300"/>
  </TIME_ORDER>
  <TIER LINGUISTIC_TYPE_REF="default-lt" TIER_ID="Gloss" PARTICIPANT="signer1">
    <ANNOTATION>
      <ALIGNABLE_ANNOTATION ANNOTATION_ID="a1" TIME_SLOT_REF1="ts1" TIME_SLOT_REF2="ts2">
        <ANNOTATION_VALUE>HELLO</ANNOTATION_VALUE>
      </ALIGNABLE_ANNOTATION>
    </ANNOTATION>
    <ANNOTATION>
      <ALIGNABLE_ANNOTATION ANNOTATION_ID="a2" TIME_SLOT_REF1="ts3" TIME_SLOT_REF2="ts4">
        <ANNOTATION_VALUE>WORLD</ANNOTATION_VALUE>
      </ALIGNABLE_ANNOTATION>
    </ANNOTATION>
    <ANNOTATION>
      <ALIGNABLE_ANNOTATION ANNOTATION_ID="a3" TIME_SLOT_REF1="ts5" TIME_SLOT_REF2="ts6">
        <ANNOTATION_VALUE>HELLO</ANNOTATION_VALUE>
      </ALIGNABLE_ANNOTATION>
    </ANNOTATION>
    <ANNOTATION>
      <ALIGNABLE_ANNOTATION ANNOTATION_ID="a4" TIME_SLOT_REF1="ts7" TIME_SLOT_REF2="ts8">
        <ANNOTATION_VALUE>THANK-YOU</ANNOTATION_VALUE>
      </ALIGNABLE_ANNOTATION>
    </ANNOTATION>
  </TIER>
</ANNOTATION_DOCUMENT>"""

    eaf_path = tmp_path / "test.eaf"
    eaf_path.write_text(eaf_content)
    db.ingest_eaf(eaf_path)
    return db


# ------------------------------------------------------------------
# Tool definitions
# ------------------------------------------------------------------


def test_tool_definitions_valid():
    """All tools should have valid OpenAI function format."""
    assert len(CORPUS_TOOLS) == 33
    for tool in CORPUS_TOOLS:
        assert tool["type"] == "function"
        fn = tool["function"]
        assert "name" in fn
        assert "description" in fn
        assert "parameters" in fn
        assert fn["parameters"]["type"] == "object"


def test_all_tool_names_unique():
    names = [t["function"]["name"] for t in CORPUS_TOOLS]
    assert len(names) == len(set(names))


# ------------------------------------------------------------------
# Tool dispatch
# ------------------------------------------------------------------


def test_dispatch_get_corpus_summary(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_corpus_summary", {})
    assert result["total_files"] == 1
    assert result["total_segments"] == 4
    assert result["vocabulary_size"] == 3


def test_dispatch_search_glosses(corpus_db):
    result = _dispatch_tool_call(corpus_db, "search_glosses", {"query": "HELLO"})
    assert isinstance(result, list)
    assert len(result) >= 1


def test_dispatch_get_top_glosses(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_top_glosses", {"limit": 10})
    assert isinstance(result, list)
    assert result[0]["gloss"].lower() == "hello"
    assert result[0]["frequency"] == 2


def test_dispatch_get_gloss_occurrences(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_gloss_occurrences", {"gloss": "HELLO"})
    assert isinstance(result, list)
    assert len(result) == 2


def test_dispatch_get_gloss_occurrences_with_signer(corpus_db):
    result = _dispatch_tool_call(
        corpus_db,
        "get_gloss_occurrences",
        {"gloss": "HELLO", "signer_id": "signer1"},
    )
    assert isinstance(result, list)
    assert len(result) == 2


def test_dispatch_get_gloss_duration_stats(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_gloss_duration_stats", {"gloss": "HELLO"})
    assert result["count"] == 2
    assert result["mean_ms"] > 0


def test_dispatch_compare_signer_durations(corpus_db):
    result = _dispatch_tool_call(corpus_db, "compare_signer_durations", {"gloss": "HELLO"})
    assert isinstance(result, list)


def test_dispatch_get_concordance(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_concordance", {"target": "HELLO"})
    assert isinstance(result, list)
    assert len(result) >= 1
    assert result[0]["target"] == "HELLO"


def test_dispatch_get_ngrams(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_ngrams", {"n": 2, "limit": 10})
    assert isinstance(result, list)


def test_dispatch_get_cooccurrence(corpus_db):
    result = _dispatch_tool_call(
        corpus_db,
        "get_cooccurrence",
        {"target": "HELLO", "window": 3, "limit": 10},
    )
    assert isinstance(result, list)


def test_dispatch_get_vocabulary_stats(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_vocabulary_stats", {"limit": 100})
    assert result["vocabulary_size"] == 3
    assert result["total_tokens"] == 4


def test_dispatch_get_lexical_richness(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_lexical_richness", {"mattr_window": 50})
    assert "ttr" in result
    assert "yules_k" in result


def test_dispatch_get_transitions(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_transitions", {"top_n": 10})
    assert "signs" in result
    assert "matrix" in result


def test_dispatch_get_fluency_stats(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_fluency_stats", {})
    assert "file_stats" in result


def test_dispatch_get_per_signer_fluency(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_per_signer_fluency", {})
    assert isinstance(result, list)


def test_dispatch_get_dashboard_stats(corpus_db):
    result = _dispatch_tool_call(corpus_db, "get_dashboard_stats", {})
    assert result["total_files"] == 1
    assert result["total_segments"] == 4


def test_dispatch_unknown_tool(corpus_db):
    result = _dispatch_tool_call(corpus_db, "nonexistent_tool", {})
    assert "error" in result
    assert "Unknown tool" in result["error"]


# ------------------------------------------------------------------
# SQL query tool
# ------------------------------------------------------------------


def test_dispatch_run_sql_query_select(corpus_db):
    """Valid SELECT query returns results as list of dicts."""
    result = _dispatch_tool_call(
        corpus_db,
        "run_sql_query",
        {"sql": "SELECT label, count(*) AS cnt FROM segments GROUP BY label ORDER BY cnt DESC"},
    )
    assert isinstance(result, list)
    assert len(result) == 3  # HELLO, WORLD, THANK-YOU
    assert result[0]["cnt"] == 2  # HELLO appears twice
    assert "label" in result[0]


def test_dispatch_run_sql_query_with_limit(corpus_db):
    """LIMIT parameter caps the number of rows returned."""
    result = _dispatch_tool_call(
        corpus_db,
        "run_sql_query",
        {"sql": "SELECT label FROM segments", "limit": 2},
    )
    assert isinstance(result, list)
    assert len(result) == 2


def test_dispatch_run_sql_query_with_cte(corpus_db):
    """WITH (CTE) queries are allowed."""
    result = _dispatch_tool_call(
        corpus_db,
        "run_sql_query",
        {
            "sql": "WITH counts AS (SELECT label, count(*) AS n FROM segments GROUP BY label) SELECT * FROM counts WHERE n > 1"
        },
    )
    assert isinstance(result, list)
    assert len(result) == 1  # Only HELLO has count > 1
    assert result[0]["label"] == "HELLO"


def test_dispatch_run_sql_query_rejects_insert(corpus_db):
    """Non-SELECT statements return an error."""
    result = _dispatch_tool_call(
        corpus_db,
        "run_sql_query",
        {"sql": "INSERT INTO segments (label) VALUES ('HACK')"},
    )
    assert isinstance(result, dict)
    assert "error" in result


def test_dispatch_run_sql_query_rejects_drop(corpus_db):
    """DROP statements return an error."""
    result = _dispatch_tool_call(
        corpus_db,
        "run_sql_query",
        {"sql": "DROP TABLE segments"},
    )
    assert isinstance(result, dict)
    assert "error" in result


def test_dispatch_run_sql_query_invalid_sql(corpus_db):
    """Invalid SQL returns a friendly error."""
    result = _dispatch_tool_call(
        corpus_db,
        "run_sql_query",
        {"sql": "SELECT FROM nonexistent_table WHERE"},
    )
    assert isinstance(result, dict)
    assert "error" in result


# ------------------------------------------------------------------
# Result truncation
# ------------------------------------------------------------------


def test_truncate_small_result():
    data = [{"a": 1}, {"b": 2}]
    assert _truncate_result(data) == data


def test_truncate_large_list():
    # Each item ~500 bytes, 200 items = ~100KB > MAX_RESULT_BYTES (50KB)
    data = [{"item": i, "data": "x" * 500} for i in range(200)]
    result = _truncate_result(data)
    assert len(result) == 101  # 100 items + truncation marker
    assert result[-1]["_truncated"] is True
    assert result[-1]["total"] == 200


# ------------------------------------------------------------------
# System prompt
# ------------------------------------------------------------------


def test_system_prompt_content():
    assert "sign language" in SYSTEM_PROMPT.lower()
    assert "gloss" in SYSTEM_PROMPT.lower()


# ------------------------------------------------------------------
# Chat API endpoint (FastAPI)
# ------------------------------------------------------------------


@pytest.fixture
def client(corpus_db, tmp_path):
    """Create a test client with a real CorpusDB."""
    from fastapi.testclient import TestClient

    from sltk.api.main import app

    # Patch _get_corpus_db to return our test DB
    with patch("sltk.api.routers.chat._get_corpus_db", return_value=corpus_db):
        yield TestClient(app)


def test_chat_missing_api_key(client, tmp_path):
    """Chat without API key returns helpful error."""
    settings_file = Path.home() / ".sltk" / "settings.json"
    original = settings_file.read_text() if settings_file.exists() else None

    try:
        # Write settings without API key
        settings_file.parent.mkdir(parents=True, exist_ok=True)
        settings_file.write_text(json.dumps({"theme": "dark"}))

        resp = client.post("/api/chat", json={"message": "hello"})
        assert resp.status_code == 200
        data = resp.json()
        assert data["error"] is not None
        assert "API key" in data["error"]
    finally:
        if original is not None:
            settings_file.write_text(original)
        elif settings_file.exists():
            settings_file.unlink()


def test_chat_success(client, tmp_path):
    """Chat with mocked OpenAI returns a reply."""
    settings_file = Path.home() / ".sltk" / "settings.json"
    original = settings_file.read_text() if settings_file.exists() else None

    try:
        settings_file.parent.mkdir(parents=True, exist_ok=True)
        settings_file.write_text(
            json.dumps(
                {
                    "openai_api_key": "sk-test-key-123",
                    "openai_model": "gpt-4o",
                }
            )
        )

        # Mock the openai client
        mock_message = MagicMock()
        mock_message.content = "Your corpus has 4 segments."
        mock_message.tool_calls = None

        mock_choice = MagicMock()
        mock_choice.message = mock_message

        mock_response = MagicMock()
        mock_response.choices = [mock_choice]

        with patch("openai.OpenAI") as MockOpenAI:
            mock_client = MagicMock()
            mock_client.chat.completions.create.return_value = mock_response
            MockOpenAI.return_value = mock_client

            resp = client.post(
                "/api/chat",
                json={
                    "message": "How many segments?",
                    "history": [],
                },
            )

        assert resp.status_code == 200
        data = resp.json()
        assert data["error"] is None
        assert "4 segments" in data["reply"]
    finally:
        if original is not None:
            settings_file.write_text(original)
        elif settings_file.exists():
            settings_file.unlink()


def test_settings_includes_ai_fields():
    """UserSettings model includes AI fields."""
    from sltk.api.models import UserSettings

    s = UserSettings()
    assert s.openai_api_key is None
    assert s.openai_model == "gpt-4o"

    s2 = UserSettings(openai_api_key="sk-test", openai_model="gpt-4o-mini")
    assert s2.openai_api_key == "sk-test"
    assert s2.openai_model == "gpt-4o-mini"

    # Roundtrip
    data = s2.model_dump()
    assert data["openai_api_key"] == "sk-test"
    assert data["openai_model"] == "gpt-4o-mini"
    s3 = UserSettings(**data)
    assert s3.openai_api_key == "sk-test"


# ------------------------------------------------------------------
# API key security
# ------------------------------------------------------------------


def test_mask_api_key():
    """API keys are properly masked."""
    from sltk.api.routers.settings import _mask_api_key

    assert _mask_api_key(None) is None
    assert _mask_api_key("") is None
    assert _mask_api_key("short") is None  # too short to mask
    assert _mask_api_key("sk-proj-abc123xyz789") == "sk-proj...z789"
    assert _mask_api_key("sk-1234567890abcdef") == "sk-1234...cdef"


def test_get_settings_masks_api_key(tmp_path):
    """GET /api/settings masks the API key."""
    import json

    from fastapi.testclient import TestClient

    from sltk.api.main import app

    settings_file = Path.home() / ".sltk" / "settings.json"
    original = settings_file.read_text() if settings_file.exists() else None

    try:
        settings_file.parent.mkdir(parents=True, exist_ok=True)
        settings_file.write_text(
            json.dumps(
                {
                    "openai_api_key": "sk-proj-abc123xyz789secretkey",
                    "openai_model": "gpt-4o",
                }
            )
        )

        client = TestClient(app)
        resp = client.get("/api/settings")
        assert resp.status_code == 200
        data = resp.json()

        # Key should be masked, not returned in full
        assert data["openai_api_key"] != "sk-proj-abc123xyz789secretkey"
        assert data["openai_api_key"].startswith("sk-proj")
        assert "..." in data["openai_api_key"]
        # Full key must not appear anywhere in response
        assert "secretkey" not in json.dumps(data)
    finally:
        if original is not None:
            settings_file.write_text(original)
        elif settings_file.exists():
            settings_file.unlink()


def test_save_settings_preserves_key_when_masked(tmp_path):
    """POST /api/settings with masked key preserves the real key on disk."""
    import json

    from fastapi.testclient import TestClient

    from sltk.api.main import app

    settings_file = Path.home() / ".sltk" / "settings.json"
    original = settings_file.read_text() if settings_file.exists() else None
    real_key = "sk-proj-abc123xyz789secretkey"

    try:
        settings_file.parent.mkdir(parents=True, exist_ok=True)
        settings_file.write_text(
            json.dumps(
                {
                    "openai_api_key": real_key,
                    "openai_model": "gpt-4o",
                }
            )
        )

        client = TestClient(app)

        # Save with masked key — should preserve the real key
        resp = client.post(
            "/api/settings",
            json={
                "openai_api_key": "sk-proj...ekey",
                "openai_model": "gpt-4o-mini",
            },
        )
        assert resp.status_code == 200

        # Verify the real key is still on disk
        saved = json.loads(settings_file.read_text())
        assert saved["openai_api_key"] == real_key
        assert saved["openai_model"] == "gpt-4o-mini"
    finally:
        if original is not None:
            settings_file.write_text(original)
        elif settings_file.exists():
            settings_file.unlink()
