"""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 pathlib
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


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

    When running in a TTY, shows a spinner while waiting for the first
    chunk (during EB1 candidate execution), then progressively renders
    the response as formatted markdown using Rich.

    When stdout is not a TTY (piped), outputs raw text without formatting.

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

    Returns:
        The full response text.
    """
    start = time.monotonic()
    full_text: list[str] = []
    iterator = iter(chunks)

    # 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 = next(iterator, None)
    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 first_chunk is None:
        return ""

    full_text.append(first_chunk)

    # Phase 2: Stream response with progressive markdown rendering.
    if decorated:
        _stream_markdown(full_text, iterator)
    else:
        sys.stdout.write(first_chunk)
        sys.stdout.flush()
        for chunk in iterator:
            full_text.append(chunk)
            sys.stdout.write(chunk)
            sys.stdout.flush()

    return "".join(full_text)


def _stream_markdown(
    full_text: list[str],
    iterator: Iterable[str],
) -> None:
    """Progressively render streaming tokens as formatted markdown.

    Uses ``rich.Live`` to maintain a live-updating terminal region.
    The markdown is re-rendered at a fixed refresh rate (no flicker)
    as new tokens arrive. This produces Claude Code-style output with
    syntax-highlighted code blocks, bold headers, and formatted lists.
    """
    from rich.console import Console
    from rich.live import Live
    from rich.markdown import Markdown
    from rich.padding import Padding

    console = Console(highlight=False)

    with Live(
        _render_md("".join(full_text)),
        console=console,
        refresh_per_second=8,
        vertical_overflow="visible",
    ) as live:
        for chunk in iterator:
            full_text.append(chunk)
            live.update(_render_md("".join(full_text)))

    # Final clean render after stream completes (ensures complete
    # markdown structures like code fences are properly closed).


def _render_md(text: str) -> "Padding":
    """Render markdown text with left padding for visual alignment."""
    from rich.markdown import Markdown
    from rich.padding import Padding

    return 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 _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>\001\033[0m\002 ")
            except EOFError:
                sys.stdout.write("\n")
                break

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

            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,
                )
                history.append({"role": "user", "content": prompt})
                history.append(
                    {"role": "assistant", "content": response_text},
                )
            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


def _print_welcome(model: str, *, admin: bool = False) -> None:
    """Print a minimal welcome banner."""
    from rich.console import Console
    from rich.text import Text

    console = Console(highlight=False)
    banner = Text()
    banner.append(" keiro", style="bold")
    banner.append(f" ({model})", style="dim")
    if admin:
        banner.append(" admin", style="yellow")
    console.print(banner)

    hints: list[str] = []
    if admin:
        hints.append("'/admin' toggles metrics.")
    hints.append("'/clear' resets history.")
    hints.append("Ctrl-C or 'exit' to quit.")
    console.print(f" [dim]{' '.join(hints)}[/dim]")
    console.print()


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()
