"""Step definitions targeting uncovered lines in langchain_chat_provider.py.

Targeted uncovered lines:
  36-38  — _openai_callback discovery from langchain.callbacks module
  302-303 — _resolve_token_cost TypeError/ValueError fallback
  345-346 — _extract_event StopIteration guard for empty dict
  376-377 — _estimate_token_usage when estimator() raises
  385-386 — _estimate_token_usage when int(result) raises
  450     — _extract_error_message returning None for empty list/tuple/set
"""

from __future__ import annotations

import types
from unittest.mock import MagicMock, patch

from behave import given, then, when

from cleveragents.domain.models.core import Context, Plan, Project
from cleveragents.providers.llm.langchain_chat_provider import LangChainChatProvider

# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------


@given("a fresh LangChain chat provider with mocked dependencies")
def step_fresh_provider(context):
    """Set up a clean LangChainChatProvider with controllable fakes."""
    context.llm_instance = MagicMock()
    context.llm_instance.__class__ = type(
        "FakeLLM", (), {"__module__": "langchain_community.llms"}
    )
    context.llm_instance.get_num_tokens = MagicMock(return_value=0)

    def fake_llm_factory(model_id: str):
        return context.llm_instance

    context.provider = LangChainChatProvider(
        name="boost-test",
        model_id="boost-model",
        llm_factory=fake_llm_factory,
        max_retries=1,
        supports_streaming=False,
    )

    context.project = MagicMock(spec=Project)
    context.plan = MagicMock(spec=Plan)
    context.plan.prompt = "Test prompt"
    context.contexts = [MagicMock(spec=Context)]
    context.contexts[0].content = "test content"

    context.progress_updates = []

    def progress_cb(pct: int) -> None:
        context.progress_updates.append(pct)

    context.progress_callback = progress_cb


# ---------------------------------------------------------------------------
# Lines 302-303: _resolve_token_cost — TypeError/ValueError when float() fails
# ---------------------------------------------------------------------------


@when("the usage tracker reports a non-numeric cost value")
def step_non_numeric_cost(context):
    tracker = MagicMock()
    tracker.total_cost = object()  # not convertible to float
    context.token_cost_result = context.provider._resolve_token_cost(tracker)


@then("resolving token cost should return None")
def step_token_cost_is_none(context):
    assert context.token_cost_result is None, (
        f"Expected None, got {context.token_cost_result!r}"
    )


# ---------------------------------------------------------------------------
# Lines 345-346: _extract_event — StopIteration for empty dict
# ---------------------------------------------------------------------------


@when("an empty event dictionary is passed to extract_event")
def step_empty_event_dict(context):
    context.node_name, context.payload = context.provider._extract_event({})


@then('the extracted node name should be "__unknown__" with an empty payload')
def step_assert_unknown_event(context):
    assert context.node_name == "__unknown__", (
        f"Expected '__unknown__', got {context.node_name!r}"
    )
    assert context.payload == {}, (
        f"Expected empty dict payload, got {context.payload!r}"
    )


# ---------------------------------------------------------------------------
# Lines 376-377: _estimate_token_usage — estimator raises exception
# ---------------------------------------------------------------------------


@when("the LLM token estimator raises an exception during estimation")
def step_estimator_raises(context):
    context.llm_instance.get_num_tokens = MagicMock(
        side_effect=RuntimeError("tokenizer crashed")
    )
    context.estimated_tokens = context.provider._estimate_token_usage(
        context.llm_instance, context.plan, context.contexts
    )


@then("the estimated token count should be zero")
def step_assert_zero_tokens(context):
    assert context.estimated_tokens == 0, (
        f"Expected 0, got {context.estimated_tokens!r}"
    )


# ---------------------------------------------------------------------------
# Lines 385-386: _estimate_token_usage — int() conversion fails
# ---------------------------------------------------------------------------


@when("the LLM token estimator returns a value that cannot be cast to int")
def step_estimator_returns_non_int(context):
    # Return an object whose __int__ raises TypeError
    class BadInt:
        def __int__(self):
            raise TypeError("cannot convert")

        def __str__(self):
            return "BadInt()"

    context.llm_instance.get_num_tokens = MagicMock(return_value=BadInt())
    context.estimated_tokens = context.provider._estimate_token_usage(
        context.llm_instance, context.plan, context.contexts
    )


# ---------------------------------------------------------------------------
# Line 450: _extract_error_message — returns None for empty list/tuple/set
# ---------------------------------------------------------------------------


@when("the error value is an empty list")
def step_error_empty_list(context):
    context.extracted_error = context.provider._extract_error_message([])


@when("the error value is an empty tuple")
def step_error_empty_tuple(context):
    context.extracted_error = context.provider._extract_error_message(())


@when("the error value is an empty set")
def step_error_empty_set(context):
    context.extracted_error = context.provider._extract_error_message(set())


@when("the error value is a list containing only None entries")
def step_error_list_of_nones(context):
    context.extracted_error = context.provider._extract_error_message([None, None])


@then("the extracted error message should be None")
def step_assert_extracted_error_none(context):
    assert context.extracted_error is None, (
        f"Expected None, got {context.extracted_error!r}"
    )


# ---------------------------------------------------------------------------
# Lines 36-38: _openai_callback discovery when module is available
# ---------------------------------------------------------------------------


@when("the langchain callbacks module exposes get_openai_callback")
def step_callback_discovery_success(context):
    """Simulate the module-level callback discovery logic (lines 30-38)."""
    fake_callback = MagicMock()
    fake_module = types.ModuleType("langchain.callbacks")
    fake_module.get_openai_callback = fake_callback  # type: ignore[attr-defined]

    # Re-execute the discovery logic that runs at module level
    discovered = None
    try:
        callbacks_module = fake_module
    except Exception:
        pass
    else:
        cb = getattr(callbacks_module, "get_openai_callback", None)
        if callable(cb):
            discovered = cb

    context.discovered_callback = discovered
    context.expected_callback = fake_callback


@then("the callback discovery logic should bind the callable")
def step_assert_callback_bound(context):
    assert context.discovered_callback is context.expected_callback, (
        "Expected the discovered callback to be the fake callable"
    )


@when("the langchain callbacks module exposes a non-callable get_openai_callback")
def step_callback_discovery_non_callable(context):
    """Simulate the discovery logic when the attribute is not callable."""
    fake_module = types.ModuleType("langchain.callbacks")
    fake_module.get_openai_callback = "not-a-callable"  # type: ignore[attr-defined]

    discovered = None
    try:
        callbacks_module = fake_module
    except Exception:
        pass
    else:
        cb = getattr(callbacks_module, "get_openai_callback", None)
        if callable(cb):
            discovered = cb

    context.discovered_callback = discovered


@then("the callback discovery logic should not bind any callable")
def step_assert_callback_not_bound(context):
    assert context.discovered_callback is None, (
        f"Expected None, got {context.discovered_callback!r}"
    )


# ---------------------------------------------------------------------------
# Lines 302-303 variant: tracker with no total_cost attribute at all
# ---------------------------------------------------------------------------


@when("the usage tracker has no total_cost attribute")
def step_tracker_no_cost_attr(context):
    tracker = MagicMock(spec=[])  # empty spec → no attributes
    context.token_cost_result = context.provider._resolve_token_cost(tracker)


# ---------------------------------------------------------------------------
# Token estimation with multiple contexts (lines 368-372 coverage boost)
# ---------------------------------------------------------------------------


@when("the LLM token estimator counts tokens for plan and multiple contexts")
def step_estimator_with_contexts(context):
    call_args_capture = []

    def fake_get_num_tokens(text: str) -> int:
        call_args_capture.append(text)
        return len(text.split())

    context.llm_instance.get_num_tokens = fake_get_num_tokens
    context.plan.prompt = "Build a widget"

    ctx1 = MagicMock(spec=Context)
    ctx1.content = "First context content"
    ctx2 = MagicMock(spec=Context)
    ctx2.content = "Second context content"
    ctx3 = MagicMock(spec=Context)
    ctx3.content = ""  # empty content should be skipped

    context.contexts = [ctx1, ctx2, ctx3]
    context.estimated_tokens = context.provider._estimate_token_usage(
        context.llm_instance, context.plan, context.contexts
    )
    context.estimator_call_args = call_args_capture


@then("the estimated token count should reflect the combined prompt text")
def step_assert_combined_tokens(context):
    assert context.estimated_tokens > 0, (
        f"Expected positive token count, got {context.estimated_tokens}"
    )
    # Verify the prompt text included both non-empty contexts
    call_text = context.estimator_call_args[0]
    assert "First context content" in call_text
    assert "Second context content" in call_text


# ---------------------------------------------------------------------------
# Generate changes exception path (lines 132-143)
# ---------------------------------------------------------------------------


@when("generate_changes is called and the workflow raises an exception")
def step_generate_raises_exception(context):
    with patch(
        "cleveragents.providers.llm.langchain_chat_provider.PlanGenerationGraph"
    ) as graph_cls:
        mock_graph = graph_cls.return_value
        mock_graph.invoke.side_effect = RuntimeError("workflow exploded")
        context.response = context.provider.generate_changes(
            context.project,
            context.plan,
            context.contexts,
            progress_callback=context.progress_callback,
        )


@then("the response should contain the exception message with zero changes")
def step_assert_error_response(context):
    assert context.response is not None
    assert context.response.changes == []
    assert context.response.error_message == "workflow exploded"
    assert context.response.model_used == "boost-model"


@then("progress should be reported as complete despite the failure")
def step_assert_progress_complete_on_failure(context):
    assert context.progress_updates[-1] == 100, (
        f"Expected last progress to be 100, got {context.progress_updates[-1]}"
    )
