"""Step definitions for overlay filesystem sandbox lifecycle tests.

Covers the OverlaySandbox implementation including creation, path
resolution, commit, rollback, cleanup, and userspace fallback mode.
"""

from __future__ import annotations

import os
import re
import shutil
import tempfile
from unittest.mock import patch

from behave import given, then, when

from cleveragents.infrastructure.sandbox.overlay import OverlaySandbox
from cleveragents.infrastructure.sandbox.protocol import (
    SandboxCommitError,
    SandboxCreationError,
    SandboxRollbackError,
    SandboxStateError,
    SandboxStatus,
)

# ---------------------------------------------------------------------------
# Givens
# ---------------------------------------------------------------------------


@given("an ovl test directory is initialised")
def given_ovl_test_directory(context):
    """Create a temporary directory with test files for overlay sandbox."""
    context.ovl_tmpdir = tempfile.mkdtemp(prefix="ovl-test-")
    context.add_cleanup(shutil.rmtree, context.ovl_tmpdir)
    context.ovl_original = os.path.join(context.ovl_tmpdir, "original")
    os.makedirs(context.ovl_original)

    # Create test files
    with open(os.path.join(context.ovl_original, "existing.txt"), "w") as fh:
        fh.write("original content")
    with open(os.path.join(context.ovl_original, "to_delete.txt"), "w") as fh:
        fh.write("delete me")

    context.ovl_error = None
    context.ovl_sandbox = None
    context.ovl_commit_result = None
    context.ovl_resolved_path = None


@given("an ovl non-existent directory")
def given_ovl_nonexistent_dir(context):
    """Set up a path to a non-existent directory."""
    context.ovl_nonexistent = os.path.join(context.ovl_tmpdir, "nonexistent")


# ---------------------------------------------------------------------------
# Whens - Creation
# ---------------------------------------------------------------------------


@when('an ovl sandbox is created for plan "{plan_id}"')
def when_ovl_sandbox_created(context, plan_id: str):
    """Create an OverlaySandbox and call create()."""
    context.ovl_error = None
    context.ovl_sandbox = OverlaySandbox(
        resource_id="test-resource",
        original_path=context.ovl_original,
    )
    try:
        context.ovl_context = context.ovl_sandbox.create(plan_id)
    except Exception as exc:
        context.ovl_error = exc


@when("an ovl sandbox is created with empty plan_id")
def when_ovl_sandbox_empty_plan(context):
    """Attempt to create sandbox with empty plan_id."""
    context.ovl_error = None
    context.ovl_sandbox = OverlaySandbox(
        resource_id="test-resource",
        original_path=context.ovl_original,
    )
    try:
        context.ovl_sandbox.create("")
    except ValueError as exc:
        context.ovl_error = exc


@when("an ovl sandbox is prepared with empty resource_id")
def when_ovl_sandbox_empty_resource_id(context):
    """Attempt to instantiate sandbox with empty resource_id."""
    context.ovl_error = None
    try:
        OverlaySandbox(resource_id="", original_path=context.ovl_original)
    except ValueError as exc:
        context.ovl_error = exc


@when("an ovl sandbox is prepared with empty original_path")
def when_ovl_sandbox_empty_path(context):
    """Attempt to instantiate sandbox with empty original_path."""
    context.ovl_error = None
    try:
        OverlaySandbox(resource_id="test-resource", original_path="")
    except ValueError as exc:
        context.ovl_error = exc


@when('an ovl sandbox is created on the non-existent directory for plan "{plan_id}"')
def when_ovl_sandbox_nonexistent(context, plan_id: str):
    """Create sandbox pointing to a non-existent directory."""
    context.ovl_error = None
    context.ovl_sandbox = OverlaySandbox(
        resource_id="test-resource",
        original_path=context.ovl_nonexistent,
    )
    try:
        context.ovl_sandbox.create(plan_id)
    except SandboxCreationError as exc:
        context.ovl_error = exc


@when("an ovl sandbox is instantiated")
def when_ovl_sandbox_instantiated(context):
    """Instantiate sandbox without calling create()."""
    context.ovl_sandbox = OverlaySandbox(
        resource_id="test-resource",
        original_path=context.ovl_original,
    )


# ---------------------------------------------------------------------------
# Whens - Path resolution
# ---------------------------------------------------------------------------


@when('the ovl path "{path}" is resolved')
def when_ovl_path_resolved(context, path: str):
    """Resolve a path in the overlay sandbox."""
    context.ovl_error = None
    try:
        context.ovl_resolved_path = context.ovl_sandbox.get_path(path)
    except (ValueError, SandboxStateError) as exc:
        context.ovl_error = exc


@when('the ovl path "{path}" is resolved on a cleaned-up sandbox')
def when_ovl_path_on_cleaned(context, path: str):
    """Resolve a path after sandbox cleanup."""
    context.ovl_error = None
    try:
        context.ovl_sandbox.get_path(path)
    except SandboxStateError as exc:
        context.ovl_error = exc


# ---------------------------------------------------------------------------
# Whens - File operations in sandbox
# ---------------------------------------------------------------------------


@when('an ovl file "{filename}" is created in the sandbox with content "{content}"')
def when_ovl_file_created(context, filename: str, content: str):
    """Create a new file in the merged directory."""
    merged = context.ovl_sandbox._merged_dir
    filepath = os.path.join(merged, filename)
    os.makedirs(os.path.dirname(filepath), exist_ok=True)
    with open(filepath, "w") as fh:
        fh.write(content)


@when(
    'the ovl existing file "{filename}" is modified '
    'in the sandbox with content "{content}"'
)
def when_ovl_file_modified(context, filename: str, content: str):
    """Modify an existing file in the merged directory."""
    merged = context.ovl_sandbox._merged_dir
    filepath = os.path.join(merged, filename)
    with open(filepath, "w") as fh:
        fh.write(content)


@when('the ovl existing file "{filename}" is deleted from the sandbox')
def when_ovl_file_deleted(context, filename: str):
    """Delete a file from the merged directory."""
    merged = context.ovl_sandbox._merged_dir
    filepath = os.path.join(merged, filename)
    os.remove(filepath)


# ---------------------------------------------------------------------------
# Whens - Commit / Rollback / Cleanup
# ---------------------------------------------------------------------------


@when("the ovl sandbox is committed")
def when_ovl_sandbox_committed(context):
    """Commit sandbox changes."""
    context.ovl_error = None
    try:
        context.ovl_commit_result = context.ovl_sandbox.commit()
    except Exception as exc:
        context.ovl_error = exc


@when("the ovl sandbox commit is attempted on cleaned-up sandbox")
def when_ovl_commit_on_cleaned(context):
    """Attempt to commit after cleanup."""
    context.ovl_error = None
    try:
        context.ovl_sandbox.commit()
    except SandboxStateError as exc:
        context.ovl_error = exc


@when("the ovl sandbox is rolled back")
def when_ovl_sandbox_rolled_back(context):
    """Roll back the sandbox."""
    context.ovl_error = None
    try:
        context.ovl_sandbox.rollback()
    except Exception as exc:
        context.ovl_error = exc


@when("the ovl sandbox rollback is attempted on created sandbox")
def when_ovl_rollback_on_created(context):
    """Attempt rollback from CREATED state (invalid)."""
    context.ovl_error = None
    try:
        context.ovl_sandbox.rollback()
    except SandboxStateError as exc:
        context.ovl_error = exc


@when("the ovl sandbox is cleaned up")
def when_ovl_sandbox_cleaned_up(context):
    """Clean up the sandbox."""
    context.ovl_sandbox.cleanup()


@when("the ovl sandbox is cleaned up again")
def when_ovl_sandbox_cleaned_up_again(context):
    """Clean up the sandbox again (idempotency check)."""
    context.ovl_sandbox.cleanup()


# ---------------------------------------------------------------------------
# Thens - State
# ---------------------------------------------------------------------------


@then('the ovl sandbox should be in the "{state}" state')
def then_ovl_sandbox_state(context, state: str):
    """Assert sandbox is in the expected state."""
    assert context.ovl_sandbox.status.value == state, (
        f"Expected {state}, got {context.ovl_sandbox.status.value}"
    )


@then('the ovl sandbox status should be "{status}"')
def then_ovl_sandbox_status(context, status: str):
    """Assert sandbox status value."""
    assert context.ovl_sandbox.status.value == status


# ---------------------------------------------------------------------------
# Thens - Context
# ---------------------------------------------------------------------------


@then('the ovl sandbox context should reference plan "{plan_id}"')
def then_ovl_context_plan(context, plan_id: str):
    """Assert context references the correct plan."""
    assert context.ovl_sandbox.context is not None
    assert context.ovl_sandbox.context.plan_id == plan_id


@then('the ovl sandbox context should have strategy metadata "{strategy}"')
def then_ovl_context_strategy(context, strategy: str):
    """Assert context metadata includes strategy."""
    assert context.ovl_sandbox.context is not None
    assert context.ovl_sandbox.context.metadata["strategy"] == strategy


@then("the ovl sandbox context should be None")
def then_ovl_context_none(context):
    """Assert context is None before creation."""
    assert context.ovl_sandbox.context is None


# ---------------------------------------------------------------------------
# Thens - Directory structure
# ---------------------------------------------------------------------------


@then("the ovl sandbox merged path should exist")
def then_ovl_merged_exists(context):
    """Assert the merged directory exists."""
    assert context.ovl_sandbox._merged_dir is not None
    assert os.path.isdir(context.ovl_sandbox._merged_dir)


@then("the ovl sandbox should have upper, work, and merged directories")
def then_ovl_directory_structure(context):
    """Assert all overlay layer directories exist."""
    assert context.ovl_sandbox._upper_dir is not None
    assert context.ovl_sandbox._work_dir is not None
    assert context.ovl_sandbox._merged_dir is not None
    assert os.path.isdir(context.ovl_sandbox._upper_dir)
    assert os.path.isdir(context.ovl_sandbox._work_dir)
    assert os.path.isdir(context.ovl_sandbox._merged_dir)


@then("the ovl sandbox base path should not exist")
def then_ovl_base_gone(context):
    """Assert the base directory has been removed."""
    if context.ovl_sandbox._base_dir is not None:
        assert not os.path.exists(context.ovl_sandbox._base_dir)


# ---------------------------------------------------------------------------
# Thens - Path resolution
# ---------------------------------------------------------------------------


@then("the ovl resolved path should be inside the merged directory")
def then_ovl_path_in_merged(context):
    """Assert resolved path is within the merged directory."""
    assert context.ovl_resolved_path is not None
    assert context.ovl_sandbox._merged_dir is not None
    assert context.ovl_resolved_path.startswith(context.ovl_sandbox._merged_dir)


# ---------------------------------------------------------------------------
# Thens - Errors
# ---------------------------------------------------------------------------


@then('an ovl ValueError should be raised with message "{msg}"')
def then_ovl_value_error(context, msg: str):
    """Assert a ValueError was raised with expected message."""
    assert context.ovl_error is not None, "Expected a ValueError"
    assert isinstance(context.ovl_error, ValueError)
    assert msg in str(context.ovl_error)


@then("an ovl SandboxCreationError should be raised")
def then_ovl_creation_error(context):
    """Assert a SandboxCreationError was raised."""
    assert context.ovl_error is not None, "Expected a SandboxCreationError"
    assert isinstance(context.ovl_error, SandboxCreationError)


@then("an ovl SandboxStateError should be raised")
def then_ovl_state_error(context):
    """Assert a SandboxStateError was raised."""
    assert context.ovl_error is not None, "Expected a SandboxStateError"
    assert isinstance(context.ovl_error, SandboxStateError)


# ---------------------------------------------------------------------------
# Thens - Commit results
# ---------------------------------------------------------------------------


@then("the ovl commit result should indicate success")
def then_ovl_commit_success(context):
    """Assert commit was successful."""
    assert context.ovl_commit_result is not None
    assert context.ovl_commit_result.success is True


@then("the ovl commit result should have {count:d} changed files")
def then_ovl_commit_changed(context, count: int):
    """Assert commit result has expected number of changed files."""
    assert len(context.ovl_commit_result.changed_files) == count


@then("the ovl commit result should have {count:d} added files")
def then_ovl_commit_added(context, count: int):
    """Assert commit result has expected number of added files."""
    assert len(context.ovl_commit_result.added_files) == count


@then("the ovl commit result should have {count:d} deleted files")
def then_ovl_commit_deleted(context, count: int):
    """Assert commit result has expected number of deleted files."""
    assert len(context.ovl_commit_result.deleted_files) == count


# ---------------------------------------------------------------------------
# Thens - File assertions
# ---------------------------------------------------------------------------


@then(
    'the ovl file "{filename}" should exist in the original '
    'directory with content "{content}"'
)
def then_ovl_file_in_original(context, filename: str, content: str):
    """Assert file exists in original directory with expected content."""
    filepath = os.path.join(context.ovl_original, filename)
    assert os.path.isfile(filepath), f"File not found: {filepath}"
    with open(filepath) as fh:
        assert fh.read() == content


@then('the ovl file "{filename}" in the original should have content "{content}"')
def then_ovl_file_content(context, filename: str, content: str):
    """Assert file in original has expected content."""
    filepath = os.path.join(context.ovl_original, filename)
    with open(filepath) as fh:
        assert fh.read() == content


@then('the ovl file "{filename}" should not exist in the original directory')
def then_ovl_file_not_in_original(context, filename: str):
    """Assert file does not exist in original directory."""
    filepath = os.path.join(context.ovl_original, filename)
    assert not os.path.exists(filepath)


@then('the ovl file "{filename}" should not exist in the sandbox')
def then_ovl_file_not_in_sandbox(context, filename: str):
    """Assert file does not exist in the merged directory."""
    merged = context.ovl_sandbox._merged_dir
    assert merged is not None
    filepath = os.path.join(merged, filename)
    assert not os.path.exists(filepath)


# ---------------------------------------------------------------------------
# Thens - ULID / Protocol
# ---------------------------------------------------------------------------

_ULID_RE = re.compile(r"^[0-9A-Z]{26}$")


@then("the ovl sandbox_id should be a valid ULID")
def then_ovl_valid_ulid(context):
    """Assert sandbox_id matches ULID format."""
    assert _ULID_RE.match(context.ovl_sandbox.sandbox_id), (
        f"Not a valid ULID: {context.ovl_sandbox.sandbox_id}"
    )


# ---------------------------------------------------------------------------
# Thens - Fallback mode
# ---------------------------------------------------------------------------


@then("the ovl sandbox should use userspace fallback")
def then_ovl_fallback(context):
    """Assert sandbox is using userspace fallback (common in CI)."""
    # In CI/containers, OverlayFS is typically not available
    assert context.ovl_sandbox._use_real_overlay is False


# ---------------------------------------------------------------------------
# Coverage boost steps
# ---------------------------------------------------------------------------


@when('the ovl sandbox is committed with message "{msg}"')
def when_ovl_commit_with_message(context, msg: str):
    """Commit the overlay sandbox with a message."""
    context.ovl_commit_result = context.ovl_sandbox.commit(message=msg)


@then('the ovl commit metadata should contain message "{msg}"')
def then_ovl_commit_metadata_message(context, msg: str):
    """Assert commit result metadata contains the message."""
    assert context.ovl_commit_result.metadata.get("message") == msg


@given("an ovl sandbox with merged_dir forced to None")
def given_ovl_sandbox_merged_dir_none(context):
    """Create an OverlaySandbox with merged_dir forced to None."""
    tmpdir = tempfile.mkdtemp(prefix="ovl-none-")
    os.makedirs(os.path.join(tmpdir, "orig"))
    context.ovl_sandbox = OverlaySandbox(
        resource_id="res-none",
        original_path=os.path.join(tmpdir, "orig"),
    )
    context.ovl_sandbox.create(plan_id="plan-none")
    # Force merged_dir to None after creation
    context.ovl_sandbox._merged_dir = None
    context.ovl_sandbox._status = SandboxStatus.ACTIVE
    context.ovl_error = None
    context.ovl_tmpdir = tmpdir


@when('ovl get_path is called with "{path}" expecting an error')
def when_ovl_get_path_error(context, path: str):
    """Call get_path expecting an error."""
    try:
        context.ovl_sandbox.get_path(path)
        context.ovl_error = None
    except SandboxStateError as exc:
        context.ovl_error = exc


@then('an ovl SandboxStateError should be raised with message "{msg}"')
def then_ovl_sandbox_state_error(context, msg: str):
    """Assert a SandboxStateError was raised with the expected message."""
    assert context.ovl_error is not None, "Expected SandboxStateError but none raised"
    assert msg in str(context.ovl_error), f"Expected '{msg}' in '{context.ovl_error}'"


@given('an ovl sandbox is created and activated for plan "{plan_id}"')
def given_ovl_sandbox_active(context, plan_id: str):
    """Create an overlay sandbox and activate it via get_path."""
    context.ovl_sandbox = OverlaySandbox(
        resource_id="res-active",
        original_path=context.ovl_tmpdir,
    )
    context.ovl_sandbox.create(plan_id=plan_id)
    context.ovl_sandbox.get_path("existing.txt")
    context.ovl_error = None


@given("ovl shutil.copy2 is patched to raise OSError")
def given_ovl_copy2_raises(context):
    """Patch shutil.copy2 to raise OSError."""
    context.ovl_copy2_patcher = patch(
        "cleveragents.infrastructure.sandbox.overlay.shutil.copy2",
        side_effect=OSError("disk full"),
    )
    context.ovl_copy2_patcher.start()


@when("the ovl sandbox commit is attempted")
def when_ovl_commit_attempted(context):
    """Attempt to commit, catching errors."""
    # Ensure there's a changed file to trigger the copy2 path
    merged = context.ovl_sandbox._merged_dir
    if merged:
        fpath = os.path.join(merged, "existing.txt")
        with open(fpath, "w") as f:
            f.write("modified for commit error test")
    try:
        context.ovl_sandbox.commit()
        context.ovl_error = None
    except SandboxCommitError as exc:
        context.ovl_error = exc
    finally:
        if hasattr(context, "ovl_copy2_patcher"):
            context.ovl_copy2_patcher.stop()


@then("an ovl SandboxCommitError should be raised")
def then_ovl_commit_error(context):
    """Assert a SandboxCommitError was raised."""
    assert context.ovl_error is not None, "Expected SandboxCommitError"
    assert isinstance(context.ovl_error, SandboxCommitError)


@given("ovl shutil.rmtree is patched to raise OSError for rollback")
def given_ovl_rmtree_raises(context):
    """Patch shutil.rmtree to raise OSError."""
    context.ovl_rmtree_patcher = patch(
        "cleveragents.infrastructure.sandbox.overlay.shutil.rmtree",
        side_effect=OSError("permission denied"),
    )
    context.ovl_rmtree_patcher.start()


@when("the ovl sandbox rollback is attempted")
def when_ovl_rollback_attempted(context):
    """Attempt to rollback, catching errors."""
    try:
        context.ovl_sandbox.rollback()
        context.ovl_error = None
    except SandboxRollbackError as exc:
        context.ovl_error = exc
    finally:
        if hasattr(context, "ovl_rmtree_patcher"):
            context.ovl_rmtree_patcher.stop()


@then("an ovl SandboxRollbackError should be raised")
def then_ovl_rollback_error(context):
    """Assert a SandboxRollbackError was raised."""
    assert context.ovl_error is not None, "Expected SandboxRollbackError"
    assert isinstance(context.ovl_error, SandboxRollbackError)


@given("ovl shutil.copytree is patched to raise OSError")
def given_ovl_copytree_raises(context):
    """Patch shutil.copytree to raise OSError."""
    context.ovl_copytree_patcher = patch(
        "cleveragents.infrastructure.sandbox.overlay.shutil.copytree",
        side_effect=OSError("I/O error"),
    )
    context.ovl_copytree_patcher.start()


@when('an ovl sandbox create is attempted for plan "{plan_id}"')
def when_ovl_create_attempted(context, plan_id: str):
    """Attempt to create a sandbox, catching errors."""
    context.ovl_sandbox = OverlaySandbox(
        resource_id="res-create-err",
        original_path=context.ovl_tmpdir,
    )
    try:
        context.ovl_sandbox.create(plan_id=plan_id)
        context.ovl_error = None
    except SandboxCreationError as exc:
        context.ovl_error = exc
    finally:
        if hasattr(context, "ovl_copytree_patcher"):
            context.ovl_copytree_patcher.stop()


# ---------------------------------------------------------------------------
# Atomic rollback from COMMITTED steps
# ---------------------------------------------------------------------------


@when("the ovl sandbox is rolled back from committed")
def when_ovl_sandbox_rolled_back_from_committed(context):
    """Roll back the sandbox from COMMITTED state."""
    assert context.ovl_sandbox.status == SandboxStatus.COMMITTED, (
        f"Expected COMMITTED, got {context.ovl_sandbox.status.value}"
    )
    context.ovl_error = None
    try:
        context.ovl_sandbox.rollback()
    except Exception as exc:
        context.ovl_error = exc


@then('the ovl original file "{filename}" should contain "{expected}"')
def then_ovl_original_contains(context, filename: str, expected: str):
    """Verify content of a file in the original directory."""
    path = os.path.join(context.ovl_original, filename)
    assert os.path.isfile(path), f"Expected file at {path}"
    with open(path) as fh:
        content = fh.read()
    assert content == expected, f"Expected '{expected}', got '{content}'"


@then('the ovl merged directory should not contain stale file "{filename}"')
def then_ovl_merged_no_stale(context, filename: str):
    """Verify a file does NOT appear in the merged directory after rollback.

    After rollback from COMMITTED the merged directory is reset from the
    restored original, so sandbox-only files should be gone.
    """
    merged = context.ovl_sandbox._merged_dir
    assert merged is not None, "Merged directory is None"
    stale_path = os.path.join(merged, filename)
    assert not os.path.exists(stale_path), (
        f"Stale file {filename} should not exist in merged dir after rollback"
    )
