"""Deep coverage tests for token_counter.py – targeting uncovered branches.

Focuses on:
- on_llm_start model/provider extraction paths
- on_llm_end token usage extraction (usage_metadata, response_metadata, llm_output)
- on_llm_error tracking
- _get_context_overflow_fields
- cost calculation helpers / tiktoken mocking
- TokenCounter.create_callback
- TokenCounter edge cases (no research_id, missing counts)
"""

import time
from unittest.mock import MagicMock, patch

from langchain_core.outputs import LLMResult

from local_deep_research.metrics.token_counter import (
    TokenCounter,
    TokenCountingCallback,
)

MODULE = "local_deep_research.metrics.token_counter"


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


def _make_llm_result(llm_output=None, generations=None):
    result = MagicMock(spec=LLMResult)
    result.llm_output = llm_output
    result.generations = generations or []
    return result


def _make_generation(usage_metadata=None, response_metadata=None):
    gen = MagicMock()
    msg = MagicMock()
    msg.usage_metadata = usage_metadata
    msg.response_metadata = response_metadata or {}
    gen.message = msg
    return gen


def _make_callback(**overrides):
    ctx = overrides.pop("research_context", {"research_query": "q"})
    cb = TokenCountingCallback(
        research_id=overrides.pop("research_id", "rid-1"),
        research_context=ctx,
    )
    for k, v in overrides.items():
        setattr(cb, k, v)
    return cb


# ---------------------------------------------------------------------------
# on_llm_start: model name extraction
# ---------------------------------------------------------------------------


class TestOnLlmStartModelExtraction:
    def test_preset_model_used_when_set(self):
        cb = _make_callback()
        cb.preset_model = "my-preset-model"
        cb.preset_provider = "openai"
        cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello"])
        assert cb.current_model == "my-preset-model"
        assert cb.current_provider == "openai"

    def test_model_from_invocation_params(self):
        cb = _make_callback()
        cb.on_llm_start(
            {},
            ["hello"],
            invocation_params={"model": "gpt-4-turbo"},
        )
        assert cb.current_model == "gpt-4-turbo"

    def test_model_name_from_invocation_params(self):
        cb = _make_callback()
        cb.on_llm_start(
            {},
            ["hello"],
            invocation_params={"model_name": "claude-3"},
        )
        assert cb.current_model == "claude-3"

    def test_model_from_serialized_kwargs(self):
        cb = _make_callback()
        cb.on_llm_start(
            {"kwargs": {"model": "gemma3:12b"}},
            ["hello"],
        )
        assert cb.current_model == "gemma3:12b"

    def test_model_name_from_serialized_kwargs(self):
        cb = _make_callback()
        cb.on_llm_start(
            {"kwargs": {"model_name": "llama3"}},
            ["hello"],
        )
        assert cb.current_model == "llama3"

    def test_model_from_serialized_name(self):
        cb = _make_callback()
        cb.on_llm_start({"name": "SerializedModelName"}, ["hello"])
        assert cb.current_model == "SerializedModelName"

    def test_ollama_fallback_to_kwargs_model(self):
        cb = _make_callback()
        cb.on_llm_start(
            {"_type": "ChatOllama", "kwargs": {"model": "mistral"}},
            ["hello"],
        )
        assert cb.current_model == "mistral"
        assert cb.current_provider == "ollama"

    def test_ollama_fallback_to_type_string(self):
        """When Ollama _type present but no model in kwargs, falls back to 'ollama'."""
        cb = _make_callback()
        cb.on_llm_start(
            {"_type": "ChatOllama", "kwargs": {}},
            ["hello"],
        )
        assert cb.current_model == "ollama"

    def test_unknown_model_from_type(self):
        cb = _make_callback()
        cb.on_llm_start({"_type": "ChatSomething"}, ["hello"])
        assert cb.current_model == "ChatSomething"

    def test_unknown_model_fallback(self):
        cb = _make_callback()
        cb.on_llm_start({}, ["hello"])
        assert cb.current_model == "unknown"

    def test_provider_ollama_from_type(self):
        cb = _make_callback()
        cb.on_llm_start({"_type": "ChatOllama"}, ["hello"])
        assert cb.current_provider == "ollama"

    def test_provider_openai_from_type(self):
        cb = _make_callback()
        cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello"])
        assert cb.current_provider == "openai"

    def test_provider_anthropic_from_type(self):
        cb = _make_callback()
        cb.on_llm_start({"_type": "ChatAnthropic"}, ["hello"])
        assert cb.current_provider == "anthropic"

    def test_provider_unknown_when_no_type(self):
        cb = _make_callback()
        cb.on_llm_start({}, ["hello"])
        assert cb.current_provider == "unknown"

    def test_call_count_incremented(self):
        cb = _make_callback()
        cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello"])
        cb.on_llm_start({"_type": "ChatOpenAI"}, ["hello again"])
        model = cb.current_model
        assert cb.counts["by_model"][model]["calls"] == 2

    def test_start_time_recorded(self):
        cb = _make_callback()
        cb.on_llm_start({}, ["hello"])
        assert cb.start_time is not None
        assert cb.start_time <= time.time()

    def test_prompt_estimate_computed(self):
        cb = _make_callback()
        cb.on_llm_start({}, ["a" * 400])
        assert cb.original_prompt_estimate == 100  # 400 // 4


# ---------------------------------------------------------------------------
# on_llm_end: token usage paths
# ---------------------------------------------------------------------------


class TestOnLlmEndTokenUsage:
    def _run_end(self, cb, llm_result):
        with patch.object(cb, "_save_to_db") as mock_save:
            cb.on_llm_end(llm_result)
            return mock_save

    def test_token_usage_from_llm_output(self):
        cb = _make_callback()
        cb.current_model = "gpt-4"
        cb.counts["by_model"]["gpt-4"] = {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "calls": 1,
            "provider": "openai",
        }
        result = _make_llm_result(
            llm_output={
                "token_usage": {
                    "prompt_tokens": 50,
                    "completion_tokens": 30,
                    "total_tokens": 80,
                }
            }
        )
        save_mock = self._run_end(cb, result)
        assert cb.counts["total_tokens"] == 80
        save_mock.assert_called_once_with(50, 30)

    def test_token_usage_from_usage_metadata(self):
        cb = _make_callback()
        cb.current_model = "claude-3"
        cb.counts["by_model"]["claude-3"] = {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "calls": 1,
            "provider": "anthropic",
        }
        usage_meta = {
            "input_tokens": 20,
            "output_tokens": 10,
            "total_tokens": 30,
        }
        gen = _make_generation(usage_metadata=usage_meta)
        result = _make_llm_result(generations=[[gen]])
        save_mock = self._run_end(cb, result)
        assert cb.counts["total_tokens"] == 30
        save_mock.assert_called_once_with(20, 10)

    def test_token_usage_from_response_metadata_ollama(self):
        cb = _make_callback()
        cb.current_model = "mistral"
        cb.counts["by_model"]["mistral"] = {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "calls": 1,
            "provider": "ollama",
        }
        resp_meta = {"prompt_eval_count": 40, "eval_count": 20}
        gen = _make_generation(usage_metadata=None, response_metadata=resp_meta)
        result = _make_llm_result(generations=[[gen]])
        save_mock = self._run_end(cb, result)
        assert cb.counts["total_tokens"] == 60
        save_mock.assert_called_once_with(40, 20)

    def test_no_token_usage_saves_zero_counts(self):
        """No usage data from the provider still records the call (#4457)."""
        cb = _make_callback()
        result = _make_llm_result(llm_output=None, generations=[])
        save_mock = self._run_end(cb, result)
        save_mock.assert_called_once_with(0, 0)

    def test_response_time_calculated(self):
        cb = _make_callback()
        cb.start_time = time.time() - 0.5  # 500ms ago
        result = _make_llm_result(llm_output=None, generations=[])
        cb.on_llm_end(result)
        assert cb.response_time_ms is not None
        assert cb.response_time_ms >= 400  # at least 400ms

    def test_no_save_when_no_research_id(self):
        cb = TokenCountingCallback(research_id=None)
        cb.current_model = "gpt-4"
        cb.counts["by_model"]["gpt-4"] = {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "calls": 1,
            "provider": "openai",
        }
        result = _make_llm_result(
            llm_output={
                "token_usage": {"prompt_tokens": 10, "completion_tokens": 5}
            }
        )
        with patch.object(cb, "_save_to_db") as mock_save:
            cb.on_llm_end(result)
            mock_save.assert_not_called()

    def test_context_overflow_detection(self):
        """When prompt_eval_count >= 95% of context_limit, context_truncated is set."""
        cb = _make_callback()
        cb.context_limit = 1000
        cb.original_prompt_estimate = 1200  # More than actual => truncated
        cb.current_model = "llama"
        cb.counts["by_model"]["llama"] = {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "calls": 1,
            "provider": "ollama",
        }
        resp_meta = {
            "prompt_eval_count": 960,
            "eval_count": 40,
        }  # 960 >= 950 (95%)
        gen = _make_generation(usage_metadata=None, response_metadata=resp_meta)
        result = _make_llm_result(generations=[[gen]])
        with patch.object(cb, "_save_to_db"):
            cb.on_llm_end(result)
        assert cb.context_truncated is True
        assert cb.tokens_truncated > 0


# ---------------------------------------------------------------------------
# on_llm_error
# ---------------------------------------------------------------------------


class TestOnLlmError:
    def test_sets_error_status(self):
        cb = _make_callback()
        with patch.object(cb, "_save_to_db") as mock_save:
            cb.on_llm_error(ValueError("bad input"))
        assert cb.success_status == "error"
        assert cb.error_type == "ValueError"
        mock_save.assert_called_once_with(0, 0)

    def test_calculates_response_time_on_error(self):
        cb = _make_callback()
        cb.start_time = time.time() - 1.0
        with patch.object(cb, "_save_to_db"):
            cb.on_llm_error(RuntimeError("crash"))
        assert cb.response_time_ms is not None
        assert cb.response_time_ms >= 900

    def test_no_save_when_no_research_id(self):
        cb = TokenCountingCallback(research_id=None)
        with patch.object(cb, "_save_to_db") as mock_save:
            cb.on_llm_error(RuntimeError("boom"))
        mock_save.assert_not_called()


# ---------------------------------------------------------------------------
# _get_context_overflow_fields
# ---------------------------------------------------------------------------


class TestGetContextOverflowFields:
    def test_fields_when_not_truncated(self):
        cb = _make_callback()
        cb.context_limit = 4096
        cb.context_truncated = False
        cb.tokens_truncated = 0
        cb.truncation_ratio = 0.0
        fields = cb._get_context_overflow_fields()
        assert fields["context_limit"] == 4096
        assert fields["context_truncated"] is False
        assert fields["tokens_truncated"] is None
        assert fields["truncation_ratio"] is None

    def test_fields_when_truncated(self):
        cb = _make_callback()
        cb.context_limit = 2048
        cb.context_truncated = True
        cb.tokens_truncated = 300
        cb.truncation_ratio = 0.25
        fields = cb._get_context_overflow_fields()
        assert fields["tokens_truncated"] == 300
        assert fields["truncation_ratio"] == 0.25

    def test_ollama_metrics_included(self):
        cb = _make_callback()
        cb.ollama_metrics = {
            "prompt_eval_count": 500,
            "eval_count": 100,
            "total_duration": 999,
        }
        fields = cb._get_context_overflow_fields()
        assert fields["ollama_prompt_eval_count"] == 500
        assert fields["ollama_eval_count"] == 100

    def test_missing_ollama_metrics_returns_none(self):
        cb = _make_callback()
        cb.ollama_metrics = {}
        fields = cb._get_context_overflow_fields()
        assert fields["ollama_prompt_eval_count"] is None
        assert fields["ollama_total_duration"] is None


# ---------------------------------------------------------------------------
# TokenCounter.create_callback
# ---------------------------------------------------------------------------


class TestTokenCounterCreateCallback:
    def test_create_callback_returns_callback_instance(self):
        counter = TokenCounter()
        cb = counter.create_callback("res-99")
        assert isinstance(cb, TokenCountingCallback)
        assert cb.research_id == "res-99"

    def test_create_callback_with_context(self):
        counter = TokenCounter()
        ctx = {"research_query": "AI safety", "username": "bob"}
        cb = counter.create_callback("res-42", ctx)
        assert cb.research_context == ctx

    def test_create_callback_no_context(self):
        counter = TokenCounter()
        cb = counter.create_callback("res-1", None)
        assert cb.research_context == {}

    def test_multiple_callbacks_independent(self):
        counter = TokenCounter()
        cb1 = counter.create_callback("res-1")
        cb2 = counter.create_callback("res-2")
        cb1.current_model = "gpt-4"
        assert cb2.current_model is None


# ---------------------------------------------------------------------------
# Tiktoken mocking – cost calculation helpers
# ---------------------------------------------------------------------------


class TestTiktokenMocking:
    """Test that token counting works when tiktoken is mocked."""

    def test_on_llm_start_no_tiktoken_needed(self):
        """on_llm_start should work without tiktoken."""
        cb = _make_callback()
        # No patch needed – tiktoken is not used in on_llm_start
        cb.on_llm_start({"_type": "ChatOpenAI"}, ["Hello world"])
        assert cb.current_provider == "openai"

    def test_token_count_aggregates_across_calls(self):
        cb = _make_callback()
        cb.on_llm_start({"_type": "ChatOpenAI"}, ["prompt"])
        cb.current_model = "gpt-4"
        cb.counts["by_model"].setdefault(
            "gpt-4",
            {
                "prompt_tokens": 0,
                "completion_tokens": 0,
                "total_tokens": 0,
                "calls": 1,
                "provider": "openai",
            },
        )

        result1 = _make_llm_result(
            llm_output={
                "token_usage": {
                    "prompt_tokens": 100,
                    "completion_tokens": 50,
                    "total_tokens": 150,
                }
            }
        )
        result2 = _make_llm_result(
            llm_output={
                "token_usage": {
                    "prompt_tokens": 200,
                    "completion_tokens": 100,
                    "total_tokens": 300,
                }
            }
        )
        with patch.object(cb, "_save_to_db"):
            cb.on_llm_end(result1)
            cb.on_llm_end(result2)

        assert cb.counts["total_tokens"] == 450
        assert cb.counts["total_prompt_tokens"] == 300
        assert cb.counts["total_completion_tokens"] == 150


# ---------------------------------------------------------------------------
# TokenCounter – cost metrics and empty states
# ---------------------------------------------------------------------------


class TestTokenCounterMetrics:
    def test_initial_counts_are_zero(self):
        counter = TokenCounter()
        cb = counter.create_callback("res-1")
        assert cb.counts["total_tokens"] == 0
        assert cb.counts["total_prompt_tokens"] == 0
        assert cb.counts["total_completion_tokens"] == 0
        assert cb.counts["by_model"] == {}

    def test_llm_output_usage_key_fallback(self):
        """When token_usage absent, falls back to 'usage' key in llm_output."""
        cb = _make_callback()
        cb.current_model = "gpt-3.5"
        cb.counts["by_model"]["gpt-3.5"] = {
            "prompt_tokens": 0,
            "completion_tokens": 0,
            "total_tokens": 0,
            "calls": 1,
            "provider": "openai",
        }
        result = _make_llm_result(
            llm_output={
                "usage": {
                    "prompt_tokens": 10,
                    "completion_tokens": 5,
                    "total_tokens": 15,
                }
            }
        )
        with patch.object(cb, "_save_to_db"):
            cb.on_llm_end(result)
        assert cb.counts["total_tokens"] == 15
