"""Step definitions for checkpoint_manager_coverage.feature.

Targets every method, branch, and edge case in checkpoint.py to achieve
full line-rate and branch-rate coverage.
"""

from __future__ import annotations

import os
import shutil
import tempfile
import threading
from datetime import datetime
from typing import Any
from unittest.mock import MagicMock, patch

from behave import given, then, when
from behave.runner import Context

from cleveragents.infrastructure.sandbox.checkpoint import (
    Checkpointable,
    CheckpointManager,
    SandboxCheckpoint,
)
from cleveragents.infrastructure.sandbox.protocol import SandboxError

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


def _make_mock_sandbox(
    sandbox_id: str = "sb-mock-001",
    context: Any = None,
) -> MagicMock:
    """Build a mock that satisfies the Checkpointable protocol."""
    sb = MagicMock()
    sb.sandbox_id = sandbox_id
    sb.context = context
    return sb


def _make_temp_sandbox_dir() -> str:
    """Create a temp dir with sample files and a subdirectory."""
    tmpdir = tempfile.mkdtemp(prefix="ca-cpmgr-test-")
    os.makedirs(os.path.join(tmpdir, "subdir"), exist_ok=True)
    with open(os.path.join(tmpdir, "file.txt"), "w") as f:
        f.write("original\n")
    with open(os.path.join(tmpdir, "subdir", "nested.txt"), "w") as f:
        f.write("nested-original\n")
    return tmpdir


def _register_cleanup(context: Context, path: str) -> None:
    """Schedule a directory for cleanup after the scenario."""
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(lambda: shutil.rmtree(path, ignore_errors=True))


# ===========================================================================
# SandboxCheckpoint Pydantic model
# ===========================================================================


@given("I build a SandboxCheckpoint with valid fields")
def step_build_checkpoint_model(context: Context) -> None:
    context.cp_model = SandboxCheckpoint(
        checkpoint_id="cp-aaa",
        sandbox_id="sb-bbb",
        plan_id="plan-ccc",
        phase="pre_execute",
        created_at=datetime(2025, 1, 1, 12, 0, 0),
        metadata={"key": "val"},
        snapshot_path="/tmp/snap",
    )


@then("the constructed checkpoint model should expose all field values")
def step_assert_checkpoint_fields(context: Context) -> None:
    cp = context.cp_model
    assert cp.checkpoint_id == "cp-aaa"
    assert cp.sandbox_id == "sb-bbb"
    assert cp.plan_id == "plan-ccc"
    assert cp.phase == "pre_execute"
    assert cp.created_at == datetime(2025, 1, 1, 12, 0, 0)
    assert cp.metadata == {"key": "val"}
    assert cp.snapshot_path == "/tmp/snap"


@then("the constructed checkpoint model should be frozen")
def step_assert_checkpoint_frozen(context: Context) -> None:
    try:
        context.cp_model.phase = "modified"
        raise AssertionError("Expected ValidationError for frozen model")
    except Exception:
        pass  # pydantic frozen model rejects mutation


@given("I build a SandboxCheckpoint without explicit metadata")
def step_build_checkpoint_no_metadata(context: Context) -> None:
    context.cp_model = SandboxCheckpoint(
        checkpoint_id="cp-ddd",
        sandbox_id="sb-eee",
        plan_id="plan-fff",
        phase="post_execute",
        created_at=datetime.now(),
        snapshot_path="/tmp/snap2",
    )


@then("the constructed checkpoint metadata should be an empty dict")
def step_assert_empty_metadata_default(context: Context) -> None:
    assert context.cp_model.metadata == {}


# ===========================================================================
# Checkpointable protocol
# ===========================================================================


@given("a mock object with sandbox_id and context properties")
def step_mock_checkpointable(context: Context) -> None:
    class _Good:
        @property
        def sandbox_id(self) -> str:
            return "sb-proto"

        @property
        def context(self) -> Any:
            return None

    context.proto_obj = _Good()


@then("the mock object should satisfy the Checkpointable protocol")
def step_assert_satisfies_checkpointable(context: Context) -> None:
    assert isinstance(context.proto_obj, Checkpointable)


@given("a mock object without a sandbox_id property")
def step_mock_not_checkpointable(context: Context) -> None:
    class _Bad:
        @property
        def context(self) -> Any:
            return None

    context.proto_obj = _Bad()


@then("the mock object should not satisfy the Checkpointable protocol")
def step_assert_not_checkpointable(context: Context) -> None:
    assert not isinstance(context.proto_obj, Checkpointable)


# ===========================================================================
# CheckpointManager.__init__
# ===========================================================================


@given("a freshly created CheckpointManager")
def step_fresh_manager(context: Context) -> None:
    context.mgr = CheckpointManager()
    context.crafted_cp = None
    context.cp_result = None
    context.rollback_result = None
    context.delete_result = None
    context.cp_list = None
    context.snapshot_result = None
    context.raised_error = None
    context.cleanup_error = None


@then("the manager internal checkpoints dict should be empty")
def step_assert_empty_checkpoints(context: Context) -> None:
    assert context.mgr._checkpoints == {}


@then("the manager should have a threading lock")
def step_assert_has_lock(context: Context) -> None:
    assert isinstance(context.mgr._lock, type(threading.RLock()))


# ===========================================================================
# create_checkpoint - context is None
# ===========================================================================


@given("a mock sandbox whose context is None")
def step_mock_sandbox_no_context(context: Context) -> None:
    context.mock_sb = _make_mock_sandbox(sandbox_id="sb-nocontext")


@when(
    'I invoke create_checkpoint with plan "{plan_id}" phase "{phase}" and no metadata'
)
def step_create_cp_no_metadata(context: Context, plan_id: str, phase: str) -> None:
    context.cp_result = context.mgr.create_checkpoint(
        sandbox=context.mock_sb,
        plan_id=plan_id,
        phase=phase,
    )


@then("the returned checkpoint should have a non-empty snapshot_path directory")
def step_assert_snapshot_path_dir(context: Context) -> None:
    sp = context.cp_result.snapshot_path
    assert sp, "snapshot_path should not be empty"
    assert os.path.isdir(sp), f"Expected directory at {sp}"
    _register_cleanup(context, os.path.dirname(sp))


@then("the returned checkpoint sandbox_id should equal the mock sandbox id")
def step_assert_cp_sandbox_id(context: Context) -> None:
    assert context.cp_result.sandbox_id == context.mock_sb.sandbox_id


@when(
    'I invoke create_checkpoint with plan "{plan_id}" phase "{phase}" and numeric metadata'
)
def step_create_cp_numeric_metadata(context: Context, plan_id: str, phase: str) -> None:
    context.cp_result = context.mgr.create_checkpoint(
        sandbox=context.mock_sb,
        plan_id=plan_id,
        phase=phase,
        metadata={"count": 42, "flag": True},
    )
    _register_cleanup(context, os.path.dirname(context.cp_result.snapshot_path))


@then("the returned checkpoint metadata values should all be strings")
def step_assert_metadata_strings(context: Context) -> None:
    for k, v in context.cp_result.metadata.items():
        assert isinstance(v, str), f"metadata[{k!r}] = {v!r} is not a string"


@when(
    'I invoke create_checkpoint with plan "{plan_id}" phase "{phase}" and None metadata'
)
def step_create_cp_none_metadata(context: Context, plan_id: str, phase: str) -> None:
    context.cp_result = context.mgr.create_checkpoint(
        sandbox=context.mock_sb,
        plan_id=plan_id,
        phase=phase,
        metadata=None,
    )
    _register_cleanup(context, os.path.dirname(context.cp_result.snapshot_path))


@then("the returned checkpoint metadata should be an empty dict")
def step_assert_cp_metadata_empty(context: Context) -> None:
    assert context.cp_result.metadata == {}


# ===========================================================================
# create_checkpoint - context with sandbox_path
# ===========================================================================


@given("a temporary sandbox directory with sample files")
def step_temp_sandbox_dir(context: Context) -> None:
    context.temp_sandbox = _make_temp_sandbox_dir()
    _register_cleanup(context, context.temp_sandbox)


@given("a mock sandbox whose context points to the temporary directory")
def step_mock_sandbox_with_context(context: Context) -> None:
    ctx = MagicMock()
    ctx.sandbox_path = context.temp_sandbox
    context.mock_sb = _make_mock_sandbox(sandbox_id="sb-withctx", context=ctx)


@then("the snapshot directory should contain copies of the sample files")
def step_assert_snapshot_has_files(context: Context) -> None:
    snap = context.cp_result.snapshot_path
    _register_cleanup(context, os.path.dirname(snap))
    assert os.path.isfile(os.path.join(snap, "file.txt"))
    assert os.path.isfile(os.path.join(snap, "subdir", "nested.txt"))
    with open(os.path.join(snap, "file.txt")) as f:
        assert f.read() == "original\n"


# ===========================================================================
# _snapshot_directory - branches
# ===========================================================================


@when("I call _snapshot_directory with a None sandbox_path")
def step_snapshot_none_path(context: Context) -> None:
    context.snapshot_result = context.mgr._snapshot_directory(None, "cp-test-none")
    _register_cleanup(context, os.path.dirname(context.snapshot_result))


@then("the returned snapshot should be an existing empty directory")
def step_assert_snapshot_empty_dir(context: Context) -> None:
    sp = context.snapshot_result
    assert os.path.isdir(sp), f"Expected directory at {sp}"
    assert os.listdir(sp) == [], f"Expected empty dir, got {os.listdir(sp)}"


@when("I call _snapshot_directory with a non-existent sandbox_path")
def step_snapshot_nonexistent_path(context: Context) -> None:
    context.snapshot_result = context.mgr._snapshot_directory(
        "/nonexistent/path/xyz", "cp-test-noexist"
    )
    _register_cleanup(context, os.path.dirname(context.snapshot_result))


@when("I call _snapshot_directory with the temporary sandbox_path")
def step_snapshot_valid_path(context: Context) -> None:
    context.snapshot_result = context.mgr._snapshot_directory(
        context.temp_sandbox, "cp-test-valid"
    )
    _register_cleanup(context, os.path.dirname(context.snapshot_result))


@then("the returned snapshot should contain the sample files")
def step_assert_snapshot_has_sample_files(context: Context) -> None:
    sp = context.snapshot_result
    assert os.path.isfile(os.path.join(sp, "file.txt"))
    assert os.path.isfile(os.path.join(sp, "subdir", "nested.txt"))


@when("I call _snapshot_directory with a path that triggers OSError")
def step_snapshot_oserror(context: Context) -> None:
    context.raised_error = None
    with patch("shutil.copytree", side_effect=OSError("mock copy failure")):
        try:
            context.mgr._snapshot_directory(context.temp_sandbox, "cp-test-err")
        except SandboxError as exc:
            context.raised_error = exc


@then("a SandboxError should have been raised")
def step_assert_sandbox_error(context: Context) -> None:
    assert context.raised_error is not None, "Expected SandboxError to be raised"
    assert isinstance(context.raised_error, SandboxError)
    assert "Failed to snapshot" in str(context.raised_error)


# ===========================================================================
# _cleanup_snapshot - branches
# ===========================================================================


@when("I call _cleanup_snapshot with an empty string")
def step_cleanup_empty_string(context: Context) -> None:
    context.cleanup_error = None
    try:
        CheckpointManager._cleanup_snapshot("")
    except Exception as exc:
        context.cleanup_error = exc


@then("no error should occur from cleanup_snapshot")
def step_assert_no_cleanup_error(context: Context) -> None:
    assert context.cleanup_error is None, (
        f"Expected no error, got {context.cleanup_error!r}"
    )


@given("a temporary snapshot directory tree for cleanup")
def step_temp_snapshot_for_cleanup(context: Context) -> None:
    parent = tempfile.mkdtemp(prefix="ca-cleanup-test-")
    snap = os.path.join(parent, "snapshot")
    os.makedirs(snap, exist_ok=True)
    with open(os.path.join(snap, "data.txt"), "w") as f:
        f.write("to-be-cleaned\n")
    context.cleanup_parent = parent
    context.cleanup_snapshot = snap


@when("I call _cleanup_snapshot with the temporary snapshot path")
def step_cleanup_real_snapshot(context: Context) -> None:
    context.cleanup_error = None
    try:
        CheckpointManager._cleanup_snapshot(context.cleanup_snapshot)
    except Exception as exc:
        context.cleanup_error = exc


@then("the temporary snapshot parent should no longer exist")
def step_assert_parent_removed(context: Context) -> None:
    assert not os.path.exists(context.cleanup_parent), (
        f"Parent should have been removed: {context.cleanup_parent}"
    )


@when("I call _cleanup_snapshot with a non-existent path")
def step_cleanup_nonexistent(context: Context) -> None:
    context.cleanup_error = None
    try:
        CheckpointManager._cleanup_snapshot("/nonexistent/snapshot/path")
    except Exception as exc:
        context.cleanup_error = exc


# ===========================================================================
# rollback_to, list_checkpoints, delete_checkpoint branches
# (step definitions moved to checkpoint_manager_rollback_steps.py to keep
#  this file under the 500-line limit)
# ===========================================================================

# ===========================================================================
# Bug fix #7488 — sandbox_path auto-stored in metadata
# (step definitions moved to checkpoint_manager_bug7488_steps.py to keep
#  this file under the 500-line limit)
# ===========================================================================
