"""Step definitions for CleanupService sandbox cache invalidation tests (#7527).

Verifies that ``_purge_sandboxes()`` invalidates ``_sandbox_dirs_cache``
so that a subsequent ``scan()`` call re-reads the filesystem instead of
returning already-deleted paths.

All tests use real filesystem operations — no mocks, no patches.

Test isolation strategy
-----------------------
Each scenario creates a private temporary directory (via ``tempfile.mkdtemp()``)
and uses an ``_IsolatedCleanupService`` subclass that overrides
``_get_sandbox_dirs()`` to scan only that private directory.  This prevents
the test from interfering with other concurrently-running scenarios that also
create ``ca-sandbox-*`` directories in the shared system temp directory.
"""

from __future__ import annotations

import os
import shutil
import tempfile
import time
import uuid
from pathlib import Path

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

from cleveragents.application.services.cleanup_service import (
    CleanupReport,
    CleanupService,
)
from cleveragents.config.settings import Settings

# ── Helpers ──────────────────────────────────────────────────────

# Use 1 hour (minimum allowed) as max age; sandbox dirs are set ~11.5 days old
_STALE_MAX_AGE_HOURS = 1
# Sandbox dirs are set this many seconds in the past (well beyond 1 hour)
_STALE_MTIME_OFFSET = 999_999


class _IsolatedCleanupService(CleanupService):
    """CleanupService subclass that scans a private temp directory.

    Overrides ``_get_sandbox_dirs()`` to look only in ``_private_tmp``
    instead of the system-wide ``tempfile.gettempdir()``.  This prevents
    the test from reading or deleting sandbox directories created by other
    concurrently-running scenarios.
    """

    def __init__(self, settings: Settings, private_tmp: Path) -> None:
        super().__init__(settings)
        self._private_tmp = private_tmp

    def _get_sandbox_dirs(self) -> list[Path]:
        """Return sandbox dirs from the private temp directory only."""
        if self._sandbox_dirs_cache is not None:
            return self._sandbox_dirs_cache
        if not self._private_tmp.exists():
            self._sandbox_dirs_cache = []
            return self._sandbox_dirs_cache
        dirs: list[Path] = []
        try:
            entries = list(self._private_tmp.iterdir())
        except OSError:
            self._sandbox_dirs_cache = []
            return self._sandbox_dirs_cache
        for p in entries:
            try:
                is_dir = p.is_dir()
            except OSError:
                continue
            if is_dir and any(
                p.name.startswith(pfx) for pfx in ("ca-sandbox-", "ca-cow-sandbox-")
            ):
                dirs.append(p)
        self._sandbox_dirs_cache = dirs
        return dirs


def _make_settings(**overrides: object) -> Settings:
    """Create a real Settings instance with optional field overrides.

    Uses ``model_copy(update=...)`` so that Pydantic validators
    (including ``ge=`` bounds on retention fields) are enforced.
    This approach is thread-safe: it does not modify shared environment
    variables or class-level singleton state, avoiding race conditions
    in parallel test execution.  Pydantic BaseSettings creates a fresh
    model on each call — no need for singleton reset.
    """
    base = Settings()
    if overrides:
        return base.model_copy(update=overrides)
    return base


def _unique_sandbox_name(prefix: str = "ca-sandbox-test") -> str:
    """Generate a unique sandbox directory name to avoid cross-test collisions."""
    return f"{prefix}-{uuid.uuid4().hex[:12]}"


def _make_private_tmp(context: Context) -> Path:
    """Create a private temp directory for this scenario and register cleanup."""
    private_tmp = Path(tempfile.mkdtemp(prefix="ca-test-isolation-"))

    def _remove() -> None:
        if private_tmp.exists():
            shutil.rmtree(str(private_tmp), ignore_errors=True)

    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []
    context._cleanup_handlers.append(_remove)
    return private_tmp


def _make_real_stale_sandbox(parent: Path, name: str) -> Path:
    """Create a real stale sandbox directory inside *parent*.

    Sets the mtime far in the past so ``_is_sandbox_stale`` returns True
    when ``cleanup_sandbox_max_age_hours=1``.
    """
    sandbox = parent / name
    sandbox.mkdir(exist_ok=True)
    old_time = time.time() - _STALE_MTIME_OFFSET
    os.utime(str(sandbox), (old_time, old_time))
    return sandbox


def _make_real_fresh_sandbox(parent: Path, name: str) -> Path:
    """Create a real fresh (non-stale) sandbox directory inside *parent*.

    Uses the current mtime so the directory is not considered stale
    under the default ``cleanup_sandbox_max_age_hours=48`` setting.
    """
    sandbox = parent / name
    sandbox.mkdir(exist_ok=True)
    return sandbox


def _register_cleanup(context: Context, path: Path) -> None:
    """Register a path for cleanup in the after_scenario hook."""
    if not hasattr(context, "_cleanup_handlers"):
        context._cleanup_handlers = []

    def _remove() -> None:
        if path.exists():
            shutil.rmtree(str(path), ignore_errors=True)

    context._cleanup_handlers.append(_remove)


# ── Given steps ──────────────────────────────────────────────────


@given("cache invalidation has a CleanupService with a pre-populated sandbox cache")
def step_cache_inv_service_prepopulated(context: Context) -> None:
    """Create an isolated CleanupService whose cache holds real stale sandbox paths."""
    private_tmp = _make_private_tmp(context)
    context.cache_inv_settings = _make_settings(
        cleanup_sandbox_max_age_hours=_STALE_MAX_AGE_HOURS
    )
    context.cache_inv_service = _IsolatedCleanupService(
        context.cache_inv_settings, private_tmp
    )

    # Create two real stale sandbox directories in the private temp dir
    name_a = _unique_sandbox_name("ca-sandbox-plan-aaa")
    name_b = _unique_sandbox_name("ca-sandbox-plan-bbb")
    dir_a = _make_real_stale_sandbox(private_tmp, name_a)
    dir_b = _make_real_stale_sandbox(private_tmp, name_b)

    # Pre-populate the cache with the real Path objects
    context.cache_inv_service._sandbox_dirs_cache = [dir_a, dir_b]
    context.cache_inv_pre_cached_paths = [dir_a, dir_b]


@given("cache invalidation has a CleanupService with stale sandbox directories on disk")
def step_cache_inv_service_real_stale_dirs(context: Context) -> None:
    """Create real stale sandbox directories in a private temp dir."""
    private_tmp = _make_private_tmp(context)
    name_a = _unique_sandbox_name("ca-sandbox-planA")
    name_b = _unique_sandbox_name("ca-sandbox-planB")
    dir_a = _make_real_stale_sandbox(private_tmp, name_a)
    dir_b = _make_real_stale_sandbox(private_tmp, name_b)

    # Use minimum allowed max_age_hours=1; dirs are ~11.5 days old so they
    # are immediately stale.
    context.cache_inv_settings = _make_settings(
        cleanup_sandbox_max_age_hours=_STALE_MAX_AGE_HOURS
    )
    context.cache_inv_service = _IsolatedCleanupService(
        context.cache_inv_settings, private_tmp
    )
    context.cache_inv_real_dirs = [dir_a, dir_b]


@given("cache invalidation has a CleanupService with a fresh non-stale sandbox cache")
def step_cache_inv_service_nonstale_cache(context: Context) -> None:
    """Create an isolated CleanupService whose cache holds a real non-stale sandbox dir."""
    private_tmp = _make_private_tmp(context)
    # Use default max_age_hours=48 so the fresh dir (current mtime) is NOT stale
    context.cache_inv_settings = _make_settings()
    context.cache_inv_service = _IsolatedCleanupService(
        context.cache_inv_settings, private_tmp
    )

    # Create a real fresh (non-stale) sandbox directory in the private temp dir
    name = _unique_sandbox_name("ca-sandbox-plan-fresh")
    fresh_dir = _make_real_fresh_sandbox(private_tmp, name)

    # Pre-populate the cache with the real Path object
    context.cache_inv_service._sandbox_dirs_cache = [fresh_dir]
    context.cache_inv_fresh_dir = fresh_dir


# ── When steps ───────────────────────────────────────────────────


@when("cache invalidation calls _purge_sandboxes")
def step_cache_inv_purge_sandboxes(context: Context) -> None:
    """Call ``_purge_sandboxes`` with a real CleanupReport."""
    report = CleanupReport(dry_run=False)
    context.cache_inv_service._purge_sandboxes(report)
    context.cache_inv_report = report


@when("cache invalidation calls scan then purge then scan again")
def step_cache_inv_scan_purge_scan(context: Context) -> None:
    """Run the full scan -> purge -> scan workflow using real filesystem dirs."""
    svc = context.cache_inv_service
    real_dirs = context.cache_inv_real_dirs

    # First scan: inject the real dirs into the cache so scan() finds them
    svc._sandbox_dirs_cache = list(real_dirs)
    context.cache_inv_first_scan = svc.scan()

    # Purge: actually deletes the directories and invalidates the cache.
    # Because svc is an _IsolatedCleanupService, _get_sandbox_dirs() will
    # re-read only the private temp dir — not the system-wide /tmp.
    context.cache_inv_purge_report = svc.purge()

    # Second scan: cache was invalidated, so _get_sandbox_dirs re-reads
    # the private temp dir.  The real dirs are now gone from disk.
    context.cache_inv_second_scan = svc.scan()


@when("cache invalidation calls purge then scan")
def step_cache_inv_purge_then_scan(context: Context) -> None:
    """Purge (deletes real dirs and invalidates cache) then scan."""
    svc = context.cache_inv_service
    context.cache_inv_purge_report = svc.purge()
    context.cache_inv_scan_after_purge = svc.scan()


@when("cache invalidation calls _purge_sandboxes then _get_sandbox_dirs")
def step_cache_inv_purge_then_get_dirs(context: Context) -> None:
    """Purge (cache invalidated), then call _get_sandbox_dirs to repopulate."""
    svc = context.cache_inv_service

    # Purge: deletes the pre-cached stale dirs and invalidates the cache
    report = CleanupReport(dry_run=False)
    svc._purge_sandboxes(report)

    # Create a new real sandbox dir in the private temp dir so
    # _get_sandbox_dirs has something to find after cache invalidation.
    private_tmp = svc._private_tmp
    new_name = _unique_sandbox_name("ca-sandbox-plan-new")
    new_dir = _make_real_stale_sandbox(private_tmp, new_name)
    _register_cleanup(context, new_dir)
    context.cache_inv_new_dir = new_dir

    # Call _get_sandbox_dirs to repopulate the cache from the private temp dir
    context.cache_inv_repopulated = svc._get_sandbox_dirs()


# ── Then steps ───────────────────────────────────────────────────


@then("cache invalidation sandbox dirs cache should be None")
def step_cache_inv_cache_is_none(context: Context) -> None:
    """Assert that _sandbox_dirs_cache was set to None after purge."""
    assert context.cache_inv_service._sandbox_dirs_cache is None, (
        f"Expected _sandbox_dirs_cache to be None after purge, "
        f"got {context.cache_inv_service._sandbox_dirs_cache!r}"
    )


@then(
    "cache invalidation second scan should not contain the previously created sandbox directories"
)
def step_cache_inv_second_scan_no_created_dirs(context: Context) -> None:
    """Assert that the second scan does not contain the dirs we created."""
    second_scan = context.cache_inv_second_scan
    created_paths = {str(d) for d in context.cache_inv_real_dirs}
    stale_paths = {
        item.path for item in second_scan.stale_items if item.resource_type == "sandbox"
    }
    overlap = created_paths & stale_paths
    assert not overlap, (
        f"Second scan still contains previously created sandbox dirs: {overlap}"
    )


@then("cache invalidation scan result should not contain the pre-cached paths")
def step_cache_inv_scan_no_precached(context: Context) -> None:
    """Assert that scan after purge does not include the pre-cached paths."""
    scan_report = context.cache_inv_scan_after_purge
    pre_cached_paths = {str(p) for p in context.cache_inv_pre_cached_paths}
    stale_paths = {item.path for item in scan_report.stale_items}
    overlap = pre_cached_paths & stale_paths
    assert not overlap, f"Scan after purge still contains pre-cached paths: {overlap}"


@then("cache invalidation cache should be repopulated from filesystem")
def step_cache_inv_cache_repopulated(context: Context) -> None:
    """Assert that _get_sandbox_dirs returned the new dir and cache is set."""
    svc = context.cache_inv_service
    new_dir = context.cache_inv_new_dir
    repopulated = context.cache_inv_repopulated

    assert new_dir in repopulated, (
        f"Expected repopulated list to contain new_dir {new_dir}, got {repopulated!r}"
    )
    assert svc._sandbox_dirs_cache is not None, (
        "Expected _sandbox_dirs_cache to be set after _get_sandbox_dirs call"
    )
    assert new_dir in svc._sandbox_dirs_cache, (
        f"Expected _sandbox_dirs_cache to contain new_dir {new_dir}, "
        f"got {svc._sandbox_dirs_cache!r}"
    )
