"""Step definitions for plan_executor_new_coverage.feature.

Covers uncovered branches and lines in plan_executor.py:
- PlanExecutor.__init__ with None lifecycle_service
- has_runtime / changeset_store properties
- run_strategize guard branches (wrong phase, empty plan_id, exception path)
- _guard_execute branches (wrong phase, wrong state, None decision_root_id)
- run_execute routing (stub vs runtime)
- StrategizeStubActor / ExecuteStubActor with and without stream_callback
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

from behave import given, then, when
from ulid import ULID

from cleveragents.application.services.plan_execution_context import (
    PlanExecutionContext,
    RuntimeExecuteResult,
)
from cleveragents.application.services.plan_executor import (
    ExecuteResult,
    ExecuteStubActor,
    PlanExecutor,
    StrategizeStubActor,
    StrategyDecision,
)
from cleveragents.core.exceptions import PlanError, ValidationError
from cleveragents.domain.models.core.plan import PlanPhase, ProcessingState
from cleveragents.tool.registry import ToolRegistry
from cleveragents.tool.runner import ToolRunner

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


def _make_plan_mock(
    phase: PlanPhase = PlanPhase.STRATEGIZE,
    state: ProcessingState = ProcessingState.QUEUED,
    decision_root_id: str | None = None,
    definition_of_done: str = "Step one\nStep two",
    invariants: list | None = None,
) -> MagicMock:
    """Create a mock Plan object with the given attributes."""
    plan = MagicMock()
    plan.phase = phase
    plan.state = state
    plan.decision_root_id = decision_root_id
    plan.definition_of_done = definition_of_done
    plan.invariants = invariants or []
    plan.timestamps = MagicMock()
    plan.error_details = {}
    plan.changeset_id = None
    plan.sandbox_refs = []
    plan.namespaced_name = "test/plan"
    plan.identity = MagicMock()
    return plan


def _make_lifecycle(**overrides: Any) -> MagicMock:
    """Create a mock lifecycle service with default methods."""
    lc = MagicMock()
    lc.start_strategize = MagicMock()
    lc.complete_strategize = MagicMock()
    lc.fail_strategize = MagicMock()
    lc.start_execute = MagicMock()
    lc.complete_execute = MagicMock()
    lc.fail_execute = MagicMock()
    lc._commit_plan = MagicMock()
    for key, value in overrides.items():
        setattr(lc, key, value)
    return lc


def _make_decisions(count: int = 2) -> list[StrategyDecision]:
    root_id = str(ULID())
    decisions = []
    for i in range(count):
        decisions.append(
            StrategyDecision(
                decision_id=root_id if i == 0 else str(ULID()),
                step_text=f"Step {i + 1}",
                sequence=i,
                parent_id=root_id if i > 0 else None,
            )
        )
    return decisions


# ---------------------------------------------------------------------------
# PlanExecutor __init__ validation
# ---------------------------------------------------------------------------


@when("I construct a PlanExecutor with None lifecycle_service")
def step_construct_pe_none_lc(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        PlanExecutor(lifecycle_service=None)
    except ValidationError as exc:
        ctx.pe_error = exc


@then('a PE ValidationError should be raised containing "{text}"')
def step_assert_pe_validation_error_contains(ctx: Any, text: str) -> None:
    assert ctx.pe_error is not None, "Expected a ValidationError but none was raised"
    assert isinstance(ctx.pe_error, ValidationError), (
        f"Expected ValidationError, got {type(ctx.pe_error).__name__}"
    )
    assert text in str(ctx.pe_error), (
        f"Expected '{text}' in error message, got: {ctx.pe_error}"
    )


@given("a mock plan lifecycle service for plan executor")
def step_given_mock_lifecycle(ctx: Any) -> None:
    ctx.lifecycle = _make_lifecycle()
    ctx.plan_id = str(ULID())


@when("I construct a PlanExecutor with the mock lifecycle service")
def step_construct_pe_with_lc(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.executor = PlanExecutor(lifecycle_service=ctx.lifecycle)
    except Exception as exc:
        ctx.pe_error = exc


@then("the PlanExecutor should be created without error")
def step_assert_no_error(ctx: Any) -> None:
    assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}"
    assert ctx.executor is not None


# ---------------------------------------------------------------------------
# has_runtime property
# ---------------------------------------------------------------------------


@given("a mock execution context")
def step_given_mock_exec_ctx(ctx: Any) -> None:
    ctx.exec_ctx = PlanExecutionContext(plan_id=ctx.plan_id)


@when(
    "I construct a PlanExecutor with the mock lifecycle service and execution context"
)
def step_construct_pe_with_ctx(ctx: Any) -> None:
    ctx.executor = PlanExecutor(
        lifecycle_service=ctx.lifecycle,
        execution_context=ctx.exec_ctx,
    )


@then("the executor has_runtime property should be True")
def step_assert_has_runtime_true(ctx: Any) -> None:
    assert ctx.executor.has_runtime is True


@then("the executor has_runtime property should be False")
def step_assert_has_runtime_false(ctx: Any) -> None:
    assert ctx.executor.has_runtime is False


# ---------------------------------------------------------------------------
# changeset_store property
# ---------------------------------------------------------------------------


@given("a mock execution context with a changeset store")
def step_given_exec_ctx_with_store(ctx: Any) -> None:
    ctx.mock_store = MagicMock()
    ctx.exec_ctx = MagicMock()
    ctx.exec_ctx.changeset_store = ctx.mock_store
    ctx.exec_ctx.decision_root_id = None


@then("the executor changeset_store should be the mock store")
def step_assert_changeset_store_is_mock(ctx: Any) -> None:
    assert ctx.executor.changeset_store is ctx.mock_store


@then("the executor changeset_store should be None")
def step_assert_changeset_store_none(ctx: Any) -> None:
    assert ctx.executor.changeset_store is None


# ---------------------------------------------------------------------------
# run_strategize guard: wrong phase
# ---------------------------------------------------------------------------


@given("a PlanExecutor with the mock lifecycle service")
def step_given_pe_with_lc(ctx: Any) -> None:
    ctx.executor = PlanExecutor(lifecycle_service=ctx.lifecycle)


@given("the lifecycle returns a plan in EXECUTE phase")
def step_lifecycle_returns_execute_phase(ctx: Any) -> None:
    plan = _make_plan_mock(phase=PlanPhase.EXECUTE)
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)


@when("I attempt to run strategize on the executor")
def step_attempt_run_strategize(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.executor.run_strategize(ctx.plan_id)
    except (PlanError, ValidationError) as exc:
        ctx.pe_error = exc


@then('a PE PlanError should be raised containing "{text}"')
def step_assert_pe_plan_error_contains(ctx: Any, text: str) -> None:
    assert ctx.pe_error is not None, "Expected a PlanError but none was raised"
    assert isinstance(ctx.pe_error, PlanError), (
        f"Expected PlanError, got {type(ctx.pe_error).__name__}"
    )
    assert text in str(ctx.pe_error), (
        f"Expected '{text}' in error message, got: {ctx.pe_error}"
    )


# ---------------------------------------------------------------------------
# run_strategize guard: empty plan_id
# ---------------------------------------------------------------------------


@when("I attempt to run strategize with an empty plan_id")
def step_attempt_strategize_empty_plan_id(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.executor.run_strategize("")
    except (ValidationError, PlanError) as exc:
        ctx.pe_error = exc


# ---------------------------------------------------------------------------
# run_strategize exception handling
# ---------------------------------------------------------------------------


@given("the lifecycle returns a plan in STRATEGIZE phase")
def step_lifecycle_returns_strategize_phase(ctx: Any) -> None:
    plan = _make_plan_mock(
        phase=PlanPhase.STRATEGIZE,
        definition_of_done="Step one\nStep two",
    )
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)
    ctx.mock_plan = plan


@given("the lifecycle start_strategize will raise a RuntimeError")
def step_lifecycle_start_strategize_raises(ctx: Any) -> None:
    """Make the strategize actor itself raise inside the try block.

    start_strategize is called *before* the try, so we need to make
    the actor.execute raise instead to hit the except branch (line 314).
    """
    ctx._patch_strategize_actor_to_fail = True


@when("I attempt to run strategize and expect an exception")
def step_attempt_strategize_expect_exception(ctx: Any) -> None:
    # If the actor should fail, patch it before running
    if getattr(ctx, "_patch_strategize_actor_to_fail", False):
        ctx.executor._strategize_actor.execute = MagicMock(
            side_effect=RuntimeError("simulated actor failure")
        )
    if getattr(ctx, "_patch_actor_to_fail", False):
        ctx.executor._strategize_actor.execute = MagicMock(
            side_effect=RuntimeError("simulated actor failure")
        )
    ctx.pe_error = None
    ctx.raised_exception = None
    try:
        ctx.executor.run_strategize(ctx.plan_id)
    except Exception as exc:
        ctx.pe_error = exc
        ctx.raised_exception = exc


@then("fail_strategize should have been called with the plan_id")
def step_assert_fail_strategize_called(ctx: Any) -> None:
    ctx.lifecycle.fail_strategize.assert_called()
    call_args = ctx.lifecycle.fail_strategize.call_args
    assert ctx.plan_id == call_args[0][0], (
        f"Expected plan_id={ctx.plan_id}, got {call_args[0][0]}"
    )


@then("the raised exception should be a RuntimeError")
def step_assert_raised_runtime_error(ctx: Any) -> None:
    assert ctx.raised_exception is not None, "No exception was raised"
    assert isinstance(ctx.raised_exception, RuntimeError), (
        f"Expected RuntimeError, got {type(ctx.raised_exception).__name__}"
    )


# ---------------------------------------------------------------------------
# run_strategize exception during actor execute (except branch line 314)
# ---------------------------------------------------------------------------


@given(
    "the lifecycle returns a plan in STRATEGIZE phase that fails during actor execute"
)
def step_lifecycle_strategize_fails_in_actor(ctx: Any) -> None:
    """Set up lifecycle so start_strategize succeeds but actor.execute raises."""
    plan = _make_plan_mock(
        phase=PlanPhase.STRATEGIZE,
        definition_of_done="Step one\nStep two",
    )
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)
    ctx.lifecycle.start_strategize = MagicMock()  # succeeds
    ctx.mock_plan = plan
    ctx._patch_actor_to_fail = True


@then("the plan error_details should contain the exception_type")
def step_assert_error_details_exception_type(ctx: Any) -> None:
    plan = ctx.lifecycle.get_plan.return_value
    assert plan.error_details is not None
    if isinstance(plan.error_details, dict):
        assert "exception_type" in plan.error_details


# ---------------------------------------------------------------------------
# _guard_execute: wrong phase
# ---------------------------------------------------------------------------


@given("the lifecycle returns a plan in STRATEGIZE phase for execute guard")
def step_lifecycle_returns_strategize_for_execute(ctx: Any) -> None:
    plan = _make_plan_mock(phase=PlanPhase.STRATEGIZE)
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)


@when("I attempt to run execute on the executor")
def step_attempt_run_execute(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.executor.run_execute(ctx.plan_id)
    except (PlanError, ValidationError) as exc:
        ctx.pe_error = exc


# ---------------------------------------------------------------------------
# _guard_execute: wrong state
# ---------------------------------------------------------------------------


@given("the lifecycle returns a plan in EXECUTE phase with PROCESSING state")
def step_lifecycle_returns_execute_processing(ctx: Any) -> None:
    plan = _make_plan_mock(
        phase=PlanPhase.EXECUTE,
        state=ProcessingState.PROCESSING,
    )
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)


# ---------------------------------------------------------------------------
# _guard_execute: None decision_root_id
# ---------------------------------------------------------------------------


@given(
    "the lifecycle returns a plan in EXECUTE phase with QUEUED state but no decision root"
)
def step_lifecycle_returns_execute_queued_no_root(ctx: Any) -> None:
    plan = _make_plan_mock(
        phase=PlanPhase.EXECUTE,
        state=ProcessingState.QUEUED,
        decision_root_id=None,
    )
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)


# ---------------------------------------------------------------------------
# run_execute routing to stub
# ---------------------------------------------------------------------------


@given("a PlanExecutor with the mock lifecycle service and no execution context")
def step_given_pe_no_ctx(ctx: Any) -> None:
    ctx.executor = PlanExecutor(lifecycle_service=ctx.lifecycle)


@given("the lifecycle returns a fully valid plan for stub execution")
def step_lifecycle_returns_valid_plan_for_stub(ctx: Any) -> None:
    root_id = str(ULID())
    plan = _make_plan_mock(
        phase=PlanPhase.EXECUTE,
        state=ProcessingState.QUEUED,
        decision_root_id=root_id,
        definition_of_done="Do thing one\nDo thing two",
    )
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)
    ctx.mock_plan = plan


@when("I run execute on the executor")
def step_run_execute(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.result = ctx.executor.run_execute(ctx.plan_id)
    except Exception as exc:
        ctx.pe_error = exc


@then("the plan executor result should be an ExecuteResult")
def step_assert_execute_result_type(ctx: Any) -> None:
    assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}"
    assert isinstance(ctx.result, ExecuteResult), (
        f"Expected ExecuteResult, got {type(ctx.result).__name__}"
    )


@then("complete_execute should have been called")
def step_assert_complete_execute_called(ctx: Any) -> None:
    ctx.lifecycle.complete_execute.assert_called_once()


# ---------------------------------------------------------------------------
# run_execute routing to runtime
# ---------------------------------------------------------------------------


@given(
    "a PlanExecutor with the mock lifecycle service and execution context and tool runner"
)
def step_given_pe_with_ctx_and_runner(ctx: Any) -> None:
    ctx.tool_runner = ToolRunner(registry=ToolRegistry())
    ctx.executor = PlanExecutor(
        lifecycle_service=ctx.lifecycle,
        tool_runner=ctx.tool_runner,
        execution_context=ctx.exec_ctx,
    )


@given("the lifecycle returns a fully valid plan for runtime execution")
def step_lifecycle_returns_valid_plan_for_runtime(ctx: Any) -> None:
    root_id = str(ULID())
    plan = _make_plan_mock(
        phase=PlanPhase.EXECUTE,
        state=ProcessingState.QUEUED,
        decision_root_id=root_id,
        definition_of_done="Do thing one\nDo thing two",
    )
    ctx.lifecycle.get_plan = MagicMock(return_value=plan)
    ctx.mock_plan = plan


@then("the plan executor result should be a RuntimeExecuteResult")
def step_assert_runtime_execute_result_type(ctx: Any) -> None:
    assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}"
    assert isinstance(ctx.result, RuntimeExecuteResult), (
        f"Expected RuntimeExecuteResult, got {type(ctx.result).__name__}"
    )


# ---------------------------------------------------------------------------
# StrategizeStubActor with stream_callback
# ---------------------------------------------------------------------------


@given("a stream event collector")
def step_given_stream_collector(ctx: Any) -> None:
    ctx.stream_events = []
    ctx.stream_callback = lambda event_type, data: ctx.stream_events.append(
        (event_type, data)
    )


@when("I execute the StrategizeStubActor with a plan_id and stream callback")
def step_exec_strategize_actor_with_cb(ctx: Any) -> None:
    actor = StrategizeStubActor()
    plan_id = str(ULID())
    ctx.actor_plan_id = plan_id
    ctx.strategize_result = actor.execute(
        plan_id=plan_id,
        definition_of_done="Step A\nStep B",
        stream_callback=ctx.stream_callback,
    )


@then('the collector should contain a "{event_type}" event')
def step_assert_collector_contains_event(ctx: Any, event_type: str) -> None:
    event_types = [e[0] for e in ctx.stream_events]
    assert event_type in event_types, (
        f"Expected '{event_type}' in events, got: {event_types}"
    )


@when("I execute the StrategizeStubActor with a plan_id and no callback")
def step_exec_strategize_actor_no_cb(ctx: Any) -> None:
    actor = StrategizeStubActor()
    plan_id = str(ULID())
    ctx.strategize_result = actor.execute(
        plan_id=plan_id,
        definition_of_done="Step A\nStep B",
        stream_callback=None,
    )


@then("the strategize result should have a valid decision_root_id")
def step_assert_strategize_result_root_id(ctx: Any) -> None:
    assert ctx.strategize_result.decision_root_id
    assert len(ctx.strategize_result.decision_root_id) == 26


@then("the strategize result should have at least one decision")
def step_assert_strategize_result_has_decisions(ctx: Any) -> None:
    assert len(ctx.strategize_result.decisions) >= 1


# ---------------------------------------------------------------------------
# ExecuteStubActor with stream_callback
# ---------------------------------------------------------------------------


@given("a list of two strategy decisions")
def step_given_two_decisions(ctx: Any) -> None:
    ctx.decisions = _make_decisions(2)


@when("I execute the ExecuteStubActor with the decisions and stream callback")
def step_exec_execute_actor_with_cb(ctx: Any) -> None:
    actor = ExecuteStubActor()
    plan_id = str(ULID())
    ctx.actor_plan_id = plan_id
    ctx.execute_result = actor.execute(
        plan_id=plan_id,
        decisions=ctx.decisions,
        stream_callback=ctx.stream_callback,
    )


@then('the collector should contain an "{event_type}" event')
def step_assert_collector_contains_event_an(ctx: Any, event_type: str) -> None:
    event_types = [e[0] for e in ctx.stream_events]
    assert event_type in event_types, (
        f"Expected '{event_type}' in events, got: {event_types}"
    )


@when("I execute the ExecuteStubActor with the decisions and no callback")
def step_exec_execute_actor_no_cb(ctx: Any) -> None:
    actor = ExecuteStubActor()
    plan_id = str(ULID())
    ctx.execute_result = actor.execute(
        plan_id=plan_id,
        decisions=ctx.decisions,
        stream_callback=None,
    )


@then("the execute result should have a valid changeset_id")
def step_assert_execute_result_changeset_id(ctx: Any) -> None:
    assert ctx.execute_result.changeset_id
    assert len(ctx.execute_result.changeset_id) == 26


@then("the execute result tool_calls_count should be zero")
def step_assert_execute_result_tool_calls_zero(ctx: Any) -> None:
    assert ctx.execute_result.tool_calls_count == 0


# ---------------------------------------------------------------------------
# run_strategize happy path with stream_callback
# ---------------------------------------------------------------------------


@when("I run strategize with the stream callback")
def step_run_strategize_with_callback(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.strategize_result = ctx.executor.run_strategize(
            ctx.plan_id,
            stream_callback=ctx.stream_callback,
        )
    except Exception as exc:
        ctx.pe_error = exc


@then("the strategize result should have decisions")
def step_assert_result_has_decisions(ctx: Any) -> None:
    assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}"
    assert len(ctx.strategize_result.decisions) >= 1


@then("complete_strategize should have been called")
def step_assert_complete_strategize_called(ctx: Any) -> None:
    ctx.lifecycle.complete_strategize.assert_called_once()


# ---------------------------------------------------------------------------
# PlanExecutor with execution context (for run_strategize + context scenario)
# ---------------------------------------------------------------------------


@given("a PlanExecutor with the mock lifecycle and execution context")
def step_given_pe_with_lc_and_ctx(ctx: Any) -> None:
    ctx.executor = PlanExecutor(
        lifecycle_service=ctx.lifecycle,
        execution_context=ctx.exec_ctx,
    )


# ---------------------------------------------------------------------------
# run_strategize sets execution_context.decision_root_id
# ---------------------------------------------------------------------------


@when("I run strategize successfully")
def step_run_strategize_successfully(ctx: Any) -> None:
    ctx.pe_error = None
    try:
        ctx.strategize_result = ctx.executor.run_strategize(ctx.plan_id)
    except Exception as exc:
        ctx.pe_error = exc


@then("the execution context decision_root_id should be set")
def step_assert_exec_ctx_decision_root_id_set(ctx: Any) -> None:
    assert ctx.pe_error is None, f"Unexpected error: {ctx.pe_error}"
    assert ctx.exec_ctx.decision_root_id is not None
    assert ctx.exec_ctx.decision_root_id == ctx.strategize_result.decision_root_id


# ---------------------------------------------------------------------------
# run_execute stub exception path
# ---------------------------------------------------------------------------


@given("the stub execute actor is patched to raise an error")
def step_patch_stub_execute_to_fail(ctx: Any) -> None:
    ctx.executor._execute_actor.execute = MagicMock(
        side_effect=RuntimeError("simulated stub execution failure")
    )


@when("I attempt to run execute and expect an exception")
def step_attempt_run_execute_expect_exception(ctx: Any) -> None:
    ctx.pe_error = None
    ctx.raised_exception = None
    try:
        ctx.result = ctx.executor.run_execute(ctx.plan_id)
    except Exception as exc:
        ctx.pe_error = exc
        ctx.raised_exception = exc


@then("fail_execute should have been called with the plan_id")
def step_assert_fail_execute_called(ctx: Any) -> None:
    ctx.lifecycle.fail_execute.assert_called()
    call_args = ctx.lifecycle.fail_execute.call_args
    assert ctx.plan_id == call_args[0][0], (
        f"Expected plan_id={ctx.plan_id}, got {call_args[0][0]}"
    )
