package web

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"gitea.dooplex.hu/admin/felhom-controller/internal/appexport"
	"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)

// Chunked browser .fab upload (v0.128.0) — scenarios §7 A–F. Red-proofs recorded in REPORT.md:
//   C: sanitize bypassed (raw join of the client filename) → the traversal test FAILS
//      (the file lands outside the exports dir).
//   B: offset check removed (append regardless) → the replay test FAILS (bytes doubled on disk).
//   F: naive overwrite (rename straight to the requested name) → the collision test FAILS
//      (the pre-existing bundle's bytes are clobbered).

// uploadTestServer builds a Server with a DEFAULT registered drive on a TempDir and returns the
// drive's exports dir. The free-space seam reports "plenty" unless a test overrides it.
func uploadTestServer(t *testing.T) (*Server, string) {
	t.Helper()
	s := testServer(t)
	drive := t.TempDir()
	if err := s.settings.AddStoragePath(settings.StoragePath{
		Path: drive, Label: "Teszt HDD", IsDefault: true, Schedulable: true,
		AddedAt: "2026-07-13T00:00:00Z",
	}); err != nil {
		t.Fatal(err)
	}
	if err := os.MkdirAll(appexport.ExportDir(drive), 0755); err != nil {
		t.Fatal(err)
	}
	prevFree := uploadDiskFree
	uploadDiskFree = func(string) int64 { return 1 << 40 } // 1 TiB — never the constraint
	t.Cleanup(func() {
		uploadDiskFree = prevFree
		s.dropUpload(t)
	})
	return s, appexport.ExportDir(drive)
}

// dropUpload force-releases a leftover slot so TempDir cleanup never races the idle timer.
func (s *Server) dropUpload(t *testing.T) {
	t.Helper()
	s.fabUpload.mu.Lock()
	job := s.fabUpload.cur
	s.fabUpload.cur = nil
	s.fabUpload.mu.Unlock()
	if job != nil {
		job.timer.Stop()
		job.file.Close()
		os.Remove(job.partPath)
	}
}

func uploadInit(s *Server, filename string, size int64) *httptest.ResponseRecorder {
	body, _ := json.Marshal(map[string]interface{}{"filename": filename, "size_bytes": size})
	rr := httptest.NewRecorder()
	s.apiUploadInit(rr, httptest.NewRequest(http.MethodPost, "/api/export/upload/init", bytes.NewReader(body)))
	return rr
}

func uploadChunk(s *Server, id string, offset int, data []byte) *httptest.ResponseRecorder {
	rr := httptest.NewRecorder()
	url := fmt.Sprintf("/api/export/upload/chunk?id=%s&offset=%d", id, offset)
	s.apiUploadChunk(rr, httptest.NewRequest(http.MethodPost, url, bytes.NewReader(data)))
	return rr
}

func uploadFinalize(s *Server, id string) *httptest.ResponseRecorder {
	body, _ := json.Marshal(map[string]string{"upload_id": id})
	rr := httptest.NewRecorder()
	s.apiUploadFinalize(rr, httptest.NewRequest(http.MethodPost, "/api/export/upload/finalize", bytes.NewReader(body)))
	return rr
}

func decodeUpload(t *testing.T, rr *httptest.ResponseRecorder) map[string]interface{} {
	t.Helper()
	var m map[string]interface{}
	if err := json.Unmarshal(rr.Body.Bytes(), &m); err != nil {
		t.Fatalf("bad JSON %q: %v", rr.Body.String(), err)
	}
	return m
}

// mustInit runs a full init and returns the upload id.
func mustInit(t *testing.T, s *Server, filename string, size int64) string {
	t.Helper()
	rr := uploadInit(s, filename, size)
	if rr.Code != http.StatusOK {
		t.Fatalf("init: got %d (%s)", rr.Code, rr.Body.String())
	}
	m := decodeUpload(t, rr)
	id, _ := m["upload_id"].(string)
	if id == "" {
		t.Fatal("init returned no upload_id")
	}
	if got := int64(m["chunk_bytes"].(float64)); got != uploadChunkBytes {
		t.Fatalf("chunk_bytes = %d, want %d", got, int64(uploadChunkBytes))
	}
	return id
}

// Scenario A — happy path: 3 sequential chunks land byte-exactly in the exports dir under the
// requested name, and the EXISTING bundle scan lists it (no second lister). The server streams
// via io.Copy — nothing here (or in the handler) buffers the file.
func TestFabUpload_HappyPath(t *testing.T) {
	s, exportDir := uploadTestServer(t)

	chunks := [][]byte{
		bytes.Repeat([]byte{0xA1}, 700),
		bytes.Repeat([]byte{0xB2}, 700),
		bytes.Repeat([]byte{0xC3}, 300),
	}
	var want []byte
	for _, c := range chunks {
		want = append(want, c...)
	}
	id := mustInit(t, s, "sajatapp_20260713.fab", int64(len(want)))

	off := 0
	for i, c := range chunks {
		rr := uploadChunk(s, id, off, c)
		if rr.Code != http.StatusOK {
			t.Fatalf("chunk %d: got %d (%s)", i, rr.Code, rr.Body.String())
		}
		off += len(c)
		if got := int64(decodeUpload(t, rr)["received_bytes"].(float64)); got != int64(off) {
			t.Fatalf("chunk %d: received_bytes = %d, want %d", i, got, off)
		}
	}

	rr := uploadFinalize(s, id)
	if rr.Code != http.StatusOK {
		t.Fatalf("finalize: got %d (%s)", rr.Code, rr.Body.String())
	}
	if got := decodeUpload(t, rr)["filename"]; got != "sajatapp_20260713.fab" {
		t.Fatalf("finalize filename = %v", got)
	}

	final := filepath.Join(exportDir, "sajatapp_20260713.fab")
	got, err := os.ReadFile(final)
	if err != nil {
		t.Fatalf("final file: %v", err)
	}
	if !bytes.Equal(got, want) {
		t.Fatalf("final bytes differ: got %d bytes, want %d", len(got), len(want))
	}
	// No .part residue.
	entries, _ := os.ReadDir(exportDir)
	for _, e := range entries {
		if strings.Contains(e.Name(), ".part-") {
			t.Fatalf("leftover part file: %s", e.Name())
		}
	}
	// The EXISTING scan lists the upload (drive label included — the same row the page renders).
	bundles := appexport.ScanForBundles([]appexport.DrivePathInfo{{Path: filepath.Dir(exportDir), Label: "Teszt HDD"}})
	found := false
	for _, b := range bundles {
		if b.FileName == "sajatapp_20260713.fab" && b.SizeBytes == int64(len(want)) {
			found = true
		}
	}
	if !found {
		t.Fatalf("bundle scan does not list the upload: %+v", bundles)
	}
}

// Scenario B — offset mismatch: replaying an already-received chunk answers 409 with the current
// received_bytes (the client's re-sync datum) and appends NOTHING (asserted via on-disk size).
// Red-proof: drop the offset==received check → this FAILS (size doubles).
func TestFabUpload_OffsetMismatchRejected(t *testing.T) {
	s, _ := uploadTestServer(t)

	chunk := bytes.Repeat([]byte{0x55}, 512)
	id := mustInit(t, s, "b.fab", 1024)
	if rr := uploadChunk(s, id, 0, chunk); rr.Code != http.StatusOK {
		t.Fatalf("chunk 0: got %d", rr.Code)
	}

	// Replay chunk at offset 0 (client lost the ack).
	rr := uploadChunk(s, id, 0, chunk)
	if rr.Code != http.StatusConflict {
		t.Fatalf("replay: got %d, want 409", rr.Code)
	}
	if got := int64(decodeUpload(t, rr)["received_bytes"].(float64)); got != 512 {
		t.Fatalf("replay received_bytes = %d, want 512 (the re-sync datum)", got)
	}
	s.fabUpload.mu.Lock()
	part := s.fabUpload.cur.partPath
	s.fabUpload.mu.Unlock()
	if fi, err := os.Stat(part); err != nil || fi.Size() != 512 {
		t.Fatalf("part size after replay = %v (err=%v), want 512 — replay must not append", fi, err)
	}

	// Re-synced continuation at the echoed offset succeeds.
	if rr := uploadChunk(s, id, 512, chunk); rr.Code != http.StatusOK {
		t.Fatalf("re-synced chunk: got %d", rr.Code)
	}
}

// Scenario C — security gates on init: traversal filenames are neutered to a base name INSIDE the
// exports dir (never resolved outside), wrong extension refused, oversize vs the (faked) free
// space refused with the Hungarian message, and a second concurrent init answers 409.
// Red-proof: bypass sanitizeUploadFilename (raw join) → the traversal leg FAILS.
func TestFabUpload_SecurityGates(t *testing.T) {
	s, exportDir := uploadTestServer(t)

	// Traversal name: sanitized to its base — the upload lands INSIDE exports, the target of the
	// traversal stays untouched.
	outside := filepath.Join(filepath.Dir(exportDir), "evil.fab") // {drive}/evil.fab — where ../evil.fab would land
	id := mustInit(t, s, "../evil.fab", 4)
	if rr := uploadChunk(s, id, 0, []byte("EVIL")); rr.Code != http.StatusOK {
		t.Fatalf("chunk: got %d", rr.Code)
	}
	if rr := uploadFinalize(s, id); rr.Code != http.StatusOK {
		t.Fatalf("finalize: got %d", rr.Code)
	}
	if _, err := os.Stat(filepath.Join(exportDir, "evil.fab")); err != nil {
		t.Fatal("sanitized upload must land inside the exports dir")
	}
	if _, err := os.Stat(outside); !os.IsNotExist(err) {
		t.Fatal("TRAVERSAL: a file appeared outside the exports dir")
	}

	// Wrong extension / hidden / empty stem / bad charset → 400, no slot claimed.
	for _, name := range []string{"x.txt", ".hidden.fab", ".fab", "árvíztűrő.fab", "a/b.txt"} {
		if rr := uploadInit(s, name, 10); rr.Code != http.StatusBadRequest {
			t.Errorf("init(%q): got %d, want 400", name, rr.Code)
		}
	}

	// Oversize vs free space (fake statfs seam): Hungarian message with both numbers.
	prevFree := uploadDiskFree
	uploadDiskFree = func(string) int64 { return 2 << 30 } // 2 GiB free
	rr := uploadInit(s, "big.fab", 3<<30)                  // 3 GiB declared
	uploadDiskFree = prevFree
	if rr.Code != http.StatusConflict {
		t.Fatalf("oversize init: got %d, want 409", rr.Code)
	}
	if msg, _ := decodeUpload(t, rr)["error"].(string); !strings.Contains(msg, "Nincs elég szabad hely") ||
		!strings.Contains(msg, "3.0 GB") || !strings.Contains(msg, "2.0 GB") {
		t.Fatalf("oversize error message = %q", msg)
	}

	// Second concurrent init → 409 (single-flight).
	first := mustInit(t, s, "one.fab", 10)
	if rr := uploadInit(s, "two.fab", 10); rr.Code != http.StatusConflict {
		t.Fatalf("second init: got %d, want 409", rr.Code)
	}
	_ = first
}

// Scenario C (route wrap) — the new upload paths live under the SAME main.go mount
// (RequireAuth(CsrfProtect(ServeExportAPI))): an unauthenticated POST answers 401 before any
// upload handler runs, proving the wrap applies to the new routes with nothing added at the mux.
func TestFabUpload_MountInheritsAuth(t *testing.T) {
	s, _, _ := claimTestServer(t)
	mux := http.NewServeMux()
	mux.Handle("/api/export/", s.RequireAuth(s.CsrfProtect(http.HandlerFunc(s.ServeExportAPI))))

	for _, p := range []string{"/api/export/upload/init", "/api/export/upload/chunk", "/api/export/upload/finalize", "/api/export/upload/abort"} {
		rr := httptest.NewRecorder()
		mux.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}")))
		if rr.Code != http.StatusUnauthorized {
			t.Errorf("%s unauthenticated: got %d, want 401", p, rr.Code)
		}
	}
}

// Scenario D — finalize size mismatch: 422, the .part deleted, nothing renamed, slot freed.
func TestFabUpload_FinalizeSizeMismatch(t *testing.T) {
	s, exportDir := uploadTestServer(t)

	id := mustInit(t, s, "short.fab", 1000)
	if rr := uploadChunk(s, id, 0, bytes.Repeat([]byte{1}, 400)); rr.Code != http.StatusOK {
		t.Fatalf("chunk: got %d", rr.Code)
	}
	rr := uploadFinalize(s, id)
	if rr.Code != http.StatusUnprocessableEntity {
		t.Fatalf("finalize: got %d, want 422", rr.Code)
	}
	entries, _ := os.ReadDir(exportDir)
	if len(entries) != 0 {
		t.Fatalf("exports dir must be empty after a size-mismatch finalize, got %v", entries)
	}
	// Slot freed: a fresh init succeeds.
	mustInit(t, s, "again.fab", 10)
}

// Scenario E — GC: startup sweep removes stray .part files; an idle upload is aborted
// server-side (timer seam shortened) and its .part deleted, freeing the slot.
func TestFabUpload_GCAndIdleTimeout(t *testing.T) {
	s, exportDir := uploadTestServer(t)

	// Startup GC.
	if err := os.WriteFile(filepath.Join(exportDir, "stray.fab.part-deadbeef"), []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	keep := filepath.Join(exportDir, "keep.fab")
	if err := os.WriteFile(keep, []byte("x"), 0644); err != nil {
		t.Fatal(err)
	}
	s.CleanupStaleUploadParts()
	if _, err := os.Stat(filepath.Join(exportDir, "stray.fab.part-deadbeef")); !os.IsNotExist(err) {
		t.Fatal("startup GC must remove stray .part files")
	}
	if _, err := os.Stat(keep); err != nil {
		t.Fatal("startup GC must not touch real bundles")
	}

	// Idle timeout.
	prev := uploadIdleTimeout
	uploadIdleTimeout = 30 * time.Millisecond
	defer func() { uploadIdleTimeout = prev }()

	id := mustInit(t, s, "idle.fab", 100)
	s.fabUpload.mu.Lock()
	part := s.fabUpload.cur.partPath
	s.fabUpload.mu.Unlock()

	deadline := time.Now().Add(3 * time.Second)
	for {
		s.fabUpload.mu.Lock()
		gone := s.fabUpload.cur == nil
		s.fabUpload.mu.Unlock()
		if gone {
			break
		}
		if time.Now().After(deadline) {
			t.Fatal("idle upload was never expired")
		}
		time.Sleep(10 * time.Millisecond)
	}
	// The unlink is ASYNCHRONOUS with respect to the slot: expireIdleUpload nils `cur`, releases
	// the mutex, and only then closes + removes the file. So "the slot is free" does not yet mean
	// "the .part is gone", and stat-ing immediately is a race the test loses under package load
	// (observed 2026-07-20 — passes alone, fails in the full package). Wait for the outcome being
	// asserted; the assertion itself is unchanged, and the deadline still fails a part that is
	// never deleted.
	partGone := false
	for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline); {
		if _, err := os.Stat(part); os.IsNotExist(err) {
			partGone = true
			break
		}
		time.Sleep(10 * time.Millisecond)
	}
	if !partGone {
		t.Fatal("idle-expired .part must be deleted")
	}
	if rr := uploadChunk(s, id, 0, []byte("late")); rr.Code != http.StatusNotFound {
		t.Fatalf("chunk after expiry: got %d, want 404", rr.Code)
	}
}

// Scenario F — collision: an existing "app.fab" is never overwritten; the upload lands as
// "app (1).fab", and a re-run finds its own prior (1) and lands as "app (2).fab" — never
// "(1)(1)". Red-proof: rename straight to the requested name → this FAILS (bytes clobbered).
func TestFabUpload_CollisionLowestFreeSuffix(t *testing.T) {
	s, exportDir := uploadTestServer(t)

	pre := []byte("PRE-EXISTING")
	if err := os.WriteFile(filepath.Join(exportDir, "app.fab"), pre, 0644); err != nil {
		t.Fatal(err)
	}

	run := func(payload string) string {
		id := mustInit(t, s, "app.fab", int64(len(payload)))
		if rr := uploadChunk(s, id, 0, []byte(payload)); rr.Code != http.StatusOK {
			t.Fatalf("chunk: got %d", rr.Code)
		}
		rr := uploadFinalize(s, id)
		if rr.Code != http.StatusOK {
			t.Fatalf("finalize: got %d (%s)", rr.Code, rr.Body.String())
		}
		return decodeUpload(t, rr)["filename"].(string)
	}

	if got := run("FIRST-RERUN"); got != "app (1).fab" {
		t.Fatalf("first collision landed as %q, want \"app (1).fab\"", got)
	}
	if got := run("SECOND-RERUN"); got != "app (2).fab" {
		t.Fatalf("second collision landed as %q, want \"app (2).fab\" (never (1)(1))", got)
	}
	if body, err := os.ReadFile(filepath.Join(exportDir, "app.fab")); err != nil || !bytes.Equal(body, pre) {
		t.Fatal("the pre-existing bundle was overwritten")
	}
	if body, _ := os.ReadFile(filepath.Join(exportDir, "app (1).fab")); string(body) != "FIRST-RERUN" {
		t.Fatalf("app (1).fab content = %q", body)
	}
}

// Abort deletes the .part and frees the slot (the Megszakítás button's route).
func TestFabUpload_Abort(t *testing.T) {
	s, exportDir := uploadTestServer(t)

	id := mustInit(t, s, "cancelme.fab", 100)
	if rr := uploadChunk(s, id, 0, bytes.Repeat([]byte{7}, 50)); rr.Code != http.StatusOK {
		t.Fatalf("chunk: got %d", rr.Code)
	}
	body, _ := json.Marshal(map[string]string{"upload_id": id})
	rr := httptest.NewRecorder()
	s.apiUploadAbort(rr, httptest.NewRequest(http.MethodPost, "/api/export/upload/abort", bytes.NewReader(body)))
	if rr.Code != http.StatusOK {
		t.Fatalf("abort: got %d", rr.Code)
	}
	entries, _ := os.ReadDir(exportDir)
	if len(entries) != 0 {
		t.Fatalf("exports dir must be empty after abort, got %v", entries)
	}
	mustInit(t, s, "next.fab", 10) // slot freed
}
