package inventory

// The cost/availability split on GET /inventory/stock.
//
// One response shape carried both, behind one gate, so a POS operator and a B2B
// buyer both received purchase_price / hpp / value — the margin. No permission
// key could express the difference because the payload had a single form.
// inventory.cost.view splits it, and these tests pin the split from both sides:
// a holder sees cost, a non-holder does not, and a genuine zero cost stays
// distinguishable from a denied field.

import (
	"context"
	"encoding/json"
	"testing"
	"time"

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

// costCtx builds a request context carrying a user with exactly the given
// permissions, through the same SetRoleMatrix the server loads at boot.
//
// The matrix is used rather than a per-user override because that is what
// production authorization actually resolves against: role_permissions rows
// loaded once at boot. Using the override map here would test a path the
// server never takes for a role-level grant.
func costCtx(t *testing.T, role auth.Role, perms ...string) context.Context {
	t.Helper()
	auth.SetRoleMatrix(map[auth.Role][]string{role: perms})
	t.Cleanup(func() { auth.SetRoleMatrix(nil) })
	return auth.ToContext(context.Background(), &auth.User{ID: 7, Role: role}, 0)
}

// costRows seeds two stock lines with cost.
func costRows() []StockLine {
	return []StockLine{
		{
			ProductID: 1, ProductCode: "PRD-1", ProductName: "Aqua",
			Quantity:      decimal.NewFromInt(10),
			PurchasePrice: decPtr(decimal.NewFromInt(43000)),
			HPP:           decPtr(decimal.NewFromInt(43000)),
			Value:         decPtr(decimal.NewFromInt(430000)),
		},
		{
			ProductID: 2, ProductCode: "PRD-2", ProductName: "Teh Botol",
			Quantity:      decimal.NewFromInt(2),
			PurchasePrice: decPtr(decimal.NewFromInt(53000)),
			HPP:           decPtr(decimal.NewFromInt(53000)),
			Value:         decPtr(decimal.NewFromInt(106000)),
		},
	}
}

func costService(rows []StockLine) *Service {
	return NewService(&fakeStore{lines: rows}, &fakeSeq{}, time.UTC)
}

// A holder of inventory.cost.view gets the cost fields, on both the list and
// the summary. Run for all four roles 000103 grants it to — the valuation roles
// — because a per-role loop is what makes "the grant is four names" true at the
// service boundary and not only in the migration. finance is included: it
// reaches the same pages with the platform bypass, and its stock screens must
// not depend on a key it was never granted.
func TestStockCost_GrantedCallerSeesCost(t *testing.T) {
	for _, role := range []auth.Role{auth.RoleStoreManager, auth.RoleSPV, auth.RoleBM, auth.RoleGM, auth.RoleFinance} {
		t.Run(string(role), func(t *testing.T) {
			s := costService(costRows())
			ctx := costCtx(t, role, "inventory.stock.view", "inventory.cost.view")

			list, _, err := s.ListStock(ctx, StockFilter{})
			if err != nil {
				t.Fatal(err)
			}
			if len(list) != 2 {
				t.Fatalf("rows = %d, want 2", len(list))
			}
			for _, l := range list {
				if l.PurchasePrice == nil || l.HPP == nil || l.Value == nil {
					t.Fatalf("cost omitted for %s holding inventory.cost.view: %+v", role, l)
				}
			}
			if !list[0].Value.Equal(decimal.NewFromInt(430000)) {
				t.Errorf("value = %v, want 430000", list[0].Value)
			}

			sum, err := s.StockSummary(ctx, StockFilter{})
			if err != nil {
				t.Fatal(err)
			}
			if sum.TotalValue == nil {
				t.Fatalf("summary total_value omitted for %s holding inventory.cost.view", role)
			}
			if !sum.TotalValue.Equal(decimal.NewFromInt(536000)) {
				t.Errorf("summary total_value = %v, want 536000", *sum.TotalValue)
			}
		})
	}
}

// A caller WITHOUT the key gets availability and no cost. This is the leak
// itself: a POS operator and a B2B buyer hold inventory.stock.view (or
// sales.pos.create) and need to know there is stock, not what it cost.
//
// The four roles 000103 reviewed and rejected are all here. The grant list is
// read for what it contains, so a role that was considered and excluded is
// visible only in this table — which is the point of naming them.
func TestStockCost_DeniedCallerSeesNoCost(t *testing.T) {
	for _, tc := range []struct {
		name  string
		role  auth.Role
		perms []string
	}{
		{"pos-operator", auth.RoleCashier, []string{"inventory.stock.view", "sales.pos.create"}},
		{"b2b-buyer", auth.RoleBuyer, []string{"inventory.stock.view"}},
		// store_manager HOLDS the key per 000103, so a caller that merely has
		// inventory.stock.view and not the cost key is not a store manager in
		// production. It is here to prove the key is a separate grant and not
		// implied by availability — the field rep below is the real case.
		{"stock-view-without-cost", auth.RoleStoreManager, []string{"inventory.stock.view"}},
		// sales: WITHHELD BY DECISION. A field rep taking orders has no
		// valuation role. If the business decides the desk should see margin,
		// this is the case that changes — deliberately, and with a second
		// migration rather than a silent edit.
		{"field-rep", auth.RoleSales, []string{"inventory.stock.view", "sales.sales-orders.view"}},
		// warehouse: physical counts, not valuation.
		{"warehouse", auth.RoleWarehouse, []string{"inventory.stock.view"}},
		// A role that holds OTHER accounting keys but not the cost key: the
		// point of a separate key is that accounting.journals.view is not a
		// back door to the margin.
		{"has-journals-not-cost", auth.RoleSales, []string{"inventory.stock.view", "accounting.journals.view"}},
	} {
		t.Run(tc.name, func(t *testing.T) {
			s := costService(costRows())
			ctx := costCtx(t, tc.role, tc.perms...)

			list, _, err := s.ListStock(ctx, StockFilter{})
			if err != nil {
				t.Fatal(err)
			}
			if len(list) != 2 {
				t.Fatalf("rows = %d, want 2 (availability must still be served)", len(list))
			}
			for _, l := range list {
				if l.PurchasePrice != nil || l.HPP != nil || l.Value != nil {
					t.Fatalf("cost leaked to a caller without inventory.cost.view: %+v", l)
				}
			}
			// Availability is what they came for and must survive the split.
			if !list[0].Quantity.Equal(decimal.NewFromInt(10)) {
				t.Errorf("quantity = %v, want 10 (availability must not be redacted)", list[0].Quantity)
			}
			if list[0].ProductName != "Aqua" {
				t.Errorf("product_name = %q, want Aqua", list[0].ProductName)
			}

			sum, err := s.StockSummary(ctx, StockFilter{})
			if err != nil {
				t.Fatal(err)
			}
			// The aggregate is the same leak in one number, so it is redacted
			// too. A caller who summed the rows they cannot see must not be
			// handed the total.
			if sum.TotalValue != nil {
				t.Errorf("summary total_value = %v for a denied caller, want absent", *sum.TotalValue)
			}
			if sum.TotalProducts != 2 {
				t.Errorf("total_products = %d, want 2 (availability must survive)", sum.TotalProducts)
			}
		})
	}
}

// ZERO COST IS NOT THE SAME AS NO COST. A free sample or a promotional good has
// a real purchase_price of 0; a denied caller has no field at all. If the
// redaction zeroed the fields, both would read 0 and a caller computing margin
// would get a plausible wrong answer instead of an obvious absence — which is
// the failure this split exists to make impossible.
//
// The JSON boundary is where this is decided, because that is where the two
// become indistinguishable to a client, so this asserts the wire form.
func TestStockCost_ZeroCostIsDistinguishableFromDenied(t *testing.T) {
	rows := []StockLine{{
		ProductID: 1, ProductCode: "PRD-FREE", ProductName: "Sampel Gratis",
		Quantity: decimal.NewFromInt(5),
		// A genuine zero. NOT an unset fixture: this is the case that would be
		// indistinguishable from a redaction if the fields were plain decimals.
		PurchasePrice: decPtr(decimal.Zero),
		HPP:           decPtr(decimal.Zero),
		Value:         decPtr(decimal.Zero),
	}}
	s := costService(rows)

	// A holder sees 0 — present, and zero.
	holder, _, err := s.ListStock(costCtx(t, auth.RoleFinance, "inventory.cost.view"), StockFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if len(holder) != 1 || holder[0].PurchasePrice == nil {
		t.Fatal("a zero cost was omitted for a caller holding the key")
	}
	if !holder[0].PurchasePrice.IsZero() {
		t.Errorf("purchase_price = %v, want 0", holder[0].PurchasePrice)
	}
	holderJSON := marshalStock(t, holder)

	// A denied caller sees no field at all.
	denied, _, err := s.ListStock(costCtx(t, auth.RoleCashier, "inventory.stock.view"), StockFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if len(denied) != 1 || denied[0].PurchasePrice != nil {
		t.Fatal("a zero cost was served to a denied caller")
	}
	deniedJSON := marshalStock(t, denied)

	// The two wire forms differ, which is the whole claim.
	if string(holderJSON) == string(deniedJSON) {
		t.Errorf("a zero cost and a denied field serialise identically:\n holder: %s\n denied: %s",
			holderJSON, deniedJSON)
	}
	var probe []map[string]any
	if err := json.Unmarshal(deniedJSON, &probe); err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"purchase_price", "hpp", "value"} {
		if _, present := probe[0][k]; present {
			t.Errorf("denied payload carries %q: %s", k, deniedJSON)
		}
	}
	if err := json.Unmarshal(holderJSON, &probe); err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"purchase_price", "hpp", "value"} {
		if _, present := probe[0][k]; !present {
			t.Errorf("holder payload is missing %q even though the cost is 0: %s", k, holderJSON)
		}
	}
}

// A missing user must not be treated as privileged. An internal call with no
// authenticated identity resolves to the same denial as an identified caller
// without the key, so no code path can widen visibility by forgetting to
// authenticate.
func TestStockCost_NoUserOnContextIsDenied(t *testing.T) {
	s := costService(costRows())
	list, _, err := s.ListStock(context.Background(), StockFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if list[0].PurchasePrice != nil || list[0].Value != nil {
		t.Errorf("cost served to a request with no user on ctx: %+v", list[0])
	}
}

// admin and owner bypass Can() outright, so they keep the cost view without a
// grant. Asserted because it is the one path where "no role_permissions row"
// still means yes, and a future change to the bypass would break it silently.
func TestStockCost_AdminBypassesWithoutAGrant(t *testing.T) {
	s := costService(costRows())
	for _, role := range []auth.Role{auth.RoleAdmin, auth.RoleOwner} {
		auth.SetRoleMatrix(map[auth.Role][]string{role: {}})
		ctx := auth.ToContext(context.Background(), &auth.User{ID: 1, Role: role}, 0)
		list, _, err := s.ListStock(ctx, StockFilter{})
		if err != nil {
			t.Fatal(err)
		}
		if list[0].PurchasePrice == nil {
			t.Errorf("%s lost the cost view; Can() is expected to bypass everything", role)
		}
	}
	auth.SetRoleMatrix(nil)
}

func marshalStock(t *testing.T, rows []StockLine) []byte {
	t.Helper()
	b, err := json.Marshal(rows)
	if err != nil {
		t.Fatal(err)
	}
	return b
}
