"""Terminal rendering helpers built on rich.

The only module that knows about rich; everything user-facing that wants
pretty output (the tty sink, the docker build stream, the session recap)
goes through here so the rest of spens stays renderer-agnostic:

- :func:`new_console` -- a ``rich.console.Console`` bound to a stream with
  markup/highlight disabled (spens lines are rendered verbatim);
- :class:`BuildProgress` -- a fixed-height live panel that keeps docker
  build output constrained to a few lines instead of flooding the terminal.
  Docker's buildx "plain" progress re-emits a step's ``#N ...`` line as the
  step advances, so those updates *replace* their slot in the panel rather
  than scrolling; on non-terminal streams the region degrades to echoing
  each line plainly (output is never swallowed);
- :func:`print_summary` / :func:`render_summary_text` -- the session-recap
  panel rendered from the machine-readable ``summary`` dict carried by the
  ``finished`` event.
"""

from __future__ import annotations

import io
import re
import sys
import time
from collections import OrderedDict
from collections import deque as _deque
from typing import IO, Any

from rich import box
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.rule import Rule
from rich.table import Table
from rich.text import Text

#: Match a docker buildx "plain" progress step line: ``#12 [4/9] RUN ...``.
_STEP_RE = re.compile(r"^#(\d+)\b")

#: How many build lines the live panel shows at once.
BUILD_PANEL_HEIGHT = 6

#: Fixed width for the recap panel when rendering to a non-terminal stream
#: (tests, logs) so the plain-text output stays deterministic.
SUMMARY_WIDTH = 80


def new_console(stream: IO[str] | None = None, **kwargs: Any) -> Console:
    """A Console for ``stream`` (default stdout) with markup disabled.

    ``markup=False`` matters: spens messages contain literal ``[spens]``
    brackets, which rich would otherwise parse as style tags.
    ``highlight=False`` keeps values (numbers, paths) from being
    recolored, and ``emoji=False`` keeps text byte-stable.
    """
    file = stream if stream is not None else sys.stdout
    _ensure_unicode_stream(file)
    return Console(
        file=file,
        markup=False,
        highlight=False,
        emoji=False,
        **kwargs,
    )


def _ensure_unicode_stream(stream: IO[str] | None) -> None:
    """Best-effort switch a text stream to UTF-8 with lossy fallback.

    On Windows the default console encoding is a legacy code page (e.g.
    ``cp1252``) that can't represent the Unicode box-drawing glyphs rich
    uses for rules and panels, so writing the session recap raises
    ``UnicodeEncodeError``.  Reconfiguring to UTF-8 with ``errors="replace"``
    keeps output rendering instead of crashing; on POSIX (already UTF-8)
    this is a no-op, and streams without ``reconfigure`` (StringIO in tests)
    are left untouched.
    """
    reconfigure = getattr(stream, "reconfigure", None)
    if reconfigure is None:
        return
    encoding = (getattr(stream, "encoding", "") or "").lower()
    try:
        if encoding not in ("utf-8", "utf8"):
            reconfigure(encoding="utf-8", errors="replace")
        else:
            reconfigure(errors="replace")
    except (ValueError, OSError):
        # Detached or non-reconfigurable stream -- fall back to rich's
        # own legacy handling rather than failing here.
        pass


# ---------------------------------------------------------------------------
# Build progress panel
# ---------------------------------------------------------------------------


class BuildProgress:
    """Constrain docker build output to a fixed-height live panel.

    Feed it lines via :meth:`update`; between :meth:`start` and
    :meth:`stop` the panel redraws itself in place (rich ``Live``).  The
    final frame is deliberately left on screen (``transient=False``): the
    last steps of a build are exactly what you want in scrollback when
    diagnosing the next one.  On failure the recent build tail is printed
    persistently below the panel so the actual error stays visible.
    """

    def __init__(
        self,
        console: Console | None = None,
        *,
        title: str = "",
        height: int = BUILD_PANEL_HEIGHT,
        live: bool | None = None,
    ) -> None:
        self.console = console if console is not None else new_console()
        self.title = title
        self.height = max(2, height)
        #: ``None`` autodetects (live only on interactive terminals); tests
        #: force it explicitly.
        self.live = (
            self.console.is_terminal and not self.console.is_dumb_terminal
            if live is None
            else bool(live)
        )
        self._rows: OrderedDict[str, str] = OrderedDict()
        self._anon = 0
        self._tail: _deque[str] = _deque(maxlen=30)
        self._live: Live | None = None
        self._started: float | None = None

    # -- lifecycle -----------------------------------------------------------

    def start(self) -> None:
        if self._started is not None:
            return
        self._started = time.monotonic()
        if self.live:
            # Manual refresh (no auto_refresh thread): every fed line is a
            # natural refresh point, and no background thread can outlive a
            # failed build.
            self._live = Live(
                console=self.console,
                auto_refresh=False,
                transient=False,
                screen=False,
            )
            self._live.start()

    def stop(self, ok: bool = True) -> None:
        """Freeze the panel with a completion line; on failure dump the tail."""
        if self._started is None:
            return
        elapsed = time.monotonic() - self._started
        self._started = None
        if self._live is not None:
            self._live.update(self._render(status_ok=ok, elapsed=elapsed))
            self._live.stop()
            self._live = None
        verdict = Text(
            f"built {self.title} in {elapsed:.1f}s" if ok
            else f"build of {self.title} FAILED",
            style="green" if ok else "bold red",
        )
        self.console.print(verdict)
        if not ok:
            self._dump_tail()

    def __enter__(self) -> BuildProgress:
        self.start()
        return self

    def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
        self.stop(ok=exc_type is None)
        return False

    # -- feeding -------------------------------------------------------------

    def update(self, line: str) -> None:
        """Consume one line of docker build output."""
        line = (line or "").rstrip("\r\n")
        if not line.strip():
            return
        self._tail.append(line)
        match = _STEP_RE.match(line)
        if match:
            # A buildx step status line replaces the previous line for the
            # same step (that is how docker itself renders plain progress).
            key = f"step-{match.group(1)}"
        else:
            self._anon += 1
            key = f"line-{self._anon}"
        self._rows[key] = line
        if self._live is not None:
            self._live.update(self._render())
            self._live.refresh()
        elif self._started is not None:
            # No live region (piped output): echo plainly, never swallow.
            self.console.out(line)

    # -- rendering -----------------------------------------------------------

    def _render(self, status_ok: bool | None = None, elapsed: float | None = None) -> Panel:
        rows = list(self._rows.values())[-self.height :]
        while len(rows) < self.height:
            rows.insert(0, "")
        body = Text("\n".join(rows), no_wrap=True, overflow="crop", style="dim")
        title = f"docker build · {self.title}" if self.title else "docker build"
        if status_ok is None:
            subtitle = None
        else:
            mark = "✓ built" if status_ok else "✗ failed"
            style = "green" if status_ok else "red"
            subtitle = Text(f" {mark} in {elapsed:.1f}s ", style=style)
        return Panel(
            body,
            title=f" {title} ",
            subtitle=subtitle,
            box=box.ROUNDED,
            border_style="cyan" if status_ok is None else ("green" if status_ok else "red"),
            height=self.height + 2,
        )

    def _dump_tail(self) -> None:
        """Print the last build lines persistently below a failed build."""
        self.console.print(Text("last build output:", style="bold red"))
        for line in list(self._tail)[-12:]:
            self.console.out(line)


# ---------------------------------------------------------------------------
# Session recap panel
# ---------------------------------------------------------------------------


def _fmt_duration(seconds: Any) -> str:
    try:
        seconds_f = float(seconds)
    except (TypeError, ValueError):
        return ""
    if seconds_f < 0:
        return ""
    total = int(seconds_f)
    h, rem = divmod(total, 3600)
    m, s = divmod(rem, 60)
    if h:
        return f"{h}h {m}m {s}s"
    if m:
        return f"{m}m {s}s"
    return f"{total}s"


def _fmt_exit(code: Any) -> Text:
    if code is None:
        return Text("")
    if code == 0:
        return Text("OK", style="bold green")
    return Text(f"FAILED ({code})", style="bold red")


def _fmt_int(value: Any) -> str:
    try:
        return f"{int(value):,}"
    except (TypeError, ValueError):
        return "0"


def _summary_grid(summary: dict[str, Any]) -> Table:
    """The recap body: a two-column key/value grid plus a model breakdown."""
    grid = Table.grid(padding=(0, 2))
    grid.add_column(style="bold", no_wrap=True)
    grid.add_column()

    def row(label: str, value: Any, style: str | None = None) -> None:
        if value is None:
            return
        text = str(value)
        if not text:
            return
        grid.add_row(label, Text(text, style=style) if style else text)

    row("session", summary.get("session_id", "unknown"))
    row("command", summary.get("command", ""))
    started, ended = summary.get("started", ""), summary.get("ended", "")
    if started and ended:
        row("started", started)
        row("ended", ended)
    row("duration", _fmt_duration(summary.get("duration_seconds")))
    exit_text = _fmt_exit(summary.get("exit_code"))
    if exit_text.plain:
        grid.add_row("exit code", exit_text)

    llm = summary.get("llm") or {}
    grid.add_row("llm", f"{llm.get('turns', 0)} turns · {llm.get('calls', 0)} calls · "
                        f"{llm.get('tool_calls', 0)} tool calls")
    tokens = f"{_fmt_int(llm.get('tokens_in'))} in · {_fmt_int(llm.get('tokens_out'))} out"
    if llm.get("total_tokens"):
        tokens += f" · {_fmt_int(llm.get('total_tokens'))} total"
    row("tokens", tokens)
    cache_read, cache_write = llm.get("cache_read") or 0, llm.get("cache_write") or 0
    if cache_read or cache_write:
        row("cache", f"{_fmt_int(cache_read)} read · {_fmt_int(cache_write)} write")
    cost = llm.get("estimated_cost_usd") or 0.0
    if cost:
        row("cost", f"${cost:.4f} (est.)", style="green")

    models = llm.get("models") or {}
    if models:
        models_grid = Table.grid(padding=(0, 2))
        models_grid.add_column(no_wrap=True)
        models_grid.add_column(justify="right", no_wrap=True)
        models_grid.add_column(justify="right", no_wrap=True)
        models_grid.add_column(justify="right", no_wrap=True)
        models_grid.add_column(justify="right", no_wrap=True)
        for name, stats in sorted(models.items(), key=lambda x: -x[1].get("calls", 0)):
            cells: list[Any] = [
                str(name),
                f"{stats.get('calls', 0)} calls",
                f"{_fmt_int(stats.get('tokens_in'))} in",
                f"{_fmt_int(stats.get('tokens_out'))} out",
            ]
            model_cost = stats.get("cost_usd", 0.0)
            cells.append(f"${model_cost:.4f}" if model_cost else "")
            models_grid.add_row(*cells)
        grid.add_row("models", models_grid)

    net = summary.get("network") or {}
    row("network", f"{net.get('http_calls', 0)} http calls · "
                   f"{net.get('request_log_entries', 0)} request-log entries")

    files = summary.get("files") or {}
    if files.get("available"):
        row("files", f"{files.get('total', 0)} changes · {files.get('created', 0)} created · "
                     f"{files.get('modified', 0)} modified · {files.get('deleted', 0)} deleted")
        if files.get("workspace"):
            row("", f"{files.get('workspace', 0)} under /workspace")

    audit = summary.get("audit") or {}
    row("audit", f"{audit.get('event_count', 0)} events")
    return grid


def print_summary(console: Console, summary: dict[str, Any]) -> None:
    """Render the session-recap panel onto ``console``."""
    console.print()
    console.print(Rule(Text("Session recap", style="bold"), style="dim"))
    console.print(_summary_grid(summary))
    console.print(Rule(style="dim"))


def render_summary_text(summary: dict[str, Any]) -> str:
    """The recap panel as plain text (ANSI-free, fixed width).

    Used for non-terminal rendering (``summarizer.format_summary_for_cli``)
    and pinned by unit tests; rich emits no escape codes when the console
    is not a terminal, so the output is deterministic.
    """
    buffer = io.StringIO()
    console = Console(
        file=buffer,
        width=SUMMARY_WIDTH,
        markup=False,
        highlight=False,
        emoji=False,
        no_color=True,
    )
    print_summary(console, summary)
    return buffer.getvalue().strip("\n")
