# 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

"""
Self-Management Skill

Gives the agent the ability to inspect and act on itself — not just advise.

The agent can observe its own state, propose configuration changes, apply
updates, and restart itself, always with user approval before any action
that is hard to reverse.

This is the bridge between "I suggest you change X" and "I changed X —
here's exactly what I did and why, and here's how to undo it."

Design principles:
  1. Read operations (show_self_status, list_config_checkpoints) — no
     confirmation needed, never destructive.
  2. Config changes — always show current → proposed diff, require
     confirmation, write a checkpoint before applying so rollback works.
  3. Updates — show what will change (pip dry-run), require confirmation.
  4. Restart — always require confirmation; never auto-restart without approval.
  5. Rollback — require confirmation; restore atomically from checkpoint.

All writes go through needs_confirmation() so every channel (dashboard,
Discord, Telegram, Android) gets the same approval UI without special casing.
"""

import importlib
import json
import logging
import os
import platform
import shlex
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional

import yaml

from familiar.core.confirmations import needs_confirmation
from familiar.core.paths import AGENT_DIR, CONFIG_FILE, DATA_DIR

logger = logging.getLogger(__name__)

# Checkpoints live here — one JSON file per saved config state
CHECKPOINTS_DIR = DATA_DIR / "config_checkpoints"

# Maximum checkpoints to keep before pruning oldest
MAX_CHECKPOINTS = 20

# Sections users are allowed to modify via this skill
# Anything not in this set is read-only (prevents breaking internal state)
_EDITABLE_SECTIONS = {
    "agent.name",
    "agent.persona",
    "agent.memory_enabled",
    "agent.skills_enabled",
    "agent.scheduler_enabled",
    "agent.heartbeat_interval_minutes",
    "agent.security_mode",
    "agent.allow_shell_commands",
    "agent.allow_file_write",
    "agent.max_iterations",
    "agent.guardrail_max_cost_day",
    "agent.guardrail_max_cost_hour",
    "llm.provider",
    "llm.model",
    "llm.temperature",
    "llm.max_tokens",
    "proactive.enabled",
    "proactive.morning_briefing",
    "proactive.evening_summary",
}


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _read_config_raw() -> dict:
    """Load raw config dict from disk (not parsed into dataclasses)."""
    if not CONFIG_FILE.exists():
        return {}
    with open(CONFIG_FILE) as f:
        return yaml.safe_load(f) or {}


def _write_config_raw(data: dict) -> None:
    """Write raw config dict back to disk."""
    CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
    with open(CONFIG_FILE, "w") as f:
        yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)


def _get_nested(data: dict, dotted_key: str) -> Any:
    """Get a value by dotted key (e.g. 'agent.name') from a nested dict."""
    parts = dotted_key.split(".")
    current = data
    for part in parts:
        if not isinstance(current, dict) or part not in current:
            return None
        current = current[part]
    return current


def _set_nested(data: dict, dotted_key: str, value: Any) -> dict:
    """Return a new dict with the dotted key set to value (does not mutate)."""
    import copy

    result = copy.deepcopy(data)
    parts = dotted_key.split(".")
    current = result
    for part in parts[:-1]:
        if part not in current or not isinstance(current[part], dict):
            current[part] = {}
        current = current[part]
    current[parts[-1]] = value
    return result


def _save_checkpoint(reason: str) -> str:
    """Save current config to a timestamped checkpoint. Returns checkpoint ID."""
    CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc)
    checkpoint_id = ts.strftime("%Y%m%d_%H%M%S")
    checkpoint_path = CHECKPOINTS_DIR / f"{checkpoint_id}.json"

    config_data = _read_config_raw()
    checkpoint = {
        "id": checkpoint_id,
        "reason": reason,
        "timestamp": ts.isoformat(),
        "config": config_data,
    }
    with open(checkpoint_path, "w") as f:
        json.dump(checkpoint, f, indent=2)

    # Prune oldest checkpoints
    all_checkpoints = sorted(CHECKPOINTS_DIR.glob("*.json"))
    if len(all_checkpoints) > MAX_CHECKPOINTS:
        for old in all_checkpoints[: len(all_checkpoints) - MAX_CHECKPOINTS]:
            old.unlink(missing_ok=True)

    return checkpoint_id


def _load_checkpoint(checkpoint_id: str) -> Optional[dict]:
    """Load a checkpoint by ID. Returns None if not found."""
    path = CHECKPOINTS_DIR / f"{checkpoint_id}.json"
    if not path.exists():
        return None
    with open(path) as f:
        return json.load(f)


def _get_uptime() -> str:
    """Return human-readable uptime string."""
    try:
        # Try to read /proc/uptime for system uptime (Linux)
        if Path("/proc/uptime").exists():
            with open("/proc/uptime") as f:
                seconds = float(f.read().split()[0])
            days = int(seconds // 86400)
            hours = int((seconds % 86400) // 3600)
            minutes = int((seconds % 3600) // 60)
            if days:
                return f"{days}d {hours}h {minutes}m"
            if hours:
                return f"{hours}h {minutes}m"
            return f"{minutes}m"
    except Exception:
        pass
    return "unknown"


def _pip_dry_run(package: str) -> tuple:
    """
    Run pip install --dry-run to preview what would change.
    Returns (would_change: bool, summary: str).
    """
    try:
        result = subprocess.run(
            [sys.executable, "-m", "pip", "install", "--dry-run", "--quiet", package],
            capture_output=True,
            text=True,
            timeout=60,
        )
        output = (result.stdout + result.stderr).strip()
        would_change = "Would install" in output or "Would upgrade" in output
        return would_change, output or "No changes needed — already up to date."
    except subprocess.TimeoutExpired:
        return False, "pip dry-run timed out"
    except Exception as e:
        return False, f"pip dry-run failed: {e}"


# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------


def show_self_status(data: dict) -> dict:
    """
    Show the agent's current state — version, uptime, loaded skills, config summary.

    Read-only. No confirmation needed.

    Args:
        (none required)

    Returns:
        Dict with version, uptime, platform, config_summary, loaded_skills,
        memory_usage_mb, config_file, data_dir.
    """
    status: Dict[str, Any] = {}

    # Version
    try:
        from familiar.core import __version__

        status["version"] = __version__
    except Exception:
        status["version"] = "unknown"

    # Platform
    status["python_version"] = platform.python_version()
    status["platform"] = platform.system()
    status["hostname"] = platform.node()
    status["uptime"] = _get_uptime()

    # Paths
    status["config_file"] = str(CONFIG_FILE)
    status["config_exists"] = CONFIG_FILE.exists()
    status["data_dir"] = str(DATA_DIR)

    # Config summary (key settings, no secrets)
    cfg = _read_config_raw()
    agent_cfg = cfg.get("agent", {})
    llm_cfg = cfg.get("llm", {})
    status["config_summary"] = {
        "agent_name": agent_cfg.get("name", "Familiar"),
        "security_mode": agent_cfg.get("security_mode", "balanced"),
        "shell_commands": agent_cfg.get("allow_shell_commands", False),
        "scheduler": agent_cfg.get("scheduler_enabled", True),
        "llm_provider": llm_cfg.get("provider", "ollama"),
        "llm_model": llm_cfg.get("model", ""),
    }

    # Loaded skills (scan skill directories)
    loaded_skills: List[str] = []
    try:
        from familiar.core.tools import get_tool_registry

        registry = get_tool_registry()
        loaded_skills = sorted(registry.list_tools())
    except Exception as e:
        logger.debug("Could not list tools: %s", e)

    status["loaded_skill_count"] = len(loaded_skills)
    status["loaded_skills"] = loaded_skills[:50]  # cap for readability
    if len(loaded_skills) > 50:
        status["loaded_skills_truncated"] = len(loaded_skills) - 50

    # Memory usage
    try:
        import resource

        usage = resource.getrusage(resource.RUSAGE_SELF)
        # maxrss is in KB on Linux, bytes on macOS
        rss_kb = usage.ru_maxrss if platform.system() == "Linux" else usage.ru_maxrss // 1024
        status["memory_usage_mb"] = round(rss_kb / 1024, 1)
    except Exception:
        status["memory_usage_mb"] = 0

    # Checkpoint count
    CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True)
    status["config_checkpoints"] = len(list(CHECKPOINTS_DIR.glob("*.json")))

    return status


def list_config_checkpoints(data: dict) -> dict:
    """
    List available config checkpoints for rollback.

    Read-only. No confirmation needed.

    Args:
        (none required)

    Returns:
        Dict with checkpoints list (id, timestamp, reason).
    """
    CHECKPOINTS_DIR.mkdir(parents=True, exist_ok=True)
    checkpoints = []
    for path in sorted(CHECKPOINTS_DIR.glob("*.json"), reverse=True):
        try:
            with open(path) as f:
                data_raw = json.load(f)
            checkpoints.append(
                {
                    "id": data_raw.get("id", path.stem),
                    "timestamp": data_raw.get("timestamp", ""),
                    "reason": data_raw.get("reason", ""),
                }
            )
        except Exception:
            continue
    return {"checkpoints": checkpoints, "count": len(checkpoints)}


def propose_config_change(data: dict):
    """
    Propose a change to the agent's configuration.

    Shows the current value and the proposed value with an explanation,
    then asks for confirmation before writing anything.

    Args:
        key: Dotted config key to change (e.g. 'agent.name', 'llm.model').
        value: New value for the key.
        reason: Why this change is needed (shown in the confirmation prompt).

    Returns:
        ConfirmationRequired if not yet confirmed.
        Dict with applied=True, checkpoint_id on confirmation.
    """
    key = (data.get("key") or "").strip()
    value = data.get("value")
    reason = (data.get("reason") or "").strip()

    if not key:
        return {"error": "key is required (e.g. 'agent.name' or 'llm.model')"}
    if value is None:
        return {"error": "value is required"}
    if not reason:
        return {"error": "reason is required — explain why you want this change"}

    # Guard: only allow editable sections
    if key not in _EDITABLE_SECTIONS:
        return {
            "error": (
                f"'{key}' is not in the list of agent-editable config keys. "
                f"Editable keys: {sorted(_EDITABLE_SECTIONS)}"
            )
        }

    # Read current value
    cfg = _read_config_raw()
    current = _get_nested(cfg, key)

    # If already confirmed, apply the change
    if data.get("_confirmed"):
        checkpoint_id = _save_checkpoint(f"before change: {key}")
        new_cfg = _set_nested(cfg, key, value)
        _write_config_raw(new_cfg)
        logger.info("Config change applied: %s = %r (checkpoint: %s)", key, value, checkpoint_id)
        return {
            "applied": True,
            "key": key,
            "old_value": current,
            "new_value": value,
            "checkpoint_id": checkpoint_id,
            "message": (
                f"Config updated: {key} changed from {current!r} to {value!r}. "
                f"Rollback checkpoint saved as '{checkpoint_id}'. "
                f"Restart the agent for changes to take effect."
            ),
        }

    # Build plain-language preview for novice users
    preview_lines = [
        f"Change config: {key}",
        f"  Current value : {current!r}",
        f"  New value     : {value!r}",
        f"  Reason        : {reason}",
        "",
        "A checkpoint of your current config will be saved first so you can",
        "roll back with: rollback_config(checkpoint_id='<id>')",
        "Restart the agent after applying for changes to take effect.",
    ]

    return needs_confirmation(
        tool_name="propose_config_change",
        tool_input=data,
        preview="\n".join(preview_lines),
        risk="medium",
    )


def rollback_config(data: dict):
    """
    Restore the agent's configuration from a saved checkpoint.

    Use list_config_checkpoints() to find the checkpoint ID you want.
    The current config is also checkpointed before rollback so you can
    always undo the undo.

    Args:
        checkpoint_id: ID of the checkpoint to restore (from list_config_checkpoints).

    Returns:
        ConfirmationRequired if not yet confirmed.
        Dict with restored=True on success.
    """
    checkpoint_id = (data.get("checkpoint_id") or "").strip()
    if not checkpoint_id:
        return {"error": "checkpoint_id is required. Use list_config_checkpoints() to see options."}

    checkpoint = _load_checkpoint(checkpoint_id)
    if not checkpoint:
        return {"error": f"Checkpoint '{checkpoint_id}' not found. Use list_config_checkpoints()."}

    if data.get("_confirmed"):
        # Save current as a safety checkpoint before restoring
        safety_id = _save_checkpoint(f"before rollback to {checkpoint_id}")
        _write_config_raw(checkpoint["config"])
        logger.info("Config rolled back to checkpoint %s (safety: %s)", checkpoint_id, safety_id)
        return {
            "restored": True,
            "checkpoint_id": checkpoint_id,
            "safety_checkpoint_id": safety_id,
            "checkpoint_timestamp": checkpoint.get("timestamp", ""),
            "checkpoint_reason": checkpoint.get("reason", ""),
            "message": (
                f"Config restored from checkpoint '{checkpoint_id}' "
                f"({checkpoint.get('timestamp', '')}). "
                f"Safety checkpoint of your previous config saved as '{safety_id}'. "
                f"Restart the agent for changes to take effect."
            ),
        }

    ts = checkpoint.get("timestamp", "unknown time")
    reason = checkpoint.get("reason", "")
    preview_lines = [
        f"Rollback config to checkpoint: {checkpoint_id}",
        f"  Saved at : {ts}",
        f"  Reason   : {reason}",
        "",
        "This will overwrite your current config.yaml.",
        "Your current config will be saved as a safety checkpoint first.",
        "Restart the agent after rollback for changes to take effect.",
    ]

    return needs_confirmation(
        tool_name="rollback_config",
        tool_input=data,
        preview="\n".join(preview_lines),
        risk="high",
    )


def apply_update(data: dict):
    """
    Update the agent software to the latest version from PyPI.

    First runs a dry-run to show exactly what would change, then asks for
    confirmation before actually installing anything.

    Args:
        channel: Package channel to update. Options:
            'stable'  — latest stable release (default)
            'all'     — familiar-agent[full] (all optional extras)
            Custom package spec also accepted (e.g. 'familiar-agent==1.15.0').

    Returns:
        ConfirmationRequired if not yet confirmed.
        Dict with updated=True and pip output on success.
    """
    channel = (data.get("channel") or "stable").strip().lower()

    # Map channel names to pip specs
    _channel_map = {
        "stable": "familiar-agent",
        "all": "familiar-agent[full]",
    }
    package = _channel_map.get(channel, channel)  # allow raw spec passthrough

    if data.get("_confirmed"):
        try:
            result = subprocess.run(
                [sys.executable, "-m", "pip", "install", "--upgrade", package],
                capture_output=True,
                text=True,
                timeout=300,
            )
            output = (result.stdout + result.stderr).strip()
            success = result.returncode == 0
            return {
                "updated": success,
                "package": package,
                "output": output[:4000],  # cap
                "message": (
                    f"Update {'succeeded' if success else 'failed'} for {package}. "
                    + ("Restart the agent to load the new version." if success else "")
                ),
            }
        except subprocess.TimeoutExpired:
            return {"updated": False, "error": "pip install timed out after 5 minutes"}
        except Exception as e:
            return {"updated": False, "error": str(e)}

    # Dry-run preview
    would_change, dry_run_output = _pip_dry_run(package)

    if not would_change:
        return {
            "up_to_date": True,
            "package": package,
            "message": f"{package} is already up to date. No update needed.",
            "dry_run_output": dry_run_output,
        }

    preview_lines = [
        f"Update package: {package}",
        "",
        "What pip would install/upgrade:",
        dry_run_output[:800],
        "",
        "After updating you will need to restart the agent for the new",
        "version to take effect.",
    ]

    return needs_confirmation(
        tool_name="apply_update",
        tool_input=data,
        preview="\n".join(preview_lines),
        risk="medium",
    )


def restart_agent(data: dict):
    """
    Gracefully restart the agent process.

    The restart method is detected automatically:
    - systemd service  (systemctl restart familiar)
    - supervisord      (supervisorctl restart familiar)
    - Docker container (container will restart via --restart unless-stopped)
    - Direct process   (os.execv — replaces current process)

    Always requires confirmation. The reason is logged for audit trail.

    Args:
        reason: Why the restart is needed (shown in confirmation and audit log).

    Returns:
        ConfirmationRequired if not yet confirmed.
        Dict with restarting=True and method used on confirmation.
    """
    reason = (data.get("reason") or "manual restart requested").strip()

    if data.get("_confirmed"):
        method = _detect_restart_method()
        logger.info("Agent restart initiated via %s. Reason: %s", method, reason)

        try:
            if method == "systemd":
                subprocess.Popen(
                    ["systemctl", "restart", "familiar"],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return {
                    "restarting": True,
                    "method": "systemd",
                    "message": "systemctl restart familiar — reconnect in a few seconds.",
                }

            elif method == "supervisord":
                subprocess.Popen(
                    ["supervisorctl", "restart", "familiar"],
                    stdout=subprocess.DEVNULL,
                    stderr=subprocess.DEVNULL,
                )
                return {
                    "restarting": True,
                    "method": "supervisord",
                    "message": "supervisorctl restart familiar — reconnect in a few seconds.",
                }

            else:
                # Direct process restart — schedule via thread so this
                # response can be returned before exec replaces the process.
                import threading
                import time

                def _deferred_restart():
                    time.sleep(0.5)
                    trampoline = (
                        "import time, os, sys; time.sleep(2); os.execv(sys.argv[1], sys.argv[1:])"
                    )
                    subprocess.Popen(
                        [sys.executable, "-c", trampoline, sys.executable] + sys.argv,
                        close_fds=True,
                        start_new_session=True,
                    )
                    os._exit(0)

                threading.Thread(target=_deferred_restart, daemon=True).start()
                return {
                    "restarting": True,
                    "method": "direct",
                    "message": "Restarting now — reconnect in a few seconds.",
                }

        except Exception as e:
            logger.error("Restart failed: %s", e)
            return {"restarting": False, "error": str(e)}

    # Detect method so the preview is honest about what will happen
    method = _detect_restart_method()

    preview_lines = [
        "Restart the Familiar agent",
        f"  Reason  : {reason}",
        f"  Method  : {method}",
        "",
        "The agent will be unavailable for a few seconds during restart.",
        "Active conversations will be resumed when it comes back online.",
        "Config changes only take effect after a restart.",
    ]

    return needs_confirmation(
        tool_name="restart_agent",
        tool_input=data,
        preview="\n".join(preview_lines),
        risk="medium",
    )


def _detect_restart_method() -> str:
    """Detect the best restart method for this environment."""
    # Check if running under systemd
    if shutil.which("systemctl"):
        try:
            result = subprocess.run(
                ["systemctl", "is-active", "familiar"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if result.stdout.strip() == "active":
                return "systemd"
        except Exception:
            pass

    # Check for supervisord
    if shutil.which("supervisorctl"):
        try:
            result = subprocess.run(
                ["supervisorctl", "status", "familiar"],
                capture_output=True,
                text=True,
                timeout=5,
            )
            if "RUNNING" in result.stdout:
                return "supervisord"
        except Exception:
            pass

    # Direct process restart
    return "direct (os.execv)"


# ---------------------------------------------------------------------------
# TOOLS registry
# ---------------------------------------------------------------------------

TOOLS = [
    {
        "name": "show_self_status",
        "description": (
            "Show the agent's current state: version, uptime, loaded skills, "
            "active config settings, and memory usage. "
            "Use this when the user asks 'what version are you?', "
            "'what skills do you have?', 'how are you configured?', "
            "or 'how much memory are you using?'. "
            "Read-only — no changes, no confirmation needed."
        ),
        "input_schema": {
            "type": "object",
            "properties": {},
            "required": [],
        },
        "handler": show_self_status,
        "category": "self_management",
    },
    {
        "name": "list_config_checkpoints",
        "description": (
            "List saved config checkpoints available for rollback. "
            "Each checkpoint records the full config state at a point in time. "
            "Use rollback_config(checkpoint_id=...) to restore one. "
            "Read-only — no changes, no confirmation needed."
        ),
        "input_schema": {
            "type": "object",
            "properties": {},
            "required": [],
        },
        "handler": list_config_checkpoints,
        "category": "self_management",
    },
    {
        "name": "propose_config_change",
        "description": (
            "Propose a change to the agent's configuration. "
            "Shows the current value, the proposed value, and the reason — "
            "then asks the user to confirm before writing anything. "
            "A checkpoint is saved automatically before applying so you can roll back. "
            "Use for: renaming the agent, changing the LLM model, adjusting security mode, "
            "enabling/disabling the scheduler, adjusting cost caps. "
            "Editable keys include: agent.name, agent.persona, agent.security_mode, "
            "agent.allow_shell_commands, llm.provider, llm.model, llm.temperature, "
            "proactive.enabled, and others. "
            "Restart the agent after applying for changes to take effect."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "key": {
                    "type": "string",
                    "description": (
                        "Dotted config key to change. Examples: "
                        "'agent.name', 'agent.persona', 'agent.security_mode', "
                        "'llm.model', 'llm.temperature', 'proactive.enabled'"
                    ),
                },
                "value": {
                    "description": "New value for the key (string, number, or boolean)",
                },
                "reason": {
                    "type": "string",
                    "description": "Plain-language explanation of why this change is needed",
                },
            },
            "required": ["key", "value", "reason"],
        },
        "handler": propose_config_change,
        "category": "self_management",
    },
    {
        "name": "rollback_config",
        "description": (
            "Restore the agent's config from a previously saved checkpoint. "
            "Use list_config_checkpoints() to find the checkpoint ID. "
            "Your current config is saved as a safety checkpoint before restoring, "
            "so you can always undo the rollback too. "
            "Restart the agent after rollback for changes to take effect. "
            "Requires user confirmation."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "checkpoint_id": {
                    "type": "string",
                    "description": "Checkpoint ID from list_config_checkpoints() (e.g. '20260312_143022')",
                },
            },
            "required": ["checkpoint_id"],
        },
        "handler": rollback_config,
        "category": "self_management",
    },
    {
        "name": "apply_update",
        "description": (
            "Update the agent to the latest version from PyPI. "
            "First runs a dry-run to show exactly what would be installed, "
            "then asks for confirmation before making any changes. "
            "Use channel='stable' for the latest release (default), "
            "or channel='all' to also update all optional extras. "
            "Restart the agent after updating. "
            "Requires user confirmation."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "channel": {
                    "type": "string",
                    "enum": ["stable", "all"],
                    "description": "Update channel: 'stable' (default) or 'all' (with all extras)",
                    "default": "stable",
                },
            },
            "required": [],
        },
        "handler": apply_update,
        "category": "self_management",
    },
    {
        "name": "restart_agent",
        "description": (
            "Restart the agent process. "
            "Automatically uses the right method for the environment: "
            "systemd, supervisord, or direct process restart. "
            "Required after config changes or updates to load new settings. "
            "The agent will be offline for a few seconds. "
            "Requires user confirmation."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "reason": {
                    "type": "string",
                    "description": "Why the restart is needed (shown in confirmation and audit log)",
                    "default": "applying configuration changes",
                },
            },
            "required": [],
        },
        "handler": restart_agent,
        "category": "self_management",
    },
]
