package inventory

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"sort"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgtype"
	"github.com/shopspring/decimal"

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

// fakeTx is a no-op transaction (in-memory store ignores q on writes). tag/err
// let a test drive the raw sqlc writes that go through db.New(q) — mirrors the
// purchasing fake so :execrows row counts are assertable without a database.
type fakeTx struct {
	store *fakeStore
	tag   pgconn.CommandTag
	err   error
}

func (f *fakeTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
	return &fakeRow{err: pgx.ErrNoRows}
}
func (f *fakeTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
	return nil, nil
}
func (f *fakeTx) Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) {
	return f.tag, f.err
}
func (f *fakeTx) Commit(context.Context) error   { return nil }
func (f *fakeTx) Rollback(context.Context) error { return nil }

type fakeRow struct {
	vals []any
	err  error
}

func (f *fakeRow) Scan(dest ...any) error {
	if f.err != nil {
		return f.err
	}
	for i, d := range dest {
		if p, ok := d.(*any); ok {
			*p = f.vals[i]
		}
	}
	return nil
}

// Stock mirrors the stocks row shape the fake uses for stock assertions.
type Stock struct {
	ID               int64
	ProductID        int64
	WarehouseID      int64
	Quantity         decimal.Decimal
	ReservedQuantity decimal.Decimal
}

type fakeStore struct {
	stocks         map[string]*Stock // key "prod:wh"
	movements      []*Movement
	opnames        map[int64]*Opname
	opnameItems    map[int64][]*OpnameItem
	transfers      map[int64]*Transfer
	lines          []StockLine
	opnameFilter   OpnameFilter
	transferFilter TransferFilter
	whsForStores   map[int64][]int64
	// frozenWh marks warehouses with an open (in_progress) opname.
	frozenWh map[int64]bool
	nextID   int64
	beginTx  bool
	// lockOrder records every LockStockRow call as "warehouse:product", in
	// call order, so tests can assert the canonical lock sequence.
	lockOrder []string
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		stocks:       map[string]*Stock{},
		movements:    []*Movement{},
		opnames:      map[int64]*Opname{},
		opnameItems:  map[int64][]*OpnameItem{},
		transfers:    map[int64]*Transfer{},
		whsForStores: map[int64][]int64{},
		nextID:       1,
		beginTx:      true,
	}
}

func (f *fakeStore) Begin(context.Context) (Tx, error) {
	return &fakeTx{store: f}, nil
}

// Maps store IDs to warehouse IDs when configured in tests.
func (f *fakeStore) WarehousesForStores(_ context.Context, storeIDs []int64) ([]int64, error) {
	if len(f.whsForStores) > 0 {
		var out []int64
		for _, sID := range storeIDs {
			out = append(out, f.whsForStores[sID]...)
		}
		return out, nil
	}
	return nil, nil
}

func key(p, w int64) string { return itoa(p) + ":" + itoa(w) }
func itoa(n int64) string {
	if n == 0 {
		return "0"
	}
	var b []byte
	for n > 0 {
		b = append([]byte{byte('0' + n%10)}, b...)
		n /= 10
	}
	return string(b)
}

func (f *fakeStore) StockRow(_ context.Context, _ db.DBTX, productID, warehouseID int64) (db.GetStockRowRow, error) {
	if s, ok := f.stocks[key(productID, warehouseID)]; ok {
		return stockRowFrom(s), nil
	}
	return db.GetStockRowRow{}, ErrNotFound
}

// WarehouseStockFrozen mirrors the SQL: an opname in 'in_progress' for the
// warehouse freezes movements. Tests set f.frozenWh to simulate it.
func (f *fakeStore) WarehouseStockFrozen(_ context.Context, _ db.DBTX, warehouseID int64) (bool, error) {
	if f.frozenWh == nil {
		return false, nil
	}
	return f.frozenWh[warehouseID], nil
}

func (f *fakeStore) LockStockRow(_ context.Context, _ db.DBTX, productID, warehouseID int64) (db.GetStockRowForUpdateRow, error) {
	f.lockOrder = append(f.lockOrder, fmt.Sprintf("%d:%d", warehouseID, productID))
	row, err := f.StockRow(context.Background(), nil, productID, warehouseID)
	return db.GetStockRowForUpdateRow(row), err
}

func (f *fakeStore) EnsureStockRow(_ context.Context, _ db.DBTX, productID, warehouseID int64) error {
	k := key(productID, warehouseID)
	if _, ok := f.stocks[k]; ok {
		return nil
	}
	f.stocks[k] = &Stock{ID: f.nextID, ProductID: productID, WarehouseID: warehouseID,
		Quantity: decimal.Zero, ReservedQuantity: decimal.Zero}
	f.nextID++
	return nil
}

func (f *fakeStore) UpdateStock(_ context.Context, _ db.DBTX, p db.UpdateStockRowParams) error {
	for _, s := range f.stocks {
		if s.ID == p.ID {
			s.Quantity = p.Quantity
			s.ReservedQuantity = p.ReservedQuantity
		}
	}
	return nil
}

func (f *fakeStore) CreateMovement(_ context.Context, _ db.DBTX, p db.CreateMovementParams) (db.CreateMovementRow, error) {
	m := &Movement{
		ID:             f.nextID,
		MovementNumber: p.MovementNumber,
		ProductID:      p.ProductID,
		WarehouseID:    p.WarehouseID,
		MovementType:   p.MovementType,
		Quantity:       p.Quantity,
		QuantityBefore: p.QuantityBefore,
		QuantityAfter:  p.QuantityAfter,
		ReferenceType:  pgtzTextToStrPtr(p.ReferenceType),
		ReferenceID:    int8ToPtr(p.ReferenceID),
		Notes:          pgtzTextToStrPtr(p.Notes),
		CreatedBy:      int8ToPtr(p.CreatedBy),
		CreatedAt:      time.Now(),
	}
	f.nextID++
	f.movements = append(f.movements, m)
	return db.CreateMovementRow{ID: m.ID, CreatedAt: tsToPg(m.CreatedAt)}, nil
}

func (f *fakeStore) ListStock(_ context.Context, fl StockFilter) ([]db.ListStockLinesRow, int, error) {
	out := make([]db.ListStockLinesRow, 0, len(f.lines))
	for _, l := range f.lines {
		out = append(out, stockLineRowFrom(l))
	}
	if fl.SortBy != "" {
		sort.SliceStable(out, func(i, j int) bool {
			less := false
			switch fl.SortBy {
			case "quantity":
				less = out[i].Quantity.LessThan(out[j].Quantity)
			case "min_stock":
				less = out[i].MinStock.LessThan(out[j].MinStock)
			case "purchase_price":
				less = out[i].PurchasePrice.LessThan(out[j].PurchasePrice)
			case "value":
				less = out[i].Value.LessThan(out[j].Value)
			case "product_code":
				less = out[i].Code < out[j].Code
			case "category_name":
				less = out[i].CategoryName < out[j].CategoryName
			case "is_low_stock":
				iLow := out[i].Quantity.LessThanOrEqual(out[i].MinStock)
				jLow := out[j].Quantity.LessThanOrEqual(out[j].MinStock)
				if iLow != jLow {
					less = !iLow && jLow
				}
			default:
				less = strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
			}
			if fl.SortDir == "desc" {
				return !less
			}
			return less
		})
	}
	return out, len(out), nil
}

func (f *fakeStore) ListStockOn(ctx context.Context, _ db.DBTX, fl StockFilter) ([]db.ListStockLinesRow, int, error) {
	return f.ListStock(ctx, fl)
}

// StockSummary returns the raw StockSummaryValues, mirroring the repository:
// the fake has no user on its context and must not apply the
// inventory.cost.view split, which is the service's job.
func (f *fakeStore) StockSummary(_ context.Context, _ StockFilter) (StockSummaryValues, error) {
	var totalQty, totalVal decimal.Decimal
	var lowStock int
	for _, l := range f.lines {
		totalQty = totalQty.Add(l.Quantity)
		// The fake's rows carry cost in the view type's now-pointer fields;
		// deref what the service would have set, or the aggregate silently
		// becomes 0 and every summary assertion passes for the wrong reason.
		if l.Value != nil {
			totalVal = totalVal.Add(*l.Value)
		}
		if l.IsLowStock {
			lowStock++
		}
	}
	return StockSummaryValues{
		TotalProducts: len(f.lines),
		TotalQty:      totalQty,
		TotalValue:    totalVal,
		LowStock:      lowStock,
	}, nil
}

func (f *fakeStore) ListMovements(context.Context, MovementFilter) ([]db.ListMovementsRow, int, error) {
	out := []db.ListMovementsRow{}
	for _, m := range f.movements {
		out = append(out, movementRowFrom(m))
	}
	return out, len(out), nil
}

func (f *fakeStore) ProductExists(context.Context, int64) (bool, error)   { return true, nil }
func (f *fakeStore) WarehouseExists(context.Context, int64) (bool, error) { return true, nil }

func (f *fakeStore) CreateOpname(_ context.Context, p db.CreateOpnameParams) (int64, error) {
	o := &Opname{
		ID:           f.nextID,
		OpnameNumber: p.OpnameNumber,
		WarehouseID:  p.WarehouseID,
		OpnameDate:   pgtzDateToStr(p.OpnameDate),
		Status:       p.Status,
		Notes:        pgtzTextToStrPtr(p.Notes),
		CreatedBy:    int8ToPtr(p.CreatedBy),
	}
	f.nextID++
	f.opnames[o.ID] = o
	return o.ID, nil
}

func (f *fakeStore) CreateOpnameOn(ctx context.Context, _ db.DBTX, p db.CreateOpnameParams) (int64, error) {
	return f.CreateOpname(ctx, p)
}

func (f *fakeStore) OpnameByID(_ context.Context, id int64) (db.GetOpnameByIDRow, error) {
	if o, ok := f.opnames[id]; ok {
		return opnameRowFrom(o), nil
	}
	return db.GetOpnameByIDRow{}, ErrNotFound
}

func (f *fakeStore) LockOpnameByID(_ context.Context, _ db.DBTX, id int64) (db.LockOpnameByIDRow, error) {
	if o, ok := f.opnames[id]; ok {
		return db.LockOpnameByIDRow(opnameRowFrom(o)), nil
	}
	return db.LockOpnameByIDRow{}, ErrNotFound
}

func (f *fakeStore) ListOpnames(_ context.Context, filter OpnameFilter) ([]db.ListOpnamesRow, int, error) {
	f.opnameFilter = filter
	return nil, 0, nil
}

func (f *fakeStore) CreateOpnameItem(_ context.Context, p db.CreateOpnameItemParams) (int64, error) {
	it := &OpnameItem{
		ID:             f.nextID,
		StockOpnameID:  p.StockOpnameID,
		ProductID:      p.ProductID,
		SystemQuantity: p.SystemQuantity,
		PhysicalQty:    ndToDecPtr(p.PhysicalQuantity),
		Notes:          pgtzTextToStrPtr(p.Notes),
	}
	f.nextID++
	f.opnameItems[it.StockOpnameID] = append(f.opnameItems[it.StockOpnameID], it)
	return it.ID, nil
}

func (f *fakeStore) CreateOpnameItemOn(ctx context.Context, _ db.DBTX, p db.CreateOpnameItemParams) (int64, error) {
	return f.CreateOpnameItem(ctx, p)
}

func (f *fakeStore) OpnameItems(_ context.Context, opnameID int64) ([]db.ListOpnameItemsRow, error) {
	var out []db.ListOpnameItemsRow
	for _, it := range f.opnameItems[opnameID] {
		out = append(out, opnameItemRowFrom(it))
	}
	return out, nil
}

func (f *fakeStore) OpnameItemsOn(ctx context.Context, _ db.DBTX, opnameID int64) ([]db.ListOpnameItemsRow, error) {
	return f.OpnameItems(ctx, opnameID)
}

func (f *fakeStore) SetOpnameItemsPhysical(_ context.Context, opnameID int64, items []OpnameItemInput) error {
	for _, in := range items {
		for _, it := range f.opnameItems[opnameID] {
			if it.ProductID == in.ProductID {
				it.PhysicalQty = in.PhysicalQty
			}
		}
	}
	return nil
}

func (f *fakeStore) UpdateOpnameStatus(_ context.Context, id int64, status string) error {
	o := f.opnames[id]
	o.Status = status
	return nil
}

func (f *fakeStore) UpdateOpnameStatusOn(_ context.Context, _ db.DBTX, id int64, status string) error {
	return f.UpdateOpnameStatus(context.Background(), id, status)
}

// --- fake row <-> view helpers ----------------------------------------------

func stockRowFrom(s *Stock) db.GetStockRowRow {
	return db.GetStockRowRow{
		ID:               s.ID,
		ProductID:        s.ProductID,
		WarehouseID:      s.WarehouseID,
		Quantity:         s.Quantity,
		ReservedQuantity: s.ReservedQuantity,
	}
}

// stockLineRowFrom converts a view row BACK into the repository's row type,
// which is the direction the fake needs: the store returns db rows, the
// service builds StockLine. The cost fields are dereferenced here, because the
// db row shape is the pre-split one with plain decimals and the pointer-ness
// lives only in the view type. A nil cost pointer means "the fixture did not
// set one", which is zero cost — a real value, and the tests below rely on
// that being distinct from a redacted field.
func stockLineRowFrom(l StockLine) db.ListStockLinesRow {
	out := db.ListStockLinesRow{
		ID:               l.ProductID,
		Code:             l.ProductCode,
		Name:             l.ProductName,
		CategoryName:     l.CategoryName,
		UnitSymbol:       textOrNil(l.UnitSymbol),
		Quantity:         l.Quantity,
		MinStock:         l.MinStock,
		IsActive:         l.IsActive,
		ReservedQuantity: l.ReservedQuantity,
		Outlets:          mustJSON(l.Outlets),
	}
	if l.PurchasePrice != nil {
		out.PurchasePrice = *l.PurchasePrice
	}
	// No HPP on the db row: the view's HPP is derived from PurchasePrice in
	// stockLineFromRow (ListStockLines selects purchase_price once and the
	// service sets both fields from it), so there is nothing to convert here.
	if l.Value != nil {
		out.Value = *l.Value
	}
	return out
}

// mustJSON marshals a value to []byte for the sqlc interface{} JSON column.
func mustJSON(v any) []byte {
	b, _ := json.Marshal(v)
	return b
}

// decPtr is the fixture-side shorthand for the now-pointer cost fields. Using a
// helper rather than an inline &literal keeps a loop variable's address from
// being captured by accident, which is exactly the aliasing bug the pointer
// fields could otherwise invite in a test table.
func decPtr(d decimal.Decimal) *decimal.Decimal { return &d }
func movementRowFrom(m *Movement) db.ListMovementsRow {
	return db.ListMovementsRow{
		ID:             m.ID,
		MovementNumber: m.MovementNumber,
		ProductID:      m.ProductID,
		WarehouseID:    m.WarehouseID,
		MovementType:   m.MovementType,
		Quantity:       m.Quantity,
		QuantityBefore: m.QuantityBefore,
		QuantityAfter:  m.QuantityAfter,
		ReferenceType:  textOrNilStr(m.ReferenceType),
		ReferenceID:    int8OrNil(m.ReferenceID),
		Notes:          textOrNilStr(m.Notes),
		CreatedBy:      int8OrNil(m.CreatedBy),
		CreatedAt:      tsToPg(m.CreatedAt),
	}
}

func opnameRowFrom(o *Opname) db.GetOpnameByIDRow {
	return db.GetOpnameByIDRow{
		ID:           o.ID,
		OpnameNumber: o.OpnameNumber,
		WarehouseID:  o.WarehouseID,
		OpnameDate:   o.OpnameDate,
		Status:       o.Status,
		Notes:        textOrNilStr(o.Notes),
		CreatedBy:    int8OrNil(o.CreatedBy),
		ApprovedBy:   int8OrNil(o.ApprovedBy),
	}
}

func opnameItemRowFrom(it *OpnameItem) db.ListOpnameItemsRow {
	return db.ListOpnameItemsRow{
		ID:               it.ID,
		StockOpnameID:    it.StockOpnameID,
		ProductID:        it.ProductID,
		SystemQuantity:   it.SystemQuantity,
		PhysicalQuantity: decToNdPtr(it.PhysicalQty),
		Notes:            textOrNilStr(it.Notes),
		Code:             it.ProductCode,
		Name:             it.ProductName,
		UnitSymbol:       textOrNil(it.UnitSymbol),
	}
}

func tsToPg(t time.Time) pgtype.Timestamptz {
	return pgtype.Timestamptz{Time: t, Valid: true}
}

func pgtzDateToStr(v pgtype.Date) string {
	if !v.Valid {
		return ""
	}
	return v.Time.Format("2006-01-02")
}

func pgtzTextToStrPtr(v pgtype.Text) *string {
	if !v.Valid {
		return nil
	}
	s := v.String
	return &s
}

type fakeSeq struct{ n int64 }

func (f *fakeSeq) Sequence(context.Context, string, time.Time, any) (int64, error) {
	f.n++
	return f.n, nil
}

func svc() *Service { return NewService(newFakeStore(), &fakeSeq{}, time.UTC) }

func TestStockIn_AddsQuantity(t *testing.T) {
	s := svc()
	m, err := s.StockIn(context.Background(), StockInInput{
		ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromFloat(10.5),
	})
	if err != nil {
		t.Fatal(err)
	}
	st := s.store.(*fakeStore).stocks["1:1"]
	if st.Quantity.String() != "10.5" {
		t.Errorf("quantity = %s, want 10.5", st.Quantity)
	}
	if m.QuantityBefore.String() != "0" || m.QuantityAfter.String() != "10.5" {
		t.Errorf("before/after = %s/%s, want 0/10.5", m.QuantityBefore, m.QuantityAfter)
	}
}

func TestStockIn_Validation(t *testing.T) {
	s := svc()
	_, err := s.StockIn(context.Background(), StockInInput{ProductID: 1, Quantity: decimal.NewFromFloat(1)})
	if err == nil {
		t.Fatal("want error: warehouse required")
	}
	_, err = s.StockIn(context.Background(), StockInInput{ProductID: 1, WarehouseID: 1, Quantity: decimal.Zero})
	if err == nil {
		t.Fatal("want error: quantity must be positive")
	}
}

func TestStockOut_Sufficient(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(20), ReservedQuantity: decimal.Zero}
	m, err := s.StockOut(context.Background(), StockOutInput{
		ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(8),
	})
	if err != nil {
		t.Fatal(err)
	}
	if m.QuantityAfter.String() != "12" {
		t.Errorf("after = %s, want 12", m.QuantityAfter)
	}
}

func TestStockOut_Insufficient(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(5), ReservedQuantity: decimal.Zero}
	_, err := s.StockOut(context.Background(), StockOutInput{
		ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(8),
	})
	if !errors.Is(err, ErrInsufficient) {
		t.Errorf("got %v, want ErrInsufficient", err)
	}
}

func TestOpnameCreate_SnapshotsStock(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(3), ReservedQuantity: decimal.Zero}
	st.stocks["2:1"] = &Stock{ID: 2, ProductID: 2, WarehouseID: 1, Quantity: decimal.NewFromInt(7), ReservedQuantity: decimal.Zero}
	st.lines = []StockLine{
		{ProductID: 1, Quantity: decimal.NewFromInt(3)},
		{ProductID: 2, Quantity: decimal.NewFromInt(7)},
	}
	o, err := s.OpnameCreate(context.Background(), OpnameInput{WarehouseID: 1})
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "in_progress" {
		t.Errorf("status = %s, want in_progress", o.Status)
	}
}

func TestOpnameFinalize_AdjustsAndCompletes(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(10), ReservedQuantity: decimal.Zero}
	st.lines = []StockLine{{ProductID: 1, Quantity: decimal.NewFromInt(10)}}
	o, err := s.OpnameCreate(context.Background(), OpnameInput{WarehouseID: 1})
	if err != nil {
		t.Fatal(err)
	}
	// physical 13 -> difference +3
	if err := s.OpnameSetPhysical(context.Background(), o.ID, []OpnameItemInput{
		{ProductID: 1, PhysicalQty: &[]decimal.Decimal{decimal.NewFromInt(13)}[0]},
	}); err != nil {
		t.Fatal(err)
	}
	adjusted, err := s.OpnameFinalize(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if adjusted != 1 {
		t.Errorf("adjusted = %d, want 1", adjusted)
	}
	if st.stocks["1:1"].Quantity.String() != "13" {
		t.Errorf("quantity = %s, want 13", st.stocks["1:1"].Quantity)
	}
	o, err = s.OpnameByID(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if o.Status != "completed" {
		t.Errorf("status = %s, want completed", o.Status)
	}
}

func TestTransferCreate_Validation(t *testing.T) {
	s := svc()
	_, err := s.TransferCreate(context.Background(), TransferInput{FromWarehouseID: 1, ToWarehouseID: 1})
	if err == nil {
		t.Fatal("want error: same warehouse")
	}
	_, err = s.TransferCreate(context.Background(), TransferInput{FromWarehouseID: 1, ToWarehouseID: 2, TransferDate: "2026-10-26", Items: []TransferItemInput{}})
	if !errors.Is(err, ErrItemRequired) {
		t.Errorf("got %v, want ErrItemRequired", err)
	}
}

func TestTransferCreate_RejectsInvalidItemsBeforePersistence(t *testing.T) {
	for _, qty := range []decimal.Decimal{decimal.Zero, decimal.NewFromInt(-1), decimal.RequireFromString("0.00001")} {
		if err := validateTransfer(TransferInput{FromWarehouseID: 1, ToWarehouseID: 2, TransferDate: "2026-10-26", Items: []TransferItemInput{{ProductID: 9, QuantityRequested: qty}}}); err == nil {
			t.Fatalf("invalid quantity accepted: %s", qty)
		}
	}
	if _, err := svc().TransferCreate(context.Background(), TransferInput{FromWarehouseID: 1, ToWarehouseID: 2, TransferDate: "2026-10-26", Items: []TransferItemInput{{ProductID: 9, QuantityRequested: decimal.NewFromInt(4)}}}); !errors.Is(err, ErrForbiddenWarehouse) {
		t.Fatalf("missing actor/scope must fail closed: %v", err)
	}
}

func TestOpnameByID_RejectsOutOfScopeCaller(t *testing.T) {
	s := svc()
	st := s.store.(*fakeStore)
	st.stocks["1:1"] = &Stock{ID: 1, ProductID: 1, WarehouseID: 1, Quantity: decimal.NewFromInt(3), ReservedQuantity: decimal.Zero}
	o, err := s.OpnameCreate(context.Background(), OpnameInput{WarehouseID: 1})
	if err != nil {
		t.Fatal(err)
	}
	// A store-bound caller whose stores map to no warehouses (fakeStore
	// WarehousesForStores is a no-op) must not reach another outlet's opname.
	scoped := auth.WithStoreScope(context.Background(), auth.NewStoreScope([]int64{99}))
	if _, err := s.OpnameByID(scoped, o.ID); !errors.Is(err, ErrForbiddenWarehouse) {
		t.Errorf("got %v, want ErrForbiddenWarehouse", err)
	}
	// Platform-wide caller is unaffected.
	if _, err := s.OpnameByID(context.Background(), o.ID); err != nil {
		t.Errorf("platform-wide caller got %v, want nil", err)
	}
}

func TestListFiltersForwarded(t *testing.T) {
	st := newFakeStore()
	s := NewService(st, &fakeSeq{}, time.UTC)

	if _, _, err := s.ListOpnames(context.Background(), OpnameFilter{Status: "completed", WarehouseID: 7}); err != nil {
		t.Fatal(err)
	}
	if st.opnameFilter != (OpnameFilter{Page: 1, PerPage: 20, Status: "completed", WarehouseID: 7}) {
		t.Errorf("opname filter = %+v", st.opnameFilter)
	}

}

func TestStockLineOutletsRoundTrip(t *testing.T) {
	lines := []StockLine{
		{
			ProductID:   1,
			ProductCode: "SKU-1",
			Quantity:    decimal.RequireFromString("100"),
			UnitSymbol:  "botol",
			Outlets: []StockOutletLine{
				{WarehouseID: 2, WarehouseName: "Gudang A", Quantity: decimal.RequireFromString("40")},
				{WarehouseID: 3, WarehouseName: "Gudang B", Quantity: decimal.RequireFromString("60")},
			},
		},
	}
	st := &fakeStore{lines: lines}
	s := NewService(st, &fakeSeq{}, time.UTC)
	got, _, err := s.ListStock(context.Background(), StockFilter{Page: 1, PerPage: 20})
	if err != nil {
		t.Fatal(err)
	}
	if len(got) != 1 || len(got[0].Outlets) != 2 {
		t.Fatalf("outlets not round-tripped: %+v", got)
	}
	if got[0].Outlets[0].WarehouseName != "Gudang A" || got[0].Outlets[1].Quantity.IntPart() != 60 {
		t.Errorf("outlet content wrong: %+v", got[0].Outlets)
	}
}

func TestService_ListStock_Sorting(t *testing.T) {
	price1 := decimal.RequireFromString("5000")
	price2 := decimal.RequireFromString("15000")
	lines := []StockLine{
		{
			ProductID:     1,
			ProductCode:   "SKU-B",
			ProductName:   "Bravo Item",
			Quantity:      decimal.RequireFromString("50"),
			MinStock:      decimal.RequireFromString("10"),
			PurchasePrice: &price1,
		},
		{
			ProductID:     2,
			ProductCode:   "SKU-A",
			ProductName:   "Alpha Item",
			Quantity:      decimal.RequireFromString("10"),
			MinStock:      decimal.RequireFromString("20"),
			PurchasePrice: &price2,
		},
	}
	st := &fakeStore{lines: lines}
	s := NewService(st, &fakeSeq{}, time.UTC)

	// Test 1: Sort by quantity ASC
	res, _, err := s.ListStock(context.Background(), StockFilter{SortBy: "quantity", SortDir: "asc"})
	if err != nil {
		t.Fatal(err)
	}
	if len(res) != 2 || res[0].ProductName != "Alpha Item" || res[1].ProductName != "Bravo Item" {
		t.Errorf("quantity ASC sort failed: got %v then %v", res[0].ProductName, res[1].ProductName)
	}

	// Test 2: Sort by quantity DESC
	res, _, err = s.ListStock(context.Background(), StockFilter{SortBy: "quantity", SortDir: "desc"})
	if err != nil {
		t.Fatal(err)
	}
	if len(res) != 2 || res[0].ProductName != "Bravo Item" || res[1].ProductName != "Alpha Item" {
		t.Errorf("quantity DESC sort failed: got %v then %v", res[0].ProductName, res[1].ProductName)
	}

	// Test 3: Sort by product_name ASC
	res, _, err = s.ListStock(context.Background(), StockFilter{SortBy: "product_name", SortDir: "asc"})
	if err != nil {
		t.Fatal(err)
	}
	if len(res) != 2 || res[0].ProductName != "Alpha Item" || res[1].ProductName != "Bravo Item" {
		t.Errorf("product_name ASC sort failed: got %v then %v", res[0].ProductName, res[1].ProductName)
	}

	// Test 4: Unprivileged user sorting by cost fields falls back to product_name
	res, _, err = s.ListStock(context.Background(), StockFilter{SortBy: "purchase_price", SortDir: "asc"})
	if err != nil {
		t.Fatal(err)
	}
	// Without inventory.cost.view permission, falls back to product_name ASC
	if len(res) != 2 || res[0].ProductName != "Alpha Item" || res[1].ProductName != "Bravo Item" {
		t.Errorf("unprivileged cost sort fallback failed: got %v then %v", res[0].ProductName, res[1].ProductName)
	}
}

// The availability half of the summary is unconditional; TotalValue is
// asserted separately in the cost-permission tests, because a caller with no
// user on ctx is denied cost by design and this one asserts the counts.
func TestStockSummary(t *testing.T) {
	st := &fakeStore{
		lines: []StockLine{
			{ProductID: 1, Quantity: decimal.NewFromInt(10), Value: decPtr(decimal.NewFromInt(50000)), IsLowStock: false},
			{ProductID: 2, Quantity: decimal.NewFromInt(2), Value: decPtr(decimal.NewFromInt(10000)), IsLowStock: true},
		},
	}
	s := NewService(st, &fakeSeq{}, time.UTC)
	sum, err := s.StockSummary(context.Background(), StockFilter{})
	if err != nil {
		t.Fatal(err)
	}
	if sum.TotalProducts != 2 {
		t.Errorf("expected 2 products, got %d", sum.TotalProducts)
	}
	if !sum.TotalQty.Equal(decimal.NewFromInt(12)) {
		t.Errorf("expected total qty 12, got %v", sum.TotalQty)
	}
	// No user on ctx => no inventory.cost.view => absent, not zero. Asserting
	// the ABSENCE here is the point: this is the pre-existing test that used
	// to prove total_value was computed, and it is the test that would have
	// caught the aggregate leaking.
	if sum.TotalValue != nil {
		t.Errorf("total_value = %v for a caller with no cost permission, want absent", *sum.TotalValue)
	}
	if sum.LowStock != 1 {
		t.Errorf("expected 1 low stock, got %d", sum.LowStock)
	}
}
