"""Step definitions for sandbox boundary algebra BDD tests.

Covers:
- ``sandbox_boundary(r)`` function
- ``is_sandbox_boundary(r)`` predicate
- ``compute_sandbox_domains()`` grouping
- ``BoundaryCache`` caching behaviour
- ``SandboxManager`` boundary-aware keying
"""

from __future__ import annotations

from datetime import datetime
from typing import Any
from unittest.mock import MagicMock

from behave import given, then, when

from cleveragents.domain.models.core.resource import (
    PhysVirt,
    Resource,
    ResourceCapabilities,
    SandboxStrategy,
)
from cleveragents.infrastructure.sandbox.boundary import (
    BoundaryCache,
    NoSandboxBoundaryError,
    compute_sandbox_domains,
    is_sandbox_boundary,
    sandbox_boundary,
)
from cleveragents.infrastructure.sandbox.factory import SandboxFactory
from cleveragents.infrastructure.sandbox.manager import SandboxManager
from cleveragents.infrastructure.sandbox.protocol import (
    CommitResult,
    SandboxContext,
    SandboxStatus,
)

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

# Counter for generating unique ULIDs in tests.
_ULID_COUNTER: int = 0


def _make_ulid(name: str) -> str:
    """Generate a deterministic ULID-like ID from a name.

    Uses Crockford base-32 charset (0-9, A-H, J-K, M-N, P-T, V-Z)
    to satisfy the ULID pattern ``^[0-9A-HJKMNP-TV-Z]{26}$``.
    """
    global _ULID_COUNTER
    _ULID_COUNTER += 1
    # Pad the counter + hash of name into 26 chars of Crockford base32.
    base = abs(hash(name)) % (10**20)
    raw = f"{_ULID_COUNTER:06d}{base:020d}"
    # Map digits to Crockford charset.
    charset = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
    result = ""
    for ch in raw:
        result += charset[int(ch)]
    # Pad/truncate to 26.
    return (result + "0" * 26)[:26]


def _make_resource(
    name: str,
    sandboxable: bool = False,
    strategy: str = "none",
    parents: list[str] | None = None,
    location: str | None = None,
) -> Resource:
    """Create a test resource with the given properties."""
    rid = _make_ulid(name)
    return Resource(
        resource_id=rid,
        name=name,
        resource_type_name="fs-file" if not sandboxable else "git-checkout",
        classification=PhysVirt.PHYSICAL,
        sandbox_strategy=SandboxStrategy(strategy),
        capabilities=ResourceCapabilities(
            readable=True,
            writable=True,
            sandboxable=sandboxable,
            checkpointable=False,
        ),
        parents=parents or [],
        location=location,
    )


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


@given("a resource registry for boundary algebra tests")
def step_given_resource_registry(context: Any) -> None:
    """Initialise a fresh resource registry (dict) for this scenario."""
    context.resource_registry = {}
    context.resources_by_name = {}
    context.boundary_result = None
    context.boundary_error = None
    context.is_boundary_result = None
    context.domain_result = None
    context.boundary_cache = None
    context.sandbox_key_result = None
    context.boundary_sandbox_results = []
    context.error = None


# ---------------------------------------------------------------------------
# Given steps - resource construction
# ---------------------------------------------------------------------------


@given('a sandboxable resource "{name}" with strategy "{strategy}"')
def step_given_sandboxable_resource(context: Any, name: str, strategy: str) -> None:
    res = _make_resource(name, sandboxable=True, strategy=strategy)
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@given('a located sandboxable resource "{name}" strategy "{strategy}" location "{loc}"')
def step_given_sandboxable_resource_with_location(
    context: Any, name: str, strategy: str, loc: str
) -> None:
    res = _make_resource(name, sandboxable=True, strategy=strategy, location=loc)
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@given('a non-sandboxable resource "{name}" with parent "{parent_name}"')
def step_given_nonsandboxable_with_parent(
    context: Any, name: str, parent_name: str
) -> None:
    parent = context.resources_by_name[parent_name]
    res = _make_resource(name, sandboxable=False, parents=[parent.resource_id])
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@given('a non-sandboxable resource "{name}" with no parents')
def step_given_nonsandboxable_orphan(context: Any, name: str) -> None:
    res = _make_resource(name, sandboxable=False)
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@given('a non-sandboxable resource "{name}" with missing parent "{missing_parent}"')
def step_given_nonsandboxable_dangling(
    context: Any, name: str, missing_parent: str
) -> None:
    # Parent ID is not registered in the registry.
    fake_parent_id = _make_ulid(missing_parent)
    res = _make_resource(name, sandboxable=False, parents=[fake_parent_id])
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@given('a resource "{name}" with sandboxable true and strategy "{strategy}"')
def step_given_sandboxable_with_strategy(
    context: Any, name: str, strategy: str
) -> None:
    res = _make_resource(name, sandboxable=True, strategy=strategy)
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@given("a fresh boundary cache")
def step_given_boundary_cache(context: Any) -> None:
    context.boundary_cache = BoundaryCache()


def _make_mock_sandbox(
    resource_id: str = "res-mock",
    plan_id: str = "plan-mock",
    original_path: str = "/tmp/mock",
) -> MagicMock:
    """Create a mock sandbox satisfying the Sandbox protocol."""
    sb = MagicMock()
    sandbox_id = f"sb-{resource_id}"
    sb.sandbox_id = sandbox_id
    sb.status = SandboxStatus.CREATED
    sb.context = SandboxContext(
        sandbox_id=sandbox_id,
        sandbox_path=original_path,
        original_path=original_path,
        resource_id=resource_id,
        plan_id=plan_id,
        created_at=datetime.now(),
    )
    sb.create.return_value = sb.context
    sb.commit.return_value = CommitResult(
        sandbox_id=sandbox_id,
        success=True,
        timestamp=datetime.now(),
    )
    sb.rollback.return_value = None
    sb.cleanup.return_value = None
    return sb


@given("a mock sandbox factory")
def step_given_mock_factory(context: Any) -> None:
    """Create a mock factory that returns mock sandboxes.

    Mocks are cached by resource_id so the same mock is returned for
    repeated calls with the same resource_id (mimics real dedup).
    """
    factory = MagicMock(spec=SandboxFactory)
    mock_cache: dict[str, MagicMock] = {}

    def _create_sandbox(
        resource_id: str,
        original_path: str,
        sandbox_strategy: str,
    ) -> MagicMock:
        if resource_id not in mock_cache:
            mock_cache[resource_id] = _make_mock_sandbox(
                resource_id=resource_id,
                original_path=original_path,
            )
        return mock_cache[resource_id]

    factory.create_sandbox.side_effect = _create_sandbox
    context.factory = factory


@given("a sandbox manager with the mock factory")
def step_given_manager_mock_factory(context: Any) -> None:
    context.manager = SandboxManager(factory=context.factory, cleanup_on_exit=False)
    context.error = None


# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------


@when('I compute the sandbox boundary for "{name}"')
def step_when_compute_boundary(context: Any, name: str) -> None:
    res = context.resources_by_name[name]
    context.boundary_result = sandbox_boundary(res, context.resource_registry)


@when('I check if "{name}" is a sandbox boundary')
def step_when_check_is_boundary(context: Any, name: str) -> None:
    res = context.resources_by_name[name]
    context.is_boundary_result = is_sandbox_boundary(res)


@when("I check is_sandbox_boundary with None resource")
def step_when_check_is_boundary_none(context: Any) -> None:
    try:
        is_sandbox_boundary(None)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


@when('I compute sandbox domains for "{name_a}" and "{name_b}"')
def step_when_compute_domains(context: Any, name_a: str, name_b: str) -> None:
    res_a = context.resources_by_name[name_a]
    res_b = context.resources_by_name[name_b]
    context.domain_result = compute_sandbox_domains(
        [res_a, res_b], context.resource_registry
    )


@when('I look up the boundary for "{name}" via the cache')
def step_when_cache_lookup(context: Any, name: str) -> None:
    res = context.resources_by_name[name]
    context.boundary_result = context.boundary_cache.get_boundary(
        res, context.resource_registry
    )


@when('I look up the boundary for "{name}" via the cache again')
def step_when_cache_lookup_again(context: Any, name: str) -> None:
    # Same as above; exercises cache hit path.
    res = context.resources_by_name[name]
    context.boundary_result = context.boundary_cache.get_boundary(
        res, context.resource_registry
    )


@when("I clear the boundary cache")
def step_when_clear_cache(context: Any) -> None:
    if context.boundary_cache is not None:
        context.boundary_cache.clear()
    else:
        context.manager.clear_boundary_cache()


@when('I resolve the sandbox key for plan "{plan_id}" resource "{name}"')
def step_when_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
    res = context.resources_by_name[name]
    context.sandbox_key_result = context.manager.resolve_sandbox_key(
        res, context.resource_registry
    )


@when('I try to resolve the sandbox key for plan "{plan_id}" resource "{name}"')
def step_when_try_resolve_sandbox_key(context: Any, plan_id: str, name: str) -> None:
    res = context.resources_by_name[name]
    try:
        context.manager.resolve_sandbox_key(res, context.resource_registry)
    except NoSandboxBoundaryError as exc:
        context.error = exc


@when('I get or create a boundary sandbox for plan "{plan_id}" resource "{name}"')
def step_when_get_or_create_boundary_sandbox(
    context: Any, plan_id: str, name: str
) -> None:
    res = context.resources_by_name[name]
    sb = context.manager.get_or_create_sandbox_for_resource(
        plan_id=plan_id,
        resource=res,
        resource_registry=context.resource_registry,
    )
    context.boundary_sandbox_results.append(sb)


@when("I clear the manager boundary cache")
def step_when_clear_manager_cache(context: Any) -> None:
    context.manager.clear_boundary_cache()


@given('a non-sandboxable resource "{name}" with parents "{parent_a}" and "{parent_b}"')
def step_given_nonsandboxable_multi_parent(
    context: Any, name: str, parent_a: str, parent_b: str
) -> None:
    pa = context.resources_by_name[parent_a]
    pb = context.resources_by_name[parent_b]
    res = _make_resource(
        name, sandboxable=False, parents=[pa.resource_id, pb.resource_id]
    )
    context.resource_registry[res.resource_id] = res
    context.resources_by_name[name] = res


@when("I call boundary cache get_boundary with None resource")
def step_when_cache_none_resource(context: Any) -> None:
    try:
        context.boundary_cache.get_boundary(None, context.resource_registry)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


@when('I call boundary cache get_boundary with None registry for "{name}"')
def step_when_cache_none_registry(context: Any, name: str) -> None:
    res = context.resources_by_name[name]
    try:
        context.boundary_cache.get_boundary(res, None)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


@when("I call sandbox_boundary with None resource")
def step_when_sandbox_boundary_none_resource(context: Any) -> None:
    try:
        sandbox_boundary(None, context.resource_registry)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


@when('I call sandbox_boundary with None registry for "{name}"')
def step_when_sandbox_boundary_none_registry(context: Any, name: str) -> None:
    res = context.resources_by_name[name]
    try:
        sandbox_boundary(res, None)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


@when("I call compute_sandbox_domains with None resources")
def step_when_domains_none_resources(context: Any) -> None:
    try:
        compute_sandbox_domains(None, context.resource_registry)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


@when("I call compute_sandbox_domains with None registry")
def step_when_domains_none_registry(context: Any) -> None:
    res = context.resources_by_name.get("dummy")
    try:
        compute_sandbox_domains([res] if res else [], None)  # type: ignore[arg-type]
    except ValueError as exc:
        context.error = exc


# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------


@then('the boundary should be "{expected_name}"')
def step_then_boundary_is(context: Any, expected_name: str) -> None:
    expected = context.resources_by_name[expected_name]
    assert context.boundary_result is not None, (
        f"Expected boundary '{expected_name}' but got None"
    )
    assert context.boundary_result.resource_id == expected.resource_id, (
        f"Expected boundary {expected.resource_id}, "
        f"got {context.boundary_result.resource_id}"
    )


@then("the boundary should be None")
def step_then_boundary_is_none(context: Any) -> None:
    assert context.boundary_result is None, (
        f"Expected None boundary, got {context.boundary_result}"
    )


@then("it should be a sandbox boundary")
def step_then_is_boundary(context: Any) -> None:
    assert context.is_boundary_result is True, (
        "Expected resource to be a sandbox boundary"
    )


@then("it should not be a sandbox boundary")
def step_then_is_not_boundary(context: Any) -> None:
    assert context.is_boundary_result is False, (
        "Expected resource NOT to be a sandbox boundary"
    )


@then('the error should be a ValueError with text "{msg}"')
def step_then_value_error_containing(context: Any, msg: str) -> None:
    assert context.error is not None, "Expected a ValueError but no error was raised"
    assert isinstance(context.error, ValueError), (
        f"Expected ValueError, got {type(context.error).__name__}: {context.error}"
    )
    assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"
    assert msg in str(context.error), f"Expected '{msg}' in '{context.error}'"


@then('both resources should be in the domain of "{boundary_name}"')
def step_then_both_in_domain(context: Any, boundary_name: str) -> None:
    boundary = context.resources_by_name[boundary_name]
    domain = context.domain_result.get(boundary.resource_id, [])
    assert len(domain) == 2, (
        f"Expected 2 resources in domain of '{boundary_name}', got {len(domain)}"
    )


@then('"{name}" should be in the domain of "{boundary_name}"')
def step_then_resource_in_domain(context: Any, name: str, boundary_name: str) -> None:
    boundary = context.resources_by_name[boundary_name]
    res = context.resources_by_name[name]
    domain = context.domain_result.get(boundary.resource_id, [])
    ids = [r.resource_id for r in domain]
    assert res.resource_id in ids, (
        f"Resource '{name}' not found in domain of '{boundary_name}'"
    )


@then("both resources should be in the unsandboxable domain")
def step_then_both_unsandboxable(context: Any) -> None:
    domain = context.domain_result.get("__unsandboxable__", [])
    assert len(domain) == 2, f"Expected 2 unsandboxable resources, got {len(domain)}"


@then("the cache should contain {count:d} entry")
def step_then_cache_size_singular(context: Any, count: int) -> None:
    cache = context.boundary_cache or context.manager._boundary_cache
    assert cache.size == count, f"Expected cache size {count}, got {cache.size}"


@then("the cache should contain {count:d} entries")
def step_then_cache_size(context: Any, count: int) -> None:
    cache = context.boundary_cache or context.manager._boundary_cache
    assert cache.size == count, f"Expected cache size {count}, got {cache.size}"


@then('the cached boundary for "{name}" should be "{boundary_name}"')
def step_then_cached_boundary(context: Any, name: str, boundary_name: str) -> None:
    res = context.resources_by_name[name]
    boundary = context.resources_by_name[boundary_name]
    cache = context.boundary_cache
    result = cache.get_boundary(res, context.resource_registry)
    assert result is not None, f"Cached boundary for '{name}' is None"
    assert result.resource_id == boundary.resource_id


@then("the cached boundary result should be None")
def step_then_cached_boundary_none(context: Any) -> None:
    assert context.boundary_result is None


@then('the sandbox key should be "{expected_name}"')
def step_then_sandbox_key(context: Any, expected_name: str) -> None:
    expected = context.resources_by_name[expected_name]
    assert context.sandbox_key_result == expected.resource_id, (
        f"Expected key {expected.resource_id}, got {context.sandbox_key_result}"
    )


@then("both resources should share the same sandbox instance")
def step_then_same_sandbox_instance(context: Any) -> None:
    assert len(context.boundary_sandbox_results) == 2
    assert context.boundary_sandbox_results[0] is context.boundary_sandbox_results[1], (
        "Expected both resources to share the same sandbox instance"
    )


@then("a NoSandboxBoundaryError should be raised")
def step_then_no_boundary_error(context: Any) -> None:
    assert context.error is not None, (
        "Expected NoSandboxBoundaryError but no error was raised"
    )
    assert isinstance(context.error, NoSandboxBoundaryError), (
        f"Expected NoSandboxBoundaryError, got {type(context.error).__name__}"
    )


@then("the manager boundary cache should have {count:d} entry")
def step_then_manager_cache_singular(context: Any, count: int) -> None:
    assert context.manager.boundary_cache_size == count, (
        f"Expected {count} entry, got {context.manager.boundary_cache_size}"
    )


@then("the manager boundary cache should have {count:d} entries")
def step_then_manager_cache(context: Any, count: int) -> None:
    assert context.manager.boundary_cache_size == count, (
        f"Expected {count} entries, got {context.manager.boundary_cache_size}"
    )
