"""ASCII dashboard for live NexusOS service / tool / resource stats.

No curses, no rich — pure box-drawing + optional ANSI color so it works in
Termux, plain SSH, and Windows Terminal alike. The collector is separate from
the renderer so tests can feed fixtures without a running stack.
"""
from __future__ import annotations

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

from synapse.nexus_config import settings

from . import ncp as services

# Box drawing — ASCII fallbacks when the terminal can't do Unicode.
_BOX = {
    "tl": "┌", "tr": "┐", "bl": "└", "br": "┘",
    "h": "─", "v": "│", "l": "├", "r": "┤",
}
_BOX_ASCII = {
    "tl": "+", "tr": "+", "bl": "+", "br": "+",
    "h": "-", "v": "|", "l": "+", "r": "+",
}

_FILL = "█"
_EMPTY = "░"
_FILL_ASCII = "#"
_EMPTY_ASCII = "-"


def _use_unicode() -> bool:
    enc = (getattr(__import__("sys").stdout, "encoding", None) or "").lower()
    return "utf" in enc or enc in ("cp65001",)


def _http_ok(url: str, timeout: float = 1.0) -> bool:
    try:
        urllib.request.urlopen(url, timeout=timeout).read(1)
        return True
    except urllib.error.HTTPError:
        return True
    except Exception:
        return False


def _provider_payload() -> dict:
    url = settings.ollama_host.rstrip("/")
    return {
        "provider": settings.provider,
        "url": url,
        "managed_by_nexus": settings.manage_ollama,
        "reachable": _http_ok(url + "/api/tags", timeout=2.0),
    }


def _service_status() -> dict:
    payload = {}
    for key in ("backend", "memory", "frontend"):
        svc = services.SERVICES[key]
        pid = services.read_pid(svc)
        payload[key] = {
            "running": services.alive(pid) or _http_ok(svc.url),
            "pid": pid if services.alive(pid) else None,
            "url": svc.url,
        }
    payload["provider"] = _provider_payload()
    return payload


def _get_json(url: str, timeout: float = 1.5) -> Any | None:
    try:
        with urllib.request.urlopen(url, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8", errors="replace"))
    except Exception:
        return None


def _bar(ratio: float, width: int = 20, unicode: bool = True) -> str:
    ratio = max(0.0, min(1.0, float(ratio)))
    filled = int(round(ratio * width))
    fill = _FILL if unicode else _FILL_ASCII
    empty = _EMPTY if unicode else _EMPTY_ASCII
    return fill * filled + empty * (width - filled)


def _fmt_bytes(n: float | int | None) -> str:
    if n is None:
        return "—"
    n = float(n)
    for unit in ("B", "K", "M", "G", "T"):
        if abs(n) < 1024 or unit == "T":
            return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
        n /= 1024
    return f"{n:.1f}T"


def _pid_stats(pids: list[int | None]) -> dict:
    """Aggregate CPU%/RSS for known service PIDs. Soft-depends on psutil."""
    live = [int(p) for p in pids if p]
    if not live:
        return {"cpu_pct": None, "rss": None, "pids": []}
    try:
        import psutil  # type: ignore
    except ImportError:
        return {"cpu_pct": None, "rss": None, "pids": live}
    cpu = 0.0
    rss = 0
    seen: list[int] = []
    for pid in live:
        try:
            proc = psutil.Process(pid)
            cpu += proc.cpu_percent(interval=0.0)
            rss += proc.memory_info().rss
            seen.append(pid)
        except (psutil.Error, ProcessLookupError, ValueError):
            continue
    return {"cpu_pct": cpu, "rss": rss, "pids": seen}


def _host_stats() -> dict:
    try:
        import psutil  # type: ignore
    except ImportError:
        return {"cpu_pct": None, "mem_used": None, "mem_total": None, "mem_pct": None}
    vm = psutil.virtual_memory()
    return {
        "cpu_pct": psutil.cpu_percent(interval=0.05),
        "mem_used": vm.used,
        "mem_total": vm.total,
        "mem_pct": vm.percent,
    }


def _api_counts(api_url: str) -> dict:
    """Pull cheap inventory counts from the backend when it is up."""
    base = api_url.rstrip("/")
    out = {
        "online": False,
        "version": None,
        "ollama": None,
        "memories": None,
        "conversations": None,
        "playbooks": None,
        "models": None,
        "action_tool_policy": None,
    }
    status = _get_json(base + "/status")
    if not isinstance(status, dict):
        return out
    out["online"] = True
    out["version"] = status.get("version")
    out["ollama"] = status.get("ollama")

    mem = _get_json(base + "/memory")
    if isinstance(mem, list):
        out["memories"] = len(mem)
    elif isinstance(mem, dict) and isinstance(mem.get("memories"), list):
        out["memories"] = len(mem["memories"])

    conv = _get_json(base + "/conversations")
    if isinstance(conv, list):
        out["conversations"] = len(conv)
    elif isinstance(conv, dict):
        items = conv.get("conversations") or conv.get("items") or []
        if isinstance(items, list):
            out["conversations"] = len(items)

    pbs = _get_json(base + "/playbooks")
    if isinstance(pbs, list):
        out["playbooks"] = len(pbs)
    elif isinstance(pbs, dict) and isinstance(pbs.get("playbooks"), list):
        out["playbooks"] = len(pbs["playbooks"])

    models = _get_json(base + "/models")
    if isinstance(models, list):
        out["models"] = len(models)
    elif isinstance(models, dict):
        items = models.get("models") or models.get("items") or []
        if isinstance(items, list):
            out["models"] = len(items)

    settings_payload = _get_json(base + "/settings")
    if isinstance(settings_payload, dict):
        out["action_tool_policy"] = settings_payload.get("action_tool_policy")

    return out


def _toolchain_stats() -> list[dict]:
    """Which run_snippet languages have a host toolchain right now."""
    try:
        from synapse import code_run
    except Exception:
        return []
    rows = []
    for name, spec in code_run.RUN_LANGS.items():
        tool = None
        try:
            tool = spec["tool"]()
        except Exception:
            tool = None
        rows.append({
            "lang": name,
            "ready": bool(tool),
            "tool": tool or None,
            "summary": spec.get("summary") or name,
        })
    return rows


def _recent_tools(log_path: Path, limit: int = 8) -> list[str]:
    """Best-effort scrape of recent tool names from chat.log."""
    if not log_path.is_file():
        return []
    try:
        # Read the tail without pulling a multi-MB log into memory.
        data = log_path.read_bytes()
        if len(data) > 64_000:
            data = data[-64_000:]
        text = data.decode("utf-8", errors="replace")
    except OSError:
        return []
    found: list[str] = []
    for line in reversed(text.splitlines()):
        # Chat tool loop yields "__status__<tool>"; logs may also name tools
        # in JSON payloads. Keep the match narrow.
        if "__status__" in line:
            name = line.split("__status__", 1)[-1].strip().split()[0].strip(",\"'")
            if name and name not in ("tools",) and name not in found:
                found.append(name)
        elif '"name":' in line and any(
            t in line for t in ("render_preview", "run_snippet", "web_search",
                                "fetch_url", "remember", "get_time")
        ):
            for t in ("run_snippet", "render_preview", "web_search", "fetch_url",
                      "remember", "get_time", "search_documents"):
                if t in line and t not in found:
                    found.append(t)
        if len(found) >= limit:
            break
    return found


def collect_snapshot() -> dict:
    """Gather one monitoring frame. Safe when services are down."""
    services_payload = _service_status()
    pids = [
        services_payload.get("backend", {}).get("pid"),
        services_payload.get("memory", {}).get("pid"),
        services_payload.get("frontend", {}).get("pid"),
    ]
    api = _api_counts(settings.api_url)
    return {
        "ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
        "version": settings.version,
        "services": services_payload,
        "api": api,
        "host": _host_stats(),
        "procs": _pid_stats(pids),
        "toolchains": _toolchain_stats(),
        "recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
        "paths": {
            "api_url": settings.api_url,
            "memory_url": settings.memory_url,
            "runtime_dir": str(settings.runtime_dir),
        },
    }


def _pad(text: str, width: int) -> str:
    # Visual width ≈ len for our ASCII/box content (no wide East-Asian chars).
    if len(text) > width:
        return text[: width - 1] + "…" if width > 1 else text[:width]
    return text + " " * (width - len(text))


def _row(box: dict, inner: str, width: int) -> str:
    return f"{box['v']} {_pad(inner, width - 4)} {box['v']}"


def _rule(box: dict, width: int, kind: str = "mid") -> str:
    h = box["h"] * (width - 2)
    if kind == "top":
        return f"{box['tl']}{h}{box['tr']}"
    if kind == "bot":
        return f"{box['bl']}{h}{box['br']}"
    return f"{box['l']}{h}{box['r']}"


def _svc_line(name: str, running: bool, detail: str, unicode: bool) -> str:
    mark = (_FILL if unicode else _FILL_ASCII) * 3 if running else (_EMPTY if unicode else _EMPTY_ASCII) * 3
    state = "UP  " if running else "DOWN"
    return f"{name:<10} {mark} {state}  {detail}"


def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | None = None) -> str:
    """Turn a snapshot into a single multi-line ASCII panel."""
    if unicode is None:
        unicode = _use_unicode()
    box = _BOX if unicode else _BOX_ASCII
    cols = shutil.get_terminal_size((80, 24)).columns if width is None else width
    width = max(56, min(100, cols))

    lines: list[str] = []
    lines.append(_rule(box, width, "top"))
    title = f"NexusOS {snapshot.get('version', '')}  monitor"
    raw_ts = snapshot.get("ts") or ""
    # Prefer local clock HH:MM:SS from an ISO stamp; fall back to wall clock.
    stamp = ""
    if "T" in raw_ts:
        try:
            stamp = raw_ts.split("T", 1)[1][:8]
        except Exception:
            stamp = ""
    if not stamp:
        stamp = datetime.now().strftime("%H:%M:%S")
    gap = max(1, width - 4 - len(title) - len(stamp))
    header = f"{title}{' ' * gap}{stamp}"
    lines.append(_row(box, header, width))
    lines.append(_rule(box, width, "mid"))

    lines.append(_row(box, "SERVICES", width))
    svcs = snapshot.get("services") or {}
    for key, label in (("backend", "backend"), ("memory", "memory"), ("frontend", "frontend")):
        info = svcs.get(key) or {}
        running = bool(info.get("running"))
        pid = info.get("pid")
        url = info.get("url") or ""
        detail = url
        if pid:
            detail = f"pid {pid}  {url}"
        lines.append(_row(box, _svc_line(label, running, detail, unicode), width))
    provider = svcs.get("provider") or _provider_payload()
    pref = f"{provider.get('provider', '?')} @ {provider.get('url', '')}"
    lines.append(_row(box, _svc_line("provider", bool(provider.get("reachable")), pref, unicode), width))

    lines.append(_rule(box, width, "mid"))
    lines.append(_row(box, "RESOURCES", width))
    host = snapshot.get("host") or {}
    procs = snapshot.get("procs") or {}
    cpu = host.get("cpu_pct")
    if cpu is not None:
        lines.append(_row(
            box,
            f"host CPU  [{_bar(cpu / 100.0, 22, unicode)}]  {cpu:5.1f}%",
            width,
        ))
    else:
        lines.append(_row(box, "host CPU  (install psutil for live bars)", width))
    mem_pct = host.get("mem_pct")
    if mem_pct is not None:
        lines.append(_row(
            box,
            f"host MEM  [{_bar(mem_pct / 100.0, 22, unicode)}]  "
            f"{_fmt_bytes(host.get('mem_used'))} / {_fmt_bytes(host.get('mem_total'))}",
            width,
        ))
    proc_cpu = procs.get("cpu_pct")
    proc_rss = procs.get("rss")
    if proc_cpu is not None or proc_rss is not None:
        lines.append(_row(
            box,
            f"nexus     cpu={proc_cpu if proc_cpu is not None else '—':>5}  "
            f"rss={_fmt_bytes(proc_rss)}  pids={','.join(str(p) for p in (procs.get('pids') or [])) or '—'}",
            width,
        ))

    lines.append(_rule(box, width, "mid"))
    lines.append(_row(box, "DATA / TOOLS", width))
    api = snapshot.get("api") or {}
    if api.get("online"):
        policy = api.get("action_tool_policy") or "—"
        lines.append(_row(
            box,
            f"api UP  v{api.get('version') or '?'}  ollama={api.get('ollama') or '—'}  "
            f"tools={policy}",
            width,
        ))
        lines.append(_row(
            box,
            f"memories={_n(api.get('memories'))}  "
            f"chats={_n(api.get('conversations'))}  "
            f"playbooks={_n(api.get('playbooks'))}  "
            f"models={_n(api.get('models'))}",
            width,
        ))
    else:
        lines.append(_row(box, "api DOWN  — start with: nexus start", width))

    recent = snapshot.get("recent_tools") or []
    lines.append(_row(
        box,
        "recent   " + (", ".join(recent) if recent else "(none in chat.log)"),
        width,
    ))

    lines.append(_rule(box, width, "mid"))
    lines.append(_row(box, "RUN TOOLCHAINS  (run_snippet)", width))
    chains = snapshot.get("toolchains") or []
    if not chains:
        lines.append(_row(box, "(code_run unavailable)", width))
    else:
        # Pack ready/missing into one or two compact lines.
        ready = [c["lang"] for c in chains if c.get("ready")]
        missing = [c["lang"] for c in chains if not c.get("ready")]
        lines.append(_row(
            box,
            f"ready    {', '.join(ready) if ready else '—'}",
            width,
        ))
        lines.append(_row(
            box,
            f"missing  {', '.join(missing) if missing else '—'}",
            width,
        ))

    lines.append(_rule(box, width, "bot"))
    return "\n".join(lines)


def _n(value) -> str:
    return "—" if value is None else str(value)


def run_monitor(*, interval: float = 1.5, once: bool = False, json_output: bool = False) -> int:
    """Print one frame, or refresh in place until interrupted."""
    clear = "\033[H\033[J"
    first = True
    while True:
        snap = collect_snapshot()
        if json_output:
            print(json.dumps(snap, indent=2, sort_keys=True))
        else:
            frame = render_frame(snap)
            if once or not first:
                # Replacing the screen keeps the panel stable; first frame of a
                # live session also clears so leftover shell output doesn't mix.
                if not once:
                    print(clear + frame, end="", flush=True)
                else:
                    print(frame)
            else:
                print(clear + frame, end="", flush=True)
            first = False
        if once:
            return 0
        try:
            time.sleep(max(0.3, float(interval)))
        except KeyboardInterrupt:
            print()
            return 130
