package cli

import (
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	tea "charm.land/bubbletea/v2"

	"reasonix/internal/agent"
	"reasonix/internal/control"
	"reasonix/internal/event"
	"reasonix/internal/provider"
)

// TestResumeDispatchOpensPicker proves bare "/resume" opens the interactive
// picker without duplicating the same list in transcript scrollback.
func TestResumeDispatchOpensPicker(t *testing.T) {
	dir := t.TempDir()
	saveTestSession(t, filepath.Join(dir, "a.jsonl"), "alpha prompt")
	saveTestSession(t, filepath.Join(dir, "b.jsonl"), "beta prompt")

	exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
	m := newTestChatTUI()
	m.width = 80
	m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})

	if cmd := m.runSlashCommand("/resume"); cmd != nil {
		t.Fatal("/resume should not return a tea.Cmd")
	}
	if m.resumePick == nil {
		t.Fatal("bare /resume should open the picker")
	}
	if len(m.resumePick.sessions) != 2 {
		t.Fatalf("picker should have 2 sessions, got %d", len(m.resumePick.sessions))
	}
	out := strings.Join(m.transcript, "\n")
	if strings.Contains(out, "alpha prompt") || strings.Contains(out, "beta prompt") {
		t.Fatalf("picker previews should not be duplicated in scrollback:\n%s", out)
	}
}

func TestOrderResumeSessionsGroupsRecoveryCopiesAndPrefersNewestLeaf(t *testing.T) {
	base := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
	root := agent.SessionInfo{Path: "/sessions/root.jsonl", ModTime: base.Add(4 * time.Minute)}
	olderLeaf := agent.SessionInfo{
		Path: "/sessions/recovery-old.jsonl", ModTime: base.Add(2 * time.Minute),
		Recovered: true, ParentID: "root",
	}
	newerLeaf := agent.SessionInfo{
		Path: "/sessions/recovery-new.jsonl", ModTime: base.Add(3 * time.Minute),
		Recovered: true, ParentID: "root",
	}
	other := agent.SessionInfo{Path: "/sessions/other.jsonl", ModTime: base.Add(time.Minute)}

	got := orderResumeSessions([]agent.SessionInfo{root, newerLeaf, olderLeaf, other})
	want := []string{newerLeaf.Path, olderLeaf.Path, root.Path, other.Path}
	if len(got) != len(want) {
		t.Fatalf("ordered sessions len = %d, want %d", len(got), len(want))
	}
	for i := range want {
		if got[i].Path != want[i] {
			t.Fatalf("ordered[%d] = %q, want %q (all=%v)", i, got[i].Path, want[i], got)
		}
	}
}

func TestMostRecentSessionIgnoresRecoveryPickerLeafPreference(t *testing.T) {
	dir := t.TempDir()
	rootPath := filepath.Join(dir, "root.jsonl")
	recoveryPath := filepath.Join(dir, "recovery.jsonl")
	saveTestSession(t, rootPath, "latest parent prompt")
	saveTestSession(t, recoveryPath, "older recovery prompt")

	base := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
	if err := agent.SaveBranchMetaPreserveUpdated(rootPath, agent.BranchMeta{
		ID: "root", CreatedAt: base, UpdatedAt: base.Add(4 * time.Minute),
		SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1, Preview: "latest parent prompt",
	}); err != nil {
		t.Fatalf("save root meta: %v", err)
	}
	if err := agent.SaveBranchMetaPreserveUpdated(recoveryPath, agent.BranchMeta{
		ID: "recovery", ParentID: "root", Recovered: true,
		CreatedAt: base, UpdatedAt: base.Add(3 * time.Minute),
		SchemaVersion: agent.BranchMetaCountsVersion, Turns: 1, Preview: "older recovery prompt",
	}); err != nil {
		t.Fatalf("save recovery meta: %v", err)
	}

	grouped := recentSessions(dir)
	if len(grouped) != 2 || grouped[0].Path != recoveryPath {
		t.Fatalf("interactive resume order = %+v, want recovery leaf grouped first", grouped)
	}
	latest, ok := mostRecentSession(dir)
	if !ok {
		t.Fatal("mostRecentSession found no session")
	}
	if latest.Path != rootPath {
		t.Fatalf("--continue session = %q, want chronologically newest %q", latest.Path, rootPath)
	}
}

func TestRunResumeKeepsCompletedIndexStableAcrossRecoveryGC(t *testing.T) {
	dir := t.TempDir()
	parentPath := filepath.Join(dir, "recovery-parent.jsonl")
	disk := agent.NewSession("sys")
	disk.Add(provider.Message{Role: provider.RoleUser, Content: "shared prompt"})
	disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk answer"})
	if err := disk.Save(parentPath); err != nil {
		t.Fatalf("save parent: %v", err)
	}
	stale := agent.NewSession("sys")
	stale.Add(provider.Message{Role: provider.RoleUser, Content: "shared prompt"})
	stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "recovered answer"})
	recovery, err := stale.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parentPath})
	if err != nil {
		t.Fatalf("save recovery branch: %v", err)
	}
	covered, err := agent.LoadSession(parentPath)
	if err != nil {
		t.Fatalf("load recovery parent: %v", err)
	}
	covered.Replace(append([]provider.Message(nil), stale.Snapshot()...))
	covered.Add(provider.Message{Role: provider.RoleUser, Content: "later parent turn"})
	if err := covered.SaveRewrite(parentPath); err != nil {
		t.Fatalf("cover recovery branch in parent: %v", err)
	}
	recoveryMeta, ok, err := agent.LoadBranchMeta(recovery.Path)
	if err != nil || !ok {
		t.Fatalf("load recovery meta: ok=%v err=%v", ok, err)
	}
	recoveryMeta.UpdatedAt = time.Now().Add(-2 * agent.RecoveryGCGracePeriod)
	if err := agent.SaveBranchMetaPreserveUpdated(recovery.Path, recoveryMeta); err != nil {
		t.Fatalf("age recovery branch: %v", err)
	}

	targetPath := filepath.Join(dir, "wanted.jsonl")
	saveTestSession(t, targetPath, "WANTED-SESSION")
	targetMeta, ok, err := agent.LoadBranchMeta(targetPath)
	if err != nil || !ok {
		t.Fatalf("load target meta: ok=%v err=%v", ok, err)
	}
	targetMeta.UpdatedAt = time.Now().Add(-4 * agent.RecoveryGCGracePeriod)
	if err := agent.SaveBranchMetaPreserveUpdated(targetPath, targetMeta); err != nil {
		t.Fatalf("age target session: %v", err)
	}

	candidates, err := agent.ReclaimableRecoveryBranches(dir, time.Now(), agent.RecoveryGCGracePeriod)
	if err != nil || len(candidates) != 1 || candidates[0] != recovery.Path {
		t.Fatalf("recovery GC precondition = %v err=%v, want %q", candidates, err, recovery.Path)
	}
	sessions := recentSessions(dir)
	targetIndex := 0
	for i, session := range sessions {
		if session.Path == targetPath {
			targetIndex = i + 1
		}
	}
	if targetIndex != len(sessions) || targetIndex < 2 {
		t.Fatalf("target index = %d in %+v, want a trailing row shifted by GC", targetIndex, sessions)
	}

	active := agent.NewSession("sys")
	active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
	exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
	ctrl.SetSessionPath(filepath.Join(dir, "active-unpersisted.jsonl"))
	m := newTestChatTUI()
	m.width = 80
	m.ctrl = ctrl

	m.runResumeCommand("/resume " + strconv.Itoa(targetIndex))

	if got := ctrl.SessionPath(); got != targetPath {
		t.Fatalf("session path = %q, want completed index target %q", got, targetPath)
	}
	if _, err := os.Stat(recovery.Path); err != nil {
		t.Fatalf("numeric resume mutated its displayed session list: %v", err)
	}
}

func TestCapResumeSessionGroupsDoesNotSplitRecoveryFamily(t *testing.T) {
	base := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
	sessions := make([]agent.SessionInfo, 0, 12)
	for i := range 9 {
		sessions = append(sessions, agent.SessionInfo{
			Path:    filepath.Join("/sessions", "standalone-"+strconv.Itoa(i)+".jsonl"),
			ModTime: base.Add(time.Duration(20-i) * time.Minute),
		})
	}
	sessions = append(sessions,
		agent.SessionInfo{Path: "/sessions/root.jsonl", ModTime: base.Add(3 * time.Minute)},
		agent.SessionInfo{Path: "/sessions/recovery-a.jsonl", ModTime: base.Add(2 * time.Minute), Recovered: true, ParentID: "root"},
		agent.SessionInfo{Path: "/sessions/recovery-b.jsonl", ModTime: base.Add(time.Minute), Recovered: true, ParentID: "root"},
	)

	got := capResumeSessionGroups(orderResumeSessions(sessions), resumeListCap)
	if len(got) != 9 {
		t.Fatalf("capped sessions len = %d, want 9 complete standalone groups", len(got))
	}
	for _, session := range got {
		if session.Recovered || agent.BranchID(session.Path) == "root" {
			t.Fatalf("cap split recovery family instead of omitting it: %+v", got)
		}
	}
}

func TestSessionPickerLabelIdentifiesRecoveryParent(t *testing.T) {
	session := agent.SessionInfo{
		Path: "/sessions/recovery.jsonl", Preview: "keep working", Turns: 3,
		Recovered: true, ParentID: "20260803-long-parent-id",
	}
	label := sessionPickerLabel(session)
	if recoverySessionBadge(session) == "" || !strings.Contains(label, "20260803") {
		t.Fatalf("recovery picker label = %q, want recovery badge and short parent id", label)
	}
}

// TestResumePickerNavigateAndSelect proves the picker's up/down navigation and
// Enter to resume the selected session.
func TestResumePickerNavigateAndSelect(t *testing.T) {
	dir := t.TempDir()
	exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})

	// Create two saved sessions.
	aPath := filepath.Join(dir, "a.jsonl")
	saveTestSession(t, aPath, "first session prompt")
	bPath := filepath.Join(dir, "b.jsonl")
	saveTestSession(t, bPath, "SECOND-SESSION-PROMPT")
	// Pin distinct mtimes so b is unambiguously the most recent. Created back to
	// back, the two files can land in the same filesystem mtime tick (seen on the
	// CI Windows runner), which then tie-breaks to a.jsonl by path and flakes.
	now := time.Now()
	if err := os.Chtimes(aPath, now.Add(-2*time.Second), now.Add(-2*time.Second)); err != nil {
		t.Fatal(err)
	}
	if err := os.Chtimes(bPath, now, now); err != nil {
		t.Fatal(err)
	}

	m := newTestChatTUI()
	m.width = 80
	m.ctrl = ctrl

	// Open the picker via bare /resume.
	m.runSlashCommand("/resume")
	if m.resumePick == nil {
		t.Fatal("bare /resume should open the picker")
	}
	if len(m.resumePick.sessions) != 2 {
		t.Fatalf("picker should have 2 sessions, got %d", len(m.resumePick.sessions))
	}

	// The first session (default selection) is the most recent, which is b.jsonl.
	// Press Enter to resume it.
	next, _ := m.handleResumePickerKey(tea.KeyPressMsg{Code: tea.KeyEnter})
	m = next.(chatTUI)

	if got := ctrl.SessionPath(); got != bPath {
		t.Fatalf("session path = %q, want %q", got, bPath)
	}
	if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "SECOND-SESSION-PROMPT") {
		t.Fatalf("transcript should replay the resumed session:\n%s", out)
	}
	if m.resumePick != nil {
		t.Fatal("picker should close after resume")
	}
}

// TestResumePickerEscDismisses proves pressing Esc closes the picker without
// switching sessions.
func TestResumePickerEscDismisses(t *testing.T) {
	dir := t.TempDir()
	saveTestSession(t, filepath.Join(dir, "a.jsonl"), "alpha prompt")

	exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
	m := newTestChatTUI()
	m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})

	m.runSlashCommand("/resume")
	if m.resumePick == nil {
		t.Fatal("bare /resume should open the picker")
	}

	next, _ := m.handleResumePickerKey(tea.KeyPressMsg{Code: tea.KeyEsc})
	m = next.(chatTUI)
	if m.resumePick != nil {
		t.Fatal("picker should close on Esc")
	}
}

// TestResumeDispatchSwitchesAndReplays drives "/resume <n>" through the slash
// dispatcher and asserts the controller switched session AND the resumed
// transcript was replayed into the scrollback.
func TestResumeDispatchSwitchesAndReplays(t *testing.T) {
	dir := t.TempDir()
	active := agent.NewSession("sys")
	active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
	exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
	ctrl.SetSessionPath(filepath.Join(dir, "active.jsonl"))
	if err := ctrl.Snapshot(); err != nil {
		t.Fatal(err)
	}

	otherPath := filepath.Join(dir, "other.jsonl")
	saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")

	m := newTestChatTUI()
	m.width = 80
	m.ctrl = ctrl

	target := 0
	for i, s := range recentSessions(dir) {
		if s.Path == otherPath {
			target = i + 1
		}
	}
	if target == 0 {
		t.Fatal("other session not listed by recentSessions")
	}

	m.runSlashCommand("/resume " + strconv.Itoa(target))

	if got := ctrl.SessionPath(); got != otherPath {
		t.Fatalf("session path = %q, want %q", got, otherPath)
	}
	if out := strings.Join(m.transcript, "\n"); !strings.Contains(out, "OTHER-SESSION-PROMPT") {
		t.Fatalf("transcript should replay the resumed session:\n%s", out)
	}
}

// TestResumeWhileScrolledUpPinsViewportToBottom covers the session-switch
// regression where a stale scroll offset was preserved if the user had read
// back in the old transcript before resuming another session.
func TestResumeWhileScrolledUpPinsViewportToBottom(t *testing.T) {
	dir := t.TempDir()
	active := agent.NewSession("sys")
	for i := range 18 {
		active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt " + strconv.Itoa(i)})
	}
	exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
	activePath := filepath.Join(dir, "active.jsonl")
	ctrl.SetSessionPath(activePath)
	if err := ctrl.Snapshot(); err != nil {
		t.Fatal(err)
	}

	otherPath := filepath.Join(dir, "other.jsonl")
	saveTestSession(t, otherPath, "OTHER-SESSION-PROMPT")

	target := 0
	for i, s := range recentSessions(dir) {
		if s.Path == otherPath {
			target = i + 1
		}
	}
	if target == 0 {
		t.Fatal("other session not listed by recentSessions")
	}

	adv := func(m chatTUI, msg tea.Msg) chatTUI {
		n, _ := m.Update(msg)
		return n.(chatTUI)
	}

	cur := adv(newChatTUI(ctrl, "", make(chan event.Event, 1), 80), tea.WindowSizeMsg{Width: 80, Height: 8})
	if !cur.viewport.AtBottom() {
		t.Fatal("initial resumed history should start at the bottom")
	}

	cur = adv(cur, tea.MouseWheelMsg{Button: tea.MouseWheelUp})
	if cur.viewport.AtBottom() {
		t.Fatal("wheel-up should move the old transcript away from the bottom")
	}

	cur.input.SetValue("/resume " + strconv.Itoa(target))
	cur = adv(cur, tea.KeyPressMsg{Code: tea.KeyEnter})

	if got := ctrl.SessionPath(); got != otherPath {
		t.Fatalf("session path = %q, want %q", got, otherPath)
	}
	out := strings.Join(cur.transcript, "\n")
	if !strings.Contains(out, "OTHER-SESSION-PROMPT") {
		t.Fatalf("transcript should replay the resumed session:\n%s", out)
	}
	if strings.Contains(out, "active prompt") {
		t.Fatalf("transcript should not retain the previous session after resume:\n%s", out)
	}
	if !cur.viewport.AtBottom() {
		t.Fatalf("resume while scrolled up should pin to bottom, AtBottom=%v, YOffset=%d", cur.viewport.AtBottom(), cur.viewport.YOffset())
	}
}

func saveTestSession(t *testing.T, path, prompt string) {
	t.Helper()
	s := agent.NewSession("sys")
	s.Add(provider.Message{Role: provider.RoleUser, Content: prompt})
	if err := s.Save(path); err != nil {
		t.Fatal(err)
	}
}

// TestResumeArgCompletionListsSessions proves "/resume " opens an indexed menu
// of the saved sessions, mirroring the /switch branch completion.
func TestResumeArgCompletionListsSessions(t *testing.T) {
	dir := t.TempDir()
	saveTestSession(t, filepath.Join(dir, "a.jsonl"), "first")
	saveTestSession(t, filepath.Join(dir, "b.jsonl"), "second")

	exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
	m := newTestChatTUI()
	m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})

	m.input.SetValue("/resume ")
	m.updateCompletion()
	if !m.completion.active || m.completion.kind != compSlashArg {
		t.Fatalf("/resume should open argument completion: %+v", m.completion)
	}
	if got := labels(m.completion.items); len(got) != 2 || got[0] != "1" || got[1] != "2" {
		t.Fatalf("resume completion = %v, want [1 2]", got)
	}
}

// TestResumeAcceptChainsIntoSessionMenu proves accepting "/resume" (a
// non-descend command that still takes arguments) immediately opens the session
// menu, rather than waiting for the next keystroke.
func TestResumeAcceptChainsIntoSessionMenu(t *testing.T) {
	dir := t.TempDir()
	saveTestSession(t, filepath.Join(dir, "a.jsonl"), "first")

	exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
	m := newTestChatTUI()
	m.ctrl = control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})

	m.input.SetValue("/resu")
	m.updateCompletion()
	m.acceptCompletion()
	if got := m.input.Value(); got != "/resume " {
		t.Fatalf("accepting /resume should fill %q, got %q", "/resume ", got)
	}
	if !m.completion.active || m.completion.kind != compSlashArg {
		t.Fatalf("accepting /resume should chain into the session menu: %+v", m.completion)
	}
}

// TestRunResumeSwitchesSession proves "/resume <n>" repoints the running
// controller to the chosen saved session and loads its history.
func TestRunResumeSwitchesSession(t *testing.T) {
	dir := t.TempDir()

	active := agent.NewSession("sys")
	active.Add(provider.Message{Role: provider.RoleUser, Content: "active prompt"})
	exec := agent.New(nil, nil, active, agent.Options{}, event.Discard)
	ctrl := control.New(control.Options{Executor: exec, SessionDir: dir, Label: "test"})
	activePath := filepath.Join(dir, "active.jsonl")
	ctrl.SetSessionPath(activePath)
	if err := ctrl.Snapshot(); err != nil {
		t.Fatal(err)
	}

	otherPath := filepath.Join(dir, "other.jsonl")
	saveTestSession(t, otherPath, "other prompt")

	m := newTestChatTUI()
	m.width = 80
	m.ctrl = ctrl

	target := 0
	for i, s := range recentSessions(dir) {
		if s.Path == otherPath {
			target = i + 1
		}
	}
	if target == 0 {
		t.Fatal("saved session not listed by recentSessions")
	}

	m.runResumeCommand("/resume " + strconv.Itoa(target))

	if got := ctrl.SessionPath(); got != otherPath {
		t.Fatalf("session path = %q, want %q", got, otherPath)
	}
	hist := ctrl.History()
	if len(hist) == 0 || hist[len(hist)-1].Content != "other prompt" {
		t.Fatalf("history not loaded from target: %+v", hist)
	}
}
