package uploads

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

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/pkg/storage"
	"github.com/go-chi/chi/v5"
)

// A 1x1 PNG — valid magic bytes so http.DetectContentType reports image/png.
var pngBytes = []byte{
	0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
	0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
	0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4, 0x89, 0x00, 0x00, 0x00,
	0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
	0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49,
	0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
}

// The keys under test. productKey is an ordinary public image; proofKey is
// written by the current SaveProofUpload path; legacyProofKey is a
// single-segment key written before proofs were namespaced — it carries NO
// marker, so only the payments-table lookup can recognise it.
const (
	productKey      = "0f1e2d3c4b5a69788796a5b4c3d2e1f0.png"
	proofKey        = ProofPrefix + "aabbccddeeff00112233445566778899.png"
	legacyProofKey  = "99887766554433221100ffeeddccbbaa.png"
	legacyOwnerless = "ffeeddccbbaa99887766554433221100.png"
)

const proofVerifyPermission = "fakturisasi.payments.verify"

// fakeStore is an in-memory storage.Store.
type fakeStore struct {
	objects map[string][]byte
	puts    []string
}

func newFakeStore() *fakeStore { return &fakeStore{objects: map[string][]byte{}} }

func (f *fakeStore) Put(_ context.Context, key string, r io.Reader) error {
	b, err := io.ReadAll(r)
	if err != nil {
		return err
	}
	f.objects[key] = b
	f.puts = append(f.puts, key)
	return nil
}

func (f *fakeStore) Get(_ context.Context, key string) (io.ReadCloser, error) {
	b, ok := f.objects[key]
	if !ok {
		return nil, storage.ErrNotFound
	}
	return io.NopCloser(bytes.NewReader(b)), nil
}

func (f *fakeStore) Delete(_ context.Context, key string) error {
	delete(f.objects, key)
	return nil
}

func (f *fakeStore) Exists(_ context.Context, key string) (bool, error) {
	_, ok := f.objects[key]
	return ok, nil
}

// fakeProofAuthorizer mirrors the real finance-backed policy: the payments
// table decides whether a key is a private proof, and the caller must be the
// owner or hold proofVerifyPermission. internal/uploads cannot import finance,
// so the rule is re-implemented here for the handler tests — the predicate
// itself is covered by the finance package's own tests.
type fakeProofAuthorizer struct {
	// proofs maps a stored key to its owning user id (0 = proof whose
	// customer has no linked user).
	proofs   map[string]int64
	failWith error
}

func (f *fakeProofAuthorizer) authorize(_ context.Context, key string, u *auth.User) (ProofAccess, error) {
	if f.failWith != nil {
		return ProofAccess{}, f.failWith
	}
	owner, isProof := f.proofs[key]
	if !isProof && !IsProofKey(key) {
		return ProofAccess{}, nil // public image
	}
	allowed := u != nil && ((owner != 0 && owner == u.ID) || u.Can(proofVerifyPermission))
	return ProofAccess{Private: true, Allowed: allowed}, nil
}

// withUser mirrors what auth.OptionalMiddleware does on the request context.
func withUser(u *auth.User) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if u == nil {
				next.ServeHTTP(w, r)
				return
			}
			next.ServeHTTP(w, r.WithContext(auth.ToContext(r.Context(), u, 1)))
		})
	}
}

func serveRouter(h *Handler, u *auth.User) *chi.Mux {
	r := chi.NewRouter()
	r.Group(func(g chi.Router) {
		g.Use(withUser(u))
		g.Get("/uploads/{key}", h.Serve)
	})
	return r
}

// serveRequest issues GET /uploads/<key> as caller u.
func serveRequest(t *testing.T, h *Handler, u *auth.User, key string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, "/uploads/"+key, nil)
	// A traversing key never survives chi's URL routing, so hand the handler
	// the raw param the way a proxied/loosely-normalised request would.
	routeCtx := chi.NewRouteContext()
	routeCtx.URLParams.Add("key", key)
	req = req.WithContext(withRouteContext(req.Context(), routeCtx))
	res := httptest.NewRecorder()
	serveRouter(h, u).ServeHTTP(res, req)
	return res
}

func withRouteContext(ctx context.Context, rc *chi.Context) context.Context {
	return context.WithValue(ctx, chi.RouteCtxKey, rc)
}

func testHandler(proofs map[string]int64) (*Handler, *fakeStore) {
	store := newFakeStore()
	store.objects[productKey] = pngBytes
	store.objects[proofKey] = pngBytes
	store.objects[legacyProofKey] = pngBytes
	store.objects[legacyOwnerless] = pngBytes
	h := NewHandler(store, "unused").WithProofAuthorizer(
		(&fakeProofAuthorizer{proofs: proofs}).authorize)
	return h, store
}

var (
	owner       = &auth.User{ID: 7, Role: auth.RoleBuyer}
	stranger    = &auth.User{ID: 8, Role: auth.RoleBuyer}
	verifier    = &auth.User{ID: 9, Role: auth.RoleFinance, Permissions: auth.Permissions{proofVerifyPermission: true}}
	allProofs   = map[string]int64{proofKey: owner.ID, legacyProofKey: owner.ID, legacyOwnerless: 0}
	ownerOfNone = map[string]int64{}
)

// TestServeProofAuthorization is the regression suite for the confirmed
// defect: payment proofs were served from the same public route as product
// images, with public immutable caching and no authorization.
func TestServeProofAuthorization(t *testing.T) {
	cases := []struct {
		name     string
		key      string
		proofs   map[string]int64
		user     *auth.User
		wantCode int
		wantBody bool // response body must be the stored bytes
	}{
		{
			name:     "public product image served without auth",
			key:      productKey,
			proofs:   allProofs,
			wantCode: http.StatusOK,
			wantBody: true,
		},
		{
			name:     "proof requested with no auth is rejected",
			key:      proofKey,
			proofs:   allProofs,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
		{
			name:     "proof requested by non-owner without permission is rejected",
			key:      proofKey,
			proofs:   allProofs,
			user:     stranger,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
		{
			name:     "proof requested by the owner is served",
			key:      proofKey,
			proofs:   allProofs,
			user:     owner,
			wantCode: http.StatusOK,
			wantBody: true,
		},
		{
			name:     "proof requested with payments.verify permission is served",
			key:      proofKey,
			proofs:   allProofs,
			user:     verifier,
			wantCode: http.StatusOK,
			wantBody: true,
		},
		{
			// The user's binding decision: legacy keys are proofs too, and
			// must not stay publicly cacheable.
			name:     "LEGACY single-segment proof key is rejected when unauthenticated",
			key:      legacyProofKey,
			proofs:   allProofs,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
		{
			name:     "LEGACY single-segment proof key is rejected for a non-owner",
			key:      legacyProofKey,
			proofs:   allProofs,
			user:     stranger,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
		{
			name:     "LEGACY single-segment proof key is served to its owner",
			key:      legacyProofKey,
			proofs:   allProofs,
			user:     owner,
			wantCode: http.StatusOK,
			wantBody: true,
		},
		{
			name:     "LEGACY proof with no linked customer user is staff-only",
			key:      legacyOwnerless,
			proofs:   allProofs,
			user:     owner,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
		{
			name:     "LEGACY proof with no linked customer user reaches staff",
			key:      legacyOwnerless,
			proofs:   allProofs,
			user:     verifier,
			wantCode: http.StatusOK,
			wantBody: true,
		},
		{
			name:     "path traversal key is rejected",
			key:      "../../db/schema.sql",
			proofs:   allProofs,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
		{
			name:     "unknown key is a 404",
			key:      "does-not-exist.png",
			proofs:   allProofs,
			user:     owner,
			wantCode: http.StatusNotFound,
			wantBody: false,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			h, store := testHandler(tc.proofs)
			res := serveRequest(t, h, tc.user, tc.key)
			if res.Code != tc.wantCode {
				t.Fatalf("GET /uploads/%s = status %d, want %d", tc.key, res.Code, tc.wantCode)
			}
			isFile := bytes.Equal(res.Body.Bytes(), store.objects[tc.key])
			if tc.wantBody && !isFile {
				t.Fatalf("GET /uploads/%s body = %q, want the stored object", tc.key, res.Body.String())
			}
			if !tc.wantBody && isFile {
				t.Fatalf("GET /uploads/%s leaked the stored object to an unauthorized caller", tc.key)
			}
		})
	}
}

// A shared cache must never retain a customer's financial document, so a proof
// response is private and unstoreable; only genuine public images keep the
// immutable public caching.
func TestServeCacheControlNeverPublicForProofs(t *testing.T) {
	cases := []struct {
		name string
		key  string
		user *auth.User
		want string
	}{
		{name: "public image keeps immutable public caching", key: productKey, want: "public, max-age=31536000, immutable"},
		{name: "proof owner gets private no-store", key: proofKey, user: owner, want: "private, no-store"},
		{name: "legacy proof owner gets private no-store", key: legacyProofKey, user: owner, want: "private, no-store"},
		{name: "staff verifier gets private no-store", key: proofKey, user: verifier, want: "private, no-store"},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			h, _ := testHandler(allProofs)
			res := serveRequest(t, h, tc.user, tc.key)
			if res.Code != http.StatusOK {
				t.Fatalf("status = %d, want 200", res.Code)
			}
			got := res.Header().Get("Cache-Control")
			if got != tc.want {
				t.Fatalf("Cache-Control = %q, want %q", got, tc.want)
			}
		})
	}
}

// Fail closed: without the payments-backed authorizer the handler cannot prove
// a key is public, so nothing is served — an instance running without the
// payments table must not publish cheque images.
func TestServeFailsClosedWithoutAuthorizer(t *testing.T) {
	store := newFakeStore()
	store.objects[productKey] = pngBytes
	store.objects[proofKey] = pngBytes
	for name, key := range map[string]string{
		"public image":     productKey,
		"proof":            proofKey,
		"legacy proof key": legacyProofKey,
	} {
		t.Run(name, func(t *testing.T) {
			res := serveRequest(t, NewHandler(store, "unused"), verifier, key)
			if res.Code != http.StatusNotFound {
				t.Fatalf("status = %d, want 404 (no authorizer wired)", res.Code)
			}
			if bytes.Equal(res.Body.Bytes(), store.objects[key]) {
				t.Fatal("handler served the object without an authorizer")
			}
		})
	}
}

// An ownership lookup failure must not fall through to serving bytes.
func TestServeFailsClosedOnAuthorizerError(t *testing.T) {
	store := newFakeStore()
	store.objects[productKey] = pngBytes
	h := NewHandler(store, "unused").WithProofAuthorizer(
		(&fakeProofAuthorizer{failWith: errors.New("db down")}).authorize)
	res := serveRequest(t, h, verifier, productKey)
	if res.Code != http.StatusInternalServerError {
		t.Fatalf("status = %d, want 500", res.Code)
	}
	if bytes.Equal(res.Body.Bytes(), store.objects[productKey]) {
		t.Fatal("handler served the object after an ownership lookup failure")
	}
}

func multipartRequest(t *testing.T, field, filename, contentType string, body []byte) *http.Request {
	t.Helper()
	buf := &bytes.Buffer{}
	mw := multipart.NewWriter(buf)
	if field != "" {
		hdr := make(map[string][]string)
		hdr["Content-Disposition"] = []string{
			`form-data; name="` + field + `"; filename="` + filename + `"`}
		hdr["Content-Type"] = []string{contentType}
		part, err := mw.CreatePart(hdr)
		if err != nil {
			t.Fatal(err)
		}
		if _, err := part.Write(body); err != nil {
			t.Fatal(err)
		}
	}
	if err := mw.Close(); err != nil {
		t.Fatal(err)
	}
	req := httptest.NewRequest(http.MethodPost, "/api/v1/uploads", buf)
	req.Header.Set("Content-Type", mw.FormDataContentType())
	return req
}

// New proofs land in the namespaced key space; public uploads do not. The
// marker is what makes the read path's common case a cheap decision.
func TestSaveUploadKeyNamespaces(t *testing.T) {
	t.Run("public upload has no proof prefix", func(t *testing.T) {
		store := newFakeStore()
		url, err := SaveUpload(context.Background(), store, multipartRequest(t, "file", "cheque.png", "image/png", pngBytes))
		if err != nil {
			t.Fatalf("SaveUpload: %v", err)
		}
		if IsProofKey(strings.TrimPrefix(url, "/uploads/")) {
			t.Fatalf("key %q must not be marked as a proof", url)
		}
	})
	t.Run("proof upload is marked", func(t *testing.T) {
		store := newFakeStore()
		url, err := SaveProofUpload(context.Background(), store, multipartRequest(t, "file", "cheque.png", "image/png", pngBytes))
		if err != nil {
			t.Fatalf("SaveProofUpload: %v", err)
		}
		if !strings.HasPrefix(url, "/uploads/"+ProofPrefix) {
			t.Fatalf("proof url = %q, want the %q namespace", url, "/uploads/"+ProofPrefix)
		}
		if !strings.HasSuffix(url, ".png") {
			t.Fatalf("proof url = %q, want the extension derived from the sniffed type", url)
		}
	})
}

// The upload limits are unchanged by this fix and must stay enforced on every
// write path, including the proof path.
func TestSaveUploadRejectsBadInput(t *testing.T) {
	oversize := bytes.Repeat([]byte{0x89, 0x50, 0x4e, 0x47}, int(maxUploadBytes/4)+1024)
	cases := []struct {
		name        string
		request     func(t *testing.T) *http.Request
		save        func(context.Context, storage.Store, *http.Request) (string, error)
		wantErr     error
		wantObjects int
	}{
		{
			name: "svg is not an allowed content type",
			request: func(t *testing.T) *http.Request {
				return multipartRequest(t, "file", "x.svg", "image/svg+xml",
					[]byte(`<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>`))
			},
			save:        SaveUpload,
			wantErr:     ErrUnsupportedType,
			wantObjects: 0,
		},
		{
			name: "html is not an allowed content type",
			request: func(t *testing.T) *http.Request {
				return multipartRequest(t, "file", "x.png", "image/png",
					[]byte(`<html><body><script>alert(1)</script></body></html>`))
			},
			save:        SaveUpload,
			wantErr:     ErrUnsupportedType,
			wantObjects: 0,
		},
		{
			name: "declared content type is ignored in favour of the sniffed bytes",
			request: func(t *testing.T) *http.Request {
				// Claims image/png, sniffs as text — must be rejected.
				return multipartRequest(t, "file", "x.png", "image/png", []byte("not an image at all"))
			},
			save:        SaveProofUpload,
			wantErr:     ErrUnsupportedType,
			wantObjects: 0,
		},
		{
			name:        "oversize body is rejected",
			request:     func(t *testing.T) *http.Request { return multipartRequest(t, "file", "big.png", "image/png", oversize) },
			save:        SaveUpload,
			wantErr:     ErrInvalidForm,
			wantObjects: 0,
		},
		{
			name:        "missing file field is rejected",
			request:     func(t *testing.T) *http.Request { return multipartRequest(t, "", "", "", nil) },
			save:        SaveUpload,
			wantErr:     ErrMissingFile,
			wantObjects: 0,
		},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			store := newFakeStore()
			_, err := tc.save(context.Background(), store, tc.request(t))
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("err = %v, want %v", err, tc.wantErr)
			}
			if len(store.objects) != tc.wantObjects {
				t.Fatalf("stored %d objects, want %d", len(store.objects), tc.wantObjects)
			}
		})
	}
}

// The client filename is never trusted: the key is server-generated and the
// extension comes from the sniffed type.
func TestSaveUploadIgnoresClientFilename(t *testing.T) {
	store := newFakeStore()
	url, err := SaveUpload(context.Background(), store, multipartRequest(t, "file", "../../evil.svg", "image/png", pngBytes))
	if err != nil {
		t.Fatalf("SaveUpload: %v", err)
	}
	if strings.Contains(url, "evil") || strings.Contains(url, "svg") {
		t.Fatalf("url = %q, want a server-generated key with a sniffed extension", url)
	}
}
