"""Setup Core V2 — centered CLI TUI for the first-run setup wizard.

This module provides pure rendering components and interactive prompts for
driving the setup state machine defined in :mod:`otto.setup_core`.

Architecture
------------
* **Pure render functions** (``render_*``) are stateless — they return strings
  suitable for printing.  No I/O is performed inside them, making them
  straightforward to unit-test.
* **Interactive prompts** (``prompt_choice``, ``prompt_string``) perform I/O.
  They accept an optional ``_input_fn`` callable so tests can inject fake
  input without touching real stdin/stdout.
* **The wizard** (:func:`run_setup`) orchestrates prompts and calls the
  engine to produce a :class:`~otto.setup_core.models.SetupPlan`.  It
  returns the plan (or ``None`` if the user cancels) without touching the
  filesystem.  Callers call :func:`~otto.setup_core.apply.apply_plan`
  separately to write files.

Non-TTY fallback
----------------
When stdin is not a TTY (e.g. piped input, CI) arrow-key navigation is
replaced by simple numbered-option prompts.  :func:`_is_interactive` controls
the detection.
"""

import os
import shutil
import sys
from collections.abc import Callable

import clypi

from otto import ui_theme as theme
from otto.setup_core.engine import build_plan
from otto.setup_core.models import (
    CredentialGap,
    SetupChoice,
    SetupPlan,
    SetupValidationError,
)

# ---------------------------------------------------------------------------
# ANSI constants
# ---------------------------------------------------------------------------

_ESC = "\x1b"
_KEY_UP = "\x1b[A"
_KEY_DOWN = "\x1b[B"
_KEY_ENTER = ("\r", "\n")
_KEY_CTRL_C = "\x03"
_KEY_QUIT = ("q", "Q")

_CLEAR_LINE = "\r\033[K"
_MOVE_UP = "\033[{n}A"

# ---------------------------------------------------------------------------
# Styles (legacy - use ui_theme instead)
# ---------------------------------------------------------------------------

_bold = theme.bold
_dim = theme.dim
_cyan = theme.cyan
_green = theme.green
_yellow = theme.yellow
_red = theme.red
_magenta = theme.magenta

# ---------------------------------------------------------------------------
# Layout helpers
# ---------------------------------------------------------------------------

_MIN_WIDTH = 40
_CONTENT_WIDTH = 72


def _terminal_width() -> int:
    """Return the actual terminal width (clamped to a minimum)."""
    cols = shutil.get_terminal_size((80, 24)).columns
    return max(_MIN_WIDTH, cols)


def _center(text: str, width: int | None = None) -> str:
    """Center *text* within *width* columns."""
    w = width or _terminal_width()
    return clypi.align(text, "center", w)


def _hr(width: int | None = None, char: str = "─") -> str:
    """Return a centered horizontal rule (content-width, not full terminal)."""
    content_w = min(width or _terminal_width(), _CONTENT_WIDTH)
    rule = _dim(char * content_w)
    return _center(rule)


def _pad(n: int = 1) -> str:
    return "\n" * n


# ---------------------------------------------------------------------------
# TTY detection
# ---------------------------------------------------------------------------


def _is_interactive() -> bool:
    """Return True when stdin supports raw-mode arrow-key input."""
    try:
        return sys.stdin.isatty() and sys.stdout.isatty()
    except Exception:
        return False


def _is_tty_available() -> bool:
    """Return True if the tty and termios modules are importable."""
    try:
        import termios  # noqa: F401
        import tty  # noqa: F401

        return True
    except ImportError:
        return False


# ---------------------------------------------------------------------------
# Raw key reading
# ---------------------------------------------------------------------------


def _read_key() -> str:
    """Read one key (or escape sequence) from stdin in raw mode.

    Returns a string: the character or one of the ``_KEY_*`` constants.
    Raises ``KeyboardInterrupt`` on Ctrl+C.
    """
    import termios
    import tty

    fd = sys.stdin.fileno()
    old = termios.tcgetattr(fd)
    try:
        tty.setraw(fd)
        ch = sys.stdin.buffer.read(1)
        if ch == b"\x1b":
            # Try to read up to 2 more bytes for escape sequences.
            try:
                seq = sys.stdin.buffer.read(2)
            except Exception:
                seq = b""
            decoded = ch.decode("latin-1") + seq.decode("latin-1", errors="replace")
            return decoded
        decoded = ch.decode("latin-1", errors="replace")
        if decoded == _KEY_CTRL_C:
            raise KeyboardInterrupt
        return decoded
    finally:
        termios.tcsetattr(fd, termios.TCSADRAIN, old)


# ---------------------------------------------------------------------------
# Pure render functions
# ---------------------------------------------------------------------------

_OTTO_LOGO = (
    "   ___  _   _\n"
    "  / _ \\| |_| |_ ___\n"
    " | | | | __| __/ _ \\\n"
    " | |_| | |_| || (_) |\n"
    "  \\___/ \\__|\\__\\___/"
)


def render_welcome(*, width: int | None = None) -> str:
    """Return the welcome screen as a string."""
    w = width or _terminal_width()
    lines: list[str] = []

    lines.append(theme.pad(2))

    header_lines = [
        theme.bold("Otto"),
        theme.dim("AI Bot Platform"),
        "",
        theme.yellow("First-run setup"),
    ]
    for logo_line in _OTTO_LOGO.splitlines():
        header_lines.append(_cyan(logo_line))

    header_box = theme.box("\n".join(header_lines), width=w, style="default")
    lines.append(header_box)

    lines.append(theme.pad(1))
    tagline = theme.dim("Let's get you up and running in under 2 minutes.")
    lines.append(clypi.align(tagline, "center", w))
    lines.append(theme.pad(1))
    lines.append(theme.hr(width=w))

    return "\n".join(lines)


def render_choice_menu(
    title: str,
    options: list[str],
    selected_idx: int,
    *,
    subtitle: str | None = None,
    error: str | None = None,
    width: int | None = None,
) -> str:
    """Return a choice-menu screen as a string.

    Parameters
    ----------
    title:
        The question / section heading.
    options:
        List of option labels.
    selected_idx:
        The currently highlighted option (0-based).
    subtitle:
        Optional description line below the title.
    error:
        If set, a red error message is shown above the menu.
    width:
        Rendering width in columns.
    """
    w = width or _terminal_width()
    lines: list[str] = [_pad()]
    lines.append(_center(_bold(title), w))
    if subtitle:
        lines.append(_center(_dim(subtitle), w))
    lines.append(_pad())
    if error:
        lines.append(_center(_red(f"⚠  {error}"), w))
        lines.append("")
    for i, opt in enumerate(options):
        if i == selected_idx:
            marker = _cyan("❯")
            label = _bold(opt)
        else:
            marker = " "
            label = opt
        row = f"  {marker} {label}"
        lines.append(_center(row, w))
    lines.append(_pad())
    hint = _dim("↑↓ navigate   Enter select")
    lines.append(_center(hint, w))
    return "\n".join(lines)


def render_text_input(
    label: str,
    value: str = "",
    *,
    masked: bool = False,
    placeholder: str = "",
    error: str | None = None,
    width: int | None = None,
) -> str:
    """Return a text-input screen as a string.

    Parameters
    ----------
    label:
        The prompt label (e.g. ``"Your name"``).
    value:
        Current input value.
    masked:
        If True, the value is replaced with ``•`` characters (for secrets).
    placeholder:
        Hint text shown when *value* is empty.
    error:
        Optional error message to display.
    width:
        Rendering width in columns.
    """
    w = width or _terminal_width()
    lines: list[str] = [_pad()]
    lines.append(_center(_bold(label), w))
    lines.append(_pad())
    if error:
        lines.append(_center(_red(f"⚠  {error}"), w))
        lines.append("")
    if value:
        display = ("•" * len(value)) if masked else value
        lines.append(_center(_cyan(display), w))
    elif placeholder:
        lines.append(_center(_dim(placeholder), w))
    else:
        lines.append(_center(_dim("(enter value)"), w))
    return "\n".join(lines)


def render_review(
    choice: SetupChoice,
    *,
    width: int | None = None,
) -> str:
    """Return a configuration-review screen as a string.

    Parameters
    ----------
    choice:
        The :class:`~otto.setup_core.models.SetupChoice` to summarise.
    width:
        Rendering width in columns.
    """
    w = width or _terminal_width()
    lines: list[str] = [_pad()]
    lines.append(_center(_bold("Review your setup"), w))
    lines.append(_center(_dim("Everything looks correct? Press Enter to continue."), w))
    lines.append(_pad())
    lines.append(_hr(w))

    def _row(key: str, val: str) -> str:
        key_part = _dim(f"{key:<18}")
        return f"  {key_part} {_bold(val)}"

    lines.append(_row("Model", choice.model or _dim("(not set)")))
    lines.append(_row("Auth mode", choice.auth_mode))
    lines.append(_row("Channels", ", ".join(choice.channels) if choice.channels else "cli"))
    if choice.owner_name:
        lines.append(_row("Owner name", choice.owner_name))
    if choice.bot_name and choice.bot_name != "default":
        lines.append(_row("Bot name", choice.bot_name))
    lines.append(_row("Workspace", choice.workspace_root))
    if choice.provider_api_key:
        masked = "•" * min(len(choice.provider_api_key), 8) + "…"
        lines.append(_row("API key", masked))
    if choice.telegram_token:
        masked = choice.telegram_token[:8] + "…"
        lines.append(_row("Telegram token", masked))
    lines.append(_hr(w))
    lines.append(_pad())
    lines.append(_center(_dim("Enter → apply   Ctrl+C → cancel"), w))
    return "\n".join(lines)


def render_success(
    plan: SetupPlan | None = None,
    *,
    handoff_message: str | None = None,
    width: int | None = None,
) -> str:
    """Return a success screen as a string.

    Parameters
    ----------
    plan:
        The completed :class:`~otto.setup_core.models.SetupPlan`, used to
        show channel-specific next steps.
    handoff_message:
        Override the auto-generated handoff text.
    width:
        Rendering width in columns.
    """
    w = width or _terminal_width()
    lines: list[str] = [_pad()]
    lines.append(_center(_green("✓  Setup complete!"), w))
    lines.append(_pad())

    if handoff_message:
        for part in handoff_message.splitlines():
            lines.append(_center(part, w))
    elif plan is not None and plan.choice is not None:
        channels = plan.choice.channels or ("cli",)
        if "telegram" in channels and "cli" in channels:
            lines.append(_center("Your bot is ready on both CLI and Telegram.", w))
            lines.append(_center(_dim("Run `otto chat` to start here, or"), w))
            lines.append(_center(_dim("open Telegram and send /start to your bot."), w))
        elif "telegram" in channels:
            lines.append(_center("Open Telegram and send /start to your bot.", w))
        else:
            lines.append(_center("Run  `otto chat`  to start chatting.", w))
    else:
        lines.append(_center("Run  `otto chat`  to start chatting.", w))

    lines.append(_pad())
    lines.append(_hr(w))
    return "\n".join(lines)


def render_gap_prompt(
    gap: CredentialGap,
    *,
    width: int | None = None,
) -> str:
    """Return a prompt screen for a single :class:`~otto.setup_core.models.CredentialGap`."""
    w = width or _terminal_width()
    label = {
        "provider_api_key": f"API key ({gap.env_var or 'unknown'})",
        "telegram_token": "Telegram bot token",
        "oauth_credentials": f"OAuth credentials ({gap.provider or 'provider'})",
    }.get(gap.kind, gap.env_var or "Credential")
    lines: list[str] = [_pad()]
    lines.append(_center(_bold(f"Enter your {label}"), w))
    if gap.message:
        lines.append(_center(_dim(gap.message), w))
    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Interactive components
# ---------------------------------------------------------------------------


def _print(text: str, *, file=None) -> None:
    out = file or sys.stdout
    out.write(text + "\n")
    out.flush()


def _erase_lines(n: int) -> None:
    """Clear *n* lines starting from the current cursor line upward."""
    if n <= 0:
        return
    # Clear the current line first (cursor is at its end after write).
    sys.stdout.write("\r\033[2K")
    # Then move up and clear the remaining lines above.
    for _ in range(n - 1):
        sys.stdout.write("\033[1A\033[2K")
    sys.stdout.flush()


def prompt_choice(
    title: str,
    options: list[str],
    *,
    subtitle: str | None = None,
    default: int = 0,
    _input_fn: Callable[[str], str] | None = None,
    _write_fn: Callable[[str], None] | None = None,
) -> int:
    """Prompt the user to select one of *options*.

    Uses arrow-key navigation when running in an interactive TTY; falls back
    to a numbered list when stdin is not a TTY or raw mode is unavailable.

    Parameters
    ----------
    title:
        The question to display.
    options:
        List of string option labels.
    subtitle:
        Optional subtitle / hint.
    default:
        Index of the default selection.
    _input_fn:
        Injected input function (for testing non-TTY path).
    _write_fn:
        Injected write function (for testing).

    Returns
    -------
    int
        0-based index of the selected option.
    """
    write = _write_fn or _print

    if _input_fn is not None or not _is_interactive() or not _is_tty_available():
        return _prompt_choice_plain(
            title, options, subtitle=subtitle, default=default, input_fn=_input_fn or input
        )

    return _prompt_choice_arrow(title, options, subtitle=subtitle, default=default, write=write)


def _prompt_choice_plain(
    title: str,
    options: list[str],
    *,
    subtitle: str | None = None,
    default: int = 0,
    input_fn: Callable[[str], str] = input,
) -> int:
    """Non-TTY fallback: numbered list + text input."""
    print()
    print(_bold(title))
    if subtitle:
        print(_dim(subtitle))
    print()
    for i, opt in enumerate(options):
        marker = _cyan(f"  {i + 1}.") if i == default else f"  {i + 1}."
        print(f"{marker} {opt}")
    print()
    while True:
        raw = input_fn(f"Enter number [1–{len(options)}] (default {default + 1}): ").strip()
        if not raw:
            return default
        try:
            idx = int(raw) - 1
            if 0 <= idx < len(options):
                return idx
        except ValueError:
            pass
        print(_red(f"Please enter a number between 1 and {len(options)}."))


def _prompt_choice_arrow(
    title: str,
    options: list[str],
    *,
    subtitle: str | None = None,
    default: int = 0,
    write: Callable[[str], None],
) -> int:
    """TTY path: renders the menu and navigates via arrow keys."""
    selected = default
    rendered_lines = 0

    def _render() -> None:
        nonlocal rendered_lines
        if rendered_lines:
            _erase_lines(rendered_lines)
        screen = render_choice_menu(title, options, selected, subtitle=subtitle)
        lines = screen.split("\n")
        rendered_lines = len(lines)
        sys.stdout.write(screen)
        sys.stdout.flush()

    _render()
    while True:
        try:
            key = _read_key()
        except KeyboardInterrupt:
            raise

        if key == _KEY_UP:
            selected = (selected - 1) % len(options)
            _render()
        elif key == _KEY_DOWN:
            selected = (selected + 1) % len(options)
            _render()
        elif key in _KEY_ENTER:
            sys.stdout.write("\n")
            sys.stdout.flush()
            return selected
        elif key in _KEY_QUIT:
            raise KeyboardInterrupt


def prompt_string(
    label: str,
    *,
    masked: bool = False,
    default: str = "",
    placeholder: str = "",
    required: bool = True,
    _input_fn: Callable[[str], str] | None = None,
) -> str:
    """Prompt the user to enter a string value.

    Parameters
    ----------
    label:
        The prompt label.
    masked:
        If True, the input is hidden (uses :mod:`getpass`).
    default:
        Default value returned when the user presses Enter with no input.
    placeholder:
        Hint text shown in the prompt.
    required:
        If True, empty input is rejected (unless *default* is provided).
    _input_fn:
        Injected input function (for testing).

    Returns
    -------
    str
        The user-entered (or default) string.
    """
    input_fn = _input_fn

    if masked and input_fn is None:
        import getpass

        input_fn = lambda prompt="": getpass.getpass(prompt)  # noqa: E731

    if input_fn is None:
        input_fn = input

    hint = f"  [{default}]" if default else ("  " + _dim(placeholder) if placeholder else "")
    prompt_str = f"\n  {_bold(label)}{hint}: "
    while True:
        raw = input_fn(prompt_str).strip()
        value = raw or default
        if required and not value:
            print(_red("  This field is required."))
            continue
        return value


# ---------------------------------------------------------------------------
# Provider / model catalogue
# ---------------------------------------------------------------------------

# Top-level auth choices: OAuth sign-in options first, then API key fallback.
# Each entry: (display_label, auth_mode, oauth_provider_key, default_model)
_AUTH_CHOICES: list[tuple[str, str, str | None, str]] = [
    (
        "Sign in with Anthropic  (Claude)",
        "oauth",
        "anthropic",
        "anthropic/claude-sonnet-4-5-20250514",
    ),
    (
        "Sign in with OpenAI",
        "oauth",
        "openai",
        "openai/gpt-5.2",
    ),
    (
        "Sign in with Google  (Gemini)",
        "oauth",
        "google",
        "google/gemini-2.5-pro",
    ),
    (
        "Sign in with GitHub Copilot",
        "oauth",
        "copilot",
        "github_copilot/gpt-5-mini",
    ),
    (
        "Use an API key",
        "api_key",
        None,
        "",
    ),
]

# API-key provider choices (shown only when "Use an API key" is selected).
# Each entry: (display_label, default_model, provider_key)
_API_KEY_PROVIDERS: list[tuple[str, str, str]] = [
    ("Anthropic", "anthropic/claude-sonnet-4-5-20250514", "anthropic"),
    ("OpenAI", "openai/gpt-5.2", "openai"),
    ("OpenRouter  (multi-model)", "openrouter/anthropic/claude-sonnet-4-5-20250514", "openrouter"),
    ("Cerebras  (fast inference)", "cerebras/llama-4-scout-17b-16e-instruct", "cerebras"),
    ("Ollama  (local, no key needed)", "ollama/llama3", "ollama"),
    ("Other  (enter model ID manually)", "", ""),
]

_CHANNEL_OPTIONS: list[tuple[str, tuple[str, ...]]] = [
    ("CLI only  — chat in this terminal", ("cli",)),
    ("CLI + Telegram  — also available on mobile", ("cli", "telegram")),
]


# ---------------------------------------------------------------------------
# Full setup wizard
# ---------------------------------------------------------------------------


def _run_oauth_login(
    provider: str,
    write: Callable[[str], None],
) -> bool:
    """Run the OAuth browser login flow for *provider* inline.

    Returns ``True`` on success (credentials saved), ``False`` on failure.
    This performs real I/O — it opens a browser and waits for the user to
    complete the flow — so it is NOT called during testing.
    """
    import asyncio
    import importlib
    import inspect
    from contextlib import suppress

    from otto.auth import AuthStorage

    try:
        auth_module = importlib.import_module(f"otto.auth.{provider}")
    except ImportError:
        write(_red(f"\n  OAuth login for '{provider}' is not available.\n"))
        return False

    login_fn = getattr(auth_module, "login", None)
    if not callable(login_fn):
        write(_red(f"\n  Provider '{provider}' does not expose a login() function.\n"))
        return False

    def _on_auth_url(url: str, extra: str = "") -> None:
        if extra:
            write(f"  {extra}")
        write(f"\n  {_bold('Open this URL in your browser:')}")
        write(f"  {_cyan(url)}\n")
        with suppress(Exception):
            import webbrowser

            webbrowser.open(url)

    def _on_progress(message: str) -> None:
        if message:
            write(f"  {_dim(message)}")

    async def _on_prompt_code() -> str:
        import clypi

        return clypi.prompt("  Paste the authorization code").strip()

    async def _on_prompt(data: dict) -> object:
        import clypi

        prompt = str(data.get("message", "Enter value"))
        allow_empty = bool(data.get("allowEmpty", False))
        if allow_empty:
            return clypi.prompt(f"  {prompt}", default="")
        return clypi.prompt(f"  {prompt}")

    callbacks: dict[str, object] = {
        "on_auth_url": _on_auth_url,
        "on_prompt_code": _on_prompt_code,
        "on_progress": _on_progress,
        "on_prompt": _on_prompt,
    }

    try:
        sig = inspect.signature(login_fn)
        supports_kwargs = any(
            p.kind is inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()
        )
        if supports_kwargs:
            login_kwargs = callbacks
        else:
            login_kwargs = {k: v for k, v in callbacks.items() if k in sig.parameters}

        result = login_fn(**login_kwargs)
        credentials = asyncio.run(result) if inspect.isawaitable(result) else result
    except Exception as exc:
        write(_red(f"\n  Authentication failed: {exc}\n"))
        return False

    if not isinstance(credentials, dict):
        write(_red("\n  Authentication failed: invalid credentials.\n"))
        return False

    if provider == "copilot":
        storage = AuthStorage()
        if storage.get_credentials("copilot") is not None:
            write(
                _yellow("\n  Note: legacy Copilot credentials in Otto storage are now ignored.\n")
            )
        write(
            f"\n  {_green('✓')} Authenticated with {provider}. "
            "Credentials are managed by LiteLLM.\n"
        )
        return True

    storage = AuthStorage()
    storage.save_credentials(provider, credentials)
    write(f"\n  {_green('✓')} Authenticated with {provider}.\n")
    return True


def run_setup(
    *,
    _input_fn: Callable[[str], str] | None = None,
    _write_fn: Callable[[str], None] | None = None,
    _skip_oauth_login: bool = False,
) -> SetupPlan | None:
    """Run the interactive setup wizard.

    Returns the validated :class:`~otto.setup_core.models.SetupPlan` on
    success, or ``None`` if the user cancels.

    Parameters
    ----------
    _input_fn:
        Injected input callable (for testing).
    _write_fn:
        Injected write callable (for testing).
    _skip_oauth_login:
        If True, skip the real browser OAuth flow (for testing).

    The function does **not** write any files — call
    :func:`~otto.setup_core.apply.apply_plan` with the returned plan to
    persist the configuration.
    """
    write = _write_fn or _print

    try:
        # ── Welcome ──────────────────────────────────────────────────────────
        write(render_welcome())

        # ── Owner name ───────────────────────────────────────────────────────
        import getpass

        default_username = ""
        try:
            default_username = getpass.getuser()
        except Exception:
            pass

        owner_name = prompt_string(
            "Your name",
            default=default_username,
            placeholder="e.g. alice",
            _input_fn=_input_fn,
        )

        # ── Bot name ─────────────────────────────────────────────────────────
        bot_name = prompt_string(
            "Bot name",
            default="default",
            placeholder="e.g. otto",
            _input_fn=_input_fn,
        )

        # ── Authentication ───────────────────────────────────────────────────
        # Unified "Sign in with..." selector — OAuth providers first, then
        # API key fallback.
        auth_labels = [label for label, _, _, _ in _AUTH_CHOICES]
        auth_idx = prompt_choice(
            "How do you want to connect?",
            auth_labels,
            subtitle="OAuth sign-in is easiest — opens your browser.",
            _input_fn=_input_fn,
            _write_fn=_write_fn,
        )
        _, auth_mode, oauth_provider, default_model = _AUTH_CHOICES[auth_idx]

        model: str = default_model
        provider_api_key: str | None = None
        oauth_credentials_present = False

        if auth_mode == "oauth" and oauth_provider:
            # ── OAuth flow ───────────────────────────────────────────────────
            if _skip_oauth_login:
                # Testing path: assume login succeeds.
                oauth_credentials_present = True
                write(f"\n  {_green('✓')} OAuth login skipped (test mode).\n")
            else:
                write(f"\n  {_dim('Signing in with ' + oauth_provider + '…')}\n")
                success = _run_oauth_login(oauth_provider, write)
                if not success:
                    write(_yellow("\n  OAuth login failed. You can retry or use an API key.\n"))
                    return None
                oauth_credentials_present = True

            # Use the default model for this provider, but let the user
            # override if they want a different one.
            model = default_model

        else:
            # ── API key path ─────────────────────────────────────────────────
            api_provider_labels = [label for label, _, _ in _API_KEY_PROVIDERS]
            api_idx = prompt_choice(
                "Which provider?",
                api_provider_labels,
                subtitle="Pick your LLM backend.",
                _input_fn=_input_fn,
                _write_fn=_write_fn,
            )
            _, api_default_model, provider_key = _API_KEY_PROVIDERS[api_idx]
            model = api_default_model

            if provider_key == "":
                # "Other" — ask for model ID manually.
                model = prompt_string(
                    "Model ID",
                    placeholder="e.g. together_ai/meta-llama/Llama-3-70b",
                    _input_fn=_input_fn,
                )
                provider_key = model.split("/", 1)[0] if "/" in model else model

            # Ask for API key (skip for local providers like ollama).
            from otto.setup_core.engine import _provider_env_var

            env_var = _provider_env_var(provider_key)
            if env_var is not None:
                existing = os.environ.get(env_var, "")
                if existing:
                    write(f"\n  {_green('✓')} {env_var} is already set.\n")
                    provider_api_key = existing
                else:
                    provider_api_key = prompt_string(
                        f"Enter your {env_var}",
                        masked=True,
                        placeholder="sk-…",
                        _input_fn=_input_fn,
                    )

        # ── Channels ─────────────────────────────────────────────────────────
        channel_labels = [label for label, _ in _CHANNEL_OPTIONS]
        channel_idx = prompt_choice(
            "Which channels do you want?",
            channel_labels,
            subtitle="You can change this later in config.toml.",
            _input_fn=_input_fn,
            _write_fn=_write_fn,
        )
        channels = _CHANNEL_OPTIONS[channel_idx][1]

        # ── Telegram token ────────────────────────────────────────────────────
        telegram_token: str | None = None
        if "telegram" in channels:
            existing_tg = os.environ.get("TELEGRAM_BOT_TOKEN", "")
            if existing_tg:
                write(f"\n  {_green('✓')} TELEGRAM_BOT_TOKEN is already set.\n")
                telegram_token = existing_tg
            else:
                telegram_token = prompt_string(
                    "Telegram bot token",
                    masked=True,
                    placeholder="123456:ABC-DEF…  (get one from @BotFather)",
                    _input_fn=_input_fn,
                )

        # ── Workspace ─────────────────────────────────────────────────────────
        workspace_root = prompt_string(
            "Workspace directory",
            default="~/.otto/workspace",
            _input_fn=_input_fn,
        )

        # ── Build choice ──────────────────────────────────────────────────────
        choice = SetupChoice(
            model=model,
            channels=channels,
            auth_mode=auth_mode,
            provider_api_key=provider_api_key,
            telegram_token=telegram_token,
            oauth_credentials_present=oauth_credentials_present,
            owner_name=owner_name,
            bot_name=bot_name,
            workspace_root=workspace_root,
        )

        # ── Review ────────────────────────────────────────────────────────────
        write(render_review(choice))
        confirm = prompt_string(
            "Apply configuration? [Y/n]",
            default="y",
            required=False,
            _input_fn=_input_fn,
        )
        if confirm.strip().lower() in ("n", "no"):
            write(_dim("\n  Setup cancelled.\n"))
            return None

        # ── Build plan ────────────────────────────────────────────────────────
        try:
            plan = build_plan(choice)
        except SetupValidationError as exc:
            write(_red(f"\n  Setup error: {exc}\n"))
            return None

        # ── Success ───────────────────────────────────────────────────────────
        write(render_success(plan))
        return plan

    except KeyboardInterrupt:
        write(_dim("\n\n  Setup cancelled.\n"))
        return None
