#!/usr/bin/env python3
"""
nexus — a command-line client for LuiSearch / Nexus.

Talks to the local server (http://127.0.0.1:5555 by default; override with $NEXUS_URL).
Token is stored in ~/.config/nexus-cli/token after `nexus login`.

Examples:
  nexus login luishae02            # prompts for password, saves token
  nexus whoami
  nexus servers                    # list your servers
  nexus channels lobby             # channels in a server
  nexus dms                        # your DM conversations
  nexus read lobby-general 20      # last 20 messages in a channel
  nexus send lobby-general hi all  # post a message
  nexus dm noah hey there          # send a direct message
  nexus gif "cat"                  # search gifs, pick one to send (with --to)
  nexus upload lobby-general pic.png
  nexus watch lobby-general        # live-tail a channel (Ctrl-C to stop)
  nexus watch                      # live-tail EVERYTHING you can see
"""
import sys, os, json, time, getpass, argparse, urllib.request, urllib.parse, urllib.error

__version__ = "1.3.0"

BASE = os.environ.get('NEXUS_URL', 'https://luisearch.pages.dev').rstrip('/')
CFG_DIR = os.path.expanduser('~/.config/nexus-cli')
TOKEN_FILE = os.path.join(CFG_DIR, 'token')
CONFIG_FILE = os.path.join(CFG_DIR, 'config.json')

DEFAULT_CFG = {
    'update_check': True,     # ask on startup when a new version exists
    'auto_update': True,      # the background daemon applies updates by itself
    'interval': 21600,        # daemon check interval, seconds (default 6h)
    'snooze_until': 0,        # startup prompt snoozed until this epoch ("later")
}

def load_cfg():
    cfg = dict(DEFAULT_CFG)
    try:
        with open(CONFIG_FILE) as f: cfg.update(json.load(f))
    except Exception: pass
    return cfg

def save_cfg(cfg):
    os.makedirs(CFG_DIR, exist_ok=True)
    with open(CONFIG_FILE, 'w') as f: json.dump(cfg, f, indent=2)

def parse_interval(s):
    """'6h' '30m' '1d' '90s' or a plain number of seconds -> int seconds."""
    s = str(s).strip().lower()
    units = {'s': 1, 'm': 60, 'h': 3600, 'd': 86400}
    if s and s[-1] in units:
        try: return max(60, int(float(s[:-1]) * units[s[-1]]))
        except Exception: return None
    try: return max(60, int(s))
    except Exception: return None

def fmt_interval(sec):
    for u, n in (('d', 86400), ('h', 3600), ('m', 60)):
        if sec % n == 0 and sec >= n: return f'{sec // n}{u}'
    return f'{sec}s'

# ---- tiny ANSI helpers ----
def c(s, code): return f'\033[{code}m{s}\033[0m' if sys.stdout.isatty() else str(s)
def bold(s): return c(s, '1')
def dim(s): return c(s, '2')
def cyan(s): return c(s, '36')
def green(s): return c(s, '32')
def yellow(s): return c(s, '33')
def magenta(s): return c(s, '35')
def red(s): return c(s, '31')

def die(msg):
    print(red('✗ ' + msg), file=sys.stderr); sys.exit(1)

def load_token():
    try:
        with open(TOKEN_FILE) as f: return f.read().strip()
    except Exception:
        return os.environ.get('NEXUS_TOKEN', '')

def save_token(tok):
    os.makedirs(CFG_DIR, exist_ok=True)
    with open(TOKEN_FILE, 'w') as f: f.write(tok)
    os.chmod(TOKEN_FILE, 0o600)

def api(path, method='GET', body=None, token=None, timeout=40):
    url = BASE + path
    data = None
    headers = {'Content-Type': 'application/json',
               'User-Agent': 'nexus-cli/1.0 (+https://luisearch.pages.dev)',
               'Accept': 'application/json'}
    if token: headers['Authorization'] = 'Bearer ' + token
    if body is not None: data = json.dumps(body).encode()
    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            raw = r.read().decode()
            try: return r.status, json.loads(raw)
            except Exception: return r.status, raw
    except urllib.error.HTTPError as e:
        raw = e.read().decode()
        try: return e.code, json.loads(raw)
        except Exception: return e.code, raw
    except Exception as e:
        die(f'connection error: {e} (is the server up at {BASE}?)')

def need_token():
    t = load_token()
    if not t: die('not logged in — run: nexus login <username>')
    return t

# ---- commands ----
def cmd_login(a):
    pw = a.password or getpass.getpass('Password: ')
    st, d = api('/api/vn/login', 'POST', {'username': a.username, 'password': pw})
    if st == 200 and isinstance(d, dict) and d.get('token'):
        save_token(d['token'])
        print(green(f'✓ logged in as {bold(d.get("username", a.username))}'))
    else:
        die((d.get('error') if isinstance(d, dict) else str(d)) or 'login failed')

def cmd_logout(a):
    if os.path.exists(TOKEN_FILE):
        os.remove(TOKEN_FILE)
        print(green('✓ logged out') + dim(' (token cleared)'))
    else:
        print(dim('already logged out'))

def cmd_whoami(a):
    t = need_token()
    st, d = api('/api/nexus/servers', token=t)
    if st == 401: die('token invalid or expired — log in again')
    # derive username from a DM open call isn't ideal; just report token status
    print(green('✓ authenticated'), dim(f'({len(d.get("servers", []))} servers)'))

def cmd_servers(a):
    t = need_token()
    st, d = api('/api/nexus/servers', token=t)
    for s in d.get('servers', []):
        print(f"{cyan(s['id']):<28} {bold(s['name'])}  {dim(s.get('icon',''))}")

def cmd_channels(a):
    t = need_token()
    st, d = api('/api/nexus/channels/' + a.server, token=t)
    if isinstance(d, dict) and d.get('error'): die(d['error'])
    for ch in d.get('channels', []):
        print(f"{cyan(ch['id']):<28} {ch.get('type','text'):<12} {bold(ch['name'])}  {dim(ch.get('topic',''))}")

def cmd_dms(a):
    t = need_token()
    st, d = api('/api/nexus/dms', token=t)
    convos = d.get('dms', [])
    if not convos: print(dim('no conversations yet')); return
    for cv in convos:
        who = magenta(cv['user']) + ('●' if cv.get('online') else '')
        pre = ('You: ' if cv.get('from') else '') + (cv.get('last') or '')
        print(f"{who:<24} {dim(pre[:60])}")

def _fmt_msg(m):
    who = green(m['author'])
    txt = m.get('content') or ''
    atts = m.get('attachments') or '[]'
    tag = ''
    if atts and atts != '[]':
        try:
            arr = json.loads(atts) if isinstance(atts, str) else atts
            tag = ' ' + yellow('[' + ', '.join(x.get('type', 'file') for x in arr) + ']')
        except Exception: tag = ' ' + yellow('[attachment]')
    return f"{dim(m.get('created_at','')[-8:])} {who}: {txt}{tag}"

def cmd_read(a):
    t = need_token()
    st, d = api('/api/nexus/messages/' + a.channel, token=t)
    if isinstance(d, dict) and d.get('error'): die(d['error'])
    msgs = d.get('messages', [])[-a.n:]
    for m in msgs: print(_fmt_msg(m))

def _resolve_dm(t, username):
    st, d = api('/api/nexus/dm/open', 'POST', {'username': username}, token=t)
    if st != 200 or not isinstance(d, dict) or not d.get('channel_id'):
        die((d.get('error') if isinstance(d, dict) else str(d)) or 'could not open DM')
    return d['channel_id']

def cmd_send(a):
    t = need_token()
    text = ' '.join(a.text)
    st, d = api('/api/nexus/messages', 'POST', {'channel_id': a.channel, 'content': text}, token=t)
    if st == 200: print(green('✓ sent'))
    elif st == 402: print(red('402 — staff only (announcement channel) 🗿'))
    else: die((d.get('error') if isinstance(d, dict) else str(d)) or f'failed ({st})')

def cmd_dm(a):
    t = need_token()
    cid = _resolve_dm(t, a.username)
    text = ' '.join(a.text)
    st, d = api('/api/nexus/messages', 'POST', {'channel_id': cid, 'content': text}, token=t)
    print(green(f'✓ sent to {a.username}') if st == 200 else red(f'failed ({st})'))

def cmd_upload(a):
    t = need_token()
    if not os.path.isfile(a.file): die('no such file: ' + a.file)
    # multipart upload
    boundary = '----nexuscli' + os.urandom(8).hex()
    fn = os.path.basename(a.file)
    with open(a.file, 'rb') as f: content = f.read()
    body = (f'--{boundary}\r\nContent-Disposition: form-data; name="file"; filename="{fn}"\r\n'
            f'Content-Type: application/octet-stream\r\n\r\n').encode() + content + f'\r\n--{boundary}--\r\n'.encode()
    req = urllib.request.Request(BASE + '/api/nexus/upload', data=body, method='POST',
        headers={'Authorization': 'Bearer ' + t, 'Content-Type': 'multipart/form-data; boundary=' + boundary})
    with urllib.request.urlopen(req, timeout=60) as r:
        d = json.loads(r.read().decode())
    if not d.get('url'): die(d.get('error', 'upload failed'))
    cid = a.channel if not a.dm else _resolve_dm(t, a.channel)
    st, _ = api('/api/nexus/messages', 'POST',
        {'channel_id': cid, 'content': '', 'attachments': json.dumps([{'url': d['url']}])}, token=t)
    print(green(f'✓ uploaded & posted {fn}') if st == 200 else red(f'posted-failed ({st})'))

def cmd_gif(a):
    t = need_token()
    st, d = api('/api/nexus/gifs?q=' + urllib.parse.quote(a.query))
    gifs = d.get('gifs', [])
    if not gifs: die('no gifs found for: ' + a.query)
    for i, g in enumerate(gifs): print(f"  [{i}] {dim(g.get('alt','gif'))}  {g.get('vid','')}")
    if not a.to: print(dim('\ntip: add --to <channel|@user> and --pick <n> to send one')); return
    idx = a.pick if a.pick is not None else 0
    vid = gifs[idx].get('vid') or gifs[idx].get('src')
    cid = _resolve_dm(t, a.to[1:]) if a.to.startswith('@') else a.to
    st, _ = api('/api/nexus/messages', 'POST',
        {'channel_id': cid, 'content': '', 'attachments': json.dumps([{'url': vid, 'type': 'video'}])}, token=t)
    print(green(f'✓ sent gif #{idx} to {a.to}') if st == 200 else red(f'failed ({st})'))

def cmd_mobile(a):
    """Log the CLI in by scanning a QR with your phone (phone must already be logged into Nexus)."""
    import subprocess, shutil
    st, d = api('/api/nexus/qr/new', 'POST', {})
    if not isinstance(d, dict) or not d.get('code'):
        die('could not create a login code')
    code = d['code']
    # public page the phone opens to approve this sign-in
    web = 'https://luisearch.pages.dev' if BASE.startswith('http://127') or BASE.startswith('http://localhost') else BASE
    url = f'{web}/link?c={code}&m=login'

    print(bold('\n  📱 Nexus — sign in with your phone\n'))
    if shutil.which('qrencode'):
        try:
            qr = subprocess.run(['qrencode', '-t', 'ANSIUTF8', '-m', '2', url],
                                capture_output=True, text=True, timeout=10)
            print(qr.stdout)
        except Exception:
            pass
    else:
        print(dim('  (install `qrencode` to see a scannable QR here)\n'))
    print('  ' + dim('scan it, or open this on your phone:'))
    print('  ' + cyan(url))
    print('  ' + dim(f'code: {bold(code)}   ·   waiting for approval… (Ctrl-C to cancel)\n'))

    spin = '|/-\\'
    i = 0
    try:
        while True:
            st, d = api('/api/nexus/qr/poll?c=' + urllib.parse.quote(code), timeout=10)
            if isinstance(d, dict) and d.get('approved') and d.get('token'):
                save_token(d['token'])
                print('\r  ' + green(f'✓ linked! logged in as {bold(d.get("username","?"))}') + '        ')
                return
            if isinstance(d, dict) and d.get('error'):  # expired
                die('code expired — run `nexus mobile` again')
            sys.stdout.write('\r  ' + dim('waiting ' + spin[i % 4] + ' ')); sys.stdout.flush()
            i += 1
            time.sleep(1.5)
    except KeyboardInterrupt:
        print(dim('\n  cancelled'))

UPDATE_URL = os.environ.get('NEXUS_UPDATE_URL', 'https://update.luisearch.pages.dev/nexus')

def do_update(silent=False):
    """Fetch the latest CLI and replace this script in place. Returns (changed, old_ver, new_ver)."""
    import re as _re
    def _ver(b):
        m = _re.search(rb'__version__\s*=\s*["\']([^"\']+)["\']', b)
        return m.group(1).decode() if m else '?'
    target = os.path.realpath(__file__)             # resolve through the ~/.local/bin symlink
    req = urllib.request.Request(UPDATE_URL, headers={
        'User-Agent': 'nexus-cli/updater', 'Accept': 'text/plain', 'Cache-Control': 'no-cache'})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            new = r.read()
    except Exception as e:
        if silent: return (False, __version__, __version__)
        die(f'update failed: {e}')
    if not new.startswith(b'#!') and b'def main' not in new:
        if silent: return (False, __version__, __version__)
        die('downloaded file does not look like the CLI — aborting (nothing changed)')
    try:
        with open(target, 'rb') as f: old = f.read()
    except Exception:
        old = b''
    if new == old:
        return (False, __version__, __version__)
    tmp = target + '.new'                            # atomic: temp file then rename over
    with open(tmp, 'wb') as f: f.write(new)
    os.chmod(tmp, 0o755)
    os.replace(tmp, target)
    return (True, _ver(old), _ver(new))

def cmd_update(a):
    print(dim(f'checking {UPDATE_URL} …'))
    changed, ov, nv = do_update()
    if changed:
        print(green('✓ updated  ') + bold(f'v{ov} → v{nv}'))
    else:
        print(green('✓ already up to date') + dim(f'  (v{__version__})'))

def cmd_version(a):
    print(f'nexus {bold("v"+__version__)}')

SERVICE_NAME = 'nexus-updater'
SERVICE_FILE = os.path.expanduser(f'~/.config/systemd/user/{SERVICE_NAME}.service')
DAEMON_LOG = os.path.join(CFG_DIR, 'daemon.log')
DAEMON_PID = os.path.join(CFG_DIR, 'daemon.pid')

def cmd_daemon(a):
    """Background loop: check the update channel every `interval` and auto-update. Run via service/nohup."""
    try: sys.stdout.reconfigure(line_buffering=True)
    except Exception: pass
    target = os.path.realpath(__file__)
    def log(m): print(f'[{time.strftime("%Y-%m-%d %H:%M:%S")}] {m}')
    log(f'nexus updater daemon started (pid {os.getpid()}, v{__version__})')
    while True:
        cfg = load_cfg()
        interval = cfg.get('interval', 21600)
        rv = _remote_version()
        if rv and _ver_tuple(rv) > _ver_tuple(__version__):
            if cfg.get('auto_update', True):
                changed, ov, nv = do_update(silent=True)
                if changed:
                    log(f'auto-updated v{ov} → v{nv} — restarting daemon on new version')
                    os.execv(sys.executable, [sys.executable, target, 'daemon'])
            else:
                log(f'update available: v{rv} (auto_update off — run `nexus update`)')
        time.sleep(interval)

def _systemd_ok():
    import shutil, subprocess
    if not shutil.which('systemctl'): return False
    try:
        return subprocess.run(['systemctl', '--user', 'is-system-running'],
                              capture_output=True, timeout=5).returncode is not None
    except Exception:
        return False

def cmd_service(a):
    import shutil, subprocess
    action = a.action or 'install'
    target = os.path.realpath(__file__)
    py = sys.executable

    def sc(*args):
        return subprocess.run(['systemctl', '--user', *args], capture_output=True, text=True)

    if action == 'install':
        os.makedirs(CFG_DIR, exist_ok=True)
        cfg = load_cfg()
        if _systemd_ok():
            os.makedirs(os.path.dirname(SERVICE_FILE), exist_ok=True)
            with open(SERVICE_FILE, 'w') as f:
                f.write(f"""[Unit]
Description=Nexus CLI auto-updater
After=network-online.target

[Service]
Type=simple
ExecStart={py} {target} daemon
Restart=always
RestartSec=30

[Install]
WantedBy=default.target
""")
            sc('daemon-reload')
            sc('enable', '--now', SERVICE_NAME)
            # keep it running even when you're logged out (best-effort)
            try: subprocess.run(['loginctl', 'enable-linger', os.environ.get('USER', '')], capture_output=True, timeout=5)
            except Exception: pass
            print(green('✓ installed systemd service ') + dim(SERVICE_NAME) + green(' — running & enabled on boot'))
            print(dim(f'  checks every {fmt_interval(cfg["interval"])} · logs: journalctl --user -u {SERVICE_NAME} -f'))
        else:
            # nohup fallback
            _daemon_nohup_start(py, target)
            print(green('✓ started background updater with nohup ') + dim('(no systemd here)'))
            print(dim(f'  checks every {fmt_interval(cfg["interval"])} · logs: {DAEMON_LOG}'))
        return

    if action == 'status':
        if _systemd_ok() and os.path.exists(SERVICE_FILE):
            r = sc('is-active', SERVICE_NAME)
            print('systemd service: ' + (green('active') if r.stdout.strip() == 'active' else red(r.stdout.strip() or 'inactive')))
            print(sc('status', '--no-pager', SERVICE_NAME).stdout[:600])
        else:
            pid = _daemon_pid()
            print('nohup daemon: ' + (green(f'running (pid {pid})') if pid else red('not running')))
        return

    if action in ('restart', 'stop', 'remove', 'uninstall'):
        if _systemd_ok() and os.path.exists(SERVICE_FILE):
            if action == 'restart': sc('restart', SERVICE_NAME); print(green('✓ restarted'))
            else:
                sc('disable', '--now', SERVICE_NAME)
                if action in ('remove', 'uninstall'):
                    try: os.remove(SERVICE_FILE)
                    except Exception: pass
                    sc('daemon-reload'); print(green('✓ removed systemd service'))
                else: print(green('✓ stopped'))
        else:
            _daemon_stop()
            if action == 'restart': _daemon_nohup_start(py, target); print(green('✓ restarted (nohup)'))
            else: print(green('✓ stopped'))
        return
    die('usage: nexus service [install|status|restart|stop|remove]')

def _daemon_pid():
    try:
        pid = int(open(DAEMON_PID).read().strip())
        os.kill(pid, 0)
        return pid
    except Exception:
        return None

def _daemon_nohup_start(py, target):
    import subprocess
    _daemon_stop()
    os.makedirs(CFG_DIR, exist_ok=True)
    logf = open(DAEMON_LOG, 'a')
    p = subprocess.Popen([py, target, 'daemon'], stdout=logf, stderr=logf,
                         stdin=subprocess.DEVNULL, start_new_session=True)
    with open(DAEMON_PID, 'w') as f: f.write(str(p.pid))

def _daemon_stop():
    pid = _daemon_pid()
    if pid:
        try: os.kill(pid, 15)
        except Exception: pass
    try: os.remove(DAEMON_PID)
    except Exception: pass

def _ver_tuple(v):
    try: return tuple(int(x) for x in str(v).strip().split('.'))
    except Exception: return (0,)

def _remote_version():
    """Fetch just the tiny version file from the update channel (fast). '' on any failure."""
    url = UPDATE_URL.rsplit('/', 1)[0] + '/version'
    try:
        req = urllib.request.Request(url, headers={'User-Agent': 'nexus-cli/vcheck', 'Cache-Control': 'no-cache'})
        with urllib.request.urlopen(req, timeout=2.5) as r:
            return r.read().decode().strip()[:20]
    except Exception:
        return ''

def maybe_check_update(cmd):
    # skip for these commands, non-interactive shells, env override, or when disabled in settings
    if cmd in ('update', 'version', 'settings', 'daemon', 'service') or os.environ.get('NEXUS_NO_UPDATE_CHECK'):
        return
    if not (sys.stdin.isatty() and sys.stdout.isatty()):
        return
    cfg = load_cfg()
    if not cfg.get('update_check', True):
        return
    if time.time() < cfg.get('snooze_until', 0):        # user said "later"
        return
    rv = _remote_version()
    if not rv or _ver_tuple(rv) <= _ver_tuple(__version__):
        return
    print(yellow(f'⬆ nexus v{rv} is available') + dim(f' (you have v{__version__})'))
    try:
        ans = input('  update now? [Y = yes / n = skip / l = later] ').strip().lower()
    except (EOFError, KeyboardInterrupt):
        print(); return
    if ans in ('l', 'later'):
        cfg['snooze_until'] = time.time() + 86400        # don't ask again for 24h
        save_cfg(cfg)
        print(dim('  ok — will ask again tomorrow (or run: nexus update)'))
        return
    if ans in ('n', 'no'):
        return
    # default / yes -> update and re-exec on the new version
    changed, ov, nv = do_update()
    print(green('✓ updated  ') + bold(f'v{ov} → v{nv}') if changed else dim('  nothing to update'))
    try:
        os.execv(sys.executable, [sys.executable, os.path.realpath(__file__)] + sys.argv[1:])
    except Exception:
        print(dim('  updated — re-run your command to use the new version')); sys.exit(0)

def cmd_settings(a):
    cfg = load_cfg()
    if not a.key:                                        # show all settings
        print(bold('nexus settings') + dim(f'  ({CONFIG_FILE})'))
        print(f"  update_check  {green('on') if cfg['update_check'] else red('off')}   " + dim('ask on startup when an update exists'))
        print(f"  auto_update   {green('on') if cfg['auto_update'] else red('off')}   " + dim('background daemon applies updates itself'))
        print(f"  interval      {cyan(fmt_interval(cfg['interval']))}   " + dim('how often the daemon checks'))
        print(dim('\n  set with:  nexus settings <key> <value>'))
        print(dim('  e.g.  nexus settings updates off   ·   nexus settings interval 2h   ·   nexus settings auto on'))
        return
    key, val = a.key, (a.value or '')
    aliases = {'updates': 'update_check', 'update': 'update_check', 'check': 'update_check',
               'auto': 'auto_update', 'autoupdate': 'auto_update', 'interval': 'interval', 'every': 'interval'}
    k = aliases.get(key, key)
    if k in ('update_check', 'auto_update'):
        if val.lower() not in ('on', 'off', 'yes', 'no', 'true', 'false', '1', '0'):
            die(f'{key} takes on/off')
        cfg[k] = val.lower() in ('on', 'yes', 'true', '1')
        cfg['snooze_until'] = 0
        save_cfg(cfg); print(green(f'✓ {k} = {"on" if cfg[k] else "off"}'))
    elif k == 'interval':
        sec = parse_interval(val)
        if not sec: die("interval like: 30m, 2h, 1d, or seconds")
        cfg['interval'] = sec; save_cfg(cfg)
        print(green(f'✓ interval = {fmt_interval(sec)}') + dim('  (restart the service to apply: nexus service restart)'))
    else:
        die(f'unknown setting "{key}" — try: updates, auto, interval')

def cmd_watch(a):
    t = need_token()
    try: sys.stdout.reconfigure(line_buffering=True)   # flush per line even when piped to a file
    except Exception: pass
    target = a.channel
    # build a channel_id -> name map so we show names, not raw ids
    names = {}
    _, sd = api('/api/nexus/servers', token=t)
    for s in (sd.get('servers', []) if isinstance(sd, dict) else []):
        _, cd = api('/api/nexus/channels/' + s['id'], token=t)
        for ch in (cd.get('channels', []) if isinstance(cd, dict) else []):
            names[ch['id']] = '#' + ch['name'] + dim('·' + s['name'])
    def where_of(cid):
        if cid.startswith('dm:'):
            parts = cid[3:].split(':')
            return magenta('@' + '↔'.join(parts))     # DM: show both handles
        return names.get(cid, '#' + cid[:8])
    st, d = api('/api/nexus/events?after=-1&token=' + urllib.parse.quote(t))
    cursor = d.get('cursor', 0) if isinstance(d, dict) else 0
    print(dim(f'watching {where_of(target) if target else "everything"} — Ctrl-C to stop\n'))
    try:
        while True:
            st, d = api('/api/nexus/events?after=%s&token=%s' % (cursor, urllib.parse.quote(t)), timeout=40)
            if not isinstance(d, dict): time.sleep(2); continue
            cursor = d.get('cursor', cursor)
            for e in d.get('events', []):
                if e.get('type') != 'message':          # DMs already arrive as 'message' events; skip the dup 'dm' pings
                    continue
                m = e.get('message', {})
                cid = m.get('channel_id', '')
                if target and cid != target: continue
                where = '' if target else '  ' + dim(where_of(cid))
                print(_fmt_msg(m) + where)
    except KeyboardInterrupt:
        print(dim('\nstopped'))

def main():
    p = argparse.ArgumentParser(prog='nexus', description='Nexus command-line client',
                                epilog='Run "nexus <command> -h" for details on a command.')
    p.add_argument('-v', '--version', action='version', version=f'nexus v{__version__}')
    sub = p.add_subparsers(dest='cmd')
    sub.add_parser('version').set_defaults(fn=cmd_version)

    sp = sub.add_parser('login'); sp.add_argument('username'); sp.add_argument('-p', '--password'); sp.set_defaults(fn=cmd_login)
    sub.add_parser('whoami').set_defaults(fn=cmd_whoami)
    sub.add_parser('mobile').set_defaults(fn=cmd_mobile)
    sub.add_parser('update').set_defaults(fn=cmd_update)
    sub.add_parser('logout').set_defaults(fn=cmd_logout)
    sp = sub.add_parser('settings', help='view/change CLI settings'); sp.add_argument('key', nargs='?'); sp.add_argument('value', nargs='?'); sp.set_defaults(fn=cmd_settings)
    sub.add_parser('daemon', help='run the background auto-updater loop (used by the service)').set_defaults(fn=cmd_daemon)
    sp = sub.add_parser('service', help='install/manage the always-on updater service'); sp.add_argument('action', nargs='?', choices=['install','status','restart','stop','remove','uninstall']); sp.set_defaults(fn=cmd_service)
    sub.add_parser('servers').set_defaults(fn=cmd_servers)
    sp = sub.add_parser('channels'); sp.add_argument('server'); sp.set_defaults(fn=cmd_channels)
    sub.add_parser('dms').set_defaults(fn=cmd_dms)
    sp = sub.add_parser('read'); sp.add_argument('channel'); sp.add_argument('n', nargs='?', type=int, default=20); sp.set_defaults(fn=cmd_read)
    sp = sub.add_parser('send'); sp.add_argument('channel'); sp.add_argument('text', nargs='+'); sp.set_defaults(fn=cmd_send)
    sp = sub.add_parser('dm'); sp.add_argument('username'); sp.add_argument('text', nargs='+'); sp.set_defaults(fn=cmd_dm)
    sp = sub.add_parser('upload'); sp.add_argument('channel'); sp.add_argument('file'); sp.add_argument('--dm', action='store_true', help='treat channel as a @username'); sp.set_defaults(fn=cmd_upload)
    sp = sub.add_parser('gif'); sp.add_argument('query'); sp.add_argument('--to'); sp.add_argument('--pick', type=int); sp.set_defaults(fn=cmd_gif)
    sp = sub.add_parser('watch'); sp.add_argument('channel', nargs='?'); sp.set_defaults(fn=cmd_watch)

    a = p.parse_args()
    if not getattr(a, 'cmd', None):
        # bare `nexus` -> show a friendly overview instead of an argparse error
        print(bold('nexus') + dim(f' v{__version__} — LuiSearch / Nexus command-line client'))
        print(dim('  endpoint: ') + BASE + (dim('  (logged in)') if load_token() else dim('  (not logged in — run: nexus login <user>)')))
        print()
        p.print_help()
        return
    maybe_check_update(a.cmd)
    a.fn(a)

if __name__ == '__main__':
    main()
