"""Vital-bar rendering, toolbar layout, and display helpers."""

# std imports
import time
import random
import typing
import asyncio
import logging
import collections
import dataclasses
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    import blessed
    import blessed.line_editor

    from . import trigger as trigger_mod
    from . import progressbars
    from .session_context import TelixSessionContext

# 3rd party
import wcwidth

# local
from . import repl_theme, client_repl_color, client_repl_sextant

log = logging.getLogger(__name__)


# Session key injected by the REPL to scope palette lookups.
# Single-session singleton: telix runs one connection per process, so this is safe.
session_key: str = ""


def set_session_key(key: str) -> None:
    """Set the session key used for palette lookups."""
    global session_key
    session_key = key


def pal(key: str) -> str:
    """Return ``#rrggbb`` hex string for palette *key*."""
    return repl_theme.get_repl_palette(session_key)[key]


def idle_rgb() -> tuple[int, int, int]:
    """Normal input-line background as ``(r, g, b)``."""
    return repl_theme.hex_to_rgb(pal("input_bg"))


def idle_ar_rgb() -> tuple[int, int, int]:
    """Trigger input-line background as ``(r, g, b)``."""
    return repl_theme.hex_to_rgb(pal("input_ar_bg"))


SEXTANT = client_repl_sextant.SEXTANT
SEXTANT_VISIBLE = client_repl_sextant.SEXTANT_VISIBLE
SCRAMBLE_LEN = client_repl_sextant.SCRAMBLE_LEN
scramble_password = client_repl_sextant.scramble_password


def editor_cursor_col(editor: "blessed.line_editor.LineEditor") -> int:
    """Return cursor column for the editor display."""
    return editor.display.cursor


@dataclasses.dataclass(frozen=True)
class DisplayChars:
    """Display element constants: widths and decoration characters."""

    BAR_WIDTH: int = 20
    SEPARATOR_WIDTH: int = 3
    BAR_CAP_LEFT: str = "\U0001fb38"
    BAR_CAP_RIGHT: str = "\U0001fb1b"
    DMZ_CHAR: str = "\u2581"
    ELLIPSIS: str = "\u2026"


DISPLAY = DisplayChars()


CANCEL_SUFFIX = "  [return to cancel]"


def activity_hint(engine: "trigger_mod.TriggerEngine | None", cols: int = 0) -> str:
    """
    Build a short trigger status hint from *engine*.

    The hint reserves a fixed-width region: at least ``len(CANCEL_SUFFIX) + 16``
    columns and at most one third of the terminal width.  The status portion is
    truncated with an ellipsis if necessary so that ``[return to cancel]`` is
    always fully visible at a consistent right-aligned position.

    :returns: Hint string like ``"#3 | until /pat/  [return to cancel]"``,
        or ``""`` when there is nothing to display.
    """
    if engine is None:
        return ""
    parts: list[str] = []
    idx = engine.exclusive_rule_index
    if idx != 0:
        parts.append(f"#{idx}")
    st = getattr(engine, "status_text", "")
    if st:
        parts.append(st)
    if not parts:
        return ""
    status = " | ".join(parts)
    suffix_w = len(CANCEL_SUFFIX)
    if cols > 0:
        region = max(suffix_w + 16, cols // 3)
        status_budget = region - suffix_w
        if len(status) > status_budget > 0:
            status = status[: status_budget - 1] + DISPLAY.ELLIPSIS
        return status.ljust(region - suffix_w) + CANCEL_SUFFIX
    return status + CANCEL_SUFFIX


def until_progress(engine: "trigger_mod.TriggerEngine | None") -> float | None:
    """
    Return the until timer progress fraction, or ``None``.

    :returns: Float in ``0.0..1.0`` when an until/untils timer is active.
    """
    if engine is None:
        return None
    return getattr(engine, "until_progress", None)


def write_hint(
    hint: str,
    out: "asyncio.StreamWriter",
    bt: "blessed.Terminal",
    progress: float | None = None,
    bg_sgr: str = "",
    trigger: bool = False,
) -> None:
    """
    Write *hint* at the current cursor position with optional progress bar.

    When *progress* is not ``None``, the hint is split into a reverse-video
    left portion (elapsed) and a normal dim right portion (remaining).

    :param hint: Plain hint text.
    :param out: Stream to write SGR-encoded bytes to.
    :param bt: blessed Terminal instance.
    :param progress: ``0.0..1.0`` fraction, or ``None`` for plain dim text.
    :param bg_sgr: Optional background SGR prefix (e.g. trigger bg color).
    :param trigger: Use trigger suggestion color instead of normal.
    """
    if not hint:
        return
    if trigger:
        effective_bg = bg_sgr
        dim = str(bt.color_rgb(*repl_theme.hex_to_rgb(pal("input_ar_suggestion"))))
        normal = bt.normal
        if progress is not None:
            split = int(len(hint) * progress + 0.5)
            left = hint[:split]
            right = hint[split:]
            rev = str(bt.reverse)
            out.write(f"{effective_bg}{dim}{rev}{left}{normal}{effective_bg}{dim}{right}{normal}".encode())
        else:
            out.write(f"{effective_bg}{dim}{hint}{normal}".encode())
    else:
        normal = bt.normal
        rev = str(bt.reverse)
        if progress is not None:
            split = int(len(hint) * progress + 0.5)
            left = hint[:split]
            right = hint[split:]
            out.write(f"{normal}{left}{rev}{right}{normal}".encode())
        else:
            out.write(f"{rev}{hint}{normal}".encode())


def get_term() -> "blessed.Terminal":
    """Return the module-level blessed Terminal singleton."""
    from . import client_repl  # circular

    return client_repl.get_term()


# DECSCUSR cursor shape escapes (xterm extension, no terminfo equivalent).
# CURSOR_HIDE / CURSOR_SHOW removed -- use blessed term.hide_cursor / term.normal_cursor.


@dataclasses.dataclass(frozen=True)
class CursorSeq:
    """DECSCUSR shape escapes and OSC cursor-color reset."""

    BLINKING_BLOCK: str = "\x1b[1 q"
    STEADY_BLOCK: str = "\x1b[2 q"
    BLINKING_UNDERLINE: str = "\x1b[3 q"
    STEADY_UNDERLINE: str = "\x1b[4 q"
    BLINKING_BAR: str = "\x1b[5 q"
    STEADY_BAR: str = "\x1b[6 q"
    DEFAULT: str = "\x1b[0 q"
    COLOR_RESET_OSC: str = "\x1b]112\x07"
    DEFAULT_STYLE: str = "steady_block"

    @property
    def STYLES(self) -> dict[str, str]:  # noqa: N802
        """Map style name to DECSCUSR escape sequence."""
        return {
            "blinking_bar": self.BLINKING_BAR,
            "steady_bar": self.STEADY_BAR,
            "blinking_block": self.BLINKING_BLOCK,
            "steady_block": self.STEADY_BLOCK,
            "blinking_underline": self.BLINKING_UNDERLINE,
            "steady_underline": self.STEADY_UNDERLINE,
        }


CURSOR = CursorSeq()


def cursor_color_rgb() -> tuple[int, int, int]:
    """Cursor color from the theme palette."""
    return repl_theme.hex_to_rgb(pal("cursor_color"))


def cursor_ar_rgb() -> tuple[int, int, int]:
    """Trigger cursor color from the theme palette."""
    return repl_theme.hex_to_rgb(pal("cursor_ar_color"))


def cursor_osc() -> str:
    """OSC 12 escape to set cursor color for the normal background."""
    r, g, b = cursor_color_rgb()
    return f"\x1b]12;rgb:{r:02x}/{g:02x}/{b:02x}\x07"


def cursor_ar_osc() -> str:
    """OSC 12 escape to set cursor color for the trigger background."""
    r, g, b = cursor_ar_rgb()
    return f"\x1b]12;rgb:{r:02x}/{g:02x}/{b:02x}\x07"


# SGR style dicts keyed to LineEditor constructor / attribute names.
# Built lazily via make_styles() so blessed color_rgb auto-downgrades
# on terminals that lack truecolor support.
STYLE_NORMAL: dict[str, str] = {}
STYLE_TRIGGER: dict[str, str] = {}


def make_styles() -> None:
    """Populate style dicts using blessed color API and the active palette."""
    blessed_term = get_term()
    cr, cg, cb = cursor_color_rgb()
    cursor_fg = blessed_term.color_rgb(cr, cg, cb)
    ar_cr, ar_cg, ar_cb = cursor_ar_rgb()
    cursor_ar_fg = blessed_term.color_rgb(ar_cr, ar_cg, ar_cb)
    n_bg = idle_rgb()
    ar_bg = idle_ar_rgb()
    txt = repl_theme.hex_to_rgb(pal("input_text"))
    sug = repl_theme.hex_to_rgb(pal("input_suggestion"))
    ar_txt = repl_theme.hex_to_rgb(pal("input_ar_text"))
    ar_sug = repl_theme.hex_to_rgb(pal("input_ar_suggestion"))
    STYLE_NORMAL.clear()
    STYLE_NORMAL.update(
        {
            "text_sgr": blessed_term.color_rgb(*txt),
            "suggestion_sgr": blessed_term.color_rgb(*sug),
            "bg_sgr": blessed_term.on_color_rgb(*n_bg),
            "ellipsis_sgr": blessed_term.color_rgb(190, 190, 190),
            "cursor_sgr": cursor_fg + blessed_term.on_color_rgb(*n_bg),
        }
    )
    STYLE_TRIGGER.clear()
    STYLE_TRIGGER.update(
        {
            "text_sgr": blessed_term.color_rgb(*ar_txt),
            "suggestion_sgr": blessed_term.color_rgb(*ar_sug),
            "bg_sgr": blessed_term.on_color_rgb(*ar_bg),
            "ellipsis_sgr": blessed_term.color_rgb(*ar_sug),
            "cursor_sgr": cursor_ar_fg + blessed_term.on_color_rgb(*ar_bg),
        }
    )


hsv_to_rgb = client_repl_color.hsv_to_rgb
rgb_to_hsv = client_repl_color.rgb_to_hsv
lerp_hsv = client_repl_color.lerp_hsv
FlashTiming = client_repl_color.FlashTiming
FLASH = client_repl_color.FLASH
flash_color = client_repl_color.flash_color


def fmt_value(n: int) -> str:
    """
    Format a numeric value with k/m suffixes for compact display.

    :param n: Integer value.
    :returns: Formatted string, e.g. ``1.2k``, ``3.5m``.
    """
    if n >= 1_000_000:
        v = n / 1_000_000
        return f"{v:.1f}m"
    if n >= 1_000:
        v = n / 1_000
        return f"{v:.1f}k"
    return str(n)


def vital_color(fraction: float, kind: str) -> str:
    """
    Return an RGB hex color for a vitals bar.

    :param fraction: 0.0 (empty) to 1.0 (full).
    :param kind: ``"hp"`` for red-to-green, ``"mp"`` for golden-yellow-to-blue,
        ``"xp"`` for purple-to-violet.
    """
    fraction = max(0.0, min(1.0, fraction))
    if kind == "hp":
        # Stay red below 33%, then red -> pastel forest green over 33%-100%.
        hue = max(0.0, (fraction - 0.33) / 0.67) * 138.0
        sat, val = 0.50, 0.75
    elif kind == "xp":
        # Purple (270) -> cyan (180) as XP fills.
        hue = 270.0 - fraction * 90.0
        sat, val = 0.7, 0.8
    elif kind == "discover":
        # Green (120) -> magenta (300) as autodiscover progresses.
        hue = 120.0 + fraction * 180.0
        sat, val = 0.7, 0.8
    elif kind == "randomwalk":
        # Orange (30) -> teal (170) as randomwalk progresses.
        hue = 30.0 + fraction * 140.0
        sat, val = 0.7, 0.8
    else:
        # Stay golden yellow below 33%, then golden-yellow->blue over 33%-100%.
        # hue 45=golden yellow, hue 240=blue.
        t = max(0.0, (fraction - 0.33) / 0.67)
        hue = 45.0 + t * 195.0
        sat, val = 0.7, 0.8
    r, g, b = hsv_to_rgb(hue, sat, val)
    return f"#{r:02x}{g:02x}{b:02x}"


def wcswidth(text: str) -> int:
    """Return display width of *text*, handling wide chars."""
    w = wcwidth.wcswidth(text)
    return w if w >= 0 else len(text)


def dmz_line(cols: int, active: bool = False) -> str:
    """
    Return a styled DMZ divider line of *cols* width.

    :param cols: Terminal width.
    :param active: Use gold color when trigger/wander/discover is active.
    """
    blessed_term = get_term()
    rgb = repl_theme.hex_to_rgb(pal("dmz_active") if active else pal("dmz_inactive"))
    color = blessed_term.color_rgb(*rgb)
    return str(color) + (DISPLAY.DMZ_CHAR * cols) + str(blessed_term.normal)


def segmented(text: str) -> str:
    """Return *text* unchanged (plain ASCII digits)."""
    return text


def sgr_fg(hexcolor: str) -> str:
    """SGR foreground from ``#rrggbb`` hex via blessed (auto-downconverts)."""
    return str(get_term().color_hex(hexcolor))


def sgr_bg(hexcolor: str) -> str:
    """SGR background from ``#rrggbb`` hex via blessed (auto-downconverts)."""
    return str(get_term().on_color_hex(hexcolor))


def vital_bar(
    current: typing.Any,
    maximum: typing.Any,
    width: int,
    kind: str,
    flash_elapsed: float = -1.0,
    color_override: str | None = None,
    text_fill_color: str | None = None,
    text_empty_color: str | None = None,
) -> "list[tuple[str, str]]":
    """
    Build a labelled progress-bar with sextant bookends and overlaid text.

    The label (e.g. ``513/514 100% HP``) is rendered *on top of* the bar
    using segmented digit glyphs.  Sextant block characters bookend the
    bar for a rounded appearance.

    :param flash_elapsed: Seconds since flash start; negative means no flash.
    :param color_override: If set, use this ``#rrggbb`` color instead of
        computing from *kind*.
    """
    try:
        cur = int(current)
    except (TypeError, ValueError):
        cur = 0
    if maximum is not None:
        try:
            mx = int(maximum)
        except (TypeError, ValueError):
            mx = 0
    else:
        mx = 0

    if mx > 0:
        frac = max(0.0, min(1.0, cur / mx))
    else:
        frac = 1.0

    filled = round(frac * width)
    pct = round(frac * 100)
    kind_lower = kind.lower()

    bar_color = color_override if color_override else vital_color(frac, kind_lower)
    fg_fill = text_fill_color or pal("bar_text_fill")
    fg_empty = text_empty_color or pal("bar_text_empty")
    empty_bg_hex = pal("bar_empty_bg")
    if 0.0 <= flash_elapsed < FLASH.DURATION:
        fill_bg = flash_color(bar_color, flash_elapsed)
        empty_bg = flash_color(empty_bg_hex, flash_elapsed)
        filled_sgr = sgr_fg(fg_fill) + sgr_bg(fill_bg)
        empty_sgr = sgr_fg(fg_empty) + sgr_bg(empty_bg)
    else:
        fill_bg = bar_color
        empty_bg = empty_bg_hex
        filled_sgr = sgr_fg(fg_fill) + sgr_bg(bar_color)
        empty_sgr = sgr_fg(fg_empty) + sgr_bg(empty_bg_hex)

    builtin_suffix = {
        "hp": " hp",
        "mp": " mp",
        "xp": " xp",
        "wander": " AW",
        "randomwalk": " random walk",
        "discover": " discover",
    }
    suffix = builtin_suffix.get(kind_lower, f" {kind}" if kind else "")
    if mx > 0:
        left_part = segmented(f"{fmt_value(cur)}/{fmt_value(mx)}")
        right_part = segmented(f"{pct}%") + suffix
    else:
        left_part = segmented(f"{fmt_value(cur)}")
        right_part = suffix.lstrip()

    # Left-align values, right-align pct+suffix, gap in the middle
    # where the filled/empty boundary is most visible.
    gap = max(1, width - len(left_part) - len(right_part))
    bar_text = (left_part + " " * gap + right_part)[:width]
    if len(bar_text) < width:
        bar_text += " " * (width - len(bar_text))

    filled_text = bar_text[:filled]
    empty_text = bar_text[filled:]

    left_color = fill_bg if filled > 0 else empty_bg
    right_color = fill_bg if filled >= width else empty_bg

    return [
        (sgr_fg(left_color), DISPLAY.BAR_CAP_LEFT),
        (filled_sgr, filled_text),
        (empty_sgr, empty_text),
        (sgr_fg(right_color), DISPLAY.BAR_CAP_RIGHT),
    ]


def center_truncate(text: str, avail: int) -> str:
    """Truncate *text* to fit *avail* display columns."""
    if avail <= 0:
        return ""
    w = wcswidth(text)
    if w <= avail:
        return text
    # Truncate character by character.
    result = []
    total = 0
    for ch in text:
        cw = wcswidth(ch)
        if total + cw + 1 > avail:
            break
        result.append(ch)
        total += cw
    return "".join(result) + "\u2026"


class ToolbarSlot(typing.NamedTuple):
    """A single toolbar item with layout metadata."""

    priority: int
    display_order: int
    width: int
    fragments: list[tuple[str, str]]
    side: str
    min_width: int
    label: str
    growable: bool = False
    grow_params: tuple[typing.Any, ...] | None = None


BAR_GAP_WIDTH = 1


def layout_toolbar(slots: list["ToolbarSlot"], cols: int) -> tuple[list["ToolbarSlot"], list["ToolbarSlot"]]:
    """
    Fit toolbar slots into *cols* columns by priority.

    :returns: ``(left_slots, right_slots)`` ordered by ``display_order``.
    """
    left: list[ToolbarSlot] = []
    right: list[ToolbarSlot] = []
    left_used = 0
    right_used = 0
    has_left = False
    has_right = False

    for slot in sorted(slots, key=lambda s: s.priority):
        has_neighbor = (slot.side == "left" and has_left) or (slot.side == "right" and has_right)
        sep = DISPLAY.SEPARATOR_WIDTH if has_neighbor else 0
        need = slot.width + sep
        avail = cols - left_used - right_used - 1  # 1 char min pad

        if need <= avail:
            if slot.side == "left":
                left.append(slot)
                left_used += need
                has_left = True
            else:
                right.append(slot)
                right_used += need
                has_right = True
        elif slot.min_width > 0 and slot.min_width < slot.width:
            fit = avail - sep
            if fit >= slot.min_width:
                trimmed_text = center_truncate(slot.label, fit)
                trimmed_w = wcswidth(trimmed_text)
                trimmed_frags = [(slot.fragments[0][0], trimmed_text)]
                trimmed = slot._replace(width=trimmed_w, fragments=trimmed_frags)
                if slot.side == "left":
                    left.append(trimmed)
                    left_used += trimmed_w + sep
                    has_left = True
                else:
                    right.append(trimmed)
                    right_used += trimmed_w + sep
                    has_right = True

    left.sort(key=lambda s: s.display_order)
    right.sort(key=lambda s: s.display_order)
    return (left, right)


def left_sep_widths(left: list["ToolbarSlot"]) -> list[int]:
    """
    Return per-gap separator widths for left slots.

    Adjacent growable (vital-bar) slots use ``BAR_GAP_WIDTH``; all other
    gaps use ``DISPLAY.SEPARATOR_WIDTH``.
    """
    gaps: list[int] = []
    for i in range(1, len(left)):
        if left[i - 1].growable and left[i].growable:
            gaps.append(BAR_GAP_WIDTH)
        else:
            gaps.append(DISPLAY.SEPARATOR_WIDTH)
    return gaps


def fill_toolbar(
    left: list["ToolbarSlot"], right: list["ToolbarSlot"], cols: int
) -> tuple[list["ToolbarSlot"], list["ToolbarSlot"], int]:
    """
    Distribute extra horizontal space across growable slots and separators.

    :returns: ``(left_slots, right_slots, sep_width)`` with expanded bars.
    """
    left_gaps = left_sep_widths(left)
    n_right_seps = max(0, len(right) - 1)
    used = sum(s.width for s in left) + sum(s.width for s in right)
    used += sum(left_gaps) + n_right_seps * DISPLAY.SEPARATOR_WIDTH
    min_pad = 1 if left and right else 0
    extra = cols - used - min_pad

    growable = [s for s in left + right if s.growable and s.grow_params is not None]
    if not growable or extra <= 0:
        return (left, right, DISPLAY.SEPARATOR_WIDTH)

    n_expandable_seps = n_right_seps
    if n_expandable_seps > 0:
        sep_bonus = extra // 4
        per_sep = sep_bonus // n_expandable_seps
        sep_width = DISPLAY.SEPARATOR_WIDTH + per_sep
        remaining = extra - per_sep * n_expandable_seps
    else:
        sep_width = DISPLAY.SEPARATOR_WIDTH
        remaining = extra

    per_bar = remaining // len(growable)
    leftover = remaining - per_bar * len(growable)

    grow_set = {id(s) for s in growable}
    grow_idx = 0

    def expand(slot: ToolbarSlot) -> ToolbarSlot:
        nonlocal grow_idx
        if id(slot) not in grow_set:
            return slot
        bonus = per_bar
        if grow_idx < leftover:
            bonus += 1
        grow_idx += 1
        params = slot.grow_params
        assert params is not None
        if len(params) in (4, 5, 7):
            raw, maxval, kind, flash_elapsed = params[:4]
            c_override = params[4] if len(params) > 4 else None
            t_fill = params[5] if len(params) > 5 else None
            t_empty = params[6] if len(params) > 6 else None
            inner = slot.width - 2 + bonus
            frags = vital_bar(
                raw,
                maxval,
                inner,
                kind,
                flash_elapsed=flash_elapsed,
                color_override=c_override,
                text_fill_color=t_fill,
                text_empty_color=t_empty,
            )
            new_w = sum(wcswidth(t) for _, t in frags)
            return slot._replace(width=new_w, fragments=frags)
        if len(params) == 1:
            full_text = params[0]
            new_w = slot.width + bonus
            trimmed = center_truncate(full_text, new_w)
            trimmed_w = wcswidth(trimmed)
            sgr = slot.fragments[0][0] if slot.fragments else ""
            return slot._replace(width=trimmed_w, fragments=[(sgr, trimmed)])
        return slot

    new_left = [expand(s) for s in left]
    new_right = [expand(s) for s in right]
    return (new_left, new_right, sep_width)


class VitalTracker:
    """Track one vital stat (HP, MP) with flash-on-change timing."""

    __slots__ = ("flash_time", "last_value")

    def __init__(self) -> None:
        """Initialize tracker with no previous value."""
        self.last_value: int | None = None
        self.flash_time: float = 0.0

    def update(self, raw: typing.Any, now: float) -> float:
        """
        Update tracker with *raw* value at time *now*.

        :returns: Elapsed seconds since last flash (or negative if no flash).
        """
        try:
            val = int(raw)
        except (TypeError, ValueError):
            val = 0
        if self.last_value is not None and val != self.last_value:
            self.flash_time = now
        self.last_value = val
        elapsed = now - self.flash_time
        return elapsed if elapsed < FLASH.DURATION else -1.0


class XPTracker(VitalTracker):
    """Vital tracker with XP history for ETA calculation."""

    __slots__ = ("history",)

    HISTORY_WINDOW: float = 300.0

    def __init__(self) -> None:
        """Initialize XP tracker with empty history deque."""
        super().__init__()
        self.history: collections.deque[tuple[float, int]] = collections.deque()

    def update(self, raw: typing.Any, now: float) -> float:
        """Update tracker, also maintaining XP history for ETA."""
        try:
            val = int(raw)
        except (TypeError, ValueError):
            val = 0
        if self.last_value is not None and val != self.last_value:
            self.flash_time = now
            self.history.append((now, val))
        elif self.last_value is None:
            self.history.append((now, val))
        self.last_value = val

        cutoff = now - self.HISTORY_WINDOW
        while self.history and self.history[0][0] < cutoff:
            self.history.popleft()

        elapsed = now - self.flash_time
        return elapsed if elapsed < FLASH.DURATION else -1.0

    def eta_fragments(self, maxxp: typing.Any, now: float) -> list[tuple[str, str]] | None:
        """
        Compute ETA fragments from XP history.

        :returns: Fragment list for the ETA slot, or ``None`` if unavailable.
        """
        if len(self.history) < 2 or maxxp is None or self.last_value is None:
            return None
        oldest_t, oldest_xp = self.history[0]
        span = now - oldest_t
        if span <= 0:
            return None
        rate_per_sec = (self.last_value - oldest_xp) / span
        try:
            remaining = int(maxxp) - self.last_value
        except (TypeError, ValueError):
            return None
        if rate_per_sec <= 0 or remaining <= 0:
            return None
        eta_sec = remaining / rate_per_sec
        eta_hr = eta_sec / 3600.0
        if eta_hr >= 1.0:
            eta_text = segmented(f"ETA {eta_hr:.1f}h")
        else:
            eta_min = int(eta_sec / 60.0)
            eta_text = segmented(f"ETA {eta_min}m")
        return [(sgr_fg(pal("warning")), eta_text)]


class ToolbarRenderer:
    """
    Encapsulates GMCP vitals toolbar state and rendering.

    Replaces the former ``render_toolbar`` function and its ``toolbar_state``
    dict with typed attributes and small focused methods.
    """

    def __init__(
        self, ctx: "TelixSessionContext", scroll: typing.Any, out: asyncio.StreamWriter, rprompt_text: str = ""
    ) -> None:
        """Initialize toolbar renderer for *ctx*."""
        self.ctx = ctx
        self.scroll = scroll
        self.out = out
        self.rprompt_text = rprompt_text
        self.flash_active: bool = False
        self.has_gmcp: bool = False
        self.eta_refresh_active: bool = False
        self.until_progress_active: bool = False
        self.last_progress_col: int = -1
        self.last_progress_hint: str = ""
        self.last_hint_split: tuple[str, int] | None = None
        self.last_eta_text: str = ""
        self.cached_eta_frags: list[tuple[str, str]] | None = None
        self.hp = VitalTracker()
        self.mp = VitalTracker()
        self.xp = XPTracker()
        self.bar_trackers: dict[str, VitalTracker] = {}
        self.last_ar_bg: bool = False

    def is_trigger_bg(self, engine: "trigger_mod.TriggerEngine | None") -> bool:
        """Return whether the input row should use the trigger background."""
        ar = engine is not None and (engine.exclusive_active or engine.reply_pending)
        return (
            self.ctx.walk.discover_active or self.ctx.walk.randomwalk_active or bool(self.ctx.walk.await_script) or ar
        )

    def render(self, trigger_engine: "trigger_mod.TriggerEngine | None") -> bool:
        """
        Render GMCP vitals toolbar at ``scroll.input_row + 1``.

        :returns: ``True`` if a flash is active and the caller should
            schedule a re-render.
        """
        if not self.ensure_gmcp_ready():
            return False
        self.ctx.mark_gmcp_dirty()

        engine = trigger_engine
        ar_active = engine is not None and (engine.exclusive_active or engine.reply_pending)
        discover_active = self.ctx.walk.discover_active
        randomwalk_active = self.ctx.walk.randomwalk_active
        await_script = self.ctx.walk.await_script

        now = time.monotonic()
        is_ar = self.is_trigger_bg(engine)
        slots, needs_reflash = self.build_slots(
            engine, ar_active, discover_active, randomwalk_active, await_script, now, is_ar_bg=is_ar
        )
        return self.paint(slots, is_ar, needs_reflash)

    def ensure_gmcp_ready(self) -> bool:
        """Initialize toolbar on first GMCP data; return ``False`` if no data yet."""
        from . import client_repl  # circular

        gmcp_data: dict[str, typing.Any] | None = self.ctx.gmcp_data or None  # inherited from TelnetSessionContext
        if not self.has_gmcp:
            if not gmcp_data:
                return False
            self.has_gmcp = True
            self.scroll.grow_reserve(client_repl.RESERVE_WITH_TOOLBAR)
            if self.ctx.gmcp.on_ready is not None:
                self.ctx.gmcp.on_ready()
                self.ctx.gmcp.on_ready = None
        return True

    def build_slots(
        self,
        engine: "trigger_mod.TriggerEngine | None",
        ar_active: bool,
        discover_active: bool,
        randomwalk_active: bool,
        await_script: str,
        now: float,
        is_ar_bg: bool = False,
    ) -> tuple[list[ToolbarSlot], bool]:
        """Build all toolbar slots and return ``(slots, needs_reflash)``."""
        slots: list[ToolbarSlot] = []
        needs_reflash = False
        gmcp_data: dict[str, typing.Any] | None = self.ctx.gmcp_data or None  # inherited from TelnetSessionContext

        if gmcp_data:
            bar_configs = self.ctx.progress.configs
            if bar_configs:
                needs_reflash = self.config_driven_bars(gmcp_data, bar_configs, now, slots, is_ar_bg)
            else:
                needs_reflash = self.default_bars(gmcp_data, now, slots)

            room_info = gmcp_data.get("Room.Info", gmcp_data.get("Room.Name"))
            if isinstance(room_info, dict):
                room_name = str(room_info.get("name", room_info.get("Name", "")))
            elif isinstance(room_info, str):
                room_name = room_info
            else:
                room_name = ""
            if room_name:
                self.rprompt_text = room_name

        if self.ctx.chat.unread > 0:
            badge = f"Chat:{self.ctx.chat.unread}"
            slots.append(
                ToolbarSlot(
                    priority=3,
                    display_order=8,
                    width=wcswidth(badge),
                    fragments=[(sgr_fg(pal("primary")), badge)],
                    side="left",
                    min_width=0,
                    label="",
                )
            )

        self.right_slot(engine, ar_active, discover_active, randomwalk_active, await_script, slots)
        return slots, needs_reflash

    def default_bars(self, gmcp_data: dict[str, typing.Any], now: float, slots: list[ToolbarSlot]) -> bool:
        """Build HP/MP/XP bars using hardcoded aliases (backward compat)."""
        needs_reflash = False
        vitals = gmcp_data.get("Char.Vitals")
        maxstats = gmcp_data.get("Char.Maxstats")
        if not isinstance(maxstats, dict):
            maxstats = {}
        if isinstance(vitals, dict):
            hp = vitals.get("hp", vitals.get("HP"))
            maxhp = vitals.get("maxhp", vitals.get("maxHP", vitals.get("max_hp")))
            if maxhp is None:
                maxhp = maxstats.get("maxhp", maxstats.get("maxHP", maxstats.get("max_hp")))
            if hp is not None:
                if self.vital_slot(self.hp, hp, maxhp, DISPLAY.BAR_WIDTH, "hp", 1, 2, now, slots):
                    needs_reflash = True
            mp = vitals.get("mp", vitals.get("MP", vitals.get("mana", vitals.get("sp", vitals.get("SP")))))
            maxmp = vitals.get(
                "maxmp", vitals.get("maxMP", vitals.get("max_mp", vitals.get("maxsp", vitals.get("maxSP"))))
            )
            if maxmp is None:
                maxmp = maxstats.get("maxmana", maxstats.get("maxmp", maxstats.get("maxMP", maxstats.get("maxsp"))))
            if mp is not None:
                if self.vital_slot(self.mp, mp, maxmp, DISPLAY.BAR_WIDTH, "mp", 4, 3, now, slots):
                    needs_reflash = True
        status = gmcp_data.get("Char.Status")
        if isinstance(status, dict):
            xp_raw = status.get("xp", status.get("XP", status.get("experience")))
            maxxp = status.get("maxxp", status.get("maxXP", status.get("max_xp", status.get("maxexp"))))
            if xp_raw is not None:
                if self.vital_slot(self.xp, xp_raw, maxxp, DISPLAY.BAR_WIDTH, "xp", 5, 4, now, slots):
                    needs_reflash = True
                self.xp_eta_slot(maxxp, now, slots)
        return needs_reflash

    def config_driven_bars(
        self,
        gmcp_data: dict[str, typing.Any],
        bar_configs: "list[progressbars.BarConfig]",
        now: float,
        slots: list[ToolbarSlot],
        is_ar_bg: bool = False,
    ) -> bool:
        """Build vital bar slots from user-configured progress bar list."""
        from . import progressbars  # circular

        needs_reflash = False
        for i, cfg in enumerate(bar_configs):
            if not cfg.enabled:
                continue
            if not cfg.gmcp_package:
                continue
            pkg_data = gmcp_data.get(cfg.gmcp_package)
            if not isinstance(pkg_data, dict):
                continue
            raw = pkg_data.get(cfg.value_field)
            if raw is None:
                continue

            if cfg.bar_type == "label":
                self.label_slot(cfg, raw, i, is_ar_bg, slots)
                continue

            if cfg.max_gmcp_package:
                max_pkg = gmcp_data.get(cfg.max_gmcp_package)
                maxval = max_pkg.get(cfg.max_field) if isinstance(max_pkg, dict) else None
            else:
                maxval = pkg_data.get(cfg.max_field)
            tracker = self.bar_trackers.get(cfg.name)
            if tracker is None:
                tracker = VitalTracker()
                self.bar_trackers[cfg.name] = tracker
            kind = cfg.name
            priority = i + 1
            order = cfg.display_order if cfg.display_order else i + 2
            side = cfg.side
            # Compute color from config (theme or custom).
            try:
                mx = int(maxval) if maxval is not None else 0
                cur = int(raw)
                frac = max(0.0, min(1.0, cur / mx)) if mx > 0 else 1.0
            except (TypeError, ValueError):
                frac = 1.0
            color = progressbars.bar_color_at(frac, cfg)
            text_fill = progressbars.resolve_text_color_hex(cfg.text_color_fill)
            text_empty = progressbars.resolve_text_color_hex(cfg.text_color_empty)
            if self.vital_slot(
                tracker,
                raw,
                maxval,
                DISPLAY.BAR_WIDTH,
                kind,
                priority,
                order,
                now,
                slots,
                color_override=color,
                text_fill_color=text_fill,
                text_empty_color=text_empty,
                side=side,
            ):
                needs_reflash = True
        return needs_reflash

    def status_slots(self, status: dict[str, typing.Any], slots: list[ToolbarSlot], is_ar_bg: bool) -> None:
        """Add Level and Money slots from ``Char.Status``."""
        color = "#222222" if is_ar_bg else pal("muted")
        fg = sgr_fg(color)
        level = status.get("level")
        if level is not None:
            lv_text = segmented(f"Lv.{level}")
            slots.append(
                ToolbarSlot(
                    priority=7,
                    display_order=0,
                    width=wcswidth(lv_text),
                    fragments=[(fg, lv_text)],
                    side="left",
                    min_width=0,
                    label="",
                )
            )
        money = status.get("money")
        if money is not None:
            try:
                money_int = int(money)
                money_str = segmented(f"${money_int:,}")
            except (TypeError, ValueError):
                money_str = segmented(f"${money}")
            slots.append(
                ToolbarSlot(
                    priority=6,
                    display_order=1,
                    width=wcswidth(money_str),
                    fragments=[(fg, money_str)],
                    side="left",
                    min_width=0,
                    label="",
                )
            )

    @staticmethod
    def label_slot(
        cfg: "progressbars.BarConfig", raw: typing.Any, index: int, is_ar_bg: bool, slots: list[ToolbarSlot]
    ) -> None:
        """Add a label-type slot from a :class:`BarConfig`."""
        from . import progressbars  # circular

        try:
            numeric = int(raw)
        except (TypeError, ValueError):
            numeric = raw
        try:
            text = segmented(cfg.label_format.format(value=numeric))
        except (KeyError, ValueError, IndexError):
            text = segmented(str(raw))
        if is_ar_bg:
            color = "#222222"
        else:
            hex_val = progressbars.get_theme_color_hex(cfg.color_name_max)
            color = hex_val if hex_val else pal("muted")
        fg = sgr_fg(color)
        order = cfg.display_order if cfg.display_order else index + 2
        slots.append(
            ToolbarSlot(
                priority=index + 1,
                display_order=order,
                width=wcswidth(text),
                fragments=[(fg, text)],
                side=cfg.side,
                min_width=0,
                label="",
            )
        )

    @staticmethod
    def vital_slot(
        tracker: VitalTracker,
        raw: typing.Any,
        maxval: typing.Any,
        width: int,
        kind: str,
        priority: int,
        order: int,
        now: float,
        slots: list[ToolbarSlot],
        color_override: str | None = None,
        text_fill_color: str | None = None,
        text_empty_color: str | None = None,
        side: str = "left",
    ) -> bool:
        """
        Update *tracker* and append a vital bar slot.

        Bars with no known maximum (``maxval`` is ``None`` or zero) are
        suppressed -- a progress bar without a denominator is meaningless.

        :returns: ``True`` if a flash animation is active.
        """
        try:
            mx = int(maxval) if maxval is not None else 0
        except (TypeError, ValueError):
            mx = 0
        if mx <= 0:
            return False
        flash_elapsed = tracker.update(raw, now)
        needs_reflash = flash_elapsed >= 0.0
        frags = vital_bar(
            raw,
            maxval,
            width,
            kind,
            flash_elapsed=flash_elapsed,
            color_override=color_override,
            text_fill_color=text_fill_color,
            text_empty_color=text_empty_color,
        )
        frags_w = sum(wcswidth(t) for _, t in frags)
        slots.append(
            ToolbarSlot(
                priority=priority,
                display_order=order,
                width=frags_w,
                fragments=frags,
                side=side,
                min_width=0,
                label="",
                growable=True,
                grow_params=(raw, maxval, kind, flash_elapsed, color_override, text_fill_color, text_empty_color),
            )
        )
        return needs_reflash

    def xp_eta_slot(self, maxxp: typing.Any, now: float, slots: list[ToolbarSlot]) -> None:
        """
        Append an ETA slot from cached fragments.

        ETA is recomputed only by the 1-second ``schedule_eta_refresh`` timer
        to avoid jittery numbers on every line of server output.
        """
        if self.cached_eta_frags is None:
            self.cached_eta_frags = self.xp.eta_fragments(maxxp, now)
        eta_frags = self.cached_eta_frags
        if eta_frags is not None:
            eta_text = eta_frags[0][1]
            slots.append(
                ToolbarSlot(
                    priority=8,
                    display_order=5,
                    width=wcswidth(eta_text),
                    fragments=eta_frags,
                    side="left",
                    min_width=0,
                    label="",
                )
            )

    def right_slot(
        self,
        engine: "trigger_mod.TriggerEngine | None",
        ar_active: bool,
        discover_active: bool,
        randomwalk_active: bool,
        await_script: str,
        slots: list[ToolbarSlot],
    ) -> None:
        """Append the right-side slot (walk mode, trigger, or room name)."""
        if await_script:
            text = f" await {await_script}"
            slots.append(
                ToolbarSlot(
                    priority=3,
                    display_order=10,
                    width=len(text),
                    fragments=[("", text)],
                    side="right",
                    min_width=0,
                    label="",
                )
            )
        elif randomwalk_active:
            self.travel_bar_slot(
                self.ctx.walk.randomwalk_current, self.ctx.walk.randomwalk_total, 24, "randomwalk", slots
            )
        elif discover_active:
            self.travel_bar_slot(self.ctx.walk.discover_current, self.ctx.walk.discover_total, 20, "discover", slots)
        elif ar_active:
            idx = getattr(engine, "exclusive_rule_index", None)
            ar_label = f"Trigger #{idx}" if idx is not None else "Trigger"
            ar_text = " " + ar_label
            slots.append(
                ToolbarSlot(
                    priority=3,
                    display_order=10,
                    width=len(ar_text),
                    fragments=[("", ar_text)],
                    side="right",
                    min_width=0,
                    label="",
                )
            )
        else:
            loc_text = self.rprompt_text
            if loc_text:
                full_text = " " + loc_text
                full_w = wcswidth(full_text)
                slots.append(
                    ToolbarSlot(
                        priority=2,
                        display_order=10,
                        width=full_w,
                        fragments=[(sgr_fg(pal("foreground")), full_text)],
                        side="right",
                        min_width=5,
                        label=full_text,
                        growable=True,
                        grow_params=(full_text,),
                    )
                )

    def travel_bar_slot(
        self, cur: typing.Any, tot: typing.Any, width: int, kind: str, slots: list[ToolbarSlot]
    ) -> None:
        """Append a travel progress bar slot, using config colors when available."""
        from . import progressbars  # circular

        travel_cfg = None
        for cfg in self.ctx.progress.configs or []:
            if cfg.name == progressbars.TRAVEL_BAR_NAME or (cfg.name and not cfg.gmcp_package):
                travel_cfg = cfg
                break

        color_override = None
        text_fill = None
        text_empty = None
        side = "right"
        if travel_cfg is not None and travel_cfg.enabled:
            try:
                mx = int(tot) if tot is not None else 0
                cv = int(cur) if cur is not None else 0
                frac = max(0.0, min(1.0, cv / mx)) if mx > 0 else 1.0
            except (TypeError, ValueError):
                frac = 1.0
            color_override = progressbars.bar_color_at(frac, travel_cfg)
            text_fill = progressbars.resolve_text_color_hex(travel_cfg.text_color_fill)
            text_empty = progressbars.resolve_text_color_hex(travel_cfg.text_color_empty)
            side = getattr(travel_cfg, "side", "right")

        mode_frags = vital_bar(
            cur, tot, width, kind, color_override=color_override, text_fill_color=text_fill, text_empty_color=text_empty
        )
        mode_w = sum(wcswidth(t) for _, t in mode_frags)
        slots.append(
            ToolbarSlot(
                priority=3,
                display_order=10,
                width=mode_w,
                fragments=mode_frags,
                side=side,
                min_width=0,
                label="",
                growable=True,
                grow_params=(cur, tot, kind, -1.0, color_override, text_fill, text_empty),
            )
        )

    def paint(self, slots: list[ToolbarSlot], is_trigger_bg: bool, needs_reflash: bool) -> bool:
        """Write ANSI sequences for the toolbar row."""
        blessed_term = get_term()
        cols = blessed_term.width
        left_slots, right_slots = layout_toolbar(slots, cols)
        left_slots, right_slots, sep_width = fill_toolbar(left_slots, right_slots, cols)
        sep_str = " " * sep_width

        toolbar_row = self.scroll.input_row + 1
        self.out.write(blessed_term.move_yx(toolbar_row, 0).encode())

        if is_trigger_bg:
            ar_bg = idle_ar_rgb()
            ar_fg = repl_theme.hex_to_rgb(pal("input_ar_text"))
            bg_sgr = blessed_term.on_color_rgb(*ar_bg) + blessed_term.color_rgb(*ar_fg)
        else:
            bg_sgr = blessed_term.on_color_rgb(*idle_rgb())
        self.out.write(bg_sgr.encode())

        left_total = 0
        for i, slot in enumerate(left_slots):
            if i > 0:
                prev_growable = left_slots[i - 1].growable
                gap = BAR_GAP_WIDTH if prev_growable and slot.growable else sep_width
                self.out.write((" " * gap).encode())
                left_total += gap
            for sgr, text in slot.fragments:
                self.out.write(f"{sgr}{text}".encode())
                self.out.write(bg_sgr.encode())
                left_total += wcswidth(text)

        right_total = 0
        for i, slot in enumerate(right_slots):
            if i > 0:
                right_total += sep_width
            right_total += sum(wcswidth(t) for _, t in slot.fragments)

        pad = max(1, cols - left_total - right_total)
        self.out.write(f"{bg_sgr}{' ' * pad}".encode())

        right_sgr = sgr_fg(pal("foreground")) if not is_trigger_bg else ""
        for i, slot in enumerate(right_slots):
            if i > 0:
                self.out.write(sep_str.encode())
            for sgr, text in slot.fragments:
                effective_sgr = sgr if sgr else right_sgr
                self.out.write(f"{effective_sgr}{text}".encode())
                self.out.write(bg_sgr.encode())

        self.out.write(blessed_term.normal.encode())
        return needs_reflash

    def restore_cursor(self, bt: "blessed.Terminal", row: int, col: int, is_trigger_bg: bool) -> None:
        """Position cursor at *row*, *col* and make it visible."""
        osc = cursor_ar_osc() if is_trigger_bg else cursor_osc()
        self.out.write(bt.move_yx(row, col).encode())
        self.out.write(osc.encode())
        self.out.write(bt.normal_cursor.encode())

    def schedule_flash(
        self,
        loop: asyncio.AbstractEventLoop,
        trigger_engine: "trigger_mod.TriggerEngine | None",
        editor: "blessed.line_editor.LineEditor",
        bt: "blessed.Terminal",
    ) -> None:
        """Schedule repeating flash animation frames via ``loop.call_later``."""

        def tick() -> None:
            self.out.write(bt.hide_cursor.encode())
            still = self.render(trigger_engine)
            input_row = self.scroll.input_row
            engine = trigger_engine
            is_ar_bg = self.is_trigger_bg(engine)

            if is_ar_bg != self.last_ar_bg:
                self.last_ar_bg = is_ar_bg
                dmz_row = self.scroll.scroll_bottom + 1
                if dmz_row < input_row:
                    self.out.write((bt.move_yx(dmz_row, 0) + dmz_line(bt.width, is_ar_bg)).encode())

            cq = self.ctx.command_queue
            ac = self.ctx.walk.active_command
            ac_elapsed = time.monotonic() - self.ctx.walk.active_command_time
            show_cmd = cq is not None or (ac is not None and ac_elapsed < FLASH.DURATION)
            if show_cmd:
                from . import client_repl_commands  # circular

                hint = activity_hint(engine, bt.width)
                prog = until_progress(engine)
                bg = STYLE_TRIGGER["bg_sgr"] if is_ar_bg else STYLE_NORMAL["bg_sgr"]
                if cq is not None:
                    cursor_col = client_repl_commands.render_command_queue(
                        cq,
                        self.scroll,
                        self.out,
                        flash_elapsed=ac_elapsed,
                        hint=hint,
                        progress=prog,
                        base_bg_sgr=bg,
                        trigger=is_ar_bg,
                    )
                else:
                    assert ac is not None
                    cursor_col = client_repl_commands.render_active_command(
                        ac,
                        self.scroll,
                        self.out,
                        flash_elapsed=ac_elapsed,
                        hint=hint,
                        progress=prog,
                        base_bg_sgr=bg,
                        trigger=is_ar_bg,
                    )
                if prog is not None:
                    still = True
                if ac_elapsed < FLASH.DURATION:
                    still = True
                self.out.write(bt.move_yx(input_row, cursor_col).encode())
                if not still:
                    self.out.write(bt.normal_cursor.encode())
            else:
                style = STYLE_TRIGGER if is_ar_bg else STYLE_NORMAL
                for attr, val in style.items():
                    setattr(editor, attr, val)
                hint = activity_hint(engine, bt.width)
                hint_w = len(hint) if hint else 0
                edit_w = max(2, bt.width - hint_w)
                if not still:
                    raw = editor.render(bt, input_row, edit_w)
                    if editor.password_mode and editor._buf:
                        raw = "".join(
                            random.choice(SEXTANT_VISIBLE) if ch == editor.password_char else ch for ch in raw
                        )
                    self.out.write(raw.encode())
                prog = None
                if hint:
                    prog = until_progress(engine)
                    split = int(len(hint) * prog + 0.5) if prog is not None else -1
                    hint_split = (hint, split)
                    if hint_split != self.last_hint_split:
                        self.last_hint_split = hint_split
                        col = bt.width - hint_w
                        bg = STYLE_TRIGGER["bg_sgr"] if is_ar_bg else STYLE_NORMAL["bg_sgr"]
                        self.out.write(bt.move_yx(input_row, col).encode())
                        write_hint(hint, self.out, bt, progress=prog, bg_sgr=bg, trigger=is_ar_bg)
                    if prog is not None:
                        still = True
                else:
                    self.last_hint_split = None
                if prog is None and not still:
                    cursor_col = editor_cursor_col(editor)
                    self.restore_cursor(bt, input_row, cursor_col, is_ar_bg)

            if still:
                loop.call_later(FLASH.INTERVAL, tick)
            else:
                self.flash_active = False
                self.last_hint_split = None

        loop.call_later(FLASH.INTERVAL, tick)

    ETA_REFRESH_INTERVAL = 1.0

    def schedule_eta_refresh(
        self,
        loop: asyncio.AbstractEventLoop,
        trigger_engine: "trigger_mod.TriggerEngine | None",
        editor: "blessed.line_editor.LineEditor",
        bt: "blessed.Terminal",
    ) -> None:
        """
        Schedule a periodic ETA refresh at 1-second intervals.

        Only redraws the toolbar when the ETA text has changed since the last render, to avoid unnecessary flicker.
        """
        if self.eta_refresh_active:
            return
        self.eta_refresh_active = True

        def eta_tick() -> None:
            if not self.has_gmcp:
                self.eta_refresh_active = False
                return
            gmcp_data = self.ctx.gmcp_data or {}  # inherited from TelnetSessionContext
            status = gmcp_data.get("Char.Vitals", gmcp_data.get("Char.Status", {}))
            if isinstance(status, dict):
                maxxp = status.get("maxxp", status.get("maxXP", status.get("max_xp", status.get("maxexp"))))
            else:
                maxxp = None
            now = time.monotonic()
            frags = self.xp.eta_fragments(maxxp, now)
            self.cached_eta_frags = frags
            eta_text = frags[0][1] if frags else ""
            if eta_text != self.last_eta_text:
                self.last_eta_text = eta_text
                self.out.write(bt.hide_cursor.encode())
                self.render(trigger_engine)
                has_command = self.ctx.command_queue is not None or self.ctx.walk.active_command is not None
                if not has_command:
                    cursor_col = editor_cursor_col(editor)
                    input_row = self.scroll.input_row
                    engine = trigger_engine
                    is_ar_bg = self.is_trigger_bg(engine)
                    self.restore_cursor(bt, input_row, cursor_col, is_ar_bg)
            loop.call_later(self.ETA_REFRESH_INTERVAL, eta_tick)

        loop.call_later(self.ETA_REFRESH_INTERVAL, eta_tick)

    PROGRESS_REFRESH_INTERVAL = 0.1

    def schedule_until_progress(
        self,
        loop: asyncio.AbstractEventLoop,
        trigger_engine: "trigger_mod.TriggerEngine | None",
        editor: "blessed.line_editor.LineEditor",
        bt: "blessed.Terminal",
    ) -> None:
        """
        Schedule a 100 ms ticker to redraw the until progress bar.

        Only redraws when the integer progress-bar split column changes, to avoid unnecessary flicker.
        """
        if self.until_progress_active:
            return
        self.until_progress_active = True
        self.last_progress_col = -1

        def progress_tick() -> None:
            engine = trigger_engine
            prog = until_progress(engine)
            if prog is None:
                self.until_progress_active = False
                self.last_progress_col = -1
                self.last_progress_hint = ""
                self.out.write(bt.normal_cursor.encode())
                return
            hint = activity_hint(engine, bt.width)
            if not hint:
                self.until_progress_active = False
                self.last_progress_hint = ""
                self.out.write(bt.normal_cursor.encode())
                return
            split = int(len(hint) * prog + 0.5)
            if split == self.last_progress_col and hint == self.last_progress_hint:
                loop.call_later(self.PROGRESS_REFRESH_INTERVAL, progress_tick)
                return
            self.last_progress_col = split
            self.last_progress_hint = hint
            hint_w = len(hint)
            col = bt.width - hint_w
            if col < 2:
                loop.call_later(self.PROGRESS_REFRESH_INTERVAL, progress_tick)
                return
            is_ar_bg = self.is_trigger_bg(engine)
            bg = STYLE_TRIGGER["bg_sgr"] if is_ar_bg else STYLE_NORMAL["bg_sgr"]
            self.out.write(bt.hide_cursor.encode())
            self.out.write(bt.move_yx(self.scroll.input_row, col).encode())
            write_hint(hint, self.out, bt, progress=prog, bg_sgr=bg, trigger=is_ar_bg)
            if not self.flash_active:
                self.flash_active = True
                self.schedule_flash(loop, trigger_engine, editor, bt)
            loop.call_later(self.PROGRESS_REFRESH_INTERVAL, progress_tick)

        loop.call_later(self.PROGRESS_REFRESH_INTERVAL, progress_tick)
