#!/usr/bin/env python3
"""tope - full-screen Claude and Codex usage monitor (Python stdlib only)."""
from __future__ import annotations

import argparse
import atexit
import datetime as dt
import json
import math
import os
import queue
import select
import shutil
import signal
import subprocess
import sys
import termios
import threading
import time
import tty
import urllib.error
import urllib.request
from collections import deque
from dataclasses import dataclass, field
from typing import Any

CLAUDE_CREDS = os.path.expanduser("~/.claude/.credentials.json")
SPIN = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
PARTS = " ▏▎▍▌▋▊▉█"
PALETTE = {
    "pink": ((243, 139, 168), 211), "mauve": ((203, 166, 247), 183),
    "blue": ((137, 180, 250), 111), "cyan": ((137, 220, 235), 117),
    "green": ((166, 227, 161), 151), "yellow": ((249, 226, 175), 223),
    "peach": ((250, 179, 135), 216), "text": ((205, 214, 244), 189),
    "subtle": ((88, 91, 112), 60), "surface": ((49, 50, 68), 237),
}
WEEKDAYS = ["lun", "mar", "mié", "jue", "vie", "sáb", "dom"]
MONTHS = ["ene", "feb", "mar", "abr", "may", "jun", "jul", "ago", "sep", "oct", "nov", "dic"]


def load_claude_token():
    """Return the Claude Code OAuth access token.

    Linux (and some macOS): read ~/.claude/.credentials.json.
    macOS: Claude Code usually stores the same JSON in the login Keychain under
    service "Claude Code-credentials", account = $USER. Fall back to that.
    """
    try:
        with open(CLAUDE_CREDS) as f:
            return json.load(f)["claudeAiOauth"]["accessToken"]
    except (FileNotFoundError, KeyError, ValueError):
        pass
    if sys.platform == "darwin":
        user = os.environ.get("USER") or os.environ.get("LOGNAME") or ""
        try:
            out = subprocess.run(
                ["security", "find-generic-password",
                 "-s", "Claude Code-credentials", "-a", user, "-w"],
                capture_output=True, text=True, timeout=8)
            if out.returncode == 0 and out.stdout.strip():
                return json.loads(out.stdout.strip())["claudeAiOauth"]["accessToken"]
        except (OSError, ValueError, KeyError):
            pass
    raise FileNotFoundError("no encontré credenciales de Claude (¿está logueado Claude Code?)")


def fetch_claude():
    """Canonical, verified Claude request. Re-read credentials every time."""
    tok = load_claude_token()
    req = urllib.request.Request(
        "https://api.anthropic.com/api/oauth/usage",
        headers={"Authorization": f"Bearer {tok}",
                 "anthropic-beta": "oauth-2025-04-20",
                 "User-Agent": "claude-cli/2.1.206", "Accept": "application/json"})
    with urllib.request.urlopen(req, timeout=15) as response:
        return json.load(response)


def _run(cmd, timeout=5):
    """Run a local command, return stripped stdout or '' on any failure."""
    try:
        p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
        return p.stdout.strip() if p.returncode == 0 else ""
    except (OSError, subprocess.SubprocessError):
        return ""


def discover_runner():
    """Find a GitHub Actions self-hosted runner systemd unit, or None.

    Local-only, Linux+systemd. Returns the unit name (e.g.
    actions.runner.Raboys.kh1-runner.service) or None so the panel auto-hides
    on machines without a runner (e.g. a friend's Mac).
    """
    if sys.platform != "linux" or not shutil.which("systemctl"):
        return None
    out = _run(["systemctl", "list-units", "--type=service", "--all",
                "--plain", "--no-legend", "actions.runner.*"])
    for line in out.splitlines():
        parts = line.split()
        if parts and parts[0].startswith("actions.runner.") and parts[0].endswith(".service"):
            return parts[0]
    return None


def fetch_runner(unit):
    """Local status of the runner `unit`: {name, state, busy, uptime}."""
    state = _run(["systemctl", "is-active", unit]) or "unknown"
    busy = bool(_run(["pgrep", "-f", "Runner.Worker"]))  # Worker proc => running a job
    uptime = None
    lis = _run(["pgrep", "-f", "Runner.Listener"]).split()
    if lis:
        et = _run(["ps", "-o", "etimes=", "-p", lis[0]])
        if et.isdigit():
            uptime = int(et)
    short = unit[len("actions.runner."):-len(".service")]  # org.name
    return {"name": short, "state": state, "busy": busy, "uptime": uptime}


class CodexAppServer:
    """Canonical persistent JSON-RPC client from codex_appserver_client.py."""
    def __init__(self):
        self.proc = None
        self._id = 10
        self._resp = {}
        self._lock = threading.Lock()
        self._ready = False

    def start(self, timeout=8):
        self.proc = subprocess.Popen(
            ["codex", "app-server"], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL, text=True, bufsize=1)
        threading.Thread(target=self._reader, daemon=True).start()
        self._send({"jsonrpc": "2.0", "id": 1, "method": "initialize",
                    "params": {"clientInfo": {"name": "tope", "version": "1.0"},
                               "capabilities": {"experimentalApi": True}}})
        t0 = time.time()
        while time.time() - t0 < timeout:
            with self._lock:
                if 1 in self._resp:
                    self._ready = True
                    break
            time.sleep(.05)
        self._send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
        return self._ready

    def _send(self, obj):
        self.proc.stdin.write(json.dumps(obj) + "\n")
        self.proc.stdin.flush()

    def _reader(self):
        for line in self.proc.stdout:
            try:
                msg = json.loads(line.strip())
            except Exception:
                continue
            if "id" in msg and ("result" in msg or "error" in msg):
                with self._lock:
                    self._resp[msg["id"]] = msg

    def call(self, method, params=None, timeout=6):
        with self._lock:
            self._id += 1
            rid = self._id
        self._send({"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}})
        t0 = time.time()
        while time.time() - t0 < timeout:
            with self._lock:
                if rid in self._resp:
                    msg = self._resp.pop(rid)
                    if "error" in msg:
                        raise RuntimeError(msg["error"].get("message", "rpc error"))
                    return msg["result"]
            time.sleep(.03)
        raise TimeoutError(method)

    def rate_limits(self): return self.call("account/rateLimits/read")
    def usage(self): return self.call("account/usage/read")
    def alive(self): return self.proc is not None and self.proc.poll() is None
    def stop(self):
        try:
            if self.proc: self.proc.terminate()
        except Exception:
            pass


@dataclass(frozen=True)
class Cell:
    ch: str = " "
    fg: Any = None
    bold: bool = False
    bg: Any = None


def cells(text="", fg=None, bold=False, bg=None):
    return [Cell(c, fg, bold, bg) for c in text]


def fit(row, width):
    """Truncate/pad styled cells before ANSI serialization."""
    return (list(row[:width]) + [Cell()] * width)[:width]


def put(row, x, value):
    if x < 0:
        value, x = value[-x:], 0
    for i, c in enumerate(value):
        if 0 <= x + i < len(row): row[x + i] = c


def color_for(p):
    if p is None: return "yellow"
    return "green" if p < 50 else "yellow" if p < 75 else "peach" if p < 90 else "pink"


def gradient(frac):
    stops = [(0., "green"), (.5, "yellow"), (.75, "peach"), (.9, "pink"), (1., "pink")]
    for (a, ca), (b, cb) in zip(stops, stops[1:]):
        if frac <= b:
            ar, _ = PALETTE[ca]; br, _ = PALETTE[cb]
            t = 0 if b == a else (frac - a) / (b - a)
            return tuple(round(x + (y-x)*t) for x, y in zip(ar, br))
    return PALETTE["pink"][0]


def gauge_bar(percent, width, ascii_mode=False, mode256=False):
    width = max(0, width)
    if percent is None: return cells("." * width if ascii_mode else "·" * width, "subtle")
    amount = round(max(0., min(100., float(percent))) / 100 * width * 8)
    full, rem = divmod(amount, 8)
    out = []
    for i in range(width):
        filled = i < full or (i == full and rem)
        if filled:
            ch = "#" if ascii_mode else ("█" if i < full else PARTS[rem])
            pos = (i + .5) / max(1, width)
            col = color_for(pos * 100) if mode256 else gradient(pos)
            out.append(Cell(ch, col))
        else: out.append(Cell("." if ascii_mode else "·", "subtle"))
    return out


def parse_iso(value):
    try: return dt.datetime.fromisoformat(str(value).replace("Z", "+00:00")).timestamp()
    except Exception: return None


def duration_label(minutes):
    if minutes is None: return "?"
    try:
        m = int(minutes)
        return f"{m//1440}d" if m >= 1440 and m % 1440 == 0 else f"{m//60}h" if m >= 60 and m % 60 == 0 else f"{m}m"
    except Exception: return "?"


def reset_text(epoch, now=None):
    now = now or time.time()
    if not epoch: return "resets –"
    delta = epoch - now
    if delta <= 0: return "resetea…"
    sec = int(delta)
    if sec < 3600: countdown = f"{sec//60}m"
    elif sec < 86400: countdown = f"{sec//3600}h{sec%3600//60:02d}m"
    else: countdown = f"{sec//86400}d{sec%86400//3600:02d}h"
    target = dt.datetime.fromtimestamp(epoch)
    today = dt.datetime.fromtimestamp(now).date()
    when = target.strftime("%H:%M") if target.date() == today else f"{WEEKDAYS[target.weekday()]} {target.day} {target:%H:%M}"
    return f"resets {countdown} → {when}"


def human_age(stamp):
    s = max(0, int(time.time() - stamp))
    return f"{s}s" if s < 60 else f"{s//60}m{s%60:02d}s" if s < 3600 else f"{s//3600}h{s%3600//60:02d}m"


def token_count(n):
    try:
        n = float(n)
        return f"{n/1e9:.2f}B" if n >= 1e9 else f"{n/1e6:.0f}M" if n >= 1e6 else f"{n/1e3:.0f}K"
    except Exception: return "--"


@dataclass
class SourceState:
    data: dict | None = None
    success: float = 0
    error: str | None = None
    auth: bool = False
    fetching: bool = False
    failures: int = 0
    next_due: float = 0
    history: deque = field(default_factory=lambda: deque(maxlen=600))


@dataclass
class Model:
    claude: SourceState = field(default_factory=SourceState)
    codex: SourceState = field(default_factory=SourceState)
    runner: SourceState = field(default_factory=SourceState)
    usage: dict | None = None
    usage_at: float = 0
    lock: threading.Lock = field(default_factory=threading.Lock)


def auth_error(exc):
    return isinstance(exc, urllib.error.HTTPError) and exc.code in (401, 403)


class Poller:
    def __init__(self, model, claude_interval, codex_interval, runner_interval=8, runner_unit=None):
        self.model = model
        self.intervals = {"claude": claude_interval, "codex": codex_interval, "runner": runner_interval}
        self.runner_unit = runner_unit
        self.events = queue.Queue()
        self.server = CodexAppServer()
        self.stop_event = threading.Event()

    def launch(self, name):
        state = getattr(self.model, name)
        with self.model.lock:
            if state.fetching: return
            state.fetching = True
        target = {"claude": self._claude, "codex": self._codex, "runner": self._runner}[name]
        threading.Thread(target=target, daemon=True).start()

    def _finish(self, name, data=None, error=None, auth=False, usage=None):
        now = time.time()
        with self.model.lock:
            s = getattr(self.model, name); s.fetching = False
            if error is None:
                s.data, s.success, s.error, s.auth, s.failures = data, now, None, False, 0
                if name == "claude":
                    try:
                        limits = data.get("limits") or []
                        v = next(x.get("percent") for x in limits if x.get("kind") == "session")
                        s.history.append((now, float(v)))
                    except Exception: pass
            else:
                s.error, s.auth, s.failures = str(error), auth, s.failures + 1
            base = self.intervals[name]
            if error is None:
                retry = base
            elif auth:
                retry = 30
            elif isinstance(error, urllib.error.HTTPError) and error.code == 429:
                try: ra = int(error.headers.get("retry-after") or 0)
                except (TypeError, ValueError): ra = 0
                retry = max(60, ra, base * min(8, 2 ** s.failures))  # 429: back off hard
            else:
                retry = base * min(8, 2 ** s.failures)
            s.next_due = now + retry
            if usage is not None: self.model.usage, self.model.usage_at = usage, now
        self.events.put(name)

    def _claude(self):
        try: self._finish("claude", fetch_claude())
        except Exception as exc: self._finish("claude", error=exc, auth=auth_error(exc))

    def _codex(self):
        try:
            if not self.server.alive():
                self.server.stop(); self.server = CodexAppServer()
                if not self.server.start(): raise RuntimeError("codex app-server no inicializó")
            data = self.server.rate_limits()
            usage = None
            if time.time() - self.model.usage_at >= 60:
                try: usage = self.server.usage()
                except Exception: pass
            self._finish("codex", data, usage=usage)
        except Exception as exc:
            # RPC/auth errors do not reliably expose status codes; panel remains recoverable.
            self.server.stop()
            self._finish("codex", error=exc, auth=False)

    def _runner(self):
        try: self._finish("runner", fetch_runner(self.runner_unit))
        except Exception as exc: self._finish("runner", error=exc, auth=False)

    def close(self):
        self.stop_event.set(); self.server.stop()


def claude_gauges(data):
    limits = data.get("limits") if isinstance(data, dict) else None
    result = []
    if limits:
        order = {"session": 0, "weekly_all": 1, "weekly_scoped": 2}
        for item in sorted((x for x in limits if x.get("kind") in order), key=lambda x: order[x.get("kind")]):
            kind = item.get("kind")
            name = "SESSION · 5h" if kind == "session" else "WEEKLY · all models" if kind == "weekly_all" else "WEEKLY · " + str((((item.get("scope") or {}).get("model") or {}).get("display_name") or "scoped"))
            result.append({"label": name, "percent": item.get("percent"), "reset": parse_iso(item.get("resets_at")),
                           "active": bool(item.get("is_active")), "severity": item.get("severity", "normal")})
    else:
        for key, label in (("five_hour", "SESSION · 5h"), ("seven_day", "WEEKLY · all models")):
            obj = (data or {}).get(key) or {}
            result.append({"label": label, "percent": obj.get("utilization"), "reset": parse_iso(obj.get("resets_at"))})
    return result


def codex_gauges(data):
    rate = (data or {}).get("rateLimits") or {}
    out = []
    for key, title in (("primary", "PRIMARY"), ("secondary", "WEEKLY")):
        item = rate.get(key) or {}
        out.append({"label": f"{title} · {duration_label(item.get('windowDurationMins'))}",
                    "percent": item.get("usedPercent"), "reset": item.get("resetsAt")})
    return out


def border_chars(ascii_mode): return ("+", "+", "+", "+", "-", "|") if ascii_mode else ("╭", "╮", "╰", "╯", "─", "│")


def panel_base(width, height, title, tag, title_color, ascii_mode):
    tl, tr, bl, br, hz, vt = border_chars(ascii_mode)
    grid = [cells(" " * width) for _ in range(height)]
    grid[0] = cells(tl + hz * (width-2) + tr, "subtle")
    grid[-1] = cells(bl + hz * (width-2) + br, "subtle")
    for y in range(1, height-1):
        grid[y][0] = Cell(vt, "subtle"); grid[y][-1] = Cell(vt, "subtle")
    put(grid[0], 2, cells(f" {title} ", title_color, True))
    if tag:
        x = max(2, width - 3 - len(tag)); put(grid[0], x, cells(f" {tag} ", title_color))
    return grid


def error_panel(grid, state, source):
    h, w = len(grid), len(grid[0]); mid = max(2, h//2-1)
    if state.auth:
        lines = [("▲ token vencido", "pink", True),
                 (f"corré `{source}` y logueate para renovarlo", "text", False),
                 ("reintentando cada 30s…", "subtle", False)]
    else:
        lines = [("↯ sin red", "yellow", True), (str(state.error or "error desconocido"), "subtle", False),
                 ("reintentando automáticamente…", "subtle", False)]
    for i, (text, col, bold) in enumerate(lines):
        text = text[:max(0, w-4)]; put(grid[min(h-2, mid+i)], max(2, (w-len(text))//2), cells(text, col, bold))


def add_gauge(grid, y, gauge, state, opts, show_age=False, compact=False, dim=False):
    w = len(grid[0]); inner = w - 4
    p = gauge.get("percent"); label = gauge.get("label", "--")
    prefix = "▸ " if gauge.get("active") and not opts.ascii else "> " if gauge.get("active") else ""
    pct = "--%" if p is None else f"{float(p):.1f}%"
    col = "subtle" if dim else color_for(p)
    label_col = "subtle" if dim else ("pink" if p is not None and float(p) >= 90 else "text")
    row = cells(" " * (w-2)); put(row, 1, cells(prefix + label, label_col, p is not None and float(p) >= 90))
    put(row, max(1, w-3-len(pct)), cells(pct, col, True)); grid[y] = fit([grid[y][0]] + row + [grid[y][-1]], w)
    grid[y+1][2:w-2] = gauge_bar(p, inner, opts.ascii, opts.c256)
    if not compact and y+2 < len(grid)-1:
        meta = "datos incompletos" if p is None else reset_text(gauge.get("reset"))
        put(grid[y+2], 2, cells(meta[:inner], "subtle" if dim else ("yellow" if p is None else "subtle")))
        if show_age:
            if state.fetching:
                sp = ("|/-\\" if opts.ascii else SPIN)[int(time.monotonic()*10) % (4 if opts.ascii else 10)]
                age = f"{sp} actualizando"
            elif state.error:
                age = f"! sin red · dato de hace {human_age(state.success)}" if opts.ascii else f"↯ sin red · dato de hace {human_age(state.success)}"
            else: age = f"↻ {human_age(state.success)} ago"
            put(grid[y+2], max(2, w-2-len(age)), cells(age[-inner:], "cyan" if state.fetching else "yellow" if state.error else "subtle"))


def history_line(values, width, ascii_mode):
    chars = ".:-=+*#@" if ascii_mode else "▁▂▃▄▅▆▇█"
    vals = list(values)[-width:]
    out = []
    for _, v in vals:
        out.append(Cell(chars[min(7, max(0, int(v/100*8)))], color_for(v)))
    return [Cell(" ")] * (width-len(out)) + out


def braille_history_row(values, width, chart_row, ascii_mode):
    """One of four graph rows; four vertical braille dots give 16 levels total."""
    vals = list(values)[-width:]
    out = [Cell()] * (width - len(vals))
    # Braille's left-column dots, ordered top to bottom.
    dot_masks = (0x01, 0x02, 0x04, 0x40)
    for _, value in vals:
        level = max(0, min(16, round(float(value) / 100 * 16)))
        # chart_row 0 is the upper band (levels 13..16), row 3 the lower.
        filled = max(0, min(4, level - (3-chart_row)*4))
        if ascii_mode:
            ch = "#" if filled >= 3 else ":" if filled else " "
        else:
            mask = 0
            for index in range(4-filled, 4): mask |= dot_masks[index]
            ch = chr(0x2800 + mask)
        out.append(Cell(ch, color_for(value)))
    return out


def render_claude(width, height, state, opts):
    grid = panel_base(width, height, "CLAUDE", "Max 5x", "peach", opts.ascii)
    if not state.data:
        error_panel(grid, state, "claude"); return grid
    gauges = claude_gauges(state.data); dim = bool(state.error)
    compact = height < 16 or len(gauges) * 4 > height-4
    step = 2 if compact else 4
    y = 2
    for i, gauge in enumerate(gauges):
        if y+1 >= height-1: break
        add_gauge(grid, y, gauge, state, opts, i == 0, compact, dim); y += step
    if height >= 30 and y + 5 < height-1:
        put(grid[y], 2, cells("HISTORY · session", "subtle"));
        for k in range(4):
            grid[y+1+k][2:width-2] = braille_history_row(state.history, width-4, k, opts.ascii)
    elif height >= 19 and y + 2 < height-1:
        put(grid[y], 2, cells("HISTORY · session · 2h", "subtle")); grid[y+1][2:width-2] = history_line(state.history, width-4, opts.ascii)
    # Regression over the last 30 minutes, only after 10 minutes of samples.
    hist = [(t, v) for t, v in state.history if time.time()-t <= 1800]
    if len(hist) >= 2 and hist[-1][0]-hist[0][0] >= 600 and height-y > 3:
        xs = [(t-hist[0][0])/3600 for t, _ in hist]; ys = [v for _, v in hist]
        xm, ym = sum(xs)/len(xs), sum(ys)/len(ys); den = sum((x-xm)**2 for x in xs)
        slope = sum((x-xm)*(v-ym) for x, v in zip(xs, ys))/den if den else 0
        session = gauges[0]; hours = max(0, ((session.get("reset") or time.time())-time.time())/3600)
        proj = min(100, max(0, ys[-1]+slope*hours))
        put(grid[min(height-2, y+2)], 2, cells(f"ritmo {slope:+.1f}%/h · al reset: ~{proj:.0f}%"[:width-4], "pink" if proj >= 100 else "subtle", proj >= 100))
    return grid


def render_codex(width, height, state, usage, opts):
    rate = (state.data or {}).get("rateLimits") or {}; tag = str(rate.get("planType") or "--")
    grid = panel_base(width, height, "CODEX", tag, "cyan", opts.ascii)
    if not state.data:
        error_panel(grid, state, "codex"); return grid
    compact = height < 16; y = 2; dim = bool(state.error)
    for i, gauge in enumerate(codex_gauges(state.data)):
        add_gauge(grid, y, gauge, state, opts, i == 0, compact, dim); y += 2 if compact else 4
    spark = ((state.data.get("rateLimitsByLimitId") or {}).get("codex_bengalfox"))
    if spark and y + 3 < height-1:
        name = str(spark.get("limitName") or "Spark")
        put(grid[y], 2, cells(("- SPARK · " if opts.ascii else "─ SPARK · "), "subtle")); put(grid[y], 12, cells(name[:max(0,width-15)], "mauve")); y += 1
        for key in ("primary", "secondary"):
            item = spark.get(key) or {}; p=item.get("usedPercent"); window=duration_label(item.get("windowDurationMins")); bw=max(4,min(16,width-15))
            put(grid[y], 2, cells(f"{window:>3}  ", "subtle")); put(grid[y], 7, gauge_bar(p,bw,opts.ascii,opts.c256)); txt="--%" if p is None else f"{float(p):.0f}%"
            put(grid[y], 8+bw, cells(txt,color_for(p),True)); y += 1
    credits = ((state.data.get("rateLimitResetCredits") or {}).get("availableCount"))
    if y < height-1:
        text="resets gratis disponibles"; put(grid[y],2,cells(text,"subtle")); val="--" if credits is None else str(credits); put(grid[y],max(2,width-2-len(val)),cells(val,"green" if credits and credits>0 else "text")); y+=1
    if y < height-1:
        put(grid[y],2,cells("si satura →", "subtle")); put(grid[y],max(14,width-2-len("gpt-5.6-luna")),cells("gpt-5.6-luna","yellow")); y+=1
    summary=(usage or {}).get("summary") or {}
    if summary and y < height-1:
        txt=f"racha {summary.get('currentStreakDays','--')} días · {token_count(summary.get('lifetimeTokens'))} tok histórico"
        put(grid[y],2,cells(txt[:width-4],"subtle"))
    return grid


def merge_side(left, right): return [a+b for a,b in zip(left,right)]


def header(width, model, paused):
    row = cells(" "*width); logo=[(" ",None),("t","pink"),("o","mauve"),("p","blue"),("e","cyan"),(" ",None)]
    x=0
    for ch,col in logo: row[x]=Cell(ch,col,True); x+=1
    put(row,x,cells("▏ claude & codex","subtle"))
    now=dt.datetime.now(); clock=f"{WEEKDAYS[now.weekday()]} {now.day} {MONTHS[now.month-1]} · {now:%H:%M:%S}"
    put(row,width-len(clock)-1,cells(clock,"text"))
    badges=[]
    with model.lock:
        for g in claude_gauges(model.claude.data or {}):
            try:
                if float(g.get("percent"))>=90: badges.append(f"▲ claude {g['label'].split('·')[-1].strip()} {float(g['percent']):.0f}%")
            except Exception: pass
        if paused: badges.append("PAUSADO")
        if model.claude.error and model.codex.error: badges.append("SIN RED")
    badge="  ".join(badges)
    if badge:
        start=max(x+2,(width-len(badge))//2); put(row,start,cells(badge[:max(0,width-start-len(clock)-2)],"yellow" if "SIN RED" in badge else "pink",True,"surface"))
    return row


def footer(width, ci, xi, paused, ascii_mode):
    row=cells(" "*width); x=1
    for key, desc in (("q","salir"),("r","refresh"),("p","reanudar" if paused else "pausa"),("?","ayuda")):
        put(row,x,cells(f" {key} ","cyan",True,"surface")); x+=3; put(row,x,cells(desc+"  ","subtle")); x+=len(desc)+2
    mark="↻" if not ascii_mode else "~"
    right=f"claude {mark}{ci:g}s · codex {mark}{xi:g}s"
    put(row,max(x,width-len(right)-1),cells(right,"subtle")); return row


def dur_short(s):
    s=int(s); d,s=divmod(s,86400); h,s=divmod(s,3600); m,_=divmod(s,60)
    return f"{d}d{h:02d}h" if d else f"{h}h{m:02d}m" if h else f"{m}m"


def runner_line(width, r, ascii_mode):
    """Single full-width status strip for the GitHub Actions runner."""
    row=cells(" "*width); active=r.get("state")=="active"; busy=bool(r.get("busy"))
    dot="●" if not ascii_mode else "*"
    dcol="peach" if (active and busy) else "green" if active else "pink"
    x=1
    put(row,x,cells(dot+" ",dcol,True)); x+=2
    put(row,x,cells("runner ","subtle")); x+=7
    name=str(r.get("name","?")); put(row,x,cells(name,"text",True)); x+=len(name)+2
    segs=[("online" if active else (r.get("state") or "down"), "green" if active else "pink", active or False),
          ("ejecutando job" if busy else "idle", "peach" if busy else "subtle", busy)]
    up=r.get("uptime")
    if up is not None: segs.append((f"up {dur_short(up)}","subtle",False))
    for i,(t,c,b) in enumerate(segs):
        if i: put(row,x,cells("· ","subtle")); x+=2
        put(row,x,cells(t,c,b)); x+=len(t)+1
    return row


def small_frame(width,height,ascii_mode):
    msg=f"tope necesita >= 42x20 / actual: {width}x{height}" if ascii_mode else f"tope necesita ≥ 42×20 / actual: {width}×{height}"
    out=[cells(" "*width) for _ in range(height)]
    if height: put(out[height//2],max(0,(width-len(msg))//2),cells(msg[:width],"yellow",True))
    return out


def compose(width,height,model,opts,paused=False,help_open=False):
    if width<42 or height<20: return small_frame(width,height,opts.ascii)
    with model.lock:
        cs,ds,usage=model.claude,model.codex,model.usage
        rdata=model.runner.data
        bh=height-(3 if rdata else 2)  # reserve a row for the runner strip when present
        if width>=76:
            lw=width//2; rw=width-lw; body=merge_side(render_claude(lw,bh,cs,opts),render_codex(rw,bh,ds,usage,opts))
        else:
            top=bh//2; bottom=bh-top; body=render_claude(width,top,cs,opts)+render_codex(width,bottom,ds,usage,opts)
    extra=[runner_line(width,rdata,opts.ascii)] if rdata else []
    frame=[header(width,model,paused)]+body+extra+[footer(width,opts.claude_interval,opts.codex_interval,paused,opts.ascii)]
    frame=[fit(r,width) for r in frame[:height]]
    while len(frame)<height: frame.append(cells(" "*width))
    if help_open:
        lines=["tope · ayuda","q / Ctrl-C   salir","r            refresh ahora","p            pausar polling","? / Esc      cerrar"]
        bw=min(width-4,max(len(x) for x in lines)+6); bh=len(lines)+4; x=(width-bw)//2; y=(height-bh)//2
        tl,tr,bl,br,hz,vt=border_chars(opts.ascii)
        for yy in range(bh):
            content=(tl+hz*(bw-2)+tr if yy==0 else bl+hz*(bw-2)+br if yy==bh-1 else vt+" "*(bw-2)+vt)
            put(frame[y+yy],x,cells(content,"text",False,"surface"))
        for i,line in enumerate(lines): put(frame[y+2+i],x+3,cells(line,"cyan" if i==0 else "text",i==0,"surface"))
    assert len(frame)==height and all(len(row)==width for row in frame)
    return frame


def sgr(cell, c256):
    codes=[]
    if cell.bold: codes.append("1")
    if cell.fg:
        if isinstance(cell.fg,tuple):
            if c256: # gradient quantizes to nearest named ramp stop
                name=min(("green","yellow","peach","pink"),key=lambda n:sum((a-b)**2 for a,b in zip(cell.fg,PALETTE[n][0])))
                codes.append(f"38;5;{PALETTE[name][1]}")
            else: codes.append("38;2;%d;%d;%d"%cell.fg)
        else:
            rgb,n=PALETTE.get(cell.fg,PALETTE["text"]); codes.append(f"38;5;{n}" if c256 else "38;2;%d;%d;%d"%rgb)
    if cell.bg:
        rgb,n=PALETTE[cell.bg]; codes.append(f"48;5;{n}" if c256 else "48;2;%d;%d;%d"%rgb)
    return "\033["+";".join(codes)+"m" if codes else "\033[0m"


def serialize(row,c256):
    out=[]; last=None
    for c in row:
        style=(c.fg,c.bold,c.bg)
        if style!=last: out.append(sgr(c,c256)); last=style
        out.append(c.ch)
    out.append("\033[0m"); return "".join(out)


class Terminal:
    def __init__(self,c256): self.old=None; self.active=False; self.previous=[]; self.c256=c256
    def enter(self):
        if not sys.stdin.isatty() or not sys.stdout.isatty(): raise RuntimeError("tope interactivo requiere una TTY")
        self.old=termios.tcgetattr(sys.stdin.fileno()); tty.setcbreak(sys.stdin.fileno())
        os.write(sys.stdout.fileno(),b"\033[?1049h\033[?25l\033[?7l"); self.active=True
    def restore(self):
        if not self.active:return
        self.active=False
        try: termios.tcsetattr(sys.stdin.fileno(),termios.TCSADRAIN,self.old)
        except Exception: pass
        try: os.write(sys.stdout.fileno(),b"\033[0m\033[?2026l\033[?7h\033[?25h\033[?1049l")
        except Exception: pass
    def draw(self,frame):
        chunks=["\033[?2026h"]
        if len(self.previous)!=len(frame): self.previous=[]
        for y,row in enumerate(frame):
            if y>=len(self.previous) or row!=self.previous[y]: chunks += [f"\033[{y+1};1H",serialize(row,self.c256)]
        chunks.append("\033[?2026l"); os.write(sys.stdout.fileno(),"".join(chunks).encode()); self.previous=frame


def once_snapshot(model,opts,runner_unit=None):
    # Plain, deterministic and useful in scripts; fetches start concurrently.
    poller=Poller(model,opts.claude_interval,opts.codex_interval,runner_unit=runner_unit)
    srcs=("claude","codex")+(("runner",) if runner_unit else ())
    for s in srcs: poller.launch(s)
    deadline=time.time()+16
    while time.time()<deadline:
        with model.lock:
            if not any(getattr(model,s).fetching for s in srcs): break
        time.sleep(.05)
    with model.lock:
        print("tope · claude & codex")
        print("CLAUDE · Max 5x")
        if model.claude.data:
            for g in claude_gauges(model.claude.data): print(f"  {g['label']}: {'--' if g['percent'] is None else g['percent']}% · {reset_text(g['reset'])}")
            if model.claude.error: print(f"  sin red · datos guardados · {model.claude.error}")
        else: print("  token vencido" if model.claude.auth else f"  sin red · {model.claude.error or 'timeout'}")
        print("CODEX")
        if model.codex.data:
            for g in codex_gauges(model.codex.data): print(f"  {g['label']}: {'--' if g['percent'] is None else g['percent']}% · {reset_text(g['reset'])}")
            if model.codex.error: print(f"  sin red · datos guardados · {model.codex.error}")
        else: print(f"  sin red · {model.codex.error or 'timeout'}")
        if runner_unit:
            r=model.runner.data
            if r:
                st="online" if r.get("state")=="active" else (r.get("state") or "down")
                job="ejecutando job" if r.get("busy") else "idle"
                up=f" · up {dur_short(r['uptime'])}" if r.get("uptime") is not None else ""
                print(f"RUNNER · {r.get('name','?')}")
                print(f"  {st} · {job}{up}")
            else: print("RUNNER\n  sin datos")
    poller.close(); return 0


def main(argv=None):
    ap=argparse.ArgumentParser(description="Monitor TUI de límites Claude + Codex")
    # Codex se lee del app-server local (gratis) -> rápido. Claude pega a un
    # endpoint HTTP de Anthropic con rate-limit -> lento (el uso cambia despacio).
    ap.add_argument("--interval",type=float,default=None,metavar="N",help="atajo: fija ambos intervalos")
    ap.add_argument("--claude-interval",type=float,default=45,metavar="N")
    ap.add_argument("--codex-interval",type=float,default=7,metavar="N")
    ap.add_argument("--256",dest="c256",action="store_true")
    ap.add_argument("--ascii",action="store_true"); ap.add_argument("--once",action="store_true")
    ap.add_argument("--no-runner",dest="no_runner",action="store_true",help="no mostrar el runner de GitHub Actions")
    ap.add_argument("--runner",dest="runner_unit",default=None,metavar="UNIT",help="unit systemd del runner (auto-detecta por defecto)")
    opts=ap.parse_args(argv)
    if opts.interval is not None:
        opts.claude_interval = opts.codex_interval = opts.interval
    if min(opts.claude_interval, opts.codex_interval) <= 0: ap.error("los intervalos deben ser mayores que cero")
    model=Model()
    runner_unit=None if opts.no_runner else (opts.runner_unit or discover_runner())
    if opts.once:return once_snapshot(model,opts,runner_unit)
    sources=("claude","codex")+(("runner",) if runner_unit else ())
    term=Terminal(opts.c256); poller=Poller(model,opts.claude_interval,opts.codex_interval,runner_unit=runner_unit); running=True; resized=True; paused=False; help_open=False
    def stop(*_):
        nonlocal running; running=False
    def resize(*_):
        nonlocal resized; resized=True
    signal.signal(signal.SIGINT,stop); signal.signal(signal.SIGTERM,stop); signal.signal(signal.SIGWINCH,resize)
    atexit.register(term.restore); atexit.register(poller.close)
    try:
        term.enter()
        for s in sources: poller.launch(s)
        last_sec=-1; force=True
        while running:
            now=time.time()
            if not paused:
                with model.lock: due=[n for n in sources if not getattr(model,n).fetching and now>=getattr(model,n).next_due]
                for name in due: poller.launch(name)
            # drain finished-fetch events (new data => redraw)
            drained=False
            try:
                while True: poller.events.get_nowait(); drained=True
            except queue.Empty: pass
            with model.lock: fetching=model.claude.fetching or model.codex.fetching
            sec=int(now)
            # recompose only when something visible changed: key/resize/new data,
            # the 1s clock+countdown tick, or a spinner frame while fetching.
            if force or resized or drained or fetching or sec!=last_sec:
                size=shutil.get_terminal_size((80,24)); frame=compose(size.columns,size.lines,model,opts,paused,help_open); term.draw(frame); resized=False; force=False; last_sec=sec
            ready,_,_=select.select([sys.stdin],[],[],.08 if fetching else .4)
            if ready:
                key=os.read(sys.stdin.fileno(),16).decode(errors="ignore"); force=True
                if help_open: help_open=False; continue
                if key in ("q","Q","\x03"): running=False
                elif key=="r":
                    with model.lock: model.claude.next_due=model.codex.next_due=0
                    poller.launch("claude"); poller.launch("codex")
                elif key=="p": paused=not paused
                elif key=="?": help_open=True
    finally:
        poller.close(); term.restore()
    return 0


if __name__ == "__main__":
    try: raise SystemExit(main())
    except KeyboardInterrupt: raise SystemExit(0)
    except BrokenPipeError: raise SystemExit(0)
