"""Tests for spens.ui (rich-based terminal rendering).

Everything here must hold in BOTH render modes: the live/ANSI mode used on
interactive terminals, and the plain mode used for pipes and captured
streams (where rich emits no escape codes, keeping output byte-stable).
"""

from __future__ import annotations

import io

from spens import ui


def _plain_console():
    return ui.new_console(io.StringIO())


def _summary() -> dict:
    return {
        "session_id": "abc123",
        "command": "codex run",
        "duration_seconds": 90,
        "exit_code": 0,
        "llm": {
            "turns": 2, "calls": 2, "tool_calls": 3,
            "tokens_in": 1000, "tokens_out": 200, "total_tokens": 1200,
            "estimated_cost_usd": 1.2345,
            "models": {"claude-opus-4": {
                "calls": 2, "tokens_in": 1000, "tokens_out": 200, "cost_usd": 1.2345}},
        },
        "network": {"http_calls": 4, "request_log_entries": 9},
        "audit": {"event_count": 7},
    }


# ---------------------------------------------------------------------------
# Summary rendering
# ---------------------------------------------------------------------------


def test_render_summary_text_is_plain_and_complete() -> None:
    text = ui.render_summary_text(_summary())
    assert "Session recap" in text
    assert "session    abc123" in text
    assert "duration   1m 30s" in text
    assert "exit code  OK" in text
    assert "claude-opus-4" in text
    assert "$1.2345" in text
    # plain rendering: no escape codes ever (pipes, logs, golden tests)
    assert "\x1b" not in text


def test_render_summary_text_marks_failures() -> None:
    text = ui.render_summary_text({**_summary(), "exit_code": 3})
    assert "FAILED (3)" in text


def test_render_summary_text_is_deterministic() -> None:
    assert ui.render_summary_text(_summary()) == ui.render_summary_text(_summary())


def test_print_summary_on_a_non_terminal_console_is_plain() -> None:
    buffer = io.StringIO()
    console = ui.new_console(buffer)
    assert console.is_terminal is False
    ui.print_summary(console, _summary())
    out = buffer.getvalue()
    assert "Session recap" in out
    assert "\x1b" not in out


# ---------------------------------------------------------------------------
# BuildProgress
# ---------------------------------------------------------------------------


def test_build_progress_step_lines_replace_their_slot() -> None:
    progress = ui.BuildProgress(_plain_console(), title="img", live=False)
    progress.update("#2 [1/4] FROM node:20")
    progress.update("#2 DONE 0.1s")
    progress.update("#3 [2/4] RUN apt-get update")
    # the second #2 status replaced the first, so each step has one slot
    assert list(progress._rows) == ["step-2", "step-3"]
    assert progress._rows["step-2"] == "#2 DONE 0.1s"


def test_build_progress_non_live_echoes_lines_plainly() -> None:
    buffer = io.StringIO()
    progress = ui.BuildProgress(ui.new_console(buffer), title="img", live=False)
    progress.start()
    progress.update("#1 [internal] load build definition")
    progress.update("plain non-step line")
    progress.stop(ok=True)
    out = buffer.getvalue()
    # piped output is never swallowed: every line is echoed, plus the verdict
    assert "#1 [internal] load build definition" in out
    assert "plain non-step line" in out
    assert "built img in" in out
    assert "\x1b" not in out


def test_build_progress_live_renders_the_final_frame_and_tail() -> None:
    console = ui.new_console(io.StringIO(), force_terminal=True, width=80)
    progress = ui.BuildProgress(console, title="agent image img", live=True)
    progress.start()
    progress.update("#1 [internal] load build definition")
    progress.update("#2 [1/4] RUN apt-get update")
    progress.update("#2 DONE 1.0s")
    progress.update("ERROR: boom")
    progress.stop(ok=False)
    out = console.file.getvalue()
    # the constrained live frame: title, the last steps and a failure footer
    assert "docker build · agent image img" in out
    assert "#2 DONE 1.0s" in out
    assert "failed" in out
    # a failed build keeps its tail visible for diagnosis
    assert "last build output:" in out
    assert "ERROR: boom" in out
    assert "FAILED" in out


def test_build_progress_live_shows_only_the_last_height_lines() -> None:
    console = ui.new_console(io.StringIO(), force_terminal=True, width=80)
    progress = ui.BuildProgress(console, title="img", height=3, live=True)
    progress.start()
    for i in range(1, 9):
        progress.update(f"step {i}")
    progress.stop(ok=True)
    # each repaint starts with a carriage-return line wipe; the final
    # frame is everything after the last one
    final = console.file.getvalue().rsplit("\r\x1b[2K", 1)[-1]
    assert "step 8" in final
    assert "step 6" in final
    assert "step 5" not in final


def test_build_progress_autodetects_live_only_on_terminals() -> None:
    # a captured (non-terminal) stream must not use the live region
    assert ui.BuildProgress(_plain_console()).live is False
    # ... while a forced-terminal console (how a real tty looks to rich) does
    console = ui.new_console(io.StringIO(), force_terminal=True, width=80)
    assert ui.BuildProgress(console).live is True
