# 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

"""
Service Manager — provisions and manages self-hosted services via Podman/Docker.

Detects available container runtime, pulls images, creates/starts/stops containers,
and persists state to ``~/.familiar/services/state.json``.
"""

import json
import logging
import secrets
import shutil
import socket
import subprocess
import threading
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Any, Dict, List, Optional

from familiar.services.specs import SERVICE_SPECS, ServiceSpec, ServiceType

logger = logging.getLogger(__name__)

SERVICES_DIR = Path.home() / ".familiar" / "services"
STATE_FILE = SERVICES_DIR / "state.json"


class ContainerRuntime(str, Enum):
    """Detected container engine."""

    PODMAN = "podman"
    DOCKER = "docker"
    NONE = "none"


class ServiceStatus(str, Enum):
    """Current status of a managed service."""

    NOT_INSTALLED = "not_installed"
    STOPPED = "stopped"
    RUNNING = "running"
    ERROR = "error"
    PULLING = "pulling"
    STARTING = "starting"


@dataclass
class ServiceState:
    """Persisted state for a single service."""

    key: str
    status: str = ServiceStatus.NOT_INSTALLED.value
    container_id: str = ""
    container_name: str = ""
    ports: Dict[int, int] = field(default_factory=dict)
    started_at: str = ""
    error_message: str = ""
    url: str = ""

    def to_dict(self) -> dict:
        return asdict(self)


class ServiceManager:
    """
    Provisions and manages containerized (and native) services.

    Uses Podman by default, falls back to Docker.  All container names
    are prefixed ``familiar-`` to avoid collisions.
    """

    def __init__(self):
        self._runtime: Optional[ContainerRuntime] = None
        self._runtime_path: Optional[str] = None
        self._lock = threading.Lock()
        self._states: Dict[str, ServiceState] = {}
        SERVICES_DIR.mkdir(parents=True, exist_ok=True)
        self._load_state()

    # ------------------------------------------------------------------ runtime

    def detect_runtime(self) -> ContainerRuntime:
        """Detect the best available container runtime."""
        if self._runtime is not None:
            return self._runtime

        for candidate in ("podman", "docker"):
            path = shutil.which(candidate)
            if path is None:
                continue
            try:
                subprocess.run(
                    [path, "info"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
                self._runtime_path = path
                self._runtime = ContainerRuntime(candidate)
                logger.info("Detected container runtime: %s (%s)", candidate, path)
                return self._runtime
            except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
                continue

        self._runtime = ContainerRuntime.NONE
        return self._runtime

    def invalidate_runtime(self):
        """Clear cached runtime so next detect_runtime() re-probes."""
        self._runtime = None
        self._runtime_path = None

    def get_runtime_info(self) -> Dict[str, Any]:
        """Return runtime name, version, and rootless status."""
        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"runtime": "none", "version": "", "rootless": False}

        info: Dict[str, Any] = {"runtime": rt.value, "version": "", "rootless": False}
        try:
            result = subprocess.run(
                [self._runtime_path, "version", "--format", "{{.Client.Version}}"],
                capture_output=True,
                text=True,
                timeout=10,
            )
            info["version"] = result.stdout.strip() or ""
        except Exception:
            try:
                result = subprocess.run(
                    [self._runtime_path, "version"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
                for line in result.stdout.splitlines():
                    if "version" in line.lower():
                        info["version"] = line.split(":")[-1].strip()
                        break
            except Exception:
                pass

        if rt == ContainerRuntime.PODMAN:
            try:
                result = subprocess.run(
                    [self._runtime_path, "info", "--format", "{{.Host.Security.Rootless}}"],
                    capture_output=True,
                    text=True,
                    timeout=10,
                )
                info["rootless"] = result.stdout.strip().lower() == "true"
            except Exception:
                pass

        return info

    # ------------------------------------------------------------------ ports

    def _check_port_available(self, port: int) -> bool:
        """Return True if the port is free on localhost."""
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
                s.settimeout(1)
                s.bind(("127.0.0.1", port))
            return True
        except OSError:
            return False

    def check_port_conflicts(self, spec: ServiceSpec) -> List[int]:
        """Return list of host ports that are already in use."""
        conflicts = []
        for host_port in spec.ports:
            if not self._check_port_available(host_port):
                conflicts.append(host_port)
        return conflicts

    # ------------------------------------------------------------------ lifecycle

    def pull_image(self, key: str) -> Dict[str, Any]:
        """Pull the container image for a service."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}"}
        if spec.service_type == ServiceType.NATIVE:
            return {"success": True, "message": "Native service, no image to pull"}

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime found"}

        self._set_status(key, ServiceStatus.PULLING)
        try:
            result = subprocess.run(
                [self._runtime_path, "pull", spec.image],
                capture_output=True,
                text=True,
                timeout=600,
            )
            if result.returncode != 0:
                self._set_status(key, ServiceStatus.ERROR, error=result.stderr.strip())
                return {"success": False, "error": result.stderr.strip()}
            self._set_status(key, ServiceStatus.STOPPED)
            return {"success": True, "message": f"Pulled {spec.image}"}
        except subprocess.TimeoutExpired:
            self._set_status(key, ServiceStatus.ERROR, error="Image pull timed out")
            return {"success": False, "error": "Image pull timed out (10 min)"}
        except Exception as e:
            self._set_status(key, ServiceStatus.ERROR, error=str(e))
            return {"success": False, "error": str(e)}

    @staticmethod
    def _generate_provision_secrets(key: str, env_vars: Dict[str, str]) -> Dict[str, str]:
        """Generate required secrets at provision time for services that need them.

        Returns a copy of env_vars with auto-generated secrets injected.
        """
        env = dict(env_vars)
        if key == "bluesky_pds":
            env.setdefault("PDS_JWT_SECRET", secrets.token_hex(16))
            env.setdefault("PDS_ADMIN_PASSWORD", secrets.token_urlsafe(16))
            env.setdefault(
                "PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX",
                secrets.token_hex(32),
            )
        return env

    def provision(self, key: str) -> Dict[str, Any]:
        """Pull image and create a stopped container for the service."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}"}

        if spec.service_type == ServiceType.NATIVE:
            self._set_status(key, ServiceStatus.STOPPED)
            return {"success": True, "message": f"{spec.display_name} ready (native)"}

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime found. Install Podman or Docker."}

        # Pull image
        pull = self.pull_image(key)
        if not pull.get("success"):
            return pull

        # Create volume directories
        home = str(Path.home())
        for host_path in spec.get_resolved_volumes(home):
            Path(host_path).mkdir(parents=True, exist_ok=True)

        # Generate provision-time secrets
        env_vars = self._generate_provision_secrets(key, spec.env_vars)

        # Build create command
        name = spec.default_container_name
        cmd = [self._runtime_path, "create", "--name", name]

        # Port mappings
        for host_port, container_port in spec.ports.items():
            cmd.extend(["-p", f"{host_port}:{container_port}"])

        # Volume mounts
        for host_path, container_path in spec.get_resolved_volumes(home).items():
            cmd.extend(["-v", f"{host_path}:{container_path}"])

        # Environment variables
        for env_key, env_val in env_vars.items():
            cmd.extend(["-e", f"{env_key}={env_val}"])

        cmd.extend(["--restart", "unless-stopped"])
        cmd.append(spec.image)

        self._set_status(key, ServiceStatus.STARTING)
        try:
            # Remove existing container if any
            subprocess.run(
                [self._runtime_path, "rm", "-f", name],
                capture_output=True,
                text=True,
                timeout=30,
            )

            result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
            if result.returncode != 0:
                self._set_status(key, ServiceStatus.ERROR, error=result.stderr.strip())
                return {"success": False, "error": result.stderr.strip()}

            container_id = result.stdout.strip()
            state = self._get_or_create_state(key)
            state.container_id = container_id
            state.container_name = name
            state.ports = spec.ports
            state.status = ServiceStatus.STOPPED.value
            state.error_message = ""
            self._save_state()

            return {"success": True, "message": f"{spec.display_name} provisioned", "container_id": container_id}
        except subprocess.TimeoutExpired:
            self._set_status(key, ServiceStatus.ERROR, error="Container create timed out")
            return {"success": False, "error": "Container create timed out"}
        except Exception as e:
            self._set_status(key, ServiceStatus.ERROR, error=str(e))
            return {"success": False, "error": str(e)}

    def start(self, key: str) -> Dict[str, Any]:
        """Start a provisioned service."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}"}

        if spec.service_type == ServiceType.NATIVE:
            return self.start_native(key)

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime found"}

        name = spec.default_container_name
        try:
            result = subprocess.run(
                [self._runtime_path, "start", name],
                capture_output=True,
                text=True,
                timeout=30,
            )
            if result.returncode != 0:
                self._set_status(key, ServiceStatus.ERROR, error=result.stderr.strip())
                return {"success": False, "error": result.stderr.strip()}

            # Determine URL
            url = ""
            if spec.health_check_port:
                url = f"http://localhost:{spec.health_check_port}"

            state = self._get_or_create_state(key)
            state.status = ServiceStatus.RUNNING.value
            state.started_at = datetime.now(timezone.utc).isoformat()
            state.url = url
            state.error_message = ""
            self._save_state()

            return {"success": True, "message": f"{spec.display_name} started", "url": url}
        except subprocess.TimeoutExpired:
            self._set_status(key, ServiceStatus.ERROR, error="Start timed out")
            return {"success": False, "error": "Start timed out"}
        except Exception as e:
            self._set_status(key, ServiceStatus.ERROR, error=str(e))
            return {"success": False, "error": str(e)}

    def stop(self, key: str) -> Dict[str, Any]:
        """Stop a running service."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}"}

        if spec.service_type == ServiceType.NATIVE:
            return self.stop_native(key)

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime found"}

        name = spec.default_container_name
        try:
            result = subprocess.run(
                [self._runtime_path, "stop", name],
                capture_output=True,
                text=True,
                timeout=30,
            )
            if result.returncode != 0:
                self._set_status(key, ServiceStatus.ERROR, error=result.stderr.strip())
                return {"success": False, "error": result.stderr.strip()}

            state = self._get_or_create_state(key)
            state.status = ServiceStatus.STOPPED.value
            state.url = ""
            state.error_message = ""
            self._save_state()

            return {"success": True, "message": f"{spec.display_name} stopped"}
        except subprocess.TimeoutExpired:
            return {"success": False, "error": "Stop timed out"}
        except Exception as e:
            return {"success": False, "error": str(e)}

    def restart(self, key: str) -> Dict[str, Any]:
        """Restart a service."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}"}

        if spec.service_type == ServiceType.NATIVE:
            self.stop_native(key)
            return self.start_native(key)

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime found"}

        name = spec.default_container_name
        try:
            result = subprocess.run(
                [self._runtime_path, "restart", name],
                capture_output=True,
                text=True,
                timeout=30,
            )
            if result.returncode != 0:
                self._set_status(key, ServiceStatus.ERROR, error=result.stderr.strip())
                return {"success": False, "error": result.stderr.strip()}

            url = ""
            if spec.health_check_port:
                url = f"http://localhost:{spec.health_check_port}"

            state = self._get_or_create_state(key)
            state.status = ServiceStatus.RUNNING.value
            state.started_at = datetime.now(timezone.utc).isoformat()
            state.url = url
            state.error_message = ""
            self._save_state()

            return {"success": True, "message": f"{spec.display_name} restarted", "url": url}
        except subprocess.TimeoutExpired:
            return {"success": False, "error": "Restart timed out"}
        except Exception as e:
            return {"success": False, "error": str(e)}

    def remove(self, key: str, remove_data: bool = False) -> Dict[str, Any]:
        """Remove a service container and optionally its data."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}"}

        if spec.service_type == ServiceType.NATIVE:
            self.stop_native(key)
            self._set_status(key, ServiceStatus.NOT_INSTALLED)
            return {"success": True, "message": f"{spec.display_name} removed"}

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime found"}

        name = spec.default_container_name
        try:
            # Stop first
            subprocess.run(
                [self._runtime_path, "stop", name],
                capture_output=True,
                text=True,
                timeout=30,
            )
            # Remove container
            result = subprocess.run(
                [self._runtime_path, "rm", "-f", name],
                capture_output=True,
                text=True,
                timeout=30,
            )
            if result.returncode != 0:
                self._set_status(key, ServiceStatus.ERROR, error=result.stderr.strip())
                return {"success": False, "error": result.stderr.strip()}

            # Optionally remove data
            if remove_data:
                home = str(Path.home())
                for host_path in spec.get_resolved_volumes(home):
                    p = Path(host_path)
                    if p.exists():
                        shutil.rmtree(p, ignore_errors=True)

            self._set_status(key, ServiceStatus.NOT_INSTALLED)
            state = self._get_or_create_state(key)
            state.container_id = ""
            state.container_name = ""
            state.url = ""
            state.started_at = ""
            self._save_state()

            return {"success": True, "message": f"{spec.display_name} removed"}
        except subprocess.TimeoutExpired:
            return {"success": False, "error": "Remove timed out"}
        except Exception as e:
            return {"success": False, "error": str(e)}

    # ------------------------------------------------------------------ status

    def get_status(self, key: str) -> Dict[str, Any]:
        """Get live status of a service by inspecting its container."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"key": key, "status": "unknown", "error": f"Unknown service: {key}"}

        if spec.service_type == ServiceType.NATIVE:
            state = self._get_or_create_state(key)
            return {**state.to_dict(), **spec.to_dict()}

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            state = self._get_or_create_state(key)
            return {**state.to_dict(), **spec.to_dict()}

        name = spec.default_container_name
        try:
            result = subprocess.run(
                [self._runtime_path, "inspect", "--format", "json", name],
                capture_output=True,
                text=True,
                timeout=10,
            )
            if result.returncode != 0:
                # Container doesn't exist
                state = self._get_or_create_state(key)
                if state.status not in (ServiceStatus.PULLING.value, ServiceStatus.ERROR.value):
                    state.status = ServiceStatus.NOT_INSTALLED.value
                    self._save_state()
                return {**state.to_dict(), **spec.to_dict()}

            data = json.loads(result.stdout)
            if isinstance(data, list) and data:
                data = data[0]

            container_status = data.get("State", {}).get("Status", "unknown")
            state = self._get_or_create_state(key)

            if container_status == "running":
                state.status = ServiceStatus.RUNNING.value
                url = f"http://localhost:{spec.health_check_port}" if spec.health_check_port else ""
                state.url = url
            elif container_status in ("exited", "stopped", "created"):
                state.status = ServiceStatus.STOPPED.value
                state.url = ""
            else:
                state.status = ServiceStatus.ERROR.value

            state.container_id = data.get("Id", "")[:12]
            state.container_name = name
            state.error_message = ""
            self._save_state()

            return {**state.to_dict(), **spec.to_dict()}
        except (json.JSONDecodeError, KeyError, IndexError):
            state = self._get_or_create_state(key)
            return {**state.to_dict(), **spec.to_dict()}
        except Exception:
            state = self._get_or_create_state(key)
            return {**state.to_dict(), **spec.to_dict()}

    def get_all_statuses(self) -> List[Dict[str, Any]]:
        """Get status of all registered services."""
        return [self.get_status(key) for key in SERVICE_SPECS]

    def get_logs(self, key: str, tail: int = 100) -> Dict[str, Any]:
        """Get recent log lines for a container service."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"success": False, "error": f"Unknown service: {key}", "logs": ""}
        if spec.service_type == ServiceType.NATIVE:
            return {"success": True, "logs": "(Native service — check Familiar logs)"}

        rt = self.detect_runtime()
        if rt == ContainerRuntime.NONE:
            return {"success": False, "error": "No container runtime", "logs": ""}

        name = spec.default_container_name
        try:
            result = subprocess.run(
                [self._runtime_path, "logs", "--tail", str(tail), name],
                capture_output=True,
                text=True,
                timeout=10,
            )
            output = result.stdout or result.stderr or "(no logs)"
            return {"success": True, "logs": output}
        except subprocess.TimeoutExpired:
            return {"success": False, "error": "Log fetch timed out", "logs": ""}
        except Exception as e:
            return {"success": False, "error": str(e), "logs": ""}

    # ------------------------------------------------------------------ health

    def health_check(self, key: str) -> Dict[str, Any]:
        """HTTP health check against a service's health endpoint."""
        spec = SERVICE_SPECS.get(key)
        if not spec:
            return {"healthy": False, "error": f"Unknown service: {key}"}
        if not spec.health_check_port or not spec.health_check_path:
            return {"healthy": False, "error": "No health check configured"}

        try:
            import httpx

            url = f"http://localhost:{spec.health_check_port}{spec.health_check_path}"
            resp = httpx.get(url, timeout=5.0)
            return {"healthy": resp.status_code < 400, "status_code": resp.status_code, "url": url}
        except ImportError:
            # Fall back to stdlib
            import urllib.error
            import urllib.request

            url = f"http://localhost:{spec.health_check_port}{spec.health_check_path}"
            try:
                req = urllib.request.urlopen(url, timeout=5)
                return {"healthy": req.status < 400, "status_code": req.status, "url": url}
            except urllib.error.URLError as e:
                return {"healthy": False, "error": str(e), "url": url}
        except Exception as e:
            return {"healthy": False, "error": str(e)}

    # ------------------------------------------------------------------ native

    def start_native(self, key: str) -> Dict[str, Any]:
        """Start a native (in-process) service."""
        spec = SERVICE_SPECS.get(key)
        if not spec or spec.service_type != ServiceType.NATIVE:
            return {"success": False, "error": "Not a native service"}

        state = self._get_or_create_state(key)
        state.status = ServiceStatus.RUNNING.value
        state.started_at = datetime.now(timezone.utc).isoformat()
        state.error_message = ""
        self._save_state()
        return {"success": True, "message": f"{spec.display_name} started (native)"}

    def stop_native(self, key: str) -> Dict[str, Any]:
        """Stop a native service."""
        spec = SERVICE_SPECS.get(key)
        if not spec or spec.service_type != ServiceType.NATIVE:
            return {"success": False, "error": "Not a native service"}

        state = self._get_or_create_state(key)
        state.status = ServiceStatus.STOPPED.value
        state.url = ""
        state.error_message = ""
        self._save_state()
        return {"success": True, "message": f"{spec.display_name} stopped (native)"}

    # ------------------------------------------------------------------ state persistence

    def _get_or_create_state(self, key: str) -> ServiceState:
        if key not in self._states:
            self._states[key] = ServiceState(key=key)
        return self._states[key]

    def _set_status(
        self,
        key: str,
        status: ServiceStatus,
        error: str = "",
    ) -> None:
        state = self._get_or_create_state(key)
        state.status = status.value
        state.error_message = error
        self._save_state()

    def _load_state(self) -> None:
        if not STATE_FILE.exists():
            return
        try:
            with open(STATE_FILE) as f:
                data = json.load(f)
            for key, vals in data.items():
                self._states[key] = ServiceState(key=key, **{
                    k: v for k, v in vals.items() if k != "key"
                })
        except (json.JSONDecodeError, OSError, TypeError) as e:
            logger.warning("Failed to load service state: %s", e)

    def _save_state(self) -> None:
        with self._lock:
            try:
                SERVICES_DIR.mkdir(parents=True, exist_ok=True)
                data = {key: state.to_dict() for key, state in self._states.items()}
                with open(STATE_FILE, "w") as f:
                    json.dump(data, f, indent=2)
            except OSError as e:
                logger.warning("Failed to save service state: %s", e)


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

_service_manager: Optional[ServiceManager] = None
_manager_lock = threading.Lock()


def get_service_manager() -> ServiceManager:
    """Get the singleton service manager."""
    global _service_manager
    if _service_manager is None:
        with _manager_lock:
            if _service_manager is None:
                _service_manager = ServiceManager()
    return _service_manager
