// Lost acknowledgements can race stream echoes; a daemon E2E cannot inspect
// whether the detached TUI restored a duplicate prompt or retained its pending image.
package tui

import (
	"errors"
	"fmt"
	"io"
	"testing"

	"albedo/cli/internal/config"
	"albedo/cli/internal/daemon"
	tea "charm.land/bubbletea/v2"
)

func TestUncertainSubmissionRetainsPendingUntilStreamEcho(t *testing.T) {
	for _, queued := range []bool{false, true} {
		t.Run(fmt.Sprintf("queued=%v", queued), func(t *testing.T) {
			m := newTestChatModel(t, &daemon.Session{ID: "s"})
			m.SetSize(80, 30)
			image := &daemon.ImageAttachment{}
			m.AttachedImage = image
			var commands []tea.Cmd
			m.submitInput("check this", &commands)
			handle := m.pendingUsers[0].Handle
			m.pendingUsers[0].Queued = queued
			m.TextArea.Reset()
			m.interruptDeferred = true

			m, cmd := m.Update(ChatTurnSentMsg{
				SessionID: "s", Generation: m.Generation, Prompt: "check this", Image: image,
				Handle: handle, OperationID: handle.ID(),
				Err: fmt.Errorf("send: %w", &daemon.UncertainOutcomeError{Operation: "submit", Cause: io.ErrUnexpectedEOF, Handle: handle}),
			})
			if cmd == nil || m.isSending || m.interruptDeferred {
				t.Fatal("uncertain acknowledgement must settle sending and schedule receipt recovery")
			}
			if m.TextArea.Value() != "" || m.AttachedImage != nil {
				t.Fatal("uncertain send restored a duplicate prompt or image")
			}
			if len(m.pendingUsers) != 1 || m.pendingUsers[0].Image != image || m.pendingUsers[0].Queued != queued {
				t.Fatalf("lost pending submission: %+v", m.pendingUsers)
			}
			if len(m.Notices) != 1 || m.Notices[0].Message == "" {
				t.Fatalf("missing uncertainty guidance: %+v", m.Notices)
			}

			for _, event := range []daemon.StreamEvent{
				{Type: daemon.EventUser, Text: "check this", Source: "chat", ClientID: m.client.ClientID(), OperationID: m.pendingUsers[0].OperationID},
				{Type: daemon.EventCommitted, Seq: 1},
			} {
				m, _ = m.Update(ChatStreamEventMsg{SessionID: "s", Generation: m.Generation, Event: event})
			}
			if len(m.pendingUsers) != 0 || len(m.Notices) != 0 {
				t.Fatalf("stream echo did not reconcile pending submission: pending=%+v notices=%+v", m.pendingUsers, m.Notices)
			}
			users := 0
			for _, entry := range m.History.Entries() {
				if entry.Kind == EntryUser && entry.Text == "check this" {
					users++
				}
			}
			if users != 1 {
				t.Fatalf("want one committed user message, got %d", users)
			}
		})
	}
}

func TestStreamEchoBeforeUncertainAcknowledgementDoesNotRestoreSubmission(t *testing.T) {
	m := newTestChatModel(t, &daemon.Session{ID: "s"})
	m.SetSize(80, 30)
	image := &daemon.ImageAttachment{}
	m.AttachedImage = image
	var commands []tea.Cmd
	m.submitInput("already accepted", &commands)
	handle := m.pendingUsers[0].Handle
	m.TextArea.Reset()
	for _, event := range []daemon.StreamEvent{
		{Type: daemon.EventUser, Text: "already accepted", Source: "chat", ClientID: m.client.ClientID(), OperationID: m.pendingUsers[0].OperationID},
		{Type: daemon.EventCommitted, Seq: 1},
	} {
		m, _ = m.Update(ChatStreamEventMsg{SessionID: "s", Generation: m.Generation, Event: event})
	}

	m, cmd := m.Update(ChatTurnSentMsg{
		SessionID: "s", Generation: m.Generation, Prompt: "already accepted", Image: image,
		Handle: handle, OperationID: handle.ID(),
		Err: &daemon.UncertainOutcomeError{Operation: "submit", Cause: io.EOF, Handle: handle},
	})
	if cmd != nil || m.isSending || len(m.pendingUsers) != 0 || m.TextArea.Value() != "" || m.AttachedImage != nil {
		t.Fatal("late uncertain acknowledgement restored or resubmitted an echoed message")
	}
	users := 0
	for _, entry := range m.History.Entries() {
		if entry.Kind == EntryUser && entry.Text == "already accepted" {
			users++
		}
	}
	if users != 1 {
		t.Fatalf("want one committed user message, got %d", users)
	}
}

func TestUncertainCommandKeepsSessionAndEffort(t *testing.T) {
	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
	session := &daemon.Session{ID: "current", Effort: "low"}
	m := NewAppModel(conn, config.Profiles{}, session, "", false, nil)
	t.Cleanup(m.Chat.Close)
	updated, cmd := m.Update(commandExecutedMsg{Name: "/effort", Effort: "high", Err: &daemon.UncertainOutcomeError{Operation: "command", Cause: io.EOF}})
	m = updated.(AppModel)
	if cmd != nil || m.ActiveSession != session || m.ActiveSession.Effort != "low" || m.Chat.Effort != "low" || m.State != AppStateChat {
		t.Fatal("uncertain command applied an unconfirmed result or resubmitted the command")
	}
	if len(m.Chat.Notices) != 1 || m.Chat.Notices[0].Message == "" {
		t.Fatalf("missing command guidance: %+v", m.Chat.Notices)
	}
}

func TestUncertainSignInDoesNotRestartOrOpenBrowser(t *testing.T) {
	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
	opened := false
	m := NewLoginModel(conn, "", func(string) { opened = true })
	m.Step = StepOAuth
	m, cmd := m.Update(signInStartedMsg{Gen: m.Generation, Err: &daemon.UncertainOutcomeError{Operation: "sign-in", Cause: io.EOF}})
	if cmd != nil || opened || m.LoginID != "" || m.Error == "" {
		t.Fatalf("uncertain sign-in restarted or lost its guidance: %q", m.Error)
	}
	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
	if cmd != nil || m.LoginID != "" {
		t.Fatal("Enter automatically restarted an uncertain sign-in")
	}
}

func TestInvalidGlanceResponseKeepsConfirmedGlances(t *testing.T) {
	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
	m := NewAppModel(conn, config.Profiles{}, &daemon.Session{ID: "current"}, "", false, nil)
	t.Cleanup(m.Chat.Close)
	m.Glances = []PageGlance{{Title: "confirmed"}}
	m.Chat.Glances = m.Glances
	updated, cmd := m.Update(glancesPolledMsg{
		Gen: m.GlanceGen,
		Err: invalidResponseOutcome("/work", "page", errors.New("missing page")),
	})
	m = updated.(AppModel)
	if cmd != nil || len(m.Glances) != 1 || m.Glances[0].Title != "confirmed" || len(m.Chat.Glances) != 1 {
		t.Fatal("invalid glance response replaced confirmed state or retried")
	}
}

func TestInvalidOpenResponseDoesNotRefreshSettings(t *testing.T) {
	conn := daemon.NewConnection(daemon.ConnectionSnapshot{Port: 1}, nil)
	m := NewAppModel(conn, config.Profiles{}, &daemon.Session{ID: "current"}, "", false, nil)
	t.Cleanup(m.Chat.Close)
	_, cmd := m.Update(uiSavedMsg{Open: true, Err: invalidResponseOutcome("record open", "thinking", errors.New("missing preference"))})
	if cmd != nil {
		t.Fatal("invalid open response scheduled a settings refresh")
	}
}

func TestUncertainWebhookStepRetainsConfirmedSecretWithoutRefresh(t *testing.T) {
	m := loadedWebhooksPage(t)
	m.Saving = true
	secret := &webhookSecret{Hook: "ci", Session: "s", Secret: "confirmed-secret"}
	m, cmd := m.Update(webhooksSavedMsg{
		Gen: m.Generation, Reveal: secret,
		Err: invalidResponseOutcome("/webhooks", "hook", errors.New("missing hook")),
	})
	if cmd != nil || m.Reveal != secret || m.Saving || m.Loading {
		t.Fatal("uncertain later step lost the confirmed secret or scheduled a refresh")
	}
}

func invalidResponseOutcome(operation, field string, cause error) error {
	return &daemon.UncertainOutcomeError{Operation: operation, Cause: &daemon.ProtocolError{
		Code: "invalid_response", Operation: operation, Field: field, Cause: cause,
	}}
}

func TestIdenticalPendingTurnsReconcileByOperationID(t *testing.T) {
	m := newTestChatModel(t, &daemon.Session{ID: "s"})
	var commands []tea.Cmd
	m.submitInput("same message", &commands)
	first := m.pendingUsers[0].OperationID
	m.submitInput("same message", &commands)
	second := m.pendingUsers[1].OperationID
	m, _ = m.Update(ChatTurnSentMsg{SessionID: "s", Generation: m.Generation, OperationID: second, Handle: m.pendingUsers[1].Handle, Queued: true, OK: true})
	if m.pendingUsers[0].Queued || !m.pendingUsers[1].Queued {
		t.Fatal("admission result matched the wrong identical message")
	}
	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: "same message", OperationID: second})
	if len(m.pendingUsers) != 1 || m.pendingUsers[0].OperationID != first {
		t.Fatal("stream echo retired the wrong identical message")
	}
	m.handleStreamEvent(daemon.StreamEvent{Type: daemon.EventUser, Text: "same message", OperationID: first})
	if len(m.pendingUsers) != 0 {
		t.Fatal("original pending message was not reconciled")
	}
}

// A timer scheduled before expiry may arrive afterward; rejecting it in the
// model prevents the timer from issuing a receipt query.
func TestScheduledReceiptPollStopsAfterExpiry(t *testing.T) {
	for _, prompt := range []string{"waiting input", "."} {
		t.Run(prompt, func(t *testing.T) {
			m := newTestChatModel(t, &daemon.Session{ID: "s"})
			image := &daemon.ImageAttachment{}
			if prompt != "." {
				m.AttachedImage = image
			}
			var commands []tea.Cmd
			m.submitInput(prompt, &commands)
			var handle *daemon.OperationHandle
			if prompt == "." {
				for _, pending := range m.pendingContinuations {
					handle = pending.Handle
				}
			} else {
				handle = m.pendingUsers[0].Handle
			}
			tick := ChatOperationPollMsg{SessionID: "s", Generation: m.Generation, Handle: handle}
			m, command := m.Update(ChatOperationResolvedMsg{
				SessionID: "s", Generation: m.Generation, Handle: handle,
				Err: &daemon.APIError{StatusCode: 410, Code: "operation_expired"},
			})
			if command != nil {
				t.Fatal("expiry scheduled another receipt poll")
			}
			m, command = m.Update(tick)
			if command != nil || m.operationRecoverable(handle.ID()) {
				t.Fatal("already scheduled tick restarted expired receipt recovery")
			}
			if prompt != "." && (len(m.pendingUsers) != 1 || m.pendingUsers[0].Handle != handle || m.pendingUsers[0].Image != image) {
				t.Fatal("expiry lost the original submission or attachment")
			}
		})
	}
}
