package repl

// Tests for the line-based REPL (#106): slash-command dispatch (action prints
// message and does NOT run; prompt runs; unknown command errors and does NOT
// run; /exit and EOF exit cleanly), multi-turn history accumulation, and
// streaming assistant text. The REPL is driven with a fake provider so no
// network is involved — the whole read → run → stream-print loop runs over an
// in-memory input reader and output buffer.

import (
	"bytes"
	"context"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/smallnest/pigo/internal/agentcore"
	"github.com/smallnest/pigo/internal/agenttool"
	"github.com/smallnest/pigo/internal/cli"
	"github.com/smallnest/pigo/internal/cli/prompts"
	"github.com/smallnest/pigo/internal/cli/ui"
	"github.com/smallnest/pigo/internal/provider"
	"github.com/smallnest/pigo/internal/runtime"
	"github.com/smallnest/pigo/internal/session"
)

// replProvider is a minimal Provider that streams one scripted text turn per
// StreamCompletion call and records how many times it was called, so a test can
// assert whether a run was launched.
type replProvider struct {
	reply string
	calls int
}

func (p *replProvider) Name() string { return "faux" }
func (p *replProvider) Models() []provider.Model {
	return []provider.Model{{Provider: "faux", ID: "faux"}}
}

func (p *replProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
	p.calls++
	partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
	withText := partial
	withText.Content = agentcore.ContentList{agentcore.NewTextContent(p.reply)}
	final := withText
	final.StopReason = agentcore.StopReasonEndTurn
	s := provider.NewAssistantMessageEventStream(0)
	go func() {
		_ = s.Emit(ctx, provider.StreamStartEvent{Partial: partial})
		_ = s.Emit(ctx, provider.StreamTextEvent{Partial: withText})
		_ = s.Emit(ctx, provider.StreamDoneEvent{Message: final})
		s.Close()
	}()
	return s, nil
}

// newTestDeps builds replDeps wired to the fake provider and a temp session
// store, with a registry carrying one action command and one prompt command so
// slash dispatch can be exercised. actionRuns/promptResolved report whether each
// command fired.
func newTestDeps(t *testing.T, p provider.Provider) (replDeps, *session.Store) {
	t.Helper()
	store, err := session.NewStore(t.TempDir())
	if err != nil {
		t.Fatalf("new store: %v", err)
	}
	live := &cli.LiveConfig{Model: "faux", ProviderName: "faux", Provider: p}
	reg := runtime.NewSlashRegistry()
	reg.AddBuiltin(runtime.SlashCommand{
		Name:   "ping",
		Action: func(string) string { return "pong" },
	})
	reg.AddUser(runtime.SlashCommand{
		Name:   "echo",
		Expand: func(args string) string { return "expanded: " + args },
	})
	deps := replDeps{
		store:    store,
		header:   session.SessionHeader{ID: session.NewID(time.Now().UTC()), Model: "faux", Provider: "faux"},
		agentCtx: &agentcore.AgentContext{},
		live:     live,
		reg:      agenttool.NewToolRegistry(),
		slash:    reg,
		creds:    provider.NewCredentialStore(nil),
	}
	return deps, store
}

// TestREPLExitCommand verifies /exit ends the loop cleanly with no error and no
// agent run.
func TestREPLExitCommand(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL returned error: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("/exit must not launch a run, got %d calls", p.calls)
	}
}

// TestREPLQuitCommand verifies /quit is an alias for /exit.
func TestREPLQuitCommand(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/quit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL returned error: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("/quit must not launch a run, got %d calls", p.calls)
	}
}

// TestREPLEOFExits verifies EOF (no /exit) ends the loop cleanly.
func TestREPLEOFExits(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	// Empty input → immediate EOF.
	if err := runREPL(strings.NewReader(""), &out, deps); err != nil {
		t.Fatalf("EOF should exit cleanly, got: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("EOF with no input must not run, got %d calls", p.calls)
	}
}

// TestREPLFinalLineNoNewline verifies the ReadString-based loop handles a final
// input line without a trailing newline: the line is still run as a prompt and
// the loop then exits cleanly on EOF. (The previous bufio.Scanner had the same
// behavior; this pins it for the reader-based loop.)
func TestREPLFinalLineNoNewline(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("hello"), &out, deps); err != nil {
		t.Fatalf("runREPL on no-trailing-newline input: %v", err)
	}
	if p.calls != 1 {
		t.Errorf("runs fired = %d, want 1 (the final line should run once)", p.calls)
	}
}

// TestREPLEmptyLineIgnored verifies blank lines are skipped without running.
func TestREPLEmptyLineIgnored(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("\n   \n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("blank lines must not run, got %d calls", p.calls)
	}
}

// TestREPLActionCommandNoRun verifies an action slash command prints its message
// and does NOT launch an agent run.
func TestREPLActionCommandNoRun(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/ping\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("action command must not run, got %d calls", p.calls)
	}
	if !strings.Contains(out.String(), "pong") {
		t.Errorf("action command message not printed, out=%q", out.String())
	}
}

// TestREPLPromptCommandRuns verifies a prompt slash command expands and launches
// a run.
func TestREPLPromptCommandRuns(t *testing.T) {
	p := &replProvider{reply: "ack"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/echo hello\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 1 {
		t.Fatalf("prompt command should launch exactly 1 run, got %d", p.calls)
	}
	// The expanded prompt must have been appended as the user message.
	if len(deps.agentCtx.Messages) == 0 {
		t.Fatal("expected messages in context after run")
	}
	first, ok := deps.agentCtx.Messages[0].(agentcore.UserMessage)
	if !ok || agentcore.ContentToText(first.Content) != "expanded: hello" {
		t.Errorf("first message = %T %q, want expanded prompt", deps.agentCtx.Messages[0], agentcore.ContentToText(first.Content))
	}
}

// TestREPLUnknownCommandNoRun verifies an unknown slash command prints an error
// and does NOT run or crash.
func TestREPLUnknownCommandNoRun(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/nope\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("unknown command must not run, got %d calls", p.calls)
	}
	if !strings.Contains(strings.ToLower(out.String()), "unknown") {
		t.Errorf("expected an unknown-command error line, out=%q", out.String())
	}
}

// TestREPLModelSwitchTakesEffect verifies the /model action command switches the
// live model mid-session (via registerLiveCommands + resolveProvider) without
// launching a run, and that the switch is reflected in live for the next turn.
func TestREPLModelSwitchTakesEffect(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	// Register the real live action commands (/model, /models, /help) against the
	// same live config the REPL runs on, so /model mutates it.
	prompts.RegisterLiveCommands(deps.slash, deps.live)

	var out bytes.Buffer
	// /model with no arg reports the current model; /model <id> switches to an
	// Ollama preset (no API key required); /exit ends the loop.
	in := strings.NewReader("/model\n/model ollama/llama3.3\n/exit\n")
	if err := runREPL(in, &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("/model actions must not launch a run, got %d calls", p.calls)
	}
	if deps.live.Model != "ollama/llama3.3" || deps.live.ProviderName != "ollama" {
		t.Errorf("live not switched: model=%q provider=%q", deps.live.Model, deps.live.ProviderName)
	}
	s := out.String()
	if !strings.Contains(s, "faux") {
		t.Errorf("/model (no arg) should report the current model, out=%q", s)
	}
	if !strings.Contains(s, "ollama/llama3.3") {
		t.Errorf("/model switch should confirm the new model, out=%q", s)
	}
}

// TestREPLPersistsModelIntoHeader verifies that after a run the session header
// records the live model/provider (US-006: a /model switch is persisted with the
// session), by reloading the saved session and inspecting its header.
func TestREPLPersistsModelIntoHeader(t *testing.T) {
	p := &replProvider{reply: "ok"}
	deps, store := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("hello\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	headers, err := store.List()
	if err != nil {
		t.Fatalf("store.List: %v", err)
	}
	if len(headers) != 1 {
		t.Fatalf("expected 1 saved session, got %d", len(headers))
	}
	if headers[0].Model != "faux" || headers[0].Provider != "faux" {
		t.Errorf("header model/provider = %q/%q, want live faux/faux", headers[0].Model, headers[0].Provider)
	}
}

// TestRenderToolResultTodoFull verifies the todo tool's multi-line progress
// block is rendered in full under a "← todo:" header (US-011: REPL shows
// progress on update), while a non-todo result stays a one-line summary.
func TestRenderToolResultTodoFull(t *testing.T) {
	var out bytes.Buffer
	ui.RenderToolResult(&out, agentcore.ToolResultMessage{
		RoleField: agentcore.RoleToolResult, ToolName: "todo",
		Content: agentcore.ContentList{agentcore.NewTextContent("Todos:\n  [x] a\n  [ ] b\n(1/2 completed)")},
	})
	s := out.String()
	for _, want := range []string{"← todo:", "[x] a", "[ ] b", "(1/2 completed)"} {
		if !strings.Contains(s, want) {
			t.Errorf("todo render missing %q, out=%q", want, s)
		}
	}

	out.Reset()
	ui.RenderToolResult(&out, agentcore.ToolResultMessage{
		RoleField: agentcore.RoleToolResult, ToolName: "bash",
		Content: agentcore.ContentList{agentcore.NewTextContent("line1\nline2")},
	})
	if got := out.String(); !strings.Contains(got, "← result: line1") {
		t.Errorf("non-todo render = %q, want a one-line summary", got)
	}
}

// TestReplayTranscriptRendersRoles verifies a resumed session's prior messages
// are echoed by role (user / assistant / tool result) before the first new
// prompt (US-006 acceptance: resumed conversation is replayed).
func TestReplayTranscriptRendersRoles(t *testing.T) {
	msgs := []agentcore.AgentMessage{
		agentcore.UserMessage{RoleField: agentcore.RoleUser, Content: agentcore.ContentList{agentcore.NewTextContent("what is 2+2")}},
		agentcore.AssistantMessage{
			RoleField: agentcore.RoleAssistant,
			Content:   agentcore.ContentList{agentcore.NewTextContent("Let me compute."), agentcore.NewToolCallContent("c1", "calc", []byte(`{"expr":"2+2"}`))},
		},
		agentcore.ToolResultMessage{RoleField: agentcore.RoleToolResult, ToolCallID: "c1", ToolName: "calc", Content: agentcore.ContentList{agentcore.NewTextContent("4")}},
	}
	var out bytes.Buffer
	replayTranscript(&out, msgs)
	s := out.String()
	for _, want := range []string{"> what is 2+2", "Let me compute.", `→ tool: calc {"expr":"2+2"}`, "← result: 4"} {
		if !strings.Contains(s, want) {
			t.Errorf("replay missing %q, out=%q", want, s)
		}
	}
}

// TestREPLTreePrintsAndSwitchesBranch drives the /tree command end to end over
// the REPL: after two turns, "/tree" prints a numbered tree with the current-leaf
// marker; "/tree 1" switches the active leaf to the first node, so the next
// prompt branches from there — leaving the original branch intact on disk (US-007,
// #123).
func TestREPLTreePrintsAndSwitchesBranch(t *testing.T) {
	p := &replProvider{reply: "reply"}
	deps, store := newTestDeps(t, p)
	var out bytes.Buffer
	// Two turns build a linear history (4 messages), then /tree lists it, /tree 1
	// switches to the first node, a new prompt branches, then /exit.
	in := strings.NewReader("first\nsecond\n/tree\n/tree 1\nbranched\n/exit\n")
	if err := runREPL(in, &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	s := out.String()
	if !strings.Contains(s, "← current") {
		t.Errorf("/tree should mark the current leaf, out=%q", s)
	}
	if !strings.Contains(s, "1. user:") {
		t.Errorf("/tree should number entries starting at the root user message, out=%q", s)
	}
	if !strings.Contains(s, "switched to branch at node 1") {
		t.Errorf("/tree 1 should confirm the switch, out=%q", s)
	}
	// The on-disk tree must retain both branches: the original 4-message line plus
	// the new branch off node 1. Reload and confirm the root has 2 children.
	_, entries, err := store.LoadEntries(deps.header.ID)
	if err != nil {
		t.Fatalf("LoadEntries: %v", err)
	}
	rootID := ""
	for _, e := range entries {
		if e.ParentID == "" {
			rootID = e.ID
			break
		}
	}
	if rootID == "" {
		t.Fatal("no root entry found")
	}
	kids := 0
	for _, e := range entries {
		if e.ParentID == rootID {
			kids++
		}
	}
	if kids != 2 {
		t.Errorf("root should have 2 children after branching, got %d (entries=%d)", kids, len(entries))
	}
}

// TestREPLTreeEmptyNoop verifies /tree on a fresh session (no messages) prints a
// friendly notice and does not crash or run.
func TestREPLTreeEmptyNoop(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/tree\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("/tree must not launch a run, got %d calls", p.calls)
	}
	if !strings.Contains(out.String(), "empty") {
		t.Errorf("/tree on empty session should say so, out=%q", out.String())
	}
}

// is printed, and history accumulates across two turns in the shared context.
func TestREPLStreamsAndAccumulatesHistory(t *testing.T) {
	p := &replProvider{reply: "the answer"}
	deps, store := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("first question\nsecond question\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 2 {
		t.Fatalf("two prompts should launch 2 runs, got %d", p.calls)
	}
	// The streamed reply text must appear in the output.
	if !strings.Contains(out.String(), "the answer") {
		t.Errorf("assistant reply not streamed to output, out=%q", out.String())
	}
	// History: user(first) + assistant + user(second) + assistant = 4 messages.
	if len(deps.agentCtx.Messages) != 4 {
		t.Fatalf("expected 4 accumulated messages, got %d", len(deps.agentCtx.Messages))
	}
	u0, _ := deps.agentCtx.Messages[0].(agentcore.UserMessage)
	u2, _ := deps.agentCtx.Messages[2].(agentcore.UserMessage)
	if agentcore.ContentToText(u0.Content) != "first question" || agentcore.ContentToText(u2.Content) != "second question" {
		t.Errorf("history not accumulated in order: %q, %q", agentcore.ContentToText(u0.Content), agentcore.ContentToText(u2.Content))
	}
	// The session must have been persisted after the runs.
	headers, err := store.List()
	if err != nil {
		t.Fatalf("store.List: %v", err)
	}
	if len(headers) != 1 {
		t.Errorf("expected 1 saved session, got %d", len(headers))
	}
}

// errProvider streams a single turn that ends with stopReason error carrying an
// ErrorMessage, mimicking how the loop surfaces a request failure (e.g. a 4xx
// from the endpoint) as a terminal assistant message rather than a Go error.
type errProvider struct {
	reason string
	calls  int
}

func (p *errProvider) Name() string { return "faux" }
func (p *errProvider) Models() []provider.Model {
	return []provider.Model{{Provider: "faux", ID: "faux"}}
}

func (p *errProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
	p.calls++
	partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
	final := partial
	final.StopReason = agentcore.StopReasonError
	final.ErrorMessage = p.reason
	s := provider.NewAssistantMessageEventStream(0)
	go func() {
		_ = s.Emit(ctx, provider.StreamStartEvent{Partial: partial})
		_ = s.Emit(ctx, provider.StreamErrorEvent{Message: final})
		s.Close()
	}()
	return s, nil
}

// TestREPLSurfacesTurnError verifies a turn that ends with stopReason error is
// printed to the user instead of returning silently to the prompt. Without this
// an API failure (delivered as a terminal error message, not a run error) would
// produce no output at all.
func TestREPLSurfacesTurnError(t *testing.T) {
	p := &errProvider{reason: "401 unauthorized: bad api key"}
	deps, _ := newTestDeps(t, p)
	deps.live.Provider = p
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("hello\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 1 {
		t.Fatalf("expected 1 run, got %d", p.calls)
	}
	got := out.String()
	if !strings.Contains(got, "error:") || !strings.Contains(got, "401 unauthorized: bad api key") {
		t.Errorf("turn error not surfaced to output, out=%q", got)
	}
}

// emptyProvider streams a clean end_turn with no content, thinking, or tool
// calls — the shape produced when an endpoint accepts the request with a 200 but
// returns nothing this protocol can decode.
type emptyProvider struct{ calls int }

func (p *emptyProvider) Name() string { return "faux" }
func (p *emptyProvider) Models() []provider.Model {
	return []provider.Model{{Provider: "faux", ID: "faux"}}
}

func (p *emptyProvider) StreamCompletion(ctx context.Context, req provider.CompletionRequest) (*provider.AssistantMessageEventStream, error) {
	p.calls++
	partial := agentcore.AssistantMessage{RoleField: agentcore.RoleAssistant}
	final := partial
	final.StopReason = agentcore.StopReasonEndTurn
	s := provider.NewAssistantMessageEventStream(0)
	go func() {
		_ = s.Emit(ctx, provider.StreamStartEvent{Partial: partial})
		_ = s.Emit(ctx, provider.StreamDoneEvent{Message: final})
		s.Close()
	}()
	return s, nil
}

// TestREPLNotesEmptyResponse verifies a clean turn that produced no output at
// all is flagged with a note rather than returning silently to the prompt.
func TestREPLNotesEmptyResponse(t *testing.T) {
	p := &emptyProvider{}
	deps, _ := newTestDeps(t, p)
	deps.live.Provider = p
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("hello\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 1 {
		t.Fatalf("expected 1 run, got %d", p.calls)
	}
	if got := out.String(); !strings.Contains(got, "empty response from the model") {
		t.Errorf("empty response not flagged, out=%q", got)
	}
}

// TestREPLExportImportRoundTrip drives /export and /import end to end over the
// REPL: after a turn, "/export <path>" writes a JSONL file, then "/import
// <path>" materializes it as a fresh session and switches to it (US-008, #124).
func TestREPLExportImportRoundTrip(t *testing.T) {
	p := &replProvider{reply: "the answer"}
	deps, store := newTestDeps(t, p)
	origID := deps.header.ID
	out := filepath.Join(t.TempDir(), "sess.jsonl")

	var buf bytes.Buffer
	in := strings.NewReader("hello\n/export " + out + "\n/import " + out + "\n/exit\n")
	if err := runREPL(in, &buf, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	s := buf.String()
	if !strings.Contains(s, "exported") {
		t.Errorf("/export should confirm, out=%q", s)
	}
	if !strings.Contains(s, "imported") {
		t.Errorf("/import should confirm, out=%q", s)
	}
	// The export file must exist and be non-empty.
	if info, err := os.Stat(out); err != nil || info.Size() == 0 {
		t.Fatalf("export file missing or empty: err=%v", err)
	}
	// The import creates a new session distinct from the original, so the store
	// should now hold at least 2 sessions.
	headers, err := store.List()
	if err != nil {
		t.Fatalf("store.List: %v", err)
	}
	var foundNew bool
	for _, h := range headers {
		if h.ID != origID && h.ParentSession == origID {
			foundNew = true
		}
	}
	if !foundNew {
		t.Errorf("expected an imported session with ParentSession=%q, headers=%+v", origID, headers)
	}
}

// TestREPLExportDefaultsToJSONL verifies "/export" with no path defaults to
// "<session-id>.jsonl" and does not launch an agent run.
func TestREPLExportDefaultsToJSONL(t *testing.T) {
	p := &replProvider{reply: "ok"}
	deps, _ := newTestDeps(t, p)
	dir := t.TempDir()
	// Run inside a temp dir so the default relative filename lands there.
	cwd, _ := os.Getwd()
	if err := os.Chdir(dir); err != nil {
		t.Fatalf("chdir: %v", err)
	}
	defer os.Chdir(cwd)

	var buf bytes.Buffer
	if err := runREPL(strings.NewReader("hi\n/export\n/exit\n"), &buf, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	want := deps.header.ID + ".jsonl"
	if _, err := os.Stat(filepath.Join(dir, want)); err != nil {
		t.Errorf("default export file %q not created: %v", want, err)
	}
}

// TestREPLImportTokenBoundary verifies that a command sharing a prefix with
// /import (e.g. "/important") is NOT treated as /import — it falls through to
// slash resolution and reports an unknown command rather than importing.
func TestREPLImportTokenBoundary(t *testing.T) {
	p := &replProvider{reply: "ok"}
	deps, _ := newTestDeps(t, p)
	var buf bytes.Buffer
	if err := runREPL(strings.NewReader("/important\n/exit\n"), &buf, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if strings.Contains(buf.String(), "imported") {
		t.Errorf("/important must not trigger /import, out=%q", buf.String())
	}
	if p.calls != 0 {
		t.Errorf("/important should not launch a run, got %d calls", p.calls)
	}
}

// TestREPLSessionStats drives the /session command: after a turn it prints the
// session id, message count, token estimate, model, and compaction count without
// launching another run (US-009, #125).
func TestREPLSessionStats(t *testing.T) {
	p := &replProvider{reply: "the answer"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("hello\n/session\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 1 {
		t.Errorf("/session must not launch a run (only the prompt), got %d calls", p.calls)
	}
	s := out.String()
	for _, want := range []string{"session:", "messages:", "tokens (est):", "model:", "compactions:"} {
		if !strings.Contains(s, want) {
			t.Errorf("/session output missing %q, out=%q", want, s)
		}
	}
	// After one turn the context holds user + assistant = 2 messages.
	if !strings.Contains(s, "messages:     2") {
		t.Errorf("/session should report 2 messages, out=%q", s)
	}
}

// TestREPLCopyEmpty verifies /copy on a session with no assistant reply prints a
// friendly notice rather than copying or crashing.
func TestREPLCopyEmpty(t *testing.T) {
	p := &replProvider{reply: "hi"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("/copy\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	if p.calls != 0 {
		t.Errorf("/copy must not launch a run, got %d calls", p.calls)
	}
	if !strings.Contains(out.String(), "nothing to copy") {
		t.Errorf("/copy on empty session should say so, out=%q", out.String())
	}
}

// TestREPLCopyDegradesToPrint verifies /copy degrades to printing the last reply
// when no clipboard utility is available (PATH pointed at an empty dir), so the
// content is never lost.
func TestREPLCopyDegradesToPrint(t *testing.T) {
	t.Setenv("PATH", t.TempDir())
	p := &replProvider{reply: "the important answer"}
	deps, _ := newTestDeps(t, p)
	var out bytes.Buffer
	if err := runREPL(strings.NewReader("ask\n/copy\n/exit\n"), &out, deps); err != nil {
		t.Fatalf("runREPL: %v", err)
	}
	s := out.String()
	if !strings.Contains(s, "no clipboard utility") {
		t.Errorf("/copy should report missing clipboard utility, out=%q", s)
	}
	if !strings.Contains(s, "the important answer") {
		t.Errorf("/copy should print the reply when degrading, out=%q", s)
	}
}
