package ws_test

// perm_cache_test.go — Phase 2 (ws permission cache) coverage: the ws-side
// permission gates now answer from service.PermissionService. Two invariants
// are locked here:
//  1. A role change performed through the invalidating path (the DB write plus
//     InvalidateUser, exactly what admin/handlers_users.go does) is visible to
//     the very next ws permission check — no cache-TTL wait.
//  2. The cache is actually consulted: a second check for the same user does
//     not re-read the role from the store.

import (
	"context"
	"encoding/json"
	"sync/atomic"
	"testing"
	"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"
)

// voiceJoinRaw builds a raw voice_join envelope for channelID.
func voiceJoinRaw(channelID int64) []byte {
	raw, _ := json.Marshal(map[string]any{
		"type":    "voice_join",
		"payload": map[string]any{"channel_id": channelID},
	})
	return raw
}

// TestPermCache_RoleChangeInvalidationIsImmediate drives the voice_join
// CONNECT_VOICE gate (a cached ws-side check) before and after a role change
// that mirrors the admin handler: DB write, then InvalidateUser. The demotion
// must take effect on the immediately following check, with no TTL wait.
// LiveKit is deliberately not configured, so a PASSED gate surfaces as
// VOICE_ERROR ("voice is not configured") instead of FORBIDDEN — which lets
// the test observe the gate verdict without a real SFU.
func TestPermCache_RoleChangeInvalidationIsImmediate(t *testing.T) {
	database := openHandlerDB(t)
	limiter := auth.NewRateLimiter()
	svc := service.New(database, limiter)
	hub := ws.NewHub(database, limiter, svc)
	go hub.Run()
	t.Cleanup(func() { hub.Stop() })

	user := seedMemberUser(t, database, "cache-demote") // Member: has CONNECT_VOICE
	chID := seedTestChannel(t, database, "cache-demote-vc")

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

	// First join: the gate passes (and populates the user's cache entry); the
	// join then fails on the unconfigured LiveKit, not on permissions.
	hub.HandleMessageForTest(c, voiceJoinRaw(chID))
	if code := receiveErrorCode(send, 300*time.Millisecond); code == "FORBIDDEN" {
		t.Fatalf("member with CONNECT_VOICE was denied the voice_join gate (code %q)", code)
	}

	// Demote through the invalidating path, mirroring admin/handlers_users.go:
	// role write first, then InvalidateUser.
	if _, err := database.ExecContext(context.Background(),
		`INSERT INTO roles (id, name, color, permissions, position, is_default)
		 VALUES (150, 'cache-novoice', NULL, ?, 5, 0)`,
		permissions.ReadMessages,
	); err != nil {
		t.Fatalf("seed novoice role: %v", err)
	}
	if _, err := database.ExecContext(context.Background(),
		`UPDATE users SET role_id = 150 WHERE id = ?`, user.ID); err != nil {
		t.Fatalf("reassign user role: %v", err)
	}
	svc.Permissions.InvalidateUser(user.ID)

	// The very next check must see the demoted role — cached entry gone.
	hub.HandleMessageForTest(c, voiceJoinRaw(chID))
	if code := receiveErrorCode(send, 300*time.Millisecond); code != "FORBIDDEN" {
		t.Fatalf("demoted user passed the CONNECT_VOICE gate right after InvalidateUser, got code %q, want FORBIDDEN", code)
	}
}

// countingStore wraps a service.Store and counts GetRoleForUser calls — the
// query every uncached ws-side permission check must issue.
type countingStore struct {
	service.Store
	roleLookups atomic.Int64
}

func (s *countingStore) GetRoleForUser(ctx context.Context, userID int64) (*db.Role, error) {
	s.roleLookups.Add(1)
	return s.Store.GetRoleForUser(ctx, userID)
}

// TestPermCache_SecondCheckServedFromCache proves the ws gates actually use the
// cache: two consecutive voice_join permission checks for the same user issue
// exactly one role lookup against the store — the second is a cache hit.
func TestPermCache_SecondCheckServedFromCache(t *testing.T) {
	database := openHandlerDB(t)
	limiter := auth.NewRateLimiter()
	store := &countingStore{Store: database}
	svc := service.New(store, limiter)
	hub := ws.NewHub(database, limiter, svc)
	go hub.Run()
	t.Cleanup(func() { hub.Stop() })

	user := seedMemberUser(t, database, "cache-hit")
	chID := seedTestChannel(t, database, "cache-hit-vc")

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

	for i := range 2 {
		hub.HandleMessageForTest(c, voiceJoinRaw(chID))
		if code := receiveErrorCode(send, 300*time.Millisecond); code == "FORBIDDEN" {
			t.Fatalf("join %d: member with CONNECT_VOICE was denied the gate", i+1)
		}
	}

	if got := store.roleLookups.Load(); got != 1 {
		t.Fatalf("role lookups through the permission service = %d, want 1 (second check must be a cache hit)", got)
	}
}
