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

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 prints tokens as they
    stream from the judge synthesis.

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

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

    # Phase 2: Stream response tokens.
    if first_chunk is not None:
        full_text.append(first_chunk)
        sys.stdout.write(first_chunk)
        sys.stdout.flush()

    for chunk in iterator:
        full_text.append(chunk)
        sys.stdout.write(chunk)
        sys.stdout.flush()

    if decorated:
        sys.stdout.write("\n")
        sys.stdout.flush()

    return "".join(full_text)


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

    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("\033[1m>\033[0m ")
            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."""
    suffix = " \033[33madmin\033[0m" if admin else ""
    sys.stdout.write(f"\033[1mkeiro\033[0m \033[2m({model})\033[0m{suffix}\n")
    admin_hint = "'/admin' toggles metrics. " if admin else ""
    sys.stdout.write(
        f"\033[2m{admin_hint}'/clear' resets history."
        f" Ctrl-C or 'exit' to quit.\033[0m\n\n",
    )
    sys.stdout.flush()


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)


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 = [p for p in (metrics.provider, metrics.provider_model) if p]
        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:.3f}")
        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,
        )
        ranking_str = "  ".join(
            f"{name} {prob:.1%}" for name, prob in ranked
        )
        lines.append(f"  ranking     {ranking_str}")

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