"""
flowly doctor — configuration and runtime health checker.

Architecture mirrors OpenClaw's doctor system:
  - Each check is an isolated function returning a DoctorResult
  - DoctorRunner collects results, prints a report, optionally auto-repairs
  - --fix flag auto-approves all repairs (same as openclaw doctor --fix)
"""

from __future__ import annotations

import json
import os
import platform
import subprocess
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any

from rich.console import Console

console = Console()


# ---------------------------------------------------------------------------
# Result types
# ---------------------------------------------------------------------------

class Status(str, Enum):
    OK      = "ok"
    WARN    = "warn"
    ERROR   = "error"
    FIXED   = "fixed"
    SKIPPED = "skipped"


@dataclass
class DoctorResult:
    name: str
    status: Status
    message: str
    detail: str = ""
    fixable: bool = False   # can be auto-repaired with --fix


@dataclass
class DoctorContext:
    fix: bool = False                       # --fix flag
    results: list[DoctorResult] = field(default_factory=list)
    config_path: Path = field(default_factory=lambda: Path.home() / ".flowly" / "config.json")
    data_dir: Path = field(default_factory=lambda: Path.home() / ".flowly")
    raw_config: dict[str, Any] | None = None   # parsed JSON, set by config check

    def record(self, result: DoctorResult) -> None:
        self.results.append(result)

    def ok(self, name: str, message: str) -> None:
        self.record(DoctorResult(name, Status.OK, message))

    def warn(self, name: str, message: str, detail: str = "", fixable: bool = False) -> None:
        self.record(DoctorResult(name, Status.WARN, message, detail, fixable))

    def error(self, name: str, message: str, detail: str = "", fixable: bool = False) -> None:
        self.record(DoctorResult(name, Status.ERROR, message, detail, fixable))

    def fixed(self, name: str, message: str) -> None:
        self.record(DoctorResult(name, Status.FIXED, message))

    def skipped(self, name: str, message: str) -> None:
        self.record(DoctorResult(name, Status.SKIPPED, message))


# ---------------------------------------------------------------------------
# Individual checks
# ---------------------------------------------------------------------------

def check_state_directory(ctx: DoctorContext) -> None:
    """~/.flowly/ must exist and be readable/writable with correct permissions."""
    d = ctx.data_dir

    if not d.exists():
        if ctx.fix:
            d.mkdir(parents=True, mode=0o700)
            ctx.fixed("state_dir", f"Created {d}")
        else:
            ctx.error("state_dir", f"{d} does not exist", fixable=True)
        return

    if not os.access(d, os.W_OK):
        ctx.error("state_dir", f"{d} is not writable")
        return

    ctx.ok("state_dir", str(d))


def check_config_file(ctx: DoctorContext) -> None:
    """Config file must exist, be valid JSON, and have correct permissions."""
    path = ctx.config_path

    if not path.exists():
        if ctx.fix:
            # Bootstrap minimal config
            from flowly.config.loader import save_config
            from flowly.config.schema import Config
            save_config(Config(), path)
            ctx.fixed("config_file", f"Created default config at {path}")
        else:
            ctx.error("config_file", f"{path} does not exist", fixable=True)
        return

    # Validate JSON
    try:
        with open(path, encoding="utf-8") as f:
            raw = json.load(f)
        ctx.raw_config = raw
    except json.JSONDecodeError as e:
        ctx.error("config_file", f"Config is invalid JSON: {e}")
        return

    ctx.ok("config_file", str(path))


def check_config_validity(ctx: DoctorContext) -> None:
    """Config must parse cleanly against the Pydantic schema."""
    if ctx.raw_config is None:
        ctx.skipped("config_validity", "Skipped (config file check failed)")
        return

    try:
        from flowly.config.loader import convert_keys
        from flowly.config.schema import Config
        Config.model_validate(convert_keys(ctx.raw_config))
        ctx.ok("config_validity", "Config schema valid")
    except Exception as e:
        ctx.error("config_validity", f"Config schema validation failed: {e}")


def check_duplicate_keys(ctx: DoctorContext) -> None:
    """Detect camelCase/snake_case duplicate keys (e.g. apiKey + api_key).

    When both forms exist in the same object Python's json.load keeps the last
    one, so the earlier value silently wins — a hard-to-debug bug.
    """
    if ctx.raw_config is None:
        ctx.skipped("duplicate_keys", "Skipped (config not loaded)")
        return

    from flowly.config.loader import camel_to_snake

    def find_dupes(obj: Any, path: str = "") -> list[str]:
        if not isinstance(obj, dict):
            return []
        seen: dict[str, str] = {}   # snake_key → original key
        dupes = []
        for k in obj:
            norm = camel_to_snake(k)
            if norm in seen:
                dupes.append(f"{path}.{seen[norm]} + {path}.{k}" if path else f"{seen[norm]} + {k}")
            else:
                seen[norm] = k
        for k, v in obj.items():
            dupes.extend(find_dupes(v, f"{path}.{k}" if path else k))
        return dupes

    dupes = find_dupes(ctx.raw_config)
    if dupes:
        detail = "\n".join(f"  • {d}" for d in dupes)
        if ctx.fix:
            _remove_duplicate_keys(ctx.config_path)
            ctx.fixed("duplicate_keys", f"Removed duplicate keys:\n{detail}")
        else:
            ctx.error(
                "duplicate_keys",
                f"Found {len(dupes)} duplicate key(s) — last value silently wins",
                detail,
                fixable=True,
            )
    else:
        ctx.ok("duplicate_keys", "No duplicate camelCase/snake_case keys")


def _remove_duplicate_keys(config_path: Path) -> None:
    """Keep camelCase version of each key, remove snake_case duplicates."""
    import secrets
    from flowly.config.loader import camel_to_snake

    def dedup(obj: Any) -> Any:
        if not isinstance(obj, dict):
            return obj
        seen: dict[str, str] = {}   # snake_norm → first key found
        result = {}
        for k, v in obj.items():
            norm = camel_to_snake(k)
            if norm not in seen:
                seen[norm] = k
                result[k] = dedup(v)
            # else: drop duplicate
        return result

    with open(config_path, encoding="utf-8") as f:
        raw = json.load(f)

    deduped = dedup(raw)
    tmp = config_path.with_suffix(f".tmp.{secrets.token_hex(4)}")
    tmp.write_text(json.dumps(deduped, indent=4), encoding="utf-8")
    os.replace(str(tmp), str(config_path))


def check_api_keys(ctx: DoctorContext) -> None:
    """At least one LLM provider API key must be configured."""
    if ctx.raw_config is None:
        ctx.skipped("api_keys", "Skipped (config not loaded)")
        return

    providers = ctx.raw_config.get("providers", {})

    def key_set(section: str) -> bool:
        return bool(providers.get(section, {}).get("apiKey") or providers.get(section, {}).get("api_key"))

    def base_set(section: str) -> bool:
        return bool(
            providers.get(section, {}).get("apiBase")
            or providers.get(section, {}).get("api_base")
        )

    has_key = (
        key_set("anthropic")
        or key_set("openai")
        or key_set("openrouter")
        or key_set("gemini")
        or key_set("groq")
        or key_set("xai")
        or key_set("zhipu")
        or base_set("vllm")
    )

    if has_key:
        ctx.ok("api_keys", "At least one provider API key is set")
    else:
        ctx.warn(
            "api_keys",
            "No LLM provider API key configured — agent will fail to respond",
            "Set openrouter.apiKey (or anthropic/openai/etc.) in ~/.flowly/config.json",
        )


def check_model(ctx: DoctorContext) -> None:
    """Model field must be non-empty and follow provider/model format."""
    if ctx.raw_config is None:
        ctx.skipped("model", "Skipped (config not loaded)")
        return

    model: str = (
        ctx.raw_config.get("agents", {})
        .get("defaults", {})
        .get("model", "")
    )

    if not model:
        ctx.error("model", "agents.defaults.model is empty — agent cannot start")
        return

    if "/" not in model:
        ctx.warn(
            "model",
            f"Model '{model}' has no provider prefix",
            "Expected format: provider/model-name (e.g. openrouter/anthropic/claude-haiku-4.5)",
        )
        return

    ctx.ok("model", model)


def check_workspace(ctx: DoctorContext) -> None:
    """Workspace directory must exist and be writable."""
    if ctx.raw_config is None:
        ctx.skipped("workspace", "Skipped (config not loaded)")
        return

    workspace_raw: str = (
        ctx.raw_config.get("agents", {})
        .get("defaults", {})
        .get("workspace", "~/.flowly/workspace")
    )
    workspace = Path(workspace_raw).expanduser().resolve()

    if not workspace.exists():
        if ctx.fix:
            workspace.mkdir(parents=True, exist_ok=True)
            (workspace / "memory").mkdir(exist_ok=True)
            (workspace / "skills").mkdir(exist_ok=True)
            (workspace / "personas").mkdir(exist_ok=True)
            ctx.fixed("workspace", f"Created workspace at {workspace}")
        else:
            ctx.error("workspace", f"Workspace {workspace} does not exist", fixable=True)
        return

    if not os.access(workspace, os.W_OK):
        ctx.error("workspace", f"Workspace {workspace} is not writable")
        return

    # Ensure subdirectories exist
    missing = [d for d in ("memory", "skills", "personas") if not (workspace / d).exists()]
    if missing:
        if ctx.fix:
            for d in missing:
                (workspace / d).mkdir(exist_ok=True)
            ctx.fixed("workspace", f"Created missing subdirs: {', '.join(missing)}")
        else:
            ctx.warn(
                "workspace",
                f"Missing subdirectories: {', '.join(missing)}",
                fixable=True,
            )
        return

    ctx.ok("workspace", str(workspace))


def check_gateway(ctx: DoctorContext) -> None:
    """Gateway should be reachable on its configured port."""
    port = 18790
    if ctx.raw_config:
        port = ctx.raw_config.get("gateway", {}).get("port", 18790)

    import urllib.request
    import urllib.error
    try:
        req = urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=2)
        body = json.loads(req.read().decode())
        status_str = body.get("status", "?")
        ctx.ok("gateway", f"Running on port {port} — status: {status_str}")
    except (urllib.error.URLError, OSError):
        ctx.warn("gateway", f"Gateway not responding on port {port}", "Run: flowly service start")


def check_service_installation(ctx: DoctorContext) -> None:
    """Background service should be installed."""
    system = platform.system().lower()

    if system == "darwin":
        plist = Path.home() / "Library" / "LaunchAgents" / "ai.flowly.gateway.plist"
        if plist.exists():
            ctx.ok("service_install", f"LaunchAgent installed: {plist.name}")
        else:
            ctx.warn(
                "service_install",
                "LaunchAgent not installed — gateway won't auto-start on login",
                "Run: flowly service install",
            )

    elif system == "linux":
        unit = Path.home() / ".config" / "systemd" / "user" / "ai.flowly.gateway.service"
        if unit.exists():
            ctx.ok("service_install", f"systemd unit installed: {unit.name}")
        else:
            ctx.warn(
                "service_install",
                "systemd unit not installed — gateway won't auto-start",
                "Run: flowly service install",
            )

    elif system == "windows":
        ctx.skipped("service_install", "Windows service check not implemented")

    else:
        ctx.skipped("service_install", f"Unsupported platform: {platform.system()}")


def check_service_executable(ctx: DoctorContext) -> None:
    """Service definition must point to a valid flowly executable."""
    system = platform.system().lower()

    if system == "darwin":
        plist = Path.home() / "Library" / "LaunchAgents" / "ai.flowly.gateway.plist"
        if not plist.exists():
            ctx.skipped("service_exec", "No LaunchAgent found")
            return
        try:
            import plistlib
            data = plistlib.loads(plist.read_bytes())
            args: list[str] = data.get("ProgramArguments", [])
            exe = args[0] if args else ""
            exe_path = Path(exe)
            if not exe_path.exists():
                ctx.error(
                    "service_exec",
                    f"Service executable not found: {exe}",
                    "Run: flowly service install  (reinstall to update path)",
                )
                return
            ctx.ok("service_exec", f"Executable OK: {exe}")
        except Exception as e:
            ctx.warn("service_exec", f"Could not read plist: {e}")

    elif system == "linux":
        unit = Path.home() / ".config" / "systemd" / "user" / "ai.flowly.gateway.service"
        if not unit.exists():
            ctx.skipped("service_exec", "No systemd unit found")
            return
        content = unit.read_text(encoding="utf-8")
        for line in content.splitlines():
            if line.startswith("ExecStart="):
                exe = line.split("=", 1)[1].strip().split()[0]
                if not Path(exe).exists():
                    ctx.error(
                        "service_exec",
                        f"Service executable not found: {exe}",
                        "Run: flowly service install",
                    )
                    return
                ctx.ok("service_exec", f"Executable OK: {exe}")
                return
        ctx.warn("service_exec", "Could not find ExecStart in systemd unit")

    else:
        ctx.skipped("service_exec", f"Unsupported platform: {platform.system()}")


def check_channel_config(ctx: DoctorContext) -> None:
    """At least one channel should be enabled."""
    if ctx.raw_config is None:
        ctx.skipped("channels", "Skipped (config not loaded)")
        return

    channels = ctx.raw_config.get("channels", {})
    enabled = [name for name, cfg in channels.items() if isinstance(cfg, dict) and cfg.get("enabled")]

    if enabled:
        ctx.ok("channels", f"Enabled: {', '.join(enabled)}")
    else:
        ctx.warn(
            "channels",
            "No channels enabled — bot won't receive messages from any platform",
            "Enable at least one channel in ~/.flowly/config.json or via flowly setup",
        )


def check_unknown_config_keys(ctx: DoctorContext) -> None:
    """Warn about top-level config keys that Flowly doesn't recognise."""
    if ctx.raw_config is None:
        ctx.skipped("unknown_keys", "Skipped (config not loaded)")
        return

    from flowly.config.loader import camel_to_snake
    from flowly.config.schema import Config

    known_snake = set(Config.model_fields.keys())
    unknown = []
    for k in ctx.raw_config:
        if camel_to_snake(k) not in known_snake:
            unknown.append(k)

    if unknown:
        ctx.warn(
            "unknown_keys",
            f"{len(unknown)} unrecognised top-level key(s): {', '.join(unknown)}",
            "These keys are preserved but ignored by Flowly — check for typos",
        )
    else:
        ctx.ok("unknown_keys", "All config keys recognised")


def check_gateway_security(ctx: DoctorContext) -> None:
    """Gateway with public binding should have auth token configured.

    A gateway bound to 0.0.0.0 without an auth token is openly accessible
    to anyone on the same network — similar to OpenClaw's security audit.
    """
    if ctx.raw_config is None:
        ctx.skipped("gateway_security", "Skipped (config not loaded)")
        return

    gw = ctx.raw_config.get("gateway", {})
    host: str = gw.get("host", "127.0.0.1")
    token: str = gw.get("token", "") or gw.get("auth_token", "") or gw.get("authToken", "")

    public_binding = host not in ("127.0.0.1", "localhost", "::1")

    if public_binding and not token:
        ctx.error(
            "gateway_security",
            f"Gateway bound to {host} without an auth token — OPEN TO NETWORK",
            "Set gateway.token in config.json or restrict host to 127.0.0.1",
        )
    elif public_binding and token:
        ctx.warn(
            "gateway_security",
            f"Gateway bound to {host} (public) — ensure firewall rules are in place",
        )
    else:
        ctx.ok("gateway_security", f"Gateway binding: {host} (localhost only)")


def check_memory_system(ctx: DoctorContext) -> None:
    """Check that the workspace memory directory and MEMORY.md index exist."""
    if ctx.raw_config is None:
        ctx.skipped("memory", "Skipped (config not loaded)")
        return

    workspace_raw: str = (
        ctx.raw_config.get("agents", {})
        .get("defaults", {})
        .get("workspace", "~/.flowly/workspace")
    )
    workspace = Path(workspace_raw).expanduser().resolve()
    memory_dir = workspace / "memory"
    memory_index = memory_dir / "MEMORY.md"

    if not memory_dir.exists():
        if ctx.fix:
            memory_dir.mkdir(parents=True, exist_ok=True)
            ctx.fixed("memory", f"Created memory directory: {memory_dir}")
        else:
            ctx.warn("memory", f"Memory directory missing: {memory_dir}", fixable=True)
        return

    if not memory_index.exists():
        ctx.warn(
            "memory",
            "MEMORY.md index not found — agent will start without persistent memory",
            f"Expected: {memory_index}",
        )
        return

    ctx.ok("memory", f"Memory index present: {memory_index}")


def check_session_store(ctx: DoctorContext) -> None:
    """Session transcript files referenced in sessions.json should exist on disk."""
    sessions_file = ctx.data_dir / "sessions.json"
    if not sessions_file.exists():
        ctx.skipped("session_store", "No sessions.json found (no sessions yet)")
        return

    try:
        with open(sessions_file, encoding="utf-8") as f:
            sessions = json.load(f)
    except (json.JSONDecodeError, OSError) as e:
        ctx.warn("session_store", f"Could not parse sessions.json: {e}")
        return

    if not isinstance(sessions, list):
        ctx.skipped("session_store", "sessions.json format unrecognised")
        return

    missing = []
    for s in sessions:
        if isinstance(s, dict):
            transcript = s.get("transcript") or s.get("transcriptPath")
            if transcript and not Path(transcript).expanduser().exists():
                missing.append(str(transcript))

    if missing:
        ctx.warn(
            "session_store",
            f"{len(missing)} session transcript(s) missing from disk",
            "\n".join(f"  • {p}" for p in missing[:5]) + ("  …" if len(missing) > 5 else ""),
        )
    else:
        count = len(sessions)
        ctx.ok("session_store", f"{count} session record(s) — transcripts intact")


def check_linux_linger(ctx: DoctorContext) -> None:
    """On Linux, systemd linger should be enabled so the user service survives logout."""
    if platform.system().lower() != "linux":
        return  # silently skip on non-Linux

    unit = Path.home() / ".config" / "systemd" / "user" / "ai.flowly.gateway.service"
    if not unit.exists():
        ctx.skipped("linux_linger", "systemd unit not installed — skipping linger check")
        return

    user = os.environ.get("USER") or os.environ.get("LOGNAME", "")
    linger_file = Path(f"/var/lib/systemd/linger/{user}")
    if linger_file.exists():
        ctx.ok("linux_linger", f"systemd linger enabled for {user}")
    else:
        if ctx.fix:
            result = subprocess.run(
                ["loginctl", "enable-linger", user],
                capture_output=True,
                text=True,
            )
            if result.returncode == 0:
                ctx.fixed("linux_linger", f"Enabled systemd linger for {user}")
            else:
                ctx.warn("linux_linger", f"Could not enable linger: {result.stderr.strip()}")
        else:
            ctx.warn(
                "linux_linger",
                f"systemd linger not enabled — service stops on logout",
                f"Run: loginctl enable-linger {user}",
                fixable=True,
            )


# ---------------------------------------------------------------------------
# Runner
# ---------------------------------------------------------------------------

CHECKS = [
    check_state_directory,
    check_config_file,
    check_config_validity,
    check_duplicate_keys,
    check_unknown_config_keys,
    check_api_keys,
    check_model,
    check_workspace,
    check_memory_system,
    check_gateway_security,
    check_service_installation,
    check_service_executable,
    check_linux_linger,
    check_gateway,
    check_channel_config,
    check_session_store,
]


def run_doctor(fix: bool = False) -> int:
    """Run all checks. Returns exit code (0 = all ok/fixed, 1 = errors remain)."""
    ctx = DoctorContext(fix=fix)

    if fix:
        console.print("[bold cyan]flowly doctor --fix[/bold cyan]  (auto-repair mode)\n")
    else:
        console.print("[bold cyan]flowly doctor[/bold cyan]\n")

    for check in CHECKS:
        check(ctx)

    _print_report(ctx)

    errors = [r for r in ctx.results if r.status == Status.ERROR]
    return 1 if errors else 0


def _print_report(ctx: DoctorContext) -> None:
    STATUS_ICON = {
        Status.OK:      "[green]  ✓[/green]",
        Status.WARN:    "[yellow]  ⚠[/yellow]",
        Status.ERROR:   "[red]  ✗[/red]",
        Status.FIXED:   "[cyan]  ✦[/cyan]",
        Status.SKIPPED: "[dim]  -[/dim]",
    }

    for r in ctx.results:
        icon = STATUS_ICON[r.status]
        console.print(f"{icon}  [bold]{r.name}[/bold]  {r.message}")
        if r.detail:
            for line in r.detail.splitlines():
                console.print(f"      [dim]{line}[/dim]")
        if r.fixable and not ctx.fix and r.status in (Status.WARN, Status.ERROR):
            console.print("      [dim]→ run with --fix to auto-repair[/dim]")

    # Summary line
    counts = {s: sum(1 for r in ctx.results if r.status == s) for s in Status}
    parts = []
    if counts[Status.ERROR]:
        parts.append(f"[red]{counts[Status.ERROR]} error(s)[/red]")
    if counts[Status.WARN]:
        parts.append(f"[yellow]{counts[Status.WARN]} warning(s)[/yellow]")
    if counts[Status.FIXED]:
        parts.append(f"[cyan]{counts[Status.FIXED]} fixed[/cyan]")
    if counts[Status.OK]:
        parts.append(f"[green]{counts[Status.OK]} ok[/green]")

    console.print()
    console.print("  " + "  ·  ".join(parts) if parts else "  [green]All checks passed.[/green]")

    if not ctx.fix:
        fixable = [r for r in ctx.results if r.fixable and r.status in (Status.WARN, Status.ERROR)]
        if fixable:
            console.print(f"\n  [dim]Run [bold]flowly doctor --fix[/bold] to auto-repair {len(fixable)} issue(s)[/dim]")
