//go:build integration

package inttest

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"time"

	"github.com/google/uuid"
	"go.uber.org/zap/zaptest"

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/notify"
	"scrabble/backend/internal/server"
	"scrabble/backend/internal/session"
)

// chatAccessBody mirrors the backend's /internal/chat-access JSON for the test.
type chatAccessBody struct {
	ExternalID string `json:"external_id"`
	Registered bool   `json:"registered"`
	Eligible   bool   `json:"eligible"`
}

// chatAccess issues the gateway-internal chat-access query and asserts a 200.
func chatAccess(t *testing.T, srv *server.Server, body string) chatAccessBody {
	t.Helper()
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusOK {
		t.Fatalf("chat-access %s = %d: %s", body, rec.Code, rec.Body.String())
	}
	var b chatAccessBody
	if err := json.Unmarshal(rec.Body.Bytes(), &b); err != nil {
		t.Fatalf("decode chat-access: %v", err)
	}
	return b
}

// TestChatAccessResolver drives the gateway-internal eligibility resolver over HTTP:
// the registered/suspended/chat_muted truth table by Telegram identity and by account
// id, the suspension dominating the chat_muted role, an unknown identity reported
// unregistered, and an account with no Telegram identity carrying an empty external_id.
func TestChatAccessResolver(t *testing.T) {
	ctx := context.Background()
	accounts := account.NewStore(testDB)
	srv := server.New(":0", server.Deps{Logger: zaptest.NewLogger(t), DB: testDB, Accounts: accounts})

	ext := "tg-" + uuid.NewString()
	acc, _, err := accounts.ProvisionTelegram(ctx, ext, "en", "", "Chatter", "")
	if err != nil {
		t.Fatalf("provision: %v", err)
	}
	id := acc.ID

	byExt := func() chatAccessBody { return chatAccess(t, srv, `{"external_id":"`+ext+`"}`) }
	byUser := func() chatAccessBody { return chatAccess(t, srv, `{"user_id":"`+id.String()+`"}`) }

	// A registered, unsuspended, unmuted account is eligible by either address, and the
	// account-id query resolves back to its Telegram identity.
	if b := byExt(); !b.Registered || !b.Eligible || b.ExternalID != ext {
		t.Fatalf("fresh by external_id = %+v, want registered+eligible+ext", b)
	}
	if b := byUser(); !b.Registered || !b.Eligible || b.ExternalID != ext {
		t.Fatalf("fresh by user_id = %+v, want registered+eligible+ext", b)
	}

	// A suspension mutes; a lift restores.
	if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
		t.Fatalf("suspend: %v", err)
	}
	if b := byExt(); !b.Registered || b.Eligible {
		t.Fatalf("suspended = %+v, want registered but not eligible", b)
	}
	if err := accounts.LiftSuspension(ctx, id); err != nil {
		t.Fatalf("lift: %v", err)
	}
	if b := byExt(); !b.Eligible {
		t.Fatalf("after lift = %+v, want eligible", b)
	}

	// The chat_muted role mutes independently; a revoke restores.
	if err := accounts.GrantRole(ctx, id, account.RoleChatMuted); err != nil {
		t.Fatalf("grant chat_muted: %v", err)
	}
	if b := byExt(); !b.Registered || b.Eligible {
		t.Fatalf("chat_muted = %+v, want registered but not eligible", b)
	}

	// Suspension dominates: while chat_muted is set, lifting a concurrent suspension
	// must not re-grant chat (the role still mutes).
	if _, err := accounts.Suspend(ctx, id, nil, "", "", nil); err != nil {
		t.Fatalf("suspend over mute: %v", err)
	}
	if b := byExt(); b.Eligible {
		t.Fatalf("suspended+muted = %+v, want not eligible", b)
	}
	if err := accounts.LiftSuspension(ctx, id); err != nil {
		t.Fatalf("lift over mute: %v", err)
	}
	if b := byExt(); b.Eligible {
		t.Fatalf("lifted but still muted = %+v, want not eligible", b)
	}
	if err := accounts.RevokeRole(ctx, id, account.RoleChatMuted); err != nil {
		t.Fatalf("revoke chat_muted: %v", err)
	}
	if b := byExt(); !b.Eligible {
		t.Fatalf("after revoke = %+v, want eligible", b)
	}

	// An unknown Telegram identity is unregistered (and thus left muted).
	if b := chatAccess(t, srv, `{"external_id":"tg-missing-`+uuid.NewString()+`"}`); b.Registered || b.Eligible {
		t.Fatalf("unknown identity = %+v, want neither registered nor eligible", b)
	}

	// An account with no Telegram identity (a guest) carries an empty external_id, so
	// the gateway has nothing to gate.
	guest := provisionGuest(t)
	if b := chatAccess(t, srv, `{"user_id":"`+guest.String()+`"}`); b.ExternalID != "" || b.Registered {
		t.Fatalf("guest by user_id = %+v, want empty external_id and not registered", b)
	}

	// A request naming neither address is a bad request.
	rec := httptest.NewRecorder()
	req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/chat-access", strings.NewReader(`{}`))
	req.Header.Set("Content-Type", "application/json")
	srv.Handler().ServeHTTP(rec, req)
	if rec.Code != http.StatusBadRequest {
		t.Fatalf("empty query = %d, want 400", rec.Code)
	}
}

// captureNotifier records every published intent so a test can assert which live
// events a console action emitted.
type captureNotifier struct {
	mu      sync.Mutex
	intents []notify.Intent
}

func (c *captureNotifier) Publish(in ...notify.Intent) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.intents = append(c.intents, in...)
}

// count returns how many intents of kind addressed to user were captured.
func (c *captureNotifier) count(user uuid.UUID, kind string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	n := 0
	for _, in := range c.intents {
		if in.UserID == user && in.Kind == kind {
			n++
		}
	}
	return n
}

// TestChatAccessPublishedOnModeration drives the admin console and asserts each
// moderation action that can change chat eligibility — block, unblock, and the
// chat_muted role grant/revoke — emits the chat_access_changed signal the gateway
// turns into a chat-gate command.
func TestChatAccessPublishedOnModeration(t *testing.T) {
	notifier := &captureNotifier{}
	srv := server.New(":0", server.Deps{
		Logger:   zaptest.NewLogger(t),
		DB:       testDB,
		Accounts: account.NewStore(testDB),
		Games:    newGameService(),
		Registry: testRegistry,
		DictDir:  dictDir(),
		Notifier: notifier,
	})
	h := srv.Handler()
	id := provisionAccount(t)
	base := "http://admin.test/_gm/users/" + id.String()
	const origin = "http://admin.test"

	steps := []struct {
		name, path, body string
		want             string
	}{
		{"block", "/block", "duration=permanent", "Blocked"},
		{"unblock", "/unblock", "", "Unblocked"},
		{"grant chat_muted", "/grant-role", "role=chat_muted", "Role granted"},
		{"revoke chat_muted", "/revoke-role", "role=chat_muted", "Role revoked"},
	}
	for i, s := range steps {
		code, body := consoleDo(h, http.MethodPost, base+s.path, s.body, origin)
		if code != http.StatusOK || !strings.Contains(body, s.want) {
			t.Fatalf("%s = %d, has %q = %v", s.name, code, s.want, strings.Contains(body, s.want))
		}
		if got := notifier.count(id, notify.KindChatAccessChanged); got != i+1 {
			t.Fatalf("after %s: chat_access_changed count = %d, want %d", s.name, got, i+1)
		}
	}
}

// TestChatAccessPublishedOnFirstRegistration checks that a Telegram first contact
// (the sessions/telegram endpoint creating the account) emits chat_access_changed —
// the re-grant for a user who joined the moderated chat before registering — and that
// a repeat login does not re-emit.
func TestChatAccessPublishedOnFirstRegistration(t *testing.T) {
	notifier := &captureNotifier{}
	srv := server.New(":0", server.Deps{
		Logger:   zaptest.NewLogger(t),
		DB:       testDB,
		Accounts: account.NewStore(testDB),
		Sessions: session.NewService(session.NewStore(testDB), session.NewCache()),
		Notifier: notifier,
	})
	h := srv.Handler()
	ext := "tg-" + uuid.NewString()

	post := func() {
		rec := httptest.NewRecorder()
		req := httptest.NewRequest(http.MethodPost, "/api/v1/internal/sessions/telegram",
			strings.NewReader(`{"external_id":"`+ext+`","language_code":"en","first_name":"Reg"}`))
		req.Header.Set("Content-Type", "application/json")
		h.ServeHTTP(rec, req)
		if rec.Code != http.StatusOK {
			t.Fatalf("telegram auth = %d: %s", rec.Code, rec.Body.String())
		}
	}

	post()
	acc, err := account.NewStore(testDB).AccountByIdentity(context.Background(), account.KindTelegram, ext)
	if err != nil {
		t.Fatalf("lookup: %v", err)
	}
	if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
		t.Fatalf("first registration: chat_access_changed count = %d, want 1", got)
	}
	// A repeat login (the account already exists) must not re-emit.
	post()
	if got := notifier.count(acc.ID, notify.KindChatAccessChanged); got != 1 {
		t.Fatalf("repeat login: chat_access_changed count = %d, want still 1", got)
	}
}

// TestSuspensionsExpiredBetween checks the sweeper's window query: a non-lifted
// temporary block whose expiry falls in the window is returned, while one outside the
// window, a permanent block, and a lifted block are not.
func TestSuspensionsExpiredBetween(t *testing.T) {
	ctx := context.Background()
	accounts := account.NewStore(testDB)

	// A temporary block whose expiry already lapsed at a known instant.
	tempID := provisionAccount(t)
	expiry := time.Now().Add(-time.Hour).Truncate(time.Second)
	if _, err := accounts.Suspend(ctx, tempID, &expiry, "", "", nil); err != nil {
		t.Fatalf("suspend temp: %v", err)
	}

	contains := func(ids []uuid.UUID, want uuid.UUID) bool {
		for _, id := range ids {
			if id == want {
				return true
			}
		}
		return false
	}

	// A window straddling the expiry returns the account.
	got, err := accounts.SuspensionsExpiredBetween(ctx, expiry.Add(-time.Minute), expiry.Add(time.Minute))
	if err != nil {
		t.Fatalf("expired between: %v", err)
	}
	if !contains(got, tempID) {
		t.Fatalf("window over expiry missing the lapsed block %s", tempID)
	}
	// A window entirely after the expiry does not.
	got, err = accounts.SuspensionsExpiredBetween(ctx, expiry.Add(time.Minute), expiry.Add(2*time.Minute))
	if err != nil {
		t.Fatalf("expired between (after): %v", err)
	}
	if contains(got, tempID) {
		t.Fatalf("window after expiry should not return %s", tempID)
	}

	// A permanent block never appears, even in a wide window.
	permID := provisionAccount(t)
	if _, err := accounts.Suspend(ctx, permID, nil, "", "", nil); err != nil {
		t.Fatalf("suspend perm: %v", err)
	}
	// A lifted block does not appear either. The block must still be in force when lifted
	// (LiftSuspension only lifts in-force blocks), so its expiry is in the future and the
	// wide window below still covers it — yet lifted_at excludes it.
	liftID := provisionAccount(t)
	liftExpiry := time.Now().Add(30 * time.Minute).Truncate(time.Second)
	if _, err := accounts.Suspend(ctx, liftID, &liftExpiry, "", "", nil); err != nil {
		t.Fatalf("suspend lift: %v", err)
	}
	if err := accounts.LiftSuspension(ctx, liftID); err != nil {
		t.Fatalf("lift: %v", err)
	}
	wide, err := accounts.SuspensionsExpiredBetween(ctx, time.Now().Add(-2*time.Hour), time.Now().Add(time.Hour))
	if err != nil {
		t.Fatalf("expired between (wide): %v", err)
	}
	if contains(wide, permID) {
		t.Fatalf("permanent block %s must not be reported as expired", permID)
	}
	if contains(wide, liftID) {
		t.Fatalf("lifted block %s must not be reported as expired", liftID)
	}
}
