# Familiar - Self-Hosted AI Companion Platform
# Copyright (c) 2026 George Scott Foley
# ORCID: 0009-0006-4957-0540
# Email: Georgescottfoley@proton.me
# Licensed under the MIT License - see LICENSE file for details

"""
Familiar Health Check Module (Phase 3 - v2.5.0)

Provides health check endpoints for container orchestration:
- Liveness probe: Is the process alive?
- Readiness probe: Is the service ready for traffic?
- Startup probe: Has initialization completed?

Also provides:
- Dependency health checks (LLM providers, databases, etc.)
- Resource monitoring (CPU, memory, disk)
- Component status aggregation

Usage:
    from familiar.core.health import (
        get_health_checker, HealthStatus, check_health,
        register_dependency_check
    )

    # Check overall health
    result = check_health()
    if result.status == HealthStatus.HEALTHY:
        print("All systems operational")

    # Register custom dependency check
    @register_dependency_check("my_service")
    def check_my_service():
        return DependencyHealth(healthy=True, latency_ms=10)
"""

import os
import time

try:
    import psutil

    HAS_PSUTIL = True
except ImportError:
    HAS_PSUTIL = False
    psutil = None
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeoutError
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Dict, List, Optional

logger = logging.getLogger(__name__)


# =============================================================================
# ENUMS
# =============================================================================


class HealthStatus(str, Enum):
    """Overall health status."""

    HEALTHY = "healthy"
    DEGRADED = "degraded"
    UNHEALTHY = "unhealthy"
    UNKNOWN = "unknown"


class DependencyType(str, Enum):
    """Types of dependencies."""

    LLM_PROVIDER = "llm_provider"
    DATABASE = "database"
    CACHE = "cache"
    MESSAGE_QUEUE = "message_queue"
    EXTERNAL_API = "external_api"
    FILE_SYSTEM = "file_system"
    INTERNAL = "internal"


# =============================================================================
# DATA CLASSES
# =============================================================================


@dataclass
class DependencyHealth:
    """Health status of a single dependency."""

    name: str
    healthy: bool
    latency_ms: Optional[float] = None
    message: Optional[str] = None
    last_check: datetime = field(default_factory=datetime.now)
    dependency_type: DependencyType = DependencyType.INTERNAL
    details: Dict[str, Any] = field(default_factory=dict)

    @property
    def status(self) -> HealthStatus:
        return HealthStatus.HEALTHY if self.healthy else HealthStatus.UNHEALTHY

    def to_dict(self) -> Dict[str, Any]:
        return {
            "name": self.name,
            "healthy": self.healthy,
            "status": self.status.value,
            "latency_ms": round(self.latency_ms, 2) if self.latency_ms else None,
            "message": self.message,
            "last_check": self.last_check.isoformat(),
            "type": self.dependency_type.value,
            "details": self.details,
        }


@dataclass
class ResourceHealth:
    """System resource health metrics."""

    cpu_percent: float
    memory_percent: float
    memory_used_mb: float
    memory_available_mb: float
    disk_percent: float
    disk_used_gb: float
    disk_free_gb: float
    open_files: int
    thread_count: int

    @property
    def healthy(self) -> bool:
        """Check if resources are within acceptable limits."""
        return self.cpu_percent < 90 and self.memory_percent < 90 and self.disk_percent < 90

    @property
    def status(self) -> HealthStatus:
        if self.cpu_percent > 95 or self.memory_percent > 95 or self.disk_percent > 95:
            return HealthStatus.UNHEALTHY
        if self.cpu_percent > 80 or self.memory_percent > 80 or self.disk_percent > 80:
            return HealthStatus.DEGRADED
        return HealthStatus.HEALTHY

    def to_dict(self) -> Dict[str, Any]:
        return {
            "status": self.status.value,
            "healthy": self.healthy,
            "cpu": {
                "percent": round(self.cpu_percent, 1),
            },
            "memory": {
                "percent": round(self.memory_percent, 1),
                "used_mb": round(self.memory_used_mb, 1),
                "available_mb": round(self.memory_available_mb, 1),
            },
            "disk": {
                "percent": round(self.disk_percent, 1),
                "used_gb": round(self.disk_used_gb, 2),
                "free_gb": round(self.disk_free_gb, 2),
            },
            "process": {
                "open_files": self.open_files,
                "thread_count": self.thread_count,
            },
        }


@dataclass
class HealthCheckResult:
    """Complete health check result."""

    status: HealthStatus
    healthy: bool
    dependencies: List[DependencyHealth]
    resources: Optional[ResourceHealth] = None
    version: str = ""
    uptime_seconds: float = 0.0
    timestamp: datetime = field(default_factory=datetime.now)
    message: Optional[str] = None

    @property
    def unhealthy_dependencies(self) -> List[DependencyHealth]:
        return [d for d in self.dependencies if not d.healthy]

    def to_dict(self) -> Dict[str, Any]:
        return {
            "status": self.status.value,
            "healthy": self.healthy,
            "message": self.message,
            "version": self.version,
            "uptime_seconds": round(self.uptime_seconds, 1),
            "timestamp": self.timestamp.isoformat(),
            "dependencies": [d.to_dict() for d in self.dependencies],
            "resources": self.resources.to_dict() if self.resources else None,
        }


# =============================================================================
# HEALTH CHECKER
# =============================================================================


class HealthChecker:
    """
    Central health checker for the application.

    Manages:
    - Dependency health checks
    - Resource monitoring
    - Health status aggregation
    - Probe endpoints (liveness, readiness, startup)
    """

    def __init__(
        self,
        version: str = "1.3.9",
        check_timeout_seconds: float = 5.0,
        cache_ttl_seconds: float = 10.0,
    ):
        self.version = version
        self.check_timeout_seconds = check_timeout_seconds
        self.cache_ttl_seconds = cache_ttl_seconds

        self._start_time = time.time()
        self._lock = threading.RLock()

        # Registered dependency checks
        self._dependency_checks: Dict[str, Callable[[], DependencyHealth]] = {}

        # Cached results
        self._cached_result: Optional[HealthCheckResult] = None
        self._cache_time: float = 0.0

        # Startup state
        self._startup_complete = False
        self._startup_checks: List[str] = []

        # Register built-in checks
        self._register_builtin_checks()

    def _register_builtin_checks(self) -> None:
        """Register built-in health checks."""
        # File system check
        self.register_dependency_check(
            "filesystem", self._check_filesystem, DependencyType.FILE_SYSTEM
        )

    @property
    def uptime_seconds(self) -> float:
        """Get uptime in seconds."""
        return time.time() - self._start_time

    # -------------------------------------------------------------------------
    # DEPENDENCY REGISTRATION
    # -------------------------------------------------------------------------

    def register_dependency_check(
        self,
        name: str,
        check_fn: Callable[[], DependencyHealth],
        dependency_type: DependencyType = DependencyType.INTERNAL,
    ) -> None:
        """Register a dependency health check."""
        with self._lock:
            # Wrap to add timing and type
            def wrapped_check() -> DependencyHealth:
                start = time.time()
                try:
                    result = check_fn()
                    if result.latency_ms is None:
                        result.latency_ms = (time.time() - start) * 1000
                    result.name = name
                    result.dependency_type = dependency_type
                    result.last_check = datetime.now()
                    return result
                except Exception as e:
                    return DependencyHealth(
                        name=name,
                        healthy=False,
                        latency_ms=(time.time() - start) * 1000,
                        message=f"Check failed: {str(e)}",
                        dependency_type=dependency_type,
                    )

            self._dependency_checks[name] = wrapped_check

    def unregister_dependency_check(self, name: str) -> None:
        """Unregister a dependency health check."""
        with self._lock:
            self._dependency_checks.pop(name, None)

    # -------------------------------------------------------------------------
    # STARTUP
    # -------------------------------------------------------------------------

    def mark_startup_complete(self) -> None:
        """Mark that startup is complete."""
        self._startup_complete = True
        logger.info("Startup complete, service ready for traffic")

    def add_startup_check(self, name: str) -> None:
        """Add a required startup check."""
        with self._lock:
            if name not in self._startup_checks:
                self._startup_checks.append(name)

    def is_startup_complete(self) -> bool:
        """Check if startup is complete."""
        if self._startup_complete:
            return True

        # Check if all required startup dependencies are healthy
        if not self._startup_checks:
            return True

        for name in self._startup_checks:
            if name in self._dependency_checks:
                try:
                    result = self._dependency_checks[name]()
                    if not result.healthy:
                        return False
                except Exception:
                    return False

        self._startup_complete = True
        return True

    # -------------------------------------------------------------------------
    # RESOURCE MONITORING
    # -------------------------------------------------------------------------

    def get_resource_health(self) -> ResourceHealth:
        """Get current system resource health."""
        if not HAS_PSUTIL:
            logger.debug("psutil not installed — resource monitoring unavailable")
            return ResourceHealth(
                cpu_percent=0,
                memory_percent=0,
                memory_used_mb=0,
                memory_available_mb=0,
                disk_percent=0,
                disk_used_gb=0,
                disk_free_gb=0,
                open_files=0,
                thread_count=0,
            )
        try:
            process = psutil.Process()

            # CPU
            cpu_percent = psutil.cpu_percent(interval=0.1)

            # Memory
            memory = psutil.virtual_memory()

            # Disk
            disk = psutil.disk_usage("/")

            # Process info
            open_files = len(process.open_files())
            thread_count = process.num_threads()

            return ResourceHealth(
                cpu_percent=cpu_percent,
                memory_percent=memory.percent,
                memory_used_mb=memory.used / (1024 * 1024),
                memory_available_mb=memory.available / (1024 * 1024),
                disk_percent=disk.percent,
                disk_used_gb=disk.used / (1024 * 1024 * 1024),
                disk_free_gb=disk.free / (1024 * 1024 * 1024),
                open_files=open_files,
                thread_count=thread_count,
            )
        except Exception as e:
            logger.warning(f"Failed to get resource health: {e}")
            return ResourceHealth(
                cpu_percent=0,
                memory_percent=0,
                memory_used_mb=0,
                memory_available_mb=0,
                disk_percent=0,
                disk_used_gb=0,
                disk_free_gb=0,
                open_files=0,
                thread_count=0,
            )

    # -------------------------------------------------------------------------
    # HEALTH CHECKS
    # -------------------------------------------------------------------------

    def check_health(
        self,
        include_resources: bool = True,
        use_cache: bool = True,
    ) -> HealthCheckResult:
        """
        Perform a full health check.

        Args:
            include_resources: Include system resource metrics
            use_cache: Use cached result if available

        Returns:
            HealthCheckResult with status and details
        """
        # Check cache
        if use_cache:
            with self._lock:
                if (
                    self._cached_result
                    and (time.time() - self._cache_time) < self.cache_ttl_seconds
                ):
                    return self._cached_result

        # Run dependency checks in parallel
        dependency_results: List[DependencyHealth] = []

        executor = ThreadPoolExecutor(max_workers=10)
        try:
            futures = {}
            with self._lock:
                checks = dict(self._dependency_checks)

            for name, check_fn in checks.items():
                futures[name] = executor.submit(check_fn)

            for name, future in futures.items():
                try:
                    result = future.result(timeout=self.check_timeout_seconds)
                    dependency_results.append(result)
                except FuturesTimeoutError:
                    dependency_results.append(
                        DependencyHealth(
                            name=name,
                            healthy=False,
                            message="Health check timed out",
                        )
                    )
                except Exception as e:
                    dependency_results.append(
                        DependencyHealth(
                            name=name,
                            healthy=False,
                            message=f"Health check failed: {str(e)}",
                        )
                    )
        finally:
            # Don't block on stragglers that already timed out
            executor.shutdown(wait=False)

        # Get resource health
        resources = self.get_resource_health() if include_resources else None

        # Determine overall status
        unhealthy_count = sum(1 for d in dependency_results if not d.healthy)
        resource_status = resources.status if resources else HealthStatus.HEALTHY

        if unhealthy_count == 0 and resource_status == HealthStatus.HEALTHY:
            status = HealthStatus.HEALTHY
            healthy = True
            message = "All systems operational"
        elif (
            unhealthy_count > len(dependency_results) // 2
            or resource_status == HealthStatus.UNHEALTHY
        ):
            status = HealthStatus.UNHEALTHY
            healthy = False
            message = f"{unhealthy_count} dependencies unhealthy"
        else:
            status = HealthStatus.DEGRADED
            healthy = True  # Still operational but degraded
            message = f"{unhealthy_count} dependencies degraded"

        result = HealthCheckResult(
            status=status,
            healthy=healthy,
            dependencies=dependency_results,
            resources=resources,
            version=self.version,
            uptime_seconds=self.uptime_seconds,
            message=message,
        )

        # Update cache
        with self._lock:
            self._cached_result = result
            self._cache_time = time.time()

        return result

    def liveness_check(self) -> Dict[str, Any]:
        """
        Liveness probe - is the process alive?

        This should be a minimal check that always passes unless
        the process is deadlocked or in a bad state.

        Returns:
            {"status": "ok"} or {"status": "error", "message": "..."}
        """
        try:
            # Basic check - can we allocate memory and run code?
            _ = [0] * 1000
            return {
                "status": "ok",
                "timestamp": datetime.now().isoformat(),
            }
        except Exception as e:
            return {
                "status": "error",
                "message": str(e),
                "timestamp": datetime.now().isoformat(),
            }

    def readiness_check(self) -> Dict[str, Any]:
        """
        Readiness probe - is the service ready to handle traffic?

        Returns:
            {"ready": True/False, "status": "...", ...}
        """
        result = self.check_health(include_resources=False)

        return {
            "ready": result.healthy,
            "status": result.status.value,
            "message": result.message,
            "unhealthy_dependencies": [d.name for d in result.unhealthy_dependencies],
            "timestamp": datetime.now().isoformat(),
        }

    def startup_check(self) -> Dict[str, Any]:
        """
        Startup probe - has initialization completed?

        Returns:
            {"started": True/False, ...}
        """
        started = self.is_startup_complete()

        return {
            "started": started,
            "uptime_seconds": round(self.uptime_seconds, 1),
            "pending_checks": [
                name for name in self._startup_checks if name in self._dependency_checks
            ]
            if not started
            else [],
            "timestamp": datetime.now().isoformat(),
        }

    # -------------------------------------------------------------------------
    # BUILT-IN CHECKS
    # -------------------------------------------------------------------------

    def _check_filesystem(self) -> DependencyHealth:
        """Check filesystem health."""
        try:
            # Try to write and read a temp file
            test_path = "/tmp/.familiar_health_check"
            test_data = str(time.time())

            start = time.time()
            with open(test_path, "w") as f:
                f.write(test_data)

            with open(test_path, "r") as f:
                read_data = f.read()

            os.remove(test_path)
            latency = (time.time() - start) * 1000

            if read_data == test_data:
                return DependencyHealth(
                    name="filesystem",
                    healthy=True,
                    latency_ms=latency,
                    message="Filesystem read/write OK",
                )
            else:
                return DependencyHealth(
                    name="filesystem",
                    healthy=False,
                    latency_ms=latency,
                    message="Filesystem data corruption detected",
                )
        except Exception as e:
            return DependencyHealth(
                name="filesystem",
                healthy=False,
                message=f"Filesystem check failed: {str(e)}",
            )

    def check_llm_provider(
        self,
        provider_name: str,
        test_fn: Callable[[], bool],
    ) -> DependencyHealth:
        """
        Check LLM provider connectivity.

        Args:
            provider_name: Name of the provider (e.g., "anthropic", "openai")
            test_fn: Function that returns True if provider is reachable

        Returns:
            DependencyHealth for the provider
        """
        start = time.time()
        try:
            healthy = test_fn()
            latency = (time.time() - start) * 1000
            return DependencyHealth(
                name=f"llm_{provider_name}",
                healthy=healthy,
                latency_ms=latency,
                message="Provider reachable" if healthy else "Provider unreachable",
                dependency_type=DependencyType.LLM_PROVIDER,
            )
        except Exception as e:
            return DependencyHealth(
                name=f"llm_{provider_name}",
                healthy=False,
                latency_ms=(time.time() - start) * 1000,
                message=f"Provider check failed: {str(e)}",
                dependency_type=DependencyType.LLM_PROVIDER,
            )


# =============================================================================
# PROVIDER HEALTH CHECKS
# =============================================================================


def create_anthropic_health_check() -> Callable[[], DependencyHealth]:
    """Create a health check for Anthropic API."""

    def check() -> DependencyHealth:
        try:
            import anthropic

            client = anthropic.Anthropic(timeout=5.0)

            start = time.time()
            client.models.list(limit=1)
            latency = (time.time() - start) * 1000

            return DependencyHealth(
                name="anthropic",
                healthy=True,
                latency_ms=latency,
                message="Anthropic API reachable",
                dependency_type=DependencyType.LLM_PROVIDER,
                details={"api_key_set": True},
            )
        except ImportError:
            return DependencyHealth(
                name="anthropic",
                healthy=False,
                message="anthropic package not installed",
                dependency_type=DependencyType.LLM_PROVIDER,
            )
        except Exception as e:
            return DependencyHealth(
                name="anthropic",
                healthy=False,
                message=str(e),
                dependency_type=DependencyType.LLM_PROVIDER,
                details={"api_key_set": bool(os.environ.get("ANTHROPIC_API_KEY"))},
            )

    return check


def create_openai_health_check() -> Callable[[], DependencyHealth]:
    """Create a health check for OpenAI API."""

    def check() -> DependencyHealth:
        try:
            from openai import OpenAI

            client = OpenAI(timeout=5.0)

            start = time.time()
            client.models.list()
            latency = (time.time() - start) * 1000

            return DependencyHealth(
                name="openai",
                healthy=True,
                latency_ms=latency,
                message="OpenAI API reachable",
                dependency_type=DependencyType.LLM_PROVIDER,
                details={"api_key_set": True},
            )
        except ImportError:
            return DependencyHealth(
                name="openai",
                healthy=False,
                message="openai package not installed",
                dependency_type=DependencyType.LLM_PROVIDER,
            )
        except Exception as e:
            return DependencyHealth(
                name="openai",
                healthy=False,
                message=str(e),
                dependency_type=DependencyType.LLM_PROVIDER,
                details={"api_key_set": bool(os.environ.get("OPENAI_API_KEY"))},
            )

    return check


# =============================================================================
# SINGLETON
# =============================================================================

_health_checker: Optional[HealthChecker] = None
_health_lock = threading.Lock()


def get_health_checker() -> HealthChecker:
    """Get the singleton health checker."""
    global _health_checker
    if _health_checker is None:
        with _health_lock:
            if _health_checker is None:
                _health_checker = HealthChecker()

                # Register LLM provider checks if available
                try:
                    _health_checker.register_dependency_check(
                        "anthropic",
                        create_anthropic_health_check(),
                        DependencyType.LLM_PROVIDER,
                    )
                except Exception:
                    pass

                try:
                    _health_checker.register_dependency_check(
                        "openai",
                        create_openai_health_check(),
                        DependencyType.LLM_PROVIDER,
                    )
                except Exception:
                    pass

    return _health_checker


def reset_health_checker() -> None:
    """Reset the health checker (for testing)."""
    global _health_checker
    with _health_lock:
        _health_checker = None


# =============================================================================
# CONVENIENCE FUNCTIONS
# =============================================================================


def check_health(include_resources: bool = True) -> HealthCheckResult:
    """Convenience function for health check."""
    return get_health_checker().check_health(include_resources=include_resources)


def liveness() -> Dict[str, Any]:
    """Convenience function for liveness probe."""
    return get_health_checker().liveness_check()


def readiness() -> Dict[str, Any]:
    """Convenience function for readiness probe."""
    return get_health_checker().readiness_check()


def startup() -> Dict[str, Any]:
    """Convenience function for startup probe."""
    return get_health_checker().startup_check()


def register_dependency_check(
    name: str,
    dependency_type: DependencyType = DependencyType.INTERNAL,
) -> Callable:
    """
    Decorator for registering a dependency health check.

    Usage:
        @register_dependency_check("my_database", DependencyType.DATABASE)
        def check_my_database():
            return DependencyHealth(name="my_database", healthy=True)
    """

    def decorator(fn: Callable[[], DependencyHealth]) -> Callable[[], DependencyHealth]:
        get_health_checker().register_dependency_check(name, fn, dependency_type)
        return fn

    return decorator


# =============================================================================
# BACKGROUND SUBSYSTEM HEALTH CHECKS
# =============================================================================


def create_memory_agent_health_check(memory_agent_ref) -> Callable[[], DependencyHealth]:
    """Create a health check for the memory agent background thread.

    Args:
        memory_agent_ref: The MemoryAgent instance (strong reference) or a
            weakref.  Pass a strong reference to keep the agent alive as long
            as the health check exists; pass a weakref to allow GC.

    Returns a DependencyHealth indicating whether the memory agent's
    background thread is alive and its last extraction succeeded.
    """
    import weakref

    if isinstance(memory_agent_ref, weakref.ref):
        # Caller passed a weakref — use it directly
        ref = memory_agent_ref
        _strong = None  # no strong reference held
    else:
        # Caller passed a live object — hold a strong reference so the check
        # can always inspect it (typical production use).
        _strong = memory_agent_ref
        ref = weakref.ref(memory_agent_ref)

    def check() -> DependencyHealth:
        _ = _strong  # noqa: F841 — force closure to hold strong reference
        agent = ref()
        if agent is None:
            return DependencyHealth(
                name="memory_agent",
                healthy=False,
                message="MemoryAgent has been garbage-collected",
                dependency_type=DependencyType.INTERNAL,
            )

        # Check background thread liveness
        thread = getattr(agent, "_thread", None) or getattr(agent, "_bg_thread", None)
        thread_alive = thread is not None and thread.is_alive()

        # Check for recent error
        last_error = getattr(agent, "_last_error", None)
        last_run = getattr(agent, "_last_run", None)
        last_run_str = last_run.isoformat() if last_run else "never"

        healthy = thread_alive and last_error is None
        message = (
            "Memory agent running normally"
            if healthy
            else ("Thread dead" if not thread_alive else f"Last error: {last_error}")
        )

        return DependencyHealth(
            name="memory_agent",
            healthy=healthy,
            message=message,
            dependency_type=DependencyType.INTERNAL,
            details={
                "thread_alive": thread_alive,
                "last_run": last_run_str,
                "last_error": str(last_error) if last_error else None,
            },
        )

    return check


def create_planner_health_check(planner_ref) -> Callable[[], DependencyHealth]:
    """Create a health check for the planner subsystem.

    Args:
        planner_ref: The PlannerExecutor / Planner instance (strong reference)
            or a weakref.  Pass a strong reference for normal production use;
            pass a weakref to allow GC when the planner is torn down.

    Checks that the planner is initialised, reports how many active plans
    are running, and flags if any plan has been stuck (no progress) for
    longer than its max iteration wall-clock budget.
    """
    import weakref

    if isinstance(planner_ref, weakref.ref):
        ref = planner_ref
        _strong = None
    else:
        _strong = planner_ref
        ref = weakref.ref(planner_ref)

    def check() -> DependencyHealth:
        _ = _strong  # noqa: F841 — force closure to hold strong reference
        planner = ref()
        if planner is None:
            return DependencyHealth(
                name="planner",
                healthy=False,
                message="Planner has been garbage-collected",
                dependency_type=DependencyType.INTERNAL,
            )

        # Count active vs stuck plans
        active_plans = getattr(planner, "_active_plans", {}) or {}
        active_count = len(active_plans)

        stuck_count = 0
        import datetime as _dt

        now = _dt.datetime.now()
        for plan in active_plans.values():
            started = getattr(plan, "started_at", None)
            if started and isinstance(started, str):
                try:
                    started = _dt.datetime.fromisoformat(started)
                except ValueError:
                    started = None
            if started:
                elapsed_minutes = (now - started).total_seconds() / 60
                # Flag plans running >30 minutes (conservative threshold)
                if elapsed_minutes > 30:
                    stuck_count += 1

        healthy = stuck_count == 0
        message = (
            f"Planner OK ({active_count} active plan(s))"
            if healthy
            else f"Planner has {stuck_count} possibly-stuck plan(s) (>30 min)"
        )

        return DependencyHealth(
            name="planner",
            healthy=healthy,
            message=message,
            dependency_type=DependencyType.INTERNAL,
            details={
                "active_plans": active_count,
                "stuck_plans": stuck_count,
            },
        )

    return check
