package service

// Tests for the 2026-08-12/13 message_crud.go findings: the post-commit
// GetDMParticipantIDs fan-out gap on send/edit/delete (OC-0033, OC-0067,
// OC-0068), EditMessage's fail-open channel lookup (OC-0074), DeleteMessage's
// missing archived-channel gate (OC-0077), and SendMessage's redundant
// dm_channel_open re-opens (OC-0106).

import (
	"context"
	"errors"
	"strings"
	"testing"

	"github.com/owncord/server/auth"
	"github.com/owncord/server/db"
	"github.com/owncord/server/permissions"
)

// disconnectAfterWriteStore models a client whose connection drops the instant
// its write commits — the request ctx gets canceled right after the wrapped
// write call returns, before the post-commit DM participant lookup runs.
// GetDMParticipantIDs is overridden to fail whenever it is handed an
// already-canceled context, so a test can tell whether the caller used the
// (canceled) request ctx or a detached one for that lookup.
type disconnectAfterWriteStore struct {
	Store
	cancel context.CancelFunc
}

func (s disconnectAfterWriteStore) CreateMessageWithMentions(ctx context.Context, channelID, userID int64, content string, replyTo *int64, mentionedUserIDs []int64, mentionsEveryone bool) (*db.Message, error) {
	msg, err := s.Store.CreateMessageWithMentions(ctx, channelID, userID, content, replyTo, mentionedUserIDs, mentionsEveryone)
	s.cancel()
	return msg, err
}

func (s disconnectAfterWriteStore) EditMessage(ctx context.Context, id, userID int64, content string) (*db.Message, error) {
	msg, err := s.Store.EditMessage(ctx, id, userID, content)
	s.cancel()
	return msg, err
}

func (s disconnectAfterWriteStore) DeleteMessage(ctx context.Context, id, userID int64, isMod bool) error {
	err := s.Store.DeleteMessage(ctx, id, userID, isMod)
	s.cancel()
	return err
}

func (s disconnectAfterWriteStore) GetDMParticipantIDs(ctx context.Context, channelID int64) ([]int64, error) {
	if ctx.Err() != nil {
		return nil, ctx.Err()
	}
	return s.Store.GetDMParticipantIDs(ctx, channelID)
}

// newDMFixture seeds a two-person DM channel (alice=1, bob=2) and returns the
// permission service so callers can build plain and wrapped MessageServices
// against the same underlying database.
func newDMFixture(t *testing.T) (*db.DB, *PermissionService) {
	t.Helper()
	database := newTestDB(t)
	seedRole(t, database, &db.Role{
		ID:          permissions.MemberRoleID,
		Name:        "member",
		Permissions: permissions.SendMessages | permissions.ReadMessages,
		Position:    1,
	})
	seedUser(t, database, &db.User{ID: 1, Username: "alice"})
	seedUser(t, database, &db.User{ID: 2, Username: "bob"})
	seedUserRole(t, database, 1, permissions.MemberRoleID)
	seedUserRole(t, database, 2, permissions.MemberRoleID)
	seedChannel(t, database, &db.Channel{ID: 50, Name: "dm-1-2", Type: "dm"})
	seedDMParticipant(t, database, 50, 1)
	seedDMParticipant(t, database, 50, 2)
	return database, NewPermissionService(database, permissions.NewChecker(database))
}

// OC-0033: a sender whose connection drops right after their DM message
// commits must not silently drop live fan-out to the other participant(s).
func TestSendMessage_DMFanoutSurvivesSenderDisconnectAfterCommit(t *testing.T) {
	database, permSvc := newDMFixture(t)

	ctx, cancel := context.WithCancel(context.Background())
	svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)

	result, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "hi bob",
	})
	if err != nil {
		t.Fatalf("SendMessage: %v", err)
	}
	if len(result.ParticipantIDs) == 0 {
		t.Fatal("ParticipantIDs empty after a sender disconnect that raced the post-commit DM lookup — " +
			"the recipient gets no live chat_message fan-out until their next reconnect")
	}
}

// OC-0067: same gap on the edit path — a chat_edited must still reach the
// other participant even when the editor's connection drops after the DB
// write commits.
func TestEditMessage_DMFanoutSurvivesEditorDisconnectAfterCommit(t *testing.T) {
	database, permSvc := newDMFixture(t)
	plainSvc := NewMessageService(database, permSvc, nil)

	sent, err := plainSvc.SendMessage(context.Background(), SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "original",
	})
	if err != nil {
		t.Fatalf("send: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)

	editResult, err := svc.EditMessage(ctx, 1, sent.MessageID, "edited content")
	if err != nil {
		t.Fatalf("EditMessage: %v", err)
	}
	if len(editResult.ParticipantIDs) == 0 {
		t.Fatal("ParticipantIDs empty after an editor disconnect that raced the post-commit DM lookup — " +
			"the other participant never sees the edit live")
	}
}

// OC-0068: same gap on the delete path — a chat_deleted must still reach the
// other participant even when the deleter's connection drops after the
// soft-delete commits.
func TestDeleteMessage_DMFanoutSurvivesDeleterDisconnectAfterCommit(t *testing.T) {
	database, permSvc := newDMFixture(t)
	plainSvc := NewMessageService(database, permSvc, nil)

	sent, err := plainSvc.SendMessage(context.Background(), SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "delete me",
	})
	if err != nil {
		t.Fatalf("send: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)

	delResult, err := svc.DeleteMessage(ctx, 1, sent.MessageID)
	if err != nil {
		t.Fatalf("DeleteMessage: %v", err)
	}
	if len(delResult.ParticipantIDs) == 0 {
		t.Fatal("ParticipantIDs empty after a deleter disconnect that raced the post-commit DM lookup — " +
			"the other participant never sees the delete live")
	}
}

// erroringGetChannelStore fails GetChannel for exactly one channel id,
// modeling a transient DB hiccup on that lookup alone.
type erroringGetChannelStore struct {
	Store
	failFor int64
}

func (s erroringGetChannelStore) GetChannel(ctx context.Context, id int64) (*db.Channel, error) {
	if id == s.failFor {
		return nil, errors.New("simulated transient GetChannel failure")
	}
	return s.Store.GetChannel(ctx, id)
}

// OC-0074: a GetChannel failure inside EditMessage must not fail OPEN into
// the non-DM permission branch — that branch passes on the base role mask
// (SEND_MESSAGES|READ_MESSAGES) with no per-channel override to stop it,
// skipping both the DM-participant check and the block gate entirely.
func TestEditMessage_FailsClosedWhenChannelLookupErrors(t *testing.T) {
	database, permSvc := newDMFixture(t)
	plainSvc := NewMessageService(database, permSvc, nil)

	sent, err := plainSvc.SendMessage(context.Background(), SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "hi bob",
	})
	if err != nil {
		t.Fatalf("send: %v", err)
	}
	seedBlock(t, database, 2, 1) // bob blocks alice

	svc := NewMessageService(erroringGetChannelStore{Store: database, failFor: 50}, permSvc, nil)

	if _, err := svc.EditMessage(context.Background(), 1, sent.MessageID, "slipped past the block"); err == nil {
		t.Fatal("EditMessage succeeded despite a failed channel lookup and an active block — " +
			"a GetChannel error must fail closed, not fall through to the non-DM permission path")
	}
	msg, err := database.GetMessage(context.Background(), sent.MessageID)
	if err != nil || msg == nil {
		t.Fatalf("GetMessage: %v", err)
	}
	if msg.Content != "hi bob" {
		t.Fatalf("content must survive the refused edit, got %q", msg.Content)
	}
}

// OC-0077: DeleteMessage must refuse to mutate a message in an archived
// channel, mirroring the SendMessage gate (message_crud.go:54).
func TestDeleteMessage_RefusedInArchivedChannel(t *testing.T) {
	svc, database := newTestMessageService(t)
	ctx := context.Background()

	sent, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 10, UserID: 1, Username: "alice", RoleName: "member", Content: "delete me",
	})
	if err != nil {
		t.Fatalf("send: %v", err)
	}

	if _, err := database.ExecContext(ctx, `UPDATE channels SET archived = 1 WHERE id = 10`); err != nil {
		t.Fatalf("archive channel: %v", err)
	}

	if _, err := svc.DeleteMessage(ctx, 1, sent.MessageID); !errors.Is(err, ErrForbidden) {
		t.Fatalf("DeleteMessage in an archived channel: err = %v, want ErrForbidden", err)
	}

	msg, err := database.GetMessage(ctx, sent.MessageID)
	if err != nil || msg == nil {
		t.Fatalf("GetMessage: %v", err)
	}
	if msg.Deleted {
		t.Fatal("message must survive a delete attempt against an archived channel")
	}
}

// OC-0106: OpenDM is INSERT OR IGNORE and SendMessage used to append every
// recipient to OpenedDMFor unconditionally, so a DM that is already open
// still re-emits dm_channel_open (and bumps the hub's global visibility
// watermark) on every single message. Once a DM is open for a recipient, a
// second send must not report it as freshly opened.
func TestSendMessage_DoesNotReopenAlreadyOpenDM(t *testing.T) {
	database, permSvc := newDMFixture(t)
	svc := NewMessageService(database, permSvc, nil)
	ctx := context.Background()

	first, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "first",
	})
	if err != nil {
		t.Fatalf("first send: %v", err)
	}
	if len(first.OpenedDMFor) != 1 || first.OpenedDMFor[0] != 2 {
		t.Fatalf("first send OpenedDMFor = %v, want [2] (bob's first open)", first.OpenedDMFor)
	}

	second, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "second",
	})
	if err != nil {
		t.Fatalf("second send: %v", err)
	}
	if len(second.OpenedDMFor) != 0 {
		t.Fatalf("second send OpenedDMFor = %v, want [] — the DM was already open for bob, "+
			"so re-reporting it forces a redundant dm_channel_open and a global visibility-watermark bump "+
			"for every other connected client's next reconnect", second.OpenedDMFor)
	}
}

// OC-0033 residual: the recipient's dm_open_state re-open must survive the
// sender's disconnect too — GetDMParticipantIDs was detached from ctx but the
// OpenDM loop was not, so a canceled request ctx silently skipped the re-open
// and the recipient's sidebar never learned the DM existed.
func TestSendMessage_DMReopenSurvivesSenderDisconnectAfterCommit(t *testing.T) {
	database, permSvc := newDMFixture(t)
	// Bob has closed the DM; this send must genuinely re-open it for him.
	if err := database.CloseDM(context.Background(), 2, 50); err != nil {
		t.Fatalf("CloseDM: %v", err)
	}

	ctx, cancel := context.WithCancel(context.Background())
	svc := NewMessageService(disconnectAfterWriteStore{Store: database, cancel: cancel}, permSvc, nil)

	result, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "hi bob",
	})
	if err != nil {
		t.Fatalf("SendMessage: %v", err)
	}
	if len(result.OpenedDMFor) != 1 || result.OpenedDMFor[0] != 2 {
		t.Fatalf("OpenedDMFor = %v, want [2] — OpenDM must run detached from the request ctx, "+
			"or a sender disconnect after commit leaves the DM invisible in the recipient's sidebar",
			result.OpenedDMFor)
	}
}

// Same fail-closed rule the edit path got (OC-0074): DeleteMessage must not
// fall open into the non-DM permission branch — and past the new archived
// gate (OC-0077) — when the channel lookup errors.
func TestDeleteMessage_FailsClosedWhenChannelLookupErrors(t *testing.T) {
	database, permSvc := newDMFixture(t)

	sendSvc := NewMessageService(database, permSvc, nil)
	sent, err := sendSvc.SendMessage(context.Background(), SendMessageParams{
		ChannelID: 50, UserID: 1, Username: "alice", Content: "to be deleted",
	})
	if err != nil {
		t.Fatalf("SendMessage: %v", err)
	}

	svc := NewMessageService(erroringGetChannelStore{Store: database, failFor: 50}, permSvc, nil)
	if _, err := svc.DeleteMessage(context.Background(), 1, sent.MessageID); err == nil {
		t.Fatal("DeleteMessage succeeded despite a failed channel lookup — " +
			"a GetChannel error must fail closed, not fall through to the non-DM permission path")
	}
	msg, err := database.GetMessage(context.Background(), sent.MessageID)
	if err != nil || msg == nil {
		t.Fatalf("message must survive the refused delete; GetMessage: msg=%v err=%v", msg, err)
	}
}

// OC-0036: slow mode's cooldown token is spent by limiter.Allow, which must
// only run once the send has passed content/attachment validation. Consuming
// it earlier means a send that gets rejected for an unrelated reason (content
// too long, in this case) still locks the composer for the full slow-mode
// window even though nothing was ever posted.
func TestSendMessage_SlowModeNotConsumedByFailedContentValidation(t *testing.T) {
	_, database := newTestMessageService(t)
	if err := database.SetChannelSlowMode(context.Background(), 10, 3600); err != nil {
		t.Fatalf("SetChannelSlowMode: %v", err)
	}
	checker := permissions.NewChecker(database)
	permSvc := NewPermissionService(database, checker)
	svc := NewMessageService(database, permSvc, auth.NewRateLimiter())
	ctx := context.Background()

	overLong := strings.Repeat("a", maxMessageLen+1)
	if _, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 10, UserID: 1, Username: "alice", Content: overLong,
	}); !errors.Is(err, ErrBadRequest) {
		t.Fatalf("over-length send: err = %v, want ErrBadRequest", err)
	}

	// The rejected send above must not have spent the once-per-hour slow-mode
	// token: a valid, short send immediately after should still go through.
	if _, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 10, UserID: 1, Username: "alice", Content: "hi",
	}); err != nil {
		t.Fatalf("SendMessage right after a rejected over-length send: %v — slow mode must only be "+
			"charged once a send clears content/attachment validation, not before", err)
	}
}

// disconnectAfterLinkStore models a client whose connection drops the instant
// LinkAttachmentsToMessage commits — mirrors disconnectAfterWriteStore but for
// the attachment path. GetAttachmentsByMessageIDs is overridden to fail
// whenever handed an already-canceled context, so a test can tell whether the
// post-link attachment read used the (canceled) request ctx or a detached one.
type disconnectAfterLinkStore struct {
	Store
	cancel context.CancelFunc
}

func (s disconnectAfterLinkStore) LinkAttachmentsToMessage(ctx context.Context, messageID, uploaderID int64, attachmentIDs []string) (int64, error) {
	n, err := s.Store.LinkAttachmentsToMessage(ctx, messageID, uploaderID, attachmentIDs)
	s.cancel()
	return n, err
}

func (s disconnectAfterLinkStore) GetAttachmentsByMessageIDs(ctx context.Context, msgIDs []int64) (map[int64][]db.AttachmentInfo, error) {
	if ctx.Err() != nil {
		return nil, ctx.Err()
	}
	return s.Store.GetAttachmentsByMessageIDs(ctx, msgIDs)
}

// OC-0128: a sender whose connection drops the instant the attachment link
// commits must still get the linked attachment back on the broadcast result —
// not a message with no content and no attachments. The post-link read must
// run on a detached ctx, the same way the compensating deletes in SendMessage
// already do.
func TestSendMessage_AttachmentsSurviveSenderDisconnectAfterLink(t *testing.T) {
	_, database := newTestMessageService(t)
	// Grant ATTACH_FILES on top of the base member perms newTestMessageService seeds.
	seedRole(t, database, &db.Role{
		ID:          permissions.MemberRoleID,
		Name:        "member",
		Permissions: permissions.SendMessages | permissions.ReadMessages | permissions.AddReactions | permissions.AttachFiles,
		Position:    1,
	})
	if _, err := database.ExecContext(context.Background(),
		`INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size)
		 VALUES (?, ?, ?, ?, ?, ?)`,
		"att-1", 1, "photo.png", "stored-photo.png", "image/png", 100,
	); err != nil {
		t.Fatalf("seed attachment: %v", err)
	}
	checker := permissions.NewChecker(database)
	permSvc := NewPermissionService(database, checker)

	ctx, cancel := context.WithCancel(context.Background())
	svc := NewMessageService(disconnectAfterLinkStore{Store: database, cancel: cancel}, permSvc, nil)

	result, err := svc.SendMessage(ctx, SendMessageParams{
		ChannelID: 10, UserID: 1, Username: "alice", AttachmentIDs: []string{"att-1"},
	})
	if err != nil {
		t.Fatalf("SendMessage: %v", err)
	}
	if len(result.Attachments) != 1 {
		t.Fatalf("Attachments = %v, want 1 — a disconnect right after the attachment link commits must not "+
			"broadcast a blank message bubble", result.Attachments)
	}
}
