package handlers

import (
	"bytes"
	"context"
	"encoding/json"
	"io"
	"log/slog"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"testing"
	"time"

	"warpbox.dev/backend/libs/config"
	"warpbox.dev/backend/libs/services"
	"warpbox.dev/backend/libs/web"
)

func TestUploadJSONIncludesManageURLsAndAcceptsShareXField(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	request := multipartUploadRequest(t, "/api/v1/upload", "sharex", "shot.png", "image data")
	request.Header.Set("Accept", "application/json")
	response := httptest.NewRecorder()

	app.Upload(response, request)

	if response.Code != http.StatusCreated {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	var payload services.UploadResult
	if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
		t.Fatalf("json.Unmarshal returned error: %v", err)
	}
	if payload.BoxURL == "" || payload.ManageURL == "" || payload.DeleteURL == "" {
		t.Fatalf("upload response missing URLs: %+v", payload)
	}
	if !strings.Contains(payload.ManageURL, "/manage/") || !strings.HasSuffix(payload.DeleteURL, "/delete") {
		t.Fatalf("unexpected manage/delete URLs: %+v", payload)
	}
	if len(payload.Files) != 1 || payload.Files[0].Name != "shot.png" {
		t.Fatalf("unexpected files: %+v", payload.Files)
	}
}

func TestFileReactionCanBeAddedOncePerVisitor(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	payload := uploadThroughApp(t, app)
	if len(payload.Files) != 1 {
		t.Fatalf("uploaded files = %d", len(payload.Files))
	}

	request := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	request.SetPathValue("boxID", payload.BoxID)
	request.SetPathValue("fileID", payload.Files[0].ID)
	response := httptest.NewRecorder()
	app.ReactToFile(response, request)
	if response.Code != http.StatusCreated {
		t.Fatalf("first reaction status = %d, body = %s", response.Code, response.Body.String())
	}
	if !strings.Contains(response.Body.String(), `"count":1`) {
		t.Fatalf("reaction response missing count: %s", response.Body.String())
	}

	retry := httptest.NewRequest(http.MethodPost, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/react", strings.NewReader("emoji_id=openmoji/1F600.svg"))
	retry.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	retry.SetPathValue("boxID", payload.BoxID)
	retry.SetPathValue("fileID", payload.Files[0].ID)
	for _, cookie := range response.Result().Cookies() {
		retry.AddCookie(cookie)
	}
	retryResponse := httptest.NewRecorder()
	app.ReactToFile(retryResponse, retry)
	if retryResponse.Code != http.StatusConflict {
		t.Fatalf("second reaction status = %d, body = %s", retryResponse.Code, retryResponse.Body.String())
	}
}

func TestUploadTextResponseReturnsOnlyBoxURL(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	request := multipartUploadRequest(t, "/api/v1/upload", "file", "note.txt", "hello")
	response := httptest.NewRecorder()

	app.Upload(response, request)

	if response.Code != http.StatusCreated {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	got := response.Body.String()
	if !strings.HasPrefix(got, "http://example.test/d/") || !strings.HasSuffix(got, "\n") {
		t.Fatalf("text response = %q", got)
	}
	if strings.Contains(got, "{") || strings.Contains(got, "manage") {
		t.Fatalf("text response leaked JSON/manage data: %q", got)
	}
}

func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	payload := uploadThroughApp(t, app)

	request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
	request.Header.Set("User-Agent", "Discordbot/2.0")
	request.SetPathValue("boxID", payload.BoxID)
	response := httptest.NewRecorder()
	app.DownloadPage(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
		t.Fatalf("social preview bot did not receive file card metadata: %s", body)
	}
	if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
		t.Fatalf("download page did not render text thumbnail image: %s", body)
	}
	if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
		t.Fatalf("social preview body missing preview/download description: %s", body)
	}
}

func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	payload := uploadThroughApp(t, app)

	request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
	request.Header.Set("User-Agent", "TelegramBot")
	request.SetPathValue("boxID", payload.BoxID)
	request.SetPathValue("fileID", payload.Files[0].ID)
	response := httptest.NewRecorder()
	app.DownloadFile(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
		t.Fatalf("social preview bot did not receive file card metadata: %s", body)
	}
	if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {
		t.Fatalf("social preview body missing twitter card metadata: %s", body)
	}
}

func TestSocialPreviewBotGetsRawImageFilePreview(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	request := multipartUploadRequest(t, "/api/v1/upload", "file", "image.png", "\x89PNG\r\n\x1a\nimage")
	request.Header.Set("Accept", "application/json")
	uploadResponse := httptest.NewRecorder()
	app.Upload(uploadResponse, request)
	if uploadResponse.Code != http.StatusCreated {
		t.Fatalf("upload status = %d, body = %s", uploadResponse.Code, uploadResponse.Body.String())
	}
	var payload services.UploadResult
	if err := json.Unmarshal(uploadResponse.Body.Bytes(), &payload); err != nil {
		t.Fatalf("json.Unmarshal returned error: %v", err)
	}

	previewRequest := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
	previewRequest.Header.Set("User-Agent", "Discordbot/2.0")
	previewRequest.SetPathValue("boxID", payload.BoxID)
	previewRequest.SetPathValue("fileID", payload.Files[0].ID)
	response := httptest.NewRecorder()
	app.DownloadFile(response, previewRequest)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	if strings.Contains(response.Body.String(), "preview-title") {
		t.Fatalf("image social preview bot received HTML preview page")
	}
	if !strings.HasPrefix(response.Body.String(), "\x89PNG\r\n\x1a\n") {
		t.Fatalf("image social preview body = %q", response.Body.String())
	}
}

func TestFilePreviewPageIncludesPreviewMetadata(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	payload := uploadThroughApp(t, app)

	request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID, nil)
	request.SetPathValue("boxID", payload.BoxID)
	request.SetPathValue("fileID", payload.Files[0].ID)
	response := httptest.NewRecorder()
	app.DownloadFile(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	for _, want := range []string{
		`data-size-bytes="5"`,
		`data-source-url="/d/` + payload.BoxID,
		`data-download-url="/d/` + payload.BoxID,
		`data-icon-url="/static/file-icons/`,
		`data-preview-tabs`,
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("preview page missing %q: %s", want, body)
		}
	}
}

func TestDownloadPageShowsProcessingFailure(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	payload := uploadThroughApp(t, app)
	box, err := app.uploadService.GetBox(payload.BoxID)
	if err != nil {
		t.Fatalf("GetBox returned error: %v", err)
	}
	box.Files[0].Processing = false
	box.Files[0].ProcessingError = "Access Denied."
	if err := app.uploadService.SaveBox(box); err != nil {
		t.Fatalf("SaveBox returned error: %v", err)
	}

	request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID, nil)
	request.SetPathValue("boxID", payload.BoxID)
	response := httptest.NewRecorder()
	app.DownloadPage(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	body := response.Body.String()
	for _, want := range []string{
		"Upload processing failed",
		"Access Denied.",
		"is-failed",
		"Failed",
	} {
		if !strings.Contains(body, want) {
			t.Fatalf("download page missing %q: %s", want, body)
		}
	}
	if strings.Contains(body, `data-download-url="/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/download"`) {
		t.Fatalf("failed file still exposed download context: %s", body)
	}
}

func TestFileDownloadUsesOriginalFilename(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	payload := uploadNamedFileThroughApp(t, app, "report final.txt", "hello")

	request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download", nil)
	request.SetPathValue("boxID", payload.BoxID)
	request.SetPathValue("fileID", payload.Files[0].ID)
	response := httptest.NewRecorder()
	app.DownloadFileContent(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	disposition := response.Header().Get("Content-Disposition")
	for _, want := range []string{
		`attachment;`,
		`filename="report final.txt"`,
		`filename*=UTF-8''report%20final.txt`,
	} {
		if !strings.Contains(disposition, want) {
			t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
		}
	}
	if response.Body.String() != "hello" {
		t.Fatalf("body = %q", response.Body.String())
	}
}

func TestInlineFileDownloadKeepsOriginalFilename(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	payload := uploadNamedFileThroughApp(t, app, "résumé 2026.txt", "hello")

	request := httptest.NewRequest(http.MethodGet, "/d/"+payload.BoxID+"/f/"+payload.Files[0].ID+"/download?inline=1", nil)
	request.SetPathValue("boxID", payload.BoxID)
	request.SetPathValue("fileID", payload.Files[0].ID)
	response := httptest.NewRecorder()
	app.DownloadFileContent(response, request)

	if response.Code != http.StatusOK {
		t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
	}
	disposition := response.Header().Get("Content-Disposition")
	for _, want := range []string{
		`inline;`,
		`filename="r_sum_ 2026.txt"`,
		`filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt`,
	} {
		if !strings.Contains(disposition, want) {
			t.Fatalf("Content-Disposition missing %q: %q", want, disposition)
		}
	}
}

func TestResumableUploadFlowCreatesNormalBox(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	createBody := `{"files":[{"name":"note.txt","size":11,"contentType":"text/plain"}],"expiresMinutes":60}`
	createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
	createRequest.Header.Set("Accept", "application/json")
	createResponse := httptest.NewRecorder()
	app.CreateResumableUpload(createResponse, createRequest)
	if createResponse.Code != http.StatusCreated {
		t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
	}
	var session struct {
		SessionID   string `json:"sessionId"`
		ResumeToken string `json:"resumeToken"`
		ChunkSize   int64  `json:"chunkSize"`
		Files       []struct {
			ID             string `json:"id"`
			ChunkCount     int    `json:"chunkCount"`
			UploadedChunks []int  `json:"uploadedChunks"`
		} `json:"files"`
	}
	if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
		t.Fatalf("json.Unmarshal session returned error: %v", err)
	}
	if session.SessionID == "" || session.ResumeToken == "" || session.ChunkSize != 4 || len(session.Files) != 1 || session.Files[0].ChunkCount != 3 {
		t.Fatalf("unexpected session response: %+v", session)
	}

	chunks := map[int]string{1: "o wo", 0: "hell", 2: "rld"}
	for index, body := range chunks {
		request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/"+strconv.Itoa(index), strings.NewReader(body))
		request.SetPathValue("sessionID", session.SessionID)
		request.SetPathValue("fileID", session.Files[0].ID)
		request.SetPathValue("index", strconv.Itoa(index))
		request.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
		response := httptest.NewRecorder()
		app.PutResumableChunk(response, request)
		if response.Code != http.StatusOK {
			t.Fatalf("chunk %d status = %d, body = %s", index, response.Code, response.Body.String())
		}
	}

	completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
	completeRequest.SetPathValue("sessionID", session.SessionID)
	completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	completeResponse := httptest.NewRecorder()
	app.CompleteResumableUpload(completeResponse, completeRequest)
	if completeResponse.Code != http.StatusCreated {
		t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
	}
	var payload services.UploadResult
	if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
		t.Fatalf("json.Unmarshal result returned error: %v", err)
	}
	replayRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
	replayRequest.SetPathValue("sessionID", session.SessionID)
	replayRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	replayResponse := httptest.NewRecorder()
	app.CompleteResumableUpload(replayResponse, replayRequest)
	if replayResponse.Code != http.StatusOK {
		t.Fatalf("complete replay status = %d, body = %s", replayResponse.Code, replayResponse.Body.String())
	}
	var replayPayload services.UploadResult
	if err := json.Unmarshal(replayResponse.Body.Bytes(), &replayPayload); err != nil {
		t.Fatalf("json.Unmarshal replay result returned error: %v", err)
	}
	if replayPayload.BoxID != payload.BoxID || replayPayload.BoxURL == "" {
		t.Fatalf("unexpected replay result: %+v, original: %+v", replayPayload, payload)
	}
	box := waitForProcessedBox(t, app, payload.BoxID)
	if len(box.Files) != 1 || box.Files[0].Name != "note.txt" || box.Files[0].Size != 11 {
		t.Fatalf("unexpected box files: %+v", box.Files)
	}
	object, err := app.uploadService.OpenFileObject(context.Background(), box, box.Files[0])
	if err != nil {
		t.Fatalf("OpenFileObject returned error: %v", err)
	}
	data, err := io.ReadAll(object.Body)
	object.Body.Close()
	if err != nil {
		t.Fatalf("ReadAll returned error: %v", err)
	}
	if string(data) != "hello world" {
		t.Fatalf("uploaded body = %q", string(data))
	}
}

func TestResumableUploadRequiresAllChunks(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":8,"contentType":"text/plain"}]}`))
	createResponse := httptest.NewRecorder()
	app.CreateResumableUpload(createResponse, createRequest)
	if createResponse.Code != http.StatusCreated {
		t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
	}
	var session struct {
		SessionID   string `json:"sessionId"`
		ResumeToken string `json:"resumeToken"`
		Files       []struct {
			ID string `json:"id"`
		} `json:"files"`
	}
	if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
		t.Fatalf("json.Unmarshal session returned error: %v", err)
	}
	chunkRequest := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("hell"))
	chunkRequest.SetPathValue("sessionID", session.SessionID)
	chunkRequest.SetPathValue("fileID", session.Files[0].ID)
	chunkRequest.SetPathValue("index", "0")
	chunkRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	chunkResponse := httptest.NewRecorder()
	app.PutResumableChunk(chunkResponse, chunkRequest)
	if chunkResponse.Code != http.StatusOK {
		t.Fatalf("chunk status = %d, body = %s", chunkResponse.Code, chunkResponse.Body.String())
	}

	completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
	completeRequest.SetPathValue("sessionID", session.SessionID)
	completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	completeResponse := httptest.NewRecorder()
	app.CompleteResumableUpload(completeResponse, completeRequest)
	if completeResponse.Code != http.StatusBadRequest {
		t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
	}
}

func TestResumableStatusRequiresResumeToken(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(`{"files":[{"name":"note.txt","size":4,"contentType":"text/plain"}]}`))
	createResponse := httptest.NewRecorder()
	app.CreateResumableUpload(createResponse, createRequest)
	if createResponse.Code != http.StatusCreated {
		t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
	}
	var session struct {
		SessionID   string `json:"sessionId"`
		ResumeToken string `json:"resumeToken"`
	}
	if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
		t.Fatalf("json.Unmarshal session returned error: %v", err)
	}

	missing := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
	missing.SetPathValue("sessionID", session.SessionID)
	missingResponse := httptest.NewRecorder()
	app.ResumableUploadStatus(missingResponse, missing)
	if missingResponse.Code != http.StatusUnauthorized {
		t.Fatalf("missing token status = %d, body = %s", missingResponse.Code, missingResponse.Body.String())
	}

	wrong := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
	wrong.SetPathValue("sessionID", session.SessionID)
	wrong.Header.Set("X-Warpbox-Resume-Token", "wrong")
	wrongResponse := httptest.NewRecorder()
	app.ResumableUploadStatus(wrongResponse, wrong)
	if wrongResponse.Code != http.StatusUnauthorized {
		t.Fatalf("wrong token status = %d, body = %s", wrongResponse.Code, wrongResponse.Body.String())
	}

	valid := httptest.NewRequest(http.MethodGet, "/api/v1/uploads/resumable/"+session.SessionID, nil)
	valid.SetPathValue("sessionID", session.SessionID)
	valid.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	validResponse := httptest.NewRecorder()
	app.ResumableUploadStatus(validResponse, valid)
	if validResponse.Code != http.StatusOK {
		t.Fatalf("valid token status = %d, body = %s", validResponse.Code, validResponse.Body.String())
	}
	if strings.Contains(validResponse.Body.String(), "resumeTokenHash") {
		t.Fatalf("status response leaked token hash: %s", validResponse.Body.String())
	}
}

func TestResumableUploadCanAddFilesToExistingSession(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	createBody := `{"files":[{"name":"one.txt","size":4,"contentType":"text/plain","fingerprint":"one"}],"expiresMinutes":60}`
	createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
	createResponse := httptest.NewRecorder()
	app.CreateResumableUpload(createResponse, createRequest)
	if createResponse.Code != http.StatusCreated {
		t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
	}
	var session struct {
		SessionID   string `json:"sessionId"`
		ResumeToken string `json:"resumeToken"`
		Files       []struct {
			ID string `json:"id"`
		} `json:"files"`
	}
	if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
		t.Fatalf("json.Unmarshal session returned error: %v", err)
	}
	firstChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[0].ID+"/chunks/0", strings.NewReader("one!"))
	firstChunk.SetPathValue("sessionID", session.SessionID)
	firstChunk.SetPathValue("fileID", session.Files[0].ID)
	firstChunk.SetPathValue("index", "0")
	firstChunk.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	firstChunkResponse := httptest.NewRecorder()
	app.PutResumableChunk(firstChunkResponse, firstChunk)
	if firstChunkResponse.Code != http.StatusOK {
		t.Fatalf("first chunk status = %d, body = %s", firstChunkResponse.Code, firstChunkResponse.Body.String())
	}

	addRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/files", strings.NewReader(`{"files":[{"name":"two.txt","size":4,"contentType":"text/plain","fingerprint":"two"}]}`))
	addRequest.SetPathValue("sessionID", session.SessionID)
	addRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	addResponse := httptest.NewRecorder()
	app.AddResumableFiles(addResponse, addRequest)
	if addResponse.Code != http.StatusOK {
		t.Fatalf("add status = %d, body = %s", addResponse.Code, addResponse.Body.String())
	}
	var updated struct {
		Files []struct {
			ID             string `json:"id"`
			Name           string `json:"name"`
			UploadedChunks []int  `json:"uploadedChunks"`
		} `json:"files"`
	}
	if err := json.Unmarshal(addResponse.Body.Bytes(), &updated); err != nil {
		t.Fatalf("json.Unmarshal updated returned error: %v", err)
	}
	if len(updated.Files) != 2 || len(updated.Files[0].UploadedChunks) != 1 {
		t.Fatalf("unexpected updated session: %+v", updated)
	}
	secondChunk := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+updated.Files[1].ID+"/chunks/0", strings.NewReader("two!"))
	secondChunk.SetPathValue("sessionID", session.SessionID)
	secondChunk.SetPathValue("fileID", updated.Files[1].ID)
	secondChunk.SetPathValue("index", "0")
	secondChunk.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	secondChunkResponse := httptest.NewRecorder()
	app.PutResumableChunk(secondChunkResponse, secondChunk)
	if secondChunkResponse.Code != http.StatusOK {
		t.Fatalf("second chunk status = %d, body = %s", secondChunkResponse.Code, secondChunkResponse.Body.String())
	}
	completeRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete", nil)
	completeRequest.SetPathValue("sessionID", session.SessionID)
	completeRequest.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	completeResponse := httptest.NewRecorder()
	app.CompleteResumableUpload(completeResponse, completeRequest)
	if completeResponse.Code != http.StatusCreated {
		t.Fatalf("complete status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
	}
	var payload services.UploadResult
	if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
		t.Fatalf("json.Unmarshal result returned error: %v", err)
	}
	box, err := app.uploadService.GetBox(payload.BoxID)
	if err != nil {
		t.Fatalf("GetBox returned error: %v", err)
	}
	if len(box.Files) != 2 {
		t.Fatalf("box file count = %d, want 2", len(box.Files))
	}
}

func TestResumableCompleteUploadedRequiresTokenAndKeepsFinishedFiles(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	createBody := `{"files":[{"name":"done.txt","size":4,"contentType":"text/plain","fingerprint":"done"},{"name":"partial.txt","size":8,"contentType":"text/plain","fingerprint":"partial"}],"expiresMinutes":60}`
	createRequest := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable", strings.NewReader(createBody))
	createResponse := httptest.NewRecorder()
	app.CreateResumableUpload(createResponse, createRequest)
	if createResponse.Code != http.StatusCreated {
		t.Fatalf("create status = %d, body = %s", createResponse.Code, createResponse.Body.String())
	}
	var session struct {
		SessionID   string `json:"sessionId"`
		ResumeToken string `json:"resumeToken"`
		Files       []struct {
			ID string `json:"id"`
		} `json:"files"`
	}
	if err := json.Unmarshal(createResponse.Body.Bytes(), &session); err != nil {
		t.Fatalf("json.Unmarshal session returned error: %v", err)
	}
	for _, chunk := range []struct {
		fileIndex int
		index     string
		body      string
	}{
		{fileIndex: 0, index: "0", body: "done"},
		{fileIndex: 1, index: "0", body: "part"},
	} {
		request := httptest.NewRequest(http.MethodPut, "/api/v1/uploads/resumable/"+session.SessionID+"/files/"+session.Files[chunk.fileIndex].ID+"/chunks/"+chunk.index, strings.NewReader(chunk.body))
		request.SetPathValue("sessionID", session.SessionID)
		request.SetPathValue("fileID", session.Files[chunk.fileIndex].ID)
		request.SetPathValue("index", chunk.index)
		request.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
		response := httptest.NewRecorder()
		app.PutResumableChunk(response, request)
		if response.Code != http.StatusOK {
			t.Fatalf("chunk status = %d, body = %s", response.Code, response.Body.String())
		}
	}

	missing := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete-uploaded", nil)
	missing.SetPathValue("sessionID", session.SessionID)
	missingResponse := httptest.NewRecorder()
	app.CompleteUploadedResumableUpload(missingResponse, missing)
	if missingResponse.Code != http.StatusUnauthorized {
		t.Fatalf("missing token status = %d, body = %s", missingResponse.Code, missingResponse.Body.String())
	}

	complete := httptest.NewRequest(http.MethodPost, "/api/v1/uploads/resumable/"+session.SessionID+"/complete-uploaded", nil)
	complete.SetPathValue("sessionID", session.SessionID)
	complete.Header.Set("X-Warpbox-Resume-Token", session.ResumeToken)
	completeResponse := httptest.NewRecorder()
	app.CompleteUploadedResumableUpload(completeResponse, complete)
	if completeResponse.Code != http.StatusCreated {
		t.Fatalf("complete-uploaded status = %d, body = %s", completeResponse.Code, completeResponse.Body.String())
	}
	var payload services.UploadResult
	if err := json.Unmarshal(completeResponse.Body.Bytes(), &payload); err != nil {
		t.Fatalf("json.Unmarshal result returned error: %v", err)
	}
	box, err := app.uploadService.GetBox(payload.BoxID)
	if err != nil {
		t.Fatalf("GetBox returned error: %v", err)
	}
	if len(box.Files) != 1 || box.Files[0].Name != "done.txt" {
		t.Fatalf("complete-uploaded box files = %+v", box.Files)
	}
	if _, err := app.uploadService.GetResumableSession(session.SessionID); !os.IsNotExist(err) {
		t.Fatalf("GetResumableSession after complete-uploaded error = %v, want os.ErrNotExist", err)
	}
}

func TestManageBoxAndDeleteFlow(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	result := uploadThroughApp(t, app)
	token := tokenFromURL(t, result.ManageURL)

	manageRequest := httptest.NewRequest(http.MethodGet, "/d/"+result.BoxID+"/manage/"+token, nil)
	manageRequest.SetPathValue("boxID", result.BoxID)
	manageRequest.SetPathValue("token", token)
	manageResponse := httptest.NewRecorder()
	app.ManageBox(manageResponse, manageRequest)
	if manageResponse.Code != http.StatusOK {
		t.Fatalf("manage status = %d, body = %s", manageResponse.Code, manageResponse.Body.String())
	}
	if !strings.Contains(manageResponse.Body.String(), "Delete upload") {
		t.Fatalf("manage page did not render confirmation")
	}

	deleteRequest := httptest.NewRequest(http.MethodPost, "/d/"+result.BoxID+"/manage/"+token+"/delete", nil)
	deleteRequest.SetPathValue("boxID", result.BoxID)
	deleteRequest.SetPathValue("token", token)
	deleteResponse := httptest.NewRecorder()
	app.ManageDeleteBox(deleteResponse, deleteRequest)
	if deleteResponse.Code != http.StatusSeeOther {
		t.Fatalf("delete status = %d, body = %s", deleteResponse.Code, deleteResponse.Body.String())
	}
	if location := deleteResponse.Header().Get("Location"); location != "/d/"+result.BoxID+"/deleted" {
		t.Fatalf("delete redirect = %q", location)
	}
	if _, err := app.uploadService.GetBox(result.BoxID); !os.IsNotExist(err) {
		t.Fatalf("GetBox after delete error = %v, want os.ErrNotExist", err)
	}
}

func TestManageBoxRejectsInvalidToken(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()
	result := uploadThroughApp(t, app)

	request := httptest.NewRequest(http.MethodGet, "/d/"+result.BoxID+"/manage/wrong", nil)
	request.SetPathValue("boxID", result.BoxID)
	request.SetPathValue("token", "wrong")
	response := httptest.NewRecorder()

	app.ManageBox(response, request)

	if response.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want %d", response.Code, http.StatusNotFound)
	}
	if strings.Contains(response.Body.String(), result.BoxID) {
		t.Fatalf("invalid token response leaked box id")
	}
}

func TestAPIDocsAndSchemas(t *testing.T) {
	app, cleanup := newTestApp(t)
	defer cleanup()

	docsResponse := httptest.NewRecorder()
	app.APIDocs(docsResponse, httptest.NewRequest(http.MethodGet, "/api", nil))
	if docsResponse.Code != http.StatusOK {
		t.Fatalf("docs status = %d, body = %s", docsResponse.Code, docsResponse.Body.String())
	}
	if !strings.Contains(docsResponse.Body.String(), "/api/v1/schemas/upload-request.json") ||
		!strings.Contains(docsResponse.Body.String(), "ShareX setup") {
		t.Fatalf("API docs missing schema links or ShareX tutorial")
	}

	requestSchemaResponse := httptest.NewRecorder()
	app.UploadRequestSchema(requestSchemaResponse, httptest.NewRequest(http.MethodGet, "/api/v1/schemas/upload-request.json", nil))
	if requestSchemaResponse.Code != http.StatusOK {
		t.Fatalf("request schema status = %d", requestSchemaResponse.Code)
	}
	if !strings.Contains(requestSchemaResponse.Body.String(), `"sharex"`) {
		t.Fatalf("request schema missing sharex field")
	}

	responseSchemaResponse := httptest.NewRecorder()
	app.UploadResponseSchema(responseSchemaResponse, httptest.NewRequest(http.MethodGet, "/api/v1/schemas/upload-response.json", nil))
	if responseSchemaResponse.Code != http.StatusOK {
		t.Fatalf("response schema status = %d", responseSchemaResponse.Code)
	}
	if !strings.Contains(responseSchemaResponse.Body.String(), `"manageUrl"`) {
		t.Fatalf("response schema missing manageUrl field")
	}

	shareXResponse := httptest.NewRecorder()
	app.ShareXAnonymousConfig(shareXResponse, httptest.NewRequest(http.MethodGet, "/api/v1/sharex/warpbox-anonymous.sxcu", nil))
	if shareXResponse.Code != http.StatusOK {
		t.Fatalf("ShareX config status = %d", shareXResponse.Code)
	}
	if !strings.Contains(shareXResponse.Body.String(), `"FileFormName":"sharex"`) ||
		!strings.Contains(shareXResponse.Body.String(), `"RequestURL":"http://example.test/api/v1/upload"`) {
		t.Fatalf("ShareX config did not include instance upload settings: %s", shareXResponse.Body.String())
	}
}

func newTestApp(t *testing.T) (*App, func()) {
	t.Helper()
	root := t.TempDir()
	templateDir := filepath.Join(root, "templates")
	if err := copyDir(filepath.Join("..", "..", "templates"), templateDir); err != nil {
		t.Fatalf("copy templates: %v", err)
	}
	staticDir := filepath.Join(root, "static")
	if err := copyDir(filepath.Join("..", "..", "static"), staticDir); err != nil {
		t.Fatalf("copy static: %v", err)
	}

	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	cfg := config.Config{
		AppName:                 "warpbox.dev",
		AppVersion:              "test",
		BaseURL:                 "http://example.test",
		DataDir:                 filepath.Join(root, "data"),
		StaticDir:               staticDir,
		TemplateDir:             templateDir,
		MaxUploadSize:           1024 * 1024,
		ResumableUploadsEnabled: true,
		ResumableChunkSize:      4,
		ResumableRetention:      time.Hour,
		DefaultSettings: config.SettingsDefaults{
			AnonymousUploadsEnabled: true,
			AnonymousMaxUploadMB:    1,
			AnonymousDailyUploadMB:  8,
			UserDailyUploadMB:       8,
			DefaultUserStorageMB:    16,
			UsageRetentionDays:      30,
			ResumableUploadsEnabled: true,
			ResumableChunkSizeMB:    0.000003814697265625,
			ResumableRetentionHours: 1,
			ResumableChunkMode:      "same",
		},
	}
	service, err := services.NewUploadService(cfg.MaxUploadSize, cfg.DataDir, cfg.BaseURL, logger)
	if err != nil {
		t.Fatalf("NewUploadService returned error: %v", err)
	}
	if err := os.MkdirAll(filepath.Join(cfg.DataDir, "emoji", "openmoji"), 0o755); err != nil {
		service.Close()
		t.Fatalf("create emoji test dir: %v", err)
	}
	if err := os.WriteFile(filepath.Join(cfg.DataDir, "emoji", "openmoji", "1F600.svg"), []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>`), 0o644); err != nil {
		service.Close()
		t.Fatalf("write emoji test file: %v", err)
	}
	renderer, err := web.NewRenderer(cfg.TemplateDir, cfg.AppName, cfg.AppVersion, cfg.BaseURL)
	if err != nil {
		service.Close()
		t.Fatalf("NewRenderer returned error: %v", err)
	}
	authService, err := services.NewAuthService(service.DB(), cfg.BaseURL)
	if err != nil {
		service.Close()
		t.Fatalf("NewAuthService returned error: %v", err)
	}
	settingsService, err := services.NewSettingsService(service.DB(), cfg.DefaultSettings)
	if err != nil {
		service.Close()
		t.Fatalf("NewSettingsService returned error: %v", err)
	}
	reactionService, err := services.NewReactionService(service.DB())
	if err != nil {
		service.Close()
		t.Fatalf("NewReactionService returned error: %v", err)
	}
	banService, err := services.NewBanService(service.DB())
	if err != nil {
		service.Close()
		t.Fatalf("NewBanService returned error: %v", err)
	}
	return NewApp(cfg, logger, renderer, service, authService, settingsService, reactionService, banService), func() {
		if err := service.Close(); err != nil {
			t.Fatalf("Close returned error: %v", err)
		}
	}
}

func uploadThroughApp(t *testing.T, app *App) services.UploadResult {
	return uploadNamedFileThroughApp(t, app, "note.txt", "hello")
}

func uploadNamedFileThroughApp(t *testing.T, app *App, filename, body string) services.UploadResult {
	t.Helper()
	request := multipartUploadRequest(t, "/api/v1/upload", "file", filename, body)
	request.Header.Set("Accept", "application/json")
	response := httptest.NewRecorder()
	app.Upload(response, request)
	if response.Code != http.StatusCreated {
		t.Fatalf("upload status = %d, body = %s", response.Code, response.Body.String())
	}
	var payload services.UploadResult
	if err := json.Unmarshal(response.Body.Bytes(), &payload); err != nil {
		t.Fatalf("json.Unmarshal returned error: %v", err)
	}
	return payload
}

func multipartUploadRequest(t *testing.T, path, field, filename, body string) *http.Request {
	t.Helper()
	var payload bytes.Buffer
	writer := multipart.NewWriter(&payload)
	part, err := writer.CreateFormFile(field, filename)
	if err != nil {
		t.Fatalf("CreateFormFile returned error: %v", err)
	}
	if _, err := part.Write([]byte(body)); err != nil {
		t.Fatalf("part.Write returned error: %v", err)
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("writer.Close returned error: %v", err)
	}

	request := httptest.NewRequest(http.MethodPost, path, &payload)
	request.Header.Set("Content-Type", writer.FormDataContentType())
	return request
}

func multipartUploadRequestWithField(t *testing.T, path, field, filename, body, extraName, extraValue string) *http.Request {
	t.Helper()
	var payload bytes.Buffer
	writer := multipart.NewWriter(&payload)
	part, err := writer.CreateFormFile(field, filename)
	if err != nil {
		t.Fatalf("CreateFormFile returned error: %v", err)
	}
	if _, err := part.Write([]byte(body)); err != nil {
		t.Fatalf("part.Write returned error: %v", err)
	}
	if err := writer.WriteField(extraName, extraValue); err != nil {
		t.Fatalf("WriteField returned error: %v", err)
	}
	if err := writer.Close(); err != nil {
		t.Fatalf("writer.Close returned error: %v", err)
	}

	request := httptest.NewRequest(http.MethodPost, path, &payload)
	request.Header.Set("Content-Type", writer.FormDataContentType())
	return request
}

func tokenFromURL(t *testing.T, value string) string {
	t.Helper()
	parts := strings.Split(strings.TrimRight(value, "/"), "/")
	if len(parts) == 0 {
		t.Fatalf("empty URL")
	}
	return parts[len(parts)-1]
}

func waitForProcessedBox(t *testing.T, app *App, boxID string) services.Box {
	t.Helper()
	var box services.Box
	for i := 0; i < 50; i++ {
		next, err := app.uploadService.GetBox(boxID)
		if err != nil {
			t.Fatalf("GetBox returned error: %v", err)
		}
		box = next
		processing := false
		for _, file := range box.Files {
			if file.Processing {
				processing = true
				break
			}
		}
		if !processing {
			return box
		}
		time.Sleep(10 * time.Millisecond)
	}
	t.Fatalf("box %s was still processing: %+v", boxID, box.Files)
	return box
}

func copyDir(src, dst string) error {
	return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		rel, err := filepath.Rel(src, path)
		if err != nil {
			return err
		}
		target := filepath.Join(dst, rel)
		if d.IsDir() {
			return os.MkdirAll(target, 0o755)
		}
		data, err := os.ReadFile(path)
		if err != nil {
			return err
		}
		return os.WriteFile(target, data, 0o644)
	})
}
