"""Step definitions for invariant_models.feature.

Tests the Invariant domain models, InvariantService, merge precedence,
enforcement records, and violation models.
"""

from __future__ import annotations

import re

from behave import given, then, when  # type: ignore[import-untyped]
from pydantic import ValidationError as PydanticValidationError

from cleveragents.application.services.invariant_service import InvariantService
from cleveragents.core.exceptions import NotFoundError, ValidationError
from cleveragents.domain.models.core.invariant import (
    Invariant,
    InvariantEnforcementRecord,
    InvariantScope,
    InvariantSet,
    InvariantViolation,
    merge_invariants,
)

# -- ULID pattern for validation --
ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")


# ================================================================
# Invariant Creation
# ================================================================


@when('I create an invariant with text "{text}" scope "{scope}" and source "{source}"')
def step_create_invariant(context, text, scope, source):
    context.invariant = Invariant(
        text=text,
        scope=InvariantScope(scope),
        source_name=source,
    )


@then("the invariant should be created")
def step_invariant_created(context):
    assert context.invariant is not None


@then('the invariant scope should be "{scope}"')
def step_invariant_scope(context, scope):
    assert context.invariant.scope.value == scope


@then('the invariant source name should be "{source}"')
def step_invariant_source(context, source):
    assert context.invariant.source_name == source


@then("the invariant should be active")
def step_invariant_active(context):
    assert context.invariant.active is True


@then("the invariant id should be a valid ULID")
def step_invariant_ulid(context):
    assert ULID_RE.match(context.invariant.id), (
        f"Expected ULID, got: {context.invariant.id}"
    )


@then('the invariant text should be "{text}"')
def step_invariant_text(context, text):
    assert context.invariant.text == text


@when("I try to create an invariant with empty text")
def step_create_empty_text(context):
    try:
        Invariant(text="", scope=InvariantScope.GLOBAL, source_name="system")
        context.error = None
    except Exception as e:
        context.error = e


@when("I try to create an invariant with whitespace-only text")
def step_create_whitespace_text(context):
    try:
        Invariant(text="   ", scope=InvariantScope.GLOBAL, source_name="system")
        context.error = None
    except Exception as e:
        context.error = e


# NOTE: "a validation error should be raised" and "the error should mention"
# are already defined in features/steps/domain_models_steps.py and shared
# across all features via Behave's global step registry.


# ================================================================
# InvariantScope Enum
# ================================================================


@then('InvariantScope should have values "{values}"')
def step_scope_values(context, values):
    expected = {v.strip() for v in values.split(",")}
    actual = {s.value for s in InvariantScope}
    assert expected == actual, f"Expected {expected}, got {actual}"


# ================================================================
# Merge Precedence
# ================================================================


def _parse_invariant_table(context, scope: InvariantScope):
    """Parse a Behave table into a list of Invariant objects."""
    invariants = []
    if context.table:
        for row in context.table:
            text = row["text"].strip()
            source = row["source"].strip()
            if text:
                invariants.append(
                    Invariant(
                        text=text,
                        scope=scope,
                        source_name=source,
                    )
                )
    return invariants


@given("I have plan invariants")
def step_plan_invariants(context):
    context.plan_invariants = _parse_invariant_table(context, InvariantScope.PLAN)


@given("I have project invariants")
def step_project_invariants(context):
    context.project_invariants = _parse_invariant_table(context, InvariantScope.PROJECT)


@given("I have global invariants")
def step_global_invariants(context):
    context.global_invariants = _parse_invariant_table(context, InvariantScope.GLOBAL)


@given("I have plan invariants with an inactive entry")
def step_plan_invariants_inactive(context):
    inv = Invariant(
        text="Inactive constraint",
        scope=InvariantScope.PLAN,
        source_name="plan1",
        active=False,
    )
    context.plan_invariants = [inv]


@when("I merge the invariants")
def step_merge(context):
    context.merged = merge_invariants(
        getattr(context, "plan_invariants", []),
        getattr(context, "project_invariants", []),
        getattr(context, "global_invariants", []),
    )


@then("the merged set should have {count:d} invariants")
def step_merged_count(context, count):
    assert len(context.merged) == count, f"Expected {count}, got {len(context.merged)}"


@then('the merged invariant at index {idx:d} should have text "{text}"')
def step_merged_text(context, idx, text):
    assert context.merged[idx].text == text


@then('the merged invariant at index {idx:d} should have scope "{scope}"')
def step_merged_scope(context, idx, scope):
    assert context.merged[idx].scope.value == scope


# ================================================================
# InvariantSet
# ================================================================


@when("I merge using InvariantSet")
def step_merge_invariant_set(context):
    inv_set = InvariantSet.merge(
        getattr(context, "plan_invariants", []),
        getattr(context, "project_invariants", []),
        getattr(context, "global_invariants", []),
    )
    context.invariant_set = inv_set


@then("the invariant set should have {count:d} invariants")
def step_set_count(context, count):
    assert len(context.invariant_set.invariants) == count


# ================================================================
# Service: Add / List / Remove
# ================================================================


@given("an invariant service")
def step_service(context):
    context.service = InvariantService()


@when(
    'I add an invariant via service with text "{text}" scope "{scope}" source "{source}"'
)
def step_service_add(context, text, scope, source):
    context.service_invariant = context.service.add_invariant(
        text=text,
        scope=InvariantScope(scope),
        source_name=source,
    )


@then("the service should return the created invariant")
def step_service_created(context):
    assert context.service_invariant is not None


@then('the service invariant text should be "{text}"')
def step_service_text(context, text):
    assert context.service_invariant.text == text


@when("I try to add an invariant via service with empty text")
def step_service_add_empty_text(context):
    try:
        context.service.add_invariant(
            text="", scope=InvariantScope.GLOBAL, source_name="system"
        )
        context.error = None
    except ValidationError as e:
        context.error = e


@when("I try to add an invariant via service with empty source")
def step_service_add_empty_source(context):
    try:
        context.service.add_invariant(
            text="Some text", scope=InvariantScope.GLOBAL, source_name=""
        )
        context.error = None
    except ValidationError as e:
        context.error = e


@then("a service validation error should be raised")
def step_service_validation_error(context):
    assert context.error is not None
    assert isinstance(context.error, ValidationError)


@given("an invariant service with {count:d} invariants")
def step_service_with_n(context, count):
    context.service = InvariantService()
    context.service_ids = []
    for i in range(count):
        inv = context.service.add_invariant(
            text=f"Constraint {i + 1}",
            scope=InvariantScope.GLOBAL,
            source_name="system",
        )
        context.service_ids.append(inv.id)


@when("I list all invariants via service")
def step_service_list_all(context):
    context.service_results = context.service.list_invariants()


@then("the service should return {count:d} invariants")
def step_service_list_count(context, count):
    assert len(context.service_results) == count


@given("an invariant service with mixed scopes")
def step_service_mixed_scopes(context):
    context.service = InvariantService()
    context.service.add_invariant("G1", InvariantScope.GLOBAL, "system")
    context.service.add_invariant("G2", InvariantScope.GLOBAL, "system")
    context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")


@when('I list invariants via service with scope "{scope}"')
def step_service_list_scope(context, scope):
    context.service_results = context.service.list_invariants(
        scope=InvariantScope(scope)
    )


@then('all returned invariants should have scope "{scope}"')
def step_all_scope(context, scope):
    for inv in context.service_results:
        assert inv.scope.value == scope


@given("an invariant service with mixed sources")
def step_service_mixed_sources(context):
    context.service = InvariantService()
    context.service.add_invariant("P1", InvariantScope.PROJECT, "myapp")
    context.service.add_invariant("P2", InvariantScope.PROJECT, "myapp")
    context.service.add_invariant("P3", InvariantScope.PROJECT, "other")


@when('I list invariants via service with source "{source}"')
def step_service_list_source(context, source):
    context.service_results = context.service.list_invariants(source_name=source)


@then('all returned invariants should have source "{source}"')
def step_all_source(context, source):
    for inv in context.service_results:
        assert inv.source_name == source


@given("an invariant service with {count:d} invariant")
def step_service_with_one(context, count):
    context.service = InvariantService()
    context.service_ids = []
    for i in range(count):
        inv = context.service.add_invariant(
            text=f"Constraint {i + 1}",
            scope=InvariantScope.GLOBAL,
            source_name="system",
        )
        context.service_ids.append(inv.id)


@when("I remove the invariant via service")
def step_service_remove(context):
    # Store a reference to the original before removal so we can verify
    # that the original is unchanged (frozen value-object semantics).
    context.original_inv = context.service._invariants[context.service_ids[0]]
    context.removed_inv = context.service.remove_invariant(context.service_ids[0])


@then("the invariant should be inactive")
def step_invariant_inactive(context):
    assert context.removed_inv.active is False


@then("listing active invariants should return {count:d}")
def step_list_active(context, count):
    results = context.service.list_invariants()
    assert len(results) == count


@when("I try to remove a non-existent invariant")
def step_remove_nonexistent(context):
    try:
        context.service.remove_invariant("01ZZZZZZZZZZZZZZZZZZZZZZZZ")
        context.error = None
    except NotFoundError as e:
        context.error = e


# NOTE: "a not found error should be raised" is already defined in
# features/steps/plan_lifecycle_service_steps.py and shared globally.


@when("I try to remove an invariant with empty ID")
def step_remove_empty_id(context):
    try:
        context.service.remove_invariant("")
        context.error = None
    except ValidationError as e:
        context.error = e


# ================================================================
# Effective Invariant Computation
# ================================================================


@given("an invariant service with invariants at all scopes")
def step_service_all_scopes(context):
    context.service = InvariantService()
    context.service.add_invariant("Global rule", InvariantScope.GLOBAL, "system")
    context.service.add_invariant("Project rule", InvariantScope.PROJECT, "proj1")
    context.service.add_invariant("Plan rule", InvariantScope.PLAN, "plan1")


@when('I get effective invariants for plan "{plan_id}" and project "{project}"')
def step_effective(context, plan_id, project):
    context.effective = context.service.get_effective_invariants(
        plan_id=plan_id, project_name=project
    )


@then("the effective set should contain plan invariants first")
def step_effective_plan_first(context):
    plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
    if plan_invs:
        first_plan_idx = context.effective.index(plan_invs[0])
        assert first_plan_idx == 0


@then("the effective set should contain project invariants second")
def step_effective_project_second(context):
    proj_invs = [i for i in context.effective if i.scope == InvariantScope.PROJECT]
    plan_invs = [i for i in context.effective if i.scope == InvariantScope.PLAN]
    if proj_invs:
        first_proj_idx = context.effective.index(proj_invs[0])
        assert first_proj_idx >= len(plan_invs)


@then("the effective set should contain global invariants last")
def step_effective_global_last(context):
    glob_invs = [i for i in context.effective if i.scope == InvariantScope.GLOBAL]
    if glob_invs:
        last_glob_idx = context.effective.index(glob_invs[-1])
        assert last_glob_idx == len(context.effective) - 1


@given("an invariant service with duplicate text across scopes")
def step_service_duplicates(context):
    context.service = InvariantService()
    context.service.add_invariant("Log all changes", InvariantScope.GLOBAL, "system")
    context.service.add_invariant("Log all changes", InvariantScope.PROJECT, "proj1")
    context.service.add_invariant("Log all changes", InvariantScope.PLAN, "plan1")


@then("the effective set should have no duplicates")
def step_no_duplicates(context):
    texts = [inv.text.lower() for inv in context.effective]
    assert len(texts) == len(set(texts)), f"Duplicates found: {texts}"


# ================================================================
# Enforcement
# ================================================================


@when('I enforce invariants for plan "{plan_id}"')
def step_enforce(context, plan_id):
    invs = context.service.list_invariants()
    context.enforcement_records = context.service.enforce_invariants(
        plan_id=plan_id, invariants=invs
    )


@then("{count:d} enforcement records should be created")
def step_enforcement_count(context, count):
    assert len(context.enforcement_records) == count


@then("each enforcement record should be marked as enforced")
def step_enforcement_enforced(context):
    for rec in context.enforcement_records:
        assert rec.enforced is True


@then("each enforcement record should have a decision ID")
def step_enforcement_decision_id(context):
    for rec in context.enforcement_records:
        assert rec.decision_id != ""


@when('I enforce invariants with actor response "{response}"')
def step_enforce_with_response(context, response):
    invs = context.service.list_invariants()
    context.enforcement_records = context.service.enforce_invariants(
        plan_id="PLAN_XYZ", invariants=invs, actor_response=response
    )


@then('the enforcement record actor response should be "{response}"')
def step_enforcement_response(context, response):
    assert context.enforcement_records[0].actor_response == response


@when("I try to enforce invariants with empty plan ID")
def step_enforce_empty_plan(context):
    try:
        context.service.enforce_invariants(plan_id="", invariants=[])
        context.error = None
    except ValidationError as e:
        context.error = e


# ================================================================
# InvariantViolation
# ================================================================


@when('I create an invariant violation with severity "{severity}"')
def step_create_violation(context, severity):
    try:
        context.violation = InvariantViolation(
            invariant_id="01TESTINVARIANTID000000000",
            violated_text="Some constraint was violated",
            severity=severity,
        )
        context.error = None
    except Exception as e:
        context.error = e
        context.violation = None


@then("the violation should be created")
def step_violation_created(context):
    assert context.violation is not None


@then('the invariant violation severity should be "{severity}"')
def step_violation_severity(context, severity):
    assert context.violation.severity == severity


@when('I try to create a violation with invalid severity "{severity}"')
def step_create_invalid_violation(context, severity):
    try:
        InvariantViolation(
            invariant_id="01TESTINVARIANTID000000000",
            violated_text="Some constraint",
            severity=severity,
        )
        context.error = None
    except Exception as e:
        context.error = e


# ================================================================
# InvariantEnforcementRecord
# ================================================================


@when("I create an enforcement record with enforced true")
def step_create_record_true(context):
    context.record = InvariantEnforcementRecord(
        invariant_id="01TESTINVARIANTID000000000",
        enforced=True,
        actor_response="OK",
        decision_id="01TESTDECISIONID0000000000",
    )


@when("I create an enforcement record with enforced false")
def step_create_record_false(context):
    context.record = InvariantEnforcementRecord(
        invariant_id="01TESTINVARIANTID000000000",
        enforced=False,
    )


@then("the record should be created")
def step_record_created(context):
    assert context.record is not None


@then("the record enforced flag should be true")
def step_record_enforced_true(context):
    assert context.record.enforced is True


@then("the record enforced flag should be false")
def step_record_enforced_false(context):
    assert context.record.enforced is False


# ================================================================
# Immutability contract (frozen=True) — Issue #3116
# ================================================================


@then("mutating the invariant active field should raise an error")
def step_invariant_mutation_raises(context):
    try:
        context.invariant.active = False
        context.error = None
    except Exception as e:
        context.error = e
    assert context.error is not None, "Expected mutation to raise an error"
    assert isinstance(context.error, PydanticValidationError), (
        f"Expected PydanticValidationError, got {type(context.error)}"
    )


@then("mutating the violation severity field should raise an error")
def step_violation_mutation_raises(context):
    try:
        context.violation.severity = "info"
        context.error = None
    except Exception as e:
        context.error = e
    assert context.error is not None, "Expected mutation to raise an error"
    assert isinstance(context.error, PydanticValidationError), (
        f"Expected PydanticValidationError, got {type(context.error)}"
    )


@then("mutating the record enforced field should raise an error")
def step_record_mutation_raises(context):
    try:
        context.record.enforced = False
        context.error = None
    except Exception as e:
        context.error = e
    assert context.error is not None, "Expected mutation to raise an error"
    assert isinstance(context.error, PydanticValidationError), (
        f"Expected PydanticValidationError, got {type(context.error)}"
    )


@then("the invariant should be hashable")
def step_invariant_hashable(context):
    h = hash(context.invariant)
    assert isinstance(h, int), f"Expected int hash, got {type(h)}"


@then("the violation should be hashable")
def step_violation_hashable(context):
    h = hash(context.violation)
    assert isinstance(h, int), f"Expected int hash, got {type(h)}"


@then("the record should be hashable")
def step_record_hashable(context):
    h = hash(context.record)
    assert isinstance(h, int), f"Expected int hash, got {type(h)}"


@then("the removed invariant is a different object than the original")
def step_removed_is_new_object(context):
    # The service stores the deactivated copy; the original object is unchanged.
    # Verify the returned object has active=False and is a valid Invariant.
    assert context.removed_inv.active is False
    assert isinstance(context.removed_inv, Invariant)
    # Verify object identity: the returned copy must be a *different* object.
    assert context.removed_inv is not context.original_inv, (
        "Expected model_copy to return a new object, but got the same object"
    )


@then("the original invariant should still be active")
def step_original_still_active(context):
    # The original frozen value-object must be unchanged after soft-delete.
    assert context.original_inv.active is True, (
        "Expected original invariant to remain active=True after soft-delete "
        "(frozen value-object must not be mutated)"
    )


# ================================================================
# InvariantSet immutability and hashability — Issue #3116
# ================================================================


@then("mutating the invariant set invariants field should raise an error")
def step_invariant_set_mutation_raises(context):
    try:
        context.invariant_set.invariants = []
        context.error = None
    except Exception as e:
        context.error = e
    assert context.error is not None, "Expected mutation to raise an error"
    assert isinstance(context.error, PydanticValidationError), (
        f"Expected PydanticValidationError, got {type(context.error)}"
    )


@then("the invariant set should be hashable")
def step_invariant_set_hashable(context):
    h = hash(context.invariant_set)
    assert isinstance(h, int), f"Expected int hash, got {type(h)}"
