"""Step definitions for features/acms_fusion.feature.

Tests the ACMS StrategyCoordinator and FusionEngine facades, covering
parallel strategy coordination, budget allocation, dedup, depth
resolution, greedy knapsack packing, and budget overage guard.
"""

from __future__ import annotations

from collections.abc import Sequence
from typing import Any

from behave import given, then, when
from behave.runner import Context

from cleveragents.application.services.acms_pipeline import (
    CircuitBreaker,
    ParallelStrategyExecutor,
)
from cleveragents.application.services.acms_service import StrategyCapabilities
from cleveragents.application.services.fusion_engine import (
    FusionConfig,
    FusionEngine,
    FusionResult,
)
from cleveragents.application.services.strategy_coordinator import (
    CoordinatorConfig,
    StrategyCoordinator,
)
from cleveragents.domain.models.core.context_fragment import (
    ContextBudget,
    ContextFragment,
    FragmentProvenance,
)

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

_DEFAULT_PROV = FragmentProvenance(resource_uri="test://fusion")


def _make_frag(**kwargs: Any) -> ContextFragment:
    """Create a ContextFragment with test defaults."""
    kwargs.setdefault("uko_node", "test://fusion/default")
    kwargs.setdefault("token_count", 10)
    kwargs.setdefault("provenance", _DEFAULT_PROV)
    return ContextFragment(**kwargs)


class _FusionTestStrategy:
    """Test strategy with configurable confidence."""

    def __init__(self, name: str = "test_strat", confidence: float = 0.7) -> None:
        self._name = name
        self._confidence = confidence

    @property
    def name(self) -> str:
        return self._name

    @property
    def capabilities(self) -> StrategyCapabilities:
        return StrategyCapabilities()

    def can_handle(self, request: dict[str, Any]) -> float:
        return self._confidence

    def assemble(
        self,
        fragments: Sequence[ContextFragment],
        budget: ContextBudget,
    ) -> Sequence[ContextFragment]:
        result: list[ContextFragment] = []
        total = 0
        for frag in fragments:
            if total + frag.token_count <= budget.available_tokens:
                result.append(frag)
                total += frag.token_count
        return result

    def explain(self) -> str:
        return f"Test strategy '{self._name}' with confidence {self._confidence}."


# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------


@given("the fusion modules are available")
def step_fusion_modules_available(context: Context) -> None:
    """Ensure all fusion modules are importable."""
    pass


@given("fusion test strategies with confidences {c1:g} and {c2:g}")
def step_fusion_strategies(context: Context, c1: float, c2: float) -> None:
    context.fusion_strategies = [
        _FusionTestStrategy("strat_a", c1),
        _FusionTestStrategy("strat_b", c2),
    ]


@given("fusion test fragments with token counts {t1:d} and {t2:d}")
def step_fusion_fragments(context: Context, t1: int, t2: int) -> None:
    context.fusion_fragments = [
        _make_frag(
            uko_node="project://fusion/a.py",
            content="alpha content",
            token_count=t1,
            relevance_score=0.9,
        ),
        _make_frag(
            uko_node="project://fusion/b.py",
            content="beta content",
            token_count=t2,
            relevance_score=0.5,
        ),
    ]


@given('a coordinator with a pre-broken circuit for "{name}"')
def step_coordinator_broken_circuit(context: Context, name: str) -> None:
    cb = CircuitBreaker(failure_threshold=1)
    cb.record_failure(name)
    executor = ParallelStrategyExecutor(circuit_breaker=cb)
    context.broken_coordinator = StrategyCoordinator(executor=executor)
    context.broken_strategy_name = name


@given("a coordinator with per-strategy max cap of {cap:d}")
def step_coordinator_with_cap(context: Context, cap: int) -> None:
    config = CoordinatorConfig(per_strategy_max_cap=cap)
    context.capped_coordinator = StrategyCoordinator(config=config)


@given("fusion fragments with duplicates by URI and content")
def step_fusion_dup_fragments(context: Context) -> None:
    context.fusion_dup_fragments = [
        _make_frag(
            uko_node="project://fusion/dup.py",
            content="same content here",
            token_count=50,
            relevance_score=0.9,
        ),
        _make_frag(
            uko_node="project://fusion/dup.py",
            content="same content here",
            token_count=50,
            relevance_score=0.7,
        ),
        _make_frag(
            uko_node="project://fusion/other.py",
            content="different content",
            token_count=50,
            relevance_score=0.5,
        ),
    ]


@given("fusion fragments with same UKO node at depths {d1:d} and {d2:d}")
def step_fusion_depth_fragments(context: Context, d1: int, d2: int) -> None:
    context.fusion_depth_fragments = [
        _make_frag(
            uko_node="project://fusion/deep.py",
            content="shallow content",
            token_count=50,
            detail_depth=d1,
            relevance_score=0.8,
        ),
        _make_frag(
            uko_node="project://fusion/deep.py",
            content="deep content with more detail",
            token_count=80,
            detail_depth=d2,
            relevance_score=0.7,
        ),
        _make_frag(
            uko_node="project://fusion/other.py",
            content="other content",
            token_count=50,
            detail_depth=3,
            relevance_score=0.6,
        ),
    ]


@given("fusion fragments totaling {total:d} tokens")
def step_fusion_total_fragments(context: Context, total: int) -> None:
    count = total // 100
    if count < 1:
        count = 1
    per_token = total // count
    context.fusion_total_fragments = [
        _make_frag(
            uko_node=f"project://fusion/file{i}.py",
            content=f"content_{i}",
            token_count=per_token,
            relevance_score=round(0.9 - i * 0.1, 2),
        )
        for i in range(count)
    ]


@given("fusion fragments with equal relevance but different depths")
def step_fusion_tiebreaker_fragments(context: Context) -> None:
    context.fusion_tiebreaker_fragments = [
        _make_frag(
            uko_node="project://fusion/t1.py",
            content="content_t1",
            token_count=50,
            detail_depth=3,
            relevance_score=0.8,
        ),
        _make_frag(
            uko_node="project://fusion/t2.py",
            content="content_t2",
            token_count=30,
            detail_depth=7,
            relevance_score=0.8,
        ),
        _make_frag(
            uko_node="project://fusion/t3.py",
            content="content_t3",
            token_count=40,
            detail_depth=5,
            relevance_score=0.8,
        ),
    ]


@given("a fusion engine with overage guard enabled")
def step_fusion_engine_overage(context: Context) -> None:
    config = FusionConfig(overage_guard_enabled=True, min_fragment_tokens=1)
    context.overage_engine = FusionEngine(config=config)


@given("fusion fragments that cause budget overage")
def step_fusion_overage_fragments(context: Context) -> None:
    # Create fragments where packer might allow overage.
    # The overage guard is a safety net for when fragments pass through
    # the packer but still exceed budget.  We test this by using a custom
    # FusionEngine with a pass-through packer that doesn't enforce budget.
    context.overage_fragments = [
        _make_frag(
            uko_node=f"project://fusion/ov{i}.py",
            content=f"overage_content_{i}",
            token_count=40,
            relevance_score=round(0.9 - i * 0.2, 2),
        )
        for i in range(4)
    ]


@given("fusion fragments totaling exactly {total:d} tokens")
def step_fusion_exact_fragments(context: Context, total: int) -> None:
    context.exact_fragments = [
        _make_frag(
            uko_node="project://fusion/exact.py",
            content="exact content",
            token_count=total,
            relevance_score=0.9,
        ),
    ]


# ---------------------------------------------------------------------------
# When steps — StrategyCoordinator
# ---------------------------------------------------------------------------


@when("I coordinate with a budget of {budget:d} tokens")
def step_coordinate(context: Context, budget: int) -> None:
    coordinator = StrategyCoordinator()
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    context.coord_result = coordinator.coordinate(
        request={},
        strategies=context.fusion_strategies,
        budget=b,
        fragments=context.fusion_fragments,
    )


@when("I coordinate with no strategies and a budget of {budget:d} tokens")
def step_coordinate_no_strategies(context: Context, budget: int) -> None:
    coordinator = StrategyCoordinator()
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    context.coord_result = coordinator.coordinate(
        request={},
        strategies=[],
        budget=b,
        fragments=context.fusion_fragments,
    )


@when("I coordinate with the circuit-broken strategy")
def step_coordinate_broken(context: Context) -> None:
    strategy = _FusionTestStrategy(context.broken_strategy_name, 0.7)
    b = ContextBudget(max_tokens=200, reserved_tokens=0)
    context.coord_result = context.broken_coordinator.coordinate(
        request={},
        strategies=[strategy],
        budget=b,
        fragments=context.fusion_fragments,
    )


@when("I coordinate with a budget of {budget:d} tokens using the capped coordinator")
def step_coordinate_capped(context: Context, budget: int) -> None:
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    context.coord_result = context.capped_coordinator.coordinate(
        request={},
        strategies=context.fusion_strategies,
        budget=b,
        fragments=context.fusion_fragments,
    )


# ---------------------------------------------------------------------------
# When steps — FusionEngine
# ---------------------------------------------------------------------------


@when("I fuse with a budget of {budget:d} tokens")
def step_fuse(context: Context, budget: int) -> None:
    engine = FusionEngine(config=FusionConfig(min_fragment_tokens=1))
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    # Use whichever fragments are available
    frags = getattr(
        context,
        "fusion_dup_fragments",
        getattr(
            context,
            "fusion_depth_fragments",
            getattr(
                context,
                "fusion_total_fragments",
                getattr(context, "fusion_tiebreaker_fragments", []),
            ),
        ),
    )
    context.fusion_result = engine.fuse(frags, b)
    context.fusion_input_count = len(frags)


@when("I fuse with the overage-prone fragments and a budget of {budget:d} tokens")
def step_fuse_overage(context: Context, budget: int) -> None:
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    # Test the overage guard directly since the packer already respects budget.
    # We call _budget_overage_guard on fragments that exceed the budget.
    surviving, dropped = FusionEngine._budget_overage_guard(
        list(context.overage_fragments), b
    )
    total_tokens = sum(f.token_count for f in surviving)
    available = b.available_tokens
    context.fusion_result = FusionResult(
        fragments=surviving,
        input_count=len(context.overage_fragments),
        dedup_count=0,
        depth_resolved_count=0,
        packed_count=len(surviving),
        dropped_by_overage_guard=dropped,
        total_tokens=total_tokens,
        budget_utilization=round(
            min(total_tokens / available if available > 0 else 0.0, 1.0), 4
        ),
    )


@when("I fuse with the exact fragments and a budget of {budget:d} tokens")
def step_fuse_exact(context: Context, budget: int) -> None:
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    context.fusion_result = context.overage_engine.fuse(context.exact_fragments, b)


@when("I fuse with no fragments and a budget of {budget:d} tokens")
def step_fuse_empty(context: Context, budget: int) -> None:
    engine = FusionEngine()
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    context.fusion_result = engine.fuse([], b)


@when("I coordinate then fuse with a budget of {budget:d} tokens")
def step_coordinate_then_fuse(context: Context, budget: int) -> None:
    coordinator = StrategyCoordinator()
    b = ContextBudget(max_tokens=budget, reserved_tokens=0)
    coord_result = coordinator.coordinate(
        request={},
        strategies=context.fusion_strategies,
        budget=b,
        fragments=context.fusion_fragments,
    )
    engine = FusionEngine(config=FusionConfig(min_fragment_tokens=1))
    context.fusion_result = engine.fuse(coord_result.fragments, b)
    context.integration_budget = budget


# ---------------------------------------------------------------------------
# When steps — Config
# ---------------------------------------------------------------------------


@when("I create a default CoordinatorConfig")
def step_create_coord_config(context: Context) -> None:
    context.coord_config = CoordinatorConfig()


@when("I create a default FusionConfig")
def step_create_fusion_config(context: Context) -> None:
    context.fusion_config = FusionConfig()


# ---------------------------------------------------------------------------
# Then steps — StrategyCoordinator
# ---------------------------------------------------------------------------


@then("the coordination result should contain fragments")
def step_coord_has_fragments(context: Context) -> None:
    assert len(context.coord_result.fragments) > 0, (
        "Expected fragments from coordination"
    )


@then("the coordination result should list strategies used")
def step_coord_strategies_used(context: Context) -> None:
    assert len(context.coord_result.strategies_used) > 0, (
        "Expected strategies used in result"
    )


@then("the coordination result should contain {count:d} fragments")
def step_coord_count(context: Context, count: int) -> None:
    assert len(context.coord_result.fragments) == count, (
        f"Expected {count} fragments, got {len(context.coord_result.fragments)}"
    )


@then("the coordination result should report circuit-broken strategies")
def step_coord_circuit_broken(context: Context) -> None:
    assert len(context.coord_result.circuit_broken) > 0, (
        "Expected circuit-broken strategies in result"
    )


@then("the allocation for the first strategy should be approximately {tokens:d}")
def step_coord_alloc_first(context: Context, tokens: int) -> None:
    first_alloc = context.coord_result.allocations[0][2]
    assert abs(first_alloc - tokens) <= 1, (
        f"First allocation {first_alloc}, expected ~{tokens}"
    )


@then("the allocation for the second strategy should be approximately {tokens:d}")
def step_coord_alloc_second(context: Context, tokens: int) -> None:
    second_alloc = context.coord_result.allocations[1][2]
    assert abs(second_alloc - tokens) <= 1, (
        f"Second allocation {second_alloc}, expected ~{tokens}"
    )


@then("the total allocation should equal {total:d}")
def step_coord_alloc_total(context: Context, total: int) -> None:
    actual = sum(a[2] for a in context.coord_result.allocations)
    assert actual == total, f"Total allocation {actual} != {total}"


@then("no strategy allocation should exceed {cap:d} tokens")
def step_coord_cap_enforced(context: Context, cap: int) -> None:
    for name, _, tokens in context.coord_result.allocations:
        assert tokens <= cap, f"Strategy {name} allocated {tokens} > cap {cap}"


# ---------------------------------------------------------------------------
# Then steps — FusionEngine
# ---------------------------------------------------------------------------


@then("the fusion result dedup count should be greater than 0")
def step_fusion_dedup_count(context: Context) -> None:
    assert context.fusion_result.dedup_count > 0, (
        f"Expected dedup_count > 0, got {context.fusion_result.dedup_count}"
    )


@then("the fusion result should contain fewer fragments than input")
def step_fusion_fewer(context: Context) -> None:
    assert len(context.fusion_result.fragments) < context.fusion_input_count, (
        f"Expected fewer fragments ({len(context.fusion_result.fragments)}) "
        f"than input ({context.fusion_input_count})"
    )


@then("the fusion result should retain the depth {depth:d} fragment")
def step_fusion_depth_retained(context: Context, depth: int) -> None:
    depths = [f.detail_depth for f in context.fusion_result.fragments]
    # After scoring, the depth values should still be present
    # The max-depth fragment should survive for the node
    node_frags = [f for f in context.fusion_result.fragments if "deep" in f.uko_node]
    if node_frags:
        max_depth = max(f.detail_depth for f in node_frags)
        assert max_depth == depth, (
            f"Expected depth {depth} retained, got max {max_depth} in {depths}"
        )


@then("the fusion result depth resolved count should be greater than 0")
def step_fusion_depth_resolved(context: Context) -> None:
    assert context.fusion_result.depth_resolved_count > 0, (
        f"Expected depth_resolved > 0, got {context.fusion_result.depth_resolved_count}"
    )


@then("the fusion result total tokens should be at most {max_tokens:d}")
def step_fusion_within_budget(context: Context, max_tokens: int) -> None:
    assert context.fusion_result.total_tokens <= max_tokens, (
        f"Total tokens {context.fusion_result.total_tokens} > {max_tokens}"
    )


@then(
    "the fusion result fragments should be ordered by depth descending "
    "then token count ascending"
)
def step_fusion_tiebreakers(context: Context) -> None:
    frags = context.fusion_result.fragments
    if len(frags) <= 1:
        return
    # Verify tie-breaker ordering within same relevance group
    for i in range(len(frags) - 1):
        a, b = frags[i], frags[i + 1]
        if (
            abs(a.relevance_score - b.relevance_score) < 0.001
            and a.detail_depth == b.detail_depth
        ):
            # Same relevance and depth: token count ascending
            assert a.token_count <= b.token_count, (
                f"Token count tie-breaker failed: {a.token_count} > {b.token_count}"
            )


@then("the fusion result dropped by overage guard should be greater than 0")
def step_fusion_overage_dropped(context: Context) -> None:
    assert context.fusion_result.dropped_by_overage_guard > 0, (
        f"Expected drops > 0, got {context.fusion_result.dropped_by_overage_guard}"
    )


@then("the fusion result dropped by overage guard should be {count:d}")
def step_fusion_overage_exact(context: Context, count: int) -> None:
    assert context.fusion_result.dropped_by_overage_guard == count, (
        f"Expected {count} drops, got {context.fusion_result.dropped_by_overage_guard}"
    )


@then("the fusion result should contain {count:d} fragments")
def step_fusion_count(context: Context, count: int) -> None:
    assert len(context.fusion_result.fragments) == count, (
        f"Expected {count} fragments, got {len(context.fusion_result.fragments)}"
    )


@then("the fusion result input count should be {count:d}")
def step_fusion_input(context: Context, count: int) -> None:
    assert context.fusion_result.input_count == count, (
        f"Expected input_count {count}, got {context.fusion_result.input_count}"
    )


@then("the final fused result should contain fragments within budget")
def step_integration_result(context: Context) -> None:
    assert len(context.fusion_result.fragments) > 0, (
        "Expected fragments from integration"
    )
    assert context.fusion_result.total_tokens <= context.integration_budget, (
        f"Total tokens {context.fusion_result.total_tokens} "
        f"> budget {context.integration_budget}"
    )


# ---------------------------------------------------------------------------
# Then steps — Config
# ---------------------------------------------------------------------------


@then("the config min_useful_budget should be {val:d}")
def step_config_min_budget(context: Context, val: int) -> None:
    assert context.coord_config.min_useful_budget == val


@then("the config executor_timeout should be {val:g}")
def step_config_timeout(context: Context, val: float) -> None:
    assert context.coord_config.executor_timeout == val


@then("the config per_strategy_max_cap should be None")
def step_config_cap_none(context: Context) -> None:
    assert context.coord_config.per_strategy_max_cap is None


@then("the fusion config overage_guard_enabled should be True")
def step_fusion_config_overage(context: Context) -> None:
    assert context.fusion_config.overage_guard_enabled is True


@then("the fusion config min_fragment_tokens should be {val:d}")
def step_fusion_config_min_tokens(context: Context, val: int) -> None:
    assert context.fusion_config.min_fragment_tokens == val
