package catalog

// Product HPP follows the stock line's cost split (internal/inventory
// cost_view_test.go): the catalog endpoint used to hand purchase_price — the
// margin — to every caller able to list products (POS operators, B2B buyers),
// and UpdateProduct accepted a purchase_price from callers who could not see
// it, so a cost-blind partial save stored zero. Migration 000103 granted
// inventory.cost.view to store_manager/spv/bm/gm/finance and dropped
// sales.sales-orders.view as a third route into cost data; this file pins the
// product side of that contract. Reuses the package's fakes and seedProduct.

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

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

// costCtx mirrors internal/inventory.costCtx: the user resolves through the
// same role matrix production loads from role_permissions.
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)
}

func productJSON(t *testing.T, v *ProductView) map[string]any {
	t.Helper()
	b, err := json.Marshal(v)
	if err != nil {
		t.Fatal(err)
	}
	var m map[string]any
	if err := json.Unmarshal(b, &m); err != nil {
		t.Fatal(err)
	}
	return m
}

// Granted callers receive purchase_price on GET; everyone else gets no key at
// all — including store_manager without the key, POS, and B2B buyer.
func TestProductCost_GetProductGatedOnCostView(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	id := seedProduct(t, svc).ID // stored cost: 1000

	for _, role := range []auth.Role{auth.RoleStoreManager, auth.RoleSPV, auth.RoleBM, auth.RoleGM, auth.RoleFinance} {
		v, err := svc.GetProduct(costCtx(t, role, "inventory.cost.view"), id)
		if err != nil {
			t.Fatal(err)
		}
		if v.PurchasePrice == nil {
			t.Fatalf("%s: purchase_price absent for a caller holding inventory.cost.view", role)
		}
		if !v.PurchasePrice.Equal(decimal.NewFromInt(1000)) {
			t.Errorf("%s: purchase_price = %s, want 1000", role, v.PurchasePrice)
		}
		if _, present := productJSON(t, v)["purchase_price"]; !present {
			t.Errorf("%s: key missing from JSON: %v", role, v)
		}
	}

	denied := []struct {
		role  auth.Role
		perms []string
	}{
		{auth.RoleCashier, []string{"inventory.stock.view", "sales.pos.create"}},
		{auth.RoleBuyer, []string{"inventory.stock.view"}},
		{auth.RoleWarehouse, []string{"inventory.stock.view"}},
		{auth.RoleSales, []string{"sales.sales-orders.view", "accounting.journals.view"}},
		// Read access + edit rights WITHOUT the cost key: stock screens
		// (availability) stay usable, cost never appears.
		{auth.RoleStoreManager, []string{"inventory.stock.view", "products.edit"}},
	}
	for _, tc := range denied {
		v, err := svc.GetProduct(costCtx(t, tc.role, tc.perms...), id)
		if err != nil {
			t.Fatal(err)
		}
		if v.PurchasePrice != nil {
			t.Errorf("%s: purchase_price = %s leaked to a caller without inventory.cost.view", tc.role, v.PurchasePrice)
		}
		if _, present := productJSON(t, v)["purchase_price"]; present {
			t.Errorf("%s: purchase_price key present in JSON without inventory.cost.view", tc.role)
		}
	}
}

// A genuine zero cost stays visible to a holder and absent for a denied
// caller: the wire forms must differ, else a margin calculation reads a
// plausible wrong 0 instead of an obvious absence.
func TestProductCost_ZeroCostIsDistinguishableFromDenied(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	free, err := svc.CreateProduct(context.Background(), ProductInput{
		Name: "Sampel Gratis", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.Zero, SellingPrice: decimal.NewFromInt(1500),
	})
	if err != nil {
		t.Fatal(err)
	}

	holder, err := svc.GetProduct(costCtx(t, auth.RoleFinance, "inventory.cost.view"), free.ID)
	if err != nil {
		t.Fatal(err)
	}
	if holder.PurchasePrice == nil {
		t.Fatal("zero cost omitted for a caller holding the key")
	}
	if !holder.PurchasePrice.IsZero() {
		t.Errorf("purchase_price = %s, want 0", holder.PurchasePrice)
	}
	denied, err := svc.GetProduct(costCtx(t, auth.RoleCashier, "sales.pos.create"), free.ID)
	if err != nil {
		t.Fatal(err)
	}
	if denied.PurchasePrice != nil {
		t.Fatal("zero cost served to a denied caller")
	}
	// The whole claim: the two wire forms differ.
	holderMap, deniedMap := productJSON(t, holder), productJSON(t, denied)
	if _, present := holderMap["purchase_price"]; !present {
		t.Errorf("holder payload lacks purchase_price: %v", holderMap)
	}
	if _, present := deniedMap["purchase_price"]; present {
		t.Errorf("denied payload carries purchase_price: %v", deniedMap)
	}
}

// Write mirrors read: a caller who cannot see HPP cannot change it, and an
// omitted purchase_price from such a caller keeps the stored cost instead of
// writing zero (the wipe pattern the partial-update contract guards against).
func TestProductCost_UpdateWithoutKeyPreservesStoredCost(t *testing.T) {
	st := newFakeStore()
	svc := NewService(st, &fakeSeq{}, time.UTC)
	id := seedProduct(t, svc).ID

	// Cost-blind caller omits purchase_price (ERP form sends it only with the
	// key): partial save must not store 0.
	if _, err := svc.UpdateProduct(costCtx(t, auth.RoleCashier, "inventory.stock.view"), id, ProductInput{
		Name: "Air Mineral", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.Zero, SellingPrice: decimal.NewFromInt(1500),
	}); err != nil {
		t.Fatal(err)
	}
	row, err := st.ProductByID(context.Background(), id)
	if err != nil {
		t.Fatal(err)
	}
	if !row.PurchasePrice.Equal(decimal.NewFromInt(1000)) {
		t.Fatalf("purchase_price = %s after a denied update, want stored 1000", row.PurchasePrice)
	}

	// A holder can still edit it.
	if _, err := svc.UpdateProduct(costCtx(t, auth.RoleFinance, "inventory.cost.view"), id, ProductInput{
		Name: "Air Mineral", CategoryID: 1, UnitID: 1,
		PurchasePrice: decimal.NewFromInt(1200), SellingPrice: decimal.NewFromInt(1500),
	}); err != nil {
		t.Fatal(err)
	}
	row, err = st.ProductByID(context.Background(), id)
	if err != nil {
		t.Fatal(err)
	}
	if !row.PurchasePrice.Equal(decimal.NewFromInt(1200)) {
		t.Errorf("purchase_price = %s after a granted update, want 1200", row.PurchasePrice)
	}
}

// Admin and owner bypass every permission (auth.User.Can §7.3-7.4), so their
// screens keep working against an empty matrix.
func TestProductCost_AdminOwnerBypass(t *testing.T) {
	svc := NewService(newFakeStore(), &fakeSeq{}, time.UTC)
	id := seedProduct(t, svc).ID

	for _, role := range []auth.Role{auth.RoleAdmin, auth.RoleOwner} {
		role := role
		auth.SetRoleMatrix(map[auth.Role][]string{role: {}})
		t.Cleanup(func() { auth.SetRoleMatrix(nil) })
		v, err := svc.GetProduct(auth.ToContext(context.Background(), &auth.User{ID: 1, Role: role}, 0), id)
		if err != nil {
			t.Fatal(err)
		}
		if v.PurchasePrice == nil {
			t.Errorf("%s: purchase_price absent despite the platform bypass", role)
		}
	}
}
