import asyncio
import inspect
import os
import re
import sys
import threading
from collections.abc import Callable
from contextlib import suppress

import clypi
import mistune
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import Completer, Completion
from prompt_toolkit.filters import has_completions
from prompt_toolkit.formatted_text import FormattedText
from prompt_toolkit.history import FileHistory
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.shortcuts import CompleteStyle
from prompt_toolkit.styles import Style

from otto import ui_theme as theme
from otto.chat import COMMANDS, Chat, build_setup_completion_message
from otto.config import OTTO_HOME, BotConfig, ChannelConfig
from otto.log import get_logger

# Layout
_INDENT = "  "  # 2 spaces — aligns response with input text after bar
_INDENT_LEN = 2
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*[A-Za-z]")


def _visible_len(s: str) -> int:
    """Return the visible (non-ANSI) length of a string."""
    return len(_ANSI_RE.sub("", s))


# Reusable styles
_dim = clypi.Styler(dim=True)
_bold = clypi.Styler(bold=True)
_italic = clypi.Styler(italic=True)
_code_style = clypi.Styler(fg="cyan")
_error_style = clypi.Styler(fg="red", bold=True)
_bar_str = clypi.style("│", fg="cyan")
_GENERIC_USER_ERROR = "There was an error with this request. Check otto logs for more information."

log = get_logger("otto.cli_chat")


def _get_username() -> str:
    import getpass

    try:
        return getpass.getuser()
    except Exception:
        return "default"


def _is_tty() -> bool:
    try:
        return sys.stdout.isatty()
    except Exception:
        return False


# -- Cancel key reader (fix #4: setcbreak, not raw) ---------------------------


def _read_cancel_key(stop_event: threading.Event) -> str:
    """Read one key while waiting for a streaming response cancellation."""
    if not _is_tty():
        return ""

    try:
        import select
        import termios
        import tty
    except Exception:
        return ""

    fd = sys.stdin.fileno()
    old_settings = termios.tcgetattr(fd)
    try:
        tty.setcbreak(fd)
        while not stop_event.is_set():
            rlist, _, _ = select.select([fd], [], [], 0.1)
            if stop_event.is_set():
                return ""
            if not rlist:
                continue

            raw = sys.stdin.buffer.read(1)
            if not raw:
                return ""
            key = raw.decode("latin-1", errors="replace")
            if key == "\x1b":
                extra = b""
                while select.select([fd], [], [], 0.0)[0]:
                    extra += sys.stdin.buffer.read(1)
                if extra:
                    key = (key + extra.decode("latin-1", errors="replace")).replace("\r", "")
                return key

            return key
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
    return ""


# -- Markdown rendering (mistune) ----------------------------------------------


class _AnsiRenderer(mistune.HTMLRenderer):
    """Render markdown to ANSI escape sequences for terminal display."""

    def text(self, text):
        return text

    def paragraph(self, text):
        return text + "\n"

    def heading(self, text, level, **attrs):
        return _bold(text) + "\n"

    def strong(self, text):
        return _bold(text)

    def emphasis(self, text):
        return _italic(text)

    def codespan(self, text):
        return _code_style(text)

    def block_code(self, code, info=None):
        return _dim(code.rstrip()) + "\n"

    def link(self, text, url, title=None):
        if text == url:
            return _code_style(url)
        return f"{text} {_dim('(' + url + ')')}"

    def image(self, text, url, title=None):
        return _dim(f"[image: {text or url}]")

    def block_quote(self, text):
        lines = text.rstrip("\n").split("\n")
        bar = _dim("│ ")
        return "\n".join(bar + ln for ln in lines) + "\n"

    def list(self, text, ordered, **attrs):
        return text + "\n"

    def list_item(self, text, **attrs):
        return f"  • {text.strip()}\n"

    def thematic_break(self):
        return _dim("─" * 40) + "\n"

    def linebreak(self):
        return "\n"

    def softbreak(self):
        return "\n"

    def strikethrough(self, text):
        return _dim(text)

    def table_cell(self, text, align=None, head=False):
        content = text.strip()
        if head:
            content = _bold(content)
        return content + "\t"

    def table_row(self, text):
        cells = [c for c in text.split("\t") if c]
        return "│ " + " │ ".join(cells) + " │\n"

    def table_head(self, text):
        return text + _dim("─" * 40) + "\n"

    def table_body(self, text):
        return text

    def table(self, text):
        return text + "\n"

    def inline_html(self, html):
        return html

    def block_html(self, html):
        return html


_md_parser = mistune.create_markdown(
    renderer=_AnsiRenderer(escape=False),
    plugins=["strikethrough", "table"],
)


def _render_md(text: str) -> str:
    """Render markdown to ANSI using mistune."""
    result = _md_parser(text)
    # Strip trailing newlines (caller handles spacing)
    return result.rstrip("\n")


class _MarkdownStream:
    """Stateful streaming markdown-to-ANSI converter."""

    def __init__(self) -> None:
        self._in_bold = False
        self._in_italic = False
        self._in_code = False
        self._in_code_block = False
        self._in_heading = False
        self._at_line_start = True
        self._hash_count = 0
        self._pending = ""

    def _flush_buf(self, buf: list[str], parts: list[str]) -> None:
        if buf:
            parts.append(self._styled("".join(buf)))
            buf.clear()

    def feed(self, chunk: str) -> str:
        text = self._pending + chunk
        self._pending = ""
        parts: list[str] = []
        buf: list[str] = []
        i = 0
        n = len(text)

        while i < n:
            remaining = n - i

            # --- inside code block: only look for closing ``` ---
            if self._in_code_block:
                if text[i : i + 3] == "```":
                    self._flush_buf(buf, parts)
                    self._in_code_block = False
                    i += 3
                    continue
                if remaining < 3 and all(c == "`" for c in text[i:]):
                    self._flush_buf(buf, parts)
                    self._pending = text[i:]
                    return "".join(parts)
                buf.append(text[i])
                i += 1
                continue

            # --- newline: end heading if active, reset line-start ---
            if text[i] == "\n":
                self._flush_buf(buf, parts)
                if self._in_heading:
                    self._in_heading = False
                parts.append("\n")
                self._at_line_start = True
                self._hash_count = 0
                i += 1
                continue

            # --- heading detection at line start ---
            if self._at_line_start and not self._in_code:
                if text[i] == "#":
                    self._hash_count += 1
                    i += 1
                    if i >= n:
                        self._pending = "#" * self._hash_count
                        self._hash_count = 0
                        return "".join(parts)
                    continue
                if self._hash_count > 0 and text[i] == " ":
                    self._in_heading = True
                    self._at_line_start = False
                    self._hash_count = 0
                    i += 1
                    continue
                if self._hash_count > 0:
                    buf.extend(["#"] * self._hash_count)
                    self._hash_count = 0
                self._at_line_start = False

            # --- opening code block: ``` ---
            if text[i : i + 3] == "```":
                self._flush_buf(buf, parts)
                self._in_code_block = True
                i += 3
                while i < n and text[i] != "\n":
                    i += 1
                if i < n:
                    i += 1
                continue

            # Possible partial ``` at end of chunk
            if remaining < 3 and not self._in_code:
                tail = text[i:]
                if all(c == "`" for c in tail):
                    self._flush_buf(buf, parts)
                    self._pending = tail
                    return "".join(parts)

            # Inline code toggle: `
            if text[i] == "`":
                self._flush_buf(buf, parts)
                self._in_code = not self._in_code
                i += 1
                continue

            # Bold toggle: **  (not inside code)
            if text[i : i + 2] == "**" and not self._in_code:
                self._flush_buf(buf, parts)
                self._in_bold = not self._in_bold
                i += 2
                continue

            # Italic toggle: single * (not inside code, not **)
            if (
                text[i] == "*"
                and not self._in_code
                and (i + 1 >= n or text[i + 1] != "*")
                and (i == 0 or text[i - 1] != "*")
            ):
                # Could be start of ** if at end of chunk
                if remaining == 1:
                    self._flush_buf(buf, parts)
                    self._pending = "*"
                    return "".join(parts)
                self._flush_buf(buf, parts)
                self._in_italic = not self._in_italic
                i += 1
                continue

            # Link: [text](url) — render as "text (url)" with dim url
            if text[i] == "[" and not self._in_code:
                # Try to find complete [text](url) pattern
                close_bracket = text.find("]", i + 1)
                if close_bracket != -1 and close_bracket + 1 < n and text[close_bracket + 1] == "(":
                    close_paren = text.find(")", close_bracket + 2)
                    if close_paren != -1:
                        self._flush_buf(buf, parts)
                        link_text = text[i + 1 : close_bracket]
                        url = text[close_bracket + 2 : close_paren]
                        if link_text == url:
                            parts.append(_code_style(url))
                        else:
                            parts.append(f"{link_text} {_dim('(' + url + ')')}")
                        i = close_paren + 1
                        continue
                # Incomplete link — buffer for next chunk if near end
                if close_bracket == -1 and remaining < 80:
                    self._flush_buf(buf, parts)
                    self._pending = text[i:]
                    return "".join(parts)

            buf.append(text[i])
            i += 1

        if buf:
            parts.append(self._styled("".join(buf)))
        return "".join(parts)

    def _styled(self, text: str) -> str:
        if not text:
            return ""
        if self._in_code_block:
            return _dim(text)
        if self._in_code:
            return _code_style(text)
        if self._in_bold or self._in_heading:
            return _bold(text)
        if self._in_italic:
            return _italic(text)
        return text


# -- Renderer ------------------------------------------------------------------


def _format_tool_args(name: str, args: dict) -> str:
    for key in ("query", "q", "pattern", "url", "path", "command", "text"):
        if key in args and isinstance(args[key], str):
            val = args[key]
            if len(val) > 40:
                val = val[:37] + "..."
            return f'"{val}"'
    return ""


class CLIRenderer:
    output_format = "markdown"

    def __init__(self, model_label: str = "") -> None:
        self._has_content = False
        self._current_tool: str | None = None
        self._current_tool_args: dict = {}
        self._in_tool_box = False
        self._tool_box_has_footer = False
        self._spinner_visible = False
        self._model_label = model_label
        self._md = _MarkdownStream()
        self._stream_col = 0  # current visible column for word-wrap tracking
        self._hard_wrapped = False  # set when terminal hard-wraps at edge

    @property
    def _terminal_width(self) -> int:
        try:
            return os.get_terminal_size().columns
        except Exception:
            return 80

    def on_model_change(self, model: str) -> None:
        self._model_label = model.rsplit("/", 1)[-1] if "/" in model else model

    def show_spinner(self) -> None:
        if _is_tty() and self._model_label:
            sys.stdout.write(f"{_INDENT}{_dim(self._model_label + ' ···')}\n")
            sys.stdout.flush()
            self._spinner_visible = True

    def _clear_spinner(self) -> None:
        if self._spinner_visible and _is_tty():
            sys.stdout.write("\033[A\r\033[K")
            sys.stdout.flush()
        self._spinner_visible = False

    def on_text(self, chunk: str) -> None:
        self._clear_spinner()
        self._close_tool_box()
        styled = self._md.feed(chunk)
        if not self._has_content:
            styled = _INDENT + styled
        # Indent any mid-chunk newlines
        styled = styled.replace("\n", "\n" + _INDENT)
        self._write_wrapped(styled)
        self._has_content = True

    def _write_wrapped(self, styled: str) -> None:
        """Write styled text, inserting soft line-breaks at word boundaries."""
        width = self._terminal_width
        out: list[str] = []
        i = 0
        n = len(styled)

        while i < n:
            c = styled[i]

            # After a terminal hard-wrap, inject indent at the next word boundary
            if self._hard_wrapped:
                if c == " ":
                    # Replace the token's leading space with proper indent
                    out.append(_INDENT)
                    self._stream_col = _INDENT_LEN
                    self._hard_wrapped = False
                    i += 1
                    continue
                if c == "\n":
                    self._hard_wrapped = False
                    # fall through to newline handler
                elif c != "\x1b":
                    # Mid-word continuation after hard-wrap — no indent needed
                    self._hard_wrapped = False

            # ANSI escape sequence — emit without advancing column
            if c == "\x1b":
                m = _ANSI_RE.match(styled, i)
                if m:
                    out.append(m.group())
                    i = m.end()
                    continue

            # Newline — reset column
            if c == "\n":
                out.append(c)
                self._stream_col = 0
                i += 1
                continue

            # Space — look ahead to decide whether to wrap
            if c == " ":
                # Count consecutive spaces starting at i
                j = i
                spaces = 0
                while j < n and styled[j] == " ":
                    spaces += 1
                    j += 1
                # Measure the visible length of the next word
                word_len = 0
                k = j
                while k < n and styled[k] not in (" ", "\n"):
                    if styled[k] == "\x1b":
                        m = _ANSI_RE.match(styled, k)
                        if m:
                            k = m.end()
                            continue
                    word_len += 1
                    k += 1
                # Would the spaces + next word overflow the line?
                if self._stream_col + spaces + word_len > width and self._stream_col > _INDENT_LEN:
                    # Soft-wrap: replace the space run with newline + indent
                    out.append("\n" + _INDENT)
                    self._stream_col = _INDENT_LEN
                    i = j  # skip past all the spaces
                else:
                    out.append(c)
                    self._stream_col += 1
                    i += 1
                continue

            # Hyphen — output it, then check if rest of compound word overflows
            if c == "-" and self._stream_col > _INDENT_LEN:
                out.append(c)
                self._stream_col += 1
                i += 1
                # Measure the next segment (up to space, newline, or another hyphen)
                seg_len = 0
                k = i
                while k < n and styled[k] not in (" ", "\n", "-"):
                    if styled[k] == "\x1b":
                        m = _ANSI_RE.match(styled, k)
                        if m:
                            k = m.end()
                            continue
                    seg_len += 1
                    k += 1
                if self._stream_col + seg_len > width:
                    out.append("\n" + _INDENT)
                    self._stream_col = _INDENT_LEN
                continue

            # Regular visible character
            out.append(c)
            self._stream_col += 1
            if self._stream_col >= width:
                self._stream_col = 0
                self._hard_wrapped = True
            i += 1

        sys.stdout.write("".join(out))
        sys.stdout.flush()

    def on_tool_start(self, name: str, args: dict) -> None:
        self._clear_spinner()
        if self._has_content:
            sys.stdout.write("\n")
            self._has_content = False
        if self._tool_box_has_footer and _is_tty():
            sys.stdout.write("\033[A\r\033[K")
        if not self._in_tool_box:
            sys.stdout.write(f"{_INDENT}{_dim('┌─ tools ' + '─' * 35)}\n")
            self._in_tool_box = True
        args_str = _format_tool_args(name, args)
        suffix = f" {args_str}" if args_str else ""
        sys.stdout.write(f"{_INDENT}{_dim('│')} ◌ {name}{suffix}\n")
        sys.stdout.flush()
        self._current_tool = name
        self._current_tool_args = args
        self._tool_box_has_footer = False

    def on_tool_end(self, name: str, result: str) -> None:
        is_error = isinstance(result, str) and (
            result.startswith("Error") or result.startswith("error")
        )
        marker = "✗" if is_error else "✓"
        if self._in_tool_box:
            args_str = _format_tool_args(name, self._current_tool_args)
            suffix = f" {args_str}" if args_str else ""
            line = f"{_INDENT}{_dim('│')} {marker} {name}{suffix}\n"
            if _is_tty():
                sys.stdout.write(f"\033[A\r\033[K{line}")
            else:
                sys.stdout.write(line)
            sys.stdout.write(f"{_INDENT}{_dim('└' + '─' * 43)}\n")
            self._tool_box_has_footer = True
        else:
            line = _dim(f"◌ {name} {marker}")
            if _is_tty():
                sys.stdout.write(f"\033[A\r\033[K{_INDENT}{line}\n")
            else:
                sys.stdout.write(f"{_INDENT}{line}\n")
            self._tool_box_has_footer = False
        sys.stdout.flush()
        self._current_tool = None
        self._current_tool_args = {}

    def _close_tool_box(self) -> None:
        if self._in_tool_box and not self._tool_box_has_footer:
            sys.stdout.write(f"{_INDENT}{_dim('└' + '─' * 43)}\n")
        self._in_tool_box = False
        self._tool_box_has_footer = False

    async def send_text(self, text: str) -> None:
        self._close_tool_box()
        if self._has_content:
            sys.stdout.write("\n")
            self._has_content = False
        rendered = _render_md(text)
        for line in rendered.split("\n"):
            sys.stdout.write(f"{_INDENT}{line}\n")
        sys.stdout.write("\n")
        sys.stdout.flush()
        self._md = _MarkdownStream()

    async def send_with_buttons(self, text: str, buttons: list[list[tuple[str, str]]]) -> None:
        self._close_tool_box()
        if self._has_content:
            sys.stdout.write("\n")
            self._has_content = False
        rendered = _render_md(text)
        for line in rendered.split("\n"):
            sys.stdout.write(f"{_INDENT}{line}\n")
        idx = 1
        parts = []
        for row in buttons:
            for label, _data in row:
                parts.append(f"{idx}. {label}")
                idx += 1
        sys.stdout.write(f"{_INDENT}{_dim('  '.join(parts))}\n")
        sys.stdout.write("\n")
        sys.stdout.flush()
        self._md = _MarkdownStream()

    async def show_error(self, error: str) -> None:
        sys.stderr.write(f"{_INDENT}{_error_style('Error: ' + error)}\n")
        sys.stderr.flush()

    async def send_file(self, path: str, caption: str | None = None) -> None:
        self._close_tool_box()
        if self._has_content:
            sys.stdout.write("\n")
            self._has_content = False
        sys.stdout.write(f"{_INDENT}\U0001f4ce {path}\n")
        if caption:
            sys.stdout.write(f"{_INDENT}{caption}\n")
        sys.stdout.write("\n")
        sys.stdout.flush()

    async def flush(self) -> None:
        self._close_tool_box()
        if self._has_content:
            self._has_content = False
        sys.stdout.write("\n\n")
        sys.stdout.flush()
        self._md = _MarkdownStream()
        self._stream_col = 0
        self._hard_wrapped = False

    def on_session_restored(self, chat_id: str, session_store) -> None:
        if not _is_tty():
            return
        sys.stdout.write("\033[2J\033[3J\033[H")
        sys.stdout.flush()
        session = session_store.load(chat_id)
        for message in session.messages:
            role = message.get("role")
            content = message.get("content")
            if not isinstance(content, str) or not content:
                continue
            if role == "user":
                for line in content.split("\n"):
                    sys.stdout.write(f"{_bar_str} {line}\n")
                sys.stdout.write("\n")
            elif role == "assistant":
                rendered = _render_md(content)
                for line in rendered.split("\n"):
                    sys.stdout.write(f"{_INDENT}{line}\n")
                sys.stdout.write("\n")
        sys.stdout.flush()


# -- Channel -------------------------------------------------------------------


_SLASH_COMMANDS = sorted(f"/{cmd}" for cmd in COMMANDS)
_SLASH_COMMANDS.extend(["/exit", "/quit", "/q"])

_HISTORY_FILE = OTTO_HOME / "cli_history"


def _format_archive_ts(ts: str) -> str:
    if len(ts) >= 13 and "T" in ts:
        return f"{ts[:4]}-{ts[4:6]}-{ts[6:8]} {ts[9:11]}:{ts[11:13]}"
    return ts


class _CLICompleter(Completer):
    def __init__(self, archives_provider: Callable[[], list[dict]]) -> None:
        self._archives_provider = archives_provider

    def get_completions(self, document, complete_event):
        text_before_cursor = document.text_before_cursor
        if text_before_cursor.lstrip() != text_before_cursor:
            return
        if not text_before_cursor.startswith("/"):
            return
        if "\n" in text_before_cursor:
            return

        if text_before_cursor.startswith("/resume"):
            parts = text_before_cursor.split(maxsplit=1)
            if len(parts) == 1 and not text_before_cursor.endswith(" "):
                if "/resume".startswith(text_before_cursor):
                    yield Completion("/resume", start_position=-len(text_before_cursor))
                return

            typed_ts = ""
            if len(parts) > 1:
                typed_ts = parts[1].strip()

            for arc in self._archives_provider():
                ts = str(arc.get("ts", ""))
                if not ts:
                    continue
                if typed_ts and not ts.startswith(typed_ts):
                    continue
                msg_count = int(arc.get("msg_count", 0) or 0)
                preview = str(arc.get("preview", "") or "(empty)").replace("\n", " ").strip()
                if len(preview) > 48:
                    preview = preview[:47] + "…"
                label = f"{_format_archive_ts(ts)} · {msg_count} msgs"
                yield Completion(
                    ts,
                    start_position=-len(typed_ts),
                    display=ts,
                    display_meta=f"{label} · {preview}",
                )
            return

        for cmd in _SLASH_COMMANDS:
            if cmd.startswith(text_before_cursor):
                yield Completion(cmd, start_position=-len(text_before_cursor))


def _build_prompt_session(completer: Completer) -> PromptSession[str]:
    key_bindings = KeyBindings()

    @key_bindings.add("enter")
    def _submit(event) -> None:
        event.current_buffer.validate_and_handle()

    @key_bindings.add("escape", "enter")
    def _insert_newline(event) -> None:
        event.current_buffer.insert_text("\n")

    @key_bindings.add("up", filter=~has_completions, eager=True)
    def _up(event) -> None:
        buf = event.current_buffer
        if "\n" in buf.document.text:
            if buf.document.cursor_position_row > 0:
                buf.cursor_up(count=1)
        else:
            buf.auto_up(count=event.arg)

    @key_bindings.add("down", filter=~has_completions, eager=True)
    def _down(event) -> None:
        buf = event.current_buffer
        doc = buf.document
        if "\n" in doc.text:
            if doc.cursor_position_row < doc.line_count - 1:
                buf.cursor_down(count=1)
        else:
            buf.auto_down(count=event.arg)

    style = Style.from_dict(
        {
            "prompt": "cyan",
        }
    )

    prompt = FormattedText([("class:prompt", "│ ")])

    return PromptSession(
        message=prompt,
        multiline=True,
        prompt_continuation=lambda width, line_number, is_soft_wrap: prompt,
        key_bindings=key_bindings,
        completer=completer,
        complete_in_thread=True,
        complete_while_typing=False,
        complete_style=CompleteStyle.READLINE_LIKE,
        history=FileHistory(str(_HISTORY_FILE)),
        style=style,
    )


class CLIChatChannel:
    def __init__(
        self,
        chat: Chat,
        bot_config: BotConfig,
        channel_config: ChannelConfig,
        resume_id: str | None = None,
    ):
        self._chat = chat
        self._bot_config = bot_config
        self._channel_config = channel_config
        self._resume_id = resume_id
        self._running = False
        model = bot_config.model or "default"
        short_model = model.rsplit("/", 1)[-1] if "/" in model else model
        self._renderer = CLIRenderer(model_label=short_model)
        chat_id = _get_username()
        self._prompt_session = _build_prompt_session(
            _CLICompleter(lambda: self._chat._session_store.list_archived(chat_id))
        )
        # Fix #1: guard erase_when_done — only pass when prompt-toolkit supports it
        try:
            self._prompt_erase_supported = (
                "erase_when_done" in inspect.signature(self._prompt_session.prompt_async).parameters
            )
        except Exception:
            self._prompt_erase_supported = False

    @staticmethod
    def _friendly_error_message(exc: BaseException) -> str:
        lower = str(exc).lower()
        if "github_copilot" in lower or "copilot" in lower:
            if "editor-version" in lower or "ide auth" in lower:
                return "Copilot auth is incomplete. Run `otto auth login copilot` and retry."
            if "connection error" in lower:
                return "Could not reach GitHub Copilot. Check network/VPN and retry."
        return _GENERIC_USER_ERROR

    @property
    def alias(self) -> str:
        return self._bot_config.name

    def _sync_renderer_model_label(self, chat_id: str) -> None:
        on_model_change = getattr(self._renderer, "on_model_change", None)
        if not callable(on_model_change):
            return

        model = self._chat._model_overrides.get(
            chat_id,
            self._bot_config.model or self._chat._config.agent.model,
        )
        on_model_change(model)

    async def _await_input(self) -> str | None:
        """Read user input from the prompt-toolkit session."""
        try:
            from typing import Any

            kwargs: dict[str, Any] = (
                {"erase_when_done": True} if self._prompt_erase_supported else {}
            )
            return await self._prompt_session.prompt_async(**kwargs)
        except EOFError:
            return None

    async def _await_cancel_key(self, stop_event: threading.Event) -> str:
        return await asyncio.get_running_loop().run_in_executor(None, _read_cancel_key, stop_event)

    @staticmethod
    def _is_cancel_key(key: str) -> bool:
        return key in {"\x1b", "\x03"}

    async def start(self) -> None:
        """Run the interactive input loop."""
        self._running = True
        chat_id = _get_username()

        self._print_startup_header()
        if self._resume_id:
            self._chat._session_store.restore(chat_id, self._resume_id)
            self.replay_history(chat_id)
        else:
            # Fresh session — archive any existing one
            self._chat._session_store.rotate(chat_id)
            self._print_setup_completion_message()

        while self._running:
            try:
                line = await self._await_input()
                if line is None:  # EOF (Ctrl+D)
                    sys.stdout.write(_dim("exit") + "\n")
                    break
                text = line.rstrip("\r\n")
                if not text.strip():
                    continue

                # CLI-local commands
                if text.strip().lower() in ("/exit", "/quit", "/q"):
                    sys.stdout.write(_dim("bye!") + "\n")
                    break

                # Fix #2: only re-echo when prompt-toolkit erased the line;
                # otherwise the submitted prompt is already visible.
                if self._prompt_erase_supported:
                    self._echo_input(text)
                else:
                    # Fix #3: CRLF so cursor returns to column 0 on next line
                    sys.stdout.write("\r\n")
                    sys.stdout.flush()
                self._sync_renderer_model_label(chat_id)
                self._renderer.show_spinner()

                # Fix #5: mid-response cancellation via ESC/CTRL-C
                response_task = asyncio.create_task(
                    self._chat.handle_message(chat_id=chat_id, text=text, renderer=self._renderer)
                )
                cancel_stop = threading.Event()
                cancel_key_task: asyncio.Task[str] | None = asyncio.create_task(
                    self._await_cancel_key(cancel_stop)
                )
                response_cancelled = False

                if not _is_tty():
                    cancel_key_task = None

                while True:
                    if cancel_key_task is None:
                        break

                    done, _ = await asyncio.wait(
                        {response_task, cancel_key_task},
                        return_when=asyncio.FIRST_COMPLETED,
                    )
                    if response_task in done:
                        break

                    pressed = cancel_key_task.result()
                    if self._is_cancel_key(pressed):
                        response_cancelled = self._chat.stop(chat_id) or response_cancelled

                    if response_task.done():
                        break

                    cancel_key_task = asyncio.create_task(self._await_cancel_key(cancel_stop))

                cancel_stop.set()
                if cancel_key_task is not None and not cancel_key_task.done():
                    with suppress(asyncio.TimeoutError):
                        await asyncio.wait_for(cancel_key_task, timeout=0.25)

                try:
                    await response_task
                except asyncio.CancelledError:
                    if not response_cancelled:
                        raise

                if response_cancelled:
                    self._renderer._clear_spinner()
                    sys.stdout.write(_dim("response cancelled") + "\n")
            except asyncio.CancelledError:
                raise
            except Exception as exc:
                log.exception("cli chat request failed", error=str(exc), bot=self.alias)
                await self._renderer.show_error(self._friendly_error_message(exc))
            except KeyboardInterrupt:
                sys.stdout.write("\n" + _dim("ctrl+c — exiting") + "\n")
                break

        self._running = False
        print()

    async def stop(self) -> None:
        self._running = False

    def _echo_input(self, text: str) -> None:
        """Echo user input as a highlighted block with the cyan bar."""
        self._print_user_block(text)

    def replay_history(self, chat_id: str) -> None:
        sys.stdout.write("\033[2J\033[3J\033[H")
        sys.stdout.flush()
        self._print_startup_header()

        session = self._chat._session_store.load(chat_id)
        for message in session.messages:
            role = message.get("role")
            content = message.get("content")
            if not isinstance(content, str) or not content:
                continue

            if role == "user":
                self._print_user_block(content)
                continue
            if role != "assistant":
                continue

            rendered = _render_md(content)
            for line in rendered.split("\n"):
                sys.stdout.write(f"{_INDENT}{line}\n")
            sys.stdout.write("\n")
        sys.stdout.flush()

    def _print_startup_header(self) -> None:
        name = self._bot_config.name
        model = self._bot_config.model or "default"
        short_model = model.rsplit("/", 1)[-1] if "/" in model else model

        header = theme.box(
            f"{theme.bold(name)} {theme.dim('· ' + short_model)}",
            style="default",
        )
        sys.stdout.write(f"\n{header}\n\n")
        sys.stdout.flush()

    def _onboarding_message(self) -> str:
        return build_setup_completion_message(
            self._bot_config.channels,
            bot_name=self._bot_config.name,
            telegram_handle=None,
            output_format="markdown",
        )

    def _print_setup_completion_message(self) -> None:
        message = self._onboarding_message()
        if not message:
            return
        for line in _render_md(message).split("\n"):
            sys.stdout.write(f"{_INDENT}{line}\n")
        sys.stdout.write("\n")

    def _print_user_block(self, text: str) -> None:
        for line in text.split("\n"):
            sys.stdout.write(f"{_bar_str} {line}\n")
        sys.stdout.write("\n")
