//go:build integration

package inttest

import (
	"context"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

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

	"scrabble/backend/internal/account"
	"scrabble/backend/internal/engine"
	"scrabble/backend/internal/game"
	"scrabble/backend/internal/ratewatch"
	"scrabble/backend/internal/server"
)

// TestComplaintResolutionPipeline drives a complaint from filing through
// resolution into the dictionary-change pipeline and on to "applied".
func TestComplaintResolutionPipeline(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	word := "zzzzzz" // a non-word the filer thinks should be valid → an accept_add candidate
	filed, err := svc.FileComplaint(ctx, g.ID, seats[0], word, "please add")
	if err != nil {
		t.Fatalf("file: %v", err)
	}

	if open, _ := svc.CountComplaints(ctx, game.StatusComplaintOpen); open < 1 {
		t.Fatalf("open complaints = %d, want >= 1", open)
	}
	list, err := svc.ListComplaints(ctx, game.StatusComplaintOpen, 100, 0)
	if err != nil || !containsComplaint(list, filed.ID) {
		t.Fatalf("open list missing filed complaint (err %v)", err)
	}

	resolved, err := svc.ResolveComplaint(ctx, filed.ID, game.DispositionAcceptAdd, "agreed")
	if err != nil {
		t.Fatalf("resolve: %v", err)
	}
	if resolved.Status != game.StatusComplaintResolved || resolved.Disposition != game.DispositionAcceptAdd || resolved.ResolvedAt == nil {
		t.Fatalf("unexpected resolved complaint: %+v", resolved)
	}

	changes, err := svc.DictionaryChanges(ctx)
	if err != nil {
		t.Fatalf("changes: %v", err)
	}
	if !changeFor(changes, word, true) {
		t.Fatalf("dictionary changes missing add %q: %+v", word, changes)
	}

	n, err := svc.MarkChangesApplied(ctx, engine.VariantEnglish, "v2")
	if err != nil || n < 1 {
		t.Fatalf("mark applied n=%d err=%v", n, err)
	}
	if after, err := svc.DictionaryChanges(ctx); err != nil || changeFor(after, word, true) {
		t.Fatalf("change still pending after apply (err %v): %+v", err, after)
	}
}

func containsComplaint(list []game.Complaint, id uuid.UUID) bool {
	for _, c := range list {
		if c.ID == id {
			return true
		}
	}
	return false
}

func changeFor(changes []game.DictionaryChange, word string, add bool) bool {
	for _, c := range changes {
		if c.Word == word && c.Add == add {
			return true
		}
	}
	return false
}

// TestAdminListsAndCounts checks the admin read queries and their COUNT scans.
func TestAdminListsAndCounts(t *testing.T) {
	ctx := context.Background()
	store := account.NewStore(testDB)
	svc := newGameService()

	accBefore, err := store.CountAccounts(ctx)
	if err != nil {
		t.Fatalf("count accounts: %v", err)
	}
	a, b := provisionAccount(t), provisionAccount(t)
	accAfter, err := store.CountAccounts(ctx)
	if err != nil {
		t.Fatalf("count accounts: %v", err)
	}
	if accAfter < accBefore+2 {
		t.Errorf("account count did not grow by 2: %d -> %d", accBefore, accAfter)
	}
	if page, err := store.ListAccounts(ctx, 1, 0); err != nil || len(page) != 1 {
		t.Fatalf("list accounts page size 1 = %d (err %v)", len(page), err)
	}
	if ids, err := store.Identities(ctx, a); err != nil || len(ids) != 1 || ids[0].Kind != account.KindTelegram {
		t.Fatalf("identities for a = %+v (err %v)", ids, err)
	}

	gBefore, _ := svc.CountGames(ctx, "")
	if _, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{a, b}, TurnTimeout: 24 * time.Hour, Seed: 2}); err != nil {
		t.Fatalf("create: %v", err)
	}
	if gAfter, _ := svc.CountGames(ctx, ""); gAfter != gBefore+1 {
		t.Errorf("game count %d -> %d, want +1", gBefore, gAfter)
	}
	if active, err := svc.ListGames(ctx, game.StatusActive, 100, 0); err != nil || len(active) == 0 {
		t.Fatalf("list active games = %d (err %v)", len(active), err)
	}
}

// TestConsoleServesAndGuardsCSRF drives the /_gm console over HTTP against real
// stores: pages render, and a state-changing POST needs a same-origin header.
func TestConsoleServesAndGuardsCSRF(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	g, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 3})
	if err != nil {
		t.Fatalf("create: %v", err)
	}
	filed, err := svc.FileComplaint(ctx, g.ID, seats[0], "qwxz", "review")
	if err != nil {
		t.Fatalf("file: %v", err)
	}

	srv := server.New(":0", server.Deps{
		Logger:   zap.NewNop(),
		Accounts: account.NewStore(testDB),
		Games:    svc,
		Registry: testRegistry,
		DictDir:  dictDir(),
	})
	h := srv.Handler()
	base := "http://admin.test/_gm"

	if code, body := consoleDo(h, http.MethodGet, base+"/", "", ""); code != http.StatusOK || !strings.Contains(body, "Dashboard") {
		t.Fatalf("dashboard = %d, has Dashboard=%v", code, strings.Contains(body, "Dashboard"))
	}
	if code, body := consoleDo(h, http.MethodGet, base+"/complaints/"+filed.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "qwxz") {
		t.Fatalf("complaint detail = %d, has word=%v", code, strings.Contains(body, "qwxz"))
	}
	// A resolve POST without a same-origin header is rejected by the CSRF guard.
	if code, _ := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject", ""); code != http.StatusForbidden {
		t.Fatalf("resolve without origin = %d, want 403", code)
	}
	// With a matching Origin it succeeds and persists.
	if code, body := consoleDo(h, http.MethodPost, base+"/complaints/"+filed.ID.String()+"/resolve", "disposition=reject&note=ok", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Resolved") {
		t.Fatalf("resolve with origin = %d, has Resolved=%v", code, strings.Contains(body, "Resolved"))
	}
	if got, err := svc.GetComplaint(ctx, filed.ID); err != nil || got.Status != game.StatusComplaintResolved {
		t.Fatalf("complaint not resolved: %+v (err %v)", got, err)
	}
}

// TestConsoleGameDetailRobotSchedule checks the admin game card surfaces a robot seat's
// play-to-win intent and, while it is the robot's turn, its next-move ETA.
func TestConsoleGameDetailRobotSchedule(t *testing.T) {
	ctx := context.Background()
	svc := newGameService()
	robotAcc, err := account.NewStore(testDB).ProvisionRobot(ctx, "robot-admin-"+uuid.NewString(), "Robo Tester")
	if err != nil {
		t.Fatalf("provision robot: %v", err)
	}
	human := provisionAccount(t)
	// Seat the robot first so it is to move (seat 0), exposing the next-move ETA.
	g, err := svc.Create(ctx, game.CreateParams{
		Variant: engine.VariantEnglish, Seats: []uuid.UUID{robotAcc.ID, human}, TurnTimeout: 24 * time.Hour, Seed: 7,
	})
	if err != nil {
		t.Fatalf("create: %v", err)
	}

	srv := server.New(":0", server.Deps{
		Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: testRegistry, DictDir: dictDir(),
	})
	code, body := consoleDo(srv.Handler(), http.MethodGet, "http://admin.test/_gm/games/"+g.ID.String(), "", "")
	if code != http.StatusOK {
		t.Fatalf("game detail = %d, want 200", code)
	}
	if !strings.Contains(body, "🤖") {
		t.Error("robot seat is not marked in the game detail")
	}
	if !strings.Contains(body, "play to win") && !strings.Contains(body, "play to lose") {
		t.Error("robot play-to-win intent missing")
	}
	if !strings.Contains(body, "next move") {
		t.Error("robot is to move but the next-move ETA is missing")
	}
	if !strings.Contains(body, "~40%") {
		t.Error("robot play-to-win target caption missing")
	}
	if !strings.Contains(body, "First-move draw") {
		t.Error("first-move draw section missing from the game detail")
	}
	if !strings.Contains(body, `id="replay-board"`) {
		t.Error("replay board container missing from the game detail")
	}
}

// TestConsoleThrottledViewAndFlagClear drives the rate-limit surface end to
// end against real stores: a gateway report past the threshold auto-flags the
// account, the throttled view shows the episode and the flagged account, the
// user card carries the marker, and the operator clear (a same-origin POST)
// reverses it.
func TestConsoleThrottledViewAndFlagClear(t *testing.T) {
	ctx := context.Background()
	accounts := account.NewStore(testDB)
	acc, _, err := accounts.ProvisionTelegram(ctx, "tg-"+uuid.NewString(), "en", "", "Throttled Player", "")
	if err != nil {
		t.Fatalf("provision: %v", err)
	}

	watch := ratewatch.New(ratewatch.Config{FlagThreshold: 100, FlagWindow: 10 * time.Minute}, accounts, zap.NewNop())
	srv := server.New(":0", server.Deps{
		Logger:    zap.NewNop(),
		Accounts:  accounts,
		Games:     newGameService(),
		Registry:  testRegistry,
		DictDir:   dictDir(),
		RateWatch: watch,
	})
	h := srv.Handler()

	report := `{"window_seconds":30,"entries":[` +
		`{"class":"user","key":"` + acc.ID.String() + `","rejected":150},` +
		`{"class":"public","key":"10.1.2.3","rejected":7}]}`
	req := httptest.NewRequest(http.MethodPost, "http://admin.test/api/v1/internal/ratelimit/report", strings.NewReader(report))
	req.Header.Set("Content-Type", "application/json")
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	if rec.Code != http.StatusNoContent {
		t.Fatalf("report = %d, want 204", rec.Code)
	}

	got, err := accounts.GetByID(ctx, acc.ID)
	if err != nil {
		t.Fatalf("get by id: %v", err)
	}
	if got.FlaggedHighRateAt.IsZero() {
		t.Fatal("account not auto-flagged past the threshold")
	}

	base := "http://admin.test/_gm"
	code, body := consoleDo(h, http.MethodGet, base+"/throttled", "", "")
	if code != http.StatusOK || !strings.Contains(body, acc.ID.String()) ||
		!strings.Contains(body, "10.1.2.3") || !strings.Contains(body, "Throttled Player") {
		t.Fatalf("throttled view = %d, episode/flag shown = %v/%v",
			code, strings.Contains(body, "10.1.2.3"), strings.Contains(body, "Throttled Player"))
	}
	if code, body = consoleDo(h, http.MethodGet, base+"/users/"+acc.ID.String(), "", ""); code != http.StatusOK || !strings.Contains(body, "Clear high-rate flag") {
		t.Fatalf("user card = %d, has clear action = %v", code, strings.Contains(body, "Clear high-rate flag"))
	}

	// The clear POST is CSRF-guarded like every console action.
	if code, _ = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "", ""); code != http.StatusForbidden {
		t.Fatalf("clear without origin = %d, want 403", code)
	}
	if code, body = consoleDo(h, http.MethodPost, base+"/users/"+acc.ID.String()+"/clear-high-rate-flag", "x=1", "http://admin.test"); code != http.StatusOK || !strings.Contains(body, "Cleared") {
		t.Fatalf("clear with origin = %d, has Cleared = %v", code, strings.Contains(body, "Cleared"))
	}
	if got, err = accounts.GetByID(ctx, acc.ID); err != nil || !got.FlaggedHighRateAt.IsZero() {
		t.Fatalf("flag survived the clear: %v (err %v)", got.FlaggedHighRateAt, err)
	}
}

// consoleDo issues a request to h, optionally with an Origin header, and returns
// the status and body. Form bodies are sent as application/x-www-form-urlencoded.
func consoleDo(h http.Handler, method, target, body, origin string) (int, string) {
	req := httptest.NewRequest(method, target, strings.NewReader(body))
	if body != "" {
		req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	}
	if origin != "" {
		req.Header.Set("Origin", origin)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec.Code, rec.Body.String()
}
