"""Behave step definitions for Custom Sandbox Strategy Registration.

Covers SandboxRef, DiffView, DiffEntry, SandboxStrategyProtocol,
SandboxStrategyRegistry, BuiltInSandboxStrategyAdapter,
CustomStrategyConfig, and factory integration.

Based on issue #586.
"""

from __future__ import annotations

import os
import tempfile
from datetime import datetime
from typing import Any

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

from cleveragents.domain.models.core.resource import (
    PhysVirt,
    Resource,
    ResourceCapabilities,
)
from cleveragents.domain.models.core.resource import (
    SandboxStrategy as SandboxStrategyEnum,
)
from cleveragents.domain.models.core.sandbox_strategy import (
    DiffEntry,
    DiffView,
    SandboxRef,
    SandboxStrategyProtocol,
)
from cleveragents.infrastructure.plugins.exceptions import (
    ProtocolMismatchError,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.strategy_adapter import (
    BuiltInSandboxStrategyAdapter,
)
from cleveragents.infrastructure.sandbox.strategy_registry import (
    CustomStrategyConfig,
    SandboxStrategyRegistry,
)

# ---------------------------------------------------------------------------
# ULID helper
# ---------------------------------------------------------------------------

_COUNTER = 0


def _ulid(name: str) -> str:
    global _COUNTER
    _COUNTER += 1
    base = abs(hash(name)) % (10**20)
    raw = f"{_COUNTER:06d}{base:020d}"
    charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
    result = ""
    for ch in raw:
        result += charset[int(ch)]
    return (result + "0" * 26)[:26]


def _make_resource(name: str, location: str | None = None) -> Resource:
    return Resource(
        resource_id=_ulid(name),
        name=name,
        resource_type_name="fs-directory",
        classification=PhysVirt.PHYSICAL,
        sandbox_strategy=SandboxStrategyEnum.NONE,
        location=location,
        capabilities=ResourceCapabilities(
            readable=True, writable=True, sandboxable=True, checkpointable=False
        ),
    )


# ---------------------------------------------------------------------------
# SandboxRef
# ---------------------------------------------------------------------------


@given('a SandboxRef with sandbox_id "{sid}" and plan_id "{pid}"')
def step_create_sandbox_ref(context: Context, sid: str, pid: str) -> None:
    context.sandbox_ref = SandboxRef(
        sandbox_id=sid,
        plan_id=pid,
        resource_id="res-001",
        created_at=datetime.now(),
    )


@then('the SandboxRef sandbox_id should be "{expected}"')
def step_sandbox_ref_sid(context: Context, expected: str) -> None:
    assert context.sandbox_ref.sandbox_id == expected


@then('the SandboxRef plan_id should be "{expected}"')
def step_sandbox_ref_pid(context: Context, expected: str) -> None:
    assert context.sandbox_ref.plan_id == expected


@then("the SandboxRef should be immutable")
def step_sandbox_ref_frozen(context: Context) -> None:
    import dataclasses

    assert dataclasses.is_dataclass(context.sandbox_ref)
    try:
        context.sandbox_ref.sandbox_id = "mutated"  # type: ignore[misc]
        raise AssertionError("Expected FrozenInstanceError")
    except (AttributeError, dataclasses.FrozenInstanceError):
        pass


@given('a SandboxRef with metadata key "{key}" value "{value}"')
def step_sandbox_ref_metadata(context: Context, key: str, value: str) -> None:
    context.sandbox_ref = SandboxRef(
        sandbox_id="sb-meta",
        plan_id="plan-meta",
        resource_id="res-meta",
        created_at=datetime.now(),
        metadata={key: value},
    )


@then('the SandboxRef metadata should contain key "{key}"')
def step_sandbox_ref_meta_key(context: Context, key: str) -> None:
    assert key in context.sandbox_ref.metadata


# ---------------------------------------------------------------------------
# DiffEntry / DiffView
# ---------------------------------------------------------------------------


@given('a DiffEntry with path "{path}" and operation "{op}"')
def step_create_diff_entry(context: Context, path: str, op: str) -> None:
    context.diff_entry = DiffEntry(path=path, operation=op)


@then('the DiffEntry path should be "{expected}"')
def step_diff_entry_path(context: Context, expected: str) -> None:
    assert context.diff_entry.path == expected


@then('the DiffEntry operation should be "{expected}"')
def step_diff_entry_op(context: Context, expected: str) -> None:
    assert context.diff_entry.operation == expected


@given("a DiffView with {count:d} entries")
def step_create_diff_view(context: Context, count: int) -> None:
    entries = [
        DiffEntry(path=f"file{i}.txt", operation="modified") for i in range(count)
    ]
    context.diff_view = DiffView(
        sandbox_id="dv-001",
        entries=entries,
    )


@then("the DiffView should have {count:d} entries")
def step_diff_view_count(context: Context, count: int) -> None:
    assert len(context.diff_view.entries) == count


@then("the DiffView should have a sandbox_id")
def step_diff_view_sid(context: Context) -> None:
    assert context.diff_view.sandbox_id


@when("I attempt to create a DiffEntry with empty path")
def step_create_diff_entry_empty(context: Context) -> None:
    context.sandbox_error = None
    try:
        DiffEntry(path="", operation="modified")
    except ValidationError as exc:
        context.sandbox_error = exc


@then("a sandbox strategy validation error should be raised")
def step_sandbox_validation_error(context: Context) -> None:
    assert context.sandbox_error is not None


# ---------------------------------------------------------------------------
# SandboxStrategyProtocol
# ---------------------------------------------------------------------------


@given("the SandboxStrategyProtocol is available")
def step_protocol_available(context: Context) -> None:
    context.protocol = SandboxStrategyProtocol


@then("it should be a runtime_checkable Protocol")
def step_protocol_runtime_checkable(context: Context) -> None:
    assert hasattr(context.protocol, "__protocol_attrs__") or hasattr(
        context.protocol, "_is_runtime_protocol"
    )


class _FullMockStrategy:
    """Mock satisfying all 9 SandboxStrategy methods."""

    def create(self, plan_id: str, resource: Any) -> Any:
        return None

    def read(self, ref: Any, path: str) -> bytes:
        return b""

    def write(self, ref: Any, path: str, content: bytes) -> Any:
        return None

    def diff(self, ref: Any) -> Any:
        return None

    def commit(self, ref: Any) -> None:
        pass

    def rollback(self, ref: Any) -> None:
        pass

    def checkpoint(self, ref: Any, checkpoint_id: str) -> None:
        pass

    def restore_checkpoint(self, ref: Any, checkpoint_id: str) -> None:
        pass

    def cleanup(self, ref: Any) -> None:
        pass


class _PartialMockStrategy:
    """Missing checkpoint method."""

    def create(self, plan_id: str, resource: Any) -> Any:
        return None

    def read(self, ref: Any, path: str) -> bytes:
        return b""

    def write(self, ref: Any, path: str, content: bytes) -> Any:
        return None

    def diff(self, ref: Any) -> Any:
        return None

    def commit(self, ref: Any) -> None:
        pass

    def rollback(self, ref: Any) -> None:
        pass

    # Missing: checkpoint, restore_checkpoint

    def cleanup(self, ref: Any) -> None:
        pass


@given("a mock class implementing all 9 SandboxStrategy methods")
def step_full_mock(context: Context) -> None:
    context.mock_cls = _FullMockStrategy


@then("the mock class should satisfy SandboxStrategyProtocol")
def step_mock_satisfies(context: Context) -> None:
    instance = context.mock_cls()
    assert isinstance(instance, SandboxStrategyProtocol)


@given("a class missing the checkpoint method")
def step_partial_mock(context: Context) -> None:
    context.partial_cls = _PartialMockStrategy


@then("the class should not satisfy SandboxStrategyProtocol")
def step_partial_fails(context: Context) -> None:
    instance = context.partial_cls()
    assert not isinstance(instance, SandboxStrategyProtocol)


# ---------------------------------------------------------------------------
# SandboxStrategyRegistry
# ---------------------------------------------------------------------------


@given("a SandboxStrategyRegistry with test allowed prefixes")
def step_registry_with_prefixes(context: Context) -> None:
    context.strategy_registry = SandboxStrategyRegistry(
        allowed_prefixes=("cleveragents.",)
    )


@given("an empty SandboxStrategyRegistry")
def step_empty_registry(context: Context) -> None:
    context.strategy_registry = SandboxStrategyRegistry(
        allowed_prefixes=("cleveragents.",)
    )


@given(
    "a valid custom strategy class in "
    '"cleveragents.infrastructure.sandbox.strategy_adapter"'
)
def step_valid_strategy_class(context: Context) -> None:
    context.strategy_module = "cleveragents.infrastructure.sandbox.strategy_adapter"
    context.strategy_class = "BuiltInSandboxStrategyAdapter"


@when('I register the strategy as "{name}"')
def step_register_strategy(context: Context, name: str) -> None:
    context.strategy_registry.register(
        name, context.strategy_module, context.strategy_class
    )


@then('the registry should contain strategy "{name}"')
def step_registry_contains(context: Context, name: str) -> None:
    assert context.strategy_registry.has(name)


@then('listing strategies should include "{name}"')
def step_registry_list(context: Context, name: str) -> None:
    assert name in context.strategy_registry.list_strategies()


@when('I attempt to register a non-protocol class as "{name}"')
def step_register_nonprotocol(context: Context, name: str) -> None:
    context.sandbox_error = None
    try:
        context.strategy_registry.register(
            name,
            "cleveragents.infrastructure.sandbox.protocol",
            "SandboxError",
        )
    except ProtocolMismatchError as exc:
        context.sandbox_error = exc


@then("a sandbox ProtocolMismatchError should be raised")
def step_sandbox_protocol_mismatch(context: Context) -> None:
    assert isinstance(context.sandbox_error, ProtocolMismatchError)


@when("I attempt to register a strategy with empty name")
def step_register_empty_name(context: Context) -> None:
    context.sandbox_error = None
    try:
        context.strategy_registry.register(
            "",
            "cleveragents.infrastructure.sandbox.strategy_adapter",
            "BuiltInSandboxStrategyAdapter",
        )
    except ValueError as exc:
        context.sandbox_error = exc


@then("a sandbox ValueError should be raised")
def step_sandbox_value_error(context: Context) -> None:
    assert isinstance(context.sandbox_error, (ValueError, type(None))) is False or (
        context.sandbox_error is not None
    )
    assert context.sandbox_error is not None


@when('I look up strategy "{name}"')
def step_lookup_strategy(context: Context, name: str) -> None:
    context.strategy_lookup_result = context.strategy_registry.get(name)


@then("the strategy lookup result should be None")
def step_strategy_result_none(context: Context) -> None:
    assert context.strategy_lookup_result is None


@given("a config dictionary with 2 custom strategies")
def step_config_dict(context: Context) -> None:
    context.config_dict = {
        "adapter1": {
            "module": "cleveragents.infrastructure.sandbox.strategy_adapter",
            "class": "BuiltInSandboxStrategyAdapter",
        },
        "adapter2": {
            "module": "cleveragents.infrastructure.sandbox.strategy_adapter",
            "class": "BuiltInSandboxStrategyAdapter",
        },
    }


@when("I register all from config")
def step_register_all_config(context: Context) -> None:
    context.registered = context.strategy_registry.register_all_from_config(
        context.config_dict
    )


@then("{count:d} strategies should be registered")
def step_count_registered(context: Context, count: int) -> None:
    assert len(context.registered) == count


@given("a SandboxStrategyRegistry with one registered strategy")
def step_registry_one(context: Context) -> None:
    context.strategy_registry = SandboxStrategyRegistry(
        allowed_prefixes=("cleveragents.",)
    )
    context.strategy_registry.register(
        "single",
        "cleveragents.infrastructure.sandbox.strategy_adapter",
        "BuiltInSandboxStrategyAdapter",
    )


@when("I clear the sandbox strategy registry")
def step_clear_sandbox_registry(context: Context) -> None:
    context.strategy_registry.clear()


@then("the sandbox strategy registry should be empty")
def step_sandbox_registry_empty(context: Context) -> None:
    assert len(context.strategy_registry.list_strategies()) == 0


# ---------------------------------------------------------------------------
# BuiltInSandboxStrategyAdapter
# ---------------------------------------------------------------------------


@given('a BuiltInSandboxStrategyAdapter for "{strategy}" strategy')
def step_adapter(context: Context, strategy: str) -> None:
    context.adapter = BuiltInSandboxStrategyAdapter(strategy_name=strategy)  # type: ignore[arg-type]


@given("a test Resource with location")
def step_resource_with_location(context: Context) -> None:
    context.tmpdir = tempfile.mkdtemp(prefix="ca-test-")
    context.test_resource = _make_resource("test-res", location=context.tmpdir)


@given("a test Resource with a temporary directory")
def step_resource_with_tmpdir(context: Context) -> None:
    context.tmpdir = tempfile.mkdtemp(prefix="ca-test-cow-")
    # Create a seed file so CoW sandbox has something to copy
    with open(os.path.join(context.tmpdir, "seed.txt"), "w") as f:
        f.write("seed")
    context.test_resource = Resource(
        resource_id=_ulid("cow-res"),
        name="cow-res",
        resource_type_name="fs-directory",
        classification=PhysVirt.PHYSICAL,
        sandbox_strategy=SandboxStrategyEnum.COPY_ON_WRITE,
        location=context.tmpdir,
        capabilities=ResourceCapabilities(
            readable=True, writable=True, sandboxable=True, checkpointable=False
        ),
    )


@when('I call adapter.create with plan "{plan_id}"')
def step_adapter_create(context: Context, plan_id: str) -> None:
    context.adapter_ref = context.adapter.create(plan_id, context.test_resource)


@given('the adapter sandbox is created for plan "{plan_id}"')
def step_adapter_create_given(context: Context, plan_id: str) -> None:
    context.adapter_ref = context.adapter.create(plan_id, context.test_resource)


@then('I should get a SandboxRef with plan_id "{expected}"')
def step_adapter_ref_check(context: Context, expected: str) -> None:
    assert isinstance(context.adapter_ref, SandboxRef)
    assert context.adapter_ref.plan_id == expected


@when('I call adapter.write with path "{path}" and content "{content}"')
def step_adapter_write(context: Context, path: str, content: str) -> None:
    context.write_result = context.adapter.write(
        context.adapter_ref, path, content.encode()
    )


@given('I write "{content}" to "{path}" via the adapter')
def step_adapter_write_given(context: Context, content: str, path: str) -> None:
    context.adapter.write(context.adapter_ref, path, content.encode())


@then('I should get a DiffEntry with operation "{expected}"')
def step_adapter_write_op(context: Context, expected: str) -> None:
    assert isinstance(context.write_result, DiffEntry)
    assert context.write_result.operation == expected


@when('I call adapter.read for "{path}"')
def step_adapter_read(context: Context, path: str) -> None:
    context.read_result = context.adapter.read(context.adapter_ref, path)


@then('I should get content "{expected}"')
def step_adapter_read_content(context: Context, expected: str) -> None:
    assert context.read_result == expected.encode()


@when("I call adapter.diff")
def step_adapter_diff(context: Context) -> None:
    context.diff_result = context.adapter.diff(context.adapter_ref)


@then("I should get a DiffView")
def step_adapter_diff_check(context: Context) -> None:
    assert isinstance(context.diff_result, DiffView)


@given('I checkpoint as "{cp_id}"')
def step_adapter_checkpoint(context: Context, cp_id: str) -> None:
    context.adapter.checkpoint(context.adapter_ref, cp_id)


@when('I restore checkpoint "{cp_id}"')
def step_adapter_restore(context: Context, cp_id: str) -> None:
    context.adapter.restore_checkpoint(context.adapter_ref, cp_id)


@then('reading "{path}" should return "{expected}"')
def step_adapter_read_after_restore(context: Context, path: str, expected: str) -> None:
    data = context.adapter.read(context.adapter_ref, path)
    assert data == expected.encode(), f"Expected {expected!r}, got {data!r}"


@when("I call adapter.cleanup")
def step_adapter_cleanup(context: Context) -> None:
    context.adapter.cleanup(context.adapter_ref)


@then("the adapter should have no tracked sandboxes")
def step_adapter_no_sandboxes(context: Context) -> None:
    assert len(context.adapter._sandboxes) == 0


# ---------------------------------------------------------------------------
# CustomStrategyConfig
# ---------------------------------------------------------------------------


@when("I attempt to create a CustomStrategyConfig with empty name")
def step_config_empty_name(context: Context) -> None:
    context.sandbox_error = None
    try:
        CustomStrategyConfig(name="", module="mod", class_name="Cls")
    except ValueError as exc:
        context.sandbox_error = exc


@when("I attempt to create a CustomStrategyConfig with empty module")
def step_config_empty_module(context: Context) -> None:
    context.sandbox_error = None
    try:
        CustomStrategyConfig(name="x", module="", class_name="Cls")
    except ValueError as exc:
        context.sandbox_error = exc


@when("I attempt to create a CustomStrategyConfig with empty class_name")
def step_config_empty_class(context: Context) -> None:
    context.sandbox_error = None
    try:
        CustomStrategyConfig(name="x", module="mod", class_name="")
    except ValueError as exc:
        context.sandbox_error = exc


# ---------------------------------------------------------------------------
# Factory custom strategy integration
# ---------------------------------------------------------------------------


@given('a SandboxFactory with a custom registry containing "{name}"')
def step_factory_with_registry(context: Context, name: str) -> None:
    registry = SandboxStrategyRegistry(allowed_prefixes=("cleveragents.",))
    registry.register(
        name,
        "cleveragents.infrastructure.sandbox.strategy_adapter",
        "BuiltInSandboxStrategyAdapter",
    )
    context.factory = SandboxFactory(custom_registry=registry)


@given("a SandboxFactory without a custom registry")
def step_factory_no_registry(context: Context) -> None:
    context.factory = SandboxFactory()


@then('the factory should report "{name}" as a custom strategy')
def step_factory_has_custom(context: Context, name: str) -> None:
    assert context.factory.has_custom_strategy(name)


@then('the factory should not report "{name}" as a custom strategy')
def step_factory_no_custom(context: Context, name: str) -> None:
    assert not context.factory.has_custom_strategy(name)
