"""Step definitions for M4 correction + subplan smoke tests.

All step names are prefixed with ``m4 smoke`` to avoid ``AmbiguousStep``
conflicts with existing steps.
"""

from __future__ import annotations

import contextlib
import json
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import MagicMock, patch

from behave import given, then, when
from behave.runner import Context
from typer.testing import CliRunner

from cleveragents.cli.commands.plan import app as plan_app
from cleveragents.domain.models.core.correction import (
    CorrectionImpact,
    CorrectionResult,
    CorrectionStatus,
)
from cleveragents.domain.models.core.plan import (
    ExecutionMode,
    NamespacedName,
    Plan,
    PlanIdentity,
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
    SubplanConfig,
    SubplanFailureHandler,
    SubplanMergeStrategy,
    SubplanStatus,
)

_FIXTURES_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "m4"
_PLAN_ULID = "01M4SM0KE00000000000000001"
_DECISION_ULID = "01M4DEC00000000000000000001"


def _make_m4_plan(
    *,
    phase: PlanPhase = PlanPhase.EXECUTE,
    state: ProcessingState = ProcessingState.COMPLETE,
    subplan_config: SubplanConfig | None = None,
    subplan_statuses: list[SubplanStatus] | None = None,
    parent_plan_id: str | None = None,
) -> Plan:
    """Create a Plan instance for M4 smoke tests."""
    now = datetime.now()
    identity = PlanIdentity(
        plan_id=_PLAN_ULID,
        parent_plan_id=parent_plan_id,
    )
    return Plan(
        identity=identity,
        namespaced_name=NamespacedName.parse("local/m4-smoke-plan"),
        description="M4 smoke test plan",
        definition_of_done="Corrections applied and subplans merged",
        action_name="local/m4-correction-test",
        phase=phase,
        processing_state=state,
        project_links=[],
        arguments={},
        arguments_order=[],
        strategy_actor="openai/gpt-4",
        execution_actor="openai/gpt-4",
        reusable=True,
        read_only=False,
        created_by=None,
        timestamps=PlanTimestamps(created_at=now, updated_at=now),
        decision_root_id=_DECISION_ULID,
        subplan_config=subplan_config,
        subplan_statuses=subplan_statuses or [],
    )


def _register_patcher_cleanup(context: Context, patcher: object) -> None:
    """Start a patcher and always stop it via Behave cleanup."""
    patcher.start()
    context.add_cleanup(_safe_stop_patcher, patcher)


def _safe_stop_patcher(patcher: object) -> None:
    """Stop patcher safely even if it was already stopped."""
    with contextlib.suppress(RuntimeError):
        patcher.stop()


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


@given("a m4 smoke test runner")
def step_m4_smoke_runner(context: Context) -> None:
    """Set up the CLI runner for M4 smoke tests."""
    context.m4_runner = CliRunner()


@given("a m4 smoke mocked lifecycle service")
def step_m4_smoke_mock_service(context: Context) -> None:
    """Set up the mocked lifecycle service for M4 smoke tests."""
    context.m4_mock_service = MagicMock()
    context.m4_mock_correction_service = MagicMock()

    context.m4_plan_patcher = patch(
        "cleveragents.cli.commands.plan._get_lifecycle_service",
        return_value=context.m4_mock_service,
    )
    _register_patcher_cleanup(context, context.m4_plan_patcher)
    context.m4_last_result = None


# ---------------------------------------------------------------------------
# Fixture loading steps
# ---------------------------------------------------------------------------


@when("I m4 smoke load the correction flows fixture")
def step_m4_load_correction_flows(context: Context) -> None:
    """Load the correction flows fixture JSON."""
    fixture_path = _FIXTURES_DIR / "correction_flows.json"
    with open(fixture_path) as f:
        context.m4_correction_fixtures = json.load(f)


@then("the m4 smoke correction fixture should have a revert entry")
def step_m4_correction_has_revert(context: Context) -> None:
    """Verify fixture has a revert correction entry."""
    fixtures = context.m4_correction_fixtures["fixtures"]
    names = [f["name"] for f in fixtures]
    assert "revert_correction" in names, f"Expected 'revert_correction' in {names}"


@then("the m4 smoke correction fixture should have an append entry")
def step_m4_correction_has_append(context: Context) -> None:
    """Verify fixture has an append correction entry."""
    fixtures = context.m4_correction_fixtures["fixtures"]
    names = [f["name"] for f in fixtures]
    assert "append_correction" in names, f"Expected 'append_correction' in {names}"


@when("I m4 smoke load the subplan execution fixture")
def step_m4_load_subplan_execution(context: Context) -> None:
    """Load the subplan execution fixture JSON."""
    fixture_path = _FIXTURES_DIR / "subplan_execution.json"
    with open(fixture_path) as f:
        context.m4_subplan_fixtures = json.load(f)


@then("the m4 smoke subplan fixture should have a sequential entry")
def step_m4_subplan_has_sequential(context: Context) -> None:
    """Verify fixture has a sequential subplan entry."""
    fixtures = context.m4_subplan_fixtures["fixtures"]
    names = [f["name"] for f in fixtures]
    assert "sequential_subplan_execution" in names, (
        f"Expected 'sequential_subplan_execution' in {names}"
    )


@then("the m4 smoke subplan fixture should have a parallel entry")
def step_m4_subplan_has_parallel(context: Context) -> None:
    """Verify fixture has a parallel subplan entry."""
    fixtures = context.m4_subplan_fixtures["fixtures"]
    names = [f["name"] for f in fixtures]
    assert "parallel_subplan_execution" in names, (
        f"Expected 'parallel_subplan_execution' in {names}"
    )


@when("I m4 smoke load the conflict simulations fixture")
def step_m4_load_conflict_simulations(context: Context) -> None:
    """Load the conflict simulations fixture JSON."""
    fixture_path = _FIXTURES_DIR / "conflict_simulations.json"
    with open(fixture_path) as f:
        context.m4_conflict_fixtures = json.load(f)


@then("the m4 smoke conflict fixture should have a no-conflict entry")
def step_m4_conflict_has_no_conflict(context: Context) -> None:
    """Verify fixture has a no-conflict entry."""
    fixtures = context.m4_conflict_fixtures["fixtures"]
    names = [f["name"] for f in fixtures]
    assert "git_three_way_no_conflict" in names, (
        f"Expected 'git_three_way_no_conflict' in {names}"
    )


@then("the m4 smoke conflict fixture should have a conflict entry")
def step_m4_conflict_has_conflict(context: Context) -> None:
    """Verify fixture has a conflict entry."""
    fixtures = context.m4_conflict_fixtures["fixtures"]
    names = [f["name"] for f in fixtures]
    assert "git_three_way_with_conflict" in names, (
        f"Expected 'git_three_way_with_conflict' in {names}"
    )


# ---------------------------------------------------------------------------
# Correction flow steps
# ---------------------------------------------------------------------------


@given("a m4 smoke plan exists with a decision tree")
def step_m4_plan_with_decision_tree(context: Context) -> None:
    """Set up a plan with a decision tree for correction tests."""
    plan = _make_m4_plan()
    context.m4_mock_service.get_plan.return_value = plan
    context.m4_mock_service.list_plans.return_value = [plan]

    # Mock the correction service
    mock_correction_svc = MagicMock()
    now = datetime.now(UTC)
    _correction_id = "01M4CORRID00000000000000001"

    # Set up request_correction return value (CorrectionRequest-like mock)
    mock_request = MagicMock()
    mock_request.correction_id = _correction_id
    mock_correction_svc.request_correction.return_value = mock_request

    # Set up execute_correction return value (used by non-dry-run paths)
    mock_correction_svc.execute_correction.return_value = CorrectionResult(
        correction_id=_correction_id,
        status=CorrectionStatus.APPLIED,
        reverted_decisions=[_DECISION_ULID],
        completed_at=now,
    )

    # Set up analyze_impact return value (used by --dry-run path)
    mock_correction_svc.analyze_impact.return_value = CorrectionImpact(
        affected_decisions=[_DECISION_ULID],
        affected_files=["src/auth.py"],
        risk_level="medium",
        rollback_tier="phase",
    )

    context.m4_correction_patcher = patch(
        "cleveragents.application.services.correction_service.CorrectionService",
        return_value=mock_correction_svc,
    )
    _register_patcher_cleanup(context, context.m4_correction_patcher)
    context.m4_mock_correction_service = mock_correction_svc

    # Mock DecisionService resolved via DI container (issue #606 fix)
    mock_decision_svc = MagicMock()
    mock_decision_svc.list_decisions.return_value = []
    mock_decision_svc.get_influence_edges.return_value = {}
    mock_container = MagicMock()
    mock_container.decision_service.return_value = mock_decision_svc
    context.m4_container_patcher = patch(
        "cleveragents.application.container.get_container",
        return_value=mock_container,
    )
    _register_patcher_cleanup(context, context.m4_container_patcher)


@when('I m4 smoke invoke plan correct with mode "{mode}" and guidance "{guidance}"')
def step_m4_invoke_correct(context: Context, mode: str, guidance: str) -> None:
    """Invoke plan correct CLI with mode and guidance."""
    result = context.m4_runner.invoke(
        plan_app,
        [
            "correct",
            _DECISION_ULID,
            "--mode",
            mode,
            "--guidance",
            guidance,
            "--plan",
            _PLAN_ULID,
            "--yes",
        ],
    )
    context.m4_last_result = result


@when("I m4 smoke invoke plan correct with dry-run")
def step_m4_invoke_correct_dry_run(context: Context) -> None:
    """Invoke plan correct CLI with dry-run flag."""
    result = context.m4_runner.invoke(
        plan_app,
        [
            "correct",
            _DECISION_ULID,
            "--mode",
            "revert",
            "--guidance",
            "Evaluate impact of revert",
            "--dry-run",
            "--plan",
            _PLAN_ULID,
        ],
    )
    context.m4_last_result = result


@then("the m4 smoke correction should succeed")
def step_m4_correction_ok(context: Context) -> None:
    """Verify correction command succeeded."""
    assert context.m4_last_result is not None
    assert context.m4_last_result.exit_code == 0, (
        f"Expected exit 0, got {context.m4_last_result.exit_code}. "
        f"Output: {context.m4_last_result.output}"
    )


@then("the m4 smoke correction should fail")
def step_m4_correction_fail(context: Context) -> None:
    """Verify correction command failed."""
    assert context.m4_last_result is not None
    assert context.m4_last_result.exit_code != 0


@then('the m4 smoke output should contain "{text}"')
def step_m4_output_contains(context: Context, text: str) -> None:
    """Verify CLI output contains expected text."""
    output = context.m4_last_result.output.lower()
    assert text.lower() in output, (
        f"Expected '{text}' in output: {context.m4_last_result.output}"
    )


# ---------------------------------------------------------------------------
# Subplan execution steps
# ---------------------------------------------------------------------------


@given("a m4 smoke parent plan with sequential subplan config")
def step_m4_parent_sequential(context: Context) -> None:
    """Set up a parent plan with sequential subplan config."""
    config = SubplanConfig(
        execution_mode=ExecutionMode.SEQUENTIAL,
        merge_strategy=SubplanMergeStrategy.GIT_THREE_WAY,
        max_parallel=1,
    )
    statuses = [
        SubplanStatus(
            subplan_id="01J4S0BP000000000000000010",
            action_name="local/refactor-auth",
            target_resources=["src/auth.py"],
            status=ProcessingState.COMPLETE,
        ),
    ]
    plan = _make_m4_plan(subplan_config=config, subplan_statuses=statuses)
    context.m4_mock_service.get_plan.return_value = plan
    context.m4_mock_service.list_plans.return_value = [plan]
    context.m4_parent_plan = plan


@given("a m4 smoke parent plan with parallel subplan config")
def step_m4_parent_parallel(context: Context) -> None:
    """Set up a parent plan with parallel subplan config."""
    config = SubplanConfig(
        execution_mode=ExecutionMode.PARALLEL,
        merge_strategy=SubplanMergeStrategy.GIT_THREE_WAY,
        max_parallel=5,
    )
    statuses = [
        SubplanStatus(
            subplan_id="01J4S0BP000000000000000020",
            action_name="local/update-api",
            target_resources=["src/api.py"],
            status=ProcessingState.COMPLETE,
        ),
        SubplanStatus(
            subplan_id="01J4S0BP000000000000000021",
            action_name="local/update-tests",
            target_resources=["tests/test_api.py"],
            status=ProcessingState.COMPLETE,
        ),
    ]
    plan = _make_m4_plan(subplan_config=config, subplan_statuses=statuses)
    context.m4_mock_service.get_plan.return_value = plan
    context.m4_mock_service.list_plans.return_value = [plan]
    context.m4_parent_plan = plan


@when("I m4 smoke invoke plan status for the parent")
def step_m4_invoke_status(context: Context) -> None:
    """Invoke plan status for the parent plan."""
    result = context.m4_runner.invoke(
        plan_app,
        ["status", _PLAN_ULID, "--format", "json"],
    )
    context.m4_last_result = result


@then("the m4 smoke status should succeed")
def step_m4_status_ok(context: Context) -> None:
    """Verify plan status succeeded."""
    assert context.m4_last_result is not None
    assert context.m4_last_result.exit_code == 0, (
        f"Expected exit 0, got {context.m4_last_result.exit_code}. "
        f"Output: {context.m4_last_result.output}"
    )


# ---------------------------------------------------------------------------
# Failure handler steps
# ---------------------------------------------------------------------------


@given("a m4 smoke subplan config with fail-fast enabled")
def step_m4_failfast_config(context: Context) -> None:
    """Set up a subplan config with fail_fast=True."""
    context.m4_subplan_config = SubplanConfig(
        execution_mode=ExecutionMode.PARALLEL,
        fail_fast=True,
    )


@when("I m4 smoke evaluate failure handler should-stop-others")
def step_m4_eval_stop_others(context: Context) -> None:
    """Evaluate SubplanFailureHandler.should_stop_others."""
    handler = SubplanFailureHandler()
    failed = SubplanStatus(
        subplan_id="01J4S0BP000000000000000041",
        action_name="local/task-b",
        status=ProcessingState.ERRORED,
        error="ConfigurationError: missing config",
    )
    context.m4_stop_result = handler.should_stop_others(
        context.m4_subplan_config, failed
    )


@then("the m4 smoke failure handler should return true for stop-others")
def step_m4_stop_others_true(context: Context) -> None:
    """Verify failure handler returns True for stop-others."""
    assert context.m4_stop_result is True


@given("a m4 smoke subplan config with retry enabled")
def step_m4_retry_config(context: Context) -> None:
    """Set up a subplan config with retry_failed=True."""
    context.m4_subplan_config = SubplanConfig(
        execution_mode=ExecutionMode.SEQUENTIAL,
        retry_failed=True,
        max_retries=2,
    )


@given('a m4 smoke subplan status with retriable error "{error_type}"')
def step_m4_retriable_error(context: Context, error_type: str) -> None:
    """Set up a subplan status with a retriable error."""
    context.m4_failed_status = SubplanStatus(
        subplan_id="01J4S0BP000000000000000030",
        action_name="local/flaky-task",
        status=ProcessingState.ERRORED,
        error=f"{error_type}: temporary failure",
        attempt_number=1,
    )


@given('a m4 smoke subplan status with non-retriable error "{error_type}"')
def step_m4_non_retriable_error(context: Context, error_type: str) -> None:
    """Set up a subplan status with a non-retriable error."""
    context.m4_failed_status = SubplanStatus(
        subplan_id="01J4S0BP000000000000000031",
        action_name="local/broken-task",
        status=ProcessingState.ERRORED,
        error=f"{error_type}: permanent failure",
        attempt_number=1,
    )


@when("I m4 smoke evaluate failure handler should-retry")
def step_m4_eval_retry(context: Context) -> None:
    """Evaluate SubplanFailureHandler.should_retry."""
    handler = SubplanFailureHandler()
    context.m4_retry_result = handler.should_retry(
        context.m4_subplan_config, context.m4_failed_status
    )


@then("the m4 smoke failure handler should return true for retry")
def step_m4_retry_true(context: Context) -> None:
    """Verify failure handler returns True for retry."""
    assert context.m4_retry_result is True


@then("the m4 smoke failure handler should return false for retry")
def step_m4_retry_false(context: Context) -> None:
    """Verify failure handler returns False for retry."""
    assert context.m4_retry_result is False


# ---------------------------------------------------------------------------
# Conflict simulation steps
# ---------------------------------------------------------------------------


@given('a m4 smoke conflict fixture with strategy "{strategy}" and no overlap')
def step_m4_conflict_no_overlap(context: Context, strategy: str) -> None:
    """Load a conflict fixture entry with no file overlap."""
    fixture_path = _FIXTURES_DIR / "conflict_simulations.json"
    with open(fixture_path) as f:
        data = json.load(f)
    # Find an entry with given strategy and no conflict
    for entry in data["fixtures"]:
        if entry["merge_strategy"] == strategy and not entry["expected_conflict"]:
            context.m4_conflict_entry = entry
            return
    raise AssertionError(f"No no-conflict fixture found for strategy '{strategy}'")


@given('a m4 smoke conflict fixture with strategy "{strategy}" and overlap')
def step_m4_conflict_with_overlap(context: Context, strategy: str) -> None:
    """Load a conflict fixture entry with file overlap."""
    fixture_path = _FIXTURES_DIR / "conflict_simulations.json"
    with open(fixture_path) as f:
        data = json.load(f)
    for entry in data["fixtures"]:
        if entry["merge_strategy"] == strategy and entry.get(
            "expected_conflict", False
        ):
            context.m4_conflict_entry = entry
            return
    # For last_wins, expected_conflict is false but files overlap
    for entry in data["fixtures"]:
        if entry["merge_strategy"] == strategy:
            context.m4_conflict_entry = entry
            return
    raise AssertionError(f"No conflict fixture found for strategy '{strategy}'")


@then("the m4 smoke conflict fixture should expect no conflict")
def step_m4_no_conflict_expected(context: Context) -> None:
    """Verify the fixture expects no conflict."""
    assert context.m4_conflict_entry["expected_conflict"] is False


@then("the m4 smoke conflict fixture should expect a conflict")
def step_m4_conflict_expected(context: Context) -> None:
    """Verify the fixture expects a conflict."""
    assert context.m4_conflict_entry["expected_conflict"] is True


@then('the m4 smoke conflict fixture should expect error "{error}"')
def step_m4_conflict_error(context: Context, error: str) -> None:
    """Verify the fixture expects a specific error."""
    assert context.m4_conflict_entry.get("expected_error") == error


@then('the m4 smoke conflict fixture should expect result "{result}"')
def step_m4_conflict_result(context: Context, result: str) -> None:
    """Verify the fixture expects a specific merge result."""
    assert context.m4_conflict_entry.get("expected_merge_result") == result


# ---------------------------------------------------------------------------
# Negative case steps
# ---------------------------------------------------------------------------


@when("I m4 smoke invoke plan correct on non-existent plan")
def step_m4_correct_nonexistent(context: Context) -> None:
    """Invoke plan correct with a non-existent plan ID."""
    result = context.m4_runner.invoke(
        plan_app,
        [
            "correct",
            "NONEXISTENT_DECISION",
            "--plan",
            "01NONEXISTENT0000000000001",
            "--yes",
        ],
    )
    context.m4_last_result = result


@when("I m4 smoke invoke plan correct with empty decision id")
def step_m4_correct_empty_decision(context: Context) -> None:
    """Invoke plan correct with empty string for decision ID."""
    result = context.m4_runner.invoke(
        plan_app,
        [
            "correct",
            "",
            "--plan",
            _PLAN_ULID,
            "--yes",
        ],
    )
    context.m4_last_result = result
