/*
 * lft_watch.c -- ncurses continuous monitoring mode for LFT
 *
 * Compiled only when HAVE_NCURSES is defined (./configure detects ncurses
 * or the user provides --with-ncurses=PATH).
 *
 * This file is part of LFT.
 *
 * The LFT software provided in this Distribution is
 * Copyright 2007 VOSTROM Holdings, Inc.
 *
 * The full text of our legal notices is contained in the file called
 * COPYING, included with this Distribution.
 */

#include "config/acconfig.h"

#ifdef HAVE_NCURSES

#include "lft_watch.h"
#include "lft_lib.h"
#include "whois.h"

/* ASCIIOutput is defined in lft_lib.c but not exposed in lft_lib.h */
void ASCIIOutput(lft_session_params *sess);

#include <ncurses.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
#include <math.h>
#include <signal.h>
#include <time.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/time.h>
#include <sys/ioctl.h>      /* struct winsize, TIOCGWINSZ — Linux glibc
                             * does not pull these in transitively the way
                             * macOS does, so include explicitly. */
#include <arpa/inet.h>
#include <locale.h>

/* Forward declarations for helper functions */

/* =========================================================================
 * Internal helpers
 * ========================================================================= */

/* Braille spinner frames (Unicode, 8-frame cycle) */
static const char *SPINNER_FRAMES[WATCH_SPINNER_FRAMES] = {
    "\xe2\xa0\x8b",  /* ⠋ */
    "\xe2\xa0\x99",  /* ⠙ */
    "\xe2\xa0\xb9",  /* ⠹ */
    "\xe2\xa0\xb8",  /* ⠸ */
    "\xe2\xa0\xbc",  /* ⠼ */
    "\xe2\xa0\xb4",  /* ⠴ */
    "\xe2\xa0\xa6",  /* ⠦ */
    "\xe2\xa0\x87",  /* ⠇ */
};
static const char *SPINNER_ASCII[WATCH_SPINNER_FRAMES] = {
    "-", "\\", "|", "/", "-", "\\", "|", "/"
};

/* Block chars for the RTT history sparkline (Unicode) */
static const char *BLOCK_CHARS[] = {
    " ",                    /* 0: no data / timeout */
    "\xe2\x96\x81",        /* ▁ */
    "\xe2\x96\x82",        /* ▂ */
    "\xe2\x96\x83",        /* ▃ */
    "\xe2\x96\x84",        /* ▄ */
    "\xe2\x96\x85",        /* ▅ */
    "\xe2\x96\x86",        /* ▆ */
    "\xe2\x96\x87",        /* ▇ */
    "\xe2\x96\x88",        /* █ */
};
#define N_BLOCK_CHARS 9

/* ncurses color pair indices */
#define CP_HEADER    1  /* white on blue */
#define CP_NORMAL    2  /* default fg/bg */
#define CP_GREEN     3  /* green (good RTT) */
#define CP_YELLOW    4  /* yellow (mild) */
#define CP_ORANGE    5  /* magenta (moderate — no true orange in ANSI) */
#define CP_MAGENTA   5  /* alias: magenta for loss/cloaked markers */
#define CP_RED       6  /* red (high RTT / loss) */
#define CP_HIGHLIGHT 7  /* reverse video for flash */
#define CP_STATUS    8  /* cyan on black */
#define CP_DRAWER    9  /* white on black for log drawer */
#define CP_BLUE     10  /* blue bold for ↑ sent counter */
#define CP_LABEL    11  /* light steel blue (xterm-256 147) — column labels */
#define CP_TBLHEAD  12  /* alice blue (xterm-256 195) — timeline table headers */
#define CP_SENT     13  /* medium slate blue (xterm-256 75) — ↑ sent counter */
#define CP_KEYHINT  14  /* cornflower (xterm-256 111) — row 2 key-hint menu */
#define CP_KEYLTR   15  /* light sky blue (xterm-256 117) — bracketed letters */

/* RTT → color pair mapping (ms thresholds) — used for bar chart and host/avg */
static int rtt_color(double rtt_ms)
{
    if (rtt_ms <= 0)   return CP_RED;
    if (rtt_ms < 30)   return CP_GREEN;
    if (rtt_ms < 80)   return CP_YELLOW;
    if (rtt_ms < 150)  return CP_ORANGE;
    return CP_RED;
}

/* AVG column — finer thresholds than rtt_color() */
static int avg_color(double avg_ms)
{
    if (avg_ms < 20)   return CP_GREEN;
    if (avg_ms < 100)  return CP_NORMAL;
    if (avg_ms < 250)  return CP_YELLOW;
    return CP_RED;
}

/* JITTER column */
static int jitter_color(double jitter_ms)
{
    if (jitter_ms < 10)  return CP_GREEN;
    if (jitter_ms < 30)  return CP_YELLOW;
    return CP_RED;
}

/* HOST column — based on loss rate, not RTT */
static int host_color(double loss_pct)
{
    if (loss_pct <= 0)  return CP_GREEN;
    if (loss_pct < 10)  return CP_YELLOW;
    return CP_RED;
}

/* Loss % → color pair */
static int loss_color(double loss_pct)
{
    if (loss_pct <= 0)   return CP_NORMAL;
    if (loss_pct < 5)    return CP_YELLOW;
    if (loss_pct < 25)   return CP_ORANGE;
    return CP_RED;
}

/* =========================================================================
 * Signal handling for SIGWINCH (terminal resize)
 * ========================================================================= */

static volatile int watch_resize_pending = 0;

/* Termination signal handlers — installed while ncurses is live so Ctrl-C
 * calls endwin() before dying, leaving the terminal in a clean state. */
static struct sigaction       g_old_sa_sigint_watch;
static struct sigaction       g_old_sa_sigterm_watch;
static struct sigaction       g_old_sa_sighup_watch;
static struct sigaction       g_old_sa_sigquit_watch;
static volatile sig_atomic_t  g_watch_ncurses_active = 0; /* 1 = ncurses is live */
static int                    g_watch_atexit          = 0; /* 1 = atexit registered */

static void watch_sigwinch(int sig)
{
    (void)sig;
    watch_resize_pending = 1;
}

/* Termination signal handler: restore terminal then re-raise the signal.
 * endwin() is not strictly async-signal-safe per POSIX, but it is the
 * standard ncurses idiom (used by vim, htop, etc.) and safe in practice
 * on every platform LFT targets.  Without it Ctrl-C leaves the terminal
 * in raw / cursor-hidden mode. */
static void watch_sig_fatal(int sig)
{
    if (g_watch_ncurses_active) {
        endwin();
        g_watch_ncurses_active = 0;
    }
    /* Restore original handlers before re-raising so the process receives
     * the default action (core-dump for SIGQUIT, terminate for the rest). */
    sigaction(SIGINT,  &g_old_sa_sigint_watch,  NULL);
    sigaction(SIGTERM, &g_old_sa_sigterm_watch,  NULL);
    sigaction(SIGHUP,  &g_old_sa_sighup_watch,   NULL);
    sigaction(SIGQUIT, &g_old_sa_sigquit_watch,  NULL);
    raise(sig);
}

/* atexit safety-net: catches any exit() path that bypasses watch_teardown_ncurses */
static void watch_atexit(void)
{
    if (g_watch_ncurses_active) {
        endwin();
        g_watch_ncurses_active = 0;
    }
}

/* =========================================================================
 * Spinner animation via SIGALRM
 *
 * During a blocking LFTExecute() call, no ncurses code runs so the spinner
 * freezes.  We work around this by arming a repeating SIGALRM that writes
 * the next spinner frame directly to /dev/tty, bypassing both the ncurses
 * layer and the stdout→/dev/null redirect that is active during a trace.
 *
 * Only async-signal-safe operations are used in the handler (write + integer
 * arithmetic).  The handler is installed with SA_RESTART so that select()
 * and recv() inside LFTExecute() restart transparently instead of failing
 * with EINTR.
 * ========================================================================= */

static volatile sig_atomic_t g_alarm_frame  = 0;
static watch_session_t      *g_watch_ws     = NULL; /* set in WatchModeRun for SIGALRM access */
static struct sigaction       g_old_sa_alarm;
static char                  g_alarm_cycle_text[80]; /* "  tracing cycle N/∞  Xs  " */
static int                   g_alarm_cycle_text_len = 0;
/* newterm() state — ncurses is opened on /dev/tty so stdout redirect has no effect */
static SCREEN               *g_ncurses_screen = NULL;
static FILE                 *g_tty_in  = NULL;
static FILE                 *g_tty_out = NULL;

/* Forward declaration — needed by watch_spinner_sigalrm before its definition */
static void watch_render_status_bar(watch_session_t *ws, int spinner_frame);

/* Format integer with comma thousands separator into buf[size].
 * Returns chars written (not including NUL).  Stack-only, safe from signal handler. */
static int fmt_comma(char *buf, int size, int val)
{
    char tmp[24];
    int  n, i, j, next_comma;
    if (size <= 1) { if (size == 1) buf[0] = '\0'; return 0; }
    n = snprintf(tmp, sizeof(tmp), "%d", val);
    next_comma = n % 3;
    if (next_comma == 0) next_comma = 3;
    j = 0;
    for (i = 0; i < n; i++) {
        if (i == next_comma && j < size - 1) {
            buf[j++] = ',';
            next_comma += 3;
        }
        if (j < size - 1)
            buf[j++] = tmp[i];
    }
    if (j < size) buf[j] = '\0';
    return j;
}

/* SIGALRM handler: updates the status bar via ncurses.
 * Because ncurses is initialised on /dev/tty (not stdout), the stdout→/dev/null
 * redirect active during LFTExecute does not affect ncurses output.  We can
 * call ncurses functions and doupdate() directly from the signal handler.
 * Single-threaded app: no re-entrancy risk from ncurses. */
static void watch_spinner_sigalrm(int sig)
{
    int frame;
    (void)sig;
    if (!g_watch_ws) return;

    frame = ((int)g_alarm_frame + 1) % WATCH_SPINNER_FRAMES;
    g_alarm_frame = (sig_atomic_t)frame;

    /* Update sticky-max / sticky-true fields; leave total_replies and
     * total_sent alone — watch_render_status_bar adds the current-cycle
     * live count on top of the accumulated baseline when in_trace=1. */
    {
        int hops = lft_o_get_hops();
        int tgt  = lft_o_get_target();
        (void)lft_o_consume_tfail();   /* consume flash flag even if unused */
        if (g_watch_ws->max_hops_seen > hops) hops = g_watch_ws->max_hops_seen;
        if (g_watch_ws->target_ever_reached)  tgt  = 1;
        if (hops > g_watch_ws->max_hops_seen) g_watch_ws->max_hops_seen = hops;
        if (tgt)  g_watch_ws->target_ever_reached = 1;
    }

    watch_render_status_bar(g_watch_ws, frame);
    wnoutrefresh(stdscr);
    doupdate();
}

/* Arm the 250 ms SIGALRM spinner. */
static void watch_spinner_arm(void)
{
    struct sigaction sa;
    struct itimerval itv;

    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = watch_spinner_sigalrm;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags   = SA_RESTART;   /* restart select()/recv() on signal */
    sigaction(SIGALRM, &sa, &g_old_sa_alarm);

    itv.it_value.tv_sec    = 0;
    itv.it_value.tv_usec   = 250000;
    itv.it_interval.tv_sec  = 0;
    itv.it_interval.tv_usec = 250000;
    setitimer(ITIMER_REAL, &itv, NULL);
}

/* Disarm the SIGALRM spinner and restore the previous handler. */
static void watch_spinner_disarm(void)
{
    struct itimerval zero;
    memset(&zero, 0, sizeof(zero));
    setitimer(ITIMER_REAL, &zero, NULL);
    sigaction(SIGALRM, &g_old_sa_alarm, NULL);
}

/* =========================================================================
 * Log drawer ring buffer
 * ========================================================================= */

static void watch_log_append(watch_session_t *ws, const char *msg)
{
    int msglen = (int)strlen(msg);
    int i;
    if (msglen <= 0) return;
    for (i = 0; i < msglen; i++) {
        if (ws->log_len < WATCH_LOG_BUF) {
            /* Space available: write at tail, grow length */
            ws->log_buf[(ws->log_head + ws->log_len) % WATCH_LOG_BUF] = msg[i];
            ws->log_len++;
        } else {
            /* Buffer full: write at head (overwrites oldest byte), advance head.
             * log_len stays at WATCH_LOG_BUF — the ring always stays full. */
            ws->log_buf[ws->log_head] = msg[i];
            ws->log_head = (ws->log_head + 1) % WATCH_LOG_BUF;
        }
    }
}

/* =========================================================================
 * Stats accumulator
 * ========================================================================= */

static void watch_update_hop(watch_hop_t *h, const char *host,
                              double rtt_ms,
                              int asnumber, const char *netname)
{
    int idx;

    if (host && host[0])
        snprintf(h->host, sizeof(h->host), "%s", host);

    h->cycles_total++;

    if (rtt_ms > 0) {
        h->cycles_seen++;
        h->rtt_last = rtt_ms;
        if (h->cycles_seen == 1) {
            h->rtt_min = h->rtt_max = rtt_ms;
            h->rtt_sum = rtt_ms;
        } else {
            if (rtt_ms < h->rtt_min) h->rtt_min = rtt_ms;
            if (rtt_ms > h->rtt_max) h->rtt_max = rtt_ms;
            h->rtt_sum += rtt_ms;
        }
        h->rtt_avg    = h->rtt_sum / h->cycles_seen;
        h->rtt_jitter = h->rtt_max - h->rtt_min;
    }

    /* Store in circular history */
    idx = h->history_head;
    h->rtt_history[idx] = rtt_ms; /* 0 = no reply */
    h->history_head = (h->history_head + 1) % WATCH_HISTORY_LEN;
    if (h->history_count < WATCH_HISTORY_LEN)
        h->history_count++;

    /* whois fields */
    if (asnumber > 0)
        snprintf(h->asn, sizeof(h->asn), "AS%d", asnumber);
    if (netname && netname[0] && strcmp(netname, "NULL") != 0)
        snprintf(h->netname, sizeof(h->netname), "%.63s", netname);
}

/* =========================================================================
 * Display address helper — IP or PTR hostname (F4)
 * ========================================================================= */

/* Return the display string for a hop: hostname if show_hostnames and
 * one is available; otherwise the IP address, or "*" for no reply. */
static const char *hop_display_addr(const watch_hop_t *h, int show_hostnames)
{
    if (show_hostnames && h->hostname[0])
        return h->hostname;
    return h->host[0] ? h->host : "*";
}

/* =========================================================================
 * Per-IP annotation cache — implementation
 *
 * FNV-1a hash over the address bytes (4 for IPv4, 16 for IPv6), masked to
 * WATCH_IP_CACHE_BUCKETS-1.  Collision chains are singly-linked lists.
 * All memory is malloc'd and freed by watch_ip_cache_free().
 * ========================================================================= */

/* FNV-1a hash over the relevant address bytes */
static uint32_t watch_ip_hash(int af, const watch_ipaddr_t *addr)
{
    uint32_t h = 2166136261u;   /* FNV offset basis */
    int len = (af == AF_INET6) ? 16 : 4;
    int i;
    for (i = 0; i < len; i++) {
        h ^= (uint32_t)addr->raw[i];
        h *= 16777619u;         /* FNV prime */
    }
    return h & (WATCH_IP_CACHE_BUCKETS - 1);
}

static int watch_ip_equal(int af,
                          const watch_ipaddr_t *a,
                          const watch_ipaddr_t *b)
{
    int len = (af == AF_INET6) ? 16 : 4;
    return memcmp(a->raw, b->raw, (size_t)len) == 0;
}

/* Parse a dotted-decimal or colon-hex string into af+addr.
 * Returns 1 on success, 0 if the string is not a valid IP. */
static int watch_ip_parse(const char *s, int *af_out, watch_ipaddr_t *addr_out)
{
    memset(addr_out, 0, sizeof(*addr_out));
    if (inet_pton(AF_INET6, s, &addr_out->v6) == 1) {
        *af_out = AF_INET6;
        return 1;
    }
    if (inet_pton(AF_INET, s, &addr_out->v4) == 1) {
        *af_out = AF_INET;
        return 1;
    }
    return 0;
}

/* Look up an IP in the cache.  Returns the record or NULL if absent. */
static watch_ip_record_t *watch_ip_cache_lookup(watch_ip_cache_t *c,
                                                 int af,
                                                 const watch_ipaddr_t *addr)
{
    uint32_t bucket = watch_ip_hash(af, addr);
    watch_ip_record_t *r = c->buckets[bucket];
    while (r) {
        if (r->af == af && watch_ip_equal(af, &r->addr, addr))
            return r;
        r = r->next;
    }
    return NULL;
}

/* Look up or create a zeroed record for the given IP.
 * Returns NULL only on malloc failure (extremely rare). */
static watch_ip_record_t *watch_ip_cache_get(watch_ip_cache_t *c,
                                              int af,
                                              const watch_ipaddr_t *addr)
{
    uint32_t bucket = watch_ip_hash(af, addr);
    watch_ip_record_t *r = c->buckets[bucket];

    while (r) {
        if (r->af == af && watch_ip_equal(af, &r->addr, addr))
            return r;
        r = r->next;
    }

    /* Not found — allocate a new zeroed record and prepend to chain */
    r = (watch_ip_record_t *)calloc(1, sizeof(*r));
    if (!r)
        return NULL;
    r->af   = af;
    r->addr = *addr;
    r->next = c->buckets[bucket];
    c->buckets[bucket] = r;
    c->count++;
    return r;
}

/* Free all records in the cache.  The cache struct itself is embedded in
 * watch_session_t (stack-allocated) so we only free the heap nodes. */
static void watch_ip_cache_free(watch_ip_cache_t *c)
{
    int i;
    for (i = 0; i < WATCH_IP_CACHE_BUCKETS; i++) {
        watch_ip_record_t *r = c->buckets[i];
        while (r) {
            watch_ip_record_t *next = r->next;
            free(r);
            r = next;
        }
        c->buckets[i] = NULL;
    }
    c->count = 0;
}

/* Copy annotation fields from a cache record into a hop's display copies.
 * Only overwrites fields that the record has (non-empty string). */
static void watch_ip_cache_copy_to_hop(const watch_ip_record_t *rec,
                                        watch_hop_t *h)
{
    if (rec->asn[0])
        snprintf(h->asn,      sizeof(h->asn),      "%.31s",  rec->asn);
    if (rec->netname[0])
        snprintf(h->netname,  sizeof(h->netname),  "%.63s",  rec->netname);
    if (rec->orgname[0])
        snprintf(h->orgname,  sizeof(h->orgname),  "%.63s",  rec->orgname);
    if (rec->hostname[0])
        snprintf(h->hostname, sizeof(h->hostname), "%.255s", rec->hostname);
}

/* =========================================================================
 * pWhoIs lookup helpers (F2)
 * ========================================================================= */

/* Query pWhoIs for a single hop and populate the session's ip_cache record.
 * The hop's display fields (asn/netname/orgname) are also updated from the
 * result.  ws->watch_whois_wsess must already be initialised before calling.
 * The record's whois_queried flag is set to 1 before the network call so that
 * errors (server down, timeout) don't cause infinite re-tries. */
static void watch_whois_query_one(watch_session_t *ws, watch_hop_t *h)
{
    int              af;
    watch_ipaddr_t   addr;
    watch_ip_record_t *rec;
    char              asn_buf[32];

    if (!watch_ip_parse(h->host, &af, &addr))
        return;   /* not a parseable IP — skip */

    rec = watch_ip_cache_get(&ws->ip_cache, af, &addr);
    if (!rec)
        return;   /* malloc failure */

    /* Cache-hit short-circuit: skip the network lookup only if we already
     * have USEFUL data cached (at least one of asn/netname/orgname is
     * populated).  If a previous attempt came back empty — transient
     * pWhoIs timeout, server hiccup, partial response — keep trying on
     * subsequent cycles so gaps eventually fill in.  IANA pre-screen
     * below still runs first, so private IPs never touch pWhoIs. */
    if (rec->whois_queried
        && (rec->asn[0] || rec->netname[0] || rec->orgname[0])) {
        watch_ip_cache_copy_to_hop(rec, h);
        return;
    }

    rec->whois_queried = 1;   /* mark before I/O so a live query error
                               * doesn't cause re-entry this same cycle */

    /* --------------------------------------------------------------------
     * IANA pre-screen: private / special-purpose addresses (RFC1918, CGNAT,
     * loopback, link-local, documentation, multicast, etc.) never have a
     * useful pWhoIs record.  Asking pWhoIs for one is worse than useless —
     * the server sometimes closes the TCP connection after a nonsense
     * query, which then breaks the NEXT hop's query too.  Detect them
     * locally and fill the display fields with the RFC tag + registry name
     * (e.g. NETNAME = ASN = "RFC1918", ORGNAME = "Private-Use"), then
     * return without touching pWhoIs.
     * -------------------------------------------------------------------- */
    {
        char iana_rfc[32]  = "";
        char iana_name[64] = "";
        int  iana_hit = 0;
        if (af == AF_INET)
            iana_hit = lft_iana_v4_lookup(addr.v4,
                                           iana_rfc,  sizeof(iana_rfc),
                                           iana_name, sizeof(iana_name));
        else if (af == AF_INET6)
            iana_hit = lft_iana_v6_lookup(&addr.v6,
                                           iana_rfc,  sizeof(iana_rfc),
                                           iana_name, sizeof(iana_name));
        if (iana_hit) {
            if (iana_rfc[0]) {
                snprintf(rec->asn,     sizeof(rec->asn),     "%.31s", iana_rfc);
                snprintf(rec->netname, sizeof(rec->netname), "%.63s", iana_rfc);
            }
            if (iana_name[0])
                snprintf(rec->orgname, sizeof(rec->orgname), "%.63s", iana_name);
            watch_ip_cache_copy_to_hop(rec, h);
            return;
        }
    }

    if (w_lookup_all_pwhois(ws->watch_whois_wsess, h->host) != 0)
        return;

    /* Store results in the cache record */
    asn_buf[0] = '\0';
    if (ws->watch_whois_wsess->consolidated_asn[0] &&
        ws->watch_whois_wsess->consolidated_asn[0] != '?') {
        if (ws->watch_whois_wsess->consolidated_asn[0] >= '0' &&
            ws->watch_whois_wsess->consolidated_asn[0] <= '9')
            snprintf(asn_buf, sizeof(asn_buf), "AS%s",
                     ws->watch_whois_wsess->consolidated_asn);
        else
            snprintf(asn_buf, sizeof(asn_buf), "%s",
                     ws->watch_whois_wsess->consolidated_asn);
    }
    if (asn_buf[0])
        snprintf(rec->asn, sizeof(rec->asn), "%.31s", asn_buf);

    if (ws->watch_whois_wsess->consolidated_netname[0] &&
        strcmp(ws->watch_whois_wsess->consolidated_netname, "NULL") != 0)
        snprintf(rec->netname, sizeof(rec->netname), "%.63s",
                 ws->watch_whois_wsess->consolidated_netname);

    if (ws->watch_whois_wsess->consolidated_orgname[0] &&
        strcmp(ws->watch_whois_wsess->consolidated_orgname, "NULL") != 0)
        snprintf(rec->orgname, sizeof(rec->orgname), "%.63s",
                 ws->watch_whois_wsess->consolidated_orgname);

    /* Push results to the hop's display copies */
    watch_ip_cache_copy_to_hop(rec, h);
}

/* Query pWhoIs for all current hops that haven't been queried yet.
 * Uses per-hop individual queries (not bulk) to avoid the pWhois response
 * parser's entity_id misalignment bug with sparse records (private/CGNAT IPs).
 * Initialises ws->watch_whois_wsess if needed.
 * Sets ws->whois_bulk_done = 1 when all current hops have been attempted. */
static void watch_whois_bulk_query(watch_session_t *ws)
{
    int i;

    if (!ws->watch_whois_wsess)
        ws->watch_whois_wsess = w_init();
    if (!ws->watch_whois_wsess) {
        ws->whois_bulk_done = 1;
        return;
    }

    for (i = 0; i < ws->n_hops; i++) {
        watch_hop_t *h = &ws->hops[i];
        if (!h->host[0] || strcmp(h->host, "*") == 0)
            continue;
        watch_whois_query_one(ws, h);
    }

    ws->whois_bulk_done = 1;
}

/* Query pWhoIs for any hop that has an IP but no cache record yet.
 * Called after each cycle to pick up new hops discovered after the initial
 * startup query — the ip_cache prevents re-querying IPs already seen. */
static void watch_whois_update_new_hops(watch_session_t *ws)
{
    int i;

    if (!ws->whois_enabled || !ws->watch_whois_wsess)
        return;

    for (i = 0; i < ws->n_hops; i++) {
        watch_hop_t *h = &ws->hops[i];
        if (!h->host[0] || strcmp(h->host, "*") == 0)
            continue;
        watch_whois_query_one(ws, h);   /* cache check + query + hop field sync */
    }
}

/* =========================================================================
 * Trace execution — sub-session per cycle
 * ========================================================================= */

/* After LFTExecute returns, LFT's dns_cache contains whatever c-ares managed
 * to resolve during the trace (async; may be incomplete if queries timed out).
 * Inject hostnames from the watch ip_cache for any IPs already resolved in a
 * previous cycle but missing/incomplete this cycle.  This makes the watch
 * ip_cache the authoritative persistent DNS store and avoids relying on c-ares
 * finishing in time on every cycle.
 *
 * Called in watch_run_one_cycle() between LFTExecute() and watch_capture_from_sess(). */
static void watch_inject_cached_hostnames(watch_session_t *ws,
                                          lft_session_params *sess)
{
    int b;
    for (b = 0; b < WATCH_IP_CACHE_BUCKETS; b++) {
        watch_ip_record_t *rec = ws->ip_cache.buckets[b];
        while (rec) {
            if (rec->af == AF_INET && rec->hostname[0]) {
                struct in_addr a = rec->addr.v4;
                int i, found = 0;
                /* Look for this IP in LFT's per-cycle dns_cache */
                for (i = 0; i < sess->dns_cache_count; i++) {
                    if (sess->dns_cache[i].addr.s_addr == a.s_addr) {
                        found = 1;
                        /* c-ares entry exists; fill name if still empty */
                        if (!sess->dns_cache[i].name[0]) {
                            strncpy(sess->dns_cache[i].name, rec->hostname,
                                    sizeof(sess->dns_cache[i].name) - 1);
                            sess->dns_cache[i].name[sizeof(sess->dns_cache[i].name)-1] = '\0';
                        }
                        break;
                    }
                }
                if (!found && sess->dns_cache_count < LFT_DNS_CACHE_SIZE) {
                    /* Not yet in dns_cache this cycle — add from our cache */
                    sess->dns_cache[sess->dns_cache_count].addr = a;
                    strncpy(sess->dns_cache[sess->dns_cache_count].name, rec->hostname,
                            sizeof(sess->dns_cache[0].name) - 1);
                    sess->dns_cache[sess->dns_cache_count].name[sizeof(sess->dns_cache[0].name)-1] = '\0';
                    sess->dns_cache_count++;
                }
            }
            rec = rec->next;
        }
    }
}

/* Capture per-hop data from a completed sub-session into ws */
static void watch_capture_from_sess(watch_session_t *ws,
                                     lft_session_params *sub)
{
    int hopno;
    struct trace_packet_info_s *tp;
    int maxhop;

    if (sub->num_hops)
        maxhop = sub->num_hops;
    else
        maxhop = sub->hop_info_length - 1;

    /* Reset n_alt counters for this cycle before scanning */
    for (hopno = 0; hopno < WATCH_MAX_HOPS; hopno++)
        ws->hops[hopno].n_alt = 0;

    for (hopno = sub->ttl_min; hopno < sub->hop_info_length && hopno <= maxhop; hopno++) {
        double best_rtt = 0.0;
        int    best_asn = 0;
        char   best_host[64] = "";
        char   best_net[512] = "";
        int    first = 1;

        if (!sub->hop_info[hopno].all_rcvd) {
            /* For TCP/UDP traces the target hop (hopno == num_hops) replies with
             * a direct packet (RST or SYN-ACK), not ICMP TTL-exceeded, so
             * hop_info[num_hops].all_rcvd is always 0.  Skip watch_update_hop
             * here; the manual target block in watch_run_one_cycle is the sole
             * handler for that slot — calling it here would double-count
             * cycles_total and insert a spurious history entry. */
            if (sub->num_hops > 0 && hopno == sub->num_hops) {
                if (hopno >= ws->n_hops)
                    ws->n_hops = hopno + 1;
                continue;
            }
            /* no reply at an intermediate hop this cycle */
            watch_update_hop(&ws->hops[hopno], NULL, 0.0, 0, NULL);
            if (hopno >= ws->n_hops)
                ws->n_hops = hopno + 1;
            continue;
        }

        /* Gather data from all packets at this TTL */
        SLIST_FOREACH(tp, &(sub->hop_info[hopno].packets), next_by_hop) {
            double rtt;
            const char *ipstr;

            if (!tp->recv.tv_sec) continue;
            if (tp->last_hop.s_addr == tp->hopaddr.s_addr) continue;

            rtt   = timediff_ms(tp->sent, tp->recv);
            ipstr = inet_ntoa(tp->hopaddr);

            if (first) {
                first = 0;
                best_rtt = rtt;
                best_asn = tp->asnumber;
                snprintf(best_host, sizeof(best_host), "%s", ipstr);
                if (tp->netname[0] && strcmp(tp->netname, "NULL") != 0)
                    snprintf(best_net, sizeof(best_net), "%s", tp->netname);
            } else {
                /* check for multipath (different IP at same TTL) */
                if (strcmp(ipstr, best_host) != 0) {
                    int n = ws->hops[hopno].n_alt;
                    if (n < WATCH_MAX_ALT_IPS) {
                        /* only add if not already listed */
                        int found = 0, j;
                        for (j = 0; j < n; j++) {
                            if (strcmp(ws->hops[hopno].alt_hosts[j], ipstr) == 0) {
                                found = 1;
                                break;
                            }
                        }
                        if (!found)
                            snprintf(ws->hops[hopno].alt_hosts[n++], 64, "%s", ipstr);
                        ws->hops[hopno].n_alt = n;
                    }
                }
                /* use min RTT across probes at this hop */
                if (rtt > 0 && (best_rtt <= 0 || rtt < best_rtt))
                    best_rtt = rtt;
            }
        }

        if (!first) {
            watch_update_hop(&ws->hops[hopno], best_host,
                             best_rtt, best_asn, best_net);
            if (hopno >= ws->n_hops)
                ws->n_hops = hopno + 1;

#ifdef HAVE_CARES
            /* Capture PTR hostname from sub-session dns_cache (F4).
             * Store in both the ip_cache record (authoritative) and the
             * hop's display copy. */
            {
                int di;
                watch_ipaddr_t haddr;
                int haf;
                if (watch_ip_parse(best_host, &haf, &haddr)) {
                    struct in_addr hop_addr4;
                    if (haf == AF_INET)
                        hop_addr4 = haddr.v4;
                    else
                        hop_addr4.s_addr = 0;
                    for (di = 0; di < sub->dns_cache_count; di++) {
                        if (haf == AF_INET &&
                            sub->dns_cache[di].addr.s_addr == hop_addr4.s_addr &&
                            sub->dns_cache[di].name[0]) {
                            watch_ip_record_t *rec =
                                watch_ip_cache_get(&ws->ip_cache, haf, &haddr);
                            if (rec && !rec->hostname[0]) {
                                snprintf(rec->hostname, sizeof(rec->hostname),
                                         "%.255s", sub->dns_cache[di].name);
                                rec->rdns_queried = 1;
                            }
                            snprintf(ws->hops[hopno].hostname,
                                     sizeof(ws->hops[hopno].hostname),
                                     "%s", sub->dns_cache[di].name);
                            break;
                        }
                    }
                }
            }
#endif
        }
    }

    /* Trim ws->n_hops to the last hop that has EVER responded.
     * This prevents no-reply hops beyond the actual path from bloating the
     * display when num_hops==0 (target unreachable, trace timed out, etc.).
     * Cloaked hops in the middle are preserved because we search backwards
     * from the end to find the last hop with any cycles_seen > 0. */
    {
        int last = 0, j;
        for (j = ws->n_hops - 1; j >= 0; j--) {
            if (ws->hops[j].cycles_seen > 0) { last = j + 1; break; }
        }
        if (last > 0 && last < ws->n_hops)
            ws->n_hops = last;
    }
}

/* Reset the per-trace state on sess so LFTExecute can be called again.
 * The raw socket (send_sock) and pcap handle (pcapdescr) are preserved;
 * the HAVE_NCURSES guards in lft_lib.c ensure they are not re-created or
 * closed across cycles. */
static void watch_reset_session(lft_session_params *sess)
{
    struct trace_packet_info_s *tp;

    /* Free all per-probe packet records from the previous cycle.
     * hop_info[].packets entries are the same pointers — freeing
     * trace_packets is sufficient; no separate per-hop free needed.
     *
     * IMPORTANT: the union u holds either:
     *   u.icmp_packet  (struct icmp_trace_packet_s) — used for protocol 2/3 (ICMP);
     *                   u.icmp_packet.packet is a heap pointer allocated by
     *                   generateICMPPacket() and must be freed here.
     *   u.packet       (struct trace_packet_s)       — used for protocol 0/1 (TCP/UDP);
     *                   the first bytes of this struct are an embedded struct ip
     *                   (NOT a pointer).  Reading u.icmp_packet.packet on a TCP
     *                   probe returns the first 8 bytes of struct ip, which is not
     *                   a heap address.  Calling free() on it triggers
     *                   ___BUG_IN_CLIENT_OF_LIBMALLOC_POINTER_BEING_FREED_WAS_NOT_ALLOCATED. */
    while (SLIST_FIRST(&sess->trace_packets)) {
        tp = SLIST_FIRST(&sess->trace_packets);
        SLIST_REMOVE_HEAD(&sess->trace_packets, next);
        if ((sess->protocol == 2 || sess->protocol == 3) &&
            tp->u.icmp_packet.packet) {
            free(tp->u.icmp_packet.packet);
            tp->u.icmp_packet.packet = NULL;
        }
        free(tp);
    }
    sess->trace_packets_num = 0;

    /* Free hop_info array; open_sockets() will re-allocate it next cycle */
    if (sess->hop_info) {
        free(sess->hop_info);
        sess->hop_info = NULL;
    }
    sess->hop_info_length = sess->ttl_min;

    /* Reset per-trace counters and state */
    sess->exit_state        = 0;
    sess->num_hops          = 0;
    sess->num_rcvd          = 0;
    sess->target_open       = 0;
    sess->target_filtered   = 0;
    sess->target_anomaly    = 0;
    sess->trg_probe_is_sent = 0;
    /* DO NOT reset sess->seq_start here.  For UDP it's the IP-ID base
     * embedded in every probe — a router on the path uses it as part of
     * the ECMP 5-tuple/flow hash, so re-randomising it between cycles
     * would route each cycle down a different ECMP branch (defeats the
     * 3.97 UDP ECMP-stability fix).  For TCP it's the ISN; reusing it
     * across cycles is harmless (each cycle is a fresh half-open SYN).
     * For ICMP it's unused (icmp_initial_seq anchors the flow). */

    /* Destroy c-ares channel from the previous cycle so LFTExecute does not
     * leak it.  LFTExecute will create a fresh channel on entry. */
#ifdef HAVE_CARES
    if (sess->ares_chan) {
        ares_destroy(sess->ares_chan);
        sess->ares_chan = NULL;
    }
#endif

    /* Free whois session; LFTExecute will open a fresh one if needed */
    if (sess->wsess) {
        w_close(sess->wsess);
        sess->wsess = NULL;
    }
}

/* Run one trace cycle by resetting per-trace state on proto_sess and
 * calling LFTExecute directly.  The raw socket and pcap handle are
 * reused across cycles (HAVE_NCURSES guards in lft_lib.c prevent them
 * from being re-created or closed). */
static void watch_run_one_cycle(watch_session_t *ws, lft_session_params *proto_sess)
{
    int saved_stdout, saved_stderr;
    int pipe_fds[2];
    int use_pipe;   /* 1 = capture stdout via pipe for log; 0 = /dev/null */
    int i;

    /* Reset per-trace fields; keep send_sock, pcapdescr, and all user params */
    watch_reset_session(proto_sess);

    /* Always redirect stdout+stderr to a pipe so LFT's -VV output is captured
     * into the log drawer ring buffer.  WatchModeRun() forces noisy=2 so there
     * is always data to capture.  On pipe failure fall back to /dev/null. */
    use_pipe = (proto_sess->noisy >= 1);  /* always true: WatchModeRun forces ≥2 */
    pipe_fds[0] = pipe_fds[1] = -1;

    saved_stdout = dup(STDOUT_FILENO);
    saved_stderr = dup(STDERR_FILENO);

    if (use_pipe && pipe(pipe_fds) == 0) {
        /* Non-blocking write end: LFT never stalls if output exceeds pipe buf */
        fcntl(pipe_fds[1], F_SETFL, fcntl(pipe_fds[1], F_GETFL) | O_NONBLOCK);
        dup2(pipe_fds[1], STDOUT_FILENO);
        dup2(pipe_fds[1], STDERR_FILENO);
        close(pipe_fds[1]);
        pipe_fds[1] = -1;
    } else {
        int devnull_fd = open("/dev/null", O_WRONLY);
        use_pipe = 0;
        if (devnull_fd >= 0) {
            dup2(devnull_fd, STDOUT_FILENO);
            dup2(devnull_fd, STDERR_FILENO);
            close(devnull_fd);
        }
    }

    /* style=0 keeps EVT_ON_EXIT from triggering a second WatchModeRun */
    setOutputStyle(0);
    /* S1: keep watch_mode=1 so lft_lib.c guards preserve the raw socket and pcap
     * handle across all cycles.  The first LFTExecute opens them as root; after
     * setuid(getuid()) drops privileges the descriptors remain open and are reused
     * for every subsequent cycle without needing elevated privileges again. */
    /* Ensure rtt_stats is set so ASCIIOutput (view 1) has per-hop chart data */
    if (proto_sess->rtt_stats <= 0)
        proto_sess->rtt_stats = ws->probes_per_hop;

    /* Pre-seed proto_sess->dns_cache from our persistent ip_cache before
     * LFTExecute fires c-ares queries.  lft_ares_fire() already has a dedup
     * check that skips any IP already in dns_cache, so injecting resolved IPs
     * here suppresses redundant PTR queries for hops we have already resolved.
     * Combined with the watch_mode guard in lft_lib.c (dns_cache_count is not
     * zeroed when watch_mode=1), stable-path hops are never re-queried after
     * the first cycle. */
    watch_inject_cached_hostnames(ws, proto_sess);

    /* Prepare the cycle/interval text for the SIGALRM handler and arm it.
     * ncurses is on /dev/tty so SIGALRM can call ncurses directly even while
     * stdout is redirected to /dev/null. */
    {
        char maxcyc[16];
        if (ws->max_cycles > 0)
            snprintf(maxcyc, sizeof(maxcyc), "%d", ws->max_cycles);
        else
            snprintf(maxcyc, sizeof(maxcyc), "\xe2\x88\x9e"); /* ∞ */
        g_alarm_cycle_text_len = snprintf(g_alarm_cycle_text,
                                          sizeof(g_alarm_cycle_text),
                                          "   tracing cycle %d/%s  %ds  ",
                                          ws->cycle, maxcyc, ws->interval);
        if (g_alarm_cycle_text_len < 0 ||
            g_alarm_cycle_text_len >= (int)sizeof(g_alarm_cycle_text))
            g_alarm_cycle_text_len = 0;

        g_alarm_frame = (sig_atomic_t)ws->spinner_frame;
        lft_o_counters_activate();
        watch_spinner_arm();
    }

    /* LFT-AUTOTUNE — apply learned-path knowledge (no-op on first cycle
     * and when --no-learn is set or stable_cycles < 3). */
    lft_auto_tune_from_learned(proto_sess);

    LFTExecute(proto_sess);

    /* Disarm the spinner alarm; stop counter accumulation. */
    watch_spinner_disarm();
    lft_o_counters_deactivate();
    /* Accumulate cumulative counters for the status bar and SIGALRM handler. */
    ws->total_replies += lft_o_get_replies();
    ws->total_sent    += lft_o_get_sent();
    if (lft_o_get_hops() > ws->max_hops_seen)
        ws->max_hops_seen = lft_o_get_hops();
    if (lft_o_get_target())
        ws->target_ever_reached = 1;
    ws->spinner_frame = (int)g_alarm_frame;

    /* Restore stdout + stderr before ncurses refresh */
    fflush(stdout);
    fflush(stderr);
    dup2(saved_stdout, STDOUT_FILENO);
    dup2(saved_stderr, STDERR_FILENO);
    close(saved_stdout);
    close(saved_stderr);

    /* Drain captured verbose output into the log ring buffer.
     * Both ends of the pipe are now closed from stdout/stderr's perspective;
     * the write end was closed after dup2, so read() returns EOF promptly. */
    if (use_pipe && pipe_fds[0] >= 0) {
        char chunk[4096];
        ssize_t nr;
        fcntl(pipe_fds[0], F_SETFL, O_NONBLOCK);
        while ((nr = read(pipe_fds[0], chunk, sizeof(chunk) - 1)) > 0) {
            chunk[nr] = '\0';
            watch_log_append(ws, chunk);
        }
        close(pipe_fds[0]);
        pipe_fds[0] = -1;
    }

    /* Augment LFT's per-cycle dns_cache with hostnames already in our watch
     * ip_cache from previous cycles.  c-ares resets dns_cache_count=0 at the
     * top of every LFTExecute and may not finish all queries before the trace
     * ends; this makes our persistent cache the reliable fallback source. */
    watch_inject_cached_hostnames(ws, proto_sess);

    /* Extract hop data */
    if (proto_sess->hop_info)
        watch_capture_from_sess(ws, proto_sess);
    /* Track current path length (not historical peak).  Using a maximum
     * here would make the ⬡ counter sticky if a prior cycle inflated the
     * count via overshoot probes — set directly so the counter always
     * matches the table. */
    ws->max_hops_seen = ws->n_hops;

    /* LFT-AUTOTUNE — capture learned-path data for use by the next cycle.
     * Only update when the cycle actually reached the target (n_hops > 0
     * and target was hit), so a transient packet-loss cycle doesn't
     * corrupt the learned envelope. */
    if (ws->n_hops > 0 && lft_o_get_target()) {
        int prev_num = proto_sess->learned.valid ? proto_sess->learned.num_hops : -1;
        int max_rtt_ms = 0;
        int min_rtt_ms = 0;
        int found_min  = 0;
        int i;
        for (i = 0; i < ws->n_hops; i++) {
            int avg = (int)ws->hops[i].rtt_avg;
            if (avg <= 0) continue;
            if (avg > max_rtt_ms) max_rtt_ms = avg;
            if (!found_min || avg < min_rtt_ms) {
                min_rtt_ms = avg;
                found_min  = 1;
            }
        }
        proto_sess->learned.num_hops      = ws->n_hops;
        proto_sess->learned.max_rtt_ms    = max_rtt_ms;
        proto_sess->learned.min_rtt_ms    = min_rtt_ms;
        proto_sess->learned.last_protocol = proto_sess->protocol;
        proto_sess->learned.valid         = 1;
        if (prev_num == ws->n_hops)
            proto_sess->learned.stable_cycles++;
        else
            proto_sess->learned.stable_cycles = 1;
    } else if (proto_sess->learned.valid) {
        /* Cycle missed the target — reset stability so we re-broaden
         * the trace until things settle again. */
        proto_sess->learned.stable_cycles = 0;
    }

    /* Handle target: TCP RST/SYN-ACK responses are not stored in hop_info[]
     * (they're direct packets, not ICMP TTL-exceeded), so watch_capture_from_sess
     * leaves the target slot as no-reply for TCP traces.  For ICMP traces the
     * target echo-reply IS stored in hop_info[], so watch_capture_from_sess
     * already called watch_update_hop for it — only do the manual update when
     * hop_info does NOT have a reply for the target slot (TCP/UDP cases). */
    ws->target_hopno    = 0;
    ws->target_open     = 0;
    ws->target_filtered = 0;
    if (proto_sess->num_hops > 0 && proto_sess->remote_address.s_addr != 0) {
        int tgt = proto_sess->num_hops;
        watch_hop_t *wh = &ws->hops[tgt];
        const char *tgt_ip = inet_ntoa(proto_sess->remote_address);

        /* Only insert synthetic target entry when watch_capture_from_sess
         * didn't already record a reply for this hop. */
        int already_captured = (proto_sess->hop_info &&
                                tgt < proto_sess->hop_info_length &&
                                proto_sess->hop_info[tgt].all_rcvd > 0);
        if (!already_captured) {
            if (!wh->host[0])
                snprintf(wh->host, sizeof(wh->host), "%s", tgt_ip);

            wh->cycles_total++;
            wh->cycles_seen++;
            {
                int idx = wh->history_head;
                wh->rtt_history[idx] = 0.001;
                wh->history_head = (wh->history_head + 1) % WATCH_HISTORY_LEN;
                if (wh->history_count < WATCH_HISTORY_LEN) wh->history_count++;
            }
            if (tgt >= ws->n_hops)
                ws->n_hops = tgt + 1;
        }

        ws->target_hopno    = tgt;
        ws->target_open     = proto_sess->target_open;
        ws->target_filtered = proto_sess->target_filtered;
    }

    /* Log brief completion note */
    {
        char logmsg[128];
        snprintf(logmsg, sizeof(logmsg),
                 "cycle %d: %d hops, target_%s\n",
                 ws->cycle, ws->n_hops,
                 proto_sess->target_open > 0 ? "open" :
                 (proto_sess->target_filtered  ? "filtered" :
                  (proto_sess->protocol == 2 || proto_sess->protocol == 3) ? "replied" : "closed"));
        watch_log_append(ws, logmsg);
    }

    /* Detect path change (compare hop IPs to snapshot) */
    ws->path_changed = 0;
    if (ws->cycle > 1) {
        for (i = 0; i < ws->n_hops; i++) {
            if (strcmp(ws->hops[i].host, ws->path_snapshot[i]) != 0) {
                /* Only alert when BOTH sides are real IPs.  A cloaked hop
                 * ('*') is a firewall/rate-limit gap, not a routing change;
                 * appearing/disappearing silently is normal behaviour.       */
                int old_real = ws->path_snapshot[i][0] &&
                               ws->path_snapshot[i][0] != '*';
                int new_real = ws->hops[i].host[0] &&
                               ws->hops[i].host[0] != '*';
                if (old_real && new_real) {
                    ws->path_changed = 1;
                    ws->path_change_time = time(NULL);
                    char logmsg[192];
                    snprintf(logmsg, sizeof(logmsg),
                             "PATH CHANGE at hop %d: was %s, now %s\n",
                             i + 1, ws->path_snapshot[i], ws->hops[i].host);
                    watch_log_append(ws, logmsg);
                    break;
                }
            }
        }
    }
    /* Update snapshot */
    for (i = 0; i < ws->n_hops; i++)
        snprintf(ws->path_snapshot[i], 64, "%s", ws->hops[i].host);
}

/* =========================================================================
 * ncurses init / teardown
 * ========================================================================= */

static int watch_init_ncurses(watch_session_t *ws)
{
    setlocale(LC_ALL, "");  /* enable UTF-8 so Unicode block chars render correctly */

    /* Open ncurses on /dev/tty directly so that redirecting stdout to /dev/null
     * during LFTExecute traces has no effect on ncurses output.  SIGALRM can
     * then call ncurses functions + doupdate() and the model stays in sync —
     * eliminating the blank-flash on the status bar row. */
    g_tty_in  = fopen("/dev/tty", "r");
    g_tty_out = fopen("/dev/tty", "w");
    if (!g_tty_in || !g_tty_out) {
        /* Fall back to stdout-based initscr if /dev/tty unavailable */
        if (g_tty_in)  { fclose(g_tty_in);  g_tty_in  = NULL; }
        if (g_tty_out) { fclose(g_tty_out); g_tty_out = NULL; }
        initscr();
    } else {
        g_ncurses_screen = newterm(NULL, g_tty_out, g_tty_in);
        if (!g_ncurses_screen) {
            fclose(g_tty_in);  g_tty_in  = NULL;
            fclose(g_tty_out); g_tty_out = NULL;
            initscr();
        } else {
            set_term(g_ncurses_screen);
        }
    }
    cbreak();
    noecho();
    keypad(stdscr, TRUE);
    nodelay(stdscr, TRUE); /* non-blocking getch() */
    curs_set(0);           /* hide cursor */

    ws->term_rows = LINES;
    ws->term_cols = COLS;

    if (has_colors()) {
        start_color();
        use_default_colors();
        init_pair(CP_HEADER,    COLOR_WHITE,   COLOR_BLUE);
        init_pair(CP_NORMAL,    -1,            -1);
        init_pair(CP_GREEN,     COLOR_GREEN,   -1);
        init_pair(CP_YELLOW,    COLOR_YELLOW,  -1);
        /* CP_ORANGE — prefer xterm-256 palette colour 208 (bright orange)
         * when the terminal supports it; fall back to COLOR_MAGENTA on
         * 8-colour terminals. */
        if (COLORS >= 256)
            init_pair(CP_ORANGE, 208, -1);
        else
            init_pair(CP_ORANGE, COLOR_MAGENTA, -1);
        init_pair(CP_RED,       COLOR_RED,     -1);
        init_pair(CP_HIGHLIGHT, COLOR_BLACK,   COLOR_CYAN);
        init_pair(CP_STATUS,    COLOR_CYAN,    -1);
        init_pair(CP_DRAWER,    COLOR_WHITE,   -1);  /* white on terminal's default bg */
        init_pair(CP_BLUE,      COLOR_BLUE,    -1);  /* ↑ sent counter */
        /* CP_LABEL — light steel blue (xterm-256 147) for candlestick
         * column labels (HOP N / SOURCE / TARGET).  Falls back to cyan
         * on 8-colour terminals. */
        if (COLORS >= 256)
            init_pair(CP_LABEL, 147, -1);
        else
            init_pair(CP_LABEL, COLOR_CYAN, -1);
        /* CP_TBLHEAD — alice blue (xterm-256 195) for timeline table
         * column headers (HOP / HOST / LOSS% / …).  Falls back to white
         * on 8-colour terminals. */
        if (COLORS >= 256)
            init_pair(CP_TBLHEAD, 195, -1);
        else
            init_pair(CP_TBLHEAD, COLOR_WHITE, -1);
        /* CP_SENT — medium slate blue (xterm-256 75) for the status-bar
         * ↑ sent counter.  Brighter than the frame's CP_BLUE so the live
         * count stands out against the other status-bar stats. */
        if (COLORS >= 256)
            init_pair(CP_SENT, 75, -1);
        else
            init_pair(CP_SENT, COLOR_BLUE, -1);
        /* CP_KEYHINT — cornflower (xterm-256 111) for the row-2 key-hint menu */
        if (COLORS >= 256)
            init_pair(CP_KEYHINT, 111, -1);
        else
            init_pair(CP_KEYHINT, COLOR_CYAN, -1);
        /* CP_KEYLTR — light sky blue (xterm-256 117) for [X] action letters */
        if (COLORS >= 256)
            init_pair(CP_KEYLTR, 117, -1);
        else
            init_pair(CP_KEYLTR, COLOR_WHITE, -1);
    }

    return (LINES >= WATCH_MIN_ROWS) ? 0 : -1;
}

static void watch_teardown_ncurses(void)
{
    endwin();
    if (g_ncurses_screen) {
        delscreen(g_ncurses_screen);
        g_ncurses_screen = NULL;
    }
    if (g_tty_out) { fclose(g_tty_out); g_tty_out = NULL; }
    if (g_tty_in)  { fclose(g_tty_in);  g_tty_in  = NULL; }
}

/* =========================================================================
 * Status bar (bottom 2 rows)
 * ========================================================================= */

static void watch_render_status_bar(watch_session_t *ws, int spinner_frame)
{
    int row1 = ws->term_rows - 2;
    int row2 = ws->term_rows - 1;
    const char *spin;
    char maxcyc[16];
    char c_replies[16], c_sent[16], c_hops[16];
    char center_str[320];
    char buf[320];   /* row2 key hints / status message */
    int i, left_w, center_sec, right_end, total_dashes, dash_l, dash_r;

    spin = SPINNER_FRAMES[spinner_frame % WATCH_SPINNER_FRAMES];

    if (ws->max_cycles > 0)
        snprintf(maxcyc, sizeof(maxcyc), "%d", ws->max_cycles);
    else
        snprintf(maxcyc, sizeof(maxcyc), "\xe2\x88\x9e"); /* ∞ */

    /* Format cumulative counters with thousands separators.
     * During a trace (in_trace=1) add the current-cycle live counts on top
     * of the accumulated baseline — the SIGALRM handler intentionally does
     * NOT write these fields, so they hold the cleanly-accumulated previous
     * totals until watch_run_one_cycle updates them at cycle end. */
    {
        int disp_replies = ws->total_replies + (ws->in_trace ? lft_o_get_replies() : 0);
        int disp_sent    = ws->total_sent    + (ws->in_trace ? lft_o_get_sent()    : 0);
        fmt_comma(c_replies, sizeof(c_replies), disp_replies);
        fmt_comma(c_sent,    sizeof(c_sent),    disp_sent);
        /* Left-justify counters to a MINIMUM of 4 display cols (trailing
         * spaces so the digits sit flush against the ↓/↑ arrows).  Keeps
         * the footer width stable for counts 0…9999; field only grows
         * once a value crosses 10,000 and the comma-formatted string
         * naturally needs more chars. */
        {
            char tmp[16];
            if ((int)strlen(c_replies) < 4) {
                snprintf(tmp, sizeof(tmp), "%-4s", c_replies);
                snprintf(c_replies, sizeof(c_replies), "%s", tmp);
            }
            if ((int)strlen(c_sent) < 4) {
                snprintf(tmp, sizeof(tmp), "%-4s", c_sent);
                snprintf(c_sent, sizeof(c_sent), "%s", tmp);
            }
        }
    }
    fmt_comma(c_hops,    sizeof(c_hops),    ws->max_hops_seen);

    /* ------------------------------------------------------------------ */
    /* Row 1: bordered status line, mirroring the top header style         */
    /*                                                                      */
    /*  └──┤ ⠹  ↓N  ↑N  ⬡N  ✔ ├────────┤ idle  cycle N/∞  URL  ~Ns ├────┘ */
    /* ------------------------------------------------------------------ */
    move(row1, 0);
    clrtoeol();

    /* --- Measure widths to compute centering --- */
    /* Left section content: spin(1) + "  ↓"(3) + replies + "  ↑"(3) + sent
     * + "  ⬡ "(4) + hops + "  ✔/✗"(3)  — ✔/✗ glyph is 1 display col, so
     * the trailing "  ✔" segment is 3 cols not 4.  Using 4 made the right
     * side of the status bar fall exactly 1 col short of term_cols-1. */
    int left_content_dw = 1 + 3 + (int)strlen(c_replies)
                            + 3 + (int)strlen(c_sent)
                            + 4 + (int)strlen(c_hops)
                            + 3;     /* "  ✔" or "  ✗" */
    left_w = left_content_dw + 7;   /* └──┤ space … space ├ */

    /* Center section content: merged state+timer field + Cycle + target.
     * state_field is FIXED-WIDTH — its length is strlen("Idle  ~Ns") for the
     * current interval.  The idle form is the template; Tracing and Paused
     * write their words into the first N chars and pad the rest with '.'.
     * This prevents any horizontal reflow when the state transitions, so the
     * border dashes on either side stay visually locked. */
    char idle_form[32];
    char state_field[32];
    int  state_w;
    int  i2;
    snprintf(idle_form, sizeof(idle_form), "Idle  ~%ds", ws->interval);
    state_w = (int)strlen(idle_form);
    if ((size_t)state_w >= sizeof(state_field)) state_w = sizeof(state_field) - 1;

    if (ws->paused) {
        for (i2 = 0; i2 < state_w; i2++) state_field[i2] = '.';
        state_field[state_w] = '\0';
        if (state_w >= 6) memcpy(state_field, "Paused", 6);
    } else if (ws->in_trace) {
        for (i2 = 0; i2 < state_w; i2++) state_field[i2] = '.';
        state_field[state_w] = '\0';
        if (state_w >= 7) memcpy(state_field, "Tracing", 7);
    } else {
        memcpy(state_field, idle_form, state_w + 1);
    }
    /* Assemble full center string for centering-math purposes.  NOTE:
     * maxcyc may contain "∞" (U+221E, 3 bytes but 1 display col), so we
     * measure display width rather than byte length — otherwise the
     * right-side dashes+corner come up short and the frame breaks. */
    snprintf(center_str, sizeof(center_str),
             "%s  Cycle %d/%s  %s",
             state_field, ws->cycle, maxcyc, ws->tgt_label);

    /* Count display-columns in center_str (non-UTF8-continuation bytes) */
    {
        const char *cp;
        int dw = 0;
        for (cp = center_str; *cp; cp++)
            if (((unsigned char)*cp & 0xC0) != 0x80) dw++;
        center_sec = dw + 4;  /* ┤ space … space ├ */
    }
    right_end  = 3;   /* ──┘ */

    total_dashes = ws->term_cols - left_w - center_sec - right_end;
    if (total_dashes < 0) total_dashes = 0;

    /* Center the center section on the full terminal width */
    dash_l = (ws->term_cols - center_sec) / 2 - left_w;
    if (dash_l < 0)           dash_l = 0;
    if (dash_l > total_dashes) dash_l = total_dashes;
    dash_r = total_dashes - dash_l;

    /* --- Draw left section ---
     * Corner is └── (bottom-left closing corner) because nothing extends
     * below this row at col 0 — the key-hints row underneath is plain
     * text outside the frame.  The table's side │ above meets this └
     * naturally to close the box. */
    if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
    addstr("\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80");  /* └── */
    addstr("\xe2\x94\xa4");                            /* ┤   */
    if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));
    addstr(" ");

    /* spinner */
    attron(A_BOLD);
    addstr(spin);
    attroff(A_BOLD);

    /* ↓ replies (green bold) */
    if (has_colors()) attron(COLOR_PAIR(CP_GREEN) | A_BOLD);
    addstr("  \xe2\x86\x93");
    addstr(c_replies);
    if (has_colors()) attroff(COLOR_PAIR(CP_GREEN) | A_BOLD);

    /* ↑ sent (dodger blue bold — brighter than frame's CP_BLUE) */
    if (has_colors()) attron(COLOR_PAIR(CP_SENT) | A_BOLD);
    addstr("  \xe2\x86\x91");
    addstr(c_sent);
    if (has_colors()) attroff(COLOR_PAIR(CP_SENT) | A_BOLD);

    /* ⬡ hops (cyan bold) */
    if (has_colors()) attron(COLOR_PAIR(CP_STATUS) | A_BOLD);
    addstr("  \xe2\xac\xa1 ");
    addstr(c_hops);
    if (has_colors()) attroff(COLOR_PAIR(CP_STATUS) | A_BOLD);

    /* ✔ / ✗ target (sticky: stays ✔ if ever reached) */
    if (ws->target_ever_reached) {
        if (has_colors()) attron(COLOR_PAIR(CP_GREEN) | A_BOLD);
        addstr("  \xe2\x9c\x94");
        if (has_colors()) attroff(COLOR_PAIR(CP_GREEN) | A_BOLD);
    } else {
        if (has_colors()) attron(COLOR_PAIR(CP_RED) | A_BOLD);
        addstr("  \xe2\x9c\x97");
        if (has_colors()) attroff(COLOR_PAIR(CP_RED) | A_BOLD);
    }

    /* Close left section: " ├" then dashes then "┤" center "├" dashes "──┘" */
    addstr(" ");
    if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
    addstr("\xe2\x94\x9c");            /* ├ */
    for (i = 0; i < dash_l; i++) addstr("\xe2\x94\x80");  /* ─ */
    addstr("\xe2\x94\xa4");            /* ┤ */
    if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));

    /* Center content — split into (state_field) + (Cycle … target) so the
     * paused state can blink independently at 1 Hz. */
    addstr(" ");
    {
        attr_t state_attr;
        if (ws->paused) {
            /* 1-Hz blink between outline-text color bold and white bold */
            state_attr = (time(NULL) & 1)
                ? (COLOR_PAIR(CP_SENT) | A_BOLD)
                : (COLOR_PAIR(CP_NORMAL) | A_BOLD);
        } else {
            state_attr = COLOR_PAIR(CP_SENT) | A_BOLD;
        }
        if (has_colors()) attron(state_attr);
        addstr(state_field);
        if (has_colors()) attroff(state_attr);
        if (has_colors()) attron(COLOR_PAIR(CP_SENT) | A_BOLD);
        printw("  Cycle %d/%s  %s", ws->cycle, maxcyc, ws->tgt_label);
        if (has_colors()) attroff(COLOR_PAIR(CP_SENT) | A_BOLD);
    }
    addstr(" ");

    if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
    addstr("\xe2\x94\x9c");            /* ├ */
    for (i = 0; i < dash_r; i++) addstr("\xe2\x94\x80");  /* ─ */
    addstr("\xe2\x94\x80\xe2\x94\x80\xe2\x94\x98"); /* ──┘ (bottom-right corner) */
    if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));

    /* ------------------------------------------------------------------ */
    /* Row 2: key hints — OR transient status message if one is active    */
    /* Key-hint menu is centered on the line and coloured CP_KEYHINT      */
    /* (xterm 111 cornflower).  A transient status message (e.g. the      */
    /* "saved to /path/file.txt" confirmation) still renders left-aligned */
    /* at col 0 for visibility.                                           */
    /* ------------------------------------------------------------------ */
    move(row2, 0);
    clrtoeol();
    if (ws->status_msg[0] &&
        difftime(time(NULL), ws->status_msg_time) < 3.0) {
        snprintf(buf, sizeof(buf), " %s", ws->status_msg);
        if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
        mvaddnstr(row2, 0, buf, ws->term_cols - 1);
        if (has_colors()) attroff(COLOR_PAIR(CP_STATUS));
    } else {
        ws->status_msg[0] = '\0';   /* clear expired message */
        snprintf(buf, sizeof(buf),
                 "[1-3]view  [q]quit  [p]%s  [r]reset  [c]clear  [v]log  [w]whois  [h]host  [x]export  [+/-]interval",
                 ws->paused ? "resume" : "pause");
        /* Center on the row (ASCII-only string → strlen == display width) */
        int menu_w = (int)strlen(buf);
        int menu_x = (ws->term_cols - menu_w) / 2;
        if (menu_x < 0) menu_x = 0;
        /* Render in two colours: CP_KEYLTR for chars inside [...], CP_KEYHINT
         * for everything else.  Walk byte-by-byte flipping attrs at [ / ]. */
        move(row2, menu_x);
        {
            int in_bracket = 0;
            const char *p;
            int remaining = ws->term_cols - menu_x;
            if (has_colors()) attron(COLOR_PAIR(CP_KEYHINT));
            for (p = buf; *p && remaining > 0; p++) {
                if (*p == '[') {
                    if (has_colors()) { attroff(COLOR_PAIR(CP_KEYHINT));
                                        attron(COLOR_PAIR(CP_KEYHINT)); }
                    addch((unsigned char)*p);
                    if (has_colors()) { attroff(COLOR_PAIR(CP_KEYHINT));
                                        attron(COLOR_PAIR(CP_KEYLTR)); }
                    in_bracket = 1;
                } else if (*p == ']') {
                    if (has_colors()) { attroff(COLOR_PAIR(CP_KEYLTR));
                                        attron(COLOR_PAIR(CP_KEYHINT)); }
                    addch((unsigned char)*p);
                    in_bracket = 0;
                } else {
                    addch((unsigned char)*p);
                }
                remaining--;
            }
            if (has_colors()) {
                if (in_bracket) attroff(COLOR_PAIR(CP_KEYLTR));
                else            attroff(COLOR_PAIR(CP_KEYHINT));
            }
        }
    }
    /* Callers (watch_render, idle loop, SIGALRM) all call wnoutrefresh+doupdate */
}

/* Forward declarations for functions defined later in this file */
static int  watch_render_timeline(watch_session_t *ws, int start_row, int max_rows, int col_offset);
static void watch_render_status_bar(watch_session_t *ws, int spinner_frame);

/* =========================================================================
 * View 1 (default): native ncurses port of ASCIIOutput candlestick chart.
 *
 * This is a self-contained copy of the ASCIIOutput logic from lft_lib.c,
 * ported to draw directly into the ncurses window.  Having our own copy
 * lets us add watch-mode-specific customisations (color pairs, row limits,
 * hostname toggle, etc.) without touching the shared lft_lib.c output path.
 *
 * Key helpers (wa_ prefix = Watch Ascii):
 *   wa_utf8_display_width()  — count display columns in a UTF-8 string
 *   wa_center()              — center-print a string into a fixed-width field
 *   wa_rtt_stats_t / wa_compute_rtt_stats() — per-hop RTT aggregation
 *   wa_col_t                 — per-hop column descriptor (mirror of ascii_col)
 *   wa_chart_render()        — draw the candlestick chart body
 *   wa_render_header_row()   — draw ┌─ HOP N ─┐ row
 *   watch_render_ascii_chart()  — main entry: build cols[], layout, render
 * ========================================================================= */

/* ---- rendering context (current cursor position inside ncurses) ---- */
typedef struct {
    int row;        /* current screen row */
    int col;        /* current screen column */
    int max_row;    /* stop rendering at this row (exclusive) */
    int term_cols;  /* full terminal width */
    int start_col;  /* left content margin (0 = no border; 1 = bordered) */
} wa_ctx_t;

/* ---- UTF-8 display width (copied from lft_lib.c) ---- */
static int wa_utf8_display_width(const char *s)
{
    int w = 0;
    while (*s) {
        unsigned char b = (unsigned char)*s++;
        if ((b & 0xC0) != 0x80)
            w++;
    }
    return w;
}

/* Truncate s in-place so its display width does not exceed max_w.
 * Truncation always lands on a UTF-8 character boundary. */
static void wa_utf8_truncate_to(char *s, int max_w)
{
    int i = (int)strlen(s);
    while (i > 0 && wa_utf8_display_width(s) > max_w) {
        i--;
        /* Back up over any UTF-8 continuation bytes (10xxxxxx) */
        while (i > 0 && ((unsigned char)s[i] & 0xC0) == 0x80) i--;
        s[i] = '\0';
    }
}

/* Advance to the next row; clears to end of old row. */
static void wa_nl(wa_ctx_t *ctx)
{
    clrtoeol();
    ctx->row++;
    ctx->col = ctx->start_col;
    if (ctx->row < ctx->max_row)
        move(ctx->row, ctx->start_col);
}

/* Write a string at the current position; update ctx->col.
 * Tracks display-column width (not bytes) via utf8_display_width arithmetic.
 * ncurses itself clips at the right margin; we just track col for layout. */
static void wa_str(wa_ctx_t *ctx, const char *s)
{
    int dw;
    if (!s || !*s || ctx->row >= ctx->max_row) return;
    /* Measure display width — count non-continuation UTF-8 bytes */
    dw = wa_utf8_display_width(s);
    if (ctx->col + dw > ctx->term_cols - 1)
        dw = ctx->term_cols - 1 - ctx->col;
    if (dw <= 0) return;
    move(ctx->row, ctx->col);
    addstr(s);          /* ncurses clips at right margin automatically */
    ctx->col += dw;
}

/* Printf-style wrapper. */
static void wa_printf(wa_ctx_t *ctx, const char *fmt, ...)
{
    char buf[512];
    va_list ap;
    va_start(ap, fmt);
    vsnprintf(buf, sizeof(buf), fmt, ap);
    va_end(ap);
    wa_str(ctx, buf);
}

/* Repeat a single-byte character n times. */
static void wa_repeat_ch(wa_ctx_t *ctx, char c, int n)
{
    int i;
    for (i = 0; i < n && ctx->col < ctx->term_cols - 1; i++) {
        move(ctx->row, ctx->col);
        addch((unsigned char)c);
        ctx->col++;
    }
}

/* Repeat a UTF-8 string n times (e.g. "─" for box drawing). */
static void wa_repeat_str(wa_ctx_t *ctx, const char *s, int n)
{
    int i;
    for (i = 0; i < n && ctx->col < ctx->term_cols - 1; i++) {
        move(ctx->row, ctx->col);
        addstr(s);
        ctx->col++;  /* each repeat is one display column */
    }
}

/* ---- center-print string s into a field of w display columns ---- */
static void wa_center(wa_ctx_t *ctx, const char *s, int w)
{
    int sl    = wa_utf8_display_width(s);
    int left, right;
    /* Always reserve at least 1 col of padding on each side when room
     * exists (i.e. string length + 2 ≤ column width).  This gives every
     * candlestick column a small visual gap so long labels don't butt up
     * against their neighbours or against the column's cloaked fill. */
    if (sl + 2 <= w) {
        left  = (w - sl) / 2;
        right = w - sl - left;
        if (left  < 1) left  = 1;
        if (right < 1) right = 1;
    } else {
        /* Content equals or exceeds column width — fall back to plain
         * centering (may emit no padding if exactly flush). */
        left  = (w - sl) / 2;
        right = w - sl - left;
        if (left  < 0) left  = 0;
        if (right < 0) right = 0;
    }
    wa_repeat_ch(ctx, ' ', left);
    wa_str(ctx, s);
    wa_repeat_ch(ctx, ' ', right);
}

/* ---- per-hop RTT statistics (copied from lft_lib.c) ---- */
typedef struct {
    double rtt_min;
    double rtt_avg;
    double rtt_max;
    double rtt_stddev;
    int    n;
} wa_rtt_stats_t;

static wa_rtt_stats_t wa_compute_rtt_stats(lft_session_params *sess, int hopno)
{
    wa_rtt_stats_t s;
    struct trace_packet_info_s *tp;
    double sum = 0.0, sum2 = 0.0;
    int n = 0;
    double rtt_min = 0.0, rtt_max = 0.0;

    memset(&s, 0, sizeof(s));
    if (hopno < 0 || hopno >= sess->hop_info_length) return s;

    SLIST_FOREACH(tp, &sess->hop_info[hopno].packets, next_by_hop) {
        if (!tp->recv.tv_sec) continue;
        {
            double rtt = timediff_ms(tp->sent, tp->recv);
            if (n == 0 || rtt < rtt_min) rtt_min = rtt;
            if (n == 0 || rtt > rtt_max) rtt_max = rtt;
            sum  += rtt;
            sum2 += rtt * rtt;
            n++;
        }
    }
    if (n == 0) return s;
    s.n        = n;
    s.rtt_min  = rtt_min;
    s.rtt_max  = rtt_max;
    s.rtt_avg  = sum / n;
    s.rtt_stddev = (n > 1) ? sqrt(sum2/n - (sum/n)*(sum/n)) : 0.0;
    if (s.rtt_stddev < 0.0) s.rtt_stddev = 0.0;
    return s;
}

/* ---- per-column descriptor (mirror of struct ascii_col) ---- */
#define WA_MAX_COLS    256
#define WA_CHART_H       8   /* candlestick body rows */
#define WA_CHART_SEP     4   /* display columns between hops */
#define WA_JITTER_WARN  25.0
#define WA_MAX_COL_W    17   /* maximum column width for candlestick/bar charts */

typedef struct {
    char hdr[64];
    char host[256];
    char sub[128];
    char asn[32];
    char net[64];
    char rtt[32];
    char ann[64];
    char mtu[32];
    char jitter_ann[32];
    wa_rtt_stats_t stats;
    int  jitter_warn;
    int  is_hole;
    int  is_target;
    int  is_source;
    int  is_ghost;   /* 1 = hop known from history but cloaked this cycle */
    int  anomtype;
    int  w;          /* column display width */
    int  n_alt;                              /* number of alternate (multipath) IPs */
    char alt_hosts[WATCH_MAX_ALT_IPS][64];  /* alternate IP strings */
} wa_col_t;

/* ---- ncurses color helpers ---- */
/* Map semantic intent to an existing watch color pair + attribute */
static void wa_color_on(int pair, int attr)
{
    if (has_colors()) attron(COLOR_PAIR(pair));
    if (attr) attron(attr);
}
static void wa_color_off(int pair, int attr)
{
    if (has_colors()) attroff(COLOR_PAIR(pair));
    if (attr) attroff(attr);
}

/* ---- render hop-label header row for one slice of columns ----
 * flip_outer=0: ┌─ HOP N ─┐ for all boxes (top row above chart)
 * flip_outer=1: └─ HOP N ─┐ / ┌─ HOP N ─┘ outer corners (bottom row below chart)
 */
static void wa_render_header_row(wa_ctx_t *ctx, const wa_col_t *cols,
                                  int start, int end, int flip_outer)
{
    int i;
    for (i = start; i <= end && ctx->col < ctx->term_cols - 1; i++) {
        const wa_col_t *c = &cols[i];
        int hlen = (int)strlen(c->hdr);
        /* Strip outer "[ " and " ]" to get inner label like "HOP 1" */
        char label[64];
        int  label_len;
        if (hlen >= 4) {
            snprintf(label, sizeof(label), "%.*s", hlen - 4, c->hdr + 2);
            label_len = (int)strlen(label);
        } else {
            snprintf(label, sizeof(label), "%s", c->hdr);
            label_len = hlen;
        }
        int inner     = c->w - 2;
        int dashes_l  = (inner - label_len - 2) / 2;
        int dashes_r  = inner - label_len - 2 - dashes_l;
        if (dashes_l < 0) dashes_l = 0;
        if (dashes_r < 0) dashes_r = 0;

        /* bottom row: all corners flip to └ / ┘ */
        const char *lc = flip_outer ? "\xe2\x94\x94" /* └ */ : "\xe2\x94\x8c"; /* ┌ */
        const char *rc = flip_outer ? "\xe2\x94\x98" /* ┘ */ : "\xe2\x94\x90"; /* ┐ */

        /* Frame chars (┌─┐└─┘) in cyan; label text ("HOP N" / "SOURCE"
         * / "TARGET") in CP_LABEL (light steel blue) for visual
         * distinction from frame and data text. */
        wa_color_on(CP_STATUS, 0);
        wa_str(ctx, lc);
        wa_repeat_str(ctx, "\xe2\x94\x80", dashes_l);  /* ─ × n */
        wa_color_off(CP_STATUS, 0);
        wa_color_on(CP_LABEL, 0);
        wa_printf(ctx, " %s ", label);
        wa_color_off(CP_LABEL, 0);
        wa_color_on(CP_STATUS, 0);
        wa_repeat_str(ctx, "\xe2\x94\x80", dashes_r);  /* ─ × n */
        wa_str(ctx, rc);
        wa_color_off(CP_STATUS, 0);

        if (i < end) wa_repeat_ch(ctx, ' ', WA_CHART_SEP);
    }
    wa_nl(ctx);
}

/* ---- draw the candlestick chart body for one column slice ---- */
static void wa_chart_render(wa_ctx_t *ctx, const wa_col_t *cols,
                             int start, int end,
                             double g_min, double g_max,
                             const char *info_str)
{
    int i, r;
    double range = (g_max > g_min + 0.1) ? (g_max - g_min) : 1.0;
    (void)g_min;

    /* Y-axis prefix width */
    char ymax_buf[16];
    snprintf(ymax_buf, sizeof(ymax_buf), "%dms", (int)ceil(g_max > 0 ? g_max : 1.0));
    int yval_w = (int)strlen(ymax_buf);

    /* ---- header row (above chart for spatial context) ---- */
    wa_color_on(CP_NORMAL, A_DIM);
    wa_printf(ctx, "%*s   ", yval_w, "");
    wa_color_off(CP_NORMAL, A_DIM);
    wa_render_header_row(ctx, cols, start, end, 0);
    if (ctx->row >= ctx->max_row) return;

    /* ---- max RTT label row ---- */
    {
        int any = 0;
        for (i = start; i <= end; i++)
            if (cols[i].stats.n > 0 || cols[i].is_hole) { any = 1; break; }
        if (any) {
            wa_color_on(CP_NORMAL, A_DIM);
            wa_printf(ctx, "%*s   ", yval_w, "");
            wa_color_off(CP_NORMAL, A_DIM);
            for (i = start; i <= end; i++) {
                const wa_col_t *c = &cols[i];
                if (c->stats.n > 0) {
                    char lbl[24];
                    snprintf(lbl, sizeof(lbl), "%dms max", (int)round(c->stats.rtt_max));
                    if (c->is_ghost)
                        wa_color_on(CP_NORMAL, A_DIM);
                    else
                        wa_color_on(c->jitter_warn ? CP_YELLOW : CP_GREEN, 0);
                    wa_center(ctx, lbl, c->w);
                    if (c->is_ghost)
                        wa_color_off(CP_NORMAL, A_DIM);
                    else
                        wa_color_off(c->jitter_warn ? CP_YELLOW : CP_GREEN, 0);
                } else if (c->is_hole) {
                    wa_color_on(CP_NORMAL, A_DIM);
                    { int d; wa_str(ctx, " "); for (d = 0; d < c->w - 2; d++) wa_str(ctx, "\xe2\x96\x91"); if (c->w >= 2) wa_str(ctx, " "); } /* ░ padded */
                    wa_color_off(CP_NORMAL, A_DIM);
                } else {
                    wa_repeat_ch(ctx, ' ', c->w);
                }
                if (i < end) wa_repeat_ch(ctx, ' ', WA_CHART_SEP);
            }
            wa_nl(ctx);
            if (ctx->row >= ctx->max_row) return;
        }
    }

    /* ---- precompute per-column bar row indices ---- */
    int col_max_row[WA_MAX_COLS];
    int col_avg_row[WA_MAX_COLS];
    int col_min_row[WA_MAX_COLS];
    for (i = start; i <= end; i++) {
        const wa_col_t *c = &cols[i];
        if (c->stats.n > 0) {
            col_max_row[i] = (int)round((g_max - c->stats.rtt_max) / range * (WA_CHART_H - 1));
            col_avg_row[i] = (int)round((g_max - c->stats.rtt_avg) / range * (WA_CHART_H - 1));
            col_min_row[i] = (int)round((g_max - c->stats.rtt_min) / range * (WA_CHART_H - 1));
            if (col_max_row[i] < 0) col_max_row[i] = 0;
            if (col_max_row[i] >= WA_CHART_H) col_max_row[i] = WA_CHART_H - 1;
            if (col_avg_row[i] < 0) col_avg_row[i] = 0;
            if (col_avg_row[i] >= WA_CHART_H) col_avg_row[i] = WA_CHART_H - 1;
            if (col_min_row[i] < 0) col_min_row[i] = 0;
            if (col_min_row[i] >= WA_CHART_H) col_min_row[i] = WA_CHART_H - 1;
            if (col_avg_row[i] < col_max_row[i]) col_avg_row[i] = col_max_row[i];
            if (col_avg_row[i] > col_min_row[i]) col_avg_row[i] = col_min_row[i];
            if (c->stats.rtt_min != c->stats.rtt_max && col_max_row[i] == col_min_row[i]) {
                if (col_min_row[i] < WA_CHART_H - 1)      col_min_row[i]++;
                else if (col_max_row[i] > 0)               col_max_row[i]--;
                if (col_avg_row[i] < col_max_row[i])       col_avg_row[i] = col_max_row[i];
                if (col_avg_row[i] > col_min_row[i])       col_avg_row[i] = col_min_row[i];
            }
        } else {
            col_max_row[i] = col_avg_row[i] = col_min_row[i] = 0;
        }
    }

    /* ---- chart body rows ---- */
    for (r = 0; r < WA_CHART_H && ctx->row < ctx->max_row; r++) {
        /* Y-axis prefix */
        wa_color_on(CP_NORMAL, A_DIM);
        if (r % 2 == 0) {
            double row_rtt = g_max - (double)r / (WA_CHART_H - 1) * range;
            wa_printf(ctx, "%*dms ", yval_w - 2, (int)round(row_rtt));
            wa_str(ctx, "\xe2\x94\xa4 ");   /* ┤  */
        } else {
            wa_printf(ctx, "%*s ", yval_w, "");
            wa_str(ctx, "\xe2\x94\x82 ");   /* │  */
        }
        wa_color_off(CP_NORMAL, A_DIM);

        for (i = start; i <= end && ctx->col < ctx->term_cols - 1; i++) {
            const wa_col_t *c = &cols[i];
            int cw = c->w;

            if (c->stats.n == 0) {
                if (c->is_hole) {
                    wa_color_on(CP_NORMAL, A_DIM);
                    { int d; wa_str(ctx, " "); for (d = 0; d < cw - 2; d++) wa_str(ctx, "\xe2\x96\x91"); if (cw >= 2) wa_str(ctx, " "); } /* ░ padded */
                    wa_color_off(CP_NORMAL, A_DIM);
                } else {
                    wa_repeat_ch(ctx, ' ', cw);
                }
            } else {
                int mr        = col_max_row[i];
                int ar        = col_avg_row[i];
                int mir       = col_min_row[i];
                int collapsed = (mr == mir);
                int in_shaft  = (r >= mr && r <= mir);
                int is_max    = (r == mr);
                int is_min    = (r == mir);
                int is_avg    = (r == ar);
                int d, mid;

                if (in_shaft) {
                    /* Ghost hops render entirely dim — no color distinction */
                    if (c->is_ghost)
                        wa_color_on(CP_NORMAL, A_DIM);
                    else if (is_avg)
                        wa_color_on(c->jitter_warn ? CP_YELLOW : CP_GREEN, 0);
                    else if (is_max || is_min)
                        wa_color_on(CP_NORMAL, A_BOLD);
                    else
                        wa_color_on(CP_NORMAL, A_DIM);
                }

                /* Bars are rendered 1-col thinner on each side (bw = cw-2)
                 * so they visually fit inside the column brackets ┌─ HOP N ─┐
                 * above / └─ HOP N ─┘ below rather than butting against them. */
                int bw   = (cw >= 2) ? (cw - 2) : cw;
                int pad  = (cw >= 2) ? 1 : 0;

                if (!in_shaft) {
                    wa_repeat_ch(ctx, ' ', cw);
                } else if (collapsed || is_avg) {
                    /* full-width solid bar █ */
                    wa_repeat_ch(ctx, ' ', pad);
                    for (d = 0; d < bw; d++) wa_str(ctx, "\xe2\x96\x88");
                    wa_repeat_ch(ctx, ' ', pad);
                } else if (is_max) {
                    /* max whisker cap ──┬── */
                    wa_repeat_ch(ctx, ' ', pad);
                    mid = (bw - 1) / 2;
                    for (d = 0; d < bw; d++)
                        wa_str(ctx, d == mid
                                    ? "\xe2\x94\xac"   /* ┬ */
                                    : "\xe2\x94\x80"); /* ─ */
                    wa_repeat_ch(ctx, ' ', pad);
                } else if (is_min) {
                    /* min whisker cap ──┴── */
                    wa_repeat_ch(ctx, ' ', pad);
                    mid = (bw - 1) / 2;
                    for (d = 0; d < bw; d++)
                        wa_str(ctx, d == mid
                                    ? "\xe2\x94\xb4"   /* ┴ */
                                    : "\xe2\x94\x80"); /* ─ */
                    wa_repeat_ch(ctx, ' ', pad);
                } else {
                    /* shaft / wick: single │ centered (wa_center keeps
                     * 1-col min padding via its built-in behavior) */
                    wa_center(ctx, "\xe2\x94\x82", cw);  /* │ */
                }

                if (in_shaft) {
                    if (c->is_ghost)
                        wa_color_off(CP_NORMAL, A_DIM);
                    else if (is_avg)
                        wa_color_off(c->jitter_warn ? CP_YELLOW : CP_GREEN, 0);
                    else if (is_max || is_min)
                        wa_color_off(CP_NORMAL, A_BOLD);
                    else
                        wa_color_off(CP_NORMAL, A_DIM);
                }
            }

            if (i < end) wa_repeat_ch(ctx, ' ', WA_CHART_SEP);
        }
        wa_nl(ctx);
    }
    if (ctx->row >= ctx->max_row) return;

    /* ---- min RTT label row ---- */
    {
        int any = 0;
        for (i = start; i <= end; i++)
            if (cols[i].stats.n > 0 || cols[i].is_hole) { any = 1; break; }
        if (any) {
            wa_color_on(CP_NORMAL, A_DIM);
            wa_printf(ctx, "%*s   ", yval_w, "");
            wa_color_off(CP_NORMAL, A_DIM);
            for (i = start; i <= end; i++) {
                const wa_col_t *c = &cols[i];
                if (c->stats.n > 0) {
                    char lbl[24];
                    snprintf(lbl, sizeof(lbl), "%dms min", (int)round(c->stats.rtt_min));
                    if (c->is_ghost)
                        wa_color_on(CP_NORMAL, A_DIM);
                    else
                        wa_color_on(c->jitter_warn ? CP_YELLOW : CP_GREEN, 0);
                    wa_center(ctx, lbl, c->w);
                    if (c->is_ghost)
                        wa_color_off(CP_NORMAL, A_DIM);
                    else
                        wa_color_off(c->jitter_warn ? CP_YELLOW : CP_GREEN, 0);
                } else if (c->is_hole) {
                    wa_color_on(CP_NORMAL, A_DIM);
                    { int d; wa_str(ctx, " "); for (d = 0; d < c->w - 2; d++) wa_str(ctx, "\xe2\x96\x91"); if (c->w >= 2) wa_str(ctx, " "); } /* ░ padded */
                    wa_color_off(CP_NORMAL, A_DIM);
                } else {
                    wa_repeat_ch(ctx, ' ', c->w);
                }
                if (i < end) wa_repeat_ch(ctx, ' ', WA_CHART_SEP);
            }
            wa_nl(ctx);
            if (ctx->row >= ctx->max_row) return;
        }
    }

    /* ---- X-axis separator row: └─────┤ label ├───── (only when info_str
     * is non-empty).  Previously emitted a blank wa_nl() even when info_str
     * was empty, which left a redundant blank row between the "Nms min"
     * row and the bottom hop-label brackets.  That gap is now gone. */
    if (info_str && info_str[0]) {
        int any = 0;
        for (i = start; i <= end; i++)
            if (cols[i].stats.n > 0) { any = 1; break; }
        if (any) {
            int total_w = 0;
            for (i = start; i <= end; i++) {
                total_w += cols[i].w;
                if (i < end) total_w += WA_CHART_SEP;
            }
            char range_lbl[256];
            snprintf(range_lbl, sizeof(range_lbl),
                     "\xe2\x94\xa4 %s \xe2\x94\x9c", info_str);  /* ┤ … ├ */
            int label_disp = wa_utf8_display_width(range_lbl);
            int dash_total = total_w - 2 - label_disp;
            int dash_l = dash_total / 2;
            int dash_r = dash_total - dash_l;
            if (dash_l < 0) dash_l = 0;
            if (dash_r < 0) dash_r = 0;

            wa_color_on(CP_NORMAL, A_DIM);
            wa_printf(ctx, "%*s ", yval_w, "");
            wa_str(ctx, "\xe2\x94\x94\xe2\x94\x80");  /* └─ */
            wa_repeat_str(ctx, "\xe2\x94\x80", dash_l);
            wa_str(ctx, range_lbl);
            wa_repeat_str(ctx, "\xe2\x94\x80", dash_r);
            wa_color_off(CP_NORMAL, A_DIM);
            wa_nl(ctx);
        }
    }
}

/*
 * Build col[] descriptors from proto_sess and render the full candlestick
 * chart (one or more column slices) into ncurses rows [start_row, max_row).
 * Returns the number of rows consumed.
 */
static int watch_render_ascii_chart(watch_session_t *ws,
                                     lft_session_params *proto_sess,
                                     int start_row, int max_rows,
                                     int start_col)
{
    wa_col_t cols[WA_MAX_COLS];
    int ncols = 0;
    int hopno, maxhop;
    int reply, noreply, neglstart, neglend;
    int prevasn = -1, prevasn_hopno = 0;
    struct in_addr classbmask, masked_target, prevasn_hopaddr;
    int netreached = 0;
    int asseam_hopno = -1, netseam_hopno = -1;
    int lastishole = 0;
    int found_target = 0;
    struct trace_packet_info_s *tp;
    /* Usable content width: full terminal minus left margin minus right border col */
    int term_cols = ws->term_cols - start_col - (start_col > 0 ? 1 : 0);

    wa_ctx_t ctx = { start_row, start_col, start_row + max_rows, ws->term_cols, start_col };

    if (proto_sess == NULL || ws->n_hops == 0) return 0;

    /* ---- determine SEAM positions ---- */
    inet_aton("255.255.0.0", &classbmask);
    masked_target.s_addr = proto_sess->remote_address.s_addr & classbmask.s_addr;

    if (proto_sess->num_hops)
        maxhop = proto_sess->num_hops;
    else
        maxhop = proto_sess->hop_info_length - 1;

    for (hopno = proto_sess->ttl_min; hopno < proto_sess->hop_info_length; hopno++) {
        int icmpcode = -100;
        if (proto_sess->hop_info[hopno].all_rcvd) {
            lastishole = 0;
            SLIST_FOREACH(tp, &(proto_sess->hop_info[hopno].packets), next_by_hop) {
                if (tp->recv.tv_sec) {
                    if (hopno <= maxhop) icmpcode = tp->icmp_type;
                    if (tp->last_hop.s_addr != tp->hopaddr.s_addr) {
                        if ((tp->hopaddr.s_addr & classbmask.s_addr) == masked_target.s_addr)
                            netreached = 1;
                        else
                            netseam_hopno = hopno;
                        if (proto_sess->do_aslookup || proto_sess->do_netlookup) {
                            if (prevasn == -1) {
                                if (tp->asnumber) {
                                    prevasn        = tp->asnumber;
                                    prevasn_hopno  = hopno;
                                    prevasn_hopaddr = tp->hopaddr;
                                }
                            } else if (tp->asnumber && tp->asnumber != prevasn) {
                                asseam_hopno   = prevasn_hopno;
                                prevasn        = tp->asnumber;
                                prevasn_hopno  = hopno;
                                prevasn_hopaddr = tp->hopaddr;
                            } else if (tp->asnumber && tp->asnumber == prevasn) {
                                prevasn_hopno  = hopno;
                                prevasn_hopaddr = tp->hopaddr;
                            }
                        }
                    }
                }
            }
        } else {
            lastishole = 1;
        }
        if (icmpcode == -1) break;
    }
    if (!netreached) netseam_hopno = -1;
    if (lastishole)  asseam_hopno  = -1;
    (void)prevasn_hopaddr;

    /* ---- build SOURCE column ---- */
    {
        wa_col_t *c = &cols[ncols++];
        memset(c, 0, sizeof(*c));
        c->is_source = 1;
        snprintf(c->hdr, sizeof(c->hdr), "[ SOURCE ]");
        /* Chart always shows IP — no hostname lookup in the candlestick pane */
        snprintf(c->host, sizeof(c->host), "%s", inet_ntoa(proto_sess->local_address));
        if (proto_sess->protocol < 2 || proto_sess->protocol > 3) {
            size_t hlen = strlen(c->host);
            snprintf(c->host + hlen, sizeof(c->host) - hlen, ":%d", proto_sess->sport);
        }
        c->sub[0] = '\0';   /* host IS the IP — no sub-line needed */
        c->w = wa_utf8_display_width(c->hdr);
        if (wa_utf8_display_width(c->host) > c->w) c->w = wa_utf8_display_width(c->host);
    }

    /* ---- build per-hop columns ---- */
    neglstart = neglend = -1;
    reply = noreply = 0;
    (void)reply; (void)noreply;

    for (hopno = proto_sess->ttl_min; hopno < proto_sess->hop_info_length; hopno++) {
        int anomtype = 0;
        int icmpcode = -100;
        wa_col_t *c;

        if (ncols >= WA_MAX_COLS) break;

        if ((proto_sess->hop_info[hopno].state == HS_SEND_FIN) &&
            (proto_sess->hop_info[hopno+1].state == HS_SEND_SYN) &&
            (proto_sess->hop_info[hopno+1].ts_last_recv.tv_sec))
            anomtype = 1;
        if ((proto_sess->hop_info[hopno].state != HS_SEND_SYN_ACK) &&
            (proto_sess->hop_info[hopno+1].state == HS_SEND_SYN_ACK) &&
            (hopno == (proto_sess->num_hops - 1)))
            anomtype = 2;
        if ((proto_sess->hop_info[hopno].flags & HF_ENDPOINT) &&
            (noreply >= ((maxhop - proto_sess->ttl_min) / 2)) &&
            proto_sess->num_hops > 3)
            anomtype = 3;

        if (proto_sess->hop_info[hopno].all_rcvd == 0) {
            /* If we have historical data for this TTL (seen in a prior cycle),
             * render it as a "ghost" column rather than collapsing it into a
             * featureless cloaked hole.  This preserves RTT history, ASN, and
             * host data in the chart even when a router stops responding. */
            if (hopno < WATCH_MAX_HOPS &&
                ws->hops[hopno].cycles_seen > 0 &&
                ws->hops[hopno].host[0] &&
                strcmp(ws->hops[hopno].host, "*") != 0) {

                /* Flush any pending pure-hole block before the ghost column */
                if (neglstart != -1 && ncols < WA_MAX_COLS) {
                    c = &cols[ncols++];
                    memset(c, 0, sizeof(*c));
                    c->is_hole = 1;
                    if (neglstart == neglend)
                        snprintf(c->hdr, sizeof(c->hdr), "[ HOP %d ]", neglstart + 1);
                    else
                        snprintf(c->hdr, sizeof(c->hdr), "[ HOP %d-%d ]",
                                 neglstart + 1, neglend + 1);
                    snprintf(c->host, sizeof(c->host), "cloaked");
                    snprintf(c->rtt,  sizeof(c->rtt),
                             neglstart == neglend ? "TTL %d" : "TTLs %d-%d",
                             neglstart + 1, neglend + 1);
                    c->w = wa_utf8_display_width(c->hdr);
                    if (wa_utf8_display_width(c->host) > c->w)
                        c->w = wa_utf8_display_width(c->host);
                    if (wa_utf8_display_width(c->rtt)  > c->w)
                        c->w = wa_utf8_display_width(c->rtt);
                    neglstart = neglend = -1;
                }

                /* Build ghost column from ws->hops[hopno] historical data */
                if (ncols < WA_MAX_COLS) {
                    const watch_hop_t *wh = &ws->hops[hopno];
                    c = &cols[ncols++];
                    memset(c, 0, sizeof(*c));
                    c->is_ghost  = 1;
                    snprintf(c->hdr,  sizeof(c->hdr),  "[ HOP %d ]", hopno + 1);
                    /* Candlestick chart always shows the IP address —
                     * hostnames only appear in the timeline table below.
                     * This drops the sub-line, saving one chart row and
                     * avoiding per-cycle alignment churn when hostnames
                     * resolve (or fail to resolve) asynchronously. */
                    snprintf(c->host, sizeof(c->host),  "%s",
                             wh->host[0] ? wh->host : "*");
                    c->sub[0] = '\0';
                    /* ASN / NETNAME are deliberately NOT populated here —
                     * they render in the timeline table below the chart.
                     * Showing them in the candlestick made them flicker in
                     * and out depending on whether a cycle had the data
                     * cached, making the chart look unstable. */
                    c->asn[0] = '\0';
                    c->net[0] = '\0';
                    /* Historical RTT stats — use all-time min/avg/max */
                    c->stats.n       = wh->cycles_seen;
                    c->stats.rtt_min = wh->rtt_min;
                    c->stats.rtt_max = wh->rtt_max;
                    c->stats.rtt_avg = wh->rtt_avg;
                    c->stats.rtt_stddev = 0.0;
                    snprintf(c->rtt, sizeof(c->rtt), "%d/%d/%dms",
                             (int)round(wh->rtt_min),
                             (int)round(wh->rtt_avg),
                             (int)round(wh->rtt_max));
                    c->w = wa_utf8_display_width(c->hdr);
                    if (wa_utf8_display_width(c->host) > c->w)
                        c->w = wa_utf8_display_width(c->host);
                    if (wa_utf8_display_width(c->rtt)  > c->w)
                        c->w = wa_utf8_display_width(c->rtt);
                }
                continue;
            }
            if (neglstart == -1) neglstart = hopno;
            neglend = hopno;
            continue;
        }

        /* emit pending hole column */
        if (neglstart != -1 && ncols < WA_MAX_COLS) {
            c = &cols[ncols++];
            memset(c, 0, sizeof(*c));
            c->is_hole = 1;
            if (neglstart == neglend)
                snprintf(c->hdr,  sizeof(c->hdr),  "[ HOP %d ]", neglstart + 1);
            else
                snprintf(c->hdr,  sizeof(c->hdr),  "[ HOP %d-%d ]", neglstart + 1, neglend + 1);
            snprintf(c->host, sizeof(c->host), "cloaked");
            /* TTL shown in the RTT row (dim color) — avoids a separate sub line */
            snprintf(c->rtt,  sizeof(c->rtt),
                     neglstart == neglend ? "TTL %d" : "TTLs %d-%d",
                     neglstart + 1, neglend + 1);
            c->w = wa_utf8_display_width(c->hdr);
            if (wa_utf8_display_width(c->host) > c->w) c->w = wa_utf8_display_width(c->host);
            if (wa_utf8_display_width(c->rtt)  > c->w) c->w = wa_utf8_display_width(c->rtt);
            neglstart = neglend = -1;
        }

        SLIST_FOREACH(tp, &(proto_sess->hop_info[hopno].packets), next_by_hop) {
            if (!tp->recv.tv_sec) continue;
            if (hopno <= maxhop) icmpcode = tp->icmp_type;
            if (tp->last_hop.s_addr == tp->hopaddr.s_addr) continue;

            if (ncols >= WA_MAX_COLS) break;
            c = &cols[ncols++];
            memset(c, 0, sizeof(*c));
            c->anomtype = anomtype;

            if (icmpcode == -1) {
                c->is_target = 1;
                found_target = 1;
                snprintf(c->hdr, sizeof(c->hdr), "[ TARGET ]");
                if (proto_sess->target_open > 0)
                    snprintf(c->ann, sizeof(c->ann), "[open]");
                else if (proto_sess->target_filtered)
                    snprintf(c->ann, sizeof(c->ann), "[filtered]");
                else if (proto_sess->protocol == 2 || proto_sess->protocol == 3)
                    snprintf(c->ann, sizeof(c->ann), "[replied]");
                else
                    snprintf(c->ann, sizeof(c->ann), "[closed]");
            } else {
                snprintf(c->hdr, sizeof(c->hdr), "[ HOP %d ]", hopno + 1);
                if (anomtype == 1)
                    snprintf(c->ann, sizeof(c->ann), "\xe2\x87\xb6 Stateful FW");
                else if (anomtype == 2)
                    snprintf(c->ann, sizeof(c->ann), "\xe2\x9a\x91 Flag FW");
                else if (anomtype == 3)
                    snprintf(c->ann, sizeof(c->ann), "\xe2\x87\x84 BSD Bug");
                else if (hopno == asseam_hopno)
                    snprintf(c->ann, sizeof(c->ann), "\xe2\x95\xb3 ASN Seam");
                else if (hopno == netseam_hopno)
                    snprintf(c->ann, sizeof(c->ann), "\xe2\x95\xb3 NET Seam");
            }

            {
                /* When hostname display is on and we have a resolved name,
                 * show hostname as the primary label and IP as the sub-line —
                 * exactly matching the ghost-column behavior so a hop never
                 * flickers between IP and hostname as it alternates live/ghost. */
                const watch_hop_t *wh_live = (hopno >= 0 && hopno < WATCH_MAX_HOPS)
                                             ? &ws->hops[hopno] : NULL;
                /* Candlestick chart always shows the IP address — hostnames
                 * only appear in the timeline table below.  This drops the
                 * sub-line entirely, saving one chart row. */
                snprintf(c->host, sizeof(c->host), "%s", inet_ntoa(tp->hopaddr));
                c->sub[0] = '\0';
                (void)wh_live;
            }
            if (c->is_target && (proto_sess->protocol < 2 || proto_sess->protocol > 3)) {
                size_t hlen = strlen(c->host);
                snprintf(c->host + hlen, sizeof(c->host) - hlen, ":%d", proto_sess->dport);
            }
            /* No sub fallback: we intentionally show IP-only in the chart pane */

            /* ASN / NETNAME deliberately left empty — they render in the
             * timeline table below.  Populating them here caused them to
             * flicker in and out between cycles depending on pWhoIs cache
             * state, which made the chart look unstable. */
            c->asn[0] = '\0';
            c->net[0] = '\0';

            /* RTT — prefer watch session's rolling stats for chart stability */
            {
                int wi = hopno - proto_sess->ttl_min;
                if (wi >= 0 && wi < ws->n_hops && ws->hops[wi].cycles_seen > 0) {
                    /* Use rolling watch stats for smoother multi-cycle display */
                    c->stats.n        = ws->hops[wi].cycles_seen;
                    c->stats.rtt_min  = ws->hops[wi].rtt_min;
                    c->stats.rtt_avg  = ws->hops[wi].rtt_avg;
                    c->stats.rtt_max  = ws->hops[wi].rtt_max;
                    c->stats.rtt_stddev = 0.0;
                    snprintf(c->rtt, sizeof(c->rtt), "%d/%d/%dms",
                             (int)round(c->stats.rtt_min),
                             (int)round(c->stats.rtt_avg),
                             (int)round(c->stats.rtt_max));
                } else if (proto_sess->rtt_stats) {
                    c->stats = wa_compute_rtt_stats(proto_sess, hopno);
                    if (c->stats.n > 0)
                        snprintf(c->rtt, sizeof(c->rtt), "%d/%d/%dms",
                                 (int)round(c->stats.rtt_min),
                                 (int)round(c->stats.rtt_avg),
                                 (int)round(c->stats.rtt_max));
                } else if (tp->recv.tv_sec) {
                    double rtt = timediff_ms(tp->sent, tp->recv);
                    c->stats.n = 1;
                    c->stats.rtt_min = c->stats.rtt_avg = c->stats.rtt_max = rtt;
                    snprintf(c->rtt, sizeof(c->rtt), "%.1fms", rtt);
                }
            }

            if (proto_sess->pmtud && proto_sess->hop_info[hopno].path_mtu > 0)
                snprintf(c->mtu, sizeof(c->mtu), "[MTU: %u]",
                         (unsigned)proto_sess->hop_info[hopno].path_mtu);

            c->w = wa_utf8_display_width(c->hdr);
            if (wa_utf8_display_width(c->host) > c->w) c->w = wa_utf8_display_width(c->host);
            if (wa_utf8_display_width(c->sub)  > c->w) c->w = wa_utf8_display_width(c->sub);
            /* c->asn and c->net intentionally excluded — they no longer render
             * in the chart, so including them in width calcs would waste space. */
            if (wa_utf8_display_width(c->rtt)  > c->w) c->w = wa_utf8_display_width(c->rtt);
            if (wa_utf8_display_width(c->ann)  > c->w) c->w = wa_utf8_display_width(c->ann);
            if (wa_utf8_display_width(c->mtu)  > c->w) c->w = wa_utf8_display_width(c->mtu);

            if (found_target) break;
        }
        if (found_target) break;
    }

    /* trailing hole */
    if (neglstart != -1 && ncols < WA_MAX_COLS) {
        wa_col_t *c = &cols[ncols++];
        memset(c, 0, sizeof(*c));
        c->is_hole = 1;
        if (neglstart == neglend)
            snprintf(c->hdr, sizeof(c->hdr), "[ HOP %d ]", neglstart + 1);
        else
            snprintf(c->hdr, sizeof(c->hdr), "[ HOP %d-%d ]", neglstart + 1, neglend + 1);
        snprintf(c->host, sizeof(c->host), "cloaked");
        /* TTL shown in the RTT row (dim color) — avoids a separate sub line */
        snprintf(c->rtt,  sizeof(c->rtt),
                 neglstart == neglend ? "TTL %d" : "TTLs %d-%d",
                 neglstart + 1, neglend + 1);
        c->w = wa_utf8_display_width(c->hdr);
        if (wa_utf8_display_width(c->host) > c->w) c->w = wa_utf8_display_width(c->host);
        if (wa_utf8_display_width(c->rtt)  > c->w) c->w = wa_utf8_display_width(c->rtt);
    }

    if (ncols == 0) return 0;

    /* ---- cap column widths at WA_MAX_COL_W ----
     * The hostname is the only field that can realistically push a column
     * beyond 17 display columns; truncate it from the right so the label
     * row stays within the cap.  All other fields (hdr, rtt, ann, mtu) are
     * fixed-format strings that are ≤17 chars in practice. */
    {
        int ci;
        for (ci = 0; ci < ncols; ci++) {
            if (cols[ci].w > WA_MAX_COL_W) {
                wa_utf8_truncate_to(cols[ci].host, WA_MAX_COL_W);
                cols[ci].w = WA_MAX_COL_W;
            }
        }
    }

    /* ---- global RTT bounds + jitter detection ---- */
    double g_rtt_min = 0.0, g_rtt_max = 0.0;
    {
        int g_found = 0, gi;
        double max_jitter = 0.0;
        int jitter_col = -1;
        for (gi = 0; gi < ncols; gi++) {
            if (cols[gi].stats.n > 0) {
                if (!g_found || cols[gi].stats.rtt_min < g_rtt_min) g_rtt_min = cols[gi].stats.rtt_min;
                if (!g_found || cols[gi].stats.rtt_max > g_rtt_max) g_rtt_max = cols[gi].stats.rtt_max;
                g_found = 1;
                double jitter = cols[gi].stats.rtt_max - cols[gi].stats.rtt_min;
                if (jitter > max_jitter) { max_jitter = jitter; jitter_col = gi; }
            }
        }
        if (jitter_col >= 0 && max_jitter > WA_JITTER_WARN) {
            int jms = (int)round(max_jitter);
            cols[jitter_col].jitter_warn = 1;
            snprintf(cols[jitter_col].jitter_ann, sizeof(cols[jitter_col].jitter_ann),
                     "\xe2\x9a\xa1 %dms \xce\x94", jms);
            if (cols[jitter_col].ann[0] == '\0')
                snprintf(cols[jitter_col].ann, sizeof(cols[jitter_col].ann),
                         "\xe2\x9a\xa1 jitter %dms", jms);
            if (wa_utf8_display_width(cols[jitter_col].jitter_ann) > cols[jitter_col].w)
                cols[jitter_col].w = wa_utf8_display_width(cols[jitter_col].jitter_ann);
            if (wa_utf8_display_width(cols[jitter_col].ann) > cols[jitter_col].w)
                cols[jitter_col].w = wa_utf8_display_width(cols[jitter_col].ann);
        }
    }

    /* ---- x-axis info string ---- */
    char xa_info[256] = "";
    {
        double tgt_min = 0.0, tgt_max = 0.0;
        int found_tgt = 0, gi;
        int n_hops = ncols - 1;
        const char *tgt_name = inet_ntoa(proto_sess->remote_address);
        char tgt_url[80];
        switch (proto_sess->protocol) {
            case 1: snprintf(tgt_url, sizeof(tgt_url), "udp://%s:%d", tgt_name, proto_sess->dport); break;
            case 2: case 3: snprintf(tgt_url, sizeof(tgt_url), "icmp://%s", tgt_name); break;
            default: snprintf(tgt_url, sizeof(tgt_url), "tcp://%s:%d", tgt_name, proto_sess->dport); break;
        }
        for (gi = 0; gi < ncols; gi++) {
            if (cols[gi].is_target && cols[gi].stats.n > 0) {
                tgt_min = cols[gi].stats.rtt_min;
                tgt_max = cols[gi].stats.rtt_max;
                found_tgt = 1;
                break;
            }
        }
        if (found_tgt)
            snprintf(xa_info, sizeof(xa_info),
                     "End-to-End RTT %d\xe2\x80\x93%dms, %d hops to %s",
                     (int)round(tgt_min), (int)round(tgt_max), n_hops, tgt_url);
        else
            snprintf(xa_info, sizeof(xa_info), "%d hops traced, target unreachable", n_hops);
    }

    /* Y-axis prefix width */
    {
        char ybuf[16];
        snprintf(ybuf, sizeof(ybuf), "%dms", (int)ceil(g_rtt_max > 0 ? g_rtt_max : 1.0));
        int y_prefix_w = (int)strlen(ybuf) + 3;
        int effective_w = term_cols - y_prefix_w;
        if (effective_w < 20) effective_w = 20;

        /* ---- render column slices ---- */
        int start = 0;
        while (start < ncols && ctx.row < ctx.max_row) {
            int end  = start;
            int used = cols[start].w;
            while (end + 1 < ncols) {
                int needed = used + WA_CHART_SEP + cols[end + 1].w;
                if (needed > effective_w) break;
                used = needed;
                end++;
            }

            /* blank row before each slice */
            if (ctx.row < ctx.max_row) { clrtoeol(); wa_nl(&ctx); }

            /* candlestick chart — no x-axis RTT label (shown in top border) */
            wa_chart_render(&ctx, cols, start, end, g_rtt_min, g_rtt_max, "");

            if (ctx.row >= ctx.max_row) break;

            /* ---- header row (hop labels, bottom — outer corners └ / ┘) ---- */
            if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
            wa_render_header_row(&ctx, cols, start, end, 1);
            if (ctx.row >= ctx.max_row) break;

            /* ---- hostname row ---- */
            if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
            {
                int ii;
                for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                    wa_col_t *c = &cols[ii];
                    if (c->is_hole)        wa_color_on(CP_NORMAL, A_DIM);
                    else if (c->is_target) wa_color_on(CP_NORMAL, A_BOLD);
                    else if (c->anomtype)  wa_color_on(CP_RED,    0);
                    else if (c->is_source) wa_color_on(CP_NORMAL, A_BOLD);
                    wa_center(&ctx, c->host, c->w);
                    if (c->is_hole)        wa_color_off(CP_NORMAL, A_DIM);
                    else if (c->is_target) wa_color_off(CP_NORMAL, A_BOLD);
                    else if (c->anomtype)  wa_color_off(CP_RED,    0);
                    else if (c->is_source) wa_color_off(CP_NORMAL, A_BOLD);
                    if (ii < end) wa_str(&ctx, " -> ");
                }
            }
            wa_nl(&ctx);
            if (ctx.row >= ctx.max_row) break;

            /* ---- alternate IP rows (multipath) ---- */
            /* Show one row per alternate IP level across all columns in this slice.
             * Stats/bar already combine all paths; these lines just identify them. */
            {
                int alt_idx;
                for (alt_idx = 0; alt_idx < WATCH_MAX_ALT_IPS; alt_idx++) {
                    int ii, any = 0;
                    for (ii = start; ii <= end; ii++)
                        if (alt_idx < cols[ii].n_alt) { any = 1; break; }
                    if (!any) break;
                    if (ctx.row >= ctx.max_row) break;
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *ca = &cols[ii];
                        const char *alt = (alt_idx < ca->n_alt) ? ca->alt_hosts[alt_idx] : "";
                        if (alt[0]) wa_color_on(CP_STATUS, A_DIM);
                        wa_center(&ctx, alt, ca->w);
                        if (alt[0]) wa_color_off(CP_STATUS, A_DIM);
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                }
            }
            if (ctx.row >= ctx.max_row) break;

            /* ---- sub line (IP in parens or TTL for holes) ---- */
            {
                int ii, any = 0;
                for (ii = start; ii <= end; ii++) if (cols[ii].sub[0]) { any=1; break; }
                if (any) {
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *c = &cols[ii];
                        if (c->is_hole) wa_color_on(CP_NORMAL, A_DIM);
                        wa_center(&ctx, c->sub, c->w);
                        if (c->is_hole) wa_color_off(CP_NORMAL, A_DIM);
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                    if (ctx.row >= ctx.max_row) break;
                }
            }

            /* ---- ASN line ---- */
            {
                int ii, any = 0;
                for (ii = start; ii <= end; ii++) if (cols[ii].asn[0]) { any=1; break; }
                if (any) {
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *c = &cols[ii];
                        if (c->asn[0]) wa_color_on(c->is_hole ? CP_NORMAL : CP_YELLOW,
                                                    c->is_hole ? A_DIM : 0);
                        wa_center(&ctx, c->asn, c->w);
                        if (c->asn[0]) wa_color_off(c->is_hole ? CP_NORMAL : CP_YELLOW,
                                                     c->is_hole ? A_DIM : 0);
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                    if (ctx.row >= ctx.max_row) break;
                }
            }

            /* ---- net name line ---- */
            {
                int ii, any = 0;
                for (ii = start; ii <= end; ii++) if (cols[ii].net[0]) { any=1; break; }
                if (any) {
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *c = &cols[ii];
                        if (c->net[0]) wa_color_on(CP_YELLOW, 0);
                        wa_center(&ctx, c->net, c->w);
                        if (c->net[0]) wa_color_off(CP_YELLOW, 0);
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                    if (ctx.row >= ctx.max_row) break;
                }
            }

            /* ---- RTT line ---- */
            {
                int ii, any = 0;
                for (ii = start; ii <= end; ii++) if (cols[ii].rtt[0]) { any=1; break; }
                if (any) {
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *c = &cols[ii];
                        if (c->rtt[0]) wa_color_on(c->is_hole ? CP_NORMAL : CP_GREEN,
                                                    c->is_hole ? A_DIM    : 0);
                        wa_center(&ctx, c->rtt, c->w);
                        if (c->rtt[0]) wa_color_off(c->is_hole ? CP_NORMAL : CP_GREEN,
                                                     c->is_hole ? A_DIM    : 0);
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                    if (ctx.row >= ctx.max_row) break;
                }
            }

            /* ---- annotation line ---- */
            {
                int ii, any = 0;
                for (ii = start; ii <= end; ii++) if (cols[ii].ann[0]) { any=1; break; }
                if (any) {
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *c = &cols[ii];
                        if (c->ann[0]) {
                            if (c->is_target) {
                                if      (proto_sess->target_open > 0)                           wa_color_on(CP_GREEN,  A_BOLD);
                                else if (proto_sess->target_filtered)                            wa_color_on(CP_YELLOW, 0);
                                else if (proto_sess->protocol == 2 || proto_sess->protocol == 3) wa_color_on(CP_GREEN,  0);
                                else                                                             wa_color_on(CP_RED,    A_BOLD);
                            } else if (c->anomtype > 0) {
                                wa_color_on(CP_RED, 0);
                            } else {
                                wa_color_on(CP_ORANGE, 0);
                            }
                        }
                        wa_center(&ctx, c->ann, c->w);
                        if (c->ann[0]) {
                            if (c->is_target) {
                                if      (proto_sess->target_open > 0)                           wa_color_off(CP_GREEN,  A_BOLD);
                                else if (proto_sess->target_filtered)                            wa_color_off(CP_YELLOW, 0);
                                else if (proto_sess->protocol == 2 || proto_sess->protocol == 3) wa_color_off(CP_GREEN,  0);
                                else                                                             wa_color_off(CP_RED,    A_BOLD);
                            } else if (c->anomtype > 0) {
                                wa_color_off(CP_RED, 0);
                            } else {
                                wa_color_off(CP_ORANGE, 0);
                            }
                        }
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                    if (ctx.row >= ctx.max_row) break;
                }
            }

            /* ---- MTU line ---- */
            if (proto_sess->pmtud) {
                int ii, any = 0;
                for (ii = start; ii <= end; ii++) if (cols[ii].mtu[0]) { any=1; break; }
                if (any) {
                    if (y_prefix_w > 0) wa_printf(&ctx, "%*s", y_prefix_w, "");
                    for (ii = start; ii <= end && ctx.col < ctx.term_cols - 1; ii++) {
                        wa_col_t *c = &cols[ii];
                        if (c->mtu[0]) wa_color_on(CP_YELLOW, 0);
                        wa_center(&ctx, c->mtu, c->w);
                        if (c->mtu[0]) wa_color_off(CP_YELLOW, 0);
                        if (ii < end) wa_repeat_ch(&ctx, ' ', WA_CHART_SEP);
                    }
                    wa_nl(&ctx);
                    if (ctx.row >= ctx.max_row) break;
                }
            }

            start = end + 1;
        }
    }

    return ctx.row - start_row;
}

/* ---- View name label for the right side of the top border ---- */
static const char *watch_view_name(int view)
{
    switch (view) {
        case 2:  return "Bars";
        case 3:  return "Status Board";
        default: return "Candlestick";
    }
}

/* ---- Shared top border row (row 0) for all views ----
 * Left:   ┌──┤ Layer Four Traceroute (LFT) ├
 * Middle: [dash_l]┤ rtt_info ├[dash_r]   (omitted when rtt_info NULL/empty)
 * Right:  ┤ ViewName ├──┐
 *
 * When RTT is present, it is centered in the space between LFT and VIEW
 * sections (equal dash_l / dash_r split).
 */
/* Build the centre RTT/hop summary string for the top border.
 * Used by all three views so they share identical header content. */
static void watch_build_rtt_info(watch_session_t *ws, char *buf, int sz)
{
    int i, last_hop = -1;
    for (i = ws->n_hops - 1; i >= 0; i--) {
        if (ws->hops[i].rtt_avg > 0.0) { last_hop = i; break; }
    }
    if (last_hop >= 0) {
        snprintf(buf, (size_t)sz,
                 "End-to-End RTT %d\xe2\x80\x93%dms, %d hops to %s",
                 (int)round(ws->hops[last_hop].rtt_min),
                 (int)round(ws->hops[last_hop].rtt_max),
                 ws->n_hops, ws->tgt_label);
    } else {
        snprintf(buf, (size_t)sz, "%s  %d hops", ws->tgt_label, ws->n_hops);
    }
}

static void watch_render_top_border(watch_session_t *ws, const char *rtt_info)
{
    const char *lft_label  = "Layer Four Traceroute (LFT)";
    const char *view_label = watch_view_name(ws->current_view);
    int lft_dw   = wa_utf8_display_width(lft_label);
    int view_dw  = wa_utf8_display_width(view_label);
    /* ┌──┤ space LFT space ├  = lft_dw + 7 */
    int lft_end  = lft_dw + 7;
    /* ┤ space VIEW space ├──┐ = view_dw + 7 */
    int view_end = view_dw + 7;
    int i;

    /* Macros for switching between frame (blue box-drawing) and text (cyan)
     * color pairs so we can draw a mixed-color top border in one pass. */
    #define FRAME_ON()  do { if (has_colors()) attron(COLOR_PAIR(CP_BLUE)); } while (0)
    #define FRAME_OFF() do { if (has_colors()) attroff(COLOR_PAIR(CP_BLUE)); } while (0)
    #define TEXT_ON()   do { if (has_colors()) attron(COLOR_PAIR(CP_SENT) | A_BOLD); } while (0)
    #define TEXT_OFF()  do { if (has_colors()) attroff(COLOR_PAIR(CP_SENT) | A_BOLD); } while (0)

    move(0, 0);
    FRAME_ON();
    addstr("\xe2\x94\x8c\xe2\x94\x80\xe2\x94\x80");   /* ┌── */
    addstr("\xe2\x94\xa4");                             /* ┤  */
    FRAME_OFF();
    TEXT_ON();
    addstr(" "); addstr(lft_label); addstr(" ");
    TEXT_OFF();
    FRAME_ON();
    addstr("\xe2\x94\x9c");                             /* ├  */

    if (rtt_info && rtt_info[0]) {
        int info_dw = wa_utf8_display_width(rtt_info);
        int rtt_sec = info_dw + 4;   /* ┤ space info space ├ */
        int total   = ws->term_cols - lft_end - rtt_sec - view_end;
        if (total < 0) total = 0;
        int dash_l  = (ws->term_cols - rtt_sec) / 2 - lft_end;
        if (dash_l < 0)     dash_l = 0;
        if (dash_l > total) dash_l = total;
        int dash_r  = total - dash_l;
        for (i = 0; i < dash_l; i++) addstr("\xe2\x94\x80");  /* ─ */
        addstr("\xe2\x94\xa4");                                 /* ┤ */
        FRAME_OFF();
        TEXT_ON();
        addstr(" "); addstr(rtt_info); addstr(" ");
        TEXT_OFF();
        FRAME_ON();
        addstr("\xe2\x94\x9c");                                 /* ├ */
        for (i = 0; i < dash_r; i++) addstr("\xe2\x94\x80");  /* ─ */
    } else {
        int total = ws->term_cols - lft_end - view_end;
        if (total < 0) total = 0;
        for (i = 0; i < total; i++) addstr("\xe2\x94\x80");   /* ─ */
    }

    addstr("\xe2\x94\xa4");                             /* ┤  */
    FRAME_OFF();
    TEXT_ON();
    addstr(" "); addstr(view_label); addstr(" ");
    TEXT_OFF();
    FRAME_ON();
    addstr("\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80");   /* ├── */
    addstr("\xe2\x94\x90");                             /* ┐  */
    FRAME_OFF();
    #undef FRAME_ON
    #undef FRAME_OFF
    #undef TEXT_ON
    #undef TEXT_OFF
}

static void watch_render_view1(watch_session_t *ws, lft_session_params *proto_sess)
{
    int chart_max, chart_rows_used;
    int div_row, bot_row, timeline_start, timeline_rows;
    int r, i;
    /* Framed layout:
     *   row 0          : ┌──┤ RTT info ├──────────────────────────────────────────┐
     *   rows 1..N      : │  candlestick chart                                     │
     *   row N+1        : ├──────────────────────────────────────────────────────────┤
     *   rows N+2..M    : │  rolling timeline table                                 │
     *   row M+1 (=bot) : └──────────────────────────────────────────────────────────┘
     *   row M+2        : status bar row 1
     *   row M+3        : status bar row 2
     */
    bot_row   = ws->term_rows - 3;    /* bottom border row (above status bar) */
    chart_max = (bot_row - 1) * 6 / 10;  /* ~60% of usable rows for chart */
    if (chart_max < 4) chart_max = 4;

    clear();

    /* ------------------------------------------------------------------ */
    /* Compute end-to-end RTT for the top border title                     */
    /* ------------------------------------------------------------------ */
    char rtt_info[256];
    watch_build_rtt_info(ws, rtt_info, (int)sizeof(rtt_info));

    /* ------------------------------------------------------------------ */
    /* Row 0: top border                                                  */
    /* ------------------------------------------------------------------ */
    watch_render_top_border(ws, rtt_info);

    if (proto_sess == NULL || ws->n_hops == 0) {
        mvprintw(2, 2, "Collecting initial trace data...");
        return;
    }

    /* ------------------------------------------------------------------ */
    /* Chart: rows 1..chart_max, content at col 1..term_cols-2.  The       */
    /* chart is given a FIXED slot of chart_max rows whether it uses all  */
    /* of them or not — that way the divider row (and the timeline table  */
    /* below it) stays put when the number of hops/paths changes between  */
    /* cycles. Without this, a growing candlestick bumps the whole table  */
    /* downward on each cycle and vice versa.                              */
    /* ------------------------------------------------------------------ */
    chart_rows_used = watch_render_ascii_chart(ws, proto_sess, 1, chart_max, 1);
    (void)chart_rows_used;

    /* ------------------------------------------------------------------ */
    /* Divider: ├──────────────────────────────────────────────────────────┤ */
    /* Fixed at row (1 + chart_max) regardless of actual chart height.     */
    /* ------------------------------------------------------------------ */
    div_row = 1 + chart_max;
    if (div_row >= bot_row) div_row = bot_row - 1;
    if (div_row > 0 && div_row < ws->term_rows - 2) {
        if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
        move(div_row, 0);
        addstr("\xe2\x94\x9c");                             /* ├ */
        for (i = 1; i < ws->term_cols - 1; i++) addstr("\xe2\x94\x80"); /* ─ */
        addstr("\xe2\x94\xa4");                             /* ┤ */
        if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));
    }
    timeline_start = div_row + 1;

    /* ------------------------------------------------------------------ */
    /* Timeline: rows timeline_start..bot_row, content at col 1..end-2    */
    /* The table's bottom border is the status bar itself (├──...──┤).    */
    /* ------------------------------------------------------------------ */
    timeline_rows = bot_row - timeline_start + 1;
    if (timeline_rows >= 2)
        watch_render_timeline(ws, timeline_start, timeline_rows, 1);

    /* ------------------------------------------------------------------ */
    /* Left │ and right │ borders for each content row                     */
    /* The chart and timeline render with col_offset=1 / start_col=1, so   */
    /* col 0 and col term_cols-1 are free for the borders.  Borders extend */
    /* all the way down to bot_row; the status bar row below acts as the   */
    /* closing horizontal via ├── ... ──┤ connectors.                      */
    /* ------------------------------------------------------------------ */
    if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
    /* Use ncurses native ACS_VLINE + mvvline() for continuous solid sides,
     * in two segments so the divider row keeps its ├ and ┤ connectors. */
    if (div_row > 1)
        mvvline(1, 0, ACS_VLINE, div_row - 1);
    if (bot_row > div_row)
        mvvline(div_row + 1, 0, ACS_VLINE, bot_row - div_row);
    if (div_row > 1)
        mvvline(1, ws->term_cols - 1, ACS_VLINE, div_row - 1);
    if (bot_row > div_row)
        mvvline(div_row + 1, ws->term_cols - 1, ACS_VLINE, bot_row - div_row);
    if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));
    (void)r;   /* no longer used */
}

/* =========================================================================
 * View 2: A+B hybrid — native bar chart (top) + timeline table (bottom)
 * ========================================================================= */

/*
 * Native ncurses RTT bar chart — drawn directly from ws->hops[] data.
 * Uses Unicode block characters and ncurses color pairs for consistent
 * styling with the timeline table below.
 * Returns the number of rows consumed.
 */
static int watch_render_native_chart(watch_session_t *ws,
                                      int start_row, int max_rows,
                                      int col_offset)
{
    int i, r;
    double g_max = 0.0;
    int n = ws->n_hops;
    int y_lbl_w = 8;    /* "9999ms |" */
    int bar_rows, avail_cols, col_w, bar_w;
    int hop_row, ip_row;
    int right_edge = ws->term_cols - col_offset;  /* exclusive right bound */

    if (n <= 0 || max_rows < 4) return 0;

    /* Find global RTT max across hops that have data (ignore 100% loss hops) */
    for (i = 0; i < n; i++) {
        double lp = (ws->hops[i].cycles_total > 0)
            ? 100.0 * (ws->hops[i].cycles_total - ws->hops[i].cycles_seen)
              / ws->hops[i].cycles_total
            : 100.0;
        if (lp < 100.0 && ws->hops[i].rtt_max > g_max)
            g_max = ws->hops[i].rtt_max;
    }
    if (g_max <= 0.0) g_max = 100.0;
    g_max *= 1.15;   /* 15% headroom so bars don't clip the top */

    /* Row allocation: bar area + 2 label rows (hop number + IP) */
    bar_rows = max_rows - 2;
    if (bar_rows < 2) bar_rows = 2;

    avail_cols = right_edge - col_offset - y_lbl_w;
    if (avail_cols < 1) avail_cols = 1;
    col_w = avail_cols / n;
    if (col_w < 1) col_w = 1;
    if (col_w > WA_MAX_COL_W) col_w = WA_MAX_COL_W;  /* keep label rows readable */
    bar_w = (col_w > 1) ? col_w - 1 : 1; /* 1-char gap between hop columns */

    /* Draw bar rows top→bottom (row 0 = g_max ms, row bar_rows-1 = 0 ms) */
    for (r = 0; r < bar_rows; r++) {
        int screen_row = start_row + r;
        double rtt_at_row;
        if (screen_row >= ws->term_rows - 2) break;

        move(screen_row, 0);
        clrtoeol();

        /* Y-axis label: only at top, midpoint, and bottom rows */
        if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
        rtt_at_row = g_max * (bar_rows - 1 - r)
                     / (double)(bar_rows > 1 ? bar_rows - 1 : 1);
        if (r == 0)
            mvprintw(screen_row, col_offset, "%5.0fms |", g_max);
        else if (r == bar_rows / 2)
            mvprintw(screen_row, col_offset, "%5.0fms |", rtt_at_row);
        else if (r == bar_rows - 1)
            mvprintw(screen_row, col_offset, "    0ms |");
        else
            mvprintw(screen_row, col_offset, "        ");
        if (has_colors()) attroff(COLOR_PAIR(CP_STATUS));

        /* Per-hop bar segments */
        for (i = 0; i < n; i++) {
            watch_hop_t *h = &ws->hops[i];
            double lp = (h->cycles_total > 0)
                ? 100.0 * (h->cycles_total - h->cycles_seen) / h->cycles_total
                : 100.0;
            int cx  = col_offset + y_lbl_w + i * col_w;
            int w   = (cx + bar_w <= right_edge) ? bar_w : right_edge - cx;
            int fill_top, max_top, j;
            if (cx >= right_edge) break;
            if (w < 1) w = 1;

            move(screen_row, cx);

            if (lp >= 100.0) {
                /* Cloaked hop: loss colour asterisk at the bottom row, blank above */
                if (r == bar_rows - 1) {
                    if (has_colors()) attron(COLOR_PAIR(CP_MAGENTA) | A_BOLD);
                    addstr("*");
                    if (has_colors()) attroff(COLOR_PAIR(CP_MAGENTA) | A_BOLD);
                } else {
                    printw("%*s", w, "");
                }
                continue;
            }

            /* bar fills from avg RTT down; max-RTT mark appears above it */
            fill_top = bar_rows - 1
                - (int)(h->rtt_avg / g_max * (bar_rows - 1) + 0.5);
            max_top  = bar_rows - 1
                - (int)(h->rtt_max / g_max * (bar_rows - 1) + 0.5);
            if (fill_top < 0) fill_top = 0;
            if (max_top  < 0) max_top  = 0;
            if (max_top > fill_top) max_top = fill_top;

            if (r >= fill_top) {
                /* Filled bar (avg RTT height and below) */
                if (has_colors()) attron(COLOR_PAIR(rtt_color(h->rtt_avg)));
                for (j = 0; j < w; j++)
                    addstr("\xe2\x96\x88");  /* █ */
                if (has_colors()) attroff(COLOR_PAIR(rtt_color(h->rtt_avg)));
            } else if (r == max_top && max_top < fill_top) {
                /* Max-RTT cap mark (▄) just above the filled bar */
                if (has_colors()) attron(COLOR_PAIR(rtt_color(h->rtt_max)));
                addstr("\xe2\x96\x84");  /* ▄ */
                if (has_colors()) attroff(COLOR_PAIR(rtt_color(h->rtt_max)));
            } else {
                printw("%*s", w, "");
            }
        }
    }

    /* Hop-number label row */
    hop_row = start_row + bar_rows;
    if (hop_row < ws->term_rows - 2) {
        move(hop_row, 0);
        clrtoeol();
        if (has_colors()) attron(COLOR_PAIR(CP_STATUS) | A_BOLD);
        mvprintw(hop_row, col_offset, "%8s", "");
        for (i = 0; i < n; i++) {
            int cx = col_offset + y_lbl_w + i * col_w;
            if (cx >= right_edge) break;
            move(hop_row, cx);
            printw("%-*d", col_w < 4 ? col_w : 3, i + 1);
        }
        if (has_colors()) attroff(COLOR_PAIR(CP_STATUS) | A_BOLD);
    }

    /* IP/host label row */
    ip_row = start_row + bar_rows + 1;
    if (ip_row < ws->term_rows - 2) {
        move(ip_row, 0);
        clrtoeol();
        for (i = 0; i < n; i++) {
            watch_hop_t *h = &ws->hops[i];
            int cx      = col_offset + y_lbl_w + i * col_w;
            int trunc_w = col_w > 1 ? col_w - 1 : 1;
            char trunc[64];
            if (cx >= right_edge) break;
            if (trunc_w > (int)sizeof(trunc) - 1)
                trunc_w = (int)sizeof(trunc) - 1;
            /* Bar chart always shows the IP — hostnames only appear in
             * the timeline table below, so 'h' only affects that. */
            snprintf(trunc, sizeof(trunc), "%-.*s", trunc_w,
                     h->host[0] ? h->host : "*");
            move(ip_row, cx);
            if (has_colors()) attron(COLOR_PAIR(rtt_color(h->rtt_avg)));
            addstr(trunc);
            if (has_colors()) attroff(COLOR_PAIR(rtt_color(h->rtt_avg)));
        }
    }

    return max_rows;
}

/* Render the rolling timeline table into rows [start_row .. start_row+max_rows).
 * col_offset: left margin (0 = full-width, 1 = inside a framed border). */
static int watch_render_timeline(watch_session_t *ws, int start_row, int max_rows,
                                  int col_offset)
{
    int i, row = start_row;
    int hist_cols;     /* history sparkline cells after fixed columns */
    int fixed_w;       /* width of fixed columns (HOP … JITTER) */
    int host_w;        /* HOST column: auto-widened to fit longest name */
    int whois_w = 0;   /* ASN(8) + NETNAME(16) [+ ORGNAME(20)] */
    int show_orgname = 0;
    int row_end = start_row + max_rows - 1;
    /* right border column (content must stop before this) */
    int right_col = ws->term_cols - (col_offset > 0 ? 1 : 0);

    if (row >= ws->term_rows - 2) return 0;

    /* ------------------------------------------------------------------ */
    /* Compute host_w: widest hop address/name, clamped to fit.           */
    /* To avoid horizontal layout shift when the user presses 'h' to      */
    /* toggle hostnames, host_w is the max of the IP width AND the        */
    /* hostname width (regardless of current show_hostnames) — so the     */
    /* column budget is identical in both states.                         */
    /* ------------------------------------------------------------------ */
    host_w = 15;   /* minimum */
    for (i = 0; i < ws->n_hops; i++) {
        const char *ip   = ws->hops[i].host[0]     ? ws->hops[i].host     : "*";
        const char *name = ws->hops[i].hostname[0] ? ws->hops[i].hostname : ip;
        int w_ip   = (int)strlen(ip);
        int w_name = (int)strlen(name);
        int w      = (w_ip > w_name) ? w_ip : w_name;
        /* Target hop gets a state tag appended — budget 11 extra chars. */
        if (i == ws->target_hopno && ws->target_hopno > 0)
            w += 11;
        if (w > host_w) host_w = w;
    }
    if (host_w > 64) host_w = 64;   /* sanity cap */

    /* Whois column widths */
    if (ws->whois_enabled) {
        whois_w = 8 + 2 + 16 + 2;
        /* ORGNAME if the terminal is wide enough (evaluated after host_w) */
        if (ws->term_cols - col_offset >= 1 + 3 + 2 + host_w + 2 + 5 + 2 + 6 + 2 +
                                          6 + 2 + 6 + 2 + 4 + 2 + whois_w + 20 + 2 + 4) {
            whois_w  += 20 + 2;
            show_orgname = 1;
        }
    }

    /* Fixed column width (everything except the sparkline) */
    fixed_w = 1 + 3 + 2 + host_w + 2 + 5 + 2 + 6 + 2 + 6 + 2 + 6 + 2 + 4 + 2;

    /* If host_w makes fixed columns too wide, shrink host_w to fit. */
    {
        int min_hist = 8;   /* keep at least 8 sparkline cells */
        int max_fixed = right_col - col_offset - whois_w - min_hist - 2;
        if (fixed_w > max_fixed && max_fixed > 1 + 3 + 2 + 15 + 2 + 5 + 2 + 6 + 2 + 6 + 2 + 6 + 2 + 4 + 2) {
            fixed_w = max_fixed;
            host_w  = fixed_w - (1 + 3 + 2 + 2 + 5 + 2 + 6 + 2 + 6 + 2 + 6 + 2 + 4 + 2);
            if (host_w < 15) host_w = 15;
            /* recompute fixed_w with clamped host_w */
            fixed_w = 1 + 3 + 2 + host_w + 2 + 5 + 2 + 6 + 2 + 6 + 2 + 6 + 2 + 4 + 2;
        }
    }

    hist_cols = (right_col - col_offset) - fixed_w - whois_w - 2;
    if (hist_cols < 0) hist_cols = 0;

    /* ------------------------------------------------------------------ */
    /* Header row — column labels left, sparkline legend right            */
    /* ------------------------------------------------------------------ */
    move(row, col_offset);
    clrtoeol();
    if (has_colors()) attron(COLOR_PAIR(CP_TBLHEAD));
    if (ws->whois_enabled && show_orgname)
        mvprintw(row, col_offset,
                 " %-3s  %-*s  %-5s  %-6s  %-6s  %-6s  %-4s  %-8.8s  %-16.16s  %-20.20s",
                 "HOP", host_w, "HOST",
                 "LOSS%", "MIN", "AVG", "MAX", "JIT",
                 "ASN", "NETNAME", "ORGNAME");
    else if (ws->whois_enabled)
        mvprintw(row, col_offset,
                 " %-3s  %-*s  %-5s  %-6s  %-6s  %-6s  %-4s  %-8.8s  %-16.16s",
                 "HOP", host_w, "HOST",
                 "LOSS%", "MIN", "AVG", "MAX", "JIT", "ASN", "NETNAME");
    else
        mvprintw(row, col_offset,
                 " %-3s  %-*s  %-5s  %-6s  %-6s  %-6s  %-4s",
                 "HOP", host_w, "HOST",
                 "LOSS%", "MIN", "AVG", "MAX", "JIT");
    if (has_colors()) attroff(COLOR_PAIR(CP_TBLHEAD));

    /* Sparkline legend: ▪<avg  ▪+20%  ▪+50%  ▪=loss
     * Rendered right-justified inside the sparkline column.  Always drawn
     * whenever the legend width fits (24 display cols).  The legend is
     * independent of whois state — pressing 'w' must not replace it with
     * a "(newest→)" indicator.
     * ▪ = U+25AA (BLACK SMALL SQUARE) = \xe2\x96\xaa (3 UTF-8 bytes, 1 col)
     * Color: +50% = CP_MAGENTA, loss = CP_RED|A_BOLD */
    if (hist_cols >= 24) {
        /* Legend total: "▪<avg " + "▪+20% " + "▪+50% " + "▪=loss" = 6+6+6+6 = 24 */
        int leg_col = right_col - 24;
        if (leg_col > col_offset) {
            move(row, leg_col);
            if (has_colors()) { attron(COLOR_PAIR(CP_GREEN)); }
            addstr("\xe2\x96\xaa");                         /* ▪ green */
            if (has_colors()) { attroff(COLOR_PAIR(CP_GREEN));
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            addstr("<avg ");
            if (has_colors()) { attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
                                attron(COLOR_PAIR(CP_YELLOW)); }
            addstr("\xe2\x96\xaa");                         /* ▪ yellow */
            if (has_colors()) { attroff(COLOR_PAIR(CP_YELLOW));
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            addstr("+20% ");
            if (has_colors()) { attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
                                attron(COLOR_PAIR(CP_MAGENTA)); }
            addstr("\xe2\x96\xaa");                         /* ▪ magenta */
            if (has_colors()) { attroff(COLOR_PAIR(CP_MAGENTA));
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            addstr("+50% ");
            if (has_colors()) { attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
                                attron(COLOR_PAIR(CP_RED) | A_BOLD); }
            addstr("\xe2\x96\xaa");                         /* ▪ red bold */
            if (has_colors()) { attroff(COLOR_PAIR(CP_RED) | A_BOLD);
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            addstr("=loss");
            if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
        }
    }
    row++;

    /* ------------------------------------------------------------------ */
    /* Time-axis scale row: horizontal dashed line with elapsed-time      */
    /* labels embedded.  Matches the vertical y-axis style of the chart:  */
    /* dim cyan dashes with labels planted at tick positions.             */
    /* Left = oldest, right = "now".  Rounded units (5s/30s/1m/5m/15m);   */
    /* at most 4 labels, minimum 2.                                        */
    /*                                                                      */
    /*  e.g.   ────── 3m ──── 2m ──── 1m ──── now                          */
    /* ------------------------------------------------------------------ */
    if (hist_cols > 0 && row <= row_end && row < ws->term_rows - 2) {
        int span_sec = hist_cols * (ws->interval > 0 ? ws->interval : 1);
        int step;
        int nt;
        int i;

        if      (span_sec <=   30) step =   5;
        else if (span_sec <=  150) step =  30;
        else if (span_sec <=  600) step =  60;
        else if (span_sec <= 3000) step = 300;
        else                       step = 900;

        nt = span_sec / step + 1;
        if (nt > 4) nt = 4;
        if (nt < 2) nt = 2;

        move(row, col_offset);
        clrtoeol();
        /* Use dim default-fg (grey) rather than dim cyan — on many terminals
         * dim cyan renders with a greenish tint, which the user flagged. */
        if (has_colors()) attron(COLOR_PAIR(CP_NORMAL) | A_DIM);
        /* Pad the fixed columns to keep sparkline area aligned */
        mvprintw(row, col_offset, "%*s",
                 fixed_w + (ws->whois_enabled ? whois_w : 0), "");
        /* Draw the dashed scale line across the sparkline area */
        move(row, right_col - hist_cols);
        for (i = 0; i < hist_cols; i++)
            addstr("\xe2\x94\x80");   /* ─ U+2500 */

        /* Helper: format an age (seconds) into a compact label */
        #define WATCH_FMT_AGE(age_, lbl_) do {                               \
            if ((age_) == 0)           snprintf((lbl_), sizeof(lbl_), "current"); \
            else if ((age_) >= 3600) {                                       \
                int _h = (age_) / 3600, _m = ((age_) % 3600) / 60;           \
                if (_m == 0) snprintf((lbl_), sizeof(lbl_), "%dh", _h);      \
                else         snprintf((lbl_), sizeof(lbl_), "%dh%dm", _h, _m); \
            } else if ((age_) >= 60)   snprintf((lbl_), sizeof(lbl_), "%dm", (age_)/60); \
            else                       snprintf((lbl_), sizeof(lbl_), "%ds", (age_)); \
        } while (0)

        /* Left-edge anchor label: the actual total span of the sparkline
         * (oldest visible sample, not rounded to step).  Planted at pos 0. */
        {
            char lbl[16];
            char padded[24];
            int llen;
            WATCH_FMT_AGE(span_sec, lbl);
            snprintf(padded, sizeof(padded), "%s ", lbl);  /* flush-left */
            llen = (int)strlen(padded);
            if (llen < hist_cols)
                mvaddstr(row, right_col - hist_cols, padded);
        }

        /* Overlay step-based intermediate labels + right-edge "current".
         * Left-most step label is suppressed if it would overlap the span
         * anchor we just planted. */
        for (i = 0; i < nt; i++) {
            int age = (nt - 1 - i) * step;
            int pos = hist_cols - 1 - (age / (ws->interval > 0 ? ws->interval : 1));
            char lbl[16];
            char padded[24];
            int llen;
            WATCH_FMT_AGE(age, lbl);
            /* Leading + trailing space separates label text from dashes */
            snprintf(padded, sizeof(padded), " %s ", lbl);
            llen = (int)strlen(padded);
            /* Right-align "current" at the rightmost edge */
            if (age == 0) pos = hist_cols - llen;
            else          pos = pos - (llen / 2);
            if (pos < 0) pos = 0;
            if (pos + llen > hist_cols) pos = hist_cols - llen;
            if (pos < 0) continue;
            /* Skip if this would overlap the left-edge span anchor (~10 cols) */
            if (age != 0 && pos < 10) continue;
            mvaddstr(row, right_col - hist_cols + pos, padded);
        }
        #undef WATCH_FMT_AGE
        if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
        row++;
    }

    /* ------------------------------------------------------------------ */
    /* Per-hop rows                                                        */
    /* ------------------------------------------------------------------ */
    for (i = 0; i < ws->n_hops && row <= row_end && row < ws->term_rows - 2; i++) {
        watch_hop_t *h = &ws->hops[i];
        double loss_pct = (h->cycles_total > 0)
            ? 100.0 * (h->cycles_total - h->cycles_seen) / h->cycles_total
            : 0.0;
        char host_trunc[72];   /* large enough for host_w (max 64) + state tag */
        char loss_s[8], min_s[8], avg_s[8], max_s[8], jitter_s[9];
        int col;

        /* For the target hop, append state annotation */
        if (i == ws->target_hopno && ws->target_hopno > 0) {
            const char *state = ws->target_open     ? "[open]"     :
                                ws->target_filtered ? "[filtered]" :
                                (ws->protocol == 2 || ws->protocol == 3) ? "[replied]" : "[closed]";
            int tag_len  = (int)strlen(state) + 1;   /* " [TAG]" */
            int name_max = host_w - tag_len;
            if (name_max < 5) name_max = 5;
            snprintf(host_trunc, sizeof(host_trunc), "%.*s %s",
                     name_max, hop_display_addr(h, ws->show_hostnames), state);
        } else {
            snprintf(host_trunc, sizeof(host_trunc), "%.*s",
                     host_w, hop_display_addr(h, ws->show_hostnames));
        }

        if (loss_pct >= 100.0)
            snprintf(loss_s, sizeof(loss_s), "100%%");
        else
            snprintf(loss_s, sizeof(loss_s), "%d%%", (int)round(loss_pct));
        snprintf(min_s,    sizeof(min_s),    "%.1f", h->rtt_min);
        snprintf(avg_s,    sizeof(avg_s),    "%d",   (int)round(h->rtt_avg));
        snprintf(max_s,    sizeof(max_s),    "%.1f", h->rtt_max);
        snprintf(jitter_s, sizeof(jitter_s), "%d",   (int)round(h->rtt_jitter));

        move(row, col_offset);
        clrtoeol();

        /* HOP number — cyan (same as ASN column values) */
        if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
        mvprintw(row, col_offset, " %3d  ", i + 1);
        if (has_colors()) attroff(COLOR_PAIR(CP_STATUS));

        /* HOST — loss-based color; bold when any loss present */
        {
            int hc = host_color(loss_pct);
            attr_t ha = (loss_pct > 0) ? (COLOR_PAIR(hc) | A_BOLD) : COLOR_PAIR(hc);
            if (has_colors()) attron(ha);
            printw("%-*s  ", host_w, host_trunc);
            if (has_colors()) attroff(ha);
        }

        /* LOSS% — 0%=green+bold, <10%=yellow+bold, <50%=red, ≥50%=red+bold */
        {
            attr_t la;
            if (loss_pct <= 0)       la = COLOR_PAIR(CP_GREEN)  | A_BOLD;
            else if (loss_pct < 10)  la = COLOR_PAIR(CP_YELLOW) | A_BOLD;
            else if (loss_pct < 50)  la = COLOR_PAIR(CP_RED);
            else                     la = COLOR_PAIR(CP_RED)    | A_BOLD;
            if (has_colors()) attron(la);
            printw("%-5s  ", loss_s);
            if (has_colors()) attroff(la);
        }

        /* MIN — dim */
        if (has_colors()) attron(COLOR_PAIR(CP_NORMAL) | A_DIM);
        printw("%-6s  ", min_s);
        if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);

        /* AVG — finer thresholds */
        {
            int ac = avg_color(h->rtt_avg);
            if (has_colors()) attron(COLOR_PAIR(ac));
            printw("%-6s  ", avg_s);
            if (has_colors()) attroff(COLOR_PAIR(ac));
        }

        /* MAX — dim */
        if (has_colors()) attron(COLOR_PAIR(CP_NORMAL) | A_DIM);
        printw("%-6s  ", max_s);
        if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);

        /* JITTER */
        {
            int jc = jitter_color(h->rtt_jitter);
            if (has_colors()) attron(COLOR_PAIR(jc));
            printw("%-4s  ", jitter_s);
            if (has_colors()) attroff(COLOR_PAIR(jc));
        }

        /* ASN — cyan; NETNAME — dim grey; ORGNAME — cyan (NETNAME/ORGNAME swapped) */
        if (ws->whois_enabled && show_orgname) {
            if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
            printw("%-8.8s  ", h->asn[0] ? h->asn : "-");
            if (has_colors()) { attroff(COLOR_PAIR(CP_STATUS));
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            printw("%-16.16s  ", h->netname[0] ? h->netname : "-");
            if (has_colors()) { attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
                                attron(COLOR_PAIR(CP_STATUS)); }
            printw("%-20.20s  ", h->orgname[0] ? h->orgname : "-");
            if (has_colors()) attroff(COLOR_PAIR(CP_STATUS));
        } else if (ws->whois_enabled) {
            if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
            printw("%-8.8s  ", h->asn[0] ? h->asn : "-");
            if (has_colors()) { attroff(COLOR_PAIR(CP_STATUS));
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            printw("%-16.16s  ", h->netname[0] ? h->netname : "-");
            if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
        }

        /* RTT history sparkline (newest at right, stops before right border).
         * Colors are relative to this hop's all-time average RTT:
         *   ≤ avg×1.2  → green
         *   ≤ avg×1.5  → yellow
         *   > avg×1.5  → magenta (hot/high jitter)
         *   no reply   → solid ▪ in red+bold (loss marker)
         * Block height scales 0→g_max across N_BLOCK_CHARS levels. */
        if (hist_cols > 0) {
            int j;
            int n = h->history_count;
            double g_max = 0;
            double avg   = (h->rtt_avg > 0) ? h->rtt_avg : 1.0;
            for (j = 0; j < n; j++) {
                int idx = (h->history_head - n + j + WATCH_HISTORY_LEN) % WATCH_HISTORY_LEN;
                if (h->rtt_history[idx] > g_max) g_max = h->rtt_history[idx];
            }
            if (g_max <= 0) g_max = 1;

            col = right_col - hist_cols;
            move(row, col);
            for (j = 0; j < hist_cols; j++) {
                int age = hist_cols - 1 - j;
                int src_idx;
                double v;
                int block;
                int sc;
                if (age >= n) { addstr(" "); continue; }
                src_idx = (h->history_head - 1 - age + WATCH_HISTORY_LEN) % WATCH_HISTORY_LEN;
                v = h->rtt_history[src_idx];
                if (v <= 0) {
                    /* Loss: solid red block marker ▪ */
                    if (has_colors()) attron(COLOR_PAIR(CP_RED) | A_BOLD);
                    addstr("\xe2\x96\xaa");  /* ▪ U+25AA */
                    if (has_colors()) attroff(COLOR_PAIR(CP_RED) | A_BOLD);
                } else {
                    block = (int)(v / g_max * (N_BLOCK_CHARS - 2)) + 1;
                    if (block >= N_BLOCK_CHARS) block = N_BLOCK_CHARS - 1;
                    /* Color relative to this hop's average */
                    if      (v <= avg * 1.2) sc = CP_GREEN;
                    else if (v <= avg * 1.5) sc = CP_YELLOW;
                    else                     sc = CP_MAGENTA;
                    if (has_colors()) attron(COLOR_PAIR(sc));
                    addstr(BLOCK_CHARS[block]);
                    if (has_colors()) attroff(COLOR_PAIR(sc));
                }
            }
        }

        row++;

        /* Multipath alternate IPs — one sub-row per extra path, matching the
         * indented style LFT uses in standard (-o) output.  ↳ sits inside the
         * hop-number column; the IP left-aligns at the HOST column start.
         * All three table views (1, 2, 3) share this via watch_render_timeline. */
        if (h->n_alt > 0) {
            int k;
            for (k = 0; k < h->n_alt && row <= row_end && row < ws->term_rows - 2; k++) {
                move(row, col_offset);
                clrtoeol();
                if (has_colors()) attron(COLOR_PAIR(CP_STATUS) | A_DIM);
                /* col_offset+4 → ↳ (1 col) + space (1 col) → IP at col_offset+6 = HOST */
                mvprintw(row, col_offset + 4, "\xe2\x86\xb3 %-*s",
                         host_w, h->alt_hosts[k]);
                if (has_colors()) attroff(COLOR_PAIR(CP_STATUS) | A_DIM);
                row++;
            }
        }
    }

    /* Path change notice (horizontally centered) */
    if (ws->path_changed &&
        difftime(time(NULL), ws->path_change_time) < WATCH_PATH_FLASH_SECS) {
        if (row <= row_end && row < ws->term_rows - 2) {
            char ts[32];
            char banner[96];
            int bw;
            int bx;
            struct tm *tm_info;
            time_t t = ws->path_change_time;
            tm_info = localtime(&t);
            strftime(ts, sizeof(ts), "%H:%M:%S", tm_info);
            snprintf(banner, sizeof(banner),
                     " *** PATH CHANGE DETECTED at %s *** ", ts);
            bw = (int)strlen(banner);
            bx = (ws->term_cols - bw) / 2;
            if (bx < col_offset) bx = col_offset;
            move(row, col_offset);
            clrtoeol();
            if (has_colors()) attron(COLOR_PAIR(CP_RED) | A_BOLD | A_REVERSE);
            mvaddstr(row, bx, banner);
            if (has_colors()) attroff(COLOR_PAIR(CP_RED) | A_BOLD | A_REVERSE);
            row++;
        }
    }

    return row - start_row;
}

/* =========================================================================
 * View 3: MTR-style stats board (was view 4; old full-screen timeline and
 * topology views removed when collapsing 4 views → 3).
 * ========================================================================= */

static void watch_render_view4(watch_session_t *ws)
{
    int i, row = 1;
    int r;
    int bot_row = ws->term_rows - 3;  /* last content row; status bar closes below */

    clear();
    watch_render_top_border(ws, NULL);

    /* Header row — leading space becomes the side │ after border draw.
     * All variable-width columns use %-N.Ns (both pad AND truncate) so
     * toggling 'h' (hostname ↔ IP) never shifts subsequent columns. */
    if (has_colors()) attron(COLOR_PAIR(CP_TBLHEAD));
    if (ws->whois_enabled)
        mvprintw(row++, 0, " %-3s %-20.20s %5s %5s %5s %7s %7s %7s %7s %7s %-8.8s %-15.15s %-19.19s",
                 "HOP", "HOST", "LOSS%", "SENT", "RECV",
                 "MIN", "AVG", "MAX", "STDDEV", "LAST",
                 "ASN", "NETNAME", "ORGNAME");
    else
        mvprintw(row++, 0, " %-3s %-20.20s %5s %5s %5s %7s %7s %7s %7s %7s",
                 "HOP", "HOST", "LOSS%", "SENT", "RECV",
                 "MIN", "AVG", "MAX", "STDDEV", "LAST");
    if (has_colors()) attroff(COLOR_PAIR(CP_TBLHEAD));

    /* Per-hop rows — per-column colors match watch_render_timeline exactly
     * so views 1, 2, 3 all use the same palette where columns overlap.
     * View 3 adds SENT/RECV/STDDEV/LAST; those use AVG-style thresholds. */
    for (i = 0; i < ws->n_hops && row < ws->term_rows - 2; i++) {
        watch_hop_t *h = &ws->hops[i];
        double loss_pct = (h->cycles_total > 0)
            ? 100.0 * (h->cycles_total - h->cycles_seen) / h->cycles_total
            : 0.0;
        char host_trunc[72];
        char loss_s[8];

        /* Approximate stddev from history */
        double stddev = 0;
        if (h->history_count > 1) {
            double sum2 = 0;
            int j;
            int n = h->history_count;
            for (j = 0; j < n; j++) {
                int idx = (h->history_head - n + j + WATCH_HISTORY_LEN) % WATCH_HISTORY_LEN;
                double v = h->rtt_history[idx];
                double d = v - h->rtt_avg;
                sum2 += d * d;
            }
            stddev = sqrt(sum2 / n);
        }

        snprintf(host_trunc, sizeof(host_trunc), "%.20s",
                 hop_display_addr(h, ws->show_hostnames));
        if (loss_pct >= 100.0) snprintf(loss_s, sizeof(loss_s), "100%%");
        else                   snprintf(loss_s, sizeof(loss_s), "%d%%", (int)round(loss_pct));

        move(row, 0);
        clrtoeol();

        /* HOP — cyan (same as ASN values, matching timeline) */
        if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
        mvprintw(row, 0, " %3d ", i + 1);
        if (has_colors()) attroff(COLOR_PAIR(CP_STATUS));

        /* HOST — loss-based color, bold if any loss */
        {
            int hc = host_color(loss_pct);
            attr_t ha = (loss_pct > 0) ? (COLOR_PAIR(hc) | A_BOLD) : COLOR_PAIR(hc);
            if (has_colors()) attron(ha);
            printw("%-20.20s ", host_trunc);
            if (has_colors()) attroff(ha);
        }

        /* LOSS% — same thresholds as timeline */
        {
            attr_t la;
            if (loss_pct <= 0)       la = COLOR_PAIR(CP_GREEN)  | A_BOLD;
            else if (loss_pct < 10)  la = COLOR_PAIR(CP_YELLOW) | A_BOLD;
            else if (loss_pct < 50)  la = COLOR_PAIR(CP_RED);
            else                     la = COLOR_PAIR(CP_RED)    | A_BOLD;
            if (has_colors()) attron(la);
            printw("%5s ", loss_s);
            if (has_colors()) attroff(la);
        }

        /* SENT / RECV — dim (counters, not thresholds) */
        if (has_colors()) attron(COLOR_PAIR(CP_NORMAL) | A_DIM);
        printw("%5d %5d ", h->cycles_total, h->cycles_seen);
        if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);

        /* MIN — dim */
        if (has_colors()) attron(COLOR_PAIR(CP_NORMAL) | A_DIM);
        printw("%7.1f ", h->rtt_min);
        if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);

        /* AVG — threshold color */
        {
            int ac = avg_color(h->rtt_avg);
            if (has_colors()) attron(COLOR_PAIR(ac));
            printw("%7.1f ", h->rtt_avg);
            if (has_colors()) attroff(COLOR_PAIR(ac));
        }

        /* MAX — dim */
        if (has_colors()) attron(COLOR_PAIR(CP_NORMAL) | A_DIM);
        printw("%7.1f ", h->rtt_max);
        if (has_colors()) attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);

        /* STDDEV — jitter thresholds */
        {
            int jc = jitter_color(stddev);
            if (has_colors()) attron(COLOR_PAIR(jc));
            printw("%7.1f ", stddev);
            if (has_colors()) attroff(COLOR_PAIR(jc));
        }

        /* LAST — avg-style thresholds */
        {
            int lc = avg_color(h->rtt_last);
            if (has_colors()) attron(COLOR_PAIR(lc));
            printw("%7.1f ", h->rtt_last);
            if (has_colors()) attroff(COLOR_PAIR(lc));
        }

        /* ASN — cyan; NETNAME — dim grey; ORGNAME — cyan (swapped) */
        if (ws->whois_enabled) {
            if (has_colors()) attron(COLOR_PAIR(CP_STATUS));
            printw("%-8.8s ", h->asn[0] ? h->asn : "-");
            if (has_colors()) { attroff(COLOR_PAIR(CP_STATUS));
                                attron(COLOR_PAIR(CP_NORMAL) | A_DIM); }
            printw("%-15.15s ", h->netname[0] ? h->netname : "-");
            if (has_colors()) { attroff(COLOR_PAIR(CP_NORMAL) | A_DIM);
                                attron(COLOR_PAIR(CP_STATUS)); }
            printw("%-19.19s", h->orgname[0] ? h->orgname : "-");
            if (has_colors()) attroff(COLOR_PAIR(CP_STATUS));
        }
        row++;
    }

    /* Side │ borders — solid ACS_VLINE from row 1 to bot_row. Status bar row
     * below has └──┘ corners that close the frame, matching views 1 and 2. */
    if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
    if (bot_row >= 1) {
        mvvline(1, 0,                   ACS_VLINE, bot_row);
        mvvline(1, ws->term_cols - 1,   ACS_VLINE, bot_row);
    }
    if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));
    (void)r;
}

/* =========================================================================
 * Log drawer
 * ========================================================================= */

static void watch_render_log_drawer(watch_session_t *ws)
{
    int drawer_height = ws->term_rows / 4;
    int start_row     = ws->term_rows - 2 - drawer_height;
    int visible_rows  = drawer_height - 1;   /* content rows below the title */
    /* Content width: inside left │ + 1-space indent, up to right │ exclusive */
    int content_w     = ws->term_cols - 3;   /* col 0=│, col 1=space, col(last)=│ */
    int i;

    if (start_row < 0) start_row = 0;
    if (visible_rows < 1) visible_rows = 1;
    if (content_w < 1) content_w = 1;

    /* ------------------------------------------------------------------ */
    /* Auto-resume: return to live mode after 30 s of inactivity           */
    /* ------------------------------------------------------------------ */
    if (ws->log_scroll_offset > 0 &&
        difftime(time(NULL), ws->log_last_scroll) >= 30.0) {
        ws->log_scroll_offset   = 0;
        ws->log_anchor_nlines   = 0;
    }

    /* ------------------------------------------------------------------ */
    /* Linearize the ring buffer into a temporary flat array               */
    /* ------------------------------------------------------------------ */
    char  *tmp       = NULL;
    char **lines_arr = NULL;
    int    nlines    = 0;

    tmp = (char *)malloc((size_t)ws->log_len + 1);
    {
        int max_ptrs = ws->log_len / 2 + 2;
        if (max_ptrs > 16384) max_ptrs = 16384;
        lines_arr = (char **)malloc((size_t)max_ptrs * sizeof(char *));
    }

    if (tmp && lines_arr) {
        int max_ptrs = ws->log_len / 2 + 2;
        if (max_ptrs > 16384) max_ptrs = 16384;

        for (i = 0; i < ws->log_len; i++)
            tmp[i] = ws->log_buf[(ws->log_head + i) % WATCH_LOG_BUF];
        tmp[ws->log_len] = '\0';

        char *p = tmp;
        while (*p && nlines < max_ptrs) {
            char *eol = strchr(p, '\n');
            if (eol) *eol = '\0';
            lines_arr[nlines++] = p;
            if (eol) p = eol + 1;
            else     break;
        }
    }

    /* ------------------------------------------------------------------ */
    /* Set the frozen anchor on the first frame where scroll_offset > 0   */
    /* ------------------------------------------------------------------ */
    if (ws->log_scroll_offset > 0 && ws->log_anchor_nlines == 0)
        ws->log_anchor_nlines = nlines;

    /* Clamp scroll offset to the usable range for this anchor */
    if (ws->log_scroll_offset > 0) {
        int max_scroll = (ws->log_anchor_nlines > visible_rows)
                         ? ws->log_anchor_nlines - visible_rows : 0;
        if (ws->log_scroll_offset > max_scroll)
            ws->log_scroll_offset = max_scroll;
    }

    /* ------------------------------------------------------------------ */
    /* Decide which lines to show                                          */
    /* ------------------------------------------------------------------ */
    int first_line;
    if (ws->log_scroll_offset == 0) {
        /* Live mode: always tail the buffer */
        first_line = (nlines > visible_rows) ? nlines - visible_rows : 0;
    } else {
        /* Frozen mode: anchor_nlines is the fixed reference */
        first_line = ws->log_anchor_nlines - visible_rows - ws->log_scroll_offset;
        if (first_line < 0) first_line = 0;
    }

    /* ------------------------------------------------------------------ */
    /* Title row label — two fixed-width variants, both 44 display cols:  */
    /*                                                                    */
    /*  Live:   "Trace Log Live · ↑k ↓j to scroll · [v] close"           */
    /*  Scroll: "Trace Log · Line NNNNN of NNNNN · [v] close"             */
    /*                                                                    */
    /* %5d gives right-justified 5-digit numbers (max 16384 lines), so   */
    /* the total label width never changes between states or line counts. */
    /* Unicode:  · = U+00B7  ↑ = U+2191  ↓ = U+2193  ─ = U+2500        */
    /* ------------------------------------------------------------------ */
    {
        char label[256];
        if (ws->log_scroll_offset == 0) {
            /* Live mode — 44 display cols */
            snprintf(label, sizeof(label),
                "Trace Log Live "
                "\xc2\xb7"                   /* · */
                " \xe2\x86\x91k"             /* ↑k */
                " \xe2\x86\x93j"             /* ↓j */
                " to scroll "
                "\xc2\xb7"                   /* · */
                " [v] close");
        } else {
            /* Scrolling mode — 44 display cols */
            int bottom = ws->log_anchor_nlines - ws->log_scroll_offset;
            if (bottom < 0) bottom = 0;
            snprintf(label, sizeof(label),
                "Trace Log "
                "\xc2\xb7"                   /* · */
                "  Line %5d of %5d "
                "\xc2\xb7"                   /* · */
                " [v] close",
                bottom, ws->log_anchor_nlines);
        }

        /* Count display columns (non-continuation UTF-8 bytes = display chars) */
        int lw = 0;
        {
            const char *cp;
            for (cp = label; *cp; cp++)
                if (((unsigned char)*cp & 0xC0) != 0x80) lw++;
        }
        /* Total border content: ├ + ─── + space + label + fill ─s + ┤ */
        /* ─── + space = 4 display cols; ├ and ┤ each = 1; fill fills the rest */
        int fill = ws->term_cols - 2 - 4 - lw;
        if (fill < 0) fill = 0;

        move(start_row, 0);
        clrtoeol();
        if (has_colors()) attron(COLOR_PAIR(CP_STATUS) | A_DIM);
        addstr("\xe2\x94\x9c"                              /* ├ */
               "\xe2\x94\x80\xe2\x94\x80\xe2\x94\x80 ");  /* ─── (space) */
        if (has_colors()) { attroff(COLOR_PAIR(CP_STATUS) | A_DIM);
                            attron(COLOR_PAIR(CP_STATUS) | A_BOLD); }
        addstr(label);
        if (has_colors()) { attroff(COLOR_PAIR(CP_STATUS) | A_BOLD);
                            attron(COLOR_PAIR(CP_STATUS) | A_DIM); }
        for (i = 0; i < fill; i++) addstr("\xe2\x94\x80");  /* ─ */
        addstr("\xe2\x94\xa4");                              /* ┤ */
        if (has_colors()) attroff(COLOR_PAIR(CP_STATUS) | A_DIM);
    }

    /* ------------------------------------------------------------------ */
    /* Content rows                                                        */
    /* ------------------------------------------------------------------ */
    {
        int row;
        for (row = 0; row < visible_rows; row++) {
            int screen_row = start_row + 1 + row;
            int line_idx   = first_line + row;

            move(screen_row, 0);
            clrtoeol();

            /* Left border │ */
            if (has_colors()) attron(COLOR_PAIR(CP_STATUS) | A_DIM);
            addstr("\xe2\x94\x82");           /* │ */
            if (has_colors()) attroff(COLOR_PAIR(CP_STATUS) | A_DIM);

            /* 1-space indent then log content, hard-truncated */
            addstr(" ");
            if (tmp && lines_arr && line_idx >= 0 && line_idx < nlines
                    && lines_arr[line_idx][0]) {
                if (has_colors()) attron(COLOR_PAIR(CP_DRAWER));
                mvaddnstr(screen_row, 2, lines_arr[line_idx], content_w);
                if (has_colors()) attroff(COLOR_PAIR(CP_DRAWER));
            }

            /* Right border │ */
            if (has_colors()) attron(COLOR_PAIR(CP_STATUS) | A_DIM);
            mvaddstr(screen_row, ws->term_cols - 1, "\xe2\x94\x82");  /* │ */
            if (has_colors()) attroff(COLOR_PAIR(CP_STATUS) | A_DIM);
        }
    }

    free(tmp);
    free(lines_arr);
}

/* =========================================================================
 * View 1: A+B Hybrid
 * ========================================================================= */

static void watch_render_view2(watch_session_t *ws, lft_session_params *proto_sess)
{
    int chart_rows, table_rows, chart_max;
    int r, i;
    int bot_row = ws->term_rows - 3;  /* last content row; status bar closes below */
    int sep;
    char rtt_info[256];

    {
        int _r;
        for (_r = 0; _r < ws->term_rows - 2; _r++) { move(_r, 0); clrtoeol(); }
    }

    watch_build_rtt_info(ws, rtt_info, (int)sizeof(rtt_info));
    watch_render_top_border(ws, rtt_info);

    /* How many rows for the chart vs table */
    chart_max  = (ws->term_rows - 5) / 2;    /* at most half the usable rows */
    if (chart_max < 5) chart_max = 5;

    chart_rows = watch_render_native_chart(ws, 1, chart_max, 1);
    if (chart_rows < 0) chart_rows = 0;
    (void)chart_rows;   /* unused — sep is locked to chart_max for stability */

    /* Divider: ├──...──┤ (T-junctions with side │ borders).  Fixed at
     * (1 + chart_max) — see view 1 for rationale. */
    sep = 1 + chart_max;
    if (sep < bot_row) {
        if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
        move(sep, 0);
        addstr("\xe2\x94\x9c");                             /* ├ */
        for (i = 1; i < ws->term_cols - 1; i++) addstr("\xe2\x94\x80"); /* ─ */
        addstr("\xe2\x94\xa4");                             /* ┤ */
        if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));
    }

    /* Timeline table in remaining space (col_offset=1 leaves room for sides) */
    table_rows = bot_row - sep;
    if (table_rows > 1)
        watch_render_timeline(ws, sep + 1, table_rows, 1);

    /* Side │ borders — two segments around the sep row (sep already has ├ ┤) */
    if (has_colors()) attron(COLOR_PAIR(CP_BLUE));
    if (sep > 1)
        mvvline(1, 0, ACS_VLINE, sep - 1);
    if (bot_row > sep)
        mvvline(sep + 1, 0, ACS_VLINE, bot_row - sep);
    if (sep > 1)
        mvvline(1, ws->term_cols - 1, ACS_VLINE, sep - 1);
    if (bot_row > sep)
        mvvline(sep + 1, ws->term_cols - 1, ACS_VLINE, bot_row - sep);
    if (has_colors()) attroff(COLOR_PAIR(CP_BLUE));
    (void)r;
}

/* =========================================================================
 * Top-level render dispatcher
 * ========================================================================= */

static void watch_render(watch_session_t *ws, lft_session_params *proto_sess,
                          int spinner_frame)
{
    /* Handle terminal resize */
    if (watch_resize_pending) {
        endwin();
        refresh();
        watch_resize_pending = 0;
        ws->term_rows = LINES;
        ws->term_cols = COLS;
        if (LINES < WATCH_MIN_ROWS) {
            endwin();
            fprintf(stderr,
                    "\nLFT: The minimum height required for --watch is %d rows,"
                    " expand your terminal window if possible.\n\n",
                    WATCH_MIN_ROWS);
            ws->running = 0;
            return;
        }
    }

    switch (ws->current_view) {
        case 2: watch_render_view2(ws, proto_sess); break;
        case 3: watch_render_view4(ws); break;   /* Stats Board (was view 4) */
        default:
            watch_render_view1(ws, proto_sess);
            break;
    }

    if (ws->log_open)
        watch_render_log_drawer(ws);

    watch_render_status_bar(ws, spinner_frame);

    /* ncurses model stays in sync (SIGALRM uses ncurses, not raw writes),
     * so no wredrawln needed — just compose and flush. */
    wnoutrefresh(stdscr);
    doupdate();
}

/* =========================================================================
 * Keypress handler
 * ========================================================================= */

/* =========================================================================
 * Screen capture (F3) — saves the current ncurses screen to .txt / .ansi
 * ========================================================================= */

static void watch_do_screen_capture(watch_session_t *ws)
{
    /* ---- Modal dialog ---- */
    int mh = 11, mw = 58;
    int my = (ws->term_rows - mh) / 2;
    int mx = (ws->term_cols - mw) / 2;
    WINDOW *modal;
    char fname[128];
    int pos = 0;   /* cursor in fname[] */
    time_t now;
    struct tm *tm_now;
    int ch;

    if (my < 0) my = 0;
    if (mx < 0) mx = 0;

    modal = newwin(mh, mw, my, mx);
    if (!modal) return;

    /* Pre-fill with timestamp */
    now = time(NULL);
    tm_now = localtime(&now);
    strftime(fname, sizeof(fname), "lft_watch_%Y%m%d_%H%M%S", tm_now);
    pos = (int)strlen(fname);

    keypad(modal, TRUE);

    for (;;) {
        /* Draw modal */
        werase(modal);
        if (has_colors()) wattron(modal, COLOR_PAIR(CP_STATUS) | A_BOLD);
        box(modal, 0, 0);
        mvwprintw(modal, 0, 2, " Save Watch Screen ");
        if (has_colors()) wattroff(modal, COLOR_PAIR(CP_STATUS) | A_BOLD);

        mvwprintw(modal, 2, 2, "Filename (without extension):");
        if (has_colors()) wattron(modal, A_BOLD);
        mvwprintw(modal, 3, 2, "> ");
        if (has_colors()) wattroff(modal, A_BOLD);

        /* Input field: mw-6 chars wide */
        {
            int field_w = mw - 6;
            char disp[128];
            snprintf(disp, sizeof(disp), "%-*.*s", field_w, field_w, fname);
            mvwaddnstr(modal, 3, 4, disp, field_w);
        }

        mvwprintw(modal, 5, 2, "Saves: %s.txt", fname);
        if (has_colors() && has_colors())
            mvwprintw(modal, 6, 2, "       %s.ansi (color)", fname);

        if (has_colors()) wattron(modal, COLOR_PAIR(CP_STATUS));
        mvwprintw(modal, 9, 2, "  [Enter] Save      [Esc] Cancel  ");
        if (has_colors()) wattroff(modal, COLOR_PAIR(CP_STATUS));

        /* Position cursor in the input field */
        wmove(modal, 3, 4 + pos);
        wrefresh(modal);

        ch = wgetch(modal);

        if (ch == 27 /* Esc */) {
            delwin(modal);
            return;   /* cancelled */
        }
        if (ch == '\n' || ch == '\r' || ch == KEY_ENTER) {
            break;    /* save */
        }
        if ((ch == KEY_BACKSPACE || ch == 127 || ch == '\b') && pos > 0) {
            fname[--pos] = '\0';
        } else if (ch >= 32 && ch < 127 && pos < (int)sizeof(fname) - 2) {
            /* Accept printable ASCII except path separators */
            if (ch != '/' && ch != '\\') {
                fname[pos++] = (char)ch;
                fname[pos]   = '\0';
            }
        }
    }

    delwin(modal);

    if (fname[0] == '\0') return;   /* nothing typed */

    /* ---- Save .txt (plain text, strip attributes) ---- */
    {
        char txt_path[256];
        FILE *fp;
        int r, c;

        snprintf(txt_path, sizeof(txt_path), "%s.txt", fname);
        fp = fopen(txt_path, "w");
        if (fp) {
            for (r = 0; r < ws->term_rows; r++) {
                for (c = 0; c < ws->term_cols; c++) {
                    chtype cell = mvinch(r, c);
                    char ch_out = (char)(cell & A_CHARTEXT);
                    if (ch_out == '\0') ch_out = ' ';
                    fputc(ch_out, fp);
                }
                fputc('\n', fp);
            }
            fclose(fp);
            snprintf(ws->status_msg, sizeof(ws->status_msg),
                     "Saved: %s.txt", fname);
            ws->status_msg_time = time(NULL);
        } else {
            snprintf(ws->status_msg, sizeof(ws->status_msg),
                     "Error: could not create %s.txt", fname);
            ws->status_msg_time = time(NULL);
        }
    }

    /* ---- Save .ansi (with color escape codes if supported) ---- */
    if (has_colors()) {
        char ansi_path[256];
        FILE *fp;
        int r, c;
        short fg_prev = -1, bg_prev = -1;

        snprintf(ansi_path, sizeof(ansi_path), "%s.ansi", fname);
        fp = fopen(ansi_path, "w");
        if (fp) {
            for (r = 0; r < ws->term_rows; r++) {
                for (c = 0; c < ws->term_cols; c++) {
                    chtype cell = mvinch(r, c);
                    chtype attr = cell & ~A_CHARTEXT;
                    char   ch_out = (char)(cell & A_CHARTEXT);
                    short  fg = -1, bg = -1;
                    int    pair_num = (int)PAIR_NUMBER(attr);

                    if (pair_num > 0)
                        pair_content((short)pair_num, &fg, &bg);

                    /* Emit color codes only on change */
                    if (fg != fg_prev || bg != bg_prev) {
                        fprintf(fp, "\033[0m");
                        if (fg >= 0 && fg <= 7)
                            fprintf(fp, "\033[3%dm", fg);
                        if (bg >= 0 && bg <= 7)
                            fprintf(fp, "\033[4%dm", bg);
                        if (attr & A_BOLD)
                            fprintf(fp, "\033[1m");
                        fg_prev = fg;
                        bg_prev = bg;
                    }
                    if (ch_out == '\0') ch_out = ' ';
                    fputc(ch_out, fp);
                }
                fprintf(fp, "\033[0m\n");
                fg_prev = bg_prev = -1;
            }
            fclose(fp);
            /* Append ".ansi" note to status message */
            {
                char tmp[192];
                snprintf(tmp, sizeof(tmp), "%s + .ansi", ws->status_msg);
                snprintf(ws->status_msg, sizeof(ws->status_msg), "%s", tmp);
            }
        }
    }

    touchwin(stdscr);
    refresh();
}

/* =========================================================================
 * Key dispatcher
 * ========================================================================= */

static void watch_handle_key(watch_session_t *ws, int ch)
{
    if (ch == ERR) return; /* no key pressed */

    switch (ch) {
        case '1': ws->current_view = 1; break;
        case '2': ws->current_view = 2; break;
        case '3': ws->current_view = 3; break;   /* Stats Board */

        case 'q':
        case 'Q':
            ws->running = 0;
            break;

        case 'p':
        case 'P':
            if (ws->paused) {
                /* Resuming: break idle loop so trace starts immediately */
                ws->paused = 0;
                ws->interrupt_sleep = 1;
            } else {
                ws->paused = 1;   /* Pausing: idle loop drains naturally */
            }
            break;

        case 'r':
        case 'R': {
            /* Reset per-hop stats and cumulative counters; keep path and cycle */
            int i;
            for (i = 0; i < WATCH_MAX_HOPS; i++) {
                watch_hop_t *h = &ws->hops[i];
                h->cycles_seen = 0;
                h->cycles_total = 0;
                h->rtt_min = h->rtt_avg = h->rtt_max = 0;
                h->rtt_jitter = h->rtt_last = h->rtt_sum = 0;
                h->history_head = h->history_count = 0;
            }
            ws->total_replies       = 0;
            ws->total_sent          = 0;
            ws->max_hops_seen       = 0;
            ws->target_ever_reached = 0;
            ws->interrupt_sleep     = 1;   /* start next trace immediately */
            break;
        }

        case 'c':
        case 'C': {
            /* Full clear: reset stats, hops, cycle, AND all annotation caches
             * so pWhoIs + DNS are re-fetched from scratch next cycle. */
            int i;
            watch_ip_record_t *rec;
            for (i = 0; i < WATCH_MAX_HOPS; i++) {
                watch_hop_t *h = &ws->hops[i];
                h->cycles_seen = 0;
                h->cycles_total = 0;
                h->rtt_min = h->rtt_avg = h->rtt_max = 0;
                h->rtt_jitter = h->rtt_last = h->rtt_sum = 0;
                h->history_head = h->history_count = 0;
                h->asn[0] = h->netname[0] = h->orgname[0] = '\0';
                h->hostname[0] = '\0';
            }
            ws->total_replies       = 0;
            ws->total_sent          = 0;
            ws->max_hops_seen       = 0;
            ws->target_ever_reached = 0;
            ws->n_hops = 0;
            ws->cycle  = 0;

            /* Flush whois + rDNS from the per-IP cache so everything is
             * re-queried.  Hostname fields cleared unconditionally (c-ares
             * re-submits anyway; watch ip_cache injection will repopulate). */
            for (i = 0; i < WATCH_IP_CACHE_BUCKETS; i++) {
                for (rec = ws->ip_cache.buckets[i]; rec; rec = rec->next) {
                    rec->asn[0]        = '\0';
                    rec->netname[0]    = '\0';
                    rec->orgname[0]    = '\0';
                    rec->hostname[0]   = '\0';
                    rec->whois_queried = 0;
                    rec->rdns_queried  = 0;
                }
            }
            ws->whois_bulk_done = 0;   /* re-trigger bulk pWhoIs next cycle */

            clear();
            wnoutrefresh(stdscr);
            doupdate();
            ws->interrupt_sleep = 1;   /* start next trace immediately */
            break;
        }

        case 'v':
        case 'V':
            ws->log_open = !ws->log_open;
            if (ws->log_open) {
                /* Always start at the live tail when opening the drawer */
                ws->log_scroll_offset = 0;
                ws->log_anchor_nlines = 0;
                ws->log_last_scroll   = 0;
            }
            break;

        /* Log drawer scroll — only active when the drawer is open */
        case KEY_UP:
        case 'k':
            if (ws->log_open) {
                ws->log_scroll_offset++;
                ws->log_last_scroll = time(NULL);
            }
            break;

        case KEY_DOWN:
        case 'j':
            if (ws->log_open) {
                if (ws->log_scroll_offset > 0) {
                    ws->log_scroll_offset--;
                    ws->log_last_scroll = time(NULL);
                }
                if (ws->log_scroll_offset == 0)
                    ws->log_anchor_nlines = 0;  /* back to live; clear anchor */
            }
            break;

        case 'w':
        case 'W':
            ws->whois_enabled = !ws->whois_enabled;
            /* If turning back on and somehow the bulk query never ran, do it now */
            if (ws->whois_enabled && !ws->whois_bulk_done)
                watch_whois_bulk_query(ws);
            break;

        case 'h':
        case 'H':
            ws->show_hostnames = !ws->show_hostnames;
            break;

        case 'x':
        case 'X':
            watch_do_screen_capture(ws);
            break;

        case '+':
        case '=':
            if (ws->interval < 300) ws->interval++;
            break;

        case '-':
        case '_':
            if (ws->interval > 1) ws->interval--;
            break;

        default:
            break;
    }
}

/* =========================================================================
 * Main entry point
 * ========================================================================= */

void WatchModeRun(lft_session_params *sess)
{
    watch_session_t *ws;
    struct sigaction sa_winch, sa_old_winch;
    int orig_noisy;   /* noisy level as passed on the CLI (before watch bumps it) */

    /* Save verbosity level, then force noisy=2 for watch mode so the log
     * drawer ring buffer is always populated at -VV level regardless of
     * whether the user passed -V.  The drawer auto-opens only if the user
     * explicitly asked for verbose output. */
    orig_noisy   = sess->noisy;
    if (sess->noisy < 2)
        sess->noisy = 2;

    /* ------------------------------------------------------------------ */
    /* Validate terminal height before entering ncurses                    */
    /* ------------------------------------------------------------------ */
    {
        struct winsize tw;
        int rows = 0;
        const char *rows_env = getenv("LINES");
        if (rows_env && atoi(rows_env) > 0)
            rows = atoi(rows_env);
        else if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &tw) == 0)
            rows = tw.ws_row;
        if (rows > 0 && rows < WATCH_MIN_ROWS) {
            fprintf(stderr,
                    "\nLFT: The minimum height required for --watch is %d rows,"
                    " expand your terminal window if possible.\n\n",
                    WATCH_MIN_ROWS);
            return;
        }
    }

    /* ------------------------------------------------------------------ */
    /* Allocate watch session state                                        */
    /* ------------------------------------------------------------------ */
    ws = (watch_session_t *)calloc(1, sizeof(watch_session_t));
    if (!ws) {
        fprintf(stderr, "LFT: out of memory in WatchModeRun\n");
        return;
    }
    g_watch_ws = ws;   /* expose to SIGALRM handler for cumulative totals */

    ws->interval     = sess->watch_interval > 0 ? sess->watch_interval : WATCH_DEFAULT_INTERVAL;
    ws->max_cycles   = sess->watch_max_cycles;
    ws->probes_per_hop = (sess->rtt_stats > 0) ? sess->rtt_stats : WATCH_DEFAULT_PROBES;
    ws->protocol     = sess->protocol;   /* 0=TCP,1=UDP,2=ICMP,3=RFC1393 — for [REPLIED] label */
    ws->current_view  = 1;
    ws->running       = 1;
    ws->first_cycle   = 1;
    /* Whois column (ASN / NETNAME / ORGNAME) is shown when the user has
     * explicitly requested lookups with -A (do_aslookup) or -N (do_netlookup);
     * otherwise it stays off.  'w' key toggles at any time. */
    ws->whois_enabled = (sess->do_aslookup || sess->do_netlookup) ? 1 : 0;
    ws->show_hostnames = (sess->hostnames_only != 0) ? 1 : 0;  /* off by default; -h CLI or 'h' key enables */

    /* Build target label string */
    {
        const char *tgt = sess->hostname ? sess->hostname : "?";
        switch (sess->protocol) {
            case 1:
                snprintf(ws->tgt_label, sizeof(ws->tgt_label),
                         "udp://%s:%d", tgt, sess->dport);
                break;
            case 2:
            case 3:
                snprintf(ws->tgt_label, sizeof(ws->tgt_label), "icmp://%s", tgt);
                break;
            default:
                snprintf(ws->tgt_label, sizeof(ws->tgt_label),
                         "tcp://%s:%d", tgt, sess->dport);
                break;
        }
    }

    /* ------------------------------------------------------------------ */
    /* Run the initial trace with the live counter spinner animation.    */
    /* lft_o_spinner_start_prefix() arms SIGALRM + writes to /dev/tty,  */
    /* then stdout/stderr are redirected to /dev/null so LFT's normal   */
    /* text output doesn't appear.  lft_o_spinner_stop() erases the     */
    /* entire animation line cleanly before ncurses initialises.        */
    /* ------------------------------------------------------------------ */
    {
        int saved_stdout, saved_stderr;

        /* Start the live counter animation using the same "Tracing proto://host:port  "
         * prefix as -o mode.  Writes to /dev/tty via SIGALRM so it survives the
         * stdout→/dev/null redirect that follows. */
        lft_o_spinner_start(sess);

        {
            int init_pipe[2];
            int init_use_pipe = (sess->noisy >= 1);
            init_pipe[0] = init_pipe[1] = -1;

            saved_stdout = dup(STDOUT_FILENO);
            saved_stderr = dup(STDERR_FILENO);

            if (init_use_pipe && pipe(init_pipe) == 0) {
                fcntl(init_pipe[1], F_SETFL,
                      fcntl(init_pipe[1], F_GETFL) | O_NONBLOCK);
                dup2(init_pipe[1], STDOUT_FILENO);
                dup2(init_pipe[1], STDERR_FILENO);
                close(init_pipe[1]);
                init_pipe[1] = -1;
            } else {
                int dn = open("/dev/null", O_WRONLY);
                init_use_pipe = 0;
                if (dn >= 0) {
                    dup2(dn, STDOUT_FILENO);
                    dup2(dn, STDERR_FILENO);
                    close(dn);
                }
            }

            setOutputStyle(0);  /* suppress EVT_ON_EXIT re-entry */
            /* S1: watch_mode stays 1 — socket + pcap reused across cycles */
            /* Ensure rtt_stats is set so ASCIIOutput (view 1) has chart data */
            if (sess->rtt_stats <= 0)
                sess->rtt_stats = ws->probes_per_hop;
            LFTExecute(sess);

            fflush(stdout);
            fflush(stderr);
            dup2(saved_stdout, STDOUT_FILENO);
            dup2(saved_stderr, STDERR_FILENO);
            close(saved_stdout);
            close(saved_stderr);

            if (init_use_pipe && init_pipe[0] >= 0) {
                char chunk[4096];
                ssize_t nr;
                fcntl(init_pipe[0], F_SETFL, O_NONBLOCK);
                while ((nr = read(init_pipe[0], chunk, sizeof(chunk) - 1)) > 0) {
                    chunk[nr] = '\0';
                    watch_log_append(ws, chunk);
                }
                close(init_pipe[0]);
            }
        }

        setOutputStyle(4);

        /* exit_state == -100 means EVT_ON_EXIT fired (normal trace completion).
         * Only bail out for actual socket/pcap open errors (-1 through -36). */
        if (sess->exit_state >= -36 && sess->exit_state < 0) {
            lft_o_spinner_stop();   /* erase animation before printing error */
            fprintf(stderr,
                    "\nLFT: --watch: failed to open raw socket or pcap (exit_state=%d)."
                    " Try: sudo chown root lft && sudo chmod u+s lft\n",
                    sess->exit_state);
            goto cleanup;
        }
        /* Capture results from the initial trace */
        ws->cycle = 1;
        ws->first_cycle = 0;
        if (sess->hop_info)
            watch_capture_from_sess(ws, sess);

        /* Whois is on by default — run the bulk pWhoIs query now so the
         * very first ncurses render has complete ASN/NETNAME/ORGNAME data.
         * The spinner continues animating during the whois query. */
        if (ws->whois_enabled && !ws->whois_bulk_done)
            watch_whois_bulk_query(ws);

        /* Accumulate initial trace counter values. */
        ws->total_replies += lft_o_get_replies();
        ws->total_sent    += lft_o_get_sent();
        if (lft_o_get_hops() > ws->max_hops_seen)
            ws->max_hops_seen = lft_o_get_hops();
        if (lft_o_get_target())
            ws->target_ever_reached = 1;
        /* Track current path length (not historical peak); see comment in
         * watch_capture_from_sess update. */
        ws->max_hops_seen = ws->n_hops;

        /* Erase the startup animation line completely before ncurses init. */
        lft_o_spinner_stop();
    }

    /* ------------------------------------------------------------------ */
    /* Install SIGWINCH handler for terminal resize                        */
    /* ------------------------------------------------------------------ */
    memset(&sa_winch, 0, sizeof(sa_winch));
    sa_winch.sa_handler = watch_sigwinch;
    sigemptyset(&sa_winch.sa_mask);
    sigaction(SIGWINCH, &sa_winch, &sa_old_winch);

    /* ------------------------------------------------------------------ */
    /* Initialize ncurses                                                  */
    /* ------------------------------------------------------------------ */
    if (watch_init_ncurses(ws) < 0) {
        endwin();
        fprintf(stderr,
                "\nLFT: The minimum height required for --watch is %d rows,"
                " expand your terminal window if possible.\n\n",
                WATCH_MIN_ROWS);
        goto cleanup;
    }

    /* ------------------------------------------------------------------ */
    /* Install SIGINT/SIGTERM/SIGHUP/SIGQUIT handlers                     */
    /* ncurses hides the cursor and puts the terminal in raw mode; without */
    /* these handlers a Ctrl-C leaves the terminal in a broken state.      */
    /* ------------------------------------------------------------------ */
    {
        struct sigaction sa;
        memset(&sa, 0, sizeof(sa));
        sa.sa_handler = watch_sig_fatal;
        sigemptyset(&sa.sa_mask);
        sa.sa_flags = 0;   /* no SA_RESTART — want interrupted syscalls to fail fast */
        sigaction(SIGINT,  &sa, &g_old_sa_sigint_watch);
        sigaction(SIGTERM, &sa, &g_old_sa_sigterm_watch);
        sigaction(SIGHUP,  &sa, &g_old_sa_sighup_watch);
        sigaction(SIGQUIT, &sa, &g_old_sa_sigquit_watch);
        g_watch_ncurses_active = 1;
    }
    if (!g_watch_atexit) {
        atexit(watch_atexit);
        g_watch_atexit = 1;
    }

    /* Auto-open the log drawer only when the user explicitly passed -V or more
     * on the command line.  The buffer is always populated at -VV level, but
     * we don't force the drawer open unless the user asked for verbosity. */
    if (orig_noisy >= 1)
        ws->log_open = 1;

    /* ------------------------------------------------------------------ */
    /* Main watch loop — cycle 1 already run above; loop starts from cycle 2 */
    /* ------------------------------------------------------------------ */
    watch_render(ws, sess, ws->spinner_frame);  /* show cycle 1 results */

    while (ws->running) {
        if (ws->max_cycles > 0 && ws->cycle >= ws->max_cycles)
            break;

        /* Inter-cycle idle: 4 sub-steps per second so the spinner animates
         * smoothly at ~4 fps instead of freezing for a whole second.
         *
         * Keys that set ws->interrupt_sleep break out immediately (≤250ms
         * latency) to start the next trace without waiting the full interval.
         * Any other key triggers a full redraw so view/log changes appear at
         * once without waiting for the next trace. */
        {
            int sub, total_steps = ws->interval * 4;
            ws->interrupt_sleep = 0;
            for (sub = 0; sub < total_steps && ws->running; sub++) {
                int ch, any_key = 0;
                ws->spinner_frame = (ws->spinner_frame + 1) % WATCH_SPINNER_FRAMES;
                while ((ch = getch()) != ERR) {
                    watch_handle_key(ws, ch);
                    any_key = 1;
                }
                if (!ws->running || ws->interrupt_sleep) break;
                /* Sleep first on sub=0: watch_render() already drew at cycle
                 * end so re-drawing immediately would cause a visible flash. */
                usleep(250000);   /* 250 ms */
                if (any_key)
                    watch_render(ws, sess, ws->spinner_frame);
                else {
                    watch_render_status_bar(ws, ws->spinner_frame);
                    wnoutrefresh(stdscr);
                    doupdate();
                }
            }
        }
        if (!ws->running) break;
        if (ws->max_cycles > 0 && ws->cycle >= ws->max_cycles)
            break;

        if (!ws->paused) {
            ws->cycle++;
            ws->in_trace = 1;
            /* SIGALRM owns the status bar during the trace — no pre-trace
             * status bar write needed here; it would just cause an extra
             * visible flicker on the footer row. */

            /* Run one full trace cycle */
            watch_run_one_cycle(ws, sess);

            ws->in_trace = 0;
            /* spinner_frame was advanced by the SIGALRM handler during the
             * trace; sync it back into ws so the idle loop continues from
             * where the alarm left off. */
            ws->spinner_frame = (int)g_alarm_frame;
        }

        /* First render: show current trace data immediately so the user
         * never sits watching a blank screen while pWhoIs blocks. */
        watch_render(ws, sess, ws->spinner_frame);

        if (!ws->paused) {
            /* Re-run bulk pWhoIs query if it hasn't completed yet (e.g. after
             * [c] clear reset whois_bulk_done to 0); otherwise just update any
             * new hop IPs that appeared since the last bulk query. */
            if (ws->whois_enabled && !ws->whois_bulk_done)
                watch_whois_bulk_query(ws);
            else if (ws->whois_enabled && ws->whois_bulk_done)
                watch_whois_update_new_hops(ws);

            /* Second render only needed when whois data was updated */
            if (ws->whois_enabled)
                watch_render(ws, sess, ws->spinner_frame);
        }
    }

    /* Restore termination signal handlers before teardown — normal exit path */
    if (g_watch_ncurses_active) {
        sigaction(SIGINT,  &g_old_sa_sigint_watch,  NULL);
        sigaction(SIGTERM, &g_old_sa_sigterm_watch,  NULL);
        sigaction(SIGHUP,  &g_old_sa_sighup_watch,   NULL);
        sigaction(SIGQUIT, &g_old_sa_sigquit_watch,  NULL);
        g_watch_ncurses_active = 0;
    }
    watch_teardown_ncurses();

cleanup:
    g_watch_ws = NULL;
    /* Free per-IP annotation cache */
    watch_ip_cache_free(&ws->ip_cache);
    /* Free pWhoIs session if allocated (F2) */
    if (ws->watch_whois_wsess) {
        w_close(ws->watch_whois_wsess);
        ws->watch_whois_wsess = NULL;
    }
    /* Restore SIGWINCH */
    sigaction(SIGWINCH, &sa_old_winch, NULL);
    free(ws);
}

#endif /* HAVE_NCURSES */
