"""Step implementations for large_project_decomposition.feature.

Tests hierarchical decomposition, clustering heuristics, dependency
closure, DAG ordering, cycle detection, fallback, threshold guard,
config validation, metrics, decision recording, and deterministic
ordering.
"""

from __future__ import annotations

import tempfile
from typing import Any
from unittest.mock import MagicMock

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

from cleveragents.application.services.decomposition_graph import (
    DependencyClosureComputer,
)
from cleveragents.application.services.decomposition_models import (
    DecompositionConfig,
)
from cleveragents.application.services.decomposition_service import (
    DecompositionService,
)
from features.steps.decomposition_test_helpers import (
    chain_graph,
    cycle_graph,
    diamond_graph,
    make_dir_files,
    make_files,
    make_lang_files,
    make_same_dir_files,
)

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


@given("a decomposition service")
def step_given_decomposition_service(context: Any) -> None:
    context.svc = DecompositionService()
    context.tmpdir = tempfile.mkdtemp(prefix="decompose-")
    context.result = None
    context.error = None
    context.graph = None
    context.closure = None
    context.topo_order = None
    context.cycles = None
    context.files = None


# ---------------------------------------------------------------------------
# Projects
# ---------------------------------------------------------------------------


@given("a project with {count:d} files across {levels:d} directory levels")
def step_given_multi_level_project(context: Any, count: int, levels: int) -> None:
    context.files = make_files(context.tmpdir, count, levels)


@given("a project with {count:d} files in a single directory")
def step_given_single_dir_project(context: Any, count: int) -> None:
    context.files = make_same_dir_files(context.tmpdir, count)


@given("a project with files totalling {tokens:d} estimated tokens")
def step_given_token_project(context: Any, tokens: int) -> None:
    file_count = tokens // 50
    context.files = make_files(context.tmpdir, file_count, 3)


@given("a project with {py:d} Python files and {ts:d} TypeScript files")
def step_given_lang_project(context: Any, py: int, ts: int) -> None:
    context.files = make_lang_files(context.tmpdir, py, ts)


@given("a project with files in src/api and src/web directories")
def step_given_dir_project(context: Any) -> None:
    context.files = make_dir_files(context.tmpdir)


@given("a project with {count:d} files all in the same directory with same extension")
def step_given_same_dir_same_ext(context: Any, count: int) -> None:
    context.files = make_same_dir_files(context.tmpdir, count)


@given("a project with {count:d} files")
def step_given_small_project(context: Any, count: int) -> None:
    context.files = make_files(context.tmpdir, count, 1)


# ---------------------------------------------------------------------------
# Dependency graphs
# ---------------------------------------------------------------------------


@given("a dependency graph with chain A -> B -> C -> D")
def step_given_chain_graph(context: Any) -> None:
    context.graph = chain_graph("A", "B", "C", "D")


@given("a dependency graph with edges A->B, A->C, B->D, C->D")
def step_given_diamond_graph(context: Any) -> None:
    context.graph = diamond_graph()


@given("a dependency graph with cycle A -> B -> C -> A")
def step_given_cycle_graph(context: Any) -> None:
    context.graph = cycle_graph()


# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------


@given("default decomposition config")
def step_given_default_config(context: Any) -> None:
    context.decomp_config = DecompositionConfig()


@given("a decomposition service with a mock decision service")
def step_given_svc_with_mock_ds(context: Any) -> None:
    context.mock_ds = MagicMock()
    context.svc = DecompositionService(decision_service=context.mock_ds)
    context.tmpdir = tempfile.mkdtemp(prefix="decompose-")


@given("two identical file sets presented in different order")
def step_given_two_file_sets(context: Any) -> None:
    context.files = make_files(context.tmpdir, 200, 3)
    context.files_reversed = list(reversed(context.files))


# ---------------------------------------------------------------------------
# When — decompose
# ---------------------------------------------------------------------------


@when("I decompose with max_depth {depth:d}")
def step_when_decompose_max_depth(context: Any, depth: int) -> None:
    cfg = DecompositionConfig(max_depth=depth, max_files_per_subplan=50)
    context.result = context.svc.decompose(context.files, cfg)


@when("I decompose with max_files_per_subplan {n:d}")
def step_when_decompose_max_files(context: Any, n: int) -> None:
    cfg = DecompositionConfig(max_files_per_subplan=n)
    context.result = context.svc.decompose(context.files, cfg)


@when("I decompose with max_tokens_per_subplan {n:d}")
def step_when_decompose_max_tokens(context: Any, n: int) -> None:
    cfg = DecompositionConfig(max_tokens_per_subplan=n)
    context.result = context.svc.decompose(context.files, cfg)


@when("I decompose with language clustering")
def step_when_decompose_language(context: Any) -> None:
    cfg = DecompositionConfig(max_files_per_subplan=80)
    context.result = context.svc.decompose(context.files, cfg)


@when("I decompose with directory clustering")
def step_when_decompose_directory(context: Any) -> None:
    cfg = DecompositionConfig(max_files_per_subplan=30)
    context.result = context.svc.decompose(context.files, cfg)


@when("I decompose with min_files_per_subplan {n:d}")
def step_when_decompose_min_files(context: Any, n: int) -> None:
    cfg = DecompositionConfig(min_files_per_subplan=n)
    context.result = context.svc.decompose(context.files, cfg)


@when("I decompose with default config")
def step_when_decompose_default(context: Any) -> None:
    context.result = context.svc.decompose(context.files)


@when('I decompose and record decisions for plan "{plan_id}"')
def step_when_decompose_and_record(context: Any, plan_id: str) -> None:
    context.result = context.svc.decompose(context.files)
    context.svc.record_decisions(plan_id, context.result)


@when("I decompose both")
def step_when_decompose_both(context: Any) -> None:
    svc = DecompositionService()
    context.result_a = svc.decompose(context.files)
    svc2 = DecompositionService()
    context.result_b = svc2.decompose(context.files_reversed)


# ---------------------------------------------------------------------------
# When — dependency ops
# ---------------------------------------------------------------------------


@when("I compute closure from A with no cutoff")
def step_when_closure_no_cutoff(context: Any) -> None:
    computer = DependencyClosureComputer()
    context.closure = computer.compute_closure(context.graph, ["A"])


@when("I compute closure from A with cutoff {n:d}")
def step_when_closure_cutoff(context: Any, n: int) -> None:
    computer = DependencyClosureComputer()
    context.closure = computer.compute_closure(context.graph, ["A"], cutoff=n)


@when("I compute the topological sort")
def step_when_topo_sort(context: Any) -> None:
    computer = DependencyClosureComputer()
    context.topo_order = computer.topological_sort(context.graph)


@when("I check for cycles")
def step_when_detect_cycles(context: Any) -> None:
    computer = DependencyClosureComputer()
    context.cycles = computer.detect_cycles(context.graph)


@when("I compute closure from A twice")
def step_when_closure_twice(context: Any) -> None:
    computer = DependencyClosureComputer()
    context.closure_1 = computer.compute_closure(context.graph, ["A"])
    context.closure_2 = computer.compute_closure(context.graph, ["A"])


# ---------------------------------------------------------------------------
# When — config
# ---------------------------------------------------------------------------


@when("I create a config with max_depth {n:d}")
def step_when_create_bad_config(context: Any, n: int) -> None:
    try:
        DecompositionConfig(max_depth=n)
    except ValueError as exc:
        context.error = exc


# ---------------------------------------------------------------------------
# Then — decomposition assertions
# ---------------------------------------------------------------------------


@then("the decomposition result should have max_depth_reached >= {n:d}")
def step_then_max_depth_ge(context: Any, n: int) -> None:
    assert context.result is not None
    assert context.result.max_depth_reached >= n, (
        f"expected >= {n}, got {context.result.max_depth_reached}"
    )


@then("the decomposition result should have max_depth_reached <= {n:d}")
def step_then_max_depth_le(context: Any, n: int) -> None:
    assert context.result is not None
    assert context.result.max_depth_reached <= n


@then("every leaf node should have at most {n:d} files")
def step_then_leaf_files_le(context: Any, n: int) -> None:
    assert context.result is not None
    for node in context.result.nodes:
        if node.is_leaf:
            assert len(node.file_paths) <= n, (
                f"leaf {node.node_id} has {len(node.file_paths)} files"
            )


@then("every leaf node should have estimated tokens at most {n:d}")
def step_then_leaf_tokens_le(context: Any, n: int) -> None:
    assert context.result is not None
    for node in context.result.nodes:
        if node.is_leaf:
            assert node.estimated_tokens <= n, (
                f"leaf {node.node_id} has {node.estimated_tokens} tokens"
            )


@then("at least two clusters should have different dominant languages")
def step_then_diff_languages(context: Any) -> None:
    assert context.result is not None
    langs = {n.language for n in context.result.nodes if n.is_leaf}
    assert len(langs) >= 2, f"only {langs}"


@then("at least two clusters should have different directory prefixes")
def step_then_diff_dirs(context: Any) -> None:
    assert context.result is not None
    prefixes = {n.directory_prefix for n in context.result.nodes if n.is_leaf}
    assert len(prefixes) >= 2, f"only {prefixes}"


@then("the decomposition should produce a single root node")
def step_then_single_root(context: Any) -> None:
    assert context.result is not None
    roots = [n for n in context.result.nodes if n.parent_id is None]
    assert len(roots) >= 1


@then("the metrics should contain skipped equal to {n:d}")
def step_then_metrics_skipped(context: Any, n: int) -> None:
    assert context.result is not None
    assert context.result.metrics.get("skipped") == n


# ---------------------------------------------------------------------------
# Then — closure / topo / cycle
# ---------------------------------------------------------------------------


@then("the closure should contain A, B, C, D")
def step_then_closure_abcd(context: Any) -> None:
    assert context.closure is not None
    assert {"A", "B", "C", "D"} <= context.closure


@then("the closure should contain at most {n:d} files")
def step_then_closure_le(context: Any, n: int) -> None:
    assert context.closure is not None
    assert len(context.closure) <= n


@then("A should appear before B and C")
def step_then_a_before_bc(context: Any) -> None:
    order = context.topo_order
    assert order.index("A") < order.index("B")
    assert order.index("A") < order.index("C")


@then("B and C should appear before D")
def step_then_bc_before_d(context: Any) -> None:
    order = context.topo_order
    assert order.index("B") < order.index("D")
    assert order.index("C") < order.index("D")


@then("at least one cycle should be detected")
def step_then_cycles_detected(context: Any) -> None:
    assert context.cycles is not None
    assert len(context.cycles) >= 1


@then("no cycles should be detected")
def step_then_no_cycles(context: Any) -> None:
    assert context.cycles is not None
    assert len(context.cycles) == 0


# ---------------------------------------------------------------------------
# Then — config
# ---------------------------------------------------------------------------


@then("max_depth should be {n:d}")
def step_then_cfg_max_depth(context: Any, n: int) -> None:
    assert context.decomp_config.max_depth == n


@then("max_files_per_subplan should be {n:d}")
def step_then_cfg_max_files(context: Any, n: int) -> None:
    assert context.decomp_config.max_files_per_subplan == n


@then("max_tokens_per_subplan should be {n:d}")
def step_then_cfg_max_tokens(context: Any, n: int) -> None:
    assert context.decomp_config.max_tokens_per_subplan == n


@then("min_files_per_subplan should be {n:d}")
def step_then_cfg_min_files(context: Any, n: int) -> None:
    assert context.decomp_config.min_files_per_subplan == n


@then("a decomposition ValueError should be raised")
def step_then_decomposition_value_error(context: Any) -> None:
    assert context.error is not None
    assert isinstance(context.error, ValueError)


# ---------------------------------------------------------------------------
# Then — metrics
# ---------------------------------------------------------------------------


@then("metrics should contain total_nodes")
def step_then_metrics_total(context: Any) -> None:
    assert "total_nodes" in context.result.metrics


@then("metrics should contain leaf_nodes")
def step_then_metrics_leaves(context: Any) -> None:
    assert "leaf_nodes" in context.result.metrics


@then("metrics should contain max_depth")
def step_then_metrics_depth(context: Any) -> None:
    assert "max_depth" in context.result.metrics


# ---------------------------------------------------------------------------
# Then — decision recording
# ---------------------------------------------------------------------------


@then("at least one strategy_choice decision should be recorded")
def step_then_strategy_choice(context: Any) -> None:
    calls = context.mock_ds.record_decision.call_args_list
    types = [
        str(c.kwargs.get("decision_type", c.args[1] if len(c.args) > 1 else ""))
        for c in calls
    ]
    assert any("strategy_choice" in t for t in types)


@then("at least one subplan_spawn decision should be recorded")
def step_then_subplan_spawn(context: Any) -> None:
    calls = context.mock_ds.record_decision.call_args_list
    types = [
        str(c.kwargs.get("decision_type", c.args[1] if len(c.args) > 1 else ""))
        for c in calls
    ]
    assert any("subplan_spawn" in t for t in types)


# ---------------------------------------------------------------------------
# Then — deterministic
# ---------------------------------------------------------------------------


@then("both decomposition results should have identical node ordering")
def step_then_identical_ordering(context: Any) -> None:
    ids_a = [n.node_id for n in context.result_a.nodes]
    ids_b = [n.node_id for n in context.result_b.nodes]
    assert len(ids_a) == len(ids_b)


# ---------------------------------------------------------------------------
# Then — memoization
# ---------------------------------------------------------------------------


@then("the second computation should return the same result")
def step_then_same_result(context: Any) -> None:
    assert context.closure_1 == context.closure_2
