package ws_test

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"testing"
	"testing/fstest"
	"time"

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

// ─── schema used by handler tests ─────────────────────────────────────────────

// handlerTestSchema extends hubTestSchema with the audit_log table required by
// some handler paths, and includes voice_states for completeness.
var handlerTestSchema = append(hubTestSchema, []byte(`
CREATE TABLE IF NOT EXISTS voice_states (
    user_id    INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
    muted      INTEGER NOT NULL DEFAULT 0,
    deafened   INTEGER NOT NULL DEFAULT 0,
    speaking   INTEGER NOT NULL DEFAULT 0,
    server_muted    INTEGER NOT NULL DEFAULT 0,
    server_deafened INTEGER NOT NULL DEFAULT 0,
    joined_at  TEXT    NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);

CREATE TABLE IF NOT EXISTS audit_log (
    id          INTEGER PRIMARY KEY AUTOINCREMENT,
    actor_id    INTEGER NOT NULL REFERENCES users(id),
    action      TEXT    NOT NULL,
    target_type TEXT    NOT NULL DEFAULT '',
    target_id   INTEGER NOT NULL DEFAULT 0,
    detail      TEXT    NOT NULL DEFAULT '',
    created_at  TEXT    NOT NULL DEFAULT (datetime('now'))
);

CREATE TABLE IF NOT EXISTS attachments (
    id          TEXT    PRIMARY KEY,
    message_id  INTEGER REFERENCES messages(id) ON DELETE CASCADE,
    uploader_id INTEGER REFERENCES users(id),
    filename    TEXT    NOT NULL,
    stored_as   TEXT    NOT NULL,
    mime_type   TEXT    NOT NULL,
    size        INTEGER NOT NULL,
    uploaded_at TEXT    NOT NULL DEFAULT (datetime('now')),
    width       INTEGER,
    height      INTEGER
);
`)...)

func openHandlerDB(t *testing.T) *db.DB {
	t.Helper()
	database, err := db.Open(":memory:")
	if err != nil {
		t.Fatalf("db.Open: %v", err)
	}
	t.Cleanup(func() { _ = database.Close() })
	migrFS := fstest.MapFS{
		"001_schema.sql": {Data: handlerTestSchema},
	}
	if err := db.MigrateFS(database, migrFS); err != nil {
		t.Fatalf("MigrateFS: %v", err)
	}
	return database
}

func newHandlerHub(t *testing.T) (*ws.Hub, *db.DB) {
	t.Helper()
	database := openHandlerDB(t)
	limiter := auth.NewRateLimiter()
	st := database
	svc := service.New(st, limiter)
	hub := ws.NewHub(database, limiter, svc)
	go hub.Run()
	t.Cleanup(func() { hub.Stop() })
	return hub, database
}

// seedModUser inserts a Moderator-role user (roleID=3, permissions=1048575 which
// includes MANAGE_MESSAGES bit 0x10000).
func seedModUser(t *testing.T, database *db.DB, username string) *db.User {
	t.Helper()
	_, err := database.CreateUser(context.Background(), username, "hash", 3) // roleID=3 → Moderator
	if err != nil {
		t.Fatalf("seedModUser CreateUser: %v", err)
	}
	user, err := database.GetUserByUsername(context.Background(), username)
	if err != nil || user == nil {
		t.Fatalf("seedModUser GetUserByUsername: %v", err)
	}
	return user
}

// seedMemberUser inserts a Member-role user (roleID=4, permissions=1635) that
// does NOT have MANAGE_MESSAGES (0x10000=65536).
func seedMemberUser(t *testing.T, database *db.DB, username string) *db.User {
	t.Helper()
	_, err := database.CreateUser(context.Background(), username, "hash", 4) // roleID=4 → Member
	if err != nil {
		t.Fatalf("seedMemberUser CreateUser: %v", err)
	}
	user, err := database.GetUserByUsername(context.Background(), username)
	if err != nil || user == nil {
		t.Fatalf("seedMemberUser GetUserByUsername: %v", err)
	}
	return user
}

// seedChannelWithSlowMode creates a text channel and sets its slow_mode to the
// given seconds value, then returns the channel ID.
func seedChannelWithSlowMode(t *testing.T, database *db.DB, name string, slowModeSecs int) int64 {
	t.Helper()
	chID, err := database.CreateChannel(context.Background(), name, "text", "", "", 0)
	if err != nil {
		t.Fatalf("seedChannelWithSlowMode CreateChannel: %v", err)
	}
	if slowModeSecs > 0 {
		if err := database.SetChannelSlowMode(context.Background(), chID, slowModeSecs); err != nil {
			t.Fatalf("seedChannelWithSlowMode SetChannelSlowMode: %v", err)
		}
	}
	return chID
}

// chatSendMsg constructs a raw chat_send WebSocket envelope.
func chatSendMsg(channelID int64, content string) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": "chat_send",
		"payload": map[string]any{
			"channel_id": channelID,
			"content":    content,
		},
	})
	return raw
}

// receiveErrorCode drains up to n messages from ch and returns the first error
// code field found, or "" if none.
func receiveErrorCode(ch <-chan []byte, deadline time.Duration) string {
	timer := time.NewTimer(deadline)
	defer timer.Stop()
	for {
		select {
		case msg := <-ch:
			var env map[string]any
			if err := json.Unmarshal(msg, &env); err != nil {
				continue
			}
			if env["type"] == "error" {
				if payload, ok := env["payload"].(map[string]any); ok {
					code, _ := payload["code"].(string)
					return code
				}
			}
		case <-timer.C:
			return ""
		}
	}
}

// ─── 2.2: Session expiry check in readPump ────────────────────────────────────

// TestSessionExpiry_TokenHashStoredOnClient verifies that a Client created via
// NewTestClientWithTokenHash carries the tokenHash field for periodic revalidation.
func TestSessionExpiry_TokenHashStoredOnClient(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "expiry-user1")
	send := make(chan []byte, 16)

	hash := "deadbeefdeadbeef"
	c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send)

	if got := c.GetTokenHash(); got != hash {
		t.Errorf("GetTokenHash() = %q, want %q", got, hash)
	}
}

// TestSessionExpiry_ValidSessionAllowsMessages verifies that when a client has a
// valid (non-expired) session stored in the DB, the periodic expiry check does
// NOT close the connection.
func TestSessionExpiry_ValidSessionAllowsMessages(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "expiry-user2")
	chID := seedTestChannel(t, database, "expiry-chan2")

	// Create a real session with a far-future expiry.
	token, err := auth.GenerateToken()
	if err != nil {
		t.Fatalf("GenerateToken: %v", err)
	}
	hash := auth.HashToken(token)
	if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil {
		t.Fatalf("CreateSession: %v", err)
	}

	send := make(chan []byte, 64)
	c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Trigger the expiry check by sending enough messages to cross the check threshold.
	for i := range ws.SessionCheckInterval + 1 {
		hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i)))
	}

	// The session check (and any kick) runs synchronously inside handleMessage.
	// Client should still be registered.
	if hub.ClientCount() == 0 {
		t.Error("client was removed despite having a valid session")
	}
}

// TestSessionExpiry_ExpiredSessionClosesConnection verifies that after
// SessionCheckInterval messages, a client whose session has been deleted from
// the DB gets kicked.
func TestSessionExpiry_ExpiredSessionClosesConnection(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "expiry-user3")

	// Create a session then immediately delete it to simulate expiry.
	token, err := auth.GenerateToken()
	if err != nil {
		t.Fatalf("GenerateToken: %v", err)
	}
	hash := auth.HashToken(token)
	if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil {
		t.Fatalf("CreateSession: %v", err)
	}
	// Delete the session to simulate it being expired/revoked.
	if err := database.DeleteSession(context.Background(), hash); err != nil {
		t.Fatalf("DeleteSession: %v", err)
	}

	send := make(chan []byte, 64)
	c := ws.NewTestClientWithTokenHash(hub, user, hash, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Trigger the expiry check.
	for range ws.SessionCheckInterval + 1 {
		// Use a harmless but parseable message to accumulate message count.
		hub.HandleMessageForTest(c, []byte(`{"type":"presence_update","payload":{"status":"online"}}`))
	}
	// The expiry check kicks synchronously (kickClient) inside handleMessage.

	// The client's send channel should be closed (connection severed).
	// We verify this by checking that the send channel has been closed,
	// which manifests as a zero-value receive without blocking.
	select {
	case _, open := <-send:
		_ = open
		// closed channel or a message — either way connection was acted on.
	default:
		// Send channel still open and empty — check hub registration instead.
	}

	// The most reliable assertion: hub should have unregistered the client.
	if hub.ClientCount() != 0 {
		t.Error("expired-session client was not removed from the hub")
	}
}

// TestSessionExpiry_MissingTokenHashSkipsCheck verifies that a client created
// without a token hash (legacy / test-only path) does not crash during the
// periodic check.
func TestSessionExpiry_MissingTokenHashSkipsCheck(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "expiry-user4")
	chID := seedTestChannel(t, database, "expiry-chan4")

	send := make(chan []byte, 64)
	// No token hash — simulates old-style test clients.
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send past the threshold; should not panic or remove the client.
	// The session check runs synchronously inside handleMessage.
	for i := range ws.SessionCheckInterval + 1 {
		hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i)))
	}

	if hub.ClientCount() == 0 {
		t.Error("client without token hash was incorrectly removed")
	}
}

// ─── 2.8: Slow mode enforcement ───────────────────────────────────────────────

// TestSlowMode_ZeroSlowMode_AllowsRapidMessages verifies that when slow_mode=0,
// messages are not throttled by slow mode (only the normal rate limiter applies).
func TestSlowMode_ZeroSlowMode_AllowsRapidMessages(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "slowmode-user1")
	chID := seedTestChannel(t, database, "no-slowmode-chan") // slow_mode defaults to 0

	send := make(chan []byte, 64)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send 3 messages in quick succession.
	for i := range 3 {
		hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("rapid %d", i)))
	}

	// Drain all messages.
	msgs := drainChanTimeout(send, 50*time.Millisecond)
	for _, m := range msgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			if payload, ok := env["payload"].(map[string]any); ok {
				if payload["code"] == "SLOW_MODE" {
					t.Error("got unexpected SLOW_MODE error when slow_mode=0")
				}
			}
		}
	}
}

// TestSlowMode_EnforcedAfterFirstMessage verifies that when slow_mode > 0, the
// second message from the same user within the slow_mode window is rejected.
func TestSlowMode_EnforcedAfterFirstMessage(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedMemberUser(t, database, "slowmode-user2")
	chID := seedChannelWithSlowMode(t, database, "slow-chan", 30) // 30s slow mode

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// First message should succeed.
	hub.HandleMessageForTest(c, chatSendMsg(chID, "first message"))
	drainChanTimeout(send, 30*time.Millisecond) // clear the ack

	// Second message within slow_mode window should be rejected.
	hub.HandleMessageForTest(c, chatSendMsg(chID, "second message too soon"))

	code := receiveErrorCode(send, 200*time.Millisecond)
	if code != "SLOW_MODE" {
		t.Errorf("expected SLOW_MODE error on second message, got %q", code)
	}
}

// TestSlowMode_DifferentUsersNotBlocked verifies that the slow mode key is
// per-user-per-channel: user B sending after user A is not blocked.
func TestSlowMode_DifferentUsersNotBlocked(t *testing.T) {
	hub, database := newHandlerHub(t)
	chID := seedChannelWithSlowMode(t, database, "slow-multi-chan", 30)

	userA := seedMemberUser(t, database, "slowmode-userA")
	userB := seedMemberUser(t, database, "slowmode-userB")

	sendA := make(chan []byte, 32)
	sendB := make(chan []byte, 32)
	cA := ws.NewTestClientWithUser(hub, userA, chID, sendA)
	cB := ws.NewTestClientWithUser(hub, userB, chID, sendB)
	hub.Register(cA)
	hub.Register(cB)
	waitRegistered(t, hub, cB)

	hub.HandleMessageForTest(cA, chatSendMsg(chID, "from A"))

	// B sends after A — B's slow mode window is independent.
	hub.HandleMessageForTest(cB, chatSendMsg(chID, "from B"))

	// B should NOT receive a SLOW_MODE error.
	msgs := drainChanTimeout(sendB, 50*time.Millisecond)
	for _, m := range msgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			if payload, ok := env["payload"].(map[string]any); ok {
				if payload["code"] == "SLOW_MODE" {
					t.Error("user B was incorrectly slow-mode throttled by user A's window")
				}
			}
		}
	}
}

// TestSlowMode_ModeratorBypassesSlowMode verifies that a user with MANAGE_MESSAGES
// permission can send multiple messages without hitting slow mode.
func TestSlowMode_ModeratorBypassesSlowMode(t *testing.T) {
	hub, database := newHandlerHub(t)
	chID := seedChannelWithSlowMode(t, database, "slow-mod-chan", 30)

	mod := seedModUser(t, database, "slowmode-mod")
	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, mod, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send two messages in rapid succession — mod should not be blocked.
	hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 1"))
	drainChanTimeout(send, 20*time.Millisecond)

	hub.HandleMessageForTest(c, chatSendMsg(chID, "mod msg 2"))

	msgs := drainChanTimeout(send, 50*time.Millisecond)
	for _, m := range msgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			if payload, ok := env["payload"].(map[string]any); ok {
				if payload["code"] == "SLOW_MODE" {
					t.Error("moderator was incorrectly blocked by slow mode")
				}
			}
		}
	}
}

// TestSlowMode_DifferentChannels_IndependentWindows verifies that slow mode is
// scoped per-channel: a user hitting slow mode in channel A is not affected in
// channel B.
func TestSlowMode_DifferentChannels_IndependentWindows(t *testing.T) {
	hub, database := newHandlerHub(t)

	chA := seedChannelWithSlowMode(t, database, "slow-chan-A", 30)
	chB := seedChannelWithSlowMode(t, database, "slow-chan-B", 30)

	user := seedMemberUser(t, database, "slowmode-multichan")

	sendA := make(chan []byte, 32)
	sendB := make(chan []byte, 32)

	// Use two separate clients in each channel to simulate the user being in both.
	cA := ws.NewTestClientWithUser(hub, user, chA, sendA)
	// For channel B we need a separate client — re-use same userID is fine for
	// this test since we are calling HandleMessageForTest directly.
	cB := ws.NewTestClientWithUser(hub, user, chB, sendB)

	hub.Register(cA)
	waitRegistered(t, hub, cA)

	// cA sends in channel A — triggers slow mode for A.
	hub.HandleMessageForTest(cA, chatSendMsg(chA, "msg in A"))
	drainChanTimeout(sendA, 20*time.Millisecond)

	// Now send in channel B via cB — should NOT be affected.
	hub.Register(cB)
	waitRegistered(t, hub, cB)

	hub.HandleMessageForTest(cB, chatSendMsg(chB, "msg in B"))

	msgs := drainChanTimeout(sendB, 50*time.Millisecond)
	for _, m := range msgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			if payload, ok := env["payload"].(map[string]any); ok {
				if payload["code"] == "SLOW_MODE" {
					t.Error("slow mode in channel A incorrectly blocked channel B")
				}
			}
		}
	}
}

// ─── Attachment permission ordering ───────────────────────────────────────────

// chatSendMsgWithAttachments constructs a raw chat_send envelope with attachment IDs.
func chatSendMsgWithAttachments(channelID int64, content string, attachmentIDs []string) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": "chat_send",
		"payload": map[string]any{
			"channel_id":  channelID,
			"content":     content,
			"attachments": attachmentIDs,
		},
	})
	return raw
}

// denyAttachOnChannel inserts a channel_override that denies ATTACH_FILES.
func denyAttachOnChannel(t *testing.T, database *db.DB, channelID, roleID int64) {
	t.Helper()
	_, err := database.ExecContext(context.Background(),
		`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, 0, ?)`,
		channelID, roleID, permissions.AttachFiles,
	)
	if err != nil {
		t.Fatalf("denyAttachOnChannel: %v", err)
	}
}

// TestChatSend_AttachmentsDeniedNoMessageCreated verifies that when ATTACH_FILES
// is denied, the message is NOT persisted (permission check before CreateMessage).
func TestChatSend_AttachmentsDeniedNoMessageCreated(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedMemberUser(t, database, "attach-denied")
	chID := seedTestChannel(t, database, "attach-chan")

	// Deny ATTACH_FILES for Member role on this channel.
	denyAttachOnChannel(t, database, chID, permissions.MemberRoleID)

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send a message with attachments — should be rejected before persisting.
	hub.HandleMessageForTest(c, chatSendMsgWithAttachments(chID, "has attachment", []string{"fake-attach-id"}))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "FORBIDDEN" {
		t.Errorf("expected FORBIDDEN for denied ATTACH_FILES, got %q", code)
	}

	// Verify no message was persisted in the database.
	var count int
	err := database.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM messages WHERE channel_id = ?", chID).Scan(&count)
	if err != nil {
		t.Fatalf("count query: %v", err)
	}
	if count != 0 {
		t.Errorf("expected 0 messages in DB (permission denied before persist), got %d", count)
	}
}

// TestSlowMode_ErrorMessageContainsSlowModeDuration verifies the error payload
// describes the slow mode duration.
func TestSlowMode_ErrorMessageContainsSlowModeDuration(t *testing.T) {
	hub, database := newHandlerHub(t)
	const slowSecs = 15
	chID := seedChannelWithSlowMode(t, database, "slow-msg-chan", slowSecs)

	user := seedMemberUser(t, database, "slowmode-errmsg")
	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// First message to prime the window.
	hub.HandleMessageForTest(c, chatSendMsg(chID, "first"))
	drainChanTimeout(send, 20*time.Millisecond)

	// Second message — should receive SLOW_MODE error with duration in message.
	hub.HandleMessageForTest(c, chatSendMsg(chID, "too soon"))

	timer := time.NewTimer(300 * time.Millisecond)
	defer timer.Stop()
	for {
		select {
		case msg := <-send:
			var env map[string]any
			if err := json.Unmarshal(msg, &env); err != nil {
				continue
			}
			if env["type"] != "error" {
				continue
			}
			payload, ok := env["payload"].(map[string]any)
			if !ok {
				continue
			}
			if payload["code"] != "SLOW_MODE" {
				continue
			}
			detail, _ := payload["message"].(string)
			expected := fmt.Sprintf("%ds slow mode", slowSecs)
			if detail == "" {
				t.Error("SLOW_MODE error had empty message")
			} else if len(detail) > 0 {
				// Verify the duration is mentioned somewhere in the message.
				found := false
				for i := 0; i <= len(detail)-len(expected); i++ {
					if detail[i:i+len(expected)] == expected {
						found = true
						break
					}
				}
				if !found {
					t.Errorf("SLOW_MODE message %q does not contain %q", detail, expected)
				}
			}
			return
		case <-timer.C:
			t.Error("did not receive SLOW_MODE error within timeout")
			return
		}
	}
}

// ─── handleChatSend additional coverage ──────────────────────────────────────

// TestChatSend_InvalidPayload_ReturnsBadRequest verifies that a non-object
// payload to chat_send returns BAD_REQUEST.
func TestChatSend_InvalidPayload_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "send-inv1")
	chID := seedTestChannel(t, database, "send-inv-chan1")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type":    "chat_send",
		"payload": "not-an-object",
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code)
	}
}

// TestChatSend_InvalidChannelID_ReturnsBadRequest verifies that channel_id=0
// returns BAD_REQUEST.
func TestChatSend_InvalidChannelID_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "send-inv2")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type": "chat_send",
		"payload": map[string]any{
			"channel_id": 0,
			"content":    "hello",
		},
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for channel_id=0, got %q", code)
	}
}

// TestChatSend_ChannelNotFound_ReturnsNotFound verifies that sending to a
// non-existent channel returns NOT_FOUND.
func TestChatSend_ChannelNotFound_ReturnsNotFound(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "send-inv3")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, 99999, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatSendMsg(99999, "hello"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "NOT_FOUND" {
		t.Errorf("expected NOT_FOUND for non-existent channel, got %q", code)
	}
}

// TestChatSend_EmptyContent_ReturnsBadRequest verifies that content that
// sanitizes to empty is rejected.
func TestChatSend_EmptyContent_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "send-empty1")
	chID := seedTestChannel(t, database, "send-empty-chan")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send message with empty content.
	hub.HandleMessageForTest(c, chatSendMsg(chID, ""))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for empty content, got %q", code)
	}
}

// TestChatSend_TooLongContent_ReturnsBadRequest verifies that content exceeding
// 4000 Unicode code points is rejected.
func TestChatSend_TooLongContent_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "send-long1")
	chID := seedTestChannel(t, database, "send-long-chan")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Build a 4001-rune string to exceed the limit.
	longContent := make([]rune, 4001)
	for i := range longContent {
		longContent[i] = 'a'
	}
	hub.HandleMessageForTest(c, chatSendMsg(chID, string(longContent)))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for too-long content, got %q", code)
	}
}

// TestChatSend_SuccessWithReplyTo verifies that a message with reply_to is
// accepted and the broadcast includes it.
func TestChatSend_SuccessWithReplyTo(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "send-reply1")
	chID := seedTestChannel(t, database, "send-reply-chan")
	parentMsgID, err := database.CreateMessage(context.Background(), chID, user.ID, "parent message", nil)
	if err != nil {
		t.Fatalf("CreateMessage parent: %v", err)
	}

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type": "chat_send",
		"payload": map[string]any{
			"channel_id": chID,
			"content":    "reply message",
			"reply_to":   parentMsgID,
		},
	})
	hub.HandleMessageForTest(c, raw)

	// Should get a chat_send_ok ack.
	timer := time.NewTimer(300 * time.Millisecond)
	defer timer.Stop()
	for {
		select {
		case msg := <-send:
			var env map[string]any
			if err := json.Unmarshal(msg, &env); err != nil {
				continue
			}
			if env["type"] == "chat_send_ok" {
				return // success
			}
		case <-timer.C:
			t.Error("expected chat_send_ok for reply message, got none")
			return
		}
	}
}

// TestChatSend_NilUserClientSendsMessage verifies that a client without a user
// object attached still sends a message (uses empty username/nil avatar).
func TestChatSend_NilUserClientSendsMessage(t *testing.T) {
	hub, database := newHandlerHub(t)
	// Create a client with just an owner userID but no user object,
	// so c.user == nil. The permission check will fail if no user is set.
	// Use an owner-level user so permissions pass.
	owner := seedOwnerUser(t, database, "send-niluser1")
	chID := seedTestChannel(t, database, "send-niluser-chan")

	send := make(chan []byte, 32)
	// Use NewTestClientWithUser so permissions work (user record is attached).
	c := ws.NewTestClientWithUser(hub, owner, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatSendMsg(chID, "hello"))

	// Expect a chat_send_ok.
	timer := time.NewTimer(300 * time.Millisecond)
	defer timer.Stop()
	for {
		select {
		case msg := <-send:
			var env map[string]any
			if err := json.Unmarshal(msg, &env); err != nil {
				continue
			}
			if env["type"] == "chat_send_ok" {
				return
			}
		case <-timer.C:
			t.Error("expected chat_send_ok for normal message, got none")
			return
		}
	}
}

// TestPresence_RateLimit_ReturnsError verifies that sending more than
// presenceRateLimit updates within presenceWindow triggers a rate-limit error.
func TestPresence_RateLimit_ReturnsError(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "presence-rl1")

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// First presence update — should succeed.
	hub.HandleMessageForTest(c, presenceUpdateMsg("online"))
	drainChanTimeout(send, 20*time.Millisecond)

	// Second presence update immediately — should be rate-limited.
	hub.HandleMessageForTest(c, presenceUpdateMsg("idle"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "RATE_LIMITED" {
		t.Errorf("expected RATE_LIMITED for excess presence updates, got %q", code)
	}
}

// ─── helpers for the new handler tests ────────────────────────────────────────

// seedMessage inserts a message into the given channel for the given user
// and returns its ID.
func seedMessage(t *testing.T, database *db.DB, channelID, userID int64, content string) int64 {
	t.Helper()
	id, err := database.CreateMessage(context.Background(), channelID, userID, content, nil)
	if err != nil {
		t.Fatalf("seedMessage CreateMessage: %v", err)
	}
	return id
}

// chatEditMsg constructs a raw chat_edit WebSocket envelope.
func chatEditMsg(messageID int64, content string) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": "chat_edit",
		"payload": map[string]any{
			"message_id": messageID,
			"content":    content,
		},
	})
	return raw
}

// chatDeleteMsg constructs a raw chat_delete WebSocket envelope.
func chatDeleteMsg(messageID int64) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": "chat_delete",
		"payload": map[string]any{
			"message_id": messageID,
		},
	})
	return raw
}

// reactionMsg constructs a raw reaction_add or reaction_remove envelope.
func reactionMsg(msgType string, messageID int64, emoji string) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": msgType,
		"payload": map[string]any{
			"message_id": messageID,
			"emoji":      emoji,
		},
	})
	return raw
}

// typingMsg constructs a raw typing_start envelope.
func typingStartMsg(channelID int64) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": "typing_start",
		"payload": map[string]any{
			"channel_id": channelID,
		},
	})
	return raw
}

// presenceMsg constructs a raw presence_update envelope.
func presenceUpdateMsg(status string) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type": "presence_update",
		"payload": map[string]any{
			"status": status,
		},
	})
	return raw
}

// receiveMsgOfType drains ch until a message with the given type field is found,
// or the deadline elapses. Returns the parsed payload or nil on timeout.
func receiveMsgOfType(ch <-chan []byte, msgType string, deadline time.Duration) map[string]any {
	timer := time.NewTimer(deadline)
	defer timer.Stop()
	for {
		select {
		case msg := <-ch:
			var env map[string]any
			if err := json.Unmarshal(msg, &env); err != nil {
				continue
			}
			if env["type"] == msgType {
				payload, _ := env["payload"].(map[string]any)
				return payload
			}
		case <-timer.C:
			return nil
		}
	}
}

// ─── handleChatEdit ───────────────────────────────────────────────────────────

// TestChatEdit_ValidEdit_BroadcastsChatEdited verifies that editing an owned
// message succeeds and broadcasts a chat_edited event to channel members.
func TestChatEdit_ValidEdit_BroadcastsChatEdited(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "edit-owner1")
	chID := seedTestChannel(t, database, "edit-chan1")
	msgID := seedMessage(t, database, chID, user.ID, "original content")

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatEditMsg(msgID, "edited content"))

	payload := receiveMsgOfType(send, "chat_edited", 300*time.Millisecond)
	if payload == nil {
		t.Fatal("expected chat_edited broadcast, got none")
	}
	// Verify the message ID is included.
	gotID, _ := payload["message_id"].(float64)
	if int64(gotID) != msgID {
		t.Errorf("chat_edited message_id = %v, want %d", gotID, msgID)
	}
}

// TestChatEdit_InvalidPayload_ReturnsBadRequest verifies that malformed JSON
// in the payload returns a BAD_REQUEST error.
func TestChatEdit_InvalidPayload_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "edit-owner2")
	chID := seedTestChannel(t, database, "edit-chan2")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send a chat_edit envelope with an unparseable payload.
	raw, _ := json.Marshal(map[string]any{
		"type":    "chat_edit",
		"payload": "not-an-object",
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code)
	}
}

// TestChatEdit_EmptyContent_ReturnsBadRequest verifies that an empty (or
// HTML-stripped-to-empty) content field is rejected.
func TestChatEdit_EmptyContent_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "edit-owner3")
	chID := seedTestChannel(t, database, "edit-chan3")
	msgID := seedMessage(t, database, chID, user.ID, "original")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatEditMsg(msgID, ""))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for empty content, got %q", code)
	}
}

// TestChatEdit_NotOwner_ReturnsForbidden verifies that editing another user's
// message is rejected with a FORBIDDEN error.
func TestChatEdit_NotOwner_ReturnsForbidden(t *testing.T) {
	hub, database := newHandlerHub(t)
	author := seedOwnerUser(t, database, "edit-author4")
	editor := seedMemberUser(t, database, "edit-editor4")
	chID := seedTestChannel(t, database, "edit-chan4")
	msgID := seedMessage(t, database, chID, author.ID, "author's message")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, editor, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatEditMsg(msgID, "stolen edit"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "FORBIDDEN" {
		t.Errorf("expected FORBIDDEN for editing another's message, got %q", code)
	}
}

// TestChatEdit_InvalidMessageID_ReturnsBadRequest verifies that a non-positive
// message_id is rejected immediately.
func TestChatEdit_InvalidMessageID_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "edit-owner5")
	chID := seedTestChannel(t, database, "edit-chan5")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type": "chat_edit",
		"payload": map[string]any{
			"message_id": 0,
			"content":    "hello",
		},
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for message_id=0, got %q", code)
	}
}

// ─── handleChatDelete ─────────────────────────────────────────────────────────

// TestChatDelete_OwnerDeletesOwn_BroadcastsChatDeleted verifies that a user
// can delete their own message and a chat_deleted broadcast is sent.
func TestChatDelete_OwnerDeletesOwn_BroadcastsChatDeleted(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "del-owner1")
	chID := seedTestChannel(t, database, "del-chan1")
	msgID := seedMessage(t, database, chID, user.ID, "to be deleted")

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatDeleteMsg(msgID))

	payload := receiveMsgOfType(send, "chat_deleted", 300*time.Millisecond)
	if payload == nil {
		t.Fatal("expected chat_deleted broadcast, got none")
	}
	gotID, _ := payload["message_id"].(float64)
	if int64(gotID) != msgID {
		t.Errorf("chat_deleted message_id = %v, want %d", gotID, msgID)
	}
}

// TestChatDelete_ModeratorDeletesOthers_BroadcastsChatDeleted verifies that a
// moderator (who has MANAGE_MESSAGES) can delete any message.
func TestChatDelete_ModeratorDeletesOthers_BroadcastsChatDeleted(t *testing.T) {
	hub, database := newHandlerHub(t)
	author := seedMemberUser(t, database, "del-author2")
	mod := seedModUser(t, database, "del-mod2")
	chID := seedTestChannel(t, database, "del-chan2")
	msgID := seedMessage(t, database, chID, author.ID, "member's message")

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, mod, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatDeleteMsg(msgID))

	payload := receiveMsgOfType(send, "chat_deleted", 300*time.Millisecond)
	if payload == nil {
		t.Fatal("expected chat_deleted broadcast after mod delete, got none")
	}
}

// TestChatDelete_NonOwnerWithoutManageMessages_ReturnsForbidden verifies that a
// regular member cannot delete another user's message.
func TestChatDelete_NonOwnerWithoutManageMessages_ReturnsForbidden(t *testing.T) {
	hub, database := newHandlerHub(t)
	author := seedOwnerUser(t, database, "del-author3")
	other := seedMemberUser(t, database, "del-other3")
	chID := seedTestChannel(t, database, "del-chan3")
	msgID := seedMessage(t, database, chID, author.ID, "owner's message")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, other, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatDeleteMsg(msgID))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "FORBIDDEN" {
		t.Errorf("expected FORBIDDEN for non-owner delete, got %q", code)
	}
}

// TestChatDelete_InvalidPayload_ReturnsBadRequest verifies that a malformed
// payload returns BAD_REQUEST.
func TestChatDelete_InvalidPayload_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "del-owner4")
	chID := seedTestChannel(t, database, "del-chan4")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type":    "chat_delete",
		"payload": "bad",
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code)
	}
}

// TestChatDelete_NonExistentMessage_ReturnsNotFound verifies that attempting
// to delete a message that does not exist returns NOT_FOUND.
func TestChatDelete_NonExistentMessage_ReturnsNotFound(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "del-owner5")
	chID := seedTestChannel(t, database, "del-chan5")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatDeleteMsg(99999))

	// Handler returns FORBIDDEN (not NOT_FOUND) to prevent message-ID enumeration.
	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "FORBIDDEN" {
		t.Errorf("expected FORBIDDEN for non-existent message, got %q", code)
	}
}

// TestChatDelete_InvalidMessageID_ReturnsBadRequest verifies that message_id=0
// is rejected before any DB lookup.
func TestChatDelete_InvalidMessageID_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "del-owner6")
	chID := seedTestChannel(t, database, "del-chan6")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type": "chat_delete",
		"payload": map[string]any{
			"message_id": 0,
		},
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for message_id=0, got %q", code)
	}
}

// TestChatEdit_RateLimit_ReturnsError verifies that exceeding the chat edit
// rate limit returns a RATE_LIMITED error.
func TestChatEdit_RateLimit_ReturnsError(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "edit-rl1")
	chID := seedTestChannel(t, database, "edit-rl-chan1")
	msgID := seedMessage(t, database, chID, user.ID, "original")

	send := make(chan []byte, 64)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Exhaust the rate limit (chatRateLimit = 10 per second).
	for i := range 11 {
		hub.HandleMessageForTest(c, chatEditMsg(msgID, fmt.Sprintf("edit-%d", i)))
	}

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "RATE_LIMITED" {
		t.Errorf("expected RATE_LIMITED for excess chat edits, got %q", code)
	}
}

// TestChatDelete_RateLimit_ReturnsError verifies that exceeding the chat delete
// rate limit returns a RATE_LIMITED error.
func TestChatDelete_RateLimit_ReturnsError(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "del-rl1")
	chID := seedTestChannel(t, database, "del-rl-chan1")

	// Seed enough messages to attempt deleting.
	msgIDs := make([]int64, 11)
	for i := range msgIDs {
		msgIDs[i] = seedMessage(t, database, chID, user.ID, fmt.Sprintf("msg-%d", i))
	}

	send := make(chan []byte, 64)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Exhaust the rate limit (chatRateLimit = 10 per second).
	for _, id := range msgIDs {
		hub.HandleMessageForTest(c, chatDeleteMsg(id))
	}

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "RATE_LIMITED" {
		t.Errorf("expected RATE_LIMITED for excess chat deletes, got %q", code)
	}
}

// ─── BUG-126: Deleted messages must not be editable ─────────────────────────

func TestChatEdit_DeletedMessage_ReturnsForbidden(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "edit-del-owner")
	chID := seedTestChannel(t, database, "edit-del-chan")
	msgID := seedMessage(t, database, chID, user.ID, "to be deleted")

	// Soft-delete the message.
	if err := database.DeleteMessage(context.Background(), msgID, user.ID, false); err != nil {
		t.Fatalf("DeleteMessage: %v", err)
	}

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, chatEditMsg(msgID, "ghost edit"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "FORBIDDEN" {
		t.Errorf("expected FORBIDDEN for editing deleted message, got %q", code)
	}
}

// ─── handleReaction ───────────────────────────────────────────────────────────

// TestReaction_AddReaction_BroadcastsReactionUpdate verifies that adding a
// valid reaction broadcasts a reaction_update event.
func TestReaction_AddReaction_BroadcastsReactionUpdate(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner1")
	chID := seedTestChannel(t, database, "react-chan1")
	msgID := seedMessage(t, database, chID, user.ID, "react to me")

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "👍"))

	payload := receiveMsgOfType(send, "reaction_update", 300*time.Millisecond)
	if payload == nil {
		t.Fatal("expected reaction_update broadcast, got none")
	}
	if payload["action"] != "add" {
		t.Errorf("expected action=add, got %v", payload["action"])
	}
}

// TestReaction_RemoveReaction_BroadcastsReactionUpdate verifies that removing
// a reaction broadcasts a reaction_update event with action=remove.
func TestReaction_RemoveReaction_BroadcastsReactionUpdate(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner2")
	chID := seedTestChannel(t, database, "react-chan2")
	msgID := seedMessage(t, database, chID, user.ID, "react to me 2")

	// Pre-seed the reaction so removal has something to remove.
	if err := database.AddReaction(context.Background(), msgID, user.ID, "❤️"); err != nil {
		t.Fatalf("seedReaction: %v", err)
	}

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_remove", msgID, "❤️"))

	payload := receiveMsgOfType(send, "reaction_update", 300*time.Millisecond)
	if payload == nil {
		t.Fatal("expected reaction_update broadcast for remove, got none")
	}
	if payload["action"] != "remove" {
		t.Errorf("expected action=remove, got %v", payload["action"])
	}
}

// TestReaction_InvalidPayload_ReturnsBadRequest verifies that a malformed
// reaction payload returns BAD_REQUEST.
func TestReaction_InvalidPayload_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner3")
	chID := seedTestChannel(t, database, "react-chan3")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type":    "reaction_add",
		"payload": "bad",
	})
	hub.HandleMessageForTest(c, raw)

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for invalid payload, got %q", code)
	}
}

// TestReaction_EmptyEmoji_ReturnsBadRequest verifies that an empty emoji string
// is rejected.
func TestReaction_EmptyEmoji_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner4")
	chID := seedTestChannel(t, database, "react-chan4")
	msgID := seedMessage(t, database, chID, user.ID, "msg4")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, ""))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for empty emoji, got %q", code)
	}
}

// TestReaction_TooLongEmoji_ReturnsBadRequest verifies that an emoji string
// past the reaction length cap is rejected. The cap is derived from the custom
// emoji shortcode limit (32) plus its two colons, so a reaction has to clear 34
// runes to be refused.
func TestReaction_TooLongEmoji_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner5")
	chID := seedTestChannel(t, database, "react-chan5")
	msgID := seedMessage(t, database, chID, user.ID, "msg5")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// 35 runes — one past the ":" + 32-rune shortcode + ":" ceiling.
	longEmoji := strings.Repeat("a", 35)
	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, longEmoji))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for too-long emoji, got %q", code)
	}
}

// TestReaction_ControlCharInEmoji_ReturnsBadRequest verifies that an emoji
// containing a control character (U+0000–U+001F) is rejected.
func TestReaction_ControlCharInEmoji_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner6")
	chID := seedTestChannel(t, database, "react-chan6")
	msgID := seedMessage(t, database, chID, user.ID, "msg6")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "a\x01b"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for control char in emoji, got %q", code)
	}
}

// TestReaction_NonExistentMessage_ReturnsBadRequest verifies that reacting to
// a non-existent message returns a sanitized BAD_REQUEST (prevents IDOR).
func TestReaction_NonExistentMessage_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner7")
	chID := seedTestChannel(t, database, "react-chan7")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_add", 99999, "👍"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for non-existent message (IDOR sanitize), got %q", code)
	}
}

// TestReaction_DuplicateAdd_ReturnsCONFLICT verifies that adding the same
// emoji twice returns a CONFLICT error (DB unique constraint).
func TestReaction_DuplicateAdd_ReturnsConflict(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner8")
	chID := seedTestChannel(t, database, "react-chan8")
	msgID := seedMessage(t, database, chID, user.ID, "msg8")

	send := make(chan []byte, 32)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// First add — should succeed.
	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "🔥"))
	drainChanTimeout(send, 30*time.Millisecond) // clear the first broadcast

	// Second add of the same emoji — should fail with CONFLICT.
	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "🔥"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "CONFLICT" {
		t.Errorf("expected CONFLICT for duplicate reaction, got %q", code)
	}
}

// TestReaction_InvalidMessageID_ReturnsBadRequest verifies that message_id=0
// is rejected before any DB call.
func TestReaction_InvalidMessageID_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-owner9")
	chID := seedTestChannel(t, database, "react-chan9")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_add", 0, "👍"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for message_id=0, got %q", code)
	}
}

// ─── BUG-126: Deleted messages must not be reactable ─────────────────────────

func TestReaction_DeletedMessage_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "react-del-owner")
	chID := seedTestChannel(t, database, "react-del-chan")
	msgID := seedMessage(t, database, chID, user.ID, "to be deleted")

	// Soft-delete the message.
	if err := database.DeleteMessage(context.Background(), msgID, user.ID, false); err != nil {
		t.Fatalf("DeleteMessage: %v", err)
	}

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, reactionMsg("reaction_add", msgID, "👍"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for reacting to deleted message, got %q", code)
	}
}

// ─── handleTyping ─────────────────────────────────────────────────────────────

// waitForClients blocks until the hub has at least n clients registered, or
// the deadline expires. Returns true if the count was reached.
func waitForClients(hub *ws.Hub, n int, deadline time.Duration) bool {
	deadlineT := time.Now().Add(deadline)
	for time.Now().Before(deadlineT) {
		if hub.ClientCount() >= n {
			return true
		}
		time.Sleep(5 * time.Millisecond)
	}
	return hub.ClientCount() >= n
}

// TestTyping_ValidTyping_BroadcastsToOthers verifies that a typing_start event
// is delivered to other channel members but NOT to the sender.
func TestTyping_ValidTyping_BroadcastsToOthers(t *testing.T) {
	hub, database := newHandlerHub(t)
	chID := seedTestChannel(t, database, "typing-chan1")

	sender := seedOwnerUser(t, database, "typing-sender1")
	watcher := seedMemberUser(t, database, "typing-watcher1")

	sendSender := make(chan []byte, 16)
	sendWatcher := make(chan []byte, 16)

	cSender := ws.NewTestClientWithUser(hub, sender, chID, sendSender)
	cWatcher := ws.NewTestClientWithUser(hub, watcher, chID, sendWatcher)

	hub.Register(cSender)
	hub.Register(cWatcher)
	// Wait until both clients are actually in the hub's client map.
	if !waitForClients(hub, 2, 500*time.Millisecond) {
		t.Fatalf("hub did not register both clients within timeout (count=%d)", hub.ClientCount())
	}
	hub.HandleMessageForTest(cSender, typingStartMsg(chID))

	// Watcher should receive a "typing" broadcast (the outbound event type from
	// buildTypingMsg is "typing", distinct from the inbound "typing_start").
	watcherMsgs := drainChanTimeout(sendWatcher, 50*time.Millisecond)
	foundTyping := false
	for _, m := range watcherMsgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "typing" {
			foundTyping = true
			break
		}
	}
	if !foundTyping {
		t.Error("watcher did not receive typing broadcast")
	}

	// Sender should NOT receive their own typing event.
	senderMsgs := drainChan(sendSender)
	for _, m := range senderMsgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "typing" {
			t.Error("sender incorrectly received their own typing event")
		}
	}
}

// TestTyping_InvalidChannelID_ReturnsBadRequest verifies that a typing_start
// with channel_id=0 returns a BAD_REQUEST error.
func TestTyping_InvalidChannelID_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "typing-owner2")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, typingStartMsg(0))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for channel_id=0, got %q", code)
	}
}

// TestTyping_RateLimited_SilentlyDropped verifies that a second typing_start
// within the rate-limit window is silently dropped (no error sent to client).
func TestTyping_RateLimited_SilentlyDropped(t *testing.T) {
	hub, database := newHandlerHub(t)
	chID := seedTestChannel(t, database, "typing-chan3")

	sender := seedOwnerUser(t, database, "typing-sender3")
	watcher := seedMemberUser(t, database, "typing-watcher3")

	sendSender := make(chan []byte, 16)
	sendWatcher := make(chan []byte, 32)

	cSender := ws.NewTestClientWithUser(hub, sender, chID, sendSender)
	cWatcher := ws.NewTestClientWithUser(hub, watcher, chID, sendWatcher)

	hub.Register(cSender)
	hub.Register(cWatcher)
	if !waitForClients(hub, 2, 500*time.Millisecond) {
		t.Fatalf("hub did not register both clients within timeout")
	}

	// First typing event — should go through.
	hub.HandleMessageForTest(cSender, typingStartMsg(chID))
	drainChanTimeout(sendWatcher, 30*time.Millisecond)

	// Second typing event immediately — should be silently dropped.
	hub.HandleMessageForTest(cSender, typingStartMsg(chID))

	// Sender should NOT receive an error (silently dropped).
	senderMsgs := drainChanTimeout(sendSender, 50*time.Millisecond)
	for _, m := range senderMsgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			t.Errorf("expected silent drop for rate-limited typing, but got error: %s", m)
		}
	}

	// Watcher should NOT receive a second typing event (broadcast type is "typing").
	watcherMsgs := drainChan(sendWatcher)
	typingCount := 0
	for _, m := range watcherMsgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "typing" {
			typingCount++
		}
	}
	if typingCount > 0 {
		t.Errorf("rate-limited typing event was not dropped; watcher received %d extra typing", typingCount)
	}
}

// ─── broadcastExclude ─────────────────────────────────────────────────────────

// TestBroadcastExclude_SendsToOthersNotSelf verifies that broadcastExclude
// delivers to all channel members except the excluded user.
// This is exercised indirectly via typing_start (which calls broadcastExcludeLow).
func TestBroadcastExclude_SendsToOthersNotSelf(t *testing.T) {
	hub, database := newHandlerHub(t)
	chID := seedTestChannel(t, database, "excl-chan1")

	u1 := seedOwnerUser(t, database, "excl-user1")
	u2 := seedMemberUser(t, database, "excl-user2")
	u3 := seedMemberUser(t, database, "excl-user3")

	send1 := make(chan []byte, 16)
	send2 := make(chan []byte, 16)
	send3 := make(chan []byte, 16)

	c1 := ws.NewTestClientWithUser(hub, u1, chID, send1)
	c2 := ws.NewTestClientWithUser(hub, u2, chID, send2)
	c3 := ws.NewTestClientWithUser(hub, u3, chID, send3)

	hub.Register(c1)
	hub.Register(c2)
	hub.Register(c3)
	// Wait until all three are registered in the hub's client map.
	if !waitForClients(hub, 3, 500*time.Millisecond) {
		t.Fatalf("hub did not register all 3 clients within timeout (count=%d)", hub.ClientCount())
	}

	// u1 sends a typing event — should reach u2 and u3 but NOT u1.
	hub.HandleMessageForTest(c1, typingStartMsg(chID))

	// u2 and u3 must receive the "typing" broadcast.
	for i, sendCh := range []<-chan []byte{send2, send3} {
		if receiveMsgOfType(sendCh, "typing", waitTimeout) == nil {
			t.Errorf("user%d (non-sender) did not receive typing broadcast", i+2)
		}
	}

	// u1 (sender) must NOT receive it. The fan-out that reached u2 and u3
	// has completed, so a wrongly-included copy would already be buffered.
	msgs1 := drainChan(send1)
	for _, m := range msgs1 {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "typing" {
			t.Error("sender (excluded user) incorrectly received their own typing event")
		}
	}
}

// TestBroadcastExclude_DifferentChannelNotReceived verifies that broadcastExclude
// does NOT deliver to clients in a different channel.
func TestBroadcastExclude_DifferentChannelNotReceived(t *testing.T) {
	hub, database := newHandlerHub(t)
	chA := seedTestChannel(t, database, "excl-chanA")
	chB := seedTestChannel(t, database, "excl-chanB")

	uA := seedOwnerUser(t, database, "excl-userA")
	uB := seedMemberUser(t, database, "excl-userB")

	sendA := make(chan []byte, 16)
	sendB := make(chan []byte, 16)

	cA := ws.NewTestClientWithUser(hub, uA, chA, sendA)
	cB := ws.NewTestClientWithUser(hub, uB, chB, sendB)

	hub.Register(cA)
	hub.Register(cB)
	if !waitForClients(hub, 2, 500*time.Millisecond) {
		t.Fatalf("hub did not register both clients within timeout")
	}

	// uA types in channel A — uB in channel B must NOT receive it.
	hub.HandleMessageForTest(cA, typingStartMsg(chA))

	msgsB := drainChanTimeout(sendB, 50*time.Millisecond)
	for _, m := range msgsB {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "typing" {
			t.Error("user in different channel incorrectly received typing broadcast via broadcastExclude")
		}
	}
}

// ─── handlePresence (invalid status path) ─────────────────────────────────────

// TestPresence_InvalidStatus_ReturnsBadRequest verifies that a status value
// not in the allowed set (online|idle|dnd|offline) is rejected.
func TestPresence_InvalidStatus_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "presence-bad1")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	hub.HandleMessageForTest(c, presenceUpdateMsg("afk"))

	code := receiveErrorCode(send, 300*time.Millisecond)
	if code != "BAD_REQUEST" {
		t.Errorf("expected BAD_REQUEST for invalid status, got %q", code)
	}
}

// TestPresence_ValidStatus_Broadcasts verifies that valid statuses are accepted
// and broadcast to all connected clients.
func TestPresence_ValidStatus_Broadcasts(t *testing.T) {
	validStatuses := []string{"online", "idle", "dnd", "offline"}
	for _, status := range validStatuses {
		t.Run(status, func(t *testing.T) {
			hub, database := newHandlerHub(t)
			user := seedOwnerUser(t, database, "presence-valid-"+status)

			send := make(chan []byte, 16)
			c := ws.NewTestClientWithUser(hub, user, 0, send)
			hub.Register(c)
			waitRegistered(t, hub, c)

			hub.HandleMessageForTest(c, presenceUpdateMsg(status))

			// Must NOT receive a BAD_REQUEST error.
			msgs := drainChanTimeout(send, 50*time.Millisecond)
			for _, m := range msgs {
				var env map[string]any
				if err := json.Unmarshal(m, &env); err != nil {
					continue
				}
				if env["type"] == "error" {
					if payload, ok := env["payload"].(map[string]any); ok {
						if payload["code"] == "BAD_REQUEST" {
							t.Errorf("valid status %q was incorrectly rejected", status)
						}
					}
				}
			}
		})
	}
}

// ─── handleChannelFocus (additional edge cases) ───────────────────────────────

// TestChannelFocus_ValidFocus_UpdatesChannelID verifies that a successful
// channel_focus updates the client's tracked channel so subsequent broadcasts
// to that channel reach the client.
func TestChannelFocus_ValidFocus_UpdatesChannelID(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "focus-update1")
	chID := seedTestChannel(t, database, "focus-update-chan")

	send := make(chan []byte, 32)
	// Start client on channel 0.
	c := ws.NewTestClientWithUser(hub, user, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Focus on chID.
	raw, _ := json.Marshal(map[string]any{
		"type":    "channel_focus",
		"payload": map[string]any{"channel_id": chID},
	})
	hub.HandleMessageForTest(c, raw)

	// No error expected.
	msgs := drainChanTimeout(send, 50*time.Millisecond)
	for _, m := range msgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			t.Errorf("unexpected error on valid channel_focus: %s", m)
		}
	}

	// Now broadcast to chID — client should receive it because channel was focused.
	hub.BroadcastToChannel(chID, []byte(`{"type":"ping","payload":{}}`))

	if receiveMsgOfType(send, "ping", waitTimeout) == nil {
		t.Error("client did not receive broadcast after channel_focus updated its channelID")
	}
}

// TestChannelFocus_InvalidChannelID_NoResponse verifies that a channel_focus
// with channel_id=0 is silently ignored (no crash, no error message).
func TestChannelFocus_InvalidChannelID_ReturnsBadRequest(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "focus-invalid1")

	send := make(chan []byte, 16)
	c := ws.NewTestClientWithUser(hub, user, 0, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	raw, _ := json.Marshal(map[string]any{
		"type":    "channel_focus",
		"payload": map[string]any{"channel_id": 0},
	})
	hub.HandleMessageForTest(c, raw)

	// Constructor rejects channel_id <= 0 with BAD_REQUEST.
	msgs := drainChanTimeout(send, 50*time.Millisecond)
	found := false
	for _, m := range msgs {
		var env map[string]any
		if err := json.Unmarshal(m, &env); err != nil {
			continue
		}
		if env["type"] == "error" {
			found = true
		}
	}
	if !found {
		t.Error("expected BAD_REQUEST error for channel_id=0, got nothing")
	}
}

// ─── handleMessage ban check (T-044) ─────────────────────────────────────────

// TestHandleMessage_BannedUser_GetKickedAfterSessionCheck verifies that a
// user who has been banned is kicked after the session-expiry check fires.
// The ban is detected via the user record (banned=1) during the session check.
func TestHandleMessage_BannedUser_GetKickedAfterSessionCheck(t *testing.T) {
	hub, database := newHandlerHub(t)
	user := seedOwnerUser(t, database, "banned-user1")
	chID := seedTestChannel(t, database, "banned-chan1")

	// Create a valid session so the session check reaches the user lookup.
	token, err := auth.GenerateToken()
	if err != nil {
		t.Fatalf("GenerateToken: %v", err)
	}
	hash := auth.HashToken(token)
	if _, err := database.CreateSession(context.Background(), user.ID, hash, "test", "127.0.0.1"); err != nil {
		t.Fatalf("CreateSession: %v", err)
	}

	// Ban the user in the database (permanent ban, no expiry).
	if _, err := database.ExecContext(context.Background(),
		`UPDATE users SET banned=1, ban_reason='test ban', ban_expires=NULL WHERE id=?`,
		user.ID,
	); err != nil {
		t.Fatalf("ban user: %v", err)
	}

	send := make(chan []byte, 64)
	c := ws.NewTestClientWithTokenHash(hub, user, hash, chID, send)
	hub.Register(c)
	waitRegistered(t, hub, c)

	// Send enough messages to cross the session-check threshold.
	for i := range ws.SessionCheckInterval + 1 {
		hub.HandleMessageForTest(c, chatSendMsg(chID, fmt.Sprintf("msg %d", i)))
	}

	// The ban check kicks synchronously (kickClient) inside handleMessage.
	if hub.ClientCount() != 0 {
		t.Error("banned user was not kicked after session check")
	}
}
