// Invariants the e2e suite cannot reach, because each lives inside the
// tui package or needs a failure the daemon never produces:
//
//   - frame pacing: an idle graph must not keep scheduling animation frames,
//     a running one must resume them exactly once (wasted CPU, double speed);
//   - the preview decoder must reveal streamed code from incomplete JSON arguments,
//     which a real stream only ever shows mid-flight;
//   - a pending delete confirm must not outlive the agent it names;
//   - a failed history seed must retry on the next selection, and a late
//     error from an older generation must not unseed the live node;
//   - overflow during a blocked mutation must preserve its later outcome and draft.
package tui

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"net/url"
	"slices"
	"strconv"
	"strings"
	"testing"
	"time"

	"albedo/cli/internal/daemon"

	tea "charm.land/bubbletea/v2"

	"github.com/charmbracelet/x/ansi"
)

func agentsFixture(t *testing.T) AgentsViewModel {
	t.Helper()
	m := NewAgentsViewModel(nil, "lead")
	m.Gen = 1
	m.streamReady = true
	m.SetSize(120, 30)
	lead := "lead"
	coder := "coder"
	m, _ = m.Update(agentsSnapshotMsg{Gen: 1, Root: "lead", Nodes: []daemon.AgentNode{
		{Session: daemon.Session{ID: "lead", Title: "lead", Model: "gpt-6-luna"}, Name: "lead", Running: true},
		{Session: daemon.Session{ID: "scout", Model: "gpt-6-luna"}, Parent: &lead, Name: "scout", Depth: 1},
		{Session: daemon.Session{ID: "coder", Model: "gpt-6-luna"}, Parent: &lead, Name: "coder", Depth: 1, Running: true},
		{Session: daemon.Session{ID: "tests", Model: "gpt-6-luna"}, Parent: &coder, Name: "tests", Depth: 2},
	}})
	return m
}

// frames runs cmd and counts the frame ticks it schedules.
func frames(cmd tea.Cmd) int {
	if cmd == nil {
		return 0
	}
	switch msg := cmd().(type) {
	case tea.BatchMsg:
		n := 0
		for _, c := range msg {
			n += frames(c)
		}
		return n
	case agentsFrameMsg:
		return 1
	}
	return 0
}

func TestAgentsFramesStopWhenStillAndResumeOnce(t *testing.T) {
	m := agentsFixture(t)
	closed := make(chan tea.Msg)
	close(closed)
	m.events = closed
	running := func(id string, on bool) agentsEventsMsg {
		return agentsEventsMsg{Gen: 1, Events: []daemon.AgentEvent{{Type: "running", Session: id, Running: on}}}
	}
	m, _ = m.Update(running("lead", false))
	m, _ = m.Update(running("coder", false))
	m, cmd := m.Update(agentsFrameMsg{Gen: 1})
	if n := frames(cmd); n != 0 {
		t.Fatalf("a still graph scheduled %d frames, want none", n)
	}
	m, cmd = m.Update(running("coder", true))
	if n := frames(cmd); n != 1 {
		t.Fatalf("a running agent scheduled %d frames, want one", n)
	}
	_, cmd = m.Update(running("lead", true))
	if n := frames(cmd); n != 0 {
		t.Fatalf("a second runner scheduled %d more frames, want none", n)
	}
}

// Tool previews must expose code before the argument JSON is complete.
func TestAgentsPreviewReadsPartialJSON(t *testing.T) {
	var preview agentPreview
	preview.appendArguments(`{"code": "a = 1\nb = \"x\"\nprint(a`)
	var got []string
	for line := range preview.lines.newest(true) {
		got = append(got, line.text)
	}
	slices.Reverse(got)
	want := []string{"a = 1", `b = "x"`, "print(a"}
	if !slices.Equal(got, want) {
		t.Fatalf("preview = %q, want %q", got, want)
	}
	preview = agentPreview{}
	preview.appendArguments(`{"code": "done\n", "timeout_ms": 5}`)
	lines := slices.Collect(preview.lines.newest(true))
	if len(lines) != 1 || lines[0].text != "done" {
		t.Fatalf("a closed string stops at its quote, got %q", lines)
	}
}

func TestAgentsDeleteConfirmDropsWithTheAgent(t *testing.T) {
	m := agentsFixture(t)
	m.selected = "coder"
	m, _ = m.key(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
	if m.confirm != "coder" {
		t.Fatal("ctrl+x should ask before deleting coder")
	}
	m, _ = m.Update(agentsEventsMsg{Gen: 1, Events: []daemon.AgentEvent{
		{Type: "gone", Session: "coder"},
	}})
	if m.confirm != "" {
		t.Fatalf("the confirm prompt outlived the agent it named:\n%s", ansi.Strip(m.View()))
	}
	if strings.Contains(ansi.Strip(m.View()), "delete coder") {
		t.Fatal("the view still offers to delete a vanished agent")
	}
	m.selected = "tests"
	m, _ = m.key(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
	if m.confirm != "tests" {
		t.Fatal("ctrl+x stopped working after the prompt was dropped")
	}
}

// A seed that fails must not lock the node out of history: the next selection
// retries, and only the failure's own generation may unseed the node.
func TestFailedSeedRetriesOnTheNextSelection(t *testing.T) {
	var calls int
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		calls++
		if calls == 1 {
			http.Error(w, `{"error":"history is gone"}`, http.StatusInternalServerError)
			return
		}
		_ = json.NewEncoder(w).Encode(map[string]any{"total": 2, "items": []map[string]string{
			{"type": "user", "preview": "fan out three scouts"},
			{"type": "assistant", "preview": "sent them off"},
		}})
	}))
	defer server.Close()
	address, _ := url.Parse(server.URL)
	port, _ := strconv.Atoi(address.Port())

	m := agentsFixture(t)
	m.Conn = daemon.NewConnection(daemon.ConnectionSnapshot{Port: port, Token: "t", Version: 2}, nil)
	m.selected = "coder"

	cmd := m.seedCmd()
	if cmd == nil {
		t.Fatal("an unseeded agent should ask for history")
	}
	msg := cmd()
	failure, ok := msg.(agentsSeedErrMsg)
	if !ok || failure.Err == nil {
		t.Fatalf("the failed request should report itself, got %T", msg)
	}
	m, _ = m.Update(failure)
	if m.nodes["coder"].seeded {
		t.Fatal("a failed seed left the node marked seeded")
	}
	if !strings.Contains(m.notice, failure.Err.Error()) {
		t.Fatalf("the notice should carry the failure, got %q", m.notice)
	}

	cmd = m.seedCmd()
	if cmd == nil {
		t.Fatal("the next selection should retry after a failed seed")
	}
	msg = cmd()
	seed, ok := msg.(agentsSeedMsg)
	if !ok {
		t.Fatalf("the retry should answer with history, got %T", msg)
	}
	m, _ = m.Update(seed)
	for _, want := range []string{"← fan out three scouts", "sent them off"} {
		if !strings.Contains(ansi.Strip(m.View()), want) {
			t.Fatalf("the retried seed should show %q in the tail:\n%s", want, ansi.Strip(m.View()))
		}
	}

	// An error from an earlier generation may not unseed the live node.
	m, _ = m.Update(agentsSeedErrMsg{Gen: m.Gen - 1, ID: "coder", Err: errors.New("late")})
	if !m.nodes["coder"].seeded {
		t.Fatal("a stale seed error unseeded the live node")
	}
	if cmd := m.seedCmd(); cmd != nil {
		t.Fatal("a seeded node should not fetch again")
	}
}

func TestAgentsOverflowPreservesPendingOperationOutcomeAndDraft(t *testing.T) {
	started, release := make(chan struct{}), make(chan struct{})
	server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.URL.Path == "/agents/stream" {
			<-r.Context().Done()
			return
		}
		if r.URL.Path != "/sessions/lead/children" {
			t.Errorf("unexpected request: %s", r.URL.Path)
			w.WriteHeader(404)
			return
		}
		close(started)
		<-release
		w.WriteHeader(http.StatusForbidden)
		_, _ = w.Write([]byte(`{"error":"spawn refused"}`))
	}))
	defer server.Close()
	defer func() {
		select {
		case <-release:
		default:
			close(release)
		}
	}()
	address, _ := url.Parse(server.URL)
	port, _ := strconv.Atoi(address.Port())
	m := agentsFixture(t)
	m.Conn = daemon.NewConnection(daemon.ConnectionSnapshot{Port: port}, nil)
	for _, char := range "/spawn helper inspect files" {
		m, _ = m.Update(tea.KeyPressMsg{Code: char, Text: string(char)})
	}
	var cmd tea.Cmd
	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
	finished := make(chan tea.Msg, 1)
	go func() { finished <- cmd() }()
	select {
	case <-started:
	case <-time.After(5 * time.Second):
		t.Fatal("spawn never reached server")
	}
	for _, char := range "next draft" {
		m, _ = m.Update(tea.KeyPressMsg{Code: char, Text: string(char)})
	}
	m, _ = m.Update(agentsEventsMsg{Gen: m.Gen, Events: []daemon.AgentEvent{{Type: "overflow"}}})
	defer func() { m.Close() }()
	close(release)
	select {
	case outcome := <-finished:
		m, _ = m.Update(outcome)
	case <-time.After(5 * time.Second):
		t.Fatal("spawn did not complete")
	}
	rendered := ansi.Strip(m.View())
	if !strings.Contains(rendered, "spawn refused") || !strings.Contains(rendered, "next draft") {
		t.Fatalf("overflow lost pending outcome or draft: %s", rendered)
	}
}
