//go:build integration

package inttest

import (
	"archive/tar"
	"bytes"
	"compress/gzip"
	"context"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"regexp"
	"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/server"
)

// TestDictionaryUpdateFlow drives the admin dictionary update end to end over HTTP
// against real stores and an isolated dictionary directory: upload a release
// archive, preview the diff, install it, and confirm it becomes the active version,
// is written to disk, survives a restart (a fresh service re-adopts it), pins new
// games while leaving an in-progress game on its old version, and is immutable.
func TestDictionaryUpdateFlow(t *testing.T) {
	ctx := context.Background()

	// Isolated dictionary directory seeded with the real DAWGs as the flat boot
	// version v1.0.0, with its own registry and service so the shared fixtures are
	// untouched.
	dir := t.TempDir()
	seedDawgs(t, dictDir(), dir)
	reg, err := engine.OpenWithVersions(dir, "v1.0.0")
	if err != nil {
		t.Fatalf("open registry: %v", err)
	}
	defer func() { _ = reg.Close() }()

	svc := newGameServiceOn(dir, "v1.0.0", reg)
	if err := svc.InitActiveVersion(ctx); err != nil {
		t.Fatalf("init active version: %v", err)
	}
	if got := svc.ActiveVersion(); got != "v1.0.0" {
		t.Fatalf("active version = %q, want v1.0.0", got)
	}

	// An in-progress game pinned to the original version.
	seats := []uuid.UUID{provisionAccount(t), provisionAccount(t)}
	oldGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: seats, TurnTimeout: 24 * time.Hour, Seed: 1})
	if err != nil {
		t.Fatalf("create old game: %v", err)
	}
	if oldGame.DictVersion != "v1.0.0" {
		t.Fatalf("old game dict_version = %q, want v1.0.0", oldGame.DictVersion)
	}

	srv := server.New(":0", server.Deps{
		Logger: zap.NewNop(), Accounts: account.NewStore(testDB), Games: svc, Registry: reg, DictDir: dir,
	})
	h := srv.Handler()
	base := "http://admin.test/_gm"
	archive := releaseArchive(t, dir)
	const fileName = "scrabble-dawg-v9.9.9.tar.gz"

	// Upload without a same-origin header is rejected by the CSRF guard.
	if code, _ := consoleUpload(h, base+"/dictionary/upload", fileName, archive, ""); code != http.StatusForbidden {
		t.Fatalf("upload without origin = %d, want 403", code)
	}

	// Upload with origin renders the preview carrying the version and a token.
	code, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test")
	if code != http.StatusOK || !strings.Contains(body, "v9.9.9") {
		t.Fatalf("upload = %d, has version=%v", code, strings.Contains(body, "v9.9.9"))
	}
	token := tokenFrom(t, body)

	// Confirm installs, loads and activates the new version.
	code, body = consoleDo(h, http.MethodPost, base+"/dictionary/install", "token="+token+"&version=v9.9.9", "http://admin.test")
	if code != http.StatusOK || !strings.Contains(body, "Updated") {
		t.Fatalf("install = %d, has Updated=%v", code, strings.Contains(body, "Updated"))
	}

	// The new version is active, resident for every variant, and written to disk.
	if got := svc.ActiveVersion(); got != "v9.9.9" {
		t.Fatalf("active version after install = %q, want v9.9.9", got)
	}
	for _, v := range engine.Variants() {
		if _, err := reg.Solver(v, "v9.9.9"); err != nil {
			t.Errorf("variant %s v9.9.9 not resident: %v", v, err)
		}
	}
	if _, err := os.Stat(filepath.Join(dir, "v9.9.9", "en_sowpods.dawg")); err != nil {
		t.Errorf("installed dawg missing on disk: %v", err)
	}

	// The choice survives a restart on the SAME build: an out-of-band admin install newer
	// than the build version is not downgraded (a restart on an older image keeps the hotfix).
	restarted := newGameServiceOn(dir, "v1.0.0", reg)
	if err := restarted.InitActiveVersion(ctx); err != nil {
		t.Fatalf("restart init: %v", err)
	}
	if got := restarted.ActiveVersion(); got != "v9.9.9" {
		t.Errorf("restarted active version = %q, want v9.9.9 (a newer out-of-band install is kept)", got)
	}

	// A redeploy that delivered a build version NEWER than the out-of-band one activates it
	// for new games without any admin step (the deploy-delivers path — docs/ARCHITECTURE.md §5).
	if _, err := reg.LoadAvailable(dir, "v10.0.0"); err != nil {
		t.Fatalf("make v10.0.0 resident: %v", err)
	}
	deployed := newGameServiceOn(dir, "v10.0.0", reg)
	if err := deployed.InitActiveVersion(ctx); err != nil {
		t.Fatalf("deploy init: %v", err)
	}
	if got := deployed.ActiveVersion(); got != "v10.0.0" {
		t.Errorf("deployed active version = %q, want v10.0.0 (a newer build version wins)", got)
	}
	// Restore the out-of-band active version so the assertions below still read v9.9.9.
	if err := restarted.SetActiveVersion(ctx, "v9.9.9"); err != nil {
		t.Fatalf("restore active: %v", err)
	}

	// New games pin the new version; the in-progress game keeps its own, still resident.
	newGame, err := svc.Create(ctx, game.CreateParams{Variant: engine.VariantEnglish, Seats: []uuid.UUID{provisionAccount(t), provisionAccount(t)}, TurnTimeout: 24 * time.Hour, Seed: 2})
	if err != nil {
		t.Fatalf("create new game: %v", err)
	}
	if newGame.DictVersion != "v9.9.9" {
		t.Errorf("new game dict_version = %q, want v9.9.9", newGame.DictVersion)
	}
	if _, err := reg.Solver(engine.VariantEnglish, oldGame.DictVersion); err != nil {
		t.Errorf("old game's version %s no longer resident: %v", oldGame.DictVersion, err)
	}

	// Re-uploading the now-resident version is rejected (versions are immutable).
	if _, body := consoleUpload(h, base+"/dictionary/upload", fileName, archive, "http://admin.test"); !strings.Contains(body, "Already resident") {
		t.Errorf("re-upload of resident version not rejected: %q", body)
	}
}

// newGameServiceOn builds a game service over the shared pool but a caller-supplied
// dictionary directory, version and registry, for the isolated dictionary tests.
func newGameServiceOn(dir, version string, reg *engine.Registry) *game.Service {
	svc := game.NewService(
		game.NewStore(testDB), account.NewStore(testDB), reg,
		game.Config{DictDir: dir, DictVersion: version, TimeoutSweepInterval: time.Minute, CacheTTL: time.Hour},
		zap.NewNop(),
	)
	svc.SetFirstMoveEntropy(seatZeroFirstMove)
	return svc
}

// seedDawgs copies the committed DAWG of every variant from src into dst (flat).
func seedDawgs(t *testing.T, src, dst string) {
	t.Helper()
	for _, name := range engine.DictFiles() {
		copyFile(t, filepath.Join(src, name), filepath.Join(dst, name))
	}
}

// releaseArchive packs the three DAWGs from dir into an in-memory gzip+tar release
// archive, the shape consoleUploadDictionary expects.
func releaseArchive(t *testing.T, dir string) []byte {
	t.Helper()
	var buf bytes.Buffer
	gz := gzip.NewWriter(&buf)
	tw := tar.NewWriter(gz)
	for _, name := range engine.DictFiles() {
		data, err := os.ReadFile(filepath.Join(dir, name))
		if err != nil {
			t.Fatalf("read %s: %v", name, err)
		}
		if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: int64(len(data)), Typeflag: tar.TypeReg}); err != nil {
			t.Fatalf("tar header %s: %v", name, err)
		}
		if _, err := tw.Write(data); err != nil {
			t.Fatalf("tar write %s: %v", name, err)
		}
	}
	if err := tw.Close(); err != nil {
		t.Fatalf("close tar: %v", err)
	}
	if err := gz.Close(); err != nil {
		t.Fatalf("close gzip: %v", err)
	}
	return buf.Bytes()
}

func copyFile(t *testing.T, src, dst string) {
	t.Helper()
	data, err := os.ReadFile(src)
	if err != nil {
		t.Fatalf("read %s: %v", src, err)
	}
	if err := os.WriteFile(dst, data, 0o644); err != nil {
		t.Fatalf("write %s: %v", dst, err)
	}
}

var tokenRE = regexp.MustCompile(`name="token" value="([0-9a-f]{32})"`)

// tokenFrom extracts the staging token from a rendered preview page.
func tokenFrom(t *testing.T, body string) string {
	t.Helper()
	m := tokenRE.FindStringSubmatch(body)
	if m == nil {
		t.Fatalf("no staging token in preview body:\n%s", body)
	}
	return m[1]
}

// consoleUpload posts a multipart archive to target as the "archive" file field,
// optionally with an Origin header, returning the status and body.
func consoleUpload(h http.Handler, target, filename string, data []byte, origin string) (int, string) {
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	fw, _ := mw.CreateFormFile("archive", filename)
	_, _ = io.Copy(fw, bytes.NewReader(data))
	_ = mw.Close()

	req := httptest.NewRequest(http.MethodPost, target, &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	if origin != "" {
		req.Header.Set("Origin", origin)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec.Code, rec.Body.String()
}
