package db_test

import (
	"context"
	"testing"
	"time"

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

// ─── JoinVoiceChannelIfCapacity ─────────────────────────────────────────────

func TestVoice_JoinVoiceChannelIfCapacity_UnderLimit(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "cap-u1")
	chanID := seedVoiceChannel(t, database, "cap-ch")

	err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2)
	if err != nil {
		t.Fatalf("JoinVoiceChannelIfCapacity: %v", err)
	}

	state, err := database.GetVoiceState(context.Background(), u1)
	if err != nil {
		t.Fatalf("GetVoiceState: %v", err)
	}
	if state == nil {
		t.Fatal("expected voice state after join")
	}
	if state.ChannelID != chanID {
		t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID)
	}
}

func TestVoice_JoinVoiceChannelIfCapacity_AtLimit(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "cap-full1")
	u2 := seedVoiceUser(t, database, "cap-full2")
	u3 := seedVoiceUser(t, database, "cap-full3")
	chanID := seedVoiceChannel(t, database, "cap-full-ch")

	// Fill channel to capacity (max 2).
	if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, chanID, 2); err != nil {
		t.Fatalf("first join: %v", err)
	}
	if err := database.JoinVoiceChannelIfCapacity(context.Background(), u2, chanID, 2); err != nil {
		t.Fatalf("second join: %v", err)
	}

	// Third join should fail with ErrChannelFull.
	err := database.JoinVoiceChannelIfCapacity(context.Background(), u3, chanID, 2)
	if err == nil {
		t.Fatal("expected ErrChannelFull, got nil")
	}
	if err != db.ErrChannelFull {
		t.Errorf("error = %v, want ErrChannelFull", err)
	}
}

func TestVoice_JoinVoiceChannelIfCapacity_ReplacesOwnState(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "cap-replace")
	ch1 := seedVoiceChannel(t, database, "cap-ch1")
	ch2 := seedVoiceChannel(t, database, "cap-ch2")

	// Join ch1, then join ch2 with capacity check — should replace.
	if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch1, 5); err != nil {
		t.Fatalf("join ch1: %v", err)
	}
	if err := database.JoinVoiceChannelIfCapacity(context.Background(), u1, ch2, 5); err != nil {
		t.Fatalf("join ch2: %v", err)
	}

	state, _ := database.GetVoiceState(context.Background(), u1)
	if state == nil || state.ChannelID != ch2 {
		t.Errorf("expected channel %d, got %v", ch2, state)
	}
}

// ─── GetAllVoiceStates ──────────────────────────────────────────────────────

func TestVoice_GetAllVoiceStates_Empty(t *testing.T) {
	database := newVoiceTestDB(t)

	states, err := database.GetAllVoiceStates(context.Background())
	if err != nil {
		t.Fatalf("GetAllVoiceStates: %v", err)
	}
	if len(states) != 0 {
		t.Errorf("got %d states, want 0", len(states))
	}
}

func TestVoice_GetAllVoiceStates_MultipleChannels(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "all-vs-u1")
	u2 := seedVoiceUser(t, database, "all-vs-u2")
	u3 := seedVoiceUser(t, database, "all-vs-u3")
	ch1 := seedVoiceChannel(t, database, "all-vs-ch1")
	ch2 := seedVoiceChannel(t, database, "all-vs-ch2")

	_ = database.JoinVoiceChannel(context.Background(), u1, ch1)
	_ = database.JoinVoiceChannel(context.Background(), u2, ch1)
	_ = database.JoinVoiceChannel(context.Background(), u3, ch2)

	states, err := database.GetAllVoiceStates(context.Background())
	if err != nil {
		t.Fatalf("GetAllVoiceStates: %v", err)
	}
	if len(states) != 3 {
		t.Errorf("got %d states, want 3", len(states))
	}
}

// ─── CountActiveCameras ─────────────────────────────────────────────────────

func TestVoice_CountActiveCameras_Zero(t *testing.T) {
	database := newVoiceTestDB(t)
	chanID := seedVoiceChannel(t, database, "cam-count-empty")

	count, err := database.CountActiveCameras(context.Background(), chanID)
	if err != nil {
		t.Fatalf("CountActiveCameras: %v", err)
	}
	if count != 0 {
		t.Errorf("count = %d, want 0", count)
	}
}

func TestVoice_CountActiveCameras_SomeCameras(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "cam-cnt-u1")
	u2 := seedVoiceUser(t, database, "cam-cnt-u2")
	u3 := seedVoiceUser(t, database, "cam-cnt-u3")
	chanID := seedVoiceChannel(t, database, "cam-cnt-ch")

	_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
	_ = database.JoinVoiceChannel(context.Background(), u2, chanID)
	_ = database.JoinVoiceChannel(context.Background(), u3, chanID)

	_ = database.UpdateVoiceCamera(context.Background(), u1, true)
	_ = database.UpdateVoiceCamera(context.Background(), u2, true)
	// u3 camera stays off.

	count, err := database.CountActiveCameras(context.Background(), chanID)
	if err != nil {
		t.Fatalf("CountActiveCameras: %v", err)
	}
	if count != 2 {
		t.Errorf("count = %d, want 2", count)
	}
}

// ─── EnableCameraIfUnderLimit ───────────────────────────────────────────────

func TestVoice_EnableCameraIfUnderLimit_Success(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "cam-limit-ok")
	chanID := seedVoiceChannel(t, database, "cam-limit-ch")

	_ = database.JoinVoiceChannel(context.Background(), u1, chanID)

	ok, err := database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2)
	if err != nil {
		t.Fatalf("EnableCameraIfUnderLimit: %v", err)
	}
	if !ok {
		t.Error("expected camera to be enabled")
	}

	state, _ := database.GetVoiceState(context.Background(), u1)
	if state == nil || !state.Camera {
		t.Error("camera should be true after enable")
	}
}

func TestVoice_EnableCameraIfUnderLimit_AtLimit(t *testing.T) {
	database := newVoiceTestDB(t)
	u1 := seedVoiceUser(t, database, "cam-lim-u1")
	u2 := seedVoiceUser(t, database, "cam-lim-u2")
	u3 := seedVoiceUser(t, database, "cam-lim-u3")
	chanID := seedVoiceChannel(t, database, "cam-lim-ch")

	_ = database.JoinVoiceChannel(context.Background(), u1, chanID)
	_ = database.JoinVoiceChannel(context.Background(), u2, chanID)
	_ = database.JoinVoiceChannel(context.Background(), u3, chanID)

	// Enable cameras for u1 and u2 (max is 2).
	_, _ = database.EnableCameraIfUnderLimit(context.Background(), u1, chanID, 2)
	_, _ = database.EnableCameraIfUnderLimit(context.Background(), u2, chanID, 2)

	// u3 should be denied.
	ok, err := database.EnableCameraIfUnderLimit(context.Background(), u3, chanID, 2)
	if err != nil {
		t.Fatalf("EnableCameraIfUnderLimit: %v", err)
	}
	if ok {
		t.Error("expected camera to be denied at limit")
	}
}

// ─── SearchMessagesInChannels ───────────────────────────────────────────────

func TestSearchMessagesInChannels_FindsInAllowedChannels(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "srch-multi")
	ch1 := seedChannel(t, database, "srch-ch1")
	ch2 := seedChannel(t, database, "srch-ch2")
	ch3 := seedChannel(t, database, "srch-ch3")

	_, _ = database.CreateMessage(context.Background(), ch1, userID, "alpha keyword here", nil)
	_, _ = database.CreateMessage(context.Background(), ch2, userID, "beta keyword here", nil)
	_, _ = database.CreateMessage(context.Background(), ch3, userID, "gamma keyword here", nil)

	// Search only in ch1 and ch2.
	results, err := database.SearchMessagesInChannels(context.Background(), "keyword", []int64{ch1, ch2}, 10)
	if err != nil {
		t.Fatalf("SearchMessagesInChannels: %v", err)
	}
	if len(results) != 2 {
		t.Errorf("expected 2 results, got %d", len(results))
	}
	for _, r := range results {
		if r.ChannelID != ch1 && r.ChannelID != ch2 {
			t.Errorf("unexpected channel_id %d in results", r.ChannelID)
		}
	}
}

func TestSearchMessagesInChannels_EmptyQuery(t *testing.T) {
	database := openMigratedMemory(t)

	results, err := database.SearchMessagesInChannels(context.Background(), "", []int64{1}, 10)
	if err != nil {
		t.Fatalf("SearchMessagesInChannels: %v", err)
	}
	if len(results) != 0 {
		t.Errorf("expected 0 results for empty query, got %d", len(results))
	}
}

func TestSearchMessagesInChannels_EmptyChannelIDs(t *testing.T) {
	database := openMigratedMemory(t)

	results, err := database.SearchMessagesInChannels(context.Background(), "test", nil, 10)
	if err != nil {
		t.Fatalf("SearchMessagesInChannels: %v", err)
	}
	if len(results) != 0 {
		t.Errorf("expected 0 results for no channels, got %d", len(results))
	}
}

func TestSearchMessagesInChannels_LimitRespected(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "srch-lim")
	ch1 := seedChannel(t, database, "srch-lim-ch")

	for range 5 {
		_, _ = database.CreateMessage(context.Background(), ch1, userID, "findme content here", nil)
	}

	results, err := database.SearchMessagesInChannels(context.Background(), "findme", []int64{ch1}, 2)
	if err != nil {
		t.Fatalf("SearchMessagesInChannels: %v", err)
	}
	if len(results) != 2 {
		t.Errorf("expected 2 results (limit), got %d", len(results))
	}
}

func TestSearchMessagesInChannels_ZeroLimit(t *testing.T) {
	database := openMigratedMemory(t)

	results, err := database.SearchMessagesInChannels(context.Background(), "test", []int64{1}, 0)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(results) != 0 {
		t.Errorf("expected 0 results for zero limit, got %d", len(results))
	}
}

// ─── GetPinnedMessages ──────────────────────────────────────────────────────

func TestGetPinnedMessages_Empty(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "pin-empty-u")
	chID := seedChannel(t, database, "pin-empty")

	msgs, err := database.GetPinnedMessages(context.Background(), chID, userID)
	if err != nil {
		t.Fatalf("GetPinnedMessages: %v", err)
	}
	if len(msgs) != 0 {
		t.Errorf("expected 0 pinned messages, got %d", len(msgs))
	}
}

func TestGetPinnedMessages_ReturnsPinnedOnly(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "pin-user")
	chID := seedChannel(t, database, "pin-ch")

	id1, _ := database.CreateMessage(context.Background(), chID, userID, "pinned msg", nil)
	_, _ = database.CreateMessage(context.Background(), chID, userID, "not pinned", nil)
	_ = database.SetMessagePinned(context.Background(), id1, true)

	msgs, err := database.GetPinnedMessages(context.Background(), chID, userID)
	if err != nil {
		t.Fatalf("GetPinnedMessages: %v", err)
	}
	if len(msgs) != 1 {
		t.Fatalf("expected 1 pinned message, got %d", len(msgs))
	}
	if msgs[0].Content != "pinned msg" {
		t.Errorf("Content = %q, want 'pinned msg'", msgs[0].Content)
	}
	if !msgs[0].Pinned {
		t.Error("expected Pinned=true")
	}
}

// ─── SetMessagePinned ───────────────────────────────────────────────────────

func TestSetMessagePinned_Pin(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "setpin-u")
	chID := seedChannel(t, database, "setpin-ch")

	id, _ := database.CreateMessage(context.Background(), chID, userID, "to pin", nil)

	if err := database.SetMessagePinned(context.Background(), id, true); err != nil {
		t.Fatalf("SetMessagePinned(true): %v", err)
	}

	msg, _ := database.GetMessage(context.Background(), id)
	if msg == nil || !msg.Pinned {
		t.Error("message should be pinned")
	}
}

func TestSetMessagePinned_Unpin(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "unpin-u")
	chID := seedChannel(t, database, "unpin-ch")

	id, _ := database.CreateMessage(context.Background(), chID, userID, "to unpin", nil)
	_ = database.SetMessagePinned(context.Background(), id, true)
	if err := database.SetMessagePinned(context.Background(), id, false); err != nil {
		t.Fatalf("SetMessagePinned(false): %v", err)
	}

	msg, _ := database.GetMessage(context.Background(), id)
	if msg == nil || msg.Pinned {
		t.Error("message should not be pinned")
	}
}

func TestSetMessagePinned_NotFound(t *testing.T) {
	database := openMigratedMemory(t)

	err := database.SetMessagePinned(context.Background(), 99999, true)
	if err == nil {
		t.Error("expected error for non-existent message")
	}
}

func TestSetMessagePinned_DeletedMessage(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "pin-del-u")
	chID := seedChannel(t, database, "pin-del-ch")

	id, _ := database.CreateMessage(context.Background(), chID, userID, "deleted", nil)
	_ = database.DeleteMessage(context.Background(), id, userID, false)

	err := database.SetMessagePinned(context.Background(), id, true)
	if err == nil {
		t.Error("expected error when pinning deleted message")
	}
}

// ─── CreateAttachment ───────────────────────────────────────────────────────

func TestCreateAttachment_Success(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "att-uploader")

	err := database.CreateAttachment(context.Background(), "att-001", userID, "photo.png", "stored-001.png", "image/png", 12345, nil, nil)
	if err != nil {
		t.Fatalf("CreateAttachment: %v", err)
	}

	att, err := database.GetAttachmentByID(context.Background(), "att-001")
	if err != nil {
		t.Fatalf("GetAttachmentByID: %v", err)
	}
	if att == nil {
		t.Fatal("expected attachment, got nil")
	}
	if att.Filename != "photo.png" {
		t.Errorf("Filename = %q, want 'photo.png'", att.Filename)
	}
	if att.Size != 12345 {
		t.Errorf("Size = %d, want 12345", att.Size)
	}
	if att.MimeType != "image/png" {
		t.Errorf("MimeType = %q, want 'image/png'", att.MimeType)
	}
}

func TestCreateAttachment_WithDimensions(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "att-dim-uploader")
	chID := seedChannel(t, database, "att-dim-chan")
	msgID, err := database.CreateMessage(context.Background(), chID, userID, "with dims", nil)
	if err != nil {
		t.Fatalf("CreateMessage: %v", err)
	}

	w, h := 1920, 1080
	err = database.CreateAttachment(context.Background(), "att-dim", userID, "photo.jpg", "stored-dim.jpg", "image/jpeg", 54321, &w, &h)
	if err != nil {
		t.Fatalf("CreateAttachment with dims: %v", err)
	}

	att, _ := database.GetAttachmentByID(context.Background(), "att-dim")
	if att == nil {
		t.Fatal("expected attachment")
	}

	if n, linkErr := database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"att-dim"}); linkErr != nil || n != 1 {
		t.Fatalf("LinkAttachmentsToMessage: n=%d err=%v", n, linkErr)
	}

	byMsg, err := database.GetAttachmentsByMessageIDs(context.Background(), []int64{msgID})
	if err != nil {
		t.Fatalf("GetAttachmentsByMessageIDs: %v", err)
	}
	infos := byMsg[msgID]
	if len(infos) != 1 {
		t.Fatalf("expected 1 attachment for message, got %d", len(infos))
	}
	if infos[0].Width == nil || *infos[0].Width != w {
		t.Errorf("Width = %v, want %d", infos[0].Width, w)
	}
	if infos[0].Height == nil || *infos[0].Height != h {
		t.Errorf("Height = %v, want %d", infos[0].Height, h)
	}
}

// ─── DeleteOrphanedAttachments ──────────────────────────────────────────────

func TestDeleteOrphanedAttachments_RemovesOrphans(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "orphan-uploader")

	// Create an unlinked attachment (message_id IS NULL).
	_ = database.CreateAttachment(context.Background(), "orphan-1", userID, "file.txt", "stored-orphan.txt", "text/plain", 100, nil, nil)

	// Use a cutoff far in the future so the attachment is considered old.
	files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
	if err != nil {
		t.Fatalf("DeleteOrphanedAttachments: %v", err)
	}
	if len(files) != 1 {
		t.Fatalf("expected 1 orphan, got %d", len(files))
	}
	if files[0] != "stored-orphan.txt" {
		t.Errorf("stored_as = %q, want 'stored-orphan.txt'", files[0])
	}

	// Should be removed from DB.
	att, _ := database.GetAttachmentByID(context.Background(), "orphan-1")
	if att != nil {
		t.Error("orphaned attachment should be deleted from DB")
	}
}

func TestDeleteOrphanedAttachments_KeepsLinked(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "orphan-linked-u")
	chID := seedChannel(t, database, "orphan-linked-ch")

	// Create attachment and link it to a message.
	_ = database.CreateAttachment(context.Background(), "linked-1", userID, "file.txt", "stored-linked.txt", "text/plain", 100, nil, nil)
	msgID, _ := database.CreateMessage(context.Background(), chID, userID, "with attachment", nil)
	_, _ = database.LinkAttachmentsToMessage(context.Background(), msgID, userID, []string{"linked-1"})

	files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
	if err != nil {
		t.Fatalf("DeleteOrphanedAttachments: %v", err)
	}
	if len(files) != 0 {
		t.Errorf("expected 0 orphans (linked), got %d", len(files))
	}
}

// An avatar is an attachment that is deliberately never linked to a message:
// users.avatar points at it by URL and that reference is what authorizes serving
// it (migration 027). The orphan sweep must therefore not treat it as garbage.
func TestDeleteOrphanedAttachments_KeepsLiveAvatars(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "avatar-owner")

	_ = database.CreateAttachment(context.Background(), "avatar-1", userID, "me.png", "stored-avatar.png", "image/png", 100, nil, nil)
	avatarURL := "/api/v1/files/avatar-1"
	if err := database.UpdateUserProfile(context.Background(), userID, "avatar-owner", &avatarURL, nil, nil); err != nil {
		t.Fatalf("UpdateUserProfile: %v", err)
	}

	files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2099, 1, 1, 0, 0, 0, 0, time.UTC))
	if err != nil {
		t.Fatalf("DeleteOrphanedAttachments: %v", err)
	}
	if len(files) != 0 {
		t.Errorf("expected 0 deletions, got %d (%v) — a live avatar was swept", len(files), files)
	}
	att, _ := database.GetAttachmentByID(context.Background(), "avatar-1")
	if att == nil {
		t.Error("live avatar attachment must survive the orphan sweep")
	}
}

// The sweep runs with a one-hour grace period. uploaded_at is written by SQLite
// as 'YYYY-MM-DD HH:MM:SS', so a cutoff in any other shape compares bytewise
// against it and silently collapses the grace window.
func TestDeleteOrphanedAttachments_GracePeriodHoldsSameDay(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "grace-uploader")

	_ = database.CreateAttachment(context.Background(), "fresh-1", userID, "file.txt", "stored-fresh.txt", "text/plain", 100, nil, nil)

	// Exactly what the maintenance loop passes: one hour ago.
	files, err := database.DeleteOrphanedAttachments(context.Background(), time.Now().Add(-1*time.Hour))
	if err != nil {
		t.Fatalf("DeleteOrphanedAttachments: %v", err)
	}
	if len(files) != 0 {
		t.Errorf("expected 0 deletions, got %d (%v) — an upload inside the grace period was swept", len(files), files)
	}
}

func TestDeleteOrphanedAttachments_CutoffRespected(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "cutoff-uploader")

	_ = database.CreateAttachment(context.Background(), "future-1", userID, "file.txt", "stored-future.txt", "text/plain", 100, nil, nil)

	// Cutoff in the past — newly created attachment should NOT be deleted.
	files, err := database.DeleteOrphanedAttachments(context.Background(), time.Date(2000, 1, 1, 0, 0, 0, 0, time.UTC))
	if err != nil {
		t.Fatalf("DeleteOrphanedAttachments: %v", err)
	}
	if len(files) != 0 {
		t.Errorf("expected 0 orphans (cutoff too old), got %d", len(files))
	}
}

// ─── GetAllChannelPermissionsForRole ────────────────────────────────────────

func TestGetAllChannelPermissionsForRole_Empty(t *testing.T) {
	database := openMigratedMemory(t)

	result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4)
	if err != nil {
		t.Fatalf("GetAllChannelPermissionsForRole: %v", err)
	}
	if len(result) != 0 {
		t.Errorf("expected empty map, got %d entries", len(result))
	}
}

func TestGetAllChannelPermissionsForRole_WithOverrides(t *testing.T) {
	database := openMigratedMemory(t)

	ch1, _ := database.CreateChannel(context.Background(), "perm-ch1", "text", "", "", 0)
	ch2, _ := database.CreateChannel(context.Background(), "perm-ch2", "text", "", "", 0)

	// Insert overrides for role 4.
	_, _ = database.ExecContext(context.Background(),
		`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
		ch1, 4, int64(0x100), int64(0x200),
	)
	_, _ = database.ExecContext(context.Background(),
		`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`,
		ch2, 4, int64(0x300), int64(0),
	)

	result, err := database.GetAllChannelPermissionsForRole(context.Background(), 4)
	if err != nil {
		t.Fatalf("GetAllChannelPermissionsForRole: %v", err)
	}
	if len(result) != 2 {
		t.Fatalf("expected 2 entries, got %d", len(result))
	}
	if o, ok := result[ch1]; !ok || o.Allow != 0x100 || o.Deny != 0x200 {
		t.Errorf("ch1 override = %+v, want allow=0x100 deny=0x200", result[ch1])
	}
}

// ─── GetChannelTypes ────────────────────────────────────────────────────────

func TestGetChannelTypes_Empty(t *testing.T) {
	database := openMigratedMemory(t)

	result, err := database.GetChannelTypes(context.Background(), nil)
	if err != nil {
		t.Fatalf("GetChannelTypes: %v", err)
	}
	if len(result) != 0 {
		t.Errorf("expected empty map, got %d", len(result))
	}
}

func TestGetChannelTypes_ReturnsTypes(t *testing.T) {
	database := openMigratedMemory(t)

	ch1, _ := database.CreateChannel(context.Background(), "type-text", "text", "", "", 0)
	ch2, _ := database.CreateChannel(context.Background(), "type-voice", "voice", "", "", 0)

	result, err := database.GetChannelTypes(context.Background(), []int64{ch1, ch2})
	if err != nil {
		t.Fatalf("GetChannelTypes: %v", err)
	}
	if result[ch1] != "text" {
		t.Errorf("ch1 type = %q, want 'text'", result[ch1])
	}
	if result[ch2] != "voice" {
		t.Errorf("ch2 type = %q, want 'voice'", result[ch2])
	}
}

func TestGetChannelTypes_NonExistentIDs(t *testing.T) {
	database := openMigratedMemory(t)

	result, err := database.GetChannelTypes(context.Background(), []int64{99999})
	if err != nil {
		t.Fatalf("GetChannelTypes: %v", err)
	}
	if len(result) != 0 {
		t.Errorf("expected empty map for non-existent IDs, got %d", len(result))
	}
}

// ─── CountUsersWithoutTOTP ──────────────────────────────────────────────────

func TestCountUsersWithoutTOTP_AllWithout(t *testing.T) {
	database := openMigratedMemory(t)
	_, _ = database.CreateUser(context.Background(), "totp-u1", "hash", 4)
	_, _ = database.CreateUser(context.Background(), "totp-u2", "hash", 4)

	count, err := database.CountUsersWithoutTOTP(context.Background())
	if err != nil {
		t.Fatalf("CountUsersWithoutTOTP: %v", err)
	}
	if count != 2 {
		t.Errorf("count = %d, want 2", count)
	}
}

func TestCountUsersWithoutTOTP_WithTOTPSetup(t *testing.T) {
	database := openMigratedMemory(t)
	uid, _ := database.CreateUser(context.Background(), "totp-with", "hash", 4)
	_, _ = database.CreateUser(context.Background(), "totp-without", "hash", 4)

	secret := "JBSWY3DPEHPK3PXP"
	_ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret)

	count, err := database.CountUsersWithoutTOTP(context.Background())
	if err != nil {
		t.Fatalf("CountUsersWithoutTOTP: %v", err)
	}
	if count != 1 {
		t.Errorf("count = %d, want 1 (one has TOTP)", count)
	}
}

// ─── UpdateUserTOTPSecret ───────────────────────────────────────────────────

func TestUpdateUserTOTPSecret_Set(t *testing.T) {
	database := openMigratedMemory(t)
	uid, _ := database.CreateUser(context.Background(), "totp-set", "hash", 4)

	secret := "JBSWY3DPEHPK3PXP"
	if err := database.UpdateUserTOTPSecret(context.Background(), uid, &secret); err != nil {
		t.Fatalf("UpdateUserTOTPSecret(set): %v", err)
	}

	user, _ := database.GetUserByID(context.Background(), uid)
	if user == nil || user.TOTPSecret == nil || *user.TOTPSecret != secret {
		t.Error("TOTP secret should be set")
	}
}

func TestUpdateUserTOTPSecret_Clear(t *testing.T) {
	database := openMigratedMemory(t)
	uid, _ := database.CreateUser(context.Background(), "totp-clear", "hash", 4)

	secret := "JBSWY3DPEHPK3PXP"
	_ = database.UpdateUserTOTPSecret(context.Background(), uid, &secret)
	if err := database.UpdateUserTOTPSecret(context.Background(), uid, nil); err != nil {
		t.Fatalf("UpdateUserTOTPSecret(clear): %v", err)
	}

	user, _ := database.GetUserByID(context.Background(), uid)
	if user == nil || user.TOTPSecret != nil {
		t.Error("TOTP secret should be nil after clear")
	}
}

// ─── CreateUserWithInvite ───────────────────────────────────────────────────

func TestCreateUserWithInvite_Success(t *testing.T) {
	database := openMigratedMemory(t)
	// Create a user who will create the invite.
	creatorID, _ := database.CreateUser(context.Background(), "invite-creator", "hash", 2)

	code, err := database.CreateInvite(context.Background(), creatorID, 5, nil)
	if err != nil {
		t.Fatalf("CreateInvite: %v", err)
	}

	uid, err := database.CreateUserWithInvite(context.Background(), "newuser", "hash", 4, code)
	if err != nil {
		t.Fatalf("CreateUserWithInvite: %v", err)
	}
	if uid <= 0 {
		t.Errorf("expected positive user ID, got %d", uid)
	}

	// Verify invite use count incremented.
	inv, _ := database.GetInvite(context.Background(), code)
	if inv == nil || inv.Uses != 1 {
		t.Errorf("invite uses = %v, want 1", inv)
	}
}

func TestCreateUserWithInvite_InvalidCode(t *testing.T) {
	database := openMigratedMemory(t)

	_, err := database.CreateUserWithInvite(context.Background(), "baduser", "hash", 4, "nonexistent-code")
	if err == nil {
		t.Error("expected error for invalid invite code")
	}
}

func TestCreateUserWithInvite_RevokedInvite(t *testing.T) {
	database := openMigratedMemory(t)
	creatorID, _ := database.CreateUser(context.Background(), "inv-revoke-creator", "hash", 2)

	code, _ := database.CreateInvite(context.Background(), creatorID, 0, nil)
	_ = database.RevokeInvite(context.Background(), code)

	_, err := database.CreateUserWithInvite(context.Background(), "revokeduser", "hash", 4, code)
	if err == nil {
		t.Error("expected error for revoked invite")
	}
}

func TestCreateUserWithInvite_ExpiredInvite(t *testing.T) {
	database := openMigratedMemory(t)
	creatorID, _ := database.CreateUser(context.Background(), "inv-expire-creator", "hash", 2)

	// Create an invite that expires in the past.
	pastTime := time.Now().Add(-1 * time.Hour)
	code, _ := database.CreateInvite(context.Background(), creatorID, 0, &pastTime)

	_, err := database.CreateUserWithInvite(context.Background(), "expireduser", "hash", 4, code)
	if err == nil {
		t.Error("expected error for expired invite")
	}
}

// ─── ListInvites (db layer) ─────────────────────────────────────────────────

func TestListInvites_DB_Empty(t *testing.T) {
	database := openMigratedMemory(t)

	invites, err := database.ListInvites(context.Background())
	if err != nil {
		t.Fatalf("ListInvites: %v", err)
	}
	if len(invites) != 0 {
		t.Errorf("expected 0 invites, got %d", len(invites))
	}
}

func TestListInvites_DB_ReturnsAll(t *testing.T) {
	database := openMigratedMemory(t)
	creatorID, _ := database.CreateUser(context.Background(), "list-inv-creator", "hash", 2)

	_, _ = database.CreateInvite(context.Background(), creatorID, 5, nil)
	_, _ = database.CreateInvite(context.Background(), creatorID, 0, nil)

	invites, err := database.ListInvites(context.Background())
	if err != nil {
		t.Fatalf("ListInvites: %v", err)
	}
	if len(invites) != 2 {
		t.Errorf("expected 2 invites, got %d", len(invites))
	}
}

func TestUseInviteAtomic_NonExistent(t *testing.T) {
	database := openMigratedMemory(t)

	err := database.UseInviteAtomic(context.Background(), "does-not-exist")
	if err == nil {
		t.Error("expected error for non-existent invite")
	}
}

// ─── SearchMessages edge cases ──────────────────────────────────────────────

func TestSearchMessages_EmptyQuery(t *testing.T) {
	database := openMigratedMemory(t)

	results, err := database.SearchMessages(context.Background(), "", nil, 10)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(results) != 0 {
		t.Errorf("expected 0 results for empty query, got %d", len(results))
	}
}

func TestSearchMessages_ZeroLimit(t *testing.T) {
	database := openMigratedMemory(t)

	results, err := database.SearchMessages(context.Background(), "test", nil, 0)
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}
	if len(results) != 0 {
		t.Errorf("expected 0 results for zero limit, got %d", len(results))
	}
}

func TestSearchMessages_SpecialCharsStripped(t *testing.T) {
	database := openMigratedMemory(t)
	userID := seedUser(t, database, "srch-special")
	chID := seedChannel(t, database, "srch-special-ch")

	_, _ = database.CreateMessage(context.Background(), chID, userID, "hello world content", nil)

	// FTS special chars should be stripped, leaving a valid query.
	results, err := database.SearchMessages(context.Background(), "hello* \"world\"", nil, 10)
	if err != nil {
		t.Fatalf("SearchMessages with special chars: %v", err)
	}
	// Should not crash, results may vary.
	_ = results
}
