package httpapi

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"

	"image"
	"image/color"
	"image/jpeg"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/auth"
	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/storage"
	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
	"git.azzamo.net/Michilis/Spanglish/photo-api/internal/worker"
)

const testSecret = "test-secret"

// Seed ids are UUID-formatted because the Postgres dialect uses uuid columns.
const (
	uAdmin   = "11111111-1111-1111-1111-111111111111"
	uMember  = "22222222-2222-2222-2222-222222222222"
	uBuyer   = "33333333-3333-3333-3333-333333333333"
	uPending = "44444444-4444-4444-4444-444444444444"
	uFree    = "55555555-5555-5555-5555-555555555555"
	evPaid   = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
	evFree   = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"
)

type testEnv struct {
	handler     http.Handler
	db          *store.DB
	worker      *worker.Worker
	storagePath string
	pg          bool
}

// setup migrates a scratch DB (SQLite by default; Postgres when
// PHOTO_TEST_PG holds a connection URL), seeds the minimal slice of the
// main app schema that access.go reads, and returns a ready handler.
func setup(t *testing.T) *testEnv {
	t.Helper()
	dir := t.TempDir()
	cfg := config.Config{
		Port:              0,
		DBType:            "sqlite",
		DatabaseURL:       filepath.Join(dir, "test.db"),
		ViewTokenSecret:   testSecret,
		StoragePath:       filepath.Join(dir, "photos"),
		MaxUploadMB:       5,
		WorkerConcurrency: 1,
		FrontendURL:       "http://localhost:3000",
	}
	if pgURL := os.Getenv("PHOTO_TEST_PG"); pgURL != "" {
		cfg.DBType = "postgres"
		cfg.DatabaseURL = pgURL
	}
	db, err := store.Open(cfg)
	if err != nil {
		t.Fatal(err)
	}
	t.Cleanup(func() { db.Close() })
	if cfg.DBType == "postgres" {
		// The scratch Postgres DB persists across tests; start clean.
		if _, err := db.Exec(`DROP TABLE IF EXISTS photos_photos, photos_galleries, photos_schema_migrations, users, events, tickets, payments, auth_sessions CASCADE`); err != nil {
			t.Fatal(err)
		}
	}
	if err := db.Migrate(context.Background()); err != nil {
		t.Fatal(err)
	}

	idType := "text"
	expiresType := "integer" // sqlite: Better Auth stores epoch-ms integers
	boolType := "integer"
	falseLit := "0"
	if cfg.DBType == "postgres" {
		idType = "uuid"
		expiresType = "timestamp"
		boolType = "boolean"
		falseLit = "FALSE"
	}
	seed := []string{
		`CREATE TABLE users (id ` + idType + ` PRIMARY KEY, email text, name text, role text, account_status text NOT NULL DEFAULT 'active', banned ` + boolType + ` NOT NULL DEFAULT ` + falseLit + `)`,
		`CREATE TABLE auth_sessions (id text PRIMARY KEY, user_id ` + idType + `, token text NOT NULL UNIQUE, expires_at ` + expiresType + ` NOT NULL)`,
		`CREATE TABLE events (id ` + idType + ` PRIMARY KEY, slug text, title text, title_es text, start_datetime text, status text, price real)`,
		`CREATE TABLE tickets (id text PRIMARY KEY, user_id ` + idType + `, event_id ` + idType + `, status text)`,
		`CREATE TABLE payments (id text PRIMARY KEY, ticket_id text, status text)`,

		`INSERT INTO users VALUES ('` + uAdmin + `','a@x.py','Admin','admin','active',` + falseLit + `)`,
		`INSERT INTO users VALUES ('` + uMember + `','m@x.py','Member','user','active',` + falseLit + `)`,
		`INSERT INTO users VALUES ('` + uBuyer + `','b@x.py','Buyer','user','active',` + falseLit + `)`,
		`INSERT INTO users VALUES ('` + uPending + `','p@x.py','Pending','user','active',` + falseLit + `)`,
		`INSERT INTO users VALUES ('` + uFree + `','f@x.py','Free','user','active',` + falseLit + `)`,

		`INSERT INTO events VALUES ('` + evPaid + `','fiesta','Fiesta','Fiesta ES','2026-06-01T20:00:00.000Z','completed',50000)`,
		`INSERT INTO events VALUES ('` + evFree + `','gratis','Gratis','Gratis ES','2026-06-02T20:00:00.000Z','completed',0)`,

		`INSERT INTO tickets VALUES ('t1','` + uBuyer + `','` + evPaid + `','confirmed')`,
		`INSERT INTO payments VALUES ('p1','t1','paid')`,
		`INSERT INTO tickets VALUES ('t2','` + uPending + `','` + evPaid + `','pending')`,
		`INSERT INTO payments VALUES ('p2','t2','pending')`,
		`INSERT INTO tickets VALUES ('t3','` + uFree + `','` + evFree + `','confirmed')`,
	}
	for _, q := range seed {
		if _, err := db.Exec(q); err != nil {
			t.Fatalf("seed %q: %v", q, err)
		}
	}

	st, err := storage.New(cfg)
	if err != nil {
		t.Fatal(err)
	}
	wrk := worker.New(db, st, nil, cfg.StoragePath, 1)
	srv := New(cfg, db, st, auth.NewVerifier(db), wrk)
	return &testEnv{
		handler:     srv.Handler(),
		db:          db,
		worker:      wrk,
		storagePath: cfg.StoragePath,
		pg:          cfg.DBType == "postgres",
	}
}

// makeToken inserts a Better Auth session row for the user and returns a
// bearer value in the cookie's "token.signature" format (the verifier
// resolves the token part against auth_sessions; the signature is unused).
func (e *testEnv) makeToken(t *testing.T, sub string) string {
	t.Helper()
	token := "tok-" + newID()
	expires := time.Now().Add(time.Hour)
	var expiresVal any = expires.UnixMilli()
	if e.pg {
		expiresVal = expires
	}
	if _, err := e.db.Exec(e.db.Rebind(
		`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
		newID(), sub, token, expiresVal); err != nil {
		t.Fatal(err)
	}
	return token + ".testsig"
}

func (e *testEnv) request(t *testing.T, method, path, token string, body any) *httptest.ResponseRecorder {
	t.Helper()
	var reader *bytes.Reader
	if body != nil {
		b, _ := json.Marshal(body)
		reader = bytes.NewReader(b)
	} else {
		reader = bytes.NewReader(nil)
	}
	req := httptest.NewRequest(method, path, reader)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	if token != "" {
		req.Header.Set("Authorization", "Bearer "+token)
	}
	w := httptest.NewRecorder()
	e.handler.ServeHTTP(w, req)
	return w
}

func decode[T any](t *testing.T, w *httptest.ResponseRecorder) T {
	t.Helper()
	var v T
	if err := json.Unmarshal(w.Body.Bytes(), &v); err != nil {
		t.Fatalf("decode %s: %v", w.Body.String(), err)
	}
	return v
}

func testJPEG(t *testing.T) []byte { return testJPEGTinted(t, 128) }

// testJPEGTinted varies the blue channel so tests that need two *different*
// images (duplicate detection) can get them without a fixture file.
func testJPEGTinted(t *testing.T, blue uint8) []byte {
	t.Helper()
	img := image.NewRGBA(image.Rect(0, 0, 800, 600))
	for x := 0; x < 800; x += 10 {
		for y := 0; y < 600; y++ {
			img.Set(x, y, color.RGBA{R: uint8(x % 255), G: uint8(y % 255), B: blue, A: 255})
		}
	}
	var buf bytes.Buffer
	if err := jpeg.Encode(&buf, img, nil); err != nil {
		t.Fatal(err)
	}
	return buf.Bytes()
}

func TestHealth(t *testing.T) {
	e := setup(t)
	if w := e.request(t, "GET", "/api/photos/health", "", nil); w.Code != 200 {
		t.Fatalf("health: %d", w.Code)
	}
}

func TestAdminGate(t *testing.T) {
	e := setup(t)
	body := map[string]string{"title": "Test"}
	if w := e.request(t, "POST", "/api/photos/galleries", "", body); w.Code != 401 {
		t.Fatalf("anon create: want 401, got %d", w.Code)
	}
	member := e.makeToken(t, uMember)
	if w := e.request(t, "POST", "/api/photos/galleries", member, body); w.Code != 403 {
		t.Fatalf("member create: want 403, got %d", w.Code)
	}
	unknown := e.makeToken(t, newID())
	if w := e.request(t, "POST", "/api/photos/galleries", unknown, body); w.Code != 401 {
		t.Fatalf("unknown-user token: want 401, got %d", w.Code)
	}
}

func TestSessionValidation(t *testing.T) {
	e := setup(t)
	body := map[string]string{"title": "Test"}

	// Expired session -> 401
	expiredTok := "tok-" + newID()
	var expiresVal any = time.Now().Add(-time.Hour).UnixMilli()
	if e.pg {
		expiresVal = time.Now().Add(-time.Hour)
	}
	if _, err := e.db.Exec(e.db.Rebind(
		`INSERT INTO auth_sessions (id, user_id, token, expires_at) VALUES (?, ?, ?, ?)`),
		newID(), uAdmin, expiredTok, expiresVal); err != nil {
		t.Fatal(err)
	}
	if w := e.request(t, "POST", "/api/photos/galleries", expiredTok+".sig", body); w.Code != 401 {
		t.Fatalf("expired session: want 401, got %d", w.Code)
	}

	// Banned (suspended) user -> 401 even with a live session
	bannedTok := e.makeToken(t, uMember)
	if _, err := e.db.Exec(e.db.Rebind(
		`UPDATE users SET account_status = 'suspended' WHERE id = ?`), uMember); err != nil {
		t.Fatal(err)
	}
	if w := e.request(t, "POST", "/api/photos/galleries", bannedTok, body); w.Code != 401 {
		t.Fatalf("suspended user: want 401, got %d", w.Code)
	}
	if _, err := e.db.Exec(e.db.Rebind(
		`UPDATE users SET account_status = 'active' WHERE id = ?`), uMember); err != nil {
		t.Fatal(err)
	}

	// Session via the browser cookie (URL-encoded token.signature) -> works
	adminTok := e.makeToken(t, uAdmin)
	req := httptest.NewRequest("POST", "/api/photos/galleries", bytes.NewReader([]byte(`{"title":"Via cookie"}`)))
	req.Header.Set("Content-Type", "application/json")
	req.AddCookie(&http.Cookie{Name: "spanglish.session_token", Value: strings.ReplaceAll(adminTok, ".", "%2E")})
	w := httptest.NewRecorder()
	e.handler.ServeHTTP(w, req)
	if w.Code != 200 && w.Code != 201 {
		t.Fatalf("cookie auth: want 2xx, got %d: %s", w.Code, w.Body.String())
	}
}

type galleryResp struct {
	Gallery galleryJSON `json:"gallery"`
	Photos  []photoJSON `json:"photos"`
}

func createGallery(t *testing.T, e *testEnv, admin string, body map[string]any) galleryJSON {
	t.Helper()
	w := e.request(t, "POST", "/api/photos/galleries", admin, body)
	if w.Code != 201 {
		t.Fatalf("create gallery: %d %s", w.Code, w.Body.String())
	}
	return decode[galleryResp](t, w).Gallery
}

func uploadPhoto(t *testing.T, e *testEnv, admin, galleryID string, file []byte) photoJSON {
	t.Helper()
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	fw, _ := mw.CreateFormFile("files", "test photo.jpg")
	fw.Write(file)
	mw.Close()
	req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	req.Header.Set("Authorization", "Bearer "+admin)
	w := httptest.NewRecorder()
	e.handler.ServeHTTP(w, req)
	if w.Code != 201 {
		t.Fatalf("upload: %d %s", w.Code, w.Body.String())
	}
	photos := decode[struct {
		Photos []photoJSON `json:"photos"`
	}](t, w).Photos
	if len(photos) != 1 {
		t.Fatalf("want 1 photo, got %d", len(photos))
	}
	return photos[0]
}

// uploadFiles posts several parts in one request, the way the API allows even
// though the admin UI sends one file per request.
func uploadFiles(t *testing.T, e *testEnv, admin, galleryID string, files ...[]byte) []photoJSON {
	t.Helper()
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	for i, f := range files {
		fw, _ := mw.CreateFormFile("files", fmt.Sprintf("photo-%d.jpg", i))
		fw.Write(f)
	}
	mw.Close()
	req := httptest.NewRequest("POST", "/api/photos/galleries/"+galleryID+"/photos", &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	req.Header.Set("Authorization", "Bearer "+admin)
	w := httptest.NewRecorder()
	e.handler.ServeHTTP(w, req)
	if w.Code != 201 {
		t.Fatalf("upload: %d %s", w.Code, w.Body.String())
	}
	return decode[struct {
		Photos []photoJSON `json:"photos"`
	}](t, w).Photos
}

// countOriginals is how many objects actually landed on disk for a gallery —
// a duplicate must not add one.
func countOriginals(t *testing.T, e *testEnv, galleryID string) int {
	t.Helper()
	entries, err := os.ReadDir(filepath.Join(e.storagePath, "galleries", galleryID, "orig"))
	if os.IsNotExist(err) {
		return 0
	}
	if err != nil {
		t.Fatal(err)
	}
	return len(entries)
}

func TestUploadSkipsDuplicates(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{"title": "Dupes"})
	img := testJPEG(t)

	first := uploadPhoto(t, e, admin, g.ID, img)
	if first.Duplicate {
		t.Fatalf("first upload flagged as duplicate: %+v", first)
	}

	// Same bytes again: echoed back as the existing photo, nothing stored.
	second := uploadPhoto(t, e, admin, g.ID, img)
	if !second.Duplicate {
		t.Fatalf("second upload not flagged as duplicate: %+v", second)
	}
	if second.ID != first.ID {
		t.Fatalf("duplicate should echo the existing photo: got %s, want %s", second.ID, first.ID)
	}
	photos, err := e.db.ListPhotos(context.Background(), g.ID, false)
	if err != nil {
		t.Fatal(err)
	}
	if len(photos) != 1 {
		t.Fatalf("want 1 row after re-upload, got %d", len(photos))
	}
	if n := countOriginals(t, e, g.ID); n != 1 {
		t.Fatalf("want 1 stored original after re-upload, got %d", n)
	}
	if photos[0].Checksum == "" {
		t.Fatal("checksum was not recorded")
	}

	// A different image is unaffected and takes the next position.
	other := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32))
	if other.Duplicate || other.ID == first.ID {
		t.Fatalf("distinct image treated as duplicate: %+v", other)
	}
	if other.Position != 1 {
		t.Fatalf("want position 1 for the second distinct photo, got %d", other.Position)
	}

	// Duplicate scope is one gallery: the same bytes elsewhere upload normally.
	g2 := createGallery(t, e, admin, map[string]any{"title": "Other gallery"})
	elsewhere := uploadPhoto(t, e, admin, g2.ID, img)
	if elsewhere.Duplicate {
		t.Fatalf("same image in another gallery must not be a duplicate: %+v", elsewhere)
	}
	if elsewhere.ID == first.ID {
		t.Fatal("second gallery should get its own photo row")
	}
}

func TestUploadBatchContinuesPastDuplicate(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{"title": "Batch"})
	a, b, c := testJPEG(t), testJPEGTinted(t, 32), testJPEGTinted(t, 200)

	if got := uploadFiles(t, e, admin, g.ID, a); len(got) != 1 {
		t.Fatalf("seed upload: %d photos", len(got))
	}
	// b duplicates nothing, a is already there, c is new: the batch must not
	// abort, and the duplicate must not consume a position.
	batch := uploadFiles(t, e, admin, g.ID, b, a, c)
	if len(batch) != 3 {
		t.Fatalf("want 3 results, got %d", len(batch))
	}
	if batch[0].Duplicate || !batch[1].Duplicate || batch[2].Duplicate {
		t.Fatalf("duplicate flags: %v %v %v", batch[0].Duplicate, batch[1].Duplicate, batch[2].Duplicate)
	}
	if batch[0].Position != 1 || batch[2].Position != 2 {
		t.Fatalf("positions should stay dense: %d, %d", batch[0].Position, batch[2].Position)
	}
	photos, err := e.db.ListPhotos(context.Background(), g.ID, false)
	if err != nil {
		t.Fatal(err)
	}
	if len(photos) != 3 {
		t.Fatalf("want 3 rows, got %d", len(photos))
	}
	if n := countOriginals(t, e, g.ID); n != 3 {
		t.Fatalf("want 3 stored originals, got %d", n)
	}
}

// processQueue runs the worker until the photo is ready or failed.
func processQueue(t *testing.T, e *testEnv, photoID string) store.Photo {
	t.Helper()
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()
	e.worker.Run(ctx)
	e.worker.Nudge()
	deadline := time.Now().Add(15 * time.Second)
	for time.Now().Before(deadline) {
		p, err := e.db.GetPhoto(ctx, photoID)
		if err != nil {
			t.Fatal(err)
		}
		if p.Status == "ready" || (p.Status == "failed" && p.Attempts > 0) {
			return p
		}
		time.Sleep(50 * time.Millisecond)
	}
	t.Fatal("timeout waiting for processing")
	return store.Photo{}
}

func TestUploadProcessAndServe(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{"title": "Fiesta de Junio", "visibility": "public"})
	if g.Slug != "fiesta-de-junio" {
		t.Fatalf("slug: %q", g.Slug)
	}
	// Public galleries share a clean URL: the token grants nothing there.
	if g.ShareToken == "" {
		t.Fatalf("share token missing: %+v", g)
	}
	if strings.Contains(g.ShareURL, "token=") {
		t.Fatalf("public gallery share url must not carry a token: %s", g.ShareURL)
	}
	if !strings.HasSuffix(g.ShareURL, "/photos/"+g.Slug) {
		t.Fatalf("public share url: %s", g.ShareURL)
	}

	p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
	if p.Status != "queued" || p.ContentType != "image/jpeg" {
		t.Fatalf("uploaded photo: %+v", p)
	}
	done := processQueue(t, e, p.ID)
	if done.Status != "ready" {
		t.Fatalf("processing failed: %s", done.LastError)
	}
	if done.Width != 800 || done.Height != 600 {
		t.Fatalf("dimensions: %dx%d", done.Width, done.Height)
	}

	for _, variant := range []string{"thumb", "preview", "original"} {
		w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/"+variant, "", nil)
		if w.Code != 200 {
			t.Fatalf("%s: %d", variant, w.Code)
		}
		if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
			t.Fatalf("%s content type: %s", variant, ct)
		}
	}
	w := e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil)
	if cd := w.Header().Get("Content-Disposition"); !strings.Contains(cd, "test photo.jpg") {
		t.Fatalf("original disposition: %q", cd)
	}

	// Bad upload is rejected by magic bytes.
	var buf bytes.Buffer
	mw := multipart.NewWriter(&buf)
	fw, _ := mw.CreateFormFile("files", "notes.txt")
	fw.Write([]byte("not an image at all"))
	mw.Close()
	req := httptest.NewRequest("POST", "/api/photos/galleries/"+g.ID+"/photos", &buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	req.Header.Set("Authorization", "Bearer "+admin)
	rec := httptest.NewRecorder()
	e.handler.ServeHTTP(rec, req)
	if rec.Code != 415 {
		t.Fatalf("text upload: want 415, got %d %s", rec.Code, rec.Body.String())
	}
}

func TestAccessMatrix(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	member := e.makeToken(t, uMember)
	buyer := e.makeToken(t, uBuyer)
	pending := e.makeToken(t, uPending)
	freeguy := e.makeToken(t, uFree)

	get := func(slug, token, bearer string) int {
		path := "/api/photos/public/galleries/" + slug
		if token != "" {
			path += "?token=" + token
		}
		return e.request(t, "GET", path, bearer, nil).Code
	}

	// private: 403 with a distinct message so the frontend can show its
	// gate page (revealing existence is accepted for this community site)
	priv := createGallery(t, e, admin, map[string]any{"title": "Privada", "visibility": "private"})
	for name, code := range map[string]int{"anon": get(priv.Slug, "", ""), "member": get(priv.Slug, "", member),
		"with-token": get(priv.Slug, priv.ShareToken, "")} {
		if code != 403 {
			t.Errorf("private/%s: want 403, got %d", name, code)
		}
	}
	if got := get(priv.Slug, "", admin); got != 200 {
		t.Errorf("private/admin: want 200, got %d", got)
	}

	// link
	link := createGallery(t, e, admin, map[string]any{"title": "Enlace", "visibility": "link"})
	if got := get(link.Slug, "", ""); got != 403 {
		t.Errorf("link/anon: want 403, got %d", got)
	}
	if got := get(link.Slug, "wrong-token", ""); got != 403 {
		t.Errorf("link/bad-token: want 403, got %d", got)
	}
	if got := get(link.Slug, link.ShareToken, ""); got != 200 {
		t.Errorf("link/token: want 200, got %d", got)
	}

	// ticket, paid event
	tick := createGallery(t, e, admin, map[string]any{"title": "Con Entrada", "visibility": "ticket", "eventId": evPaid})
	if got := get(tick.Slug, "", ""); got != 401 {
		t.Errorf("ticket/anon: want 401, got %d", got)
	}
	if got := get(tick.Slug, "", member); got != 403 {
		t.Errorf("ticket/no-ticket: want 403, got %d", got)
	}
	if got := get(tick.Slug, "", pending); got != 403 {
		t.Errorf("ticket/pending-payment: want 403, got %d", got)
	}
	if got := get(tick.Slug, "", buyer); got != 200 {
		t.Errorf("ticket/paid: want 200, got %d", got)
	}
	if got := get(tick.Slug, tick.ShareToken, ""); got != 200 {
		t.Errorf("ticket/share-token: want 200, got %d", got)
	}

	// ticket, free event: any confirmed ticket counts (review decision)
	free := createGallery(t, e, admin, map[string]any{"title": "Evento Gratis", "visibility": "ticket", "eventId": evFree})
	if got := get(free.Slug, "", freeguy); got != 200 {
		t.Errorf("ticket/free-event-confirmed: want 200, got %d", got)
	}
	if got := get(free.Slug, "", member); got != 403 {
		t.Errorf("ticket/free-event-no-ticket: want 403, got %d", got)
	}

	// public index lists only public galleries
	pub := createGallery(t, e, admin, map[string]any{"title": "Publica", "visibility": "public"})
	idx := decode[struct {
		Galleries []galleryJSON `json:"galleries"`
	}](t, e.request(t, "GET", "/api/photos/public/galleries", "", nil))
	if len(idx.Galleries) != 1 || idx.Galleries[0].ID != pub.ID {
		t.Errorf("public index: %+v", idx.Galleries)
	}
	for _, g := range idx.Galleries {
		if g.ShareToken != "" {
			t.Errorf("public index leaks share token")
		}
	}
}

func TestReorderAndVisibilityUpdate(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{"title": "Orden", "visibility": "public"})
	// Two distinct images: the same bytes twice would be deduplicated.
	p1 := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
	p2 := uploadPhoto(t, e, admin, g.ID, testJPEGTinted(t, 32))

	if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
		map[string]any{"photoIds": []string{p2.ID, p1.ID}}); w.Code != 200 {
		t.Fatalf("reorder: %d %s", w.Code, w.Body.String())
	}
	detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
	if len(detail.Photos) != 2 || detail.Photos[0].ID != p2.ID {
		t.Fatalf("order not applied: %+v", detail.Photos)
	}

	if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID+"/order", admin,
		map[string]any{"photoIds": []string{p1.ID}}); w.Code != 400 {
		t.Fatalf("partial reorder: want 400, got %d", w.Code)
	}

	if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
		map[string]any{"visibility": "banana"}); w.Code != 400 {
		t.Fatalf("bad visibility: want 400, got %d", w.Code)
	}
	if w := e.request(t, "PATCH", "/api/photos/galleries/"+g.ID, admin,
		map[string]any{"visibility": "link"}); w.Code != 200 {
		t.Fatalf("set link: %d %s", w.Code, w.Body.String())
	}
	if w := e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil); w.Code != 403 {
		t.Fatalf("after switch to link, anon: want 403, got %d", w.Code)
	}
}

// TestViewTokens covers the <img>-tag reality: image requests cannot carry
// an Authorization header, so authorized gallery responses embed a
// short-lived gallery-scoped token in file URLs that the file handler
// accepts anonymously.
func TestViewTokens(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)

	g := createGallery(t, e, admin, map[string]any{"title": "Privada Con Fotos", "visibility": "private"})
	p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
	if processQueue(t, e, p.ID).Status != "ready" {
		t.Fatal("processing failed")
	}

	// The admin detail response must carry tokenized image URLs.
	detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
	thumbURL := detail.Photos[0].URLs.Thumb
	if !strings.Contains(thumbURL, "?token=v1.") {
		t.Fatalf("private gallery photo URL should carry a view token: %s", thumbURL)
	}

	// That URL works with NO Authorization header, exactly like an <img> tag.
	if w := e.request(t, "GET", thumbURL, "", nil); w.Code != 200 {
		t.Fatalf("anon fetch with view token: want 200, got %d %s", w.Code, w.Body.String())
	}
	// Without the token (or with a forged one) the same file is denied.
	bare := strings.SplitN(thumbURL, "?", 2)[0]
	if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
		t.Fatalf("anon fetch without token: want 403, got %d", w.Code)
	}
	if w := e.request(t, "GET", bare+"?token=v1.9999999999.forged", "", nil); w.Code != 403 {
		t.Fatalf("forged token: want 403, got %d", w.Code)
	}
	// A view token for one gallery must not open another gallery's files.
	other := createGallery(t, e, admin, map[string]any{"title": "Otra Privada", "visibility": "private"})
	op := uploadPhoto(t, e, admin, other.ID, testJPEG(t))
	if processQueue(t, e, op.ID).Status != "ready" {
		t.Fatal("processing failed")
	}
	viewToken := strings.SplitN(thumbURL, "?token=", 2)[1]
	if w := e.request(t, "GET", "/api/photos/files/"+op.ID+"/thumb?token="+viewToken, "", nil); w.Code != 403 {
		t.Fatalf("cross-gallery token reuse: want 403, got %d", w.Code)
	}

	// Public galleries keep clean URLs (no token needed).
	pub := createGallery(t, e, admin, map[string]any{"title": "Publica Limpia", "visibility": "public"})
	pp := uploadPhoto(t, e, admin, pub.ID, testJPEG(t))
	if processQueue(t, e, pp.ID).Status != "ready" {
		t.Fatal("processing failed")
	}
	pubDetail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+pub.Slug, "", nil))
	if strings.Contains(pubDetail.Photos[0].URLs.Thumb, "token=") {
		t.Fatalf("public photo URL should be clean: %s", pubDetail.Photos[0].URLs.Thumb)
	}
}

func TestEventGalleryRoute(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	buyer := e.makeToken(t, uBuyer)

	g := createGallery(t, e, admin, map[string]any{"title": "Del Evento", "visibility": "ticket", "eventId": evPaid})

	// Event-linked galleries share via the event URL.
	if !strings.Contains(g.ShareURL, "/events/fiesta/gallery?token="+g.ShareToken) {
		t.Fatalf("share url should use event route: %s", g.ShareURL)
	}

	// /public/events/{slug}/gallery obeys the same access rules.
	if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", "", nil); w.Code != 401 {
		t.Fatalf("event gallery anon: want 401, got %d", w.Code)
	}
	w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery", buyer, nil)
	if w.Code != 200 {
		t.Fatalf("event gallery buyer: want 200, got %d %s", w.Code, w.Body.String())
	}
	if got := decode[galleryResp](t, w).Gallery.ID; got != g.ID {
		t.Fatalf("wrong gallery: %s != %s", got, g.ID)
	}
	if w := e.request(t, "GET", "/api/photos/public/events/fiesta/gallery?token="+g.ShareToken, "", nil); w.Code != 200 {
		t.Fatalf("event gallery share token: want 200, got %d", w.Code)
	}

	// Unknown event slug and event without a gallery are both 404.
	if w := e.request(t, "GET", "/api/photos/public/events/nope/gallery", buyer, nil); w.Code != 404 {
		t.Fatalf("unknown event: want 404, got %d", w.Code)
	}
	if w := e.request(t, "GET", "/api/photos/public/events/gratis/gallery", buyer, nil); w.Code != 404 {
		t.Fatalf("event without gallery: want 404, got %d", w.Code)
	}

	// A standalone gallery keeps the /photos share URL; link mode carries
	// the token, public mode does not.
	solo := createGallery(t, e, admin, map[string]any{"title": "Sin Evento", "visibility": "link"})
	if !strings.Contains(solo.ShareURL, "/photos/sin-evento?token=") {
		t.Fatalf("standalone share url: %s", solo.ShareURL)
	}
	pubEvent := createGallery(t, e, admin, map[string]any{"title": "Publica Con Evento", "visibility": "public", "eventId": evFree})
	if !strings.HasSuffix(pubEvent.ShareURL, "/events/gratis/gallery") {
		t.Fatalf("public event gallery share url should be the clean event link: %s", pubEvent.ShareURL)
	}
}

func TestSlugCollision(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	a := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
	b := createGallery(t, e, admin, map[string]any{"title": "Misma Fiesta"})
	if a.Slug == b.Slug {
		t.Fatalf("slug collision: %s", a.Slug)
	}
	if b.Slug != "misma-fiesta-2" {
		t.Fatalf("second slug: %s", b.Slug)
	}
	if a.Visibility != "private" {
		t.Fatalf("default visibility: %s", a.Visibility)
	}
}

// TestDownloadEndpoint pins the guarantees the mobile save sheet is built
// on: the bytes arrive same-origin (never a redirect), typed as an image,
// named after the event, sized up front and cacheable forever.
func TestDownloadEndpoint(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{
		"title": "Fotos Del Evento", "visibility": "public", "eventId": evPaid,
	})
	p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
	if processQueue(t, e, p.ID).Status != "ready" {
		t.Fatal("processing failed")
	}

	base := "/api/photos/files/" + p.ID + "/download"
	for _, tc := range []struct {
		name, path, wantDisposition, wantFilename string
	}{
		// No ?size at all must behave exactly like ?size=preview.
		{"default", base, "inline", "spanglish-fiesta-1.jpg"},
		{"preview", base + "?size=preview", "inline", "spanglish-fiesta-1.jpg"},
		{"original", base + "?size=original", "attachment", "spanglish-fiesta-1.jpg"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			w := e.request(t, "GET", tc.path, "", nil)
			if w.Code != 200 {
				t.Fatalf("status %d %s", w.Code, w.Body.String())
			}
			if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" {
				t.Fatalf("content type %q: iOS only offers Save Image for image/*", ct)
			}
			if n := w.Header().Get("Content-Length"); n != fmt.Sprint(w.Body.Len()) || n == "0" {
				t.Fatalf("content length %q, body %d bytes", n, w.Body.Len())
			}
			cd := w.Header().Get("Content-Disposition")
			if !strings.HasPrefix(cd, tc.wantDisposition) || !strings.Contains(cd, tc.wantFilename) {
				t.Fatalf("disposition %q, want %s with %s", cd, tc.wantDisposition, tc.wantFilename)
			}
			if cc := w.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
				t.Fatalf("cache-control %q", cc)
			}
		})
	}

	// Each size serves its own variant byte-for-byte. (No size comparison
	// between the two here: the fixture is a synthetic 800x600 image, so its
	// 2048px preview re-encode is larger than the "original" — which says
	// nothing about the multi-megapixel photos the sheet exists for.)
	prev := e.request(t, "GET", base, "", nil)
	orig := e.request(t, "GET", base+"?size=original", "", nil)
	if !bytes.Equal(prev.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/preview", "", nil).Body.Bytes()) {
		t.Fatal("download?size=preview must serve the same bytes as the preview variant")
	}
	if !bytes.Equal(orig.Body.Bytes(), e.request(t, "GET", "/api/photos/files/"+p.ID+"/original", "", nil).Body.Bytes()) {
		t.Fatal("download?size=original must serve the same bytes as the original variant")
	}

	// An unknown size is not a silent fallback.
	if w := e.request(t, "GET", base+"?size=thumb", "", nil); w.Code != 404 {
		t.Fatalf("unknown size: want 404, got %d", w.Code)
	}

	// The gallery response labels both rows without any extra request.
	detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
	got := detail.Photos[0]
	if got.PreviewSizeBytes != int64(prev.Body.Len()) {
		t.Fatalf("previewSizeBytes %d, served %d", got.PreviewSizeBytes, prev.Body.Len())
	}
	if got.SizeBytes != int64(orig.Body.Len()) {
		t.Fatalf("sizeBytes %d, served %d", got.SizeBytes, orig.Body.Len())
	}
	if got.URLs.Download != base {
		t.Fatalf("download url %q, want %q", got.URLs.Download, base)
	}
	if got.URLs.DownloadOriginal != base+"?size=original" {
		t.Fatalf("downloadOriginal url %q", got.URLs.DownloadOriginal)
	}
}

// A restricted gallery's download must obey the same token check as every
// other byte-serving route, and must not become shared-cacheable.
func TestDownloadRespectsAccess(t *testing.T) {
	e := setup(t)
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{"title": "Privada Descarga", "visibility": "private"})
	p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
	if processQueue(t, e, p.ID).Status != "ready" {
		t.Fatal("processing failed")
	}

	detail := decode[galleryResp](t, e.request(t, "GET", "/api/photos/galleries/"+g.ID, admin, nil))
	url := detail.Photos[0].URLs.Download
	if !strings.Contains(url, "?token=v1.") {
		t.Fatalf("private download URL should carry a view token: %s", url)
	}
	// ?size and ?token have to coexist on the original's URL.
	if o := detail.Photos[0].URLs.DownloadOriginal; !strings.Contains(o, "?size=original&token=v1.") {
		t.Fatalf("private original download URL: %s", o)
	}

	w := e.request(t, "GET", url, "", nil)
	if w.Code != 200 {
		t.Fatalf("view token fetch: want 200, got %d", w.Code)
	}
	// Restricted galleries stay out of shared caches; the browser's own
	// cache (which is what the save sheet reuses) still applies.
	if cc := w.Header().Get("Cache-Control"); cc != "private, max-age=31536000, immutable" {
		t.Fatalf("cache-control %q must not be public for a private gallery", cc)
	}
	bare := strings.SplitN(url, "?", 2)[0]
	if w := e.request(t, "GET", bare, "", nil); w.Code != 403 {
		t.Fatalf("anon download without token: want 403, got %d", w.Code)
	}
	if w := e.request(t, "GET", bare+"?size=original", "", nil); w.Code != 403 {
		t.Fatalf("anon original download without token: want 403, got %d", w.Code)
	}
}

// Photos processed before preview_size_bytes existed have no stored size.
// The first gallery view measures them and writes the result back, so no
// separate backfill command is needed for an existing library.
func TestPreviewSizeBackfilledOnRead(t *testing.T) {
	e := setup(t)
	ctx := context.Background()
	admin := e.makeToken(t, uAdmin)
	g := createGallery(t, e, admin, map[string]any{"title": "Antigua", "visibility": "public"})
	p := uploadPhoto(t, e, admin, g.ID, testJPEG(t))
	if processQueue(t, e, p.ID).Status != "ready" {
		t.Fatal("processing failed")
	}

	// Rewind to what an pre-migration row looks like.
	if _, err := e.db.ExecContext(ctx, e.db.Rebind(
		"UPDATE photos_photos SET preview_size_bytes = NULL WHERE id = ?"), p.ID); err != nil {
		t.Fatal(err)
	}
	if before, err := e.db.GetPhoto(ctx, p.ID); err != nil {
		t.Fatal(err)
	} else if before.PreviewSizeBytes != 0 {
		t.Fatalf("setup: want an unmeasured row, got %d", before.PreviewSizeBytes)
	}

	served := e.request(t, "GET", "/api/photos/files/"+p.ID+"/download", "", nil).Body.Len()
	got := decode[galleryResp](t, e.request(t, "GET", "/api/photos/public/galleries/"+g.Slug, "", nil))
	if got.Photos[0].PreviewSizeBytes != int64(served) {
		t.Fatalf("response size %d, served %d", got.Photos[0].PreviewSizeBytes, served)
	}
	// …and it was persisted, so the next view costs no Stat.
	after, err := e.db.GetPhoto(ctx, p.ID)
	if err != nil {
		t.Fatal(err)
	}
	if after.PreviewSizeBytes != int64(served) {
		t.Fatalf("stored size %d, served %d", after.PreviewSizeBytes, served)
	}
}

// Originals uploaded as HEIC or PNG must keep their real type and extension,
// and a row with a missing/generic type must still go out as an image.
func TestDownloadContentTypeNeverOctetStream(t *testing.T) {
	for _, tc := range []struct{ ct, name, want string }{
		{"image/heic", "IMG_1.HEIC", "image/heic"},
		{"image/png", "shot.png", "image/png"},
		{"application/octet-stream", "IMG_2.heic", "image/heic"},
		{"application/octet-stream", "scan.PNG", "image/png"},
		{"", "photo.jpeg", "image/jpeg"},
		{"", "", "image/jpeg"},
	} {
		if got := imageContentType(tc.ct, tc.name); got != tc.want {
			t.Errorf("imageContentType(%q, %q) = %q, want %q", tc.ct, tc.name, got, tc.want)
		}
	}
}
