"""Garbage collection and cleanup service (CONC3).

Provides centralized cleanup for stale sandboxes, excess checkpoints,
inactive sessions, expired logs, and expired backups.  Supports
dry-run mode (report without deleting) and full purge.
"""

from __future__ import annotations

import shutil
import tempfile
import time
from datetime import UTC, datetime
from pathlib import Path
from typing import Any

from cleveragents.application.services.cleanup_models import (
    CleanupReport,
    ResourceCleanupSummary,
    StaleItem,
)
from cleveragents.config.settings import Settings
from cleveragents.resource.handlers.devcontainer import stop_all_active_containers

__all__ = [
    "CleanupReport",
    "CleanupService",
    "ResourceCleanupSummary",
    "StaleItem",
]


# ── Service ───────────────────────────────────────────────────────


class CleanupService:
    """Orchestrates garbage collection for the platform.

    Args:
        settings: Application settings with retention policy fields.
        active_plan_ids: Set of plan IDs currently running (protected).
    """

    def __init__(
        self,
        settings: Settings,
        active_plan_ids: frozenset[str] | None = None,
    ) -> None:
        if not isinstance(settings, Settings):
            raise TypeError("settings must be a Settings instance")
        self._settings = settings
        self._active_plan_ids: frozenset[str] = active_plan_ids or frozenset()
        self._sandbox_dirs_cache: list[Path] | None = None

    # ── Public API ────────────────────────────────────────────────

    def scan(self) -> CleanupReport:
        """Scan for stale resources without deleting anything.

        Returns:
            A CleanupReport with dry_run=True containing identified
            stale items and per-resource counts.
        """
        report = CleanupReport(dry_run=True)
        self._scan_sandboxes(report)
        self._scan_checkpoints(report)
        self._scan_sessions(report)
        self._scan_logs(report)
        self._scan_backups(report)
        return report

    def purge(self, *, purge_all: bool = False) -> CleanupReport:
        """Delete stale resources.

        Args:
            purge_all: When True, purge all resource types.
                When False, only purge sandboxes and checkpoints.

        Returns:
            A CleanupReport with removal counts.
        """
        report = CleanupReport(dry_run=False)
        self._purge_sandboxes(report)
        self._purge_checkpoints(report)
        if purge_all:
            self._purge_sessions(report)
            self._purge_logs(report)
            self._purge_backups(report)
        return report

    # ── Sandbox cleanup ───────────────────────────────────────────

    def _get_sandbox_dirs(self) -> list[Path]:
        """Find sandbox directories in the system temp directory.

        Results are cached for the lifetime of the service instance so
        that ``scan()`` and ``purge()`` (and the CLI active-plan check)
        do not redundantly iterate ``/tmp``.
        """
        if self._sandbox_dirs_cache is not None:
            return self._sandbox_dirs_cache
        tmp = Path(tempfile.gettempdir())
        if not tmp.exists():
            self._sandbox_dirs_cache = []
            return self._sandbox_dirs_cache
        dirs: list[Path] = []
        try:
            entries = list(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

    @staticmethod
    def extract_plan_id_from_sandbox(path: Path) -> str | None:
        """Extract plan_id from a sandbox directory name.

        Handles both standard (``ca-sandbox-<plan_id>-<random>``) and
        copy-on-write (``ca-cow-sandbox-<plan_id>-<random>``) formats.

        This is a public static method so that callers (e.g. the CLI
        active-plan detection) can reuse the parsing logic without
        constructing a full :class:`CleanupService`.
        """
        name = path.name
        for prefix in ("ca-cow-sandbox-", "ca-sandbox-"):
            if name.startswith(prefix):
                # Format: <prefix><plan_id>-<random>
                remainder = name[len(prefix) :]
                parts = remainder.rsplit("-", 1)
                if parts and parts[0]:
                    return parts[0]
        return None

    def _is_sandbox_stale(self, path: Path) -> bool:
        """Check if a sandbox directory exceeds the max age."""
        max_age_seconds = self._settings.cleanup_sandbox_max_age_hours * 3600
        try:
            mtime = path.stat().st_mtime
        except OSError:
            return False
        return (time.time() - mtime) > max_age_seconds

    def _scan_sandboxes(self, report: CleanupReport) -> None:
        """Populate report with stale sandbox info."""
        dirs = self._get_sandbox_dirs()
        for d in dirs:
            report.sandboxes.scanned += 1
            plan_id = self.extract_plan_id_from_sandbox(d)
            if plan_id and plan_id in self._active_plan_ids:
                report.sandboxes.skipped += 1
                report.sandboxes.skipped_reasons.append(
                    f"Skipped {d.name}: linked to running plan {plan_id}"
                )
                continue
            if self._is_sandbox_stale(d):
                report.stale_items.append(
                    StaleItem(
                        resource_type="sandbox",
                        path=str(d),
                        age_description=self._age_description(d),
                        plan_id=plan_id,
                    )
                )

    def _purge_sandboxes(self, report: CleanupReport) -> None:
        """Remove stale sandbox directories."""
        dirs = self._get_sandbox_dirs()
        for d in dirs:
            report.sandboxes.scanned += 1
            plan_id = self.extract_plan_id_from_sandbox(d)
            if plan_id and plan_id in self._active_plan_ids:
                report.sandboxes.skipped += 1
                report.sandboxes.skipped_reasons.append(
                    f"Skipped {d.name}: linked to running plan {plan_id}"
                )
                continue
            if self._is_sandbox_stale(d):
                try:
                    shutil.rmtree(d)
                    report.sandboxes.removed += 1
                except OSError:
                    report.sandboxes.skipped += 1

    # ── Checkpoint cleanup ────────────────────────────────────────

    def scan_checkpoints_for_plan(
        self,
        checkpoint_dir: Path,
    ) -> list[Path]:
        """Return excess checkpoint files for a plan directory.

        Keeps the first and most recent, prunes the rest when count
        exceeds ``cleanup_checkpoint_max_per_plan``.
        """
        if not checkpoint_dir.exists():
            return []
        files = sorted(checkpoint_dir.glob("checkpoint_*.json"))
        max_count = self._settings.cleanup_checkpoint_max_per_plan
        if len(files) <= max_count:
            return []
        # Keep first and last, prune from the middle
        if len(files) < 3:
            return []
        middle = files[1:-1]
        excess_count = len(files) - max_count
        return middle[:excess_count]

    def prune_checkpoints_for_plan(
        self,
        checkpoint_dir: Path,
    ) -> int:
        """Delete excess checkpoint files. Returns count removed."""
        to_remove = self.scan_checkpoints_for_plan(checkpoint_dir)
        removed = 0
        for f in to_remove:
            try:
                f.unlink()
                removed += 1
            except OSError:
                pass
        return removed

    def _scan_checkpoints(self, report: CleanupReport) -> None:
        """Scan for excess checkpoints across all plan directories."""
        checkpoint_base = self._get_checkpoint_base_dir()
        if not checkpoint_base.exists():
            return
        for plan_dir in checkpoint_base.iterdir():
            if not plan_dir.is_dir():
                continue
            excess = self.scan_checkpoints_for_plan(plan_dir)
            report.checkpoints.scanned += len(list(plan_dir.glob("checkpoint_*.json")))
            for f in excess:
                report.stale_items.append(
                    StaleItem(
                        resource_type="checkpoint",
                        path=str(f),
                        age_description="excess",
                    )
                )

    def _purge_checkpoints(self, report: CleanupReport) -> None:
        """Prune excess checkpoints across all plan directories."""
        checkpoint_base = self._get_checkpoint_base_dir()
        if not checkpoint_base.exists():
            return
        for plan_dir in checkpoint_base.iterdir():
            if not plan_dir.is_dir():
                continue
            files = list(plan_dir.glob("checkpoint_*.json"))
            report.checkpoints.scanned += len(files)
            removed = self.prune_checkpoints_for_plan(plan_dir)
            report.checkpoints.removed += removed

    def _get_checkpoint_base_dir(self) -> Path:
        """Return the base directory where plan checkpoint dirs live."""
        data_dir = self._settings.data_dir
        if not data_dir.is_absolute():
            data_dir = Path.cwd() / data_dir
        return data_dir / "checkpoints"

    # ── Session cleanup ───────────────────────────────────────────

    def scan_inactive_sessions(
        self,
        sessions: list[dict[str, Any]],
    ) -> list[dict[str, Any]]:
        """Filter sessions that exceed the inactivity threshold.

        Args:
            sessions: List of session dicts with ``updated_at`` key
                as a :class:`datetime` or ISO-8601 string.

        Returns:
            Sessions that are inactive beyond the threshold.
        """
        threshold_days = self._settings.cleanup_session_inactivity_days
        now = datetime.now(tz=UTC)
        inactive: list[dict[str, Any]] = []
        for session in sessions:
            updated = session.get("updated_at")
            if updated is None:
                continue
            if isinstance(updated, str):
                updated = datetime.fromisoformat(updated)
            if not updated.tzinfo:
                updated = updated.replace(tzinfo=UTC)
            age_days = (now - updated).days
            if age_days > threshold_days:
                inactive.append(session)
        return inactive

    def _scan_sessions(self, report: CleanupReport) -> None:
        """Placeholder scan — session cleanup requires DB access.

        Session cleanup is not yet implemented in MVP.  The
        ``scan_inactive_sessions`` method is available for callers
        that can provide session dicts from the database.
        """
        # TODO(CONC3): Wire DB session query when Container is
        # available at CLI startup.

    def _purge_sessions(self, report: CleanupReport) -> None:
        """Placeholder purge — session cleanup requires DB access.

        Session deletion is not yet implemented in MVP.
        """
        # TODO(CONC3): Wire DB session deletion when Container is
        # available at CLI startup.

    # ── Log cleanup ───────────────────────────────────────────────

    def scan_expired_files(
        self,
        directory: Path,
        retention_days: int,
        pattern: str = "*.log",
    ) -> list[Path]:
        """Return files in *directory* older than *retention_days*."""
        if not directory.exists():
            return []
        max_age_seconds = retention_days * 86400
        now = time.time()
        expired: list[Path] = []
        for f in directory.glob(pattern):
            if not f.is_file():
                continue
            try:
                mtime = f.stat().st_mtime
            except OSError:
                continue
            if (now - mtime) > max_age_seconds:
                expired.append(f)
        return expired

    def _scan_logs(self, report: CleanupReport) -> None:
        """Scan for expired log files."""
        log_dir = self._settings.log_dir
        if not log_dir.is_absolute():
            log_dir = Path.cwd() / log_dir
        expired = self.scan_expired_files(
            log_dir,
            self._settings.cleanup_log_retention_days,
            pattern="*.log",
        )
        report.logs.scanned += len(
            list(log_dir.glob("*.log")) if log_dir.exists() else []
        )
        for f in expired:
            report.stale_items.append(
                StaleItem(
                    resource_type="log",
                    path=str(f),
                    age_description=self._age_description(f),
                )
            )

    def _purge_logs(self, report: CleanupReport) -> None:
        """Delete expired log files."""
        log_dir = self._settings.log_dir
        if not log_dir.is_absolute():
            log_dir = Path.cwd() / log_dir
        expired = self.scan_expired_files(
            log_dir,
            self._settings.cleanup_log_retention_days,
            pattern="*.log",
        )
        report.logs.scanned += len(
            list(log_dir.glob("*.log")) if log_dir.exists() else []
        )
        for f in expired:
            try:
                f.unlink()
                report.logs.removed += 1
            except OSError:
                report.logs.skipped += 1

    # ── Backup cleanup ────────────────────────────────────────────

    def _get_backup_dir(self) -> Path:
        """Return the backup directory."""
        data_dir = self._settings.data_dir
        if not data_dir.is_absolute():
            data_dir = Path.cwd() / data_dir
        return data_dir / "backups"

    def _scan_backups(self, report: CleanupReport) -> None:
        """Scan for expired backup files."""
        backup_dir = self._get_backup_dir()
        expired = self.scan_expired_files(
            backup_dir,
            self._settings.cleanup_backup_retention_days,
            pattern="*",
        )
        all_files = list(backup_dir.iterdir()) if backup_dir.exists() else []
        report.backups.scanned += len([f for f in all_files if f.is_file()])
        for f in expired:
            report.stale_items.append(
                StaleItem(
                    resource_type="backup",
                    path=str(f),
                    age_description=self._age_description(f),
                )
            )

    def _purge_backups(self, report: CleanupReport) -> None:
        """Delete expired backup files."""
        backup_dir = self._get_backup_dir()
        expired = self.scan_expired_files(
            backup_dir,
            self._settings.cleanup_backup_retention_days,
            pattern="*",
        )
        all_files = list(backup_dir.iterdir()) if backup_dir.exists() else []
        report.backups.scanned += len([f for f in all_files if f.is_file()])
        for f in expired:
            try:
                f.unlink()
                report.backups.removed += 1
            except OSError:
                report.backups.skipped += 1

    # ── Devcontainer lifecycle cleanup (issue #514) ─────────────

    @staticmethod
    def stop_active_devcontainers(session_id: str = "") -> list[str]:
        """Stop all active devcontainer-instance resources.

        Called as a cleanup hook when a session closes or a plan
        completes.  Delegates to the devcontainer handler's
        ``stop_all_active_containers`` function.

        Args:
            session_id: Optional session/plan ID for logging.

        Returns:
            List of resource IDs that were stopped.
        """
        # F11 fix: import moved to module top per CONTRIBUTING.
        return stop_all_active_containers(session_id=session_id)

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

    @staticmethod
    def _age_description(path: Path) -> str:
        """Human-readable age description for a file or directory."""
        try:
            mtime = path.stat().st_mtime
        except OSError:
            return "unknown age"
        age_seconds = time.time() - mtime
        if age_seconds < 3600:
            return f"{int(age_seconds / 60)} minutes old"
        if age_seconds < 86400:
            return f"{int(age_seconds / 3600)} hours old"
        return f"{int(age_seconds / 86400)} days old"
