"""Step definitions for correction_attempt_persistence.feature.

Tests the CorrectionAttemptRepository CRUD operations, state updates,
list queries, and constraint enforcement.
"""

from __future__ import annotations

from datetime import UTC, datetime, timedelta
from typing import Any

from behave import given, then, when  # type: ignore[import-untyped]
from behave.runner import Context
from pydantic import ValidationError
from sqlalchemy import create_engine, event
from sqlalchemy.exc import IntegrityError as SAIntegrityError
from sqlalchemy.orm import sessionmaker
from ulid import ULID

from cleveragents.core.exceptions import DatabaseError
from cleveragents.domain.models.core.action import Action, ActionState
from cleveragents.domain.models.core.correction import (
    CorrectionAttemptRecord,
    CorrectionAttemptState,
    CorrectionMode,
)
from cleveragents.domain.models.core.decision import (
    ContextSnapshot,
    Decision,
    DecisionType,
)
from cleveragents.domain.models.core.plan import (
    NamespacedName,
    Plan,
    PlanIdentity,
    PlanPhase,
    PlanTimestamps,
    ProcessingState,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
    ActionRepository,
    CorrectionAttemptNotFoundError,
    CorrectionAttemptRepository,
    DecisionRepository,
    DuplicateCorrectionAttemptError,
    InvalidCorrectionStateTransitionError,
    LifecyclePlanRepository,
)

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

_PLAN_ID = "01HV000000000000000000CA01"
_DECISION_ID = "01HV000000000000000000CD01"


def _setup_db(context: Context) -> None:
    """Create an in-memory SQLite DB and attach repos."""
    engine = create_engine("sqlite:///:memory:", echo=False)

    @event.listens_for(engine, "connect")
    def _enable_fk(dbapi_conn: Any, _rec: Any) -> None:
        cursor = dbapi_conn.cursor()
        cursor.execute("PRAGMA foreign_keys=ON")
        cursor.close()

    Base.metadata.create_all(engine)
    sm = sessionmaker(bind=engine)
    session = sm()
    context._ca_engine = engine
    context._ca_session = session
    context._ca_factory = lambda: session
    context._ca_repo = CorrectionAttemptRepository(
        session_factory=context._ca_factory,
    )
    context._ca_action_repo = ActionRepository(
        session_factory=context._ca_factory,
    )
    context._ca_plan_repo = LifecyclePlanRepository(
        session_factory=context._ca_factory,
    )
    context._ca_decision_repo = DecisionRepository(
        session_factory=context._ca_factory,
    )


def _make_correction_attempt(
    plan_id: str = _PLAN_ID,
    original_decision_id: str = _DECISION_ID,
    mode: CorrectionMode = CorrectionMode.REVERT,
    guidance: str = "Fix the broken implementation",
    state: CorrectionAttemptState = CorrectionAttemptState.PENDING,
    new_decision_id: str | None = None,
    archived_artifacts_path: str | None = None,
    completed_at: datetime | None = None,
) -> CorrectionAttemptRecord:
    return CorrectionAttemptRecord(
        plan_id=plan_id,
        original_decision_id=original_decision_id,
        mode=mode,
        guidance=guidance,
        state=state,
        new_decision_id=new_decision_id,
        archived_artifacts_path=archived_artifacts_path,
        completed_at=completed_at,
    )


def _create_prerequisite_action(context: Context) -> None:
    ns = NamespacedName.parse("local/correction-action")
    action = Action(
        namespaced_name=ns,
        description="Prerequisite action for correction attempt tests",
        definition_of_done="Action is complete",
        strategy_actor="strategy-actor",
        execution_actor="execution-actor",
        state=ActionState.AVAILABLE,
    )
    context._ca_action_repo.create(action)
    context._ca_session.commit()


def _create_prerequisite_plan(context: Context) -> None:
    now = datetime.now(UTC)
    plan = Plan(
        identity=PlanIdentity(plan_id=_PLAN_ID, attempt=1),
        namespaced_name=NamespacedName.parse("local/test-plan"),
        action_name="local/correction-action",
        description="Test plan for correction attempt persistence",
        phase=PlanPhase.EXECUTE,
        processing_state=ProcessingState.PROCESSING,
        timestamps=PlanTimestamps(created_at=now, updated_at=now),
    )
    context._ca_plan_repo.create(plan)
    context._ca_session.commit()


def _create_prerequisite_decision(context: Context) -> None:
    decision = Decision(
        decision_id=_DECISION_ID,
        plan_id=_PLAN_ID,
        sequence_number=0,
        decision_type=DecisionType.PROMPT_DEFINITION,
        question="What approach?",
        chosen_option="Build a REST API",
        context_snapshot=ContextSnapshot(
            hot_context_hash="sha256:test",
            hot_context_ref="ref:test",
            relevant_resources=[],
            actor_state_ref="",
        ),
    )
    context._ca_decision_repo.create(decision)
    context._ca_session.commit()


# ---------------------------------------------------------------------------
# Background steps
# ---------------------------------------------------------------------------


@given("a fresh in-memory correction attempt database")
def step_fresh_db(context: Context) -> None:
    _setup_db(context)


@given('a prerequisite action "local/correction-action" exists for correction attempts')
def step_prerequisite_action(context: Context) -> None:
    _create_prerequisite_action(context)


@given("a prerequisite plan exists for correction attempts")
def step_prerequisite_plan(context: Context) -> None:
    _create_prerequisite_plan(context)


@given("a prerequisite root decision exists for correction attempts")
def step_prerequisite_decision(context: Context) -> None:
    _create_prerequisite_decision(context)


# ---------------------------------------------------------------------------
# Create scenarios
# ---------------------------------------------------------------------------


@given("a new correction attempt in revert mode")
def step_new_revert_attempt(context: Context) -> None:
    context._ca_record = _make_correction_attempt(mode=CorrectionMode.REVERT)


@given("a new correction attempt in append mode")
def step_new_append_attempt(context: Context) -> None:
    context._ca_record = _make_correction_attempt(mode=CorrectionMode.APPEND)


@given("a new correction attempt with all fields populated")
def step_new_full_attempt(context: Context) -> None:
    context._ca_record = _make_correction_attempt(
        guidance="Detailed correction guidance text",
        archived_artifacts_path="/tmp/archived/artifacts",
    )


@when("I persist the correction attempt via the repository")
def step_persist_attempt(context: Context) -> None:
    result = context._ca_repo.create(context._ca_record)
    context._ca_session.commit()
    context._ca_persisted = result


@then("I can retrieve the correction attempt by its ID")
def step_retrieve_by_id(context: Context) -> None:
    attempt_id = context._ca_persisted.correction_attempt_id
    result = context._ca_repo.get(attempt_id)
    context._ca_retrieved = result
    assert result is not None
    assert result.correction_attempt_id == attempt_id


@then('the persisted correction attempt mode should be "{mode}"')
def step_check_mode(context: Context, mode: str) -> None:
    assert context._ca_retrieved.mode.value == mode


@then('the persisted correction attempt state should be "{state}"')
def step_check_state(context: Context, state: str) -> None:
    assert context._ca_retrieved.state.value == state


@then("the persisted correction attempt guidance should match")
def step_check_guidance(context: Context) -> None:
    assert context._ca_retrieved.guidance == context._ca_record.guidance


@then("the persisted correction attempt plan_id should match")
def step_check_plan_id(context: Context) -> None:
    assert context._ca_retrieved.plan_id == context._ca_record.plan_id


@then("the persisted correction attempt original_decision_id should match")
def step_check_original_decision_id(context: Context) -> None:
    assert (
        context._ca_retrieved.original_decision_id
        == context._ca_record.original_decision_id
    )


# ---------------------------------------------------------------------------
# List by plan
# ---------------------------------------------------------------------------


@given("3 persisted correction attempts for the same plan")
def step_three_attempts(context: Context) -> None:
    context._ca_attempts = []
    base_time = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC)
    for i in range(3):
        record = _make_correction_attempt(
            guidance=f"Correction guidance {i}",
        )
        # Use deterministic timestamps to guarantee ordering
        record.created_at = base_time + timedelta(seconds=i)
        result = context._ca_repo.create(record)
        context._ca_session.commit()
        context._ca_attempts.append(result)


@when("I list correction attempts by plan ID")
def step_list_by_plan(context: Context) -> None:
    context._ca_list_result = context._ca_repo.list_by_plan(_PLAN_ID)


@then("I should get 3 correction attempts in creation order")
def step_check_list_count(context: Context) -> None:
    assert len(context._ca_list_result) == 3
    # Check ordering
    for i in range(len(context._ca_list_result) - 1):
        assert (
            context._ca_list_result[i].created_at
            <= context._ca_list_result[i + 1].created_at
        )


# ---------------------------------------------------------------------------
# Update state
# ---------------------------------------------------------------------------


@given("a persisted correction attempt in pending state")
def step_persisted_pending(context: Context) -> None:
    record = _make_correction_attempt()
    result = context._ca_repo.create(record)
    context._ca_session.commit()
    context._ca_persisted = result
    context._ca_record = record


@given("a persisted correction attempt in executing state")
def step_persisted_executing(context: Context) -> None:
    record = _make_correction_attempt()
    result = context._ca_repo.create(record)
    context._ca_session.commit()
    # Transition to executing first (valid: pending → executing)
    result = context._ca_repo.update_state(
        result.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
    )
    context._ca_session.commit()
    context._ca_persisted = result
    context._ca_record = record


@when('I update the correction attempt state to "{state}"')
def step_update_state(context: Context, state: str) -> None:
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState(state),
    )
    context._ca_session.commit()


@when('I try to update the correction attempt state to "{state}"')
def step_try_update_state(context: Context, state: str) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState(state),
        )
        context._ca_session.commit()
        context._ca_invalid_transition_error = None
    except InvalidCorrectionStateTransitionError as exc:
        context._ca_session.rollback()
        context._ca_invalid_transition_error = exc


@then('the correction attempt state should be "{state}"')
def step_verify_state(context: Context, state: str) -> None:
    refreshed = context._ca_repo.get(
        context._ca_persisted.correction_attempt_id,
    )
    assert refreshed.state.value == state


@when("I update the correction attempt to complete with timestamp")
def step_update_complete_with_timestamp(context: Context) -> None:
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.COMPLETE,
        completed_at=datetime.now(UTC),
    )
    context._ca_session.commit()


@then("the correction attempt completed_at should be set")
def step_check_completed_at(context: Context) -> None:
    refreshed = context._ca_repo.get(
        context._ca_persisted.correction_attempt_id,
    )
    assert refreshed.completed_at is not None


@when("I update the correction attempt with a new decision ID")
def step_update_new_decision(context: Context) -> None:
    new_dec_id = str(ULID())
    # Create the new decision first
    new_decision = Decision(
        decision_id=new_dec_id,
        plan_id=_PLAN_ID,
        sequence_number=1,
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="New approach?",
        chosen_option="Use a new strategy",
        context_snapshot=ContextSnapshot(
            hot_context_hash="sha256:new",
            hot_context_ref="ref:new",
            relevant_resources=[],
            actor_state_ref="",
        ),
    )
    context._ca_decision_repo.create(new_decision)
    context._ca_session.commit()

    context._ca_new_decision_id = new_dec_id
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
        new_decision_id=new_dec_id,
    )
    context._ca_session.commit()


@then("the correction attempt new_decision_id should be set")
def step_check_new_decision_id(context: Context) -> None:
    refreshed = context._ca_repo.get(
        context._ca_persisted.correction_attempt_id,
    )
    assert refreshed.new_decision_id == context._ca_new_decision_id


@when("I update the correction attempt with archived artifacts path")
def step_update_archived_path(context: Context) -> None:
    context._ca_archived_path = "/tmp/archived/correction"
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
        archived_artifacts_path=context._ca_archived_path,
    )
    context._ca_session.commit()


@then("the correction attempt archived_artifacts_path should be set")
def step_check_archived_path(context: Context) -> None:
    refreshed = context._ca_repo.get(
        context._ca_persisted.correction_attempt_id,
    )
    assert refreshed.archived_artifacts_path == context._ca_archived_path


# ---------------------------------------------------------------------------
# Delete
# ---------------------------------------------------------------------------


@when("I delete the correction attempt")
def step_delete_attempt(context: Context) -> None:
    context._ca_delete_result = context._ca_repo.delete(
        context._ca_persisted.correction_attempt_id,
    )
    context._ca_session.commit()


@then("the correction attempt should no longer exist")
def step_check_deleted(context: Context) -> None:
    assert context._ca_delete_result is True
    try:
        context._ca_repo.get(
            context._ca_persisted.correction_attempt_id,
        )
        msg = "Should have raised CorrectionAttemptNotFoundError"
        raise AssertionError(msg)
    except CorrectionAttemptNotFoundError:
        pass


@when("I try to delete a non-existent correction attempt")
def step_delete_nonexistent(context: Context) -> None:
    context._ca_delete_result = context._ca_repo.delete(
        "01HV000000000000000NONEXIST",
    )


@then("the correction attempt delete result should be false")
def step_check_delete_false(context: Context) -> None:
    assert context._ca_delete_result is False


# ---------------------------------------------------------------------------
# Cascade deletion
# ---------------------------------------------------------------------------


@when("the parent plan is deleted")
def step_delete_parent_plan(context: Context) -> None:
    context._ca_plan_repo.delete(_PLAN_ID)
    context._ca_session.commit()


@then("the correction attempt should have been cascade deleted")
def step_check_cascade_deleted(context: Context) -> None:
    try:
        context._ca_repo.get(
            context._ca_persisted.correction_attempt_id,
        )
        msg = "Should have raised CorrectionAttemptNotFoundError after cascade delete"
        raise AssertionError(msg)
    except CorrectionAttemptNotFoundError:
        pass


# ---------------------------------------------------------------------------
# Duplicate detection
# ---------------------------------------------------------------------------


@when("I try to persist the same correction attempt again")
def step_persist_duplicate(context: Context) -> None:
    try:
        context._ca_repo.create(context._ca_record)
        context._ca_session.commit()
        context._ca_duplicate_error = None
    except DuplicateCorrectionAttemptError as exc:
        context._ca_session.rollback()
        context._ca_duplicate_error = exc


@then("a DuplicateCorrectionAttemptError should be raised")
def step_check_duplicate_error(context: Context) -> None:
    assert context._ca_duplicate_error is not None
    assert isinstance(
        context._ca_duplicate_error,
        DuplicateCorrectionAttemptError,
    )


# ---------------------------------------------------------------------------
# Not found
# ---------------------------------------------------------------------------


@when("I try to get a non-existent correction attempt")
def step_get_nonexistent(context: Context) -> None:
    try:
        context._ca_repo.get("01HV000000000000000NONEXIST")
        context._ca_not_found_error = None
    except CorrectionAttemptNotFoundError as exc:
        context._ca_not_found_error = exc


@then("a CorrectionAttemptNotFoundError should be raised")
def step_check_not_found_error(context: Context) -> None:
    assert context._ca_not_found_error is not None
    assert isinstance(
        context._ca_not_found_error,
        CorrectionAttemptNotFoundError,
    )


# ---------------------------------------------------------------------------
# Invalid state transitions
# ---------------------------------------------------------------------------


@then("an InvalidCorrectionStateTransitionError should be raised")
def step_check_invalid_transition_error(context: Context) -> None:
    assert context._ca_invalid_transition_error is not None
    assert isinstance(
        context._ca_invalid_transition_error,
        InvalidCorrectionStateTransitionError,
    )


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------


@when("I list correction attempts for a plan with no correction attempts")
def step_list_empty(context: Context) -> None:
    context._ca_list_result = context._ca_repo.list_by_plan(
        "01HV000000000000000NOATTEMPT",
    )


@then("I should get 0 correction attempts")
def step_check_empty_list(context: Context) -> None:
    assert len(context._ca_list_result) == 0


# ---------------------------------------------------------------------------
# Additional state transition coverage
# ---------------------------------------------------------------------------


@when("I update the correction attempt to failed with timestamp")
def step_update_failed_with_timestamp(context: Context) -> None:
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.FAILED,
        completed_at=datetime.now(UTC),
    )
    context._ca_session.commit()


@given("a persisted correction attempt in complete state")
def step_persisted_complete(context: Context) -> None:
    record = _make_correction_attempt()
    result = context._ca_repo.create(record)
    context._ca_session.commit()
    # pending → executing
    result = context._ca_repo.update_state(
        result.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
    )
    context._ca_session.commit()
    # executing → complete
    result = context._ca_repo.update_state(
        result.correction_attempt_id,
        state=CorrectionAttemptState.COMPLETE,
        completed_at=datetime.now(UTC),
    )
    context._ca_session.commit()
    context._ca_persisted = result
    context._ca_record = record


@given("a persisted correction attempt in failed state")
def step_persisted_failed(context: Context) -> None:
    record = _make_correction_attempt()
    result = context._ca_repo.create(record)
    context._ca_session.commit()
    # pending → executing
    result = context._ca_repo.update_state(
        result.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
    )
    context._ca_session.commit()
    # executing → failed
    result = context._ca_repo.update_state(
        result.correction_attempt_id,
        state=CorrectionAttemptState.FAILED,
        completed_at=datetime.now(UTC),
    )
    context._ca_session.commit()
    context._ca_persisted = result
    context._ca_record = record


@when("I try to update the correction attempt to executing with completed_at")
def step_try_update_executing_with_completed_at(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState.EXECUTING,
            completed_at=datetime.now(UTC),
        )
        context._ca_session.commit()
        context._ca_invalid_transition_error = None
    except InvalidCorrectionStateTransitionError as exc:
        context._ca_session.rollback()
        context._ca_invalid_transition_error = exc


# ---------------------------------------------------------------------------
# Domain model guidance validation
# ---------------------------------------------------------------------------


@when("I try to create a correction attempt with empty guidance")
def step_create_empty_guidance(context: Context) -> None:
    try:
        _make_correction_attempt(guidance="")
        context._ca_guidance_error = None
    except ValidationError as exc:
        context._ca_guidance_error = exc


@when("I try to create a correction attempt with whitespace-only guidance")
def step_create_whitespace_guidance(context: Context) -> None:
    try:
        _make_correction_attempt(guidance="   \t\n  ")
        context._ca_guidance_error = None
    except ValidationError as exc:
        context._ca_guidance_error = exc


@then("a guidance validation error should be raised")
def step_check_guidance_error(context: Context) -> None:
    assert context._ca_guidance_error is not None
    assert isinstance(context._ca_guidance_error, ValidationError)


# ---------------------------------------------------------------------------
# Additional test coverage
# ---------------------------------------------------------------------------


@when("I try to update the state of a non-existent correction attempt")
def step_try_update_nonexistent(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            "01HV000000000000000NONEXIST",
            state=CorrectionAttemptState.EXECUTING,
        )
        context._ca_not_found_error = None
    except CorrectionAttemptNotFoundError as exc:
        context._ca_not_found_error = exc


@when("I try to update the correction attempt with a non-existent decision ID")
def step_try_update_bad_fk(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState.EXECUTING,
            new_decision_id="01HV000000000000000BADFK01",
        )
        context._ca_session.commit()
        context._ca_db_error = None
    except DatabaseError as exc:
        context._ca_session.rollback()
        context._ca_db_error = exc


@then("a DatabaseError should be raised")
def step_check_db_error(context: Context) -> None:
    assert context._ca_db_error is not None
    assert isinstance(context._ca_db_error, DatabaseError)


@given("a new correction attempt with guidance at max length")
def step_new_max_guidance(context: Context) -> None:
    context._ca_record = _make_correction_attempt(
        guidance="x" * 10_000,
    )


@when("I try to create a correction attempt with guidance exceeding max length")
def step_create_over_max_guidance(context: Context) -> None:
    try:
        _make_correction_attempt(guidance="x" * 10_001)
        context._ca_guidance_error = None
    except ValidationError as exc:
        context._ca_guidance_error = exc


# ---------------------------------------------------------------------------
# Timezone normalization coverage (M3)
# ---------------------------------------------------------------------------


@given("a new correction attempt with non-UTC timezone timestamps")
def step_new_non_utc_attempt(context: Context) -> None:
    from datetime import timezone as _tz

    eastern = _tz(timedelta(hours=-5))
    context._ca_record = _make_correction_attempt(
        guidance="Timezone normalization test",
    )
    # Override created_at with a non-UTC timezone-aware datetime
    context._ca_record.created_at = datetime(2026, 6, 15, 12, 0, 0, tzinfo=eastern)


@then("the persisted correction attempt created_at should be in UTC")
def step_check_created_at_utc(context: Context) -> None:
    retrieved = context._ca_retrieved
    assert retrieved.created_at.tzinfo is not None
    assert retrieved.created_at.utcoffset() == timedelta(0)
    # Original was 12:00 EST (-5h) -> should be 17:00 UTC
    assert retrieved.created_at.hour == 17


# ---------------------------------------------------------------------------
# archived_artifacts_path round-trip on create (M4)
# ---------------------------------------------------------------------------


@given("a new correction attempt with archived_artifacts_path set")
def step_new_attempt_with_artifacts_path(context: Context) -> None:
    context._ca_record = _make_correction_attempt(
        guidance="Artifacts path round-trip test",
        archived_artifacts_path="/archive/correction/2026-06-15",
    )


@then("the persisted correction attempt archived_artifacts_path should match")
def step_check_archived_path_match(context: Context) -> None:
    assert (
        context._ca_retrieved.archived_artifacts_path
        == context._ca_record.archived_artifacts_path
    )


# ---------------------------------------------------------------------------
# Minimum-boundary guidance (L7)
# ---------------------------------------------------------------------------


@given("a new correction attempt with single-character guidance")
def step_new_single_char_guidance(context: Context) -> None:
    context._ca_record = _make_correction_attempt(guidance="X")


@then("the persisted correction attempt guidance should be a single character")
def step_check_single_char_guidance(context: Context) -> None:
    assert context._ca_retrieved.guidance == "X"
    assert len(context._ca_retrieved.guidance) == 1


# ---------------------------------------------------------------------------
# Auto-set completed_at for terminal states (T-1)
# ---------------------------------------------------------------------------


@when('I update the correction attempt state to "complete" without completed_at')
def step_update_complete_no_timestamp(context: Context) -> None:
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.COMPLETE,
    )
    context._ca_session.commit()


@when('I update the correction attempt state to "failed" without completed_at')
def step_update_failed_no_timestamp(context: Context) -> None:
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.FAILED,
    )
    context._ca_session.commit()


# ---------------------------------------------------------------------------
# FK violation on create (T-2)
# ---------------------------------------------------------------------------


@when("I try to create a correction attempt with a non-existent plan_id")
def step_create_bad_plan_fk(context: Context) -> None:
    try:
        record = _make_correction_attempt(
            plan_id="01HV000000000000000BADPLAN",
        )
        context._ca_repo.create(record)
        context._ca_session.commit()
        context._ca_db_error = None
    except DatabaseError as exc:
        context._ca_session.rollback()
        context._ca_db_error = exc


@when("I try to create a correction attempt with a non-existent original_decision_id")
def step_create_bad_decision_fk(context: Context) -> None:
    try:
        record = _make_correction_attempt(
            original_decision_id="01HV000000000000000BADDEC1",
        )
        context._ca_repo.create(record)
        context._ca_session.commit()
        context._ca_db_error = None
    except DatabaseError as exc:
        context._ca_session.rollback()
        context._ca_db_error = exc


# ---------------------------------------------------------------------------
# Invalid mode at domain model level (T-3)
# ---------------------------------------------------------------------------


@when("I try to create a correction attempt with an invalid mode")
def step_create_invalid_mode(context: Context) -> None:
    try:
        CorrectionAttemptRecord(
            plan_id=_PLAN_ID,
            original_decision_id=_DECISION_ID,
            mode="invalid_mode",  # type: ignore[arg-type]
            guidance="Some guidance",
        )
        context._ca_mode_error = None
    except ValidationError as exc:
        context._ca_mode_error = exc


@then("a mode validation error should be raised")
def step_check_mode_error(context: Context) -> None:
    assert context._ca_mode_error is not None
    assert isinstance(context._ca_mode_error, ValidationError)
    # Verify the error pertains to the mode field specifically
    error_fields = {str(e["loc"]) for e in context._ca_mode_error.errors()}
    assert any("mode" in field for field in error_fields)


# ---------------------------------------------------------------------------
# Empty/whitespace new_decision_id via update_state (M-1)
# ---------------------------------------------------------------------------


@when("I try to update the correction attempt with whitespace-only new_decision_id")
def step_try_update_whitespace_decision_id(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState.EXECUTING,
            new_decision_id="   ",
        )
        context._ca_session.commit()
        context._ca_value_error = None
    except ValueError as exc:
        context._ca_value_error = exc


@when("I try to update the correction attempt with empty new_decision_id")
def step_try_update_empty_decision_id(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState.EXECUTING,
            new_decision_id="",
        )
        context._ca_session.commit()
        context._ca_value_error = None
    except ValueError as exc:
        context._ca_value_error = exc


# ---------------------------------------------------------------------------
# Combined field update (L-5)
# ---------------------------------------------------------------------------


@when(
    "I update the correction attempt with new_decision_id and archived_artifacts_path"
)
def step_update_combined_fields(context: Context) -> None:
    new_dec_id = str(ULID())
    # Create the new decision first
    new_decision = Decision(
        decision_id=new_dec_id,
        plan_id=_PLAN_ID,
        sequence_number=2,
        decision_type=DecisionType.STRATEGY_CHOICE,
        question="Combined update approach?",
        chosen_option="Use both fields",
        context_snapshot=ContextSnapshot(
            hot_context_hash="sha256:combined",
            hot_context_ref="ref:combined",
            relevant_resources=[],
            actor_state_ref="",
        ),
    )
    context._ca_decision_repo.create(new_decision)
    context._ca_session.commit()

    context._ca_new_decision_id = new_dec_id
    context._ca_archived_path = "/tmp/archived/correction"
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
        new_decision_id=new_dec_id,
        archived_artifacts_path=context._ca_archived_path,
    )
    context._ca_session.commit()


# ---------------------------------------------------------------------------
# Empty/whitespace archived_artifacts_path via update_state (L-2)
# ---------------------------------------------------------------------------


@when("I try to update the correction attempt with empty archived_artifacts_path")
def step_try_update_empty_artifacts_path(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState.EXECUTING,
            archived_artifacts_path="",
        )
        context._ca_session.commit()
        context._ca_value_error = None
    except ValueError as exc:
        context._ca_value_error = exc


@when(
    "I try to update the correction attempt with whitespace-only archived_artifacts_path"
)
def step_try_update_whitespace_artifacts_path(context: Context) -> None:
    try:
        context._ca_repo.update_state(
            context._ca_persisted.correction_attempt_id,
            state=CorrectionAttemptState.EXECUTING,
            archived_artifacts_path="   ",
        )
        context._ca_session.commit()
        context._ca_value_error = None
    except ValueError as exc:
        context._ca_value_error = exc


# ---------------------------------------------------------------------------
# Whitespace-padded archived_artifacts_path stripping (L-6)
# ---------------------------------------------------------------------------


@when("I update the correction attempt with whitespace-padded archived_artifacts_path")
def step_update_whitespace_padded_artifacts_path(context: Context) -> None:
    context._ca_updated = context._ca_repo.update_state(
        context._ca_persisted.correction_attempt_id,
        state=CorrectionAttemptState.EXECUTING,
        archived_artifacts_path="  /tmp/archived/padded  ",
    )
    context._ca_session.commit()


@then("the correction attempt archived_artifacts_path should be stripped")
def step_check_stripped_artifacts_path(context: Context) -> None:
    refreshed = context._ca_repo.get(
        context._ca_persisted.correction_attempt_id,
    )
    assert refreshed.archived_artifacts_path == "/tmp/archived/padded"


@then("a correction attempt ValueError should be raised")
def step_check_value_error(context: Context) -> None:
    assert context._ca_value_error is not None
    assert isinstance(context._ca_value_error, ValueError)


# ---------------------------------------------------------------------------
# RESTRICT FK on original_decision_id (R-1)
# ---------------------------------------------------------------------------

_SECOND_PLAN_ID = "01HV000000000000000000CA02"


@when("I try to delete the original decision")
def step_try_delete_original_decision(context: Context) -> None:
    try:
        context._ca_decision_repo.delete(_DECISION_ID)
        context._ca_session.commit()
        context._ca_integrity_error = None
    except Exception as exc:
        context._ca_session.rollback()
        context._ca_integrity_error = exc


@then("a database integrity error should be raised for the decision")
def step_check_integrity_error(context: Context) -> None:
    assert context._ca_integrity_error is not None
    assert isinstance(context._ca_integrity_error, (SAIntegrityError, DatabaseError))


@then("the correction attempt should still exist")
def step_check_attempt_still_exists(context: Context) -> None:
    result = context._ca_repo.get(
        context._ca_persisted.correction_attempt_id,
    )
    assert result is not None


# ---------------------------------------------------------------------------
# Stronger cross-plan isolation (I-1)
# ---------------------------------------------------------------------------


@given("a second prerequisite plan exists for correction attempts")
def step_second_prerequisite_plan(context: Context) -> None:
    now = datetime.now(UTC)
    plan = Plan(
        identity=PlanIdentity(plan_id=_SECOND_PLAN_ID, attempt=1),
        namespaced_name=NamespacedName.parse("local/test-plan-2"),
        action_name="local/correction-action",
        description="Second test plan for cross-plan isolation",
        phase=PlanPhase.EXECUTE,
        processing_state=ProcessingState.PROCESSING,
        timestamps=PlanTimestamps(created_at=now, updated_at=now),
    )
    context._ca_plan_repo.create(plan)
    context._ca_session.commit()


@given("a persisted correction attempt for the first plan")
def step_persisted_first_plan(context: Context) -> None:
    record = _make_correction_attempt(
        plan_id=_PLAN_ID,
        guidance="Attempt for first plan",
    )
    result = context._ca_repo.create(record)
    context._ca_session.commit()
    context._ca_first_plan_attempt = result


@given("a persisted correction attempt for the second plan")
def step_persisted_second_plan(context: Context) -> None:
    record = _make_correction_attempt(
        plan_id=_SECOND_PLAN_ID,
        guidance="Attempt for second plan",
    )
    result = context._ca_repo.create(record)
    context._ca_session.commit()
    context._ca_second_plan_attempt = result


@when("I list correction attempts for the first plan")
def step_list_first_plan(context: Context) -> None:
    context._ca_first_list_result = context._ca_repo.list_by_plan(_PLAN_ID)


@then("I should get exactly 1 correction attempt for the first plan")
def step_check_first_plan_count(context: Context) -> None:
    assert len(context._ca_first_list_result) == 1
    assert context._ca_first_list_result[0].plan_id == _PLAN_ID


@when("I list correction attempts for the second plan")
def step_list_second_plan(context: Context) -> None:
    context._ca_second_list_result = context._ca_repo.list_by_plan(_SECOND_PLAN_ID)


@then("I should get exactly 1 correction attempt for the second plan")
def step_check_second_plan_count(context: Context) -> None:
    assert len(context._ca_second_list_result) == 1
    assert context._ca_second_list_result[0].plan_id == _SECOND_PLAN_ID


# ---------------------------------------------------------------------------
# Defensive to_domain() coercion for corrupted DB data (DC-1)
# ---------------------------------------------------------------------------


def _insert_corrupted_row(
    context: Context,
    attempt_id: str,
    mode: str = "revert",
    guidance: str = "test guidance",
    state: str = "pending",
) -> None:
    """Insert a raw row bypassing ORM and CHECK constraints.

    Temporarily disables SQLite CHECK constraints via PRAGMA so
    corrupted values can be inserted for defensive-coercion tests.
    """
    raw_conn = context._ca_engine.raw_connection()
    cursor = raw_conn.cursor()
    cursor.execute("PRAGMA ignore_check_constraints = ON")
    cursor.execute(
        "INSERT INTO correction_attempts "
        "(correction_attempt_id, plan_id, original_decision_id, "
        "mode, guidance, state, created_at) "
        "VALUES (?, ?, ?, ?, ?, ?, strftime('%Y-%m-%dT%H:%M:%f', 'now'))",
        (attempt_id, _PLAN_ID, _DECISION_ID, mode, guidance, state),
    )
    cursor.execute("PRAGMA ignore_check_constraints = OFF")
    raw_conn.commit()
    cursor.close()
    raw_conn.close()


@given('a raw correction attempt row with corrupted mode "{mode}"')
def step_insert_corrupted_mode(context: Context, mode: str) -> None:
    """Insert a raw row with a corrupted mode value."""
    attempt_id = str(ULID())
    context._ca_corrupted_id = attempt_id
    _insert_corrupted_row(context, attempt_id, mode=mode)


@given('a raw correction attempt row with corrupted state "{state}"')
def step_insert_corrupted_state(context: Context, state: str) -> None:
    """Insert a raw row with a corrupted state value."""
    attempt_id = str(ULID())
    context._ca_corrupted_id = attempt_id
    _insert_corrupted_row(context, attempt_id, state=state)


@given("a raw correction attempt row with empty guidance")
def step_insert_empty_guidance(context: Context) -> None:
    """Insert a raw row with empty guidance."""
    attempt_id = str(ULID())
    context._ca_corrupted_id = attempt_id
    _insert_corrupted_row(context, attempt_id, guidance="")


@when("I retrieve the corrupted correction attempt by ID")
def step_retrieve_corrupted(context: Context) -> None:
    # Expire session cache so the ORM re-reads from the database,
    # picking up data inserted via the raw connection.
    context._ca_session.expire_all()
    context._ca_corrupted_record = context._ca_repo.get(
        context._ca_corrupted_id,
    )


@then('the retrieved correction attempt mode should be "{mode}"')
def step_check_corrupted_mode(context: Context, mode: str) -> None:
    assert context._ca_corrupted_record.mode.value == mode


@then('the retrieved correction attempt state should be "{state}"')
def step_check_corrupted_state(context: Context, state: str) -> None:
    assert context._ca_corrupted_record.state.value == state


@then('the retrieved correction attempt guidance should be "{guidance}"')
def step_check_corrupted_guidance(context: Context, guidance: str) -> None:
    assert context._ca_corrupted_record.guidance == guidance


# ---------------------------------------------------------------------------
# format_sqlite_timestamp naive datetime rejection (TS-1)
# ---------------------------------------------------------------------------


@when("I call format_sqlite_timestamp with a naive datetime")
def step_call_format_sqlite_naive(context: Context) -> None:
    from cleveragents.infrastructure.database.models import (
        format_sqlite_timestamp,
    )

    try:
        format_sqlite_timestamp(datetime(2026, 1, 1, 12, 0, 0))
        context._ca_naive_dt_error = None
    except ValueError as exc:
        context._ca_naive_dt_error = exc


@then("a ValueError should be raised for naive datetime")
def step_check_naive_dt_error(context: Context) -> None:
    assert context._ca_naive_dt_error is not None
    assert isinstance(context._ca_naive_dt_error, ValueError)


# ---------------------------------------------------------------------------
# Domain model naive datetime normalisation (TS-2)
# ---------------------------------------------------------------------------


@when("I create a CorrectionAttemptRecord with naive created_at")
def step_create_record_naive_created_at(context: Context) -> None:
    context._ca_naive_record = CorrectionAttemptRecord(
        plan_id=_PLAN_ID,
        original_decision_id=_DECISION_ID,
        mode=CorrectionMode.REVERT,
        guidance="Naive datetime test",
        created_at=datetime(2026, 6, 15, 12, 0, 0),
    )


@then("the record created_at should have UTC timezone")
def step_check_record_created_at_utc(context: Context) -> None:
    assert context._ca_naive_record.created_at.tzinfo is not None
    assert context._ca_naive_record.created_at.utcoffset() == timedelta(0)


@when("I create a CorrectionAttemptRecord with naive completed_at")
def step_create_record_naive_completed_at(context: Context) -> None:
    context._ca_naive_record = CorrectionAttemptRecord(
        plan_id=_PLAN_ID,
        original_decision_id=_DECISION_ID,
        mode=CorrectionMode.REVERT,
        guidance="Naive datetime test",
        completed_at=datetime(2026, 6, 15, 12, 0, 0),
    )


@then("the record completed_at should have UTC timezone")
def step_check_record_completed_at_utc(context: Context) -> None:
    assert context._ca_naive_record.completed_at is not None
    assert context._ca_naive_record.completed_at.tzinfo is not None
    assert context._ca_naive_record.completed_at.utcoffset() == timedelta(0)


# ---------------------------------------------------------------------------
# Corrupted DB state in update_state (DC-2)
# ---------------------------------------------------------------------------


@when('I try to update the corrupted correction attempt state to "{state}"')
def step_try_update_corrupted_state(context: Context, state: str) -> None:
    # Expire session cache so the ORM re-reads the corrupted row.
    context._ca_session.expire_all()
    try:
        context._ca_repo.update_state(
            context._ca_corrupted_id,
            state=CorrectionAttemptState(state),
        )
        context._ca_session.commit()
        context._ca_invalid_transition_error = None
    except InvalidCorrectionStateTransitionError as exc:
        context._ca_session.rollback()
        context._ca_invalid_transition_error = exc
