package sales

// List()'s customer scoping for a B2B buyer.
//
// 000069 granted buyer sales.sales-orders.view so "reports render without 403",
// and List() applied store scope and warehouse-role scope but never customer
// scope. A buyer therefore read the whole sales desk: verified 202 rows across
// several customer_id values — other customers' names, addresses and order
// history. 000103 revokes the key, but the STOREFRONT ADMIN legitimately holds
// it and needs the unfiltered list, so no route gate can fix this. It is the
// service's job, and these tests pin it.

import (
	"context"
	"testing"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/shopspring/decimal"
)

// buyerScopedStore wraps fakeStore's ListOrders with the customer filter the
// real SQL applies. fakeStore.ListOrders does not implement CustomerID at all,
// so a test that seeded two customers and called List would pass whether or not
// the service set the filter — the fake would return both rows either way.
//
// Wrapping the store and asserting on the filter the service ACTUALLY applied is
// what makes the test capable of failing, and it fails loudly if the service
// stops setting it: seen is recorded, and the row filter is applied from the
// same value the service passed to the store, so the assertion cannot be
// satisfied by a test-side coincidence.
type buyerScopedStore struct {
	*fakeStore
	// seen is the CustomerID the service put on the filter. -1 means it left it
	// unset, which is the leak.
	seen int64
}

func (s *buyerScopedStore) ListOrders(ctx context.Context, flt SOFilter) ([]db.ListSalesOrdersRow, int, error) {
	s.seen = flt.CustomerID
	rows, total, err := s.fakeStore.ListOrders(ctx, flt)
	if flt.CustomerID <= 0 {
		return rows, total, err
	}
	out := make([]db.ListSalesOrdersRow, 0, len(rows))
	for _, r := range rows {
		if r.CustomerID == flt.CustomerID {
			out = append(out, r)
		}
	}
	return out, len(out), nil
}

// seedTwoCustomerOrders books one order for each of two customers and links
// user 42 to the first, which is the shape of the leak: two accounts, one
// buyer session that could read both books.
func seedTwoCustomerOrders(t *testing.T, s *Service) (mine, theirs int64) {
	t.Helper()
	fs := s.store.(*fakeStore)
	fs.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(50)}

	// customer 1, linked to user 42.
	fs.customerByUser[42] = 1
	o1, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	}, 42, "Mine", "mine@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}

	// customer 2: a different account entirely, linked to user 99.
	fs.customerByUser[99] = 2
	o2, err := s.PlaceCheckout(context.Background(), CheckoutInput{
		Items: []CheckoutItemInput{{ProductID: 1, Quantity: decimal.NewFromInt(1)}},
	}, 99, "Theirs", "theirs@keluargamart.local")
	if err != nil {
		t.Fatal(err)
	}
	return o1.ID, o2.ID
}

// THE FIX. A RoleBuyer caller gets their own customer_id filter applied, and
// therefore only their own orders. Before the fix the service left CustomerID
// unset, the store saw 0, and the buyer read both books.
func TestList_BuyerSeesOnlyTheirOwnOrders(t *testing.T) {
	s := svc()
	mine, theirs := seedTwoCustomerOrders(t, s)
	store := &buyerScopedStore{fakeStore: s.store.(*fakeStore)}
	s.store = store

	auth.SetRoleMatrix(map[auth.Role][]string{auth.RoleBuyer: {"sales.sales-orders.view"}})
	t.Cleanup(func() { auth.SetRoleMatrix(nil) })
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 42, Role: auth.RoleBuyer}, 0)

	list, total, err := s.List(ctx, SOFilter{})
	if err != nil {
		t.Fatal(err)
	}

	// The filter the service applied, not just the outcome: this is the part
	// that fails if the scoping branch is deleted.
	if store.seen != 1 {
		t.Errorf("the store received CustomerID = %d, want 1 (the buyer's own customer); "+
			"an unset filter is the PII leak", store.seen)
	}
	if total != 1 || len(list) != 1 {
		t.Fatalf("orders = %d (total %d), want 1", len(list), total)
	}
	if list[0].ID != mine {
		t.Errorf("saw order %d, want their own %d", list[0].ID, mine)
	}
	for _, o := range list {
		if o.CustomerID == theirs {
			t.Error("the buyer's list contained another customer's order")
		}
	}
}

// THE STOREFRONT ADMIN KEEPS EVERYTHING. It legitimately holds
// sales.sales-orders.view and genuinely needs the unfiltered list; scoping by
// role rather than by a guessed "is this a customer" is what preserves that, and
// this is the test that would fail if the scoping were keyed off a permission
// instead.
func TestList_StorefrontAdminStillSeesEverything(t *testing.T) {
	s := svc()
	mine, theirs := seedTwoCustomerOrders(t, s)
	store := &buyerScopedStore{fakeStore: s.store.(*fakeStore)}
	s.store = store

	// An admin/owner-shaped role with no buyer role: the ERP/sales side of the
	// desk, which must stay whole.
	auth.SetRoleMatrix(map[auth.Role][]string{auth.RoleSales: {"sales.sales-orders.view"}})
	t.Cleanup(func() { auth.SetRoleMatrix(nil) })
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 5, Role: auth.RoleSales}, 0)

	list, _, err := s.List(ctx, SOFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if store.seen != 0 {
		t.Errorf("a non-buyer caller got CustomerID = %d; the filter must only apply to RoleBuyer", store.seen)
	}
	if len(list) != 2 {
		t.Fatalf("orders = %d, want 2 (the sales desk needs the full list)", len(list))
	}
	seen := map[int64]bool{}
	for _, o := range list {
		seen[o.ID] = true
	}
	if !seen[mine] || !seen[theirs] {
		t.Errorf("the sales desk lost an order: saw %v, want both %d and %d", seen, mine, theirs)
	}
}

// A buyer with no customer link has no orders. The honest answer is an empty
// set, not the unfiltered one — which is what MyOrders does and what this
// mirrors. A buyer whose customer row is missing must not fall through to "no
// filter", because that is the same leak by a different route.
func TestList_BuyerWithoutCustomerSeesNothing(t *testing.T) {
	s := svc()
	seedTwoCustomerOrders(t, s)

	auth.SetRoleMatrix(map[auth.Role][]string{auth.RoleBuyer: {"sales.sales-orders.view"}})
	t.Cleanup(func() { auth.SetRoleMatrix(nil) })
	// User 4242 has no customerByUser entry, so CustomerByUserID is ErrNotFound.
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 4242, Role: auth.RoleBuyer}, 0)

	list, total, err := s.List(ctx, SOFilter{})
	if err != nil {
		t.Fatalf("an unlinked buyer must be an empty set, not an error: %v", err)
	}
	if len(list) != 0 || total != 0 {
		t.Errorf("orders = %d (total %d), want 0", len(list), total)
	}
}

// The scoping coexists with the other two filters rather than replacing them.
// A buyer is also tenant-scoped and the warehouse rule is orthogonal; this
// confirms the new branch did not disturb either, and that a buyer asking for
// ANOTHER customer id by query parameter is overridden by the service's own.
//
// The override is the security-relevant half: ?customer_id=2 in the query must
// not survive, or the scoping would be a suggestion.
func TestList_BuyerCannotOverrideCustomerByQueryParam(t *testing.T) {
	s := svc()
	mine, theirs := seedTwoCustomerOrders(t, s)
	store := &buyerScopedStore{fakeStore: s.store.(*fakeStore)}
	s.store = store

	auth.SetRoleMatrix(map[auth.Role][]string{auth.RoleBuyer: {"sales.sales-orders.view"}})
	t.Cleanup(func() { auth.SetRoleMatrix(nil) })
	ctx := auth.ToContext(context.Background(), &auth.User{ID: 42, Role: auth.RoleBuyer}, 0)

	// The handler parses ?customer_id into the filter; the service overwrites it.
	list, _, err := s.List(ctx, SOFilter{CustomerID: 2})
	if err != nil {
		t.Fatal(err)
	}
	if store.seen != 1 {
		t.Errorf("the service left the caller's CustomerID = %d in place; a query "+
			"parameter must not survive buyer scoping", store.seen)
	}
	if len(list) != 1 || list[0].ID != mine {
		t.Errorf("orders = %v, want only their own order %d (not %d)", ids(list), mine, theirs)
	}
}

func ids(list []SalesOrder) []int64 {
	out := make([]int64, 0, len(list))
	for _, o := range list {
		out = append(out, o.ID)
	}
	return out
}
