"""
Brynq Runtime Monitor -- htop-style live terminal dashboard.

A four-panel live-refreshing view of the Brynq runtime:

  1. MODELS         Detected models (claude, gemini, ollama:*) with status
  2. RECENT CHAINS  Last 10 orchestration decisions from ~/.brynq/decisions.jsonl
  3. RUNTIME STATUS Runtime server, Ollama, and cloud API health
  4. LIVE ACTIVITY  Last 5 chat messages from ~/.brynq/memory/last_session.json

Usage (standalone):
    python -m runtime.monitor

Usage (from CLI):
    from runtime.monitor import run_monitor
    run_monitor(config, interval=2.0)

No external dependencies -- raw ANSI escape codes and stdlib only.
"""

from __future__ import annotations

import json
import os
import shutil
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from runtime.config import RuntimeConfig, load_config

# -- ANSI escape codes --------------------------------------------------------

CLEAR = "\033[2J\033[H"
HIDE_CURSOR = "\033[?25l"
SHOW_CURSOR = "\033[?25h"
RESET = "\033[0m"
BOLD = "\033[1m"
DIM = "\033[2m"
BLUE = "\033[34m"
CYAN = "\033[36m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RED = "\033[31m"
MAGENTA = "\033[35m"
WHITE = "\033[37m"
BG_BLUE = "\033[44m"
BG_PANEL = "\033[48;5;236m"

_NO_COLOR = os.environ.get("NO_COLOR", "")


def _supports_color() -> bool:
    """Detect whether the terminal supports ANSI color."""
    if _NO_COLOR:
        return False
    if sys.platform == "win32":
        return os.environ.get("WT_SESSION") is not None or "ANSICON" in os.environ
    return hasattr(sys.stdout, "isatty") and sys.stdout.isatty()


_COLOR = _supports_color()


def _c(code: str, text: str) -> str:
    """Apply an ANSI code to text if color is supported."""
    return f"{code}{text}{RESET}" if _COLOR else text


# -- Data helpers --------------------------------------------------------------


def _format_age(ts_str: str) -> str:
    """Turn an ISO-8601 timestamp into a human-readable age string."""
    try:
        ts = datetime.fromisoformat(ts_str)
        if ts.tzinfo is None:
            ts = ts.replace(tzinfo=timezone.utc)
        diff = int((datetime.now(timezone.utc) - ts).total_seconds())
        if diff < 0:
            return "just now"
        if diff < 60:
            return f"{diff}s ago"
        if diff < 3600:
            return f"{diff // 60}m ago"
        if diff < 86400:
            return f"{diff // 3600}h ago"
        return f"{diff // 86400}d ago"
    except Exception:
        return "unknown"


def _truncate(text: str, width: int) -> str:
    """Truncate text to *width* characters, adding ellipsis if needed."""
    text = text.replace("\n", " ").replace("\r", "")
    if len(text) <= width:
        return text
    return text[: width - 3] + "..."


# -- Probes --------------------------------------------------------------------


def _probe_runtime(port: int) -> tuple[bool, dict[str, Any]]:
    """GET /status on the runtime server.  Returns (reachable, data)."""
    try:
        url = f"http://127.0.0.1:{port}/status"
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            return data.get("status") == "ok", data
    except Exception:
        return False, {}


def _probe_ollama(ollama_url: str) -> tuple[bool, list[str]]:
    """Check Ollama and return (reachable, [model_names])."""
    try:
        req = urllib.request.Request(f"{ollama_url}/api/tags", method="GET")
        with urllib.request.urlopen(req, timeout=3.0) as resp:
            data = json.loads(resp.read().decode("utf-8"))
            names = [m["name"].split(":")[0] for m in data.get("models", [])]
            return True, names
    except Exception:
        return False, []


def _probe_cloud(cloud_url: str) -> bool:
    """Quick reachability check against the cloud API."""
    try:
        url = f"{cloud_url}/health"
        req = urllib.request.Request(url, method="GET")
        with urllib.request.urlopen(req, timeout=5.0):
            return True
    except Exception:
        return False


def _detect_cli(name: str) -> bool:
    """Return True if a CLI tool (claude, gemini) is on PATH."""
    return shutil.which(name) is not None


def _read_keys(data_dir: Path) -> dict[str, str]:
    """Read stored BYOK API keys."""
    path = data_dir / "keys.json"
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


def _read_tokens(data_dir: Path) -> dict[str, Any]:
    """Read stored OAuth tokens."""
    path = data_dir / "oauth_tokens.json"
    if not path.exists():
        return {}
    try:
        return json.loads(path.read_text("utf-8"))
    except (json.JSONDecodeError, OSError):
        return {}


# -- Panel data collectors -----------------------------------------------------


def _collect_models(config: RuntimeConfig) -> list[tuple[str, str, str]]:
    """Return [(model_name, status_label, color_code), ...]."""
    models: list[tuple[str, str, str]] = []

    # Claude CLI
    if _detect_cli("claude"):
        models.append(("claude", "available", GREEN))
    else:
        keys = _read_keys(config.data_dir)
        tokens = _read_tokens(config.data_dir)
        if "anthropic" in keys or "anthropic" in tokens:
            models.append(("claude", "key only", YELLOW))
        else:
            models.append(("claude", "unavailable", RED))

    # Gemini CLI
    if _detect_cli("gemini"):
        models.append(("gemini", "available", GREEN))
    else:
        keys = _read_keys(config.data_dir)
        tokens = _read_tokens(config.data_dir)
        if "google" in keys or "google" in tokens:
            models.append(("gemini", "key only", YELLOW))
        else:
            models.append(("gemini", "unavailable", RED))

    # Ollama models
    ollama_ok, ollama_models = _probe_ollama(config.ollama_url)
    if ollama_ok and ollama_models:
        for m in ollama_models:
            models.append((f"ollama:{m}", "available", GREEN))
    elif ollama_ok:
        models.append(("ollama", "no models", YELLOW))
    else:
        models.append(("ollama", "unavailable", RED))

    # OpenAI / ChatGPT (BYOK only)
    keys = _read_keys(config.data_dir)
    tokens = _read_tokens(config.data_dir)
    if "openai" in keys or "openai" in tokens:
        models.append(("chatgpt", "key only", YELLOW))

    return models


def _collect_decisions(data_dir: Path, limit: int = 10) -> list[dict[str, Any]]:
    """Read the last *limit* entries from ~/.brynq/decisions.jsonl."""
    path = data_dir / "decisions.jsonl"
    if not path.exists():
        return []
    try:
        lines = path.read_text("utf-8").strip().splitlines()
        entries: list[dict[str, Any]] = []
        for line in lines[-limit:]:
            try:
                entries.append(json.loads(line))
            except json.JSONDecodeError:
                continue
        return entries
    except OSError:
        return []


def _collect_session(data_dir: Path, limit: int = 5) -> list[dict[str, str]]:
    """Read the last *limit* messages from ~/.brynq/memory/last_session.json."""
    path = data_dir / "memory" / "last_session.json"
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text("utf-8"))
        if isinstance(data, list):
            return data[-limit:]
        return []
    except (json.JSONDecodeError, OSError):
        return []


# -- Renderer ------------------------------------------------------------------


def _render(config: RuntimeConfig) -> None:
    """Build the full dashboard frame and write it to stdout."""
    cols = shutil.get_terminal_size((80, 24)).columns

    buf: list[str] = []

    def _line(text: str = "") -> None:
        buf.append(text)

    def _bar(char: str = "-", width: int | None = None) -> str:
        w = width or cols
        return _c(DIM, char * w)

    def _header(title: str) -> None:
        _line(f"  {_c(CYAN, title)}")
        _line(f"  {_bar()}")

    # -- Title bar --
    title = " BRYNQ MONITOR "
    pad_l = max(0, (cols - len(title)) // 2)
    pad_r = max(0, cols - pad_l - len(title))
    _line(_c(f"{BG_BLUE}{WHITE}{BOLD}", f"{'=' * pad_l}{title}{'=' * pad_r}"))
    _line("")

    # -- Panel 1: MODELS -------------------------------------------------------
    models = _collect_models(config)
    active_count = sum(1 for _, st, _ in models if st == "available")
    _header(f"MODELS ({active_count}/{len(models)} available)")
    col_hdr = f"{'NAME':<24} {'STATUS':<14}"
    _line(f"    {_c(DIM, col_hdr)}")
    for name, status, color in models:
        status_str = _c(color, status) if _COLOR else status
        _line(f"    {name:<24} {status_str}")
    _line("")

    # -- Panel 2: RECENT CHAINS ------------------------------------------------
    decisions = _collect_decisions(config.data_dir, limit=10)
    _header(f"RECENT CHAINS ({len(decisions)} shown)")
    if decisions:
        chain_hdr = f"{'TIME':<14} {'STRATEGY':<14} {'MODELS'}"
        _line(f"    {_c(DIM, chain_hdr)}")
        for d in reversed(decisions):
            ts = d.get("timestamp", d.get("ts", ""))
            age = _format_age(ts) if ts else "?"
            strategy = d.get("strategy", d.get("type", "?"))
            used = d.get("models", d.get("models_used", []))
            if isinstance(used, list):
                model_str = ", ".join(used)
            else:
                model_str = str(used)
            model_str = _truncate(model_str, max(cols - 36, 20))
            _line(f"    {age:<14} {strategy:<14} {_c(DIM, model_str)}")
    else:
        _line(f"    {_c(DIM, 'No decisions logged yet.')}")
        decisions_path = str(config.data_dir / "decisions.jsonl")
        _line(f"    {_c(DIM, 'Expecting: ' + decisions_path)}")
    _line("")

    # -- Panel 3: RUNTIME STATUS ------------------------------------------------
    runtime_ok, runtime_data = _probe_runtime(config.local_port)
    ollama_ok, ollama_models = _probe_ollama(config.ollama_url)
    cloud_ok = _probe_cloud(config.cloud_url)

    _header("RUNTIME STATUS")

    # Runtime server
    if runtime_ok:
        _line(f"    {'Runtime':<16} {_c(GREEN, 'running'):<28} port {config.local_port}")
    else:
        _line(f"    {'Runtime':<16} {_c(RED, 'stopped'):<28} port {config.local_port}")

    # Ollama
    if ollama_ok:
        model_count = f"{len(ollama_models)} model{'s' if len(ollama_models) != 1 else ''}"
        _line(f"    {'Ollama':<16} {_c(GREEN, 'running'):<28} {model_count}")
    else:
        _line(f"    {'Ollama':<16} {_c(RED, 'stopped'):<28} {config.ollama_url}")

    # Cloud API
    if cloud_ok:
        _line(f"    {'Cloud API':<16} {_c(GREEN, 'reachable'):<28} {config.cloud_url}")
    else:
        _line(f"    {'Cloud API':<16} {_c(YELLOW, 'unreachable'):<28} {config.cloud_url}")

    # Keys summary
    keys = _read_keys(config.data_dir)
    tokens = _read_tokens(config.data_dir)
    key_providers = sorted(set(list(keys.keys()) + list(tokens.keys())))
    if key_providers:
        _line(f"    {'Credentials':<16} {_c(GREEN, ', '.join(key_providers))}")
    else:
        _line(f"    {'Credentials':<16} {_c(DIM, 'none configured')}")
    _line("")

    # -- Panel 4: LIVE ACTIVITY -------------------------------------------------
    messages = _collect_session(config.data_dir, limit=5)
    _header(f"LIVE ACTIVITY (last {len(messages)} messages)")
    if messages:
        for msg in messages:
            role = msg.get("role", "?")
            content = msg.get("content", "")
            preview = _truncate(content, max(cols - 20, 30))
            if role == "user":
                tag = _c(f"{BOLD}{WHITE}", "YOU")
            else:
                tag = _c(f"{BOLD}{MAGENTA}", "AI ")
            _line(f"    {tag}  {_c(DIM, preview)}")
    else:
        _line(f"    {_c(DIM, 'No session activity yet.')}")
        _line(f"    {_c(DIM, 'Start a chat:  brynq-runtime chat')}")
    _line("")

    # -- Footer -----------------------------------------------------------------
    now = datetime.now().strftime("%H:%M:%S")
    _line(_bar("="))
    _line(f"  {_c(DIM, f'Last refresh: {now}  |  Ctrl+C to exit  |  Refreshing every 2s')}")

    # Flush the entire frame at once to reduce flicker
    sys.stdout.write(CLEAR)
    sys.stdout.write("\n".join(buf))
    sys.stdout.write("\n")
    sys.stdout.flush()


# -- Entry point ---------------------------------------------------------------


def run_monitor(config: RuntimeConfig, interval: float = 2.0) -> int:
    """Run the live monitor loop.

    Args:
        config:   RuntimeConfig with data_dir, ports, URLs.
        interval: Seconds between refreshes (default 2.0).

    Returns:
        Exit code (always 0 on clean exit).
    """
    sys.stdout.write(HIDE_CURSOR)
    try:
        while True:
            _render(config)
            time.sleep(interval)
    except KeyboardInterrupt:
        pass
    finally:
        sys.stdout.write(SHOW_CURSOR)
        sys.stdout.write("\n")
    return 0


# -- Standalone ----------------------------------------------------------------


def main() -> None:
    """Standalone entry point: ``python -m runtime.monitor``."""
    config = load_config()
    raise SystemExit(run_monitor(config))


if __name__ == "__main__":
    main()
