"""Step definitions for PLAN_APPLIED event enrichment with changeset statistics.

Tests that:
1. ``PlanLifecycleService.complete_apply`` accepts changeset statistics kwargs
   and includes them in the emitted ``PLAN_APPLIED`` domain event.
2. ``PlanApplyService.apply_with_validation_gate`` computes and passes
   changeset statistics to ``complete_apply``.

Based on issue #716 (SEC7 audit logging enrichment).
"""

from __future__ import annotations

from typing import Any
from unittest.mock import MagicMock

import structlog
from behave import given, then, when  # type: ignore[import-untyped]
from behave.runner import Context  # type: ignore[import-untyped]

from cleveragents.application.services.plan_apply_service import (
    PlanApplyService,
)
from cleveragents.application.services.plan_lifecycle_service import (
    PlanLifecycleService,
)
from cleveragents.domain.models.core.change import (
    ChangeEntry,
    ChangeOperation,
    SpecChangeSet,
)
from cleveragents.domain.models.core.plan import (
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
)
from cleveragents.infrastructure.events.models import DomainEvent
from cleveragents.infrastructure.events.types import EventType

__all__: list[str] = []

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_PLAN_ID = "01ENRICHTEST00000000001"
_CHANGESET_ID = "01CSENRICH000000000001"


# ---------------------------------------------------------------------------
# Mock helpers
# ---------------------------------------------------------------------------


def _make_mock_plan(
    *,
    plan_id: str = _PLAN_ID,
    phase: PlanPhase = PlanPhase.APPLY,
    state: ProcessingState = ProcessingState.PROCESSING,
    changeset_id: str | None = _CHANGESET_ID,
    action_name: str = "test-action",
    project_links: list[Any] | None = None,
    created_by: str = "test-actor",
    validation_summary: dict[str, Any] | None = None,
    is_terminal: bool = False,
    error_details: dict[str, str] | None = None,
) -> MagicMock:
    """Create a mock Plan object for enrichment tests."""
    plan = MagicMock()
    plan.identity.plan_id = plan_id
    plan.phase = phase
    plan.processing_state = state
    plan.state = state
    plan.changeset_id = changeset_id
    plan.action_name = action_name
    plan.project_links = project_links or []
    plan.created_by = created_by
    plan.validation_summary = validation_summary
    plan.is_terminal = is_terminal
    plan.error_details = error_details
    plan.timestamps = PlanTimestamps()
    return plan


def _make_changeset(
    plan_id: str = _PLAN_ID,
    n_modify: int = 0,
    n_create: int = 0,
    n_delete: int = 0,
    resource_ids: list[str] | None = None,
) -> SpecChangeSet:
    """Create a SpecChangeSet with specified entry counts."""
    cs = SpecChangeSet(plan_id=plan_id, changeset_id=_CHANGESET_ID)
    idx = 0

    def _res(i: int) -> str:
        if resource_ids:
            return resource_ids[i % len(resource_ids)]
        return f"RES{i:03d}"

    for i in range(n_create):
        cs.add_change(
            ChangeEntry(
                plan_id=plan_id,
                resource_id=_res(idx),
                tool_name="builtin/test",
                operation=ChangeOperation.CREATE,
                path=f"src/new_file_{i}.py",
                after_hash=f"after-{i}",
            )
        )
        idx += 1

    for i in range(n_modify):
        cs.add_change(
            ChangeEntry(
                plan_id=plan_id,
                resource_id=_res(idx),
                tool_name="builtin/test",
                operation=ChangeOperation.MODIFY,
                path=f"src/file_{i}.py",
                before_hash=f"before-{i}",
                after_hash=f"after-{i}",
            )
        )
        idx += 1

    for i in range(n_delete):
        cs.add_change(
            ChangeEntry(
                plan_id=plan_id,
                resource_id=_res(idx),
                tool_name="builtin/test",
                operation=ChangeOperation.DELETE,
                path=f"src/old_file_{i}.py",
                before_hash=f"before-del-{i}",
            )
        )
        idx += 1

    return cs


def _build_lifecycle_with_capturing_bus(
    plan: MagicMock,
) -> tuple[MagicMock, list[DomainEvent]]:
    """Build a lifecycle mock that calls the real complete_apply and captures events.

    Returns:
        Tuple of (lifecycle_mock, emitted_events_list).
    """
    emitted_events: list[DomainEvent] = []

    event_bus = MagicMock()
    event_bus.emit.side_effect = lambda e: emitted_events.append(e)

    # We need a lifecycle object that has all the attributes complete_apply
    # accesses, but calls the real implementation.
    lifecycle = MagicMock(spec=PlanLifecycleService)
    lifecycle.event_bus = event_bus
    lifecycle.get_plan.return_value = plan
    lifecycle._commit_plan = MagicMock()
    lifecycle._cleanup_devcontainers = MagicMock()
    lifecycle._logger = structlog.get_logger("test.lifecycle")

    # Bind the real complete_apply to this mock instance
    def _real_complete_apply(plan_id: str, **kwargs: Any) -> Any:
        return PlanLifecycleService.complete_apply(lifecycle, plan_id, **kwargs)

    lifecycle.complete_apply = _real_complete_apply

    return lifecycle, emitted_events


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


@given("a plan applied event enrichment test environment")
def step_enrichment_env(context: Context) -> None:
    """Initialise shared context for enrichment tests."""
    context.emitted_events = []
    context.complete_apply_calls = []


# ---------------------------------------------------------------------------
# Given steps — lifecycle service scenarios
# ---------------------------------------------------------------------------


@given("a plan in Apply/PROCESSING state")
def step_plan_apply_processing(context: Context) -> None:
    """Set up a plan in Apply/PROCESSING state with a capturing event bus."""
    context.plan = _make_mock_plan()
    lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
    context.lifecycle = lifecycle
    context.emitted_events = emitted_events


@given('a plan in Apply/PROCESSING state with action_name "{action_name}"')
def step_plan_apply_processing_with_action(context: Context, action_name: str) -> None:
    """Set up a plan in Apply/PROCESSING state with a specific action_name."""
    context.plan = _make_mock_plan(action_name=action_name)
    lifecycle, emitted_events = _build_lifecycle_with_capturing_bus(context.plan)
    context.lifecycle = lifecycle
    context.emitted_events = emitted_events


# ---------------------------------------------------------------------------
# Given steps — apply_with_validation_gate scenarios
# ---------------------------------------------------------------------------


@given("a plan with changeset containing {n:d} entries")
def step_plan_with_n_entries(context: Context, n: int) -> None:
    """Set up a plan with a changeset of n MODIFY entries."""
    context.plan = _make_mock_plan()
    context.changeset = _make_changeset(n_modify=n)
    context.complete_apply_kwargs = {}
    context.apply_result = None


@given("a plan with changeset containing entries for {n:d} distinct resources")
def step_plan_with_n_resources(context: Context, n: int) -> None:
    """Set up a plan with a changeset spanning n distinct resources."""
    resource_ids = [f"RES{i:03d}" for i in range(n)]
    context.plan = _make_mock_plan()
    context.changeset = _make_changeset(
        n_modify=n,
        resource_ids=resource_ids,
    )
    context.complete_apply_kwargs = {}
    context.apply_result = None


@given("a plan with changeset containing {n:d} CREATE entries")
def step_plan_with_n_creates(context: Context, n: int) -> None:
    """Set up a plan with a changeset of n CREATE entries."""
    context.plan = _make_mock_plan()
    context.changeset = _make_changeset(n_create=n)
    context.complete_apply_kwargs = {}
    context.apply_result = None


@given("a plan with changeset containing {n:d} DELETE entries")
def step_plan_with_n_deletes(context: Context, n: int) -> None:
    """Set up a plan with a changeset of n DELETE entries."""
    context.plan = _make_mock_plan()
    context.changeset = _make_changeset(n_delete=n)
    context.complete_apply_kwargs = {}
    context.apply_result = None


@given("the plan has no validation failures")
def step_no_validation_failures(context: Context) -> None:
    """Ensure the plan has no validation failures."""
    context.plan.validation_summary = {
        "required_passed": 0,
        "required_failed": 0,
        "total": 0,
    }
    context.plan.is_terminal = False


# ---------------------------------------------------------------------------
# When steps — lifecycle service scenarios
# ---------------------------------------------------------------------------


@when("complete_apply is called with files_changed={n:d}")
def step_complete_apply_files_changed(context: Context, n: int) -> None:
    """Call complete_apply with files_changed kwarg."""
    context.lifecycle.complete_apply(_PLAN_ID, files_changed=n)


@when("complete_apply is called with lines_added={n:d}")
def step_complete_apply_lines_added(context: Context, n: int) -> None:
    """Call complete_apply with lines_added kwarg."""
    context.lifecycle.complete_apply(_PLAN_ID, lines_added=n)


@when("complete_apply is called with lines_removed={n:d}")
def step_complete_apply_lines_removed(context: Context, n: int) -> None:
    """Call complete_apply with lines_removed kwarg."""
    context.lifecycle.complete_apply(_PLAN_ID, lines_removed=n)


@when("complete_apply is called with resources_modified={n:d}")
def step_complete_apply_resources_modified(context: Context, n: int) -> None:
    """Call complete_apply with resources_modified kwarg."""
    context.lifecycle.complete_apply(_PLAN_ID, resources_modified=n)


@when("complete_apply is called with apply_duration_seconds={v:f}")
def step_complete_apply_duration(context: Context, v: float) -> None:
    """Call complete_apply with apply_duration_seconds kwarg."""
    context.lifecycle.complete_apply(_PLAN_ID, apply_duration_seconds=v)


@when("complete_apply is called with all changeset statistics")
def step_complete_apply_all_stats(context: Context) -> None:
    """Call complete_apply with all changeset statistics kwargs."""
    context.lifecycle.complete_apply(
        _PLAN_ID,
        files_changed=10,
        lines_added=200,
        lines_removed=50,
        resources_modified=4,
        apply_duration_seconds=2.75,
    )
    context.expected_stats = {
        "files_changed": 10,
        "lines_added": 200,
        "lines_removed": 50,
        "resources_modified": 4,
        "apply_duration_seconds": 2.75,
    }


@when("complete_apply is called with no changeset statistics")
def step_complete_apply_no_stats(context: Context) -> None:
    """Call complete_apply with no changeset statistics (defaults)."""
    context.lifecycle.complete_apply(_PLAN_ID)


# ---------------------------------------------------------------------------
# When steps — apply_with_validation_gate scenarios
# ---------------------------------------------------------------------------


@when("apply_with_validation_gate is called")
def step_apply_with_validation_gate(context: Context) -> None:
    """Call apply_with_validation_gate and capture complete_apply kwargs."""
    plan = context.plan
    changeset = context.changeset

    lifecycle = MagicMock()
    lifecycle.get_plan.return_value = plan
    lifecycle._commit_plan = MagicMock()

    captured_kwargs: dict[str, Any] = {}

    def _capture_complete_apply(plan_id: str, **kwargs: Any) -> MagicMock:
        captured_kwargs.update(kwargs)
        return plan

    lifecycle.complete_apply.side_effect = _capture_complete_apply

    store = MagicMock()
    store.get.return_value = changeset

    service = PlanApplyService(
        lifecycle_service=lifecycle,
        changeset_store=store,
    )

    context.apply_result = service.apply_with_validation_gate(plan.identity.plan_id)
    context.complete_apply_kwargs = captured_kwargs


# ---------------------------------------------------------------------------
# Then steps — event details assertions
# ---------------------------------------------------------------------------


def _get_plan_applied_event(context: Context) -> DomainEvent:
    """Return the first PLAN_APPLIED event from the captured list."""
    events = [
        e for e in context.emitted_events if e.event_type == EventType.PLAN_APPLIED
    ]
    assert events, "No PLAN_APPLIED event was emitted"
    return events[0]


@then("the emitted PLAN_APPLIED event details should include files_changed={n:d}")
def step_assert_files_changed(context: Context, n: int) -> None:
    """Assert files_changed in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    assert event.details.get("files_changed") == n, (
        f"Expected files_changed={n}, got {event.details.get('files_changed')!r}"
    )


@then("the emitted PLAN_APPLIED event details should include lines_added={n:d}")
def step_assert_lines_added(context: Context, n: int) -> None:
    """Assert lines_added in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    assert event.details.get("lines_added") == n, (
        f"Expected lines_added={n}, got {event.details.get('lines_added')!r}"
    )


@then("the emitted PLAN_APPLIED event details should include lines_removed={n:d}")
def step_assert_lines_removed(context: Context, n: int) -> None:
    """Assert lines_removed in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    assert event.details.get("lines_removed") == n, (
        f"Expected lines_removed={n}, got {event.details.get('lines_removed')!r}"
    )


@then("the emitted PLAN_APPLIED event details should include resources_modified={n:d}")
def step_assert_resources_modified(context: Context, n: int) -> None:
    """Assert resources_modified in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    assert event.details.get("resources_modified") == n, (
        f"Expected resources_modified={n}, got {event.details.get('resources_modified')!r}"
    )


@then(
    "the emitted PLAN_APPLIED event details should include apply_duration_seconds={v:f}"
)
def step_assert_apply_duration(context: Context, v: float) -> None:
    """Assert apply_duration_seconds in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    actual = event.details.get("apply_duration_seconds")
    assert actual == v, f"Expected apply_duration_seconds={v}, got {actual!r}"


@then("the emitted PLAN_APPLIED event details should include all changeset statistics")
def step_assert_all_stats(context: Context) -> None:
    """Assert all changeset statistics are present in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    expected = context.expected_stats
    for key, value in expected.items():
        actual = event.details.get(key)
        assert actual == value, f"Expected {key}={value}, got {actual!r}"


@then(
    'the emitted PLAN_APPLIED event details should include action_name "{action_name}"'
)
def step_assert_action_name(context: Context, action_name: str) -> None:
    """Assert action_name is preserved in PLAN_APPLIED event details."""
    event = _get_plan_applied_event(context)
    assert event.details.get("action_name") == action_name, (
        f"Expected action_name={action_name!r}, got {event.details.get('action_name')!r}"
    )


# ---------------------------------------------------------------------------
# Then steps — complete_apply call assertions
# ---------------------------------------------------------------------------


@then("complete_apply should be called with files_changed={n:d}")
def step_assert_complete_apply_files_changed(context: Context, n: int) -> None:
    """Assert complete_apply was called with files_changed=n."""
    actual = context.complete_apply_kwargs.get("files_changed")
    assert actual == n, (
        f"Expected complete_apply called with files_changed={n}, got {actual!r}"
    )


@then("complete_apply should be called with resources_modified={n:d}")
def step_assert_complete_apply_resources_modified(context: Context, n: int) -> None:
    """Assert complete_apply was called with resources_modified=n."""
    actual = context.complete_apply_kwargs.get("resources_modified")
    assert actual == n, (
        f"Expected complete_apply called with resources_modified={n}, got {actual!r}"
    )


@then("complete_apply should be called with a non-negative apply_duration_seconds")
def step_assert_complete_apply_duration_nonneg(context: Context) -> None:
    """Assert complete_apply was called with a non-negative apply_duration_seconds."""
    actual = context.complete_apply_kwargs.get("apply_duration_seconds")
    assert actual is not None, "apply_duration_seconds was not passed to complete_apply"
    assert actual >= 0.0, f"Expected apply_duration_seconds >= 0.0, got {actual!r}"


@then("complete_apply should be called with lines_added={n:d}")
def step_assert_complete_apply_lines_added(context: Context, n: int) -> None:
    """Assert complete_apply was called with lines_added=n."""
    actual = context.complete_apply_kwargs.get("lines_added")
    assert actual == n, (
        f"Expected complete_apply called with lines_added={n}, got {actual!r}"
    )


@then("complete_apply should be called with lines_removed={n:d}")
def step_assert_complete_apply_lines_removed(context: Context, n: int) -> None:
    """Assert complete_apply was called with lines_removed=n."""
    actual = context.complete_apply_kwargs.get("lines_removed")
    assert actual == n, (
        f"Expected complete_apply called with lines_removed={n}, got {actual!r}"
    )
