package handlers

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"mime/multipart"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"

	"Noooste/garage-ui/internal/models"
	"Noooste/garage-ui/internal/services"
	"Noooste/garage-ui/internal/services/mocks"

	"github.com/gofiber/fiber/v3"
)

func newObjectsTestApp(t *testing.T) (*fiber.App, *mocks.S3Mock) {
	t.Helper()
	s3 := &mocks.S3Mock{}
	h := NewObjectHandler(s3)
	app := fiber.New()
	app.Get("/buckets/:bucket/objects", h.ListObjects)
	app.Post("/buckets/:bucket/objects", h.UploadObject)
	app.Post("/buckets/:bucket/objects/upload-multiple", h.UploadMultipleObjects)
	app.Post("/buckets/:bucket/objects/delete-multiple", h.DeleteMultipleObjects)
	// Wildcard endpoints — mount under :key for tests. Handlers prefer
	// c.Locals("objectKey") but fall back to c.Params("key"), so :key works.
	app.Get("/buckets/:bucket/objects/:key", h.GetObject)
	app.Get("/buckets/:bucket/objects/:key/metadata", h.GetObjectMetadata)
	app.Get("/buckets/:bucket/objects/:key/presigned", h.GetPresignedURL)
	app.Delete("/buckets/:bucket/objects/:key", h.DeleteObject)
	return app, s3
}

// --- ListObjects ---

func TestListObjects_Success(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ListObjectsFn = func(_ context.Context, bucket, prefix string, max int, tok string) (*models.ObjectListResponse, error) {
		if bucket != "b1" || prefix != "p/" || max != 50 || tok != "T" {
			t.Errorf("args = (%q, %q, %d, %q)", bucket, prefix, max, tok)
		}
		return &models.ObjectListResponse{
			Bucket: bucket, Count: 1,
			Objects: []models.ObjectInfo{{Key: "k1", Size: 1}},
		}, nil
	}
	req := httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?prefix=p/&max_keys=50&continuation_token=T", nil)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	var body struct {
		Data models.ObjectListResponse `json:"data"`
	}
	decodeJSON(t, resp.Body, &body)
	if body.Data.Count != 1 {
		t.Errorf("count = %d", body.Data.Count)
	}
}

func TestListObjects_DefaultMaxKeys(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ListObjectsFn = func(_ context.Context, _, _ string, max int, _ string) (*models.ObjectListResponse, error) {
		if max != 100 {
			t.Errorf("max = %d, want default 100", max)
		}
		return &models.ObjectListResponse{}, nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

func TestListObjects_InvalidMaxKeys400(t *testing.T) {
	app, _ := newObjectsTestApp(t)
	cases := []string{"0", "-1", "abc"}
	for _, mk := range cases {
		t.Run(mk, func(t *testing.T) {
			resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects?max_keys="+mk, nil))
			if err != nil {
				t.Fatalf("app.Test: %v", err)
			}
			defer resp.Body.Close()
			if resp.StatusCode != http.StatusBadRequest {
				t.Fatalf("status = %d, want 400", resp.StatusCode)
			}
		})
	}
}

func TestListObjects_ServiceError500(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ListObjectsFn = func(_ context.Context, _, _ string, _ int, _ string) (*models.ObjectListResponse, error) {
		return nil, errors.New("boom")
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", resp.StatusCode)
	}
}

// --- GetObjectMetadata ---

func TestGetObjectMetadata_Success(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.GetObjectMetadataFn = func(_ context.Context, b, k string) (*models.ObjectInfo, error) {
		return &models.ObjectInfo{Key: k, Size: 42, ContentType: "image/png"}, nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/metadata", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	var body struct {
		Data models.ObjectInfo `json:"data"`
	}
	decodeJSON(t, resp.Body, &body)
	if body.Data.Size != 42 {
		t.Errorf("size = %d", body.Data.Size)
	}
}

func TestGetObjectMetadata_NotFound404(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.GetObjectMetadataFn = func(_ context.Context, _, _ string) (*models.ObjectInfo, error) {
		return nil, errors.New("not found")
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/metadata", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

// --- DeleteObject ---

func TestDeleteObject_Success(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
	s3.DeleteObjectFn = func(_ context.Context, b, k string) error {
		if b != "b1" || k != "k1" {
			t.Errorf("args = (%q, %q)", b, k)
		}
		return nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

func TestDeleteObject_NotExists404(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
	resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/nope", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

func TestDeleteObject_ExistsCheckError500(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, errors.New("boom") }
	resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", resp.StatusCode)
	}
}

func TestDeleteObject_DeleteError500(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
	s3.DeleteObjectFn = func(_ context.Context, _, _ string) error { return errors.New("boom") }
	resp, err := app.Test(httptest.NewRequest(http.MethodDelete, "/buckets/b1/objects/k1", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", resp.StatusCode)
	}
}

// --- GetPresignedURL ---

func TestGetPresignedURL_DefaultExpiration(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
	s3.GetPresignedURLFn = func(_ context.Context, b, k string, exp time.Duration) (string, error) {
		if exp != 3600*time.Second {
			t.Errorf("exp = %v, want 1h", exp)
		}
		return "https://example/signed", nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	var body struct {
		Data models.PresignedURLResponse `json:"data"`
	}
	decodeJSON(t, resp.Body, &body)
	if body.Data.URL != "https://example/signed" || body.Data.ExpiresIn != 3600 {
		t.Errorf("body = %+v", body.Data)
	}
}

func TestGetPresignedURL_CustomExpirationWithinRange(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return true, nil }
	s3.GetPresignedURLFn = func(_ context.Context, _, _ string, exp time.Duration) (string, error) {
		if exp != 60*time.Second {
			t.Errorf("exp = %v, want 60s", exp)
		}
		return "u", nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in=60", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

func TestGetPresignedURL_InvalidExpiration400(t *testing.T) {
	app, _ := newObjectsTestApp(t)
	cases := []string{"0", "-1", "604801", "abc"}
	for _, val := range cases {
		t.Run(val, func(t *testing.T) {
			resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1/presigned?expires_in="+val, nil))
			if err != nil {
				t.Fatalf("app.Test: %v", err)
			}
			defer resp.Body.Close()
			if resp.StatusCode != http.StatusBadRequest {
				t.Fatalf("status = %d, want 400", resp.StatusCode)
			}
		})
	}
}

func TestGetPresignedURL_ObjectMissing404(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.ObjectExistsFn = func(_ context.Context, _, _ string) (bool, error) { return false, nil }
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope/presigned", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

// --- GetObject ---

func TestGetObject_Success_StreamsBodyAndHeaders(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	content := []byte("hello world")
	s3.GetObjectFn = func(_ context.Context, b, k string) (io.ReadCloser, *models.ObjectInfo, error) {
		return io.NopCloser(bytes.NewReader(content)), &models.ObjectInfo{
			Key: k, Size: int64(len(content)), ETag: `"etag-1"`,
			ContentType: "image/png", LastModified: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC),
		}, nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/k1", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	if got := resp.Header.Get("Content-Type"); got != "image/png" {
		t.Errorf("Content-Type = %q", got)
	}
	if got := resp.Header.Get("X-Content-Type-Options"); got != "nosniff" {
		t.Errorf("X-Content-Type-Options = %q", got)
	}
	if !strings.Contains(resp.Header.Get("Content-Disposition"), `filename="k1"`) {
		t.Errorf("Content-Disposition = %q", resp.Header.Get("Content-Disposition"))
	}
	body, _ := io.ReadAll(resp.Body)
	if !bytes.Equal(body, content) {
		t.Errorf("body = %q, want %q", body, content)
	}
}

func TestGetObject_RewritesExecutableContentType(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
		return io.NopCloser(strings.NewReader("<script>alert(1)</script>")),
			&models.ObjectInfo{Key: "evil.html", Size: 25, ContentType: "text/html"},
			nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/evil.html", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if got := resp.Header.Get("Content-Type"); got != "application/octet-stream" {
		t.Errorf("Content-Type = %q, want application/octet-stream", got)
	}
}

func TestGetObject_DownloadQuerySetsAttachment(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
		return io.NopCloser(strings.NewReader("x")), &models.ObjectInfo{Key: "file.txt", Size: 1}, nil
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/file.txt?download=true", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if !strings.HasPrefix(resp.Header.Get("Content-Disposition"), "attachment") {
		t.Errorf("Content-Disposition = %q, want attachment", resp.Header.Get("Content-Disposition"))
	}
}

func TestGetObject_ServiceErrorReturns404(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.GetObjectFn = func(_ context.Context, _, _ string) (io.ReadCloser, *models.ObjectInfo, error) {
		return nil, nil, errors.New("not found")
	}
	resp, err := app.Test(httptest.NewRequest(http.MethodGet, "/buckets/b1/objects/nope", nil))
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", resp.StatusCode)
	}
}

// buildMultipart builds a multipart body with a single file field plus
// optional additional form fields. Returns body bytes and the Content-Type
// header value (which includes the boundary).
func buildMultipart(t *testing.T, fields map[string]string, files map[string]struct {
	Filename    string
	Content     []byte
	ContentType string
}) ([]byte, string) {
	t.Helper()
	buf := &bytes.Buffer{}
	w := multipart.NewWriter(buf)
	for k, v := range fields {
		if err := w.WriteField(k, v); err != nil {
			t.Fatalf("WriteField: %v", err)
		}
	}
	for name, f := range files {
		h := make(map[string][]string)
		h["Content-Disposition"] = []string{
			`form-data; name="` + name + `"; filename="` + f.Filename + `"`,
		}
		ct := f.ContentType
		if ct == "" {
			ct = "application/octet-stream"
		}
		h["Content-Type"] = []string{ct}
		part, err := w.CreatePart(h)
		if err != nil {
			t.Fatalf("CreatePart: %v", err)
		}
		if _, err := part.Write(f.Content); err != nil {
			t.Fatalf("part.Write: %v", err)
		}
	}
	if err := w.Close(); err != nil {
		t.Fatalf("writer.Close: %v", err)
	}
	return buf.Bytes(), w.FormDataContentType()
}

func TestUploadObject_Success(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadObjectFn = func(_ context.Context, bucket, key string, body io.Reader, ct string) (*models.ObjectUploadResponse, error) {
		if bucket != "b1" || key != "myfile.bin" {
			t.Errorf("args = (%q, %q)", bucket, key)
		}
		if ct != "application/octet-stream" {
			t.Errorf("contentType = %q", ct)
		}
		b, _ := io.ReadAll(body)
		if string(b) != "payload" {
			t.Errorf("body = %q, want 'payload'", b)
		}
		return &models.ObjectUploadResponse{Bucket: bucket, Key: key, Size: int64(len(b))}, nil
	}
	body, ct := buildMultipart(t, nil, map[string]struct {
		Filename    string
		Content     []byte
		ContentType string
	}{
		"file": {Filename: "myfile.bin", Content: []byte("payload")},
	})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		raw, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
	}
}

func TestUploadObject_ExplicitKeyOverridesFilename(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadObjectFn = func(_ context.Context, _, key string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
		if key != "custom/key.txt" {
			t.Errorf("key = %q, want custom/key.txt", key)
		}
		return &models.ObjectUploadResponse{Key: key}, nil
	}
	body, ct := buildMultipart(t, map[string]string{"key": "custom/key.txt"}, map[string]struct {
		Filename    string
		Content     []byte
		ContentType string
	}{
		"file": {Filename: "whatever.txt", Content: []byte("x")},
	})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

func TestUploadObject_MissingFileReturns400(t *testing.T) {
	app, _ := newObjectsTestApp(t)
	body, ct := buildMultipart(t, map[string]string{"key": "k"}, nil)
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestUploadObject_ServiceError500(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadObjectFn = func(_ context.Context, _, _ string, _ io.Reader, _ string) (*models.ObjectUploadResponse, error) {
		return nil, errors.New("boom")
	}
	body, ct := buildMultipart(t, nil, map[string]struct {
		Filename    string
		Content     []byte
		ContentType string
	}{"file": {Filename: "f.bin", Content: []byte("x")}})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", resp.StatusCode)
	}
}

func TestDeleteMultipleObjects_Success(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.DeleteMultipleObjectsFn = func(_ context.Context, bucket string, keys []string) error {
		if bucket != "b1" || len(keys) != 3 {
			t.Errorf("args = (%q, %v)", bucket, keys)
		}
		return nil
	}
	body, _ := json.Marshal(map[string]any{"keys": []string{"a", "b", "c"}})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d", resp.StatusCode)
	}
	var out struct {
		Data models.ObjectDeleteMultipleResponse `json:"data"`
	}
	decodeJSON(t, resp.Body, &out)
	if out.Data.Deleted != 3 {
		t.Errorf("Deleted = %d, want 3", out.Data.Deleted)
	}
}

func TestDeleteMultipleObjects_EmptyKeys400(t *testing.T) {
	app, _ := newObjectsTestApp(t)
	body, _ := json.Marshal(map[string]any{"keys": []string{}})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestDeleteMultipleObjects_MalformedJSON400(t *testing.T) {
	app, _ := newObjectsTestApp(t)
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", strings.NewReader("{not-json"))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

func TestDeleteMultipleObjects_ServiceError500(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.DeleteMultipleObjectsFn = func(_ context.Context, _ string, _ []string) error { return errors.New("boom") }
	body, _ := json.Marshal(map[string]any{"keys": []string{"a"}})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/delete-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status = %d", resp.StatusCode)
	}
}

// buildMultipartMulti builds a body with N "files" parts.
func buildMultipartMulti(t *testing.T, files []struct {
	Filename, ContentType string
	Content               []byte
}) ([]byte, string) {
	t.Helper()
	buf := &bytes.Buffer{}
	w := multipart.NewWriter(buf)
	for _, f := range files {
		h := map[string][]string{
			"Content-Disposition": {`form-data; name="files"; filename="` + f.Filename + `"`},
			"Content-Type":        {f.ContentType},
		}
		part, err := w.CreatePart(h)
		if err != nil {
			t.Fatalf("CreatePart: %v", err)
		}
		_, _ = part.Write(f.Content)
	}
	_ = w.Close()
	return buf.Bytes(), w.FormDataContentType()
}

func TestUploadMultiple_AllSuccess201(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadMultipleObjectsFn = func(_ context.Context, bucket string, files []struct {
		Key         string
		Body        io.Reader
		ContentType string
	}) []services.UploadResult {
		if bucket != "b1" || len(files) != 2 {
			t.Errorf("got bucket=%q files=%d", bucket, len(files))
		}
		out := make([]services.UploadResult, len(files))
		for i, f := range files {
			out[i] = services.UploadResult{Key: f.Key, Success: true, ContentType: f.ContentType, Size: 1}
		}
		return out
	}
	body, ct := buildMultipartMulti(t, []struct {
		Filename, ContentType string
		Content               []byte
	}{
		{"a.txt", "text/plain", []byte("a")},
		{"b.txt", "text/plain", []byte("b")},
	})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		raw, _ := io.ReadAll(resp.Body)
		t.Fatalf("status = %d, want 201\nbody: %s", resp.StatusCode, raw)
	}
	var out struct {
		Data models.ObjectUploadMultipleResponse `json:"data"`
	}
	decodeJSON(t, resp.Body, &out)
	if out.Data.SuccessCount != 2 || out.Data.FailureCount != 0 {
		t.Errorf("counts = (%d, %d)", out.Data.SuccessCount, out.Data.FailureCount)
	}
}

func TestUploadMultiple_PartialReturns207(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
		Key         string
		Body        io.Reader
		ContentType string
	}) []services.UploadResult {
		return []services.UploadResult{
			{Key: files[0].Key, Success: true, Size: 1, ContentType: files[0].ContentType},
			{Key: files[1].Key, Success: false, Error: errors.New("upload failed"), ContentType: files[1].ContentType},
		}
	}
	body, ct := buildMultipartMulti(t, []struct {
		Filename, ContentType string
		Content               []byte
	}{
		{"a.txt", "text/plain", []byte("a")},
		{"b.txt", "text/plain", []byte("b")},
	})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusMultiStatus {
		t.Fatalf("status = %d, want 207", resp.StatusCode)
	}
}

func TestUploadMultiple_AllFailReturns500(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
		Key         string
		Body        io.Reader
		ContentType string
	}) []services.UploadResult {
		out := make([]services.UploadResult, len(files))
		for i, f := range files {
			out[i] = services.UploadResult{Key: f.Key, Success: false, Error: errors.New("boom")}
		}
		return out
	}
	body, ct := buildMultipartMulti(t, []struct {
		Filename, ContentType string
		Content               []byte
	}{{"a.txt", "text/plain", []byte("a")}})
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", resp.StatusCode)
	}
}

func TestUploadMultiple_NoFiles400(t *testing.T) {
	app, _ := newObjectsTestApp(t)
	body, ct := buildMultipartMulti(t, nil)
	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(body))
	req.Header.Set("Content-Type", ct)
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, want 400", resp.StatusCode)
	}
}

func TestUploadMultiple_DefaultsContentType(t *testing.T) {
	app, s3 := newObjectsTestApp(t)
	s3.UploadMultipleObjectsFn = func(_ context.Context, _ string, files []struct {
		Key         string
		Body        io.Reader
		ContentType string
	}) []services.UploadResult {
		if files[0].ContentType != "application/octet-stream" {
			t.Errorf("ContentType = %q, want default application/octet-stream", files[0].ContentType)
		}
		return []services.UploadResult{{Key: files[0].Key, Success: true}}
	}
	// Write a part with an empty Content-Type header explicitly.
	buf := &bytes.Buffer{}
	w := multipart.NewWriter(buf)
	part, err := w.CreatePart(map[string][]string{
		"Content-Disposition": {`form-data; name="files"; filename="a.txt"`},
		"Content-Type":        {""},
	})
	if err != nil {
		t.Fatalf("CreatePart: %v", err)
	}
	_, _ = part.Write([]byte("x"))
	_ = w.Close()

	req := httptest.NewRequest(http.MethodPost, "/buckets/b1/objects/upload-multiple", bytes.NewReader(buf.Bytes()))
	req.Header.Set("Content-Type", w.FormDataContentType())
	resp, err := app.Test(req)
	if err != nil {
		t.Fatalf("app.Test: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("status = %d, want 201", resp.StatusCode)
	}
}
