"""Step definitions for plan_executor_tier_hydration.feature (#10938).

Verifies that PlanExecutor.run_strategize integrates tier hydration correctly:
- Invoking hydrate_tiers_for_plan when tier_service, project_repository and
  resource_registry are wired in.
- Skipping hydration when hot fragments already exist (cache path).
- Catching hydration failures non-fatally so strategize continues.

Forgejo: #10938
"""

from __future__ import annotations

import logging
from datetime import datetime, UTC
from types import SimpleNamespace
from unittest.mock import MagicMock, patch

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

from cleveragents.application.services.plan_executor import (
    PlanExecutor,
    StrategizeResult,
    StrategyDecision,
)
from cleveragents.application.services.plan_executor import PlanPhase
from cleveragents.domain.models.acms.tiers import TieredFragment
from cleveragents.domain.models.core.plan import ProjectLink

utc = UTC
logger = logging.getLogger(__name__)


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

_FAKE_PROJECTS = ["local/test-project-1", "local/test-project-2"]


def _make_project_links():  # type: ignore[no-untyped-def]
    return [ProjectLink(project_name=name) for name in _FAKE_PROJECTS]


def _make_mock_plan(definition_of_done="Build something", invariants=None):  # type: ignore[no-untyped-def]
    return SimpleNamespace(
        project_links=_make_project_links(),
        definition_of_done=definition_of_done,
        phase=PlanPhase.STRATEGIZE,
        invariants=invariants or [],
        timestamps=SimpleNamespace(updated_at=datetime.now(tz=utc)),
    )


def _make_tier(with_fragments=True):  # type: ignore[no-untyped-def]
    """Create a ContextTierService mock."""
    tier = MagicMock()
    if with_fragments:
        tier.get_hot_fragments.return_value = [
            TieredFragment(
                fragment_id=f"frag-{i}",
                content="file content",
                tier="hot",  # type: ignore[arg-type]
                resource_id="01TEST00000000000000000001",
                project_name="local/test-project",
                token_count=50,
                metadata={"path": f"mod_{i}.py"},
            )
            for i in range(2)
        ]
    else:
        tier.get_hot_fragments.return_value = []
    return tier


def _make_executor(tier_svc=None):  # type: ignore[no-untyped-def]
    """Build a minimal PlanExecutor from pieces."""
    mock_lifecycle = MagicMock()
    mock_ace = MagicMock()
    # Provide a non-empty decisions list so that the "contains decisions"
    # Then step can assert len(decisions) > 0 (meaningful assertion, not just
    # isinstance check which passes even with decisions=[]).
    mock_decision = StrategyDecision(
        decision_id="01HQMOCK00000000000000001",
        step_text="Mock decision for test",
        sequence=0,
    )
    mock_ace.execute.return_value = StrategizeResult(
        decisions=[mock_decision],
        decision_root_id="01HQMOCK00000000000000001",
    )

    executor = PlanExecutor(
        lifecycle_service=mock_lifecycle,
        strategize_actor=mock_ace,
        tier_service=tier_svc,
        project_repository=MagicMock(),
        resource_registry=MagicMock(),
    )

    mock_plan = _make_mock_plan()
    mock_lifecycle.get_plan.return_value = mock_plan

    return executor


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


@given(
    "a mock PlanExecutor with tier_service, project_repository and resource_registry wired in"
)
def step_executor_with_services(context):  # type: ignore[no-untyped-def]
    tier_svc = _make_tier(with_fragments=False)
    executor = _make_executor(tier_svc=tier_svc)

    # M6 fix: patch hydrate_tiers_for_plan so we can assert it was called.
    patcher = patch(
        "cleveragents.application.services.plan_executor.hydrate_tiers_for_plan",
    )
    mock_fn = patcher.start()
    mock_fn._patcher = patcher  # type: ignore[attr-defined]

    context.pe_executor = executor  # type: ignore[attr-defined]
    context.pe_tier_svc = tier_svc  # type: ignore[attr-defined]
    context.pe_lazy_patc_hydrate = mock_fn  # type: ignore[attr-defined]


@given("a mock PlanExecutor with tier_service and plan_id already marked as hydrated")
def step_executor_with_existing_fragments(context):  # type: ignore[no-untyped-def]
    """M1 fix: skip path is now based on plan_id in _hydrated_plan_ids, not
    on get_hot_fragments() returning non-empty (which caused cross-plan
    contamination)."""
    tier_svc = _make_tier(with_fragments=False)
    executor = _make_executor(tier_svc=tier_svc)
    # Pre-populate the plan_id that the When step will use.
    executor._hydrated_plan_ids.add("01HQZZZZZ_TEST")  # type: ignore[attr-defined]

    # M6 fix: patch hydrate_tiers_for_plan so we can assert it was NOT called.
    patcher = patch(
        "cleveragents.application.services.plan_executor.hydrate_tiers_for_plan",
    )
    mock_fn = patcher.start()
    mock_fn._patcher = patcher  # type: ignore[attr-defined]

    context.pe_executor = executor  # type: ignore[attr-defined]
    context.pe_tier_svc = tier_svc  # type: ignore[attr-defined]
    context.pe_lazy_patc_hydrate = mock_fn  # type: ignore[attr-defined]


@given("a mock PlanExecutor with tier_service but no pre-existing hot fragments")
def step_executor_empty_tier(context):  # type: ignore[no-untyped-def]
    """Same as 'wired in' — plan_id not yet hydrated, so hydration runs."""
    step_executor_with_services(context)


@given(
    "a mock PlanExecutor with tier_service, project_repository and resource_registry where hydrate fails with OSError",
)
def step_executor_hydrate_o_error(context):  # type: ignore[no-untyped-def]
    tier_svc = _make_tier(with_fragments=False)

    executor = _make_executor(tier_svc=tier_svc)

    # Patch hydrate_tiers_for_plan to raise OSError inside run_strategize.
    # Store the mock itself (not the _patch object) so we can assert .called.
    patcher = patch(
        "cleveragents.application.services.plan_executor.hydrate_tiers_for_plan",
        side_effect=OSError("file system error"),
    )
    mock_fn = patcher.start()
    # Attach stop for cleanup (m5: patch leak fix)
    mock_fn._patcher = patcher  # type: ignore[attr-defined]

    context.pe_executor = executor  # type: ignore[attr-defined]
    context.pe_lazy_patc_hydrate = mock_fn  # type: ignore[attr-defined]


@given("a mock PlanExecutor without tier_service")
def step_executor_no_tier(context):  # type: ignore[no-untyped-def]
    """tier_service=None means hydration is never attempted."""
    executor = _make_executor(tier_svc=None)

    context.pe_executor = executor  # type: ignore[attr-defined]


@given("a plan in the Strategize phase")
def step_plan_in_strategize(context):  # type: ignore[no-untyped-def]
    """No-op — already created by previous Given steps."""


# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------


@when("I call run_strategize on that PlanExecutor")
def step_call_run_strategize(context):  # type: ignore[no-untyped-def]
    executor = context.pe_executor  # type: ignore[attr-defined]
    result = executor.run_strategize(
        plan_id="01HQZZZZZ_TEST",
        stream_callback=None,
    )
    context.pe_result = result  # type: ignore[attr-defined]


# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------


@then("hydrate_tiers_for_plan should have been called once")
def step_verify_hydrate_called(context):  # type: ignore[no-untyped-def]
    # M6 fix: all scenarios now patch hydrate_tiers_for_plan and store the
    # mock in context.pe_lazy_patc_hydrate — we assert directly on it instead
    # of checking tier_svc.get_hot_fragments (which was tautological).
    lazy_patch = getattr(context, "pe_lazy_patc_hydrate", None)  # type: ignore[attr-defined]
    assert lazy_patch is not None, (
        "Expected hydrate_tiers_for_plan patch to be set in context"
    )
    assert lazy_patch.called, "Expected hydrate_tiers_for_plan to have been called once"


@then(
    "hydrate_tiers_for_plan should not have been called because the plan was already hydrated"
)
def step_verify_hydrate_skipped(context):  # type: ignore[no-untyped-def]
    # M6 fix: use the patch mock to assert hydrate was NOT called (plan_id
    # scoped cache prevented it).
    lazy_patch = getattr(context, "pe_lazy_patc_hydrate", None)  # type: ignore[attr-defined]
    assert lazy_patch is not None, (
        "Expected hydrate_tiers_for_plan patch to be set in context"
    )
    assert not lazy_patch.called, (
        "Expected hydrate_tiers_for_plan to NOT have been called (plan_id already hydrated)"
    )


@then("hydrate_tiers_for_plan should not have been called")
def step_verify_hydrate_not_called(context):  # type: ignore[no-untyped-def]
    # No-tier scenario: tier_service is None so the hydration code path is
    # never entered.  No patch was set, so verify via executor state.
    if context.pe_executor is None:  # type: ignore[attr-defined]
        return

    tier_svc = context.pe_executor._tier_service  # type: ignore[attr-defined]
    if tier_svc is None:
        pass  # tier_service=None branch → hydration skipped, this is expected
    else:
        # Defensive: if tier_svc is wired but plan_id was hydrated, verify
        # hydration was not re-triggered.
        assert not tier_svc.get_hot_fragments.called, (
            "Expected get_hot_fragments to NOT be called when tier_service is None"
        )


@then(
    "the strategy result should contain decisions (strategize succeeded despite hydration failure)"
)
def step_verify_strategize_result_ok(context):  # type: ignore[no-untyped-def]
    # M6 fix: assert isinstance AND len(decisions) > 0 (previous assertion
    # passed even when decisions=[] which is meaningless)
    assert isinstance(context.pe_result, StrategizeResult), (
        f"Expected StrategizeResult, got {type(context.pe_result)}"
    )
    assert len(context.pe_result.decisions) > 0, (
        "Expected non-empty decisions from strategize"
    )


@then("the plan executor strategize result should be a valid run_strategize response")
def step_verify_exec_result_ok(context):  # type: ignore[no-untyped-def]
    assert isinstance(context.pe_result, StrategizeResult), (
        f"Expected StrategizeResult, got {type(context.pe_result)}"
    )


# ---------------------------------------------------------------------------
# Scenario lifecycle hooks
# ---------------------------------------------------------------------------


def after_scenario(context, scenario):  # type: ignore[no-untyped-def]
    """Stop any started patchers to prevent mock patch leak into later scenarios.

    m5 fix: patcher.start() was called in OSError/KeyError Given steps but
    patcher.stop() was never called, leaking patches into subsequent scenarios.
    The mock function stores a reference to its _patcher object so we can stop it.
    """
    lazy_patch = getattr(context, "pe_lazy_patc_hydrate", None)
    if lazy_patch is not None and hasattr(lazy_patch, "_patcher"):
        lazy_patch._patcher.stop()  # type: ignore[attr-defined]
