"""Interactive REPL and one-shot mode for the Keiro CLI.

Handles three invocation patterns:

- ``keiro`` — interactive streaming chat REPL
- ``keiro "prompt"`` — one-shot: print response, exit
- ``echo ctx | keiro "prompt"`` — pipe stdin as context

When stdout is not a TTY (piped to another program), output is raw text
with no decorations or colors.
"""

from __future__ import annotations

import os
import pathlib
import re
import sys
import threading
import time
from collections.abc import Iterable
from typing import TYPE_CHECKING

from keiro.cli.animations import _SPINNER_FRAMES, _shimmer_ansi, animations_enabled
from keiro.client import KeirError

_HISTORY_PATH = pathlib.Path.home() / ".keiro" / "history"
_HISTORY_MAX_LINES = 500

if TYPE_CHECKING:
    from keiro.model_api import AdminMetrics, ModelsAPI

# Built-in tools enabled by default in the CLI.  Uses canonical type
# names; each provider translates to its native equivalent (e.g.,
# Anthropic → web_search_20250305, Google → google_search).
_DEFAULT_TOOLS: list[dict[str, str]] = [{"type": "web_search"}]


def _report_keir_error(exc: KeirError) -> None:
    """Write a formatted KeirError to stderr with actionable guidance."""
    msg = exc.user_message
    if exc.status_code == 401:
        sys.stderr.write(f"\033[31m{msg}\033[0m\n")
    elif exc.status_code == 429:
        sys.stderr.write(f"\033[33m{msg}\033[0m\n")
    else:
        sys.stderr.write(f"error: {msg}\n")


def _is_tty_out() -> bool:
    return sys.stdout.isatty()


def _is_tty_in() -> bool:
    return sys.stdin.isatty()


def _read_stdin_context() -> str | None:
    """Read piped stdin content. Returns None if stdin is a TTY."""
    if _is_tty_in():
        return None
    try:
        return sys.stdin.read()
    except (OSError, UnicodeDecodeError):
        return None


def _spinner_loop(stop: threading.Event, start: float) -> None:
    """Write shimmer-animated spinner frames to stdout until *stop* is set.

    Combines a braille spinner character, accumulating dots, a shimmer
    highlight sweep across the label, and an elapsed counter after 2 s.
    """
    frame = 0
    dot_cycle = 0.6  # seconds per full dot cycle (1 → 2 → 3 → 1)
    shimmer_cycle = 2.0  # seconds per full shimmer sweep
    while not stop.wait(0.06):
        char = _SPINNER_FRAMES[frame % len(_SPINNER_FRAMES)]
        elapsed = time.monotonic() - start

        # Accumulating dots: Thinking. → Thinking.. → Thinking...
        dot_count = int((elapsed % dot_cycle) / dot_cycle * 3) + 1
        dots = "." * dot_count + " " * (3 - dot_count)
        label = f"Thinking{dots}"

        if elapsed > 2.0:
            label += f" ({elapsed:.0f}s)"

        shimmer_phase = (elapsed % shimmer_cycle) / shimmer_cycle
        styled = _shimmer_ansi(label, shimmer_phase)

        sys.stdout.write(f"\r\033[2m{char}\033[0m {styled}\033[K")
        sys.stdout.flush()
        frame += 1


_STREAM_SENTINEL = object()


def _stream_in_background(
    iterator: Iterable[str],
    q: "queue.Queue[str | object]",
) -> None:
    """Consume a stream iterator, pushing chunks to a queue.

    Runs in a daemon thread so the main thread stays responsive to
    signals (Ctrl-C). Pushes ``_STREAM_SENTINEL`` when the stream
    is exhausted or an error occurs.
    """
    try:
        for chunk in iterator:
            q.put(chunk)
    except Exception as exc:
        q.put(exc)
    finally:
        q.put(_STREAM_SENTINEL)


def _stream_response(
    chunks: Iterable[str],
    *,
    decorated: bool,
) -> str:
    """Consume a streaming response, printing chunks as they arrive.

    Stream consumption runs in a daemon thread so Ctrl-C is always
    responsive — even during the EB1 thinking phase when the server
    sends no data for 5-30 seconds.

    When running in a TTY, streams raw tokens for real-time feedback,
    then erases the raw output and re-renders as formatted markdown.
    When stdout is not a TTY (piped), outputs raw text.

    Args:
        chunks: Iterator of text chunks from the API.
        decorated: If True, show spinner and render markdown.

    Returns:
        The full response text.
    """
    import queue

    start = time.monotonic()
    full_text: list[str] = []
    q: queue.Queue[str | object] = queue.Queue()

    # Launch stream consumer in a daemon thread so the main thread
    # remains interruptible by Ctrl-C at all times.
    stream_thread = threading.Thread(
        target=_stream_in_background,
        args=(chunks, q),
        daemon=True,
        name="keiro-stream",
    )
    stream_thread.start()

    # Phase 1: Wait for first chunk with optional spinner.
    show_spinner = decorated and animations_enabled()
    stop: threading.Event | None = None
    spinner_thread: threading.Thread | None = None

    if show_spinner:
        stop = threading.Event()
        spinner_thread = threading.Thread(
            target=_spinner_loop,
            args=(stop, start),
            daemon=True,
            name="keiro-thinking",
        )
        spinner_thread.start()

    try:
        first_chunk = _queue_get_interruptible(q)
    finally:
        if stop is not None:
            stop.set()
        if spinner_thread is not None:
            spinner_thread.join(timeout=1.0)
        if show_spinner:
            sys.stdout.write("\r\033[K")
            sys.stdout.flush()

    if isinstance(first_chunk, Exception):
        raise first_chunk
    if first_chunk is _STREAM_SENTINEL or first_chunk is None:
        return ""

    # Phase 2: Stream raw tokens for immediate feedback.
    full_text.append(first_chunk)
    sys.stdout.write(first_chunk)
    sys.stdout.flush()

    while True:
        item = _queue_get_interruptible(q)
        if item is _STREAM_SENTINEL:
            break
        if isinstance(item, Exception):
            raise item
        full_text.append(item)
        sys.stdout.write(item)
        sys.stdout.flush()

    response_text = "".join(full_text)

    # Phase 3: Re-render as formatted markdown (TTY only).
    if decorated and _has_markdown(response_text):
        _replace_with_markdown(response_text)
    elif decorated:
        sys.stdout.write("\n")
        sys.stdout.flush()

    return response_text


def _queue_get_interruptible(
    q: "queue.Queue[str | object]",
    poll_interval: float = 0.1,
) -> "str | object":
    """Pull from a queue with short polling so Ctrl-C is responsive.

    ``queue.Queue.get(timeout=None)`` blocks in C code and defers
    ``KeyboardInterrupt``. Polling with a short timeout ensures the
    main thread checks for signals every 100ms.
    """
    import queue

    while True:
        try:
            return q.get(timeout=poll_interval)
        except queue.Empty:
            continue


def _has_markdown(text: str) -> bool:
    """Detect whether text contains markdown that benefits from rendering.

    Returns True if the text has headers, code fences, bold, or lists.
    Plain conversational text is left as-is.
    """
    for line in text.split("\n"):
        stripped = line.lstrip()
        if stripped.startswith(("# ", "## ", "### ", "#### ")):
            return True
        if stripped.startswith("```"):
            return True
        if stripped.startswith(("- ", "* ", "1. ")):
            return True
    if "**" in text:
        return True
    return False


_ANSI_ESCAPE_RE = re.compile(r"\033\[[0-9;]*[A-Za-z]")


def _visible_len(text: str) -> int:
    """Length of text excluding ANSI escape sequences."""
    return len(_ANSI_ESCAPE_RE.sub("", text))


def _count_terminal_lines(text: str) -> int:
    """Count how many terminal lines *text* occupies, including wraps."""
    try:
        cols = os.get_terminal_size().columns
    except (OSError, ValueError):
        cols = 80
    total = 0
    for line in text.split("\n"):
        # Each line occupies at least 1 row; long lines wrap.
        total += max(1, (_visible_len(line) + cols - 1) // cols)
    return total


def _replace_with_markdown(text: str) -> None:
    """Erase the raw streamed text and re-render as formatted markdown.

    Moves the cursor up to the start of the raw output, clears to end
    of screen, then prints the Rich-rendered markdown in its place.
    """
    from rich.console import Console
    from rich.markdown import Markdown
    from rich.padding import Padding

    # Erase the raw output by moving cursor up and clearing.
    num_lines = _count_terminal_lines(text)
    sys.stdout.write(f"\033[{num_lines}A\033[J")
    sys.stdout.flush()

    console = Console(highlight=False)
    console.print(Padding(Markdown(text), (0, 0, 0, 1)))


def one_shot(
    prompt: str,
    *,
    model: str,
    context: str | None = None,
    admin_token: str | None = None,
) -> int:
    """Run a single prompt, stream the response, and exit.

    Args:
        prompt: User prompt text.
        model: Model identifier.
        context: Optional piped context to prepend as a system message.
        admin_token: Admin token for server-side metrics. When set,
            an admin metrics panel is printed to stderr after the response.

    Returns:
        Exit code.
    """
    from keiro.model_api import ModelsAPI

    decorated = _is_tty_out()
    api = ModelsAPI(admin_token=admin_token)

    try:
        kwargs: dict[str, object] = {"tools": _DEFAULT_TOOLS}
        if context:
            kwargs["instructions"] = context

        messages = [{"role": "user", "content": prompt}]
        _stream_with_admin(
            api, model, messages, decorated=decorated,
            admin=bool(admin_token), **kwargs,
        )

        if not decorated:
            # Ensure trailing newline for piped output.
            sys.stdout.write("\n")
            sys.stdout.flush()
    except KeyboardInterrupt:
        sys.stdout.write("\n")
        return 130
    except KeirError as exc:
        _report_keir_error(exc)
        return 1
    except Exception as exc:
        sys.stderr.write(f"error: {exc}\n")
        return 1
    finally:
        api.close()

    return 0


def _read_fenced_block(opening_line: str) -> str:
    """Read lines until a closing ``` fence, returning the full block.

    Supports pasting a fenced code block: the opening ``` triggers
    multi-line accumulation, and the closing ``` terminates it.
    Any text after the opening ``` (e.g., a language tag) is preserved.
    """
    lines = [opening_line]
    while True:
        try:
            line = input("\001\033[2m\002...\001\033[0m\002 ")
        except EOFError:
            break
        lines.append(line)
        if line.strip() == "```":
            break
    return "\n".join(lines)


def _drain_pasted_lines() -> list[str]:
    """Read any lines already buffered in stdin from a paste operation.

    Uses ``select`` to check if data is immediately available (within
    50ms). This distinguishes pasted content (instant) from a human
    pressing Enter then typing (>50ms gap). Returns an empty list if
    stdin is not a TTY or no data is pending.
    """
    if not _is_tty_in():
        return []
    try:
        import select
    except ImportError:
        return []

    lines: list[str] = []
    while True:
        ready, _, _ = select.select([sys.stdin], [], [], 0.05)
        if not ready:
            break
        try:
            line = sys.stdin.readline()
        except (OSError, UnicodeDecodeError):
            break
        if not line:  # EOF
            break
        lines.append(line.rstrip("\n"))
    return lines


def _init_readline() -> None:
    """Enable readline for up-arrow history and persistent history file.

    Loads previous session history from ``~/.keiro/history`` and registers
    an atexit hook to save it back on exit.
    """
    try:
        import readline  # noqa: F811
    except ImportError:
        return  # Windows without pyreadline; graceful degradation.

    import atexit

    # Load history from prior sessions.
    try:
        readline.read_history_file(str(_HISTORY_PATH))
    except (FileNotFoundError, OSError):
        pass

    readline.set_history_length(_HISTORY_MAX_LINES)

    def _save() -> None:
        try:
            _HISTORY_PATH.parent.mkdir(parents=True, exist_ok=True)
            readline.write_history_file(str(_HISTORY_PATH))
        except OSError:
            pass

    atexit.register(_save)


def repl(*, model: str, admin_token: str | None = None) -> int:
    """Run an interactive streaming chat REPL.

    Args:
        model: Default model identifier.
        admin_token: Admin token for server-side metrics. ``/admin``
            toggles the metrics panel during the session.

    Returns:
        Exit code.
    """
    from keiro.model_api import ModelsAPI

    _init_readline()
    api = ModelsAPI(admin_token=admin_token)
    admin_active = admin_token is not None

    _print_welcome(model, admin=admin_active)
    history: list[dict[str, str]] = []

    try:
        while True:
            try:
                prompt = input("\001\033[1m\002\u276f\001\033[0m\002 ")
            except EOFError:
                sys.stdout.write("\n")
                break

            prompt = prompt.strip()
            if not prompt:
                continue

            # Detect multi-line input: explicit ``` fencing or pasted lines.
            if prompt.startswith("```"):
                prompt = _read_fenced_block(prompt)
            else:
                extra = _drain_pasted_lines()
                if extra:
                    prompt = prompt + "\n" + "\n".join(extra)

            command = prompt.lower()

            if command in ("exit", "quit", "/q"):
                break

            if command == "/clear":
                history.clear()
                sys.stderr.write(
                    "\033[2mConversation cleared.\033[0m\n\n",
                )
                sys.stderr.flush()
                continue

            if command == "/admin":
                admin_active = not admin_active
                state = "on" if admin_active else "off"
                sys.stderr.write(
                    f"\033[2mAdmin panel {state}\033[0m\n\n",
                )
                sys.stderr.flush()
                continue

            messages = history + [{"role": "user", "content": prompt}]
            sys.stdout.write("\n")
            try:
                response_text = _stream_with_admin(
                    api, model, messages, decorated=True,
                    admin=admin_active, tools=_DEFAULT_TOOLS,
                )
                if response_text:
                    history.append({"role": "user", "content": prompt})
                    history.append(
                        {"role": "assistant", "content": response_text},
                    )
            except KeyboardInterrupt:
                # Ctrl-C during streaming: cancel and return to prompt.
                sys.stdout.write("\n")
                sys.stderr.write(
                    "\033[2mInterrupted.\033[0m\n",
                )
                sys.stderr.flush()
            except KeirError as exc:
                _report_keir_error(exc)
            except Exception as exc:
                sys.stderr.write(f"error: {exc}\n")
            sys.stdout.write("\n")

    except KeyboardInterrupt:
        sys.stdout.write("\n")
    finally:
        api.close()

    return 0


# ---------------------------------------------------------------------------
# Startup banner
# ---------------------------------------------------------------------------

# Unicode block-character logo — rounded filled rectangle (2 lines).
_LOGO = (
    " \u2590\u259b\u2588\u2588\u259c\u258c ",  # ▐▛██▜▌
    " \u2590\u2599\u2588\u2588\u259f\u258c ",  # ▐▙██▟▌
)

_MODEL_HINTS: dict[str, str] = {
    "eb1-preview": "adaptive frontier ensemble",
    "eb1-delta-preview": "adaptive ensemble + orchestration",
    "eb1": "standard ensemble",
    "eb1-pro": "extended capability ensemble",
    "eb1-frontier": "highest quality, max reasoning",
    "eb1-codex": "code-optimized ensemble",
    "eb1-codex-high": "code-optimized, max reasoning",
    "eb1-fast": "low latency ensemble",
    "eb1-fast-preview": "adaptive low latency",
    "eb1-frontier-preview": "adaptive max quality",
    "eb1-swe": "SWE-bench optimized",
    "eb1-swe-pro": "SWE-bench extended",
}


def _model_description(model: str) -> str:
    """Short human-readable description for a model identifier."""
    desc = _MODEL_HINTS.get(model)
    if desc:
        return desc
    if model.endswith("-preview"):
        return "adaptive ensemble"
    if not model.startswith("eb1"):
        return "direct model"
    return "ensemble"


def _print_welcome(model: str, *, admin: bool = False) -> None:
    """Print a branded startup banner with logo, version, and model info."""
    import keiro
    from rich.console import Console
    from rich.text import Text

    console = Console(highlight=False)

    try:
        term_width = os.get_terminal_size().columns
    except (OSError, ValueError):
        term_width = 80

    desc = _model_description(model)

    # Line 1: logo + product name + version.
    line1 = Text()
    line1.append(_LOGO[0], style="bold")
    line1.append("  keiro", style="bold")
    line1.append(f" v{keiro.__version__}", style="dim")

    # Line 2: logo + model + description.
    line2 = Text()
    line2.append(_LOGO[1], style="bold")
    line2.append(f"  {model}", style="bold cyan")
    line2.append(f" \u00b7 {desc}", style="dim")
    if admin:
        line2.append(" \u00b7 admin", style="yellow")

    console.print()
    console.print(line1)
    console.print(line2)
    console.print()

    # Separator with keyboard hints (right-aligned, Claude Code style).
    hints = "/clear \u00b7 /admin \u00b7 ``` multi-line \u00b7 Ctrl-C"
    pad = term_width - len(hints) - 3
    if pad > 4:
        sep = "\u2500" * pad + " " + hints + " \u2500"
    else:
        sep = "\u2500" * term_width
    console.print(f"[dim]{sep}[/dim]")


def _format_duration(ms: float) -> str:
    """Format milliseconds as a compact human-readable duration."""
    if ms < 1000:
        return f"{ms:.0f}ms"
    return f"{ms / 1000:.1f}s"


def _stream_with_admin(
    api: ModelsAPI,
    model: str,
    messages: list[dict[str, str]],
    *,
    decorated: bool,
    admin: bool,
    **stream_kwargs: object,
) -> str:
    """Stream a response, optionally rendering the admin metrics panel.

    Args:
        api: Models API client.
        model: Model identifier.
        messages: Conversation history as a list of role/content dicts.
        decorated: If True, show spinner and formatting.
        admin: If True, render admin metrics panel after the response.
        **stream_kwargs: Extra kwargs forwarded to the API (tools, etc.).

    Returns:
        The full response text.
    """
    if admin:
        stream = api.responses_stream_admin(
            model, messages=messages, **stream_kwargs,
        )
        text = _stream_response(stream, decorated=decorated)
        _render_admin_panel(stream.metrics)
        return text
    else:
        chunks = api.responses_stream(
            model, messages=messages, **stream_kwargs,
        )
        return _stream_response(chunks, decorated=decorated)


_BAR_BLOCKS = " ▏▎▍▌▋▊▉█"
_BAR_MAX_WIDTH = 20
_RANKING_LABEL = "  ranking"


def _render_ranking_bars(
    lines: list[str],
    ranked: list[tuple[str, float]],
) -> None:
    """Append a multi-line bar chart for GNN model ranking.

    Uses Unicode block characters for proportional bars. Bar width is
    relative to the top model's probability so the leader always fills
    the full bar width.
    """
    if not ranked:
        return
    max_name = max(len(name) for name, _ in ranked)
    top_prob = ranked[0][1]
    blank_label = " " * len(_RANKING_LABEL)
    for i, (name, prob) in enumerate(ranked):
        ratio = prob / top_prob if top_prob > 0 else 0
        full_width = ratio * _BAR_MAX_WIDTH
        full_blocks = int(full_width)
        frac = full_width - full_blocks
        partial = _BAR_BLOCKS[int(frac * 8)]
        bar = "█" * full_blocks + (partial if partial != " " else "")
        label = _RANKING_LABEL if i == 0 else blank_label
        lines.append(
            f"  {label}  {name:<{max_name}}  {bar:<{_BAR_MAX_WIDTH}}  "
            f"{prob:5.1%}",
        )


def _render_admin_panel(metrics: AdminMetrics) -> None:
    """Render a compact admin metrics panel to stderr."""
    lines: list[str] = []

    if metrics.provider or metrics.provider_model:
        parts: list[str] = []
        for p in (metrics.provider, metrics.provider_model):
            if p:
                clean = p.removeprefix("takumi/")
                if not parts or parts[-1] != clean:
                    parts.append(clean)
        lines.append(f"  provider    {' -> '.join(parts)}")

    if metrics.candidates:
        lines.append(f"  candidates  {', '.join(metrics.candidates)}")

    if metrics.candidates_attempted is not None:
        succeeded = metrics.candidates_succeeded or 0
        ens = f"{succeeded}/{metrics.candidates_attempted} succeeded"
        if metrics.judge_model:
            ens += f" · judge: {metrics.judge_model}"
        lines.append(f"  ensemble    {ens}")
    elif metrics.judge_model:
        lines.append(f"  judge       {metrics.judge_model}")

    if metrics.primary_source_model:
        lines.append(f"  source      {metrics.primary_source_model}")

    if metrics.gnn_tier:
        gnn_parts = [f"tier: {metrics.gnn_tier}"]
        if metrics.gnn_confidence is not None:
            gnn_parts.append(f"conf: {metrics.gnn_confidence:.1%}")
        if metrics.gnn_logit_margin is not None:
            gnn_parts.append(f"margin: {metrics.gnn_logit_margin:.3f}")
        if metrics.gnn_route_time_ms is not None:
            gnn_parts.append(
                f"route: {_format_duration(metrics.gnn_route_time_ms)}",
            )
        lines.append(f"  gnn         {' · '.join(gnn_parts)}")

    if metrics.gnn_distribution:
        ranked = sorted(
            metrics.gnn_distribution.items(),
            key=lambda kv: kv[1],
            reverse=True,
        )
        _render_ranking_bars(lines, ranked)

    if metrics.total_tokens > 0:
        tok = (
            f"{metrics.prompt_tokens:,} in · "
            f"{metrics.completion_tokens:,} out = "
            f"{metrics.total_tokens:,} total"
        )
        lines.append(f"  tokens      {tok}")

    if metrics.cost_usd is not None:
        lines.append(f"  cost        ${metrics.cost_usd:.4f}")

    lat_parts: list[str] = []
    if metrics.ttfb_ms is not None:
        lat_parts.append(f"ttfb {_format_duration(metrics.ttfb_ms)}")
    if metrics.total_ms is not None:
        lat_parts.append(f"total {_format_duration(metrics.total_ms)}")
    if lat_parts:
        lines.append(f"  latency     {' · '.join(lat_parts)}")

    if metrics.timings_ms:
        steps = [
            f"{k} {_format_duration(v)}"
            for k, v in metrics.timings_ms.items()
            if k != "total_ms"
        ]
        if steps:
            lines.append(f"  pipeline    {' -> '.join(steps)}")

    if not lines:
        return

    bar_width = max(max(len(ln) for ln in lines) + 2, 40)
    sys.stderr.write(
        f"\n\033[2m{'─' * 3} admin {'─' * (bar_width - 10)}\033[0m\n",
    )
    for line in lines:
        sys.stderr.write(f"\033[2m{line}\033[0m\n")
    sys.stderr.write(f"\033[2m{'─' * bar_width}\033[0m\n")
    sys.stderr.flush()
