"""Step implementations for decomposition_service_coverage_boost.feature.

Targets the uncovered lines in decomposition_service.py:
- Lines 236-239: language clustering fallback path
- Lines 242-245: size clustering fallback path
- Lines 249-264: complete fallback with FALLBACK node + warning log
- Line 320: record_decisions early return when decision_service is None
"""

from __future__ import annotations

from typing import Any
from unittest.mock import patch

from behave import given, then, when  # type: ignore[import-untyped]

from cleveragents.application.services.decomposition_models import (
    ClusterStrategy,
    DecompositionConfig,
    DecompositionNode,
    DecompositionResult,
)
from cleveragents.application.services.decomposition_service import (
    DecompositionService,
    _reset_counter,
)

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


@given("a fresh decomposition service instance")
def step_given_fresh_service(context: Any) -> None:
    context.svc = DecompositionService()
    context.result = None
    context.error = None
    context.mock_logger = None


# ---------------------------------------------------------------------------
# Givens - file sets with controlled clustering behaviour
# ---------------------------------------------------------------------------


@given(
    "a file set that directory-clusters into one group but language-clusters into many"
)
def step_given_dir_one_lang_many(context: Any) -> None:
    """Prepare a file list large enough to skip the leaf/min threshold,
    then patch clustering so directory returns 1 cluster and language
    returns multiple clusters."""
    # We need > min_files_per_subplan (default 10) files to reach
    # the clustering section, and the total tokens must exceed
    # max_tokens_per_subplan so the is_leaf check doesn't trigger early.
    context.fake_files = [f"/fake/dir/file_{i:04d}.py" for i in range(30)]

    # Directory clustering → single cluster (triggers language fallback)
    context._dir_patch_return = [context.fake_files[:]]
    # Language clustering → two clusters (stops fallback chain)
    half = len(context.fake_files) // 2
    context._lang_patch_return = [
        context.fake_files[:half],
        context.fake_files[half:],
    ]


@given("a file set where directory and language clustering each yield one group")
def step_given_dir_one_lang_one_size_many(context: Any) -> None:
    """Both directory and language clustering produce <= 1 cluster,
    forcing the service to try cluster_by_size."""
    context.fake_files = [f"/fake/dir/file_{i:04d}.py" for i in range(30)]

    # Directory → single cluster
    context._dir_patch_return = [context.fake_files[:]]
    # Language → single cluster
    context._lang_patch_return = [context.fake_files[:]]
    # Size → two clusters (stops fallback chain)
    half = len(context.fake_files) // 2
    context._size_patch_return = [
        context.fake_files[:half],
        context.fake_files[half:],
    ]


@given("a file set where all clustering strategies return empty lists")
def step_given_all_empty(context: Any) -> None:
    """All three clustering strategies return empty → FALLBACK node."""
    context.fake_files = [f"/fake/dir/file_{i:04d}.py" for i in range(30)]
    context._dir_patch_return = []
    context._lang_patch_return = []
    context._size_patch_return = []


@given("a decomposition service without a decision service")
def step_given_svc_no_ds(context: Any) -> None:
    context.svc = DecompositionService(decision_service=None)


@given("a trivial decomposition result")
def step_given_trivial_result(context: Any) -> None:
    _reset_counter()
    context.trivial_result = DecompositionResult(
        nodes=[
            DecompositionNode(
                node_id="dn-000001",
                parent_id=None,
                depth=0,
                file_paths=["a.py"],
                language=".py",
                directory_prefix="",
                estimated_tokens=10,
                strategy=ClusterStrategy.FALLBACK,
            ),
        ],
        max_depth_reached=0,
        total_files=1,
        metrics={"skipped": 1},
    )


# ---------------------------------------------------------------------------
# Whens
# ---------------------------------------------------------------------------


_LOGGER_PATH = "cleveragents.application.services.decomposition_service.logger"


def _run_with_patches(context: Any, config: DecompositionConfig | None = None) -> None:
    """Execute decomposition with controlled clustering patches."""
    cfg = config or DecompositionConfig(
        max_files_per_subplan=20,
        min_files_per_subplan=5,
        max_tokens_per_subplan=50,  # very low so leaf check won't fire early
        max_depth=4,
    )
    cluster_mod = (
        "cleveragents.application.services.decomposition_service.ClusteringStrategy"
    )
    token_mod = "cleveragents.application.services.decomposition_service.estimate_tokens_for_path"

    dir_return = getattr(context, "_dir_patch_return", None)
    lang_return = getattr(context, "_lang_patch_return", None)
    size_return = getattr(context, "_size_patch_return", None)

    def fake_dir(files: Any, max_per: Any, **kw: Any) -> list[list[str]]:
        if dir_return is not None:
            return dir_return
        return [files]  # pragma: no cover

    def fake_lang(files: Any, max_per: Any) -> list[list[str]]:
        if lang_return is not None:
            return lang_return
        return [files]  # pragma: no cover

    def fake_size(files: Any, max_tokens: Any, **kw: Any) -> list[list[str]]:
        if size_return is not None:
            return size_return
        return [files]  # pragma: no cover

    def fake_tokens(path: str) -> int:
        return 100  # each file → 100 tokens

    with (
        patch(f"{cluster_mod}.cluster_by_directory", side_effect=fake_dir),
        patch(f"{cluster_mod}.cluster_by_language", side_effect=fake_lang),
        patch(f"{cluster_mod}.cluster_by_size", side_effect=fake_size),
        patch(token_mod, side_effect=fake_tokens),
        patch(_LOGGER_PATH) as mock_logger,
    ):
        context.mock_logger = mock_logger
        context.result = context.svc.decompose(context.fake_files, cfg)


@when("I run decomposition on those files")
def step_when_run_decomposition(context: Any) -> None:
    _run_with_patches(context)


@when("I run decomposition on those files and collect metrics")
def step_when_run_decomposition_metrics(context: Any) -> None:
    _run_with_patches(context)


@when('I call record_decisions with plan id "{plan_id}"')
def step_when_record_decisions_no_ds(context: Any, plan_id: str) -> None:
    try:
        context.svc.record_decisions(plan_id, context.trivial_result)
        context.error = None
    except Exception as exc:
        context.error = exc


@when("I run decomposition with max_depth {depth:d} on those files")
def step_when_run_decomposition_max_depth(context: Any, depth: int) -> None:
    cfg = DecompositionConfig(
        max_files_per_subplan=20,
        min_files_per_subplan=5,
        max_tokens_per_subplan=50,
        max_depth=depth,
    )
    _run_with_patches(context, config=cfg)


# ---------------------------------------------------------------------------
# Thens
# ---------------------------------------------------------------------------


@then("at least one node should use the language clustering strategy")
def step_then_language_strategy(context: Any) -> None:
    assert context.result is not None, "decomposition result is None"
    strategies = {n.strategy for n in context.result.nodes}
    assert ClusterStrategy.LANGUAGE in strategies, (
        f"Expected LANGUAGE strategy in {strategies}"
    )


@then("at least one node should use the size clustering strategy")
def step_then_size_strategy(context: Any) -> None:
    assert context.result is not None, "decomposition result is None"
    strategies = {n.strategy for n in context.result.nodes}
    assert ClusterStrategy.SIZE in strategies, f"Expected SIZE strategy in {strategies}"


@then("the result should contain a fallback strategy node")
def step_then_fallback_node(context: Any) -> None:
    assert context.result is not None, "decomposition result is None"
    strategies = {n.strategy for n in context.result.nodes}
    assert ClusterStrategy.FALLBACK in strategies, (
        f"Expected FALLBACK strategy in {strategies}"
    )


@then("a decomposition fallback warning should have been logged")
def step_then_fallback_warning_logged(context: Any) -> None:
    mock_logger = context.mock_logger
    assert mock_logger is not None, "Mock logger was not set"
    assert mock_logger.warning.called, (
        "Expected logger.warning to be called for decomposition fallback"
    )
    args_str = str(mock_logger.warning.call_args_list)
    assert "decomposition_fallback" in args_str, (
        f"Expected 'decomposition_fallback' in warning calls, got: {args_str}"
    )


@then("no exception should be raised from record_decisions")
def step_then_no_exception(context: Any) -> None:
    assert context.error is None, f"Unexpected exception: {context.error}"


@then("the result total_nodes metric should be present")
def step_then_total_nodes_metric(context: Any) -> None:
    assert context.result is not None
    assert "total_nodes" in context.result.metrics, (
        f"Missing total_nodes in {context.result.metrics}"
    )


@then("the result leaf_nodes metric should be present")
def step_then_leaf_nodes_metric(context: Any) -> None:
    assert context.result is not None
    assert "leaf_nodes" in context.result.metrics, (
        f"Missing leaf_nodes in {context.result.metrics}"
    )


@then("the result max_depth_reached should be at most {n:d}")
def step_then_max_depth_at_most(context: Any, n: int) -> None:
    assert context.result is not None
    assert context.result.max_depth_reached <= n, (
        f"Expected max_depth_reached <= {n}, got {context.result.max_depth_reached}"
    )
