package chat

// NOTE: Integration-level test TestSendMessage_ConcurrentPending_RejectedByPGUniqueIndex
// requires a real Postgres database (the partial unique index messages_one_pending_per_conv
// is enforced at DB layer). This is covered in T19 integration tests.

import (
	"bytes"
	"context"
	"errors"
	"io"
	"strings"
	"sync"
	"testing"

	"github.com/google/uuid"
	"github.com/stretchr/testify/require"

	"github.com/yan1h/agent-coding-workflow/internal/infra/errs"
	"github.com/yan1h/agent-coding-workflow/internal/infra/llm"
	"github.com/yan1h/agent-coding-workflow/internal/infra/storage"
)

// ===== msgFakeRepo =====
// Embeds panicRepo and overrides only the methods needed by messageService.

type msgFakeRepo struct {
	panicRepo

	mu sync.Mutex

	conversations map[uuid.UUID]*Conversation
	messages      map[int64]*Message
	attachments   map[uuid.UUID]*Attachment
	nextMsgID     int64

	// call tracking
	insertedMessages     []*Message
	deletedMessageIDs    []int64
	errorUpdates         []errorUpdate
	attachToMessageCalls []attachCall
	withTxCalled         bool
}

type errorUpdate struct {
	id  int64
	msg *string
}

type attachCall struct {
	ids       []uuid.UUID
	messageID int64
}

func newMsgFakeRepo() *msgFakeRepo {
	return &msgFakeRepo{
		conversations: make(map[uuid.UUID]*Conversation),
		messages:      make(map[int64]*Message),
		attachments:   make(map[uuid.UUID]*Attachment),
		nextMsgID:     1,
	}
}

func (r *msgFakeRepo) addConversation(c *Conversation) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.conversations[c.ID] = c
}

func (r *msgFakeRepo) addAttachment(a *Attachment) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.attachments[a.ID] = a
}

func (r *msgFakeRepo) addMessage(m *Message) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.messages[m.ID] = m
}

func (r *msgFakeRepo) GetConversation(_ context.Context, id uuid.UUID) (*Conversation, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	c, ok := r.conversations[id]
	if !ok {
		return nil, errs.New(errs.CodeChatConversationNotFound, "not found")
	}
	return c, nil
}

func (r *msgFakeRepo) GetLLMModel(_ context.Context, id uuid.UUID) (*LLMModel, error) {
	// Returns a model with ContextWindow=4096, MaxOutputTokens=1024 by default.
	return &LLMModel{
		ID:              id,
		EndpointID:      uuid.New(),
		ContextWindow:   4096,
		MaxOutputTokens: 1024,
		Capabilities:    ModelCapabilities{Image: true, PDF: true},
	}, nil
}

func (r *msgFakeRepo) GetLLMEndpoint(_ context.Context, id uuid.UUID) (*LLMEndpoint, error) {
	return &LLMEndpoint{ID: id}, nil
}

func (r *msgFakeRepo) GetMessage(_ context.Context, id int64) (*Message, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	m, ok := r.messages[id]
	if !ok {
		return nil, errs.New(errs.CodeChatMessageNotFound, "not found")
	}
	return m, nil
}

func (r *msgFakeRepo) InsertMessage(_ context.Context, convID uuid.UUID, role MessageRole, content string, status MessageStatus) (*Message, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	id := r.nextMsgID
	r.nextMsgID++
	m := &Message{
		ID:             id,
		ConversationID: convID,
		Role:           role,
		Content:        content,
		Status:         status,
	}
	r.messages[id] = m
	r.insertedMessages = append(r.insertedMessages, m)
	return m, nil
}

func (r *msgFakeRepo) DeleteMessage(_ context.Context, id int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.messages, id)
	r.deletedMessageIDs = append(r.deletedMessageIDs, id)
	return nil
}

func (r *msgFakeRepo) UpdateMessageError(_ context.Context, id int64, msg *string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if m, ok := r.messages[id]; ok {
		m.Status = StatusError
		m.ErrorMessage = msg
	}
	r.errorUpdates = append(r.errorUpdates, errorUpdate{id: id, msg: msg})
	return nil
}

func (r *msgFakeRepo) AttachToMessage(_ context.Context, ids []uuid.UUID, messageID int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.attachToMessageCalls = append(r.attachToMessageCalls, attachCall{ids: ids, messageID: messageID})
	return nil
}

func (r *msgFakeRepo) ListAttachmentsForMessage(_ context.Context, _ int64) ([]Attachment, error) {
	return nil, nil
}

func (r *msgFakeRepo) ListOrphanAttachmentsForUser(_ context.Context, _ uuid.UUID, ids []uuid.UUID) ([]Attachment, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []Attachment
	for _, id := range ids {
		if a, ok := r.attachments[id]; ok {
			out = append(out, *a)
		}
	}
	return out, nil
}

func (r *msgFakeRepo) ListMessagesAscending(_ context.Context, convID uuid.UUID) ([]Message, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []Message
	for _, m := range r.messages {
		if m.ConversationID == convID {
			out = append(out, *m)
		}
	}
	// Sort by ID ascending.
	for i := 1; i < len(out); i++ {
		for j := i; j > 0 && out[j].ID < out[j-1].ID; j-- {
			out[j], out[j-1] = out[j-1], out[j]
		}
	}
	return out, nil
}

func (r *msgFakeRepo) ListMessagesByConversation(_ context.Context, convID uuid.UUID, beforeID *int64, limit int) ([]Message, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []Message
	for _, m := range r.messages {
		if m.ConversationID == convID {
			if beforeID == nil || m.ID < *beforeID {
				out = append(out, *m)
			}
		}
	}
	if len(out) > limit {
		out = out[len(out)-limit:]
	}
	return out, nil
}

func (r *msgFakeRepo) WithTx(_ context.Context) (Repository, Tx, error) {
	r.mu.Lock()
	r.withTxCalled = true
	r.mu.Unlock()
	return r, &fakeTx{}, nil
}

// ===== fakeHub =====

type fakeHub struct {
	mu          sync.Mutex
	started     []StreamParams
	cancelled   []int64
	startErr    error
	subscribeFn func(int64) (Subscription, bool)
}

func (h *fakeHub) Start(_ context.Context, p StreamParams) error {
	h.mu.Lock()
	defer h.mu.Unlock()
	if h.startErr != nil {
		return h.startErr
	}
	h.started = append(h.started, p)
	return nil
}

func (h *fakeHub) Cancel(messageID int64) bool {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.cancelled = append(h.cancelled, messageID)
	return true
}

func (h *fakeHub) Subscribe(messageID int64) (Subscription, bool) {
	if h.subscribeFn != nil {
		return h.subscribeFn(messageID)
	}
	return nil, false
}

// ===== fakeStorage =====

type fakeStorage struct {
	blobs map[string][]byte
}

func newFakeStorage() *fakeStorage {
	return &fakeStorage{blobs: make(map[string][]byte)}
}

func (f *fakeStorage) Put(_ context.Context, key string, r io.Reader, _ string) (storage.PutResult, error) {
	data, err := io.ReadAll(r)
	if err != nil {
		return storage.PutResult{}, err
	}
	f.blobs[key] = data
	return storage.PutResult{StoragePath: key, Size: int64(len(data))}, nil
}

func (f *fakeStorage) Get(_ context.Context, key string) (io.ReadCloser, error) {
	data, ok := f.blobs[key]
	if !ok {
		return nil, errors.New("not found: " + key)
	}
	return io.NopCloser(bytes.NewReader(data)), nil
}

func (f *fakeStorage) Delete(_ context.Context, _ string) error {
	return nil
}

// ===== test builder =====

func buildMessageService(repo *msgFakeRepo, hub *fakeHub, cfg MessageServiceConfig) MessageService {
	return NewMessageService(repo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), cfg, nil)
}

func defaultMsgConfig() MessageServiceConfig {
	return MessageServiceConfig{
		MimeWhitelist: []string{"image/png", "image/jpeg", "application/pdf", "text/plain"},
		MaxFileBytes:  10 * 1024 * 1024, // 10 MB
		MaxMsgBytes:   50 * 1024 * 1024, // 50 MB
		SafetyPct:     0.1,
	}
}

func newConv(userID, modelID uuid.UUID) *Conversation {
	c := &Conversation{
		ID:      uuid.New(),
		UserID:  userID,
		ModelID: modelID,
	}
	return c
}

// ===== Send tests =====

func TestMessageService_Send_HappyPath(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	modelID := uuid.New()
	conv := newConv(userID, modelID)
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	uID, aID, err := svc.Send(context.Background(), userID, conv.ID, "hello", nil)

	require.NoError(t, err)
	require.Greater(t, uID, int64(0))
	require.Greater(t, aID, int64(0))
	require.NotEqual(t, uID, aID)

	// Two messages inserted: user (OK) + assistant (Pending).
	require.Len(t, repo.insertedMessages, 2)
	require.Equal(t, RoleUser, repo.insertedMessages[0].Role)
	require.Equal(t, StatusOK, repo.insertedMessages[0].Status)
	require.Equal(t, RoleAssistant, repo.insertedMessages[1].Role)
	require.Equal(t, StatusPending, repo.insertedMessages[1].Status)

	// hub.Start was called.
	hub.mu.Lock()
	require.Len(t, hub.started, 1)
	require.Equal(t, aID, hub.started[0].AssistantMessageID)
	hub.mu.Unlock()
}

func TestMessageService_Send_NotOwner_Forbidden(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	owner := uuid.New()
	caller := uuid.New()
	conv := newConv(owner, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, _, err := svc.Send(context.Background(), caller, conv.ID, "hi", nil)

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeForbidden, ae.Code)
}

func TestMessageService_Send_AttachmentOwnershipMismatch(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	// Register only 2 of 3 requested attachment IDs.
	a1 := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100}
	a2 := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100}
	repo.addAttachment(a1)
	repo.addAttachment(a2)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a1.ID, a2.ID, uuid.New()})

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatAttachmentTooLarge, ae.Code)
}

func TestMessageService_Send_AttachmentTooLarge_SingleFile(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	a := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 20 * 1024 * 1024} // 20 MB > 10 MB limit
	repo.addAttachment(a)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a.ID})

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatAttachmentTooLarge, ae.Code)
}

func TestMessageService_Send_AttachmentTooLarge_Total(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	// Each file is 9 MB (under per-file limit), but total 27 MB > 50 MB? Let's use 30+30 = 60 MB.
	a1 := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 30 * 1024 * 1024}
	a2 := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 30 * 1024 * 1024}
	repo.addAttachment(a1)
	repo.addAttachment(a2)

	// Use config with smaller maxMsg to make the test reliable.
	cfg := defaultMsgConfig()
	cfg.MaxFileBytes = 50 * 1024 * 1024 // 50 MB per file — both pass individually
	cfg.MaxMsgBytes = 40 * 1024 * 1024  // 40 MB total — 60 MB fails

	svc := buildMessageService(repo, hub, cfg)
	_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a1.ID, a2.ID})

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatAttachmentTooLarge, ae.Code)
}

func TestMessageService_Send_AttachmentMimeNotAllowed(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	a := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "video/mp4", SizeBytes: 100}
	repo.addAttachment(a)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a.ID})

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatAttachmentUnsupportedMime, ae.Code)
}

func TestMessageService_Send_CapabilityMismatch(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()

	// Model without image capability.
	modelID := uuid.New()
	conv := newConv(userID, modelID)
	repo.addConversation(conv)

	// Override GetLLMModel to return a model without image capability.
	specialRepo := &capMismatchRepo{msgFakeRepo: *newMsgFakeRepo()}
	specialRepo.conversations = repo.conversations
	specialRepo.nextMsgID = 1

	a := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100}
	specialRepo.addAttachment(a)

	svc := NewMessageService(specialRepo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), defaultMsgConfig(), nil)
	_, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a.ID})

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatModelCapabilityMismatch, ae.Code)
}

// capMismatchRepo overrides GetLLMModel to return a model without image capability.
type capMismatchRepo struct {
	msgFakeRepo
}

func (r *capMismatchRepo) GetLLMModel(_ context.Context, id uuid.UUID) (*LLMModel, error) {
	return &LLMModel{
		ID:              id,
		EndpointID:      uuid.New(),
		ContextWindow:   4096,
		MaxOutputTokens: 1024,
		Capabilities:    ModelCapabilities{Image: false, PDF: false},
	}, nil
}

func TestMessageService_Send_HubStartFailure_MarksError(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{startErr: errors.New("hub overload")}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	uID, aID, err := svc.Send(context.Background(), userID, conv.ID, "hello", nil)

	require.Error(t, err)
	require.Greater(t, uID, int64(0))
	require.Greater(t, aID, int64(0))

	// UpdateMessageError must have been called for the assistant message.
	repo.mu.Lock()
	require.Len(t, repo.errorUpdates, 1)
	require.Equal(t, aID, repo.errorUpdates[0].id)
	require.NotNil(t, repo.errorUpdates[0].msg)
	require.Equal(t, "hub overload", *repo.errorUpdates[0].msg)
	repo.mu.Unlock()
}

func TestMessageService_Send_NoAttachments(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, _, err := svc.Send(context.Background(), userID, conv.ID, "hello", []uuid.UUID{})

	require.NoError(t, err)

	// AttachToMessage must NOT have been called.
	repo.mu.Lock()
	require.Empty(t, repo.attachToMessageCalls)
	repo.mu.Unlock()
}

func TestMessageService_Send_WithAttachments_CallsAttachToMessage(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	a1 := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100}
	a2 := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/jpeg", SizeBytes: 200}
	repo.addAttachment(a1)
	repo.addAttachment(a2)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	uID, _, err := svc.Send(context.Background(), userID, conv.ID, "hello", []uuid.UUID{a1.ID, a2.ID})

	require.NoError(t, err)

	repo.mu.Lock()
	require.Len(t, repo.attachToMessageCalls, 1)
	require.Equal(t, uID, repo.attachToMessageCalls[0].messageID)
	require.Len(t, repo.attachToMessageCalls[0].ids, 2)
	repo.mu.Unlock()
}

// ===== Cancel tests =====

func TestMessageService_Cancel_NotPending(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	m := &Message{ID: 99, ConversationID: conv.ID, Status: StatusOK, Role: RoleAssistant}
	repo.addMessage(m)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	err := svc.Cancel(context.Background(), userID, 99)

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatMessageNotPending, ae.Code)
}

func TestMessageService_Cancel_HappyPath(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	m := &Message{ID: 42, ConversationID: conv.ID, Status: StatusPending, Role: RoleAssistant}
	repo.addMessage(m)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	err := svc.Cancel(context.Background(), userID, 42)

	require.NoError(t, err)

	hub.mu.Lock()
	require.Len(t, hub.cancelled, 1)
	require.Equal(t, int64(42), hub.cancelled[0])
	hub.mu.Unlock()
}

func TestMessageService_Cancel_NotOwner_Forbidden(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	owner := uuid.New()
	caller := uuid.New()
	conv := newConv(owner, uuid.New())
	repo.addConversation(conv)

	m := &Message{ID: 77, ConversationID: conv.ID, Status: StatusPending, Role: RoleAssistant}
	repo.addMessage(m)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	err := svc.Cancel(context.Background(), caller, 77)

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeForbidden, ae.Code)
}

// ===== Retry tests =====

func TestMessageService_Retry_NotRetriable(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	m := &Message{ID: 55, ConversationID: conv.ID, Status: StatusOK, Role: RoleAssistant}
	repo.addMessage(m)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, _, err := svc.Retry(context.Background(), userID, 55)

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeChatMessageNotPending, ae.Code)
}

func TestMessageService_Retry_HappyPath(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	// Old failed message.
	old := &Message{ID: 10, ConversationID: conv.ID, Status: StatusError, Role: RoleAssistant}
	repo.addMessage(old)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	newID, convID, err := svc.Retry(context.Background(), userID, 10)

	require.NoError(t, err)
	require.Greater(t, newID, int64(0))
	require.Equal(t, conv.ID, convID)

	// Old message deleted.
	repo.mu.Lock()
	require.Contains(t, repo.deletedMessageIDs, int64(10))

	// New assistant message inserted as Pending.
	var newMsg *Message
	for _, m := range repo.insertedMessages {
		if m.ID == newID {
			newMsg = m
			break
		}
	}
	repo.mu.Unlock()
	require.NotNil(t, newMsg)
	require.Equal(t, StatusPending, newMsg.Status)
	require.Equal(t, RoleAssistant, newMsg.Role)

	// hub.Start called.
	hub.mu.Lock()
	require.Len(t, hub.started, 1)
	require.Equal(t, newID, hub.started[0].AssistantMessageID)
	hub.mu.Unlock()
}

func TestMessageService_Retry_Cancelled_HappyPath(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	old := &Message{ID: 11, ConversationID: conv.ID, Status: StatusCancelled, Role: RoleAssistant}
	repo.addMessage(old)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	newID, convID, err := svc.Retry(context.Background(), userID, 11)

	require.NoError(t, err)
	require.Greater(t, newID, int64(0))
	require.Equal(t, conv.ID, convID)
}

// ===== Stream tests =====

// closedSubscription immediately returns closed=true with one event.
type closedSubscription struct {
	events []SSEEvent
}

func (s *closedSubscription) Next(_ context.Context, lastID int64) ([]SSEEvent, bool, error) {
	if lastID < int64(len(s.events)) {
		out := s.events[lastID:]
		return out, true, nil
	}
	return nil, true, nil
}

type recordingSubscription struct {
	mu      sync.Mutex
	calls   []int64
	batches []subscriptionBatch
}

type subscriptionBatch struct {
	events []SSEEvent
	closed bool
}

func (s *recordingSubscription) Next(_ context.Context, nextID int64) ([]SSEEvent, bool, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	s.calls = append(s.calls, nextID)
	i := len(s.calls) - 1
	if i >= len(s.batches) {
		return nil, true, nil
	}
	batch := s.batches[i]
	return append([]SSEEvent(nil), batch.events...), batch.closed, nil
}

func (s *recordingSubscription) callsSnapshot() []int64 {
	s.mu.Lock()
	defer s.mu.Unlock()
	return append([]int64(nil), s.calls...)
}

func TestMessageService_Stream_HappyPath_OK(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	m := &Message{
		ID:             1,
		ConversationID: conv.ID,
		Status:         StatusOK,
		Role:           RoleAssistant,
		Content:        "hello world",
	}
	repo.addMessage(m)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	ch, err := svc.Stream(context.Background(), userID, conv.ID, 0)

	require.NoError(t, err)

	var events []SSEEvent
	for ev := range ch {
		events = append(events, ev)
	}

	// Expect: message_meta + text_delta + done
	require.GreaterOrEqual(t, len(events), 3)
	require.Equal(t, "message_meta", events[0].Event)
	require.Equal(t, "text_delta", events[1].Event)
	require.Equal(t, "done", events[2].Event)
}

func TestMessageService_Stream_PendingFallbackToError(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{
		subscribeFn: func(_ int64) (Subscription, bool) {
			return nil, false // simulate server restart
		},
	}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	m := &Message{
		ID:             1,
		ConversationID: conv.ID,
		Status:         StatusPending,
		Role:           RoleAssistant,
	}
	repo.addMessage(m)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	ch, err := svc.Stream(context.Background(), userID, conv.ID, 0)

	require.NoError(t, err)

	var events []SSEEvent
	for ev := range ch {
		events = append(events, ev)
	}

	// Should receive message_meta + error event.
	require.GreaterOrEqual(t, len(events), 2)
	var gotError bool
	for _, ev := range events {
		if ev.Event == "error" {
			gotError = true
		}
	}
	require.True(t, gotError, "expected error event in stream")

	// UpdateMessageError should have been called.
	repo.mu.Lock()
	require.NotEmpty(t, repo.errorUpdates)
	require.Equal(t, int64(1), repo.errorUpdates[0].id)
	require.NotNil(t, repo.errorUpdates[0].msg)
	require.Equal(t, "server restart", *repo.errorUpdates[0].msg)
	repo.mu.Unlock()
}

func TestMessageService_Stream_PendingSubscriptionStartsAtFirstEvent(t *testing.T) {
	repo := newMsgFakeRepo()
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	pending := &Message{
		ID:             1,
		ConversationID: conv.ID,
		Status:         StatusPending,
		Role:           RoleAssistant,
	}
	repo.addMessage(pending)

	sub := &recordingSubscription{
		batches: []subscriptionBatch{
			{
				events: []SSEEvent{
					{ID: 0, Event: "text_delta", Data: map[string]string{"text": "hi"}},
				},
				closed: false,
			},
			{
				events: []SSEEvent{
					{ID: 1, Event: "done", Data: map[string]string{"finish_reason": "stop"}},
				},
				closed: true,
			},
		},
	}
	hub := &fakeHub{
		subscribeFn: func(id int64) (Subscription, bool) {
			require.Equal(t, pending.ID, id)
			return sub, true
		},
	}

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	ch, err := svc.Stream(context.Background(), userID, conv.ID, 0)

	require.NoError(t, err)

	var events []SSEEvent
	for ev := range ch {
		events = append(events, ev)
	}

	require.Equal(t, []int64{0, 1}, sub.callsSnapshot())
	require.Len(t, events, 3)
	require.Equal(t, "message_meta", events[0].Event)
	require.Equal(t, "text_delta", events[1].Event)
	require.Equal(t, "done", events[2].Event)
}

func TestMessageService_Stream_NotOwner_Forbidden(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	owner := uuid.New()
	caller := uuid.New()
	conv := newConv(owner, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, err := svc.Stream(context.Background(), caller, conv.ID, 0)

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeForbidden, ae.Code)
}

// ===== ListMessages tests =====

func TestMessageService_ListMessages_DefaultLimit(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())

	// limit=0 should return up to 50 (no error even if no messages exist).
	msgs, err := svc.ListMessages(context.Background(), userID, conv.ID, nil, 0)
	require.NoError(t, err)
	_ = msgs // empty slice is fine; we're testing the limit clamp, not the content
}

func TestMessageService_ListMessages_NotOwner(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	owner := uuid.New()
	caller := uuid.New()
	conv := newConv(owner, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())
	_, err := svc.ListMessages(context.Background(), caller, conv.ID, nil, 10)

	require.Error(t, err)
	var ae *errs.AppError
	require.True(t, errors.As(err, &ae))
	require.Equal(t, errs.CodeForbidden, ae.Code)
}

func TestMessageService_ListMessages_CapClamped(t *testing.T) {
	repo := newMsgFakeRepo()
	hub := &fakeHub{}
	userID := uuid.New()
	conv := newConv(userID, uuid.New())
	repo.addConversation(conv)

	svc := buildMessageService(repo, hub, defaultMsgConfig())

	// limit=999 should be clamped to 50 (no error even if no messages exist).
	msgs, err := svc.ListMessages(context.Background(), userID, conv.ID, nil, 999)
	require.NoError(t, err)
	_ = msgs // empty slice is fine; we're testing the cap clamp, not the content
}

// ===== composeHistory / truncateByTokens tests =====

func TestComposeHistory_TruncatesByTokens(t *testing.T) {
	// Build a list of messages with substantial text content.
	// With a very small budget, only the most recent message should survive.
	msgs := make([]llm.Message, 5)
	for i := range msgs {
		text := "This is message number " + int64ToString(int64(i)) + " with some content that takes up token budget in the conversation history."
		msgs[i] = llm.Message{
			Role:   llm.RoleUser,
			Blocks: []llm.ContentBlock{{Type: "text", Text: text}},
		}
	}

	systemPrompt := ""
	// Budget of 10 tokens: only the last message should fit (roughly 30+ tokens each).
	budget := 10
	result := truncateByTokens(msgs, systemPrompt, budget)

	// With a budget of 10, fewer messages than the original 5 should survive.
	require.Less(t, len(result), len(msgs))
}

func TestTruncateByTokens_AllFit(t *testing.T) {
	msgs := []llm.Message{
		{Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: "hi"}}},
		{Role: llm.RoleAssistant, Blocks: []llm.ContentBlock{{Type: "text", Text: "hello"}}},
	}

	result := truncateByTokens(msgs, "", 10000)
	require.Equal(t, len(msgs), len(result))
}

func TestTruncateByTokens_ZeroBudget(t *testing.T) {
	msgs := []llm.Message{
		{Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: "hi"}}},
	}

	result := truncateByTokens(msgs, "", 0)
	require.Empty(t, result)
}

func TestTruncateByTokens_PreservesNewest(t *testing.T) {
	// Make messages with distinct content.
	oldest := llm.Message{Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: "oldest message that is quite long and should be dropped from history"}}}
	newest := llm.Message{Role: llm.RoleAssistant, Blocks: []llm.ContentBlock{{Type: "text", Text: "hi"}}}

	msgs := []llm.Message{oldest, newest}

	// Budget tight enough for only one message.
	result := truncateByTokens(msgs, "", 5)

	require.Len(t, result, 1)
	require.Equal(t, "hi", result[0].Blocks[0].Text)
}

// ===== composeHistory FK context injection tests =====

// fakeContextReader 是 ContextReader 的测试替身。
type fakeContextReader struct {
	brief  string
	err    error
	called bool
}

func (f *fakeContextReader) BriefForConversation(_ context.Context, _ *Conversation) (string, error) {
	f.called = true
	return f.brief, f.err
}

func newMsgSvcWithReader(repo *msgFakeRepo, reader ContextReader) *messageService {
	return &messageService{
		repo:        repo,
		storage:     newFakeStorage(),
		auditor:     noopAuditor{},
		log:         discardLogger(),
		mimeWhite:   map[string]bool{},
		safetyPct:   0.0,
		briefReader: reader,
	}
}

func TestComposeHistory_FKBriefMergedIntoSystemPrompt(t *testing.T) {
	repo := newMsgFakeRepo()
	reqID := uuid.New()
	conv := newConv(uuid.New(), uuid.New())
	conv.SystemPrompt = "STATIC PROMPT"
	conv.RequirementID = &reqID
	repo.addConversation(conv)
	repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})

	reader := &fakeContextReader{brief: "## 当前需求\n需求 #7：导出报表"}
	svc := newMsgSvcWithReader(repo, reader)

	model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
	req, err := svc.composeHistory(context.Background(), conv, model)
	require.NoError(t, err)
	require.True(t, reader.called)
	require.Contains(t, req.SystemPrompt, "STATIC PROMPT")
	require.Contains(t, req.SystemPrompt, "需求 #7：导出报表")
}

func TestComposeHistory_FKBriefCountsAgainstTokenBudget(t *testing.T) {
	repo := newMsgFakeRepo()
	reqID := uuid.New()
	conv := newConv(uuid.New(), uuid.New())
	conv.RequirementID = &reqID
	repo.addConversation(conv)
	// 多条历史消息，预算很小，FK 简报应挤占消息预算导致历史被截断。
	for i := int64(1); i <= 5; i++ {
		repo.addMessage(&Message{
			ID: i, ConversationID: conv.ID, Role: RoleUser,
			Content: "message content that consumes tokens in the budget", Status: StatusOK,
		})
	}

	hugeBrief := strings.Repeat("需求上下文 ", 300)
	reader := &fakeContextReader{brief: hugeBrief}
	svc := newMsgSvcWithReader(repo, reader)

	// ContextWindow 紧到只够装下简报 + 少量消息：
	// budget = (500-100)*(1-0) = 400 tokens；简报约 450 tokens（已超 budget），
	// 剩余预算 < 5 条消息所需 → 消息被截断。
	model := &LLMModel{ContextWindow: 500, MaxOutputTokens: 100}
	req, err := svc.composeHistory(context.Background(), conv, model)
	require.NoError(t, err)
	require.Contains(t, req.SystemPrompt, "需求上下文")
	// 简报占了预算 → 消息被截断（少于 5 条）。
	require.Less(t, len(req.Messages), 5)
}

func TestComposeHistory_NilReaderPreservesBehavior(t *testing.T) {
	repo := newMsgFakeRepo()
	reqID := uuid.New()
	conv := newConv(uuid.New(), uuid.New())
	conv.SystemPrompt = "ONLY STATIC"
	conv.RequirementID = &reqID
	repo.addConversation(conv)
	repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})

	svc := newMsgSvcWithReader(repo, nil) // 无 reader
	model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
	req, err := svc.composeHistory(context.Background(), conv, model)
	require.NoError(t, err)
	require.Equal(t, "ONLY STATIC", req.SystemPrompt)
}

func TestComposeHistory_NoFKMountSkipsReader(t *testing.T) {
	repo := newMsgFakeRepo()
	conv := newConv(uuid.New(), uuid.New()) // 无任何 FK 挂载
	conv.SystemPrompt = "STATIC"
	repo.addConversation(conv)
	repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})

	reader := &fakeContextReader{brief: "SHOULD NOT APPEAR"}
	svc := newMsgSvcWithReader(repo, reader)
	model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
	req, err := svc.composeHistory(context.Background(), conv, model)
	require.NoError(t, err)
	require.False(t, reader.called, "reader must not be called without FK mount")
	require.Equal(t, "STATIC", req.SystemPrompt)
}

func TestComposeHistory_ReaderErrorFallsBackToStatic(t *testing.T) {
	repo := newMsgFakeRepo()
	reqID := uuid.New()
	conv := newConv(uuid.New(), uuid.New())
	conv.SystemPrompt = "STATIC FALLBACK"
	conv.RequirementID = &reqID
	repo.addConversation(conv)
	repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK})

	reader := &fakeContextReader{err: errs.New(errs.CodeInternal, "boom")}
	svc := newMsgSvcWithReader(repo, reader)
	model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024}
	req, err := svc.composeHistory(context.Background(), conv, model)
	require.NoError(t, err)
	require.Equal(t, "STATIC FALLBACK", req.SystemPrompt)
}

// ===== mimeAllowed / capabilityAllows helpers =====

func TestMimeAllowed(t *testing.T) {
	svc := &messageService{
		mimeWhite: map[string]bool{
			"image/png":       true,
			"application/pdf": true,
			"text/*":          true,
		},
	}

	require.True(t, svc.mimeAllowed("image/png"))
	require.True(t, svc.mimeAllowed("application/pdf"))
	require.True(t, svc.mimeAllowed("text/plain"))
	require.True(t, svc.mimeAllowed("text/html"))
	require.False(t, svc.mimeAllowed("video/mp4"))
	require.False(t, svc.mimeAllowed("audio/mpeg"))
}

func TestCapabilityAllows(t *testing.T) {
	caps := ModelCapabilities{Image: true, PDF: false}

	require.True(t, capabilityAllows(caps, "image/png"))
	require.True(t, capabilityAllows(caps, "image/jpeg"))
	require.False(t, capabilityAllows(caps, "application/pdf"))
	require.True(t, capabilityAllows(caps, "text/plain"))
	require.False(t, capabilityAllows(caps, "video/mp4"))
}
