package purchasing

import (
	"context"
	"errors"
	"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/db"
)

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 {
	if f.store != nil {
		f.store.commits++
	}
	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
}

type fakeStore struct {
	orders        map[int64]*PurchaseOrder
	items         map[int64][]*POItem
	receipts      map[int64]*GoodsReceipt
	grItems       map[int64][]*GRItem
	vouchers      map[int64]*Voucher
	stockQty      map[string]decimal.Decimal
	grFilter      GRFilter
	debtFilter    DebtFilter
	voucherFilter VoucherFilter
	nextID        int64
	commits       int
}

func newFakeStore() *fakeStore {
	return &fakeStore{
		orders:   map[int64]*PurchaseOrder{},
		items:    map[int64][]*POItem{},
		receipts: map[int64]*GoodsReceipt{},
		grItems:  map[int64][]*GRItem{},
		vouchers: map[int64]*Voucher{},
		stockQty: map[string]decimal.Decimal{},
		nextID:   1,
	}
}

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

func (f *fakeStore) CreateOrder(_ context.Context, _ db.DBTX, p db.CreatePurchaseOrderParams) (db.CreatePurchaseOrderRow, error) {
	o := orderFromCreateParams(p)
	o.ID = f.nextID
	f.nextID++
	f.orders[o.ID] = o
	return db.CreatePurchaseOrderRow{ID: o.ID}, nil
}

func (f *fakeStore) UpdateOrder(_ context.Context, _ db.DBTX, p db.UpdatePurchaseOrderParams) error {
	o := orderFromUpdateParams(p)
	f.orders[o.ID] = o
	return nil
}

func (f *fakeStore) DeleteOrder(_ context.Context, _ db.DBTX, id int64) error {
	delete(f.orders, id)
	delete(f.items, id)
	return nil
}

func (f *fakeStore) OrderByID(_ context.Context, id int64) (db.GetPurchaseOrderByIDRow, error) {
	if o, ok := f.orders[id]; ok {
		return orderToDBRow(o), nil
	}
	return db.GetPurchaseOrderByIDRow{}, ErrNotFound
}

func (f *fakeStore) OrderByIDForUpdate(_ context.Context, _ db.DBTX, id int64) (db.GetPurchaseOrderByIDForUpdateRow, error) {
	if o, ok := f.orders[id]; ok {
		return db.GetPurchaseOrderByIDForUpdateRow{
			ID: o.ID, PoNumber: o.PoNumber, SupplierID: o.SupplierID, WarehouseID: o.WarehouseID,
			CreatedBy: int8OrNil(o.CreatedBy), OrderDate: o.OrderDate, ExpectedDate: ptrValue(o.ExpectedDate), DueDate: ptrValue(o.DueDate),
			Status: o.Status, Subtotal: o.Subtotal, TaxAmount: o.TaxAmount, DiscountAmount: o.DiscountAmount,
			TotalAmount: o.TotalAmount, PaidAmount: o.PaidAmount, RemainingAmount: o.RemainingAmount, Notes: textPtrOrNil(o.Notes),
			CreatedAt: pgtype.Timestamptz{Time: o.CreatedAt, Valid: !o.CreatedAt.IsZero()}, UpdatedAt: pgtype.Timestamptz{Time: o.UpdatedAt, Valid: !o.UpdatedAt.IsZero()},
		}, nil
	}
	return db.GetPurchaseOrderByIDForUpdateRow{}, ErrNotFound
}

func (f *fakeStore) ListOrders(context.Context, POFilter) ([]db.ListPurchaseOrdersRow, int, error) {
	return nil, 0, nil
}

func (f *fakeStore) CreateItem(_ context.Context, _ db.DBTX, p db.CreatePurchaseOrderItemParams) (int64, error) {
	it := &POItem{PurchaseOrderID: p.PurchaseOrderID, ProductID: p.ProductID, Quantity: p.Quantity, UnitPrice: p.UnitPrice, Discount: p.Discount, Subtotal: p.Subtotal}
	it.ID = f.nextID
	f.nextID++
	f.items[it.PurchaseOrderID] = append(f.items[it.PurchaseOrderID], it)
	return it.ID, nil
}

func (f *fakeStore) ItemsByOrder(_ context.Context, orderID int64) ([]db.ListPurchaseOrderItemsRow, error) {
	out := []db.ListPurchaseOrderItemsRow{}
	for _, it := range f.items[orderID] {
		out = append(out, db.ListPurchaseOrderItemsRow{ID: it.ID, PurchaseOrderID: it.PurchaseOrderID, ProductID: it.ProductID,
			Quantity: it.Quantity, ReceivedQuantity: it.ReceivedQuantity, UnitPrice: it.UnitPrice, Discount: it.Discount, Subtotal: it.Subtotal,
			Code: it.ProductCode, Name: it.ProductName})
	}
	return out, nil
}

func (f *fakeStore) ItemsByOrderForUpdate(_ context.Context, _ db.DBTX, orderID int64) ([]db.ListPurchaseOrderItemsForUpdateRow, error) {
	out := []db.ListPurchaseOrderItemsForUpdateRow{}
	for _, it := range f.items[orderID] {
		out = append(out, db.ListPurchaseOrderItemsForUpdateRow{
			ID: it.ID, PurchaseOrderID: it.PurchaseOrderID, ProductID: it.ProductID,
			Quantity: it.Quantity, ReceivedQuantity: it.ReceivedQuantity, UnitPrice: it.UnitPrice,
			Discount: it.Discount, Subtotal: it.Subtotal, Code: it.ProductCode, Name: it.ProductName,
		})
	}
	return out, nil
}

func (f *fakeStore) DeleteItems(_ context.Context, _ db.DBTX, orderID int64) error {
	f.items[orderID] = nil
	return nil
}

func (f *fakeStore) SetOrderStatus(_ context.Context, _ db.DBTX, p db.SetPurchaseOrderStatusParams) error {
	if o, ok := f.orders[p.ID]; ok {
		o.Status = p.Status
	}
	return nil
}

func (f *fakeStore) ApproveOrder(_ context.Context, _ db.DBTX, p db.ApprovePurchaseOrderParams) error {
	if o, ok := f.orders[p.ID]; ok {
		o.Status = "approved"
	}
	return nil
}

// StampPrint mirrors the real UPDATE's atomic guard (000150): the first print
// always wins; reprints pass only with allow_reprint. execrows==0 is what the
// service turns into ErrAlreadyPrinted.
func (f *fakeStore) StampPrint(_ context.Context, p db.PurchaseOrdersStampPrintParams) (int64, error) {
	o, ok := f.orders[p.ID]
	if !ok {
		return 0, nil
	}
	if o.PrintCount > 0 && !p.AllowReprint {
		return 0, nil
	}
	o.PrintCount++
	now := time.Now()
	o.PrintedAt = &now
	return 1, nil
}

func (f *fakeStore) AddReceived(_ context.Context, _ db.DBTX, p db.AddReceivedQuantityParams) error {
	for _, list := range f.items {
		for _, it := range list {
			if it.ID == p.ID {
				it.ReceivedQuantity = it.ReceivedQuantity.Add(p.ReceivedQuantity)
			}
		}
	}
	return nil
}

func (f *fakeStore) CreateGR(_ context.Context, _ db.DBTX, p db.CreateGoodsReceiptParams) (db.CreateGoodsReceiptRow, error) {
	g := &GoodsReceipt{GrNumber: p.GrNumber, PurchaseOrderID: p.PurchaseOrderID, WarehouseID: p.WarehouseID,
		ReceivedBy: int8ToPtr(p.ReceivedBy), ReceivedDate: dateString(p.ReceivedDate), Notes: textToPtr(p.Notes), Status: p.Status}
	g.ID = f.nextID
	f.nextID++
	f.receipts[g.ID] = g
	return db.CreateGoodsReceiptRow{ID: g.ID}, nil
}

func (f *fakeStore) CreateGRItem(_ context.Context, _ db.DBTX, p db.CreateGoodsReceiptItemParams) (int64, error) {
	it := &GRItem{GoodsReceiptID: p.GoodsReceiptID, PurchaseOrderItemID: int8Value(p.PurchaseOrderItemID), ProductID: p.ProductID,
		QuantityOrdered: p.QuantityOrdered, QuantityReceived: p.QuantityReceived, BatchNumber: textToPtr(p.BatchNumber), ExpiredDate: datePtr(p.ExpiredDate)}
	it.ID = f.nextID
	f.nextID++
	f.grItems[it.GoodsReceiptID] = append(f.grItems[it.GoodsReceiptID], it)
	return it.ID, nil
}

func (f *fakeStore) GRByID(_ context.Context, id int64) (db.GetGoodsReceiptByIDRow, error) {
	if g, ok := f.receipts[id]; ok {
		return db.GetGoodsReceiptByIDRow{ID: g.ID, GrNumber: g.GrNumber, PurchaseOrderID: g.PurchaseOrderID, WarehouseID: g.WarehouseID,
			ReceivedBy: int8OrNil(g.ReceivedBy), ReceivedDate: g.ReceivedDate, Notes: textPtrOrNil(g.Notes), Status: g.Status}, nil
	}
	return db.GetGoodsReceiptByIDRow{}, ErrNotFound
}

func (f *fakeStore) GRItems(_ context.Context, grID int64) ([]db.ListGoodsReceiptItemsRow, error) {
	out := []db.ListGoodsReceiptItemsRow{}
	for _, it := range f.grItems[grID] {
		out = append(out, db.ListGoodsReceiptItemsRow{ID: it.ID, GoodsReceiptID: it.GoodsReceiptID,
			PurchaseOrderItemID: int8(it.PurchaseOrderItemID), ProductID: it.ProductID, QuantityOrdered: it.QuantityOrdered,
			QuantityReceived: it.QuantityReceived, BatchNumber: textPtrOrNil(it.BatchNumber), ExpiredDate: ptrValue(it.ExpiredDate), Code: it.ProductCode, Name: it.ProductName})
	}
	return out, nil
}

func (f *fakeStore) ListGRs(_ context.Context, filter GRFilter) ([]db.ListGoodsReceiptsRow, int, error) {
	f.grFilter = filter
	return nil, 0, nil
}

func (f *fakeStore) ReceiveStock(_ context.Context, _ db.DBTX, p db.RawStockAddParams, _, _ int64, _ *int64) error {
	k := key2(p.ProductID, p.WarehouseID)
	f.stockQty[k] = f.stockQty[k].Add(p.Quantity)
	return nil
}

func (f *fakeStore) ListDebts(_ context.Context, filter DebtFilter) ([]db.ListDebtsRow, int, DebtSummary, error) {
	f.debtFilter = filter
	return nil, 0, DebtSummary{}, nil
}

func (f *fakeStore) CreateVoucher(_ context.Context, _ db.DBTX, p db.CreateVoucherParams) (int64, error) {
	v := &Voucher{VoucherNumber: p.VoucherNumber, Type: p.Type, Description: textToPtr(p.Description), VoucherDate: dateString(p.VoucherDate),
		Amount: p.Amount, Status: p.Status, CreatedBy: int8ToPtr(p.CreatedBy),
		SupplierID: int8ToPtr(p.SupplierID), POID: int8ToPtr(p.PoID), CashAccountCode: p.CashAccountCode.String}
	v.ID = f.nextID
	f.nextID++
	f.vouchers[v.ID] = v
	return v.ID, nil
}

func voucherToUpdateRow(v *Voucher) db.GetVoucherByIDForUpdateRow {
	return db.GetVoucherByIDForUpdateRow{
		ID: v.ID, VoucherNumber: v.VoucherNumber, Type: v.Type, Description: textPtrOrNil(v.Description),
		VoucherDate: v.VoucherDate, Amount: v.Amount, Status: v.Status, CreatedBy: int8OrNil(v.CreatedBy),
		SupplierID: int8OrNil(v.SupplierID), PoID: int8OrNil(v.POID), CashAccountCode: textOrNil(v.CashAccountCode),
		PrintedAt: pgtype.Timestamptz{Time: timeOrZero(v.PrintedAt), Valid: v.PrintedAt != nil},
		PrintedBy: int8OrNil(v.PrintedBy),
	}
}

func timeOrZero(v *time.Time) time.Time {
	if v == nil {
		return time.Time{}
	}
	return *v
}

func (f *fakeStore) VoucherByID(_ context.Context, id int64) (db.GetVoucherByIDRow, error) {
	if v, ok := f.vouchers[id]; ok {
		return db.GetVoucherByIDRow(voucherToUpdateRow(v)), nil
	}
	return db.GetVoucherByIDRow{}, ErrNotFound
}

func (f *fakeStore) VoucherByIDForUpdate(_ context.Context, _ db.DBTX, id int64) (db.GetVoucherByIDForUpdateRow, error) {
	if v, ok := f.vouchers[id]; ok {
		return voucherToUpdateRow(v), nil
	}
	return db.GetVoucherByIDForUpdateRow{}, ErrNotFound
}

func (f *fakeStore) MarkVoucherPrinted(_ context.Context, _ db.DBTX, p db.MarkVoucherPrintedParams) error {
	v, ok := f.vouchers[p.ID]
	if !ok {
		return ErrNotFound
	}
	now := time.Now()
	v.PrintedAt = &now
	if p.PrintedBy.Valid {
		id := p.PrintedBy.Int64
		v.PrintedBy = &id
	}
	return nil
}

func (f *fakeStore) PurchaseOrderSupplierID(_ context.Context, _ db.DBTX, poID int64) (int64, error) {
	if o, ok := f.orders[poID]; ok {
		return o.SupplierID, nil
	}
	return 0, ErrNotFound
}

func (f *fakeStore) UpdateVoucherStatus(_ context.Context, _ db.DBTX, p db.UpdateVoucherStatusParams) error {
	if v, ok := f.vouchers[p.ID]; ok {
		// parity: only if still available
		if v.Status == "available" {
			v.Status = p.Status
		}
	}
	return nil
}

func (f *fakeStore) UpdateVoucherStatusAny(_ context.Context, _ db.DBTX, p db.UpdateVoucherStatusAnyParams) error {
	if v, ok := f.vouchers[p.ID]; ok {
		v.Status = p.Status
	}
	return nil
}

func (f *fakeStore) UpdateVoucher(_ context.Context, _ db.DBTX, p db.UpdateVoucherParams) error {
	if v, ok := f.vouchers[p.ID]; ok {
		v.Type = p.Type
		v.Description = textToPtr(p.Description)
		v.VoucherDate = dateString(p.VoucherDate)
		v.Amount = p.Amount
		v.Status = p.Status
	}
	return nil
}

func (f *fakeStore) ListVouchers(_ context.Context, filter VoucherFilter) ([]db.ListVouchersRow, int, error) {
	f.voucherFilter = filter
	return nil, 0, nil
}

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

func orderFromCreateParams(p db.CreatePurchaseOrderParams) *PurchaseOrder {
	return &PurchaseOrder{PoNumber: p.PoNumber, SupplierID: p.SupplierID, WarehouseID: p.WarehouseID, CreatedBy: int8ToPtr(p.CreatedBy),
		OrderDate: dateString(p.OrderDate), ExpectedDate: datePtr(p.ExpectedDate), DueDate: datePtr(p.DueDate), Status: p.Status,
		Subtotal: p.Subtotal, TaxAmount: p.TaxAmount, DiscountAmount: p.DiscountAmount, TotalAmount: p.TotalAmount,
		PaidAmount: p.PaidAmount, RemainingAmount: p.RemainingAmount, Notes: textToPtr(p.Notes)}
}

func orderFromUpdateParams(p db.UpdatePurchaseOrderParams) *PurchaseOrder {
	o := orderFromCreateParams(db.CreatePurchaseOrderParams{PoNumber: p.PoNumber, SupplierID: p.SupplierID, WarehouseID: p.WarehouseID,
		OrderDate: p.OrderDate, ExpectedDate: p.ExpectedDate, DueDate: p.DueDate, Status: p.Status, Subtotal: p.Subtotal,
		TaxAmount: p.TaxAmount, DiscountAmount: p.DiscountAmount, TotalAmount: p.TotalAmount, PaidAmount: p.PaidAmount,
		RemainingAmount: p.RemainingAmount, Notes: p.Notes})
	o.ID = p.ID
	return o
}

func orderToDBRow(o *PurchaseOrder) db.GetPurchaseOrderByIDRow {
	return db.GetPurchaseOrderByIDRow{ID: o.ID, PoNumber: o.PoNumber, SupplierID: o.SupplierID, WarehouseID: o.WarehouseID,
		CreatedBy: int8OrNil(o.CreatedBy), OrderDate: o.OrderDate, ExpectedDate: ptrValue(o.ExpectedDate), DueDate: ptrValue(o.DueDate),
		Status: o.Status, Subtotal: o.Subtotal, TaxAmount: o.TaxAmount, DiscountAmount: o.DiscountAmount,
		TotalAmount: o.TotalAmount, PaidAmount: o.PaidAmount, RemainingAmount: o.RemainingAmount, Notes: textPtrOrNil(o.Notes),
		PrintCount: int32(o.PrintCount), PrintedAt: pgtype.Timestamptz{Time: timeOrZero(o.PrintedAt), Valid: o.PrintedAt != nil},
		CreatedAt: pgtype.Timestamptz{Time: o.CreatedAt, Valid: !o.CreatedAt.IsZero()}, UpdatedAt: pgtype.Timestamptz{Time: o.UpdatedAt, Valid: !o.UpdatedAt.IsZero()}}
}

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

func datePtr(v pgtype.Date) *string {
	if !v.Valid {
		return nil
	}
	s := dateString(v)
	return &s
}

func ptrValue(v *string) string {
	if v == nil {
		return ""
	}
	return *v
}

func key2(p, w int64) string { return itoa2(p) + ":" + itoa2(w) }
func itoa2(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)
}

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) }

type accrualProbe struct {
	n   int
	in  GRAccrualInput
	err error
}

func svcWithAccrual() (*Service, *accrualProbe) {
	s := svc()
	p := &accrualProbe{}
	s.WithAccrual(func(_ context.Context, in GRAccrualInput, _ *int64) (string, error) {
		p.n++
		p.in = in
		return "JU-TEST-0001", p.err
	})
	return s, p
}

var errAccrualBoom = errors.New("boom")

func svcWithFailingAccrual() *Service {
	s, p := svcWithAccrual()
	p.err = errAccrualBoom
	return s
}

func in2(pid int64, qty, price string) POItemInput {
	return POItemInput{ProductID: pid, Quantity: decimal.RequireFromString(qty), UnitPrice: decimal.RequireFromString(price)}
}

func TestPOCreate_IntegerRoundedTax(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		TaxPercent: decimal.NewFromInt(10),
		Items:      []POItemInput{in2(1, "2", "100"), in2(2, "1", "50")},
	})
	if err != nil {
		t.Fatal(err)
	}
	// subtotal = 250; tax = round(25.0) = 25; total = 275
	if o.Subtotal.String() != "250" {
		t.Errorf("subtotal = %s, want 250", o.Subtotal)
	}
	if o.TaxAmount.String() != "25" {
		t.Errorf("tax = %s, want 25", o.TaxAmount)
	}
	if o.TotalAmount.String() != "275" {
		t.Errorf("total = %s, want 275", o.TotalAmount)
	}
	if o.Status != "draft" {
		t.Errorf("status = %s, want draft", o.Status)
	}
	if o.RemainingAmount.String() != "275" {
		t.Errorf("remaining = %s, want 275", o.RemainingAmount)
	}
}

func TestPOCreate_LineDiscountSkipsEmpty(t *testing.T) {
	s := svc()
	li := in2(1, "4", "100")
	li.Discount = decimal.NewFromInt(100) // sub = 400-100 = 300
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{li, {ProductID: 0, Quantity: decimal.NewFromInt(2), UnitPrice: decimal.NewFromInt(10)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if o.Subtotal.String() != "300" {
		t.Errorf("subtotal = %s, want 300", o.Subtotal)
	}
	if len(o.Items) != 1 {
		t.Errorf("items = %d, want 1 (empty skipped)", len(o.Items))
	}
}

func TestPOCreate_Validation(t *testing.T) {
	s := svc()
	_, err := s.Create(context.Background(), POInput{WarehouseID: 1, OrderDate: "2026-08-11", Items: []POItemInput{in2(1, "1", "10")}})
	if !errors.Is(err, ErrSupplierRequired) {
		t.Errorf("got %v, want ErrSupplierRequired", err)
	}
	_, err = s.Create(context.Background(), POInput{SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11", Items: []POItemInput{}})
	if !errors.Is(err, ErrItemRequired) {
		t.Errorf("got %v, want ErrItemRequired", err)
	}
	exp := "2026-08-09"
	_, err = s.Create(context.Background(), POInput{SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11", ExpectedDate: &exp,
		Items: []POItemInput{in2(1, "1", "10")}})
	if err == nil {
		t.Fatal("want error: expected_date < order_date")
	}
}

func TestPOUpdate_DraftReplacesItemsAndTotals(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}

	updated, err := s.Update(context.Background(), o.ID, POInput{
		SupplierID: 1, WarehouseID: 2, OrderDate: "2026-08-12",
		TaxPercent: decimal.NewFromInt(10), DiscountAmount: decimal.NewFromInt(5),
		Items: []POItemInput{in2(2, "2", "50")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if updated.Status != "draft" || updated.WarehouseID != 2 || updated.Subtotal.String() != "100" || updated.TaxAmount.String() != "10" || updated.TotalAmount.String() != "105" {
		t.Errorf("updated PO = %+v", updated)
	}
	if len(updated.Items) != 1 || updated.Items[0].ProductID != 2 || updated.Items[0].Quantity.String() != "2" {
		t.Errorf("updated items = %+v", updated.Items)
	}
}

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

	if _, _, err := s.ListGRs(context.Background(), GRFilter{Status: "confirmed"}); err != nil {
		t.Fatal(err)
	}
	if st.grFilter != (GRFilter{Page: 1, PerPage: 20, Status: "confirmed"}) {
		t.Errorf("GR filter = %+v", st.grFilter)
	}

	if _, count, summary, err := s.ListDebts(context.Background(), DebtFilter{SupplierID: 9, Search: "PO-9", DueDate: "2026-08-31"}); err != nil {
		t.Fatal(err)
	} else if count != 0 || !summary.TotalAmount.IsZero() {
		t.Errorf("debt summary = %d/%s", count, summary.TotalAmount)
	}
	if st.debtFilter != (DebtFilter{Page: 1, PerPage: 20, SupplierID: 9, Search: "PO-9", DueDate: "2026-08-31"}) {
		t.Errorf("debt filter = %+v", st.debtFilter)
	}

	if _, _, err := s.ListVouchers(context.Background(), VoucherFilter{Page: 2, PerPage: 10, Status: "available"}); err != nil {
		t.Fatal(err)
	}
	if st.voucherFilter != (VoucherFilter{Page: 2, PerPage: 10, Status: "available"}) {
		t.Errorf("voucher filter = %+v", st.voucherFilter)
	}
}

func TestPOApproveCancelDelete(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "10")},
	})
	if err != nil {
		t.Fatal(err)
	}
	a, err := s.Approve(context.Background(), o.ID, 42)
	if err != nil {
		t.Fatal(err)
	}
	if a.Status != "approved" {
		t.Errorf("status = %s, want approved", a.Status)
	}
	if _, err := s.Approve(context.Background(), o.ID, 42); !errors.Is(err, ErrCannotModify) {
		t.Errorf("second approve got %v, want ErrCannotModify", err)
	}
	if err := s.Delete(context.Background(), o.ID); !errors.Is(err, ErrCannotModify) {
		t.Errorf("delete approved got %v, want ErrCannotModify", err)
	}
	c, err := s.Cancel(context.Background(), o.ID)
	if err != nil {
		t.Fatal(err)
	}
	if c.Status != "cancelled" {
		t.Errorf("status = %s, want cancelled", c.Status)
	}
}

func TestGR_AddsStockAndFullReceipt(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "10", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Approve(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	g, err := s.CreateGR(context.Background(), GRInput{
		PurchaseOrderID: o.ID, WarehouseID: 1, ReceivedDate: "2026-08-12",
		Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(10)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if g.Status != "confirmed" {
		t.Errorf("gr status = %s, want confirmed", g.Status)
	}
	if q := s.store.(*fakeStore).stockQty["1:1"]; q.String() != "10" {
		t.Errorf("stock qty = %s, want 10", q)
	}
	if s.store.(*fakeStore).orders[o.ID].Status != "received" {
		t.Errorf("po status = %s, want received (full)", s.store.(*fakeStore).orders[o.ID].Status)
	}
}

func TestGR_PartialReceipt(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "10", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Approve(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	_, err = s.CreateGR(context.Background(), GRInput{
		PurchaseOrderID: o.ID, WarehouseID: 1, ReceivedDate: "2026-08-12",
		Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(4)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	if s.store.(*fakeStore).orders[o.ID].Status != "partial" {
		t.Errorf("po status = %s, want partial", s.store.(*fakeStore).orders[o.ID].Status)
	}
	if q := s.store.(*fakeStore).stockQty["1:1"]; q.String() != "4" {
		t.Errorf("stock qty = %s, want 4", q)
	}
}

func TestGR_CumulativeReceiptCompletesOrder(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "10", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Approve(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	for _, qty := range []int64{4, 6} {
		_, err = s.CreateGR(context.Background(), GRInput{
			PurchaseOrderID: o.ID, WarehouseID: 1, ReceivedDate: "2026-08-12",
			Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(qty)}},
		})
		if err != nil {
			t.Fatal(err)
		}
	}
	store := s.store.(*fakeStore)
	if store.orders[o.ID].Status != "received" {
		t.Errorf("po status = %s, want received after cumulative receipt", store.orders[o.ID].Status)
	}
	if got := store.items[o.ID][0].ReceivedQuantity.String(); got != "10" {
		t.Errorf("received quantity = %s, want 10", got)
	}
}

func TestGR_RejectsDraftPO(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "10", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	_, err = s.CreateGR(context.Background(), GRInput{
		PurchaseOrderID: o.ID, WarehouseID: 1, ReceivedDate: "2026-08-12",
		Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(10)}},
	})
	if !errors.Is(err, ErrPONotApproved) {
		t.Errorf("got %v, want ErrPONotApproved", err)
	}
}

func TestInvalidDatesReturnValidationErrors(t *testing.T) {
	ctx := context.Background()
	s := svc()
	invalid := "2026-02-30"
	tests := []struct {
		name string
		run  func() error
	}{
		{name: "order date", run: func() error {
			_, err := s.Create(ctx, POInput{SupplierID: 1, WarehouseID: 1, OrderDate: invalid, Items: []POItemInput{in2(1, "1", "10")}})
			return err
		}},
		{name: "expected date", run: func() error {
			_, err := s.Create(ctx, POInput{SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11", ExpectedDate: &invalid, Items: []POItemInput{in2(1, "1", "10")}})
			return err
		}},
		{name: "due date", run: func() error {
			_, err := s.Create(ctx, POInput{SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11", DueDate: &invalid, Items: []POItemInput{in2(1, "1", "10")}})
			return err
		}},
		{name: "received date", run: func() error {
			_, err := s.CreateGR(ctx, GRInput{PurchaseOrderID: 1, WarehouseID: 1, ReceivedDate: invalid, Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(1)}}})
			return err
		}},
		{name: "expired date", run: func() error {
			_, err := s.CreateGR(ctx, GRInput{PurchaseOrderID: 1, WarehouseID: 1, ReceivedDate: "2026-08-11", Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(1), ExpiredDate: &invalid}}})
			return err
		}},
		{name: "voucher date", run: func() error {
			_, err := s.CreateVoucher(ctx, VoucherInput{VoucherDate: invalid, Amount: decimal.NewFromInt(1)})
			return err
		}},
		{name: "debt due-date filter", run: func() error {
			_, _, _, err := s.ListDebts(ctx, DebtFilter{DueDate: invalid})
			return err
		}},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if err := tt.run(); err == nil {
				t.Fatal("expected validation error")
			}
		})
	}
}

func TestRepositoryExecRowsMapsZeroRows(t *testing.T) {
	r := &Repository{}
	tx := &fakeTx{tag: pgconn.NewCommandTag("UPDATE 0")}
	ctx := context.Background()

	if err := r.SetOrderStatus(ctx, tx, db.SetPurchaseOrderStatusParams{ID: 1, Status: "approved"}); !errors.Is(err, ErrNotFound) {
		t.Errorf("SetOrderStatus error = %v, want ErrNotFound", err)
	}
	if err := r.AddReceived(ctx, tx, db.AddReceivedQuantityParams{ID: 1, ReceivedQuantity: decimal.NewFromInt(1)}); !errors.Is(err, ErrNotFound) {
		t.Errorf("AddReceived error = %v, want ErrNotFound", err)
	}
	if err := r.UpdateVoucherStatus(ctx, tx, db.UpdateVoucherStatusParams{ID: 1, Status: "used"}); !errors.Is(err, ErrVoucherUsed) {
		t.Errorf("UpdateVoucherStatus error = %v, want ErrVoucherUsed", err)
	}
}

func TestPayDebt_FullAndParity(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	// force received so debt list would include it
	s.store.(*fakeStore).orders[o.ID].Status = "received"
	po, err := s.PayDebt(context.Background(), o.ID, decimal.NewFromInt(100), nil, nil)
	if err != nil {
		t.Fatal(err)
	}
	// Lifecycle status survives payment (2026-09): a paid received PO stays
	// 'received'; the debt list clears on remaining_amount, not status.
	if po.Status != "received" {
		t.Errorf("status = %s, want received (lifecycle kept)", po.Status)
	}
	if po.RemainingAmount.String() != "0" {
		t.Errorf("remaining = %s, want 0", po.RemainingAmount)
	}
}

func TestPayDebt_TooLarge(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	// Draft POs have no enforceable debt yet (state machine 2026-09).
	_, err = s.PayDebt(context.Background(), o.ID, decimal.NewFromInt(999), nil, nil)
	if !errors.Is(err, ErrPONotPayable) {
		t.Errorf("got %v, want ErrPONotPayable (draft po has no debt)", err)
	}
}

func TestPayDebt_VoucherMarkedUsed(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	// Payment targets a PO whose debt exists (approved lifecycle stage).
	s.store.(*fakeStore).orders[o.ID].Status = "approved"
	v, err := s.CreateVoucher(context.Background(), VoucherInput{
		VoucherDate: "2026-08-11",
		Amount:      decimal.NewFromInt(50),
	})
	if err != nil {
		t.Fatal(err)
	}
	if v.Status != "available" {
		t.Errorf("voucher status = %s, want available", v.Status)
	}
	vid := v.ID
	if _, err := s.PayDebt(context.Background(), o.ID, decimal.NewFromInt(50), &vid, nil); err != nil {
		t.Fatal(err)
	}
	if s.store.(*fakeStore).vouchers[v.ID].Status != "used" {
		t.Errorf("voucher status = %s, want used", s.store.(*fakeStore).vouchers[v.ID].Status)
	}
	// voucher already used -> second pay blocked
	if _, err := s.PayDebt(context.Background(), o.ID, decimal.NewFromInt(1), &vid, nil); !errors.Is(err, ErrVoucherUsed) {
		t.Errorf("got %v, want ErrVoucherUsed", err)
	}
}

func TestVoucherCreate(t *testing.T) {
	s := svc()
	v, err := s.CreateVoucher(context.Background(), VoucherInput{Amount: decimal.NewFromInt(100)})
	if err != nil {
		t.Fatal(err)
	}
	if v.Status != "available" {
		t.Errorf("status = %s, want available", v.Status)
	}
	if v.Type != "payment" {
		t.Errorf("type = %s, want payment default", v.Type)
	}
	if v.VoucherNumber[:2] != "PV" {
		t.Errorf("number = %s, want PV prefix", v.VoucherNumber)
	}
}

// Client-supplied status is ignored on create (server-owned transitions):
// a "paid"/"used" create must never mint an already-consumed voucher.
func TestVoucherCreateIgnoresClientStatus(t *testing.T) {
	s := svc()
	used := "used"
	v, err := s.CreateVoucher(context.Background(), VoucherInput{
		Amount: decimal.NewFromInt(100), Status: &used,
	})
	if err != nil {
		t.Fatal(err)
	}
	if v.Status != "available" {
		t.Errorf("status = %s, want available (client status ignored)", v.Status)
	}
}

// PayDebt posts the supplier-payment journal via the injected hook with the
// payment amount and the resolved cash account (default when the voucher —
// or a direct payment with no voucher — doesn't specify one).
func TestPayDebt_PostsSupplierPaymentJournal(t *testing.T) {
	s := svc()
	var gotPO, gotVoucher, gotCash string
	var gotAmount decimal.Decimal
	s.WithAccounting(func(_ context.Context, poNumber, voucherNumber, cashAccountCode string, amount decimal.Decimal, _ *int64) (string, error) {
		gotPO, gotVoucher, gotCash, gotAmount = poNumber, voucherNumber, cashAccountCode, amount
		return "KK-202608-0001", nil
	})
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	s.store.(*fakeStore).orders[o.ID].Status = "received"
	po, err := s.PayDebt(context.Background(), o.ID, decimal.NewFromInt(100), nil, nil)
	if err != nil {
		t.Fatal(err)
	}
	if po.Status != "received" {
		t.Errorf("status = %s, want received (lifecycle kept)", po.Status)
	}
	if gotPO != o.PoNumber || gotVoucher != "" || gotCash != acctCashDefault || !gotAmount.Equal(decimal.NewFromInt(100)) {
		t.Errorf("journal hook got po=%s voucher=%q cash=%s amount=%s, want po=%s voucher=\"\" cash=%s amount=100",
			gotPO, gotVoucher, gotCash, gotAmount, o.PoNumber, acctCashDefault)
	}
}

// If the journal hook fails, the whole payment must never reach tx.Commit —
// the fake store applies writes eagerly (no real transactional isolation),
// so the observable atomicity contract here is "commit was never invoked".
func TestPayDebt_RollsBackWhenJournalFails(t *testing.T) {
	s := svc()
	journalErr := errors.New("journal boom")
	s.WithAccounting(func(context.Context, string, string, string, decimal.Decimal, *int64) (string, error) {
		return "", journalErr
	})
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	s.store.(*fakeStore).orders[o.ID].Status = "received"
	commitsBefore := s.store.(*fakeStore).commits
	_, err = s.PayDebt(context.Background(), o.ID, decimal.NewFromInt(100), nil, nil)
	if !errors.Is(err, journalErr) {
		t.Errorf("got %v, want journal error", err)
	}
	if got := s.store.(*fakeStore).commits; got != commitsBefore {
		t.Errorf("tx.Commit invoked despite journal failure: commits %d -> %d", commitsBefore, got)
	}
}

// MarkVoucherPrinted is idempotent: the second call reports already_printed
// and keeps the original stamp instead of resetting it or erroring.
func TestMarkVoucherPrinted_Idempotent(t *testing.T) {
	s := svc()
	v, err := s.CreateVoucher(context.Background(), VoucherInput{Amount: decimal.NewFromInt(100)})
	if err != nil {
		t.Fatal(err)
	}
	first, already, err := s.MarkVoucherPrinted(context.Background(), v.ID, 7)
	if err != nil {
		t.Fatal(err)
	}
	if already {
		t.Error("first print reported already_printed=true")
	}
	if first.PrintedAt == nil || first.PrintedBy == nil || *first.PrintedBy != 7 {
		t.Fatalf("printed_at/printed_by not recorded: %+v", first)
	}
	second, already, err := s.MarkVoucherPrinted(context.Background(), v.ID, 9)
	if err != nil {
		t.Fatal(err)
	}
	if !already {
		t.Error("second print reported already_printed=false")
	}
	if second.PrintedBy == nil || *second.PrintedBy != 7 {
		t.Errorf("printed_by changed on reprint: %v, want original 7", second.PrintedBy)
	}
}

// CreateVoucher resolves supplier_id from po_id server-side (ignoring any
// client-supplied supplier_id) so the voucher<->debt link can't be spoofed.
func TestVoucherCreate_ResolvesSupplierFromPO(t *testing.T) {
	s := svc()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 42, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "1", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	spoofed := int64(999)
	v, err := s.CreateVoucher(context.Background(), VoucherInput{
		Amount: decimal.NewFromInt(100), POID: &o.ID, SupplierID: &spoofed,
	})
	if err != nil {
		t.Fatal(err)
	}
	if v.SupplierID == nil || *v.SupplierID != 42 {
		t.Errorf("supplier_id = %v, want 42 resolved from PO (spoofed value ignored)", v.SupplierID)
	}
	if v.CashAccountCode != acctCashDefault {
		t.Errorf("cash_account_code = %s, want default %s", v.CashAccountCode, acctCashDefault)
	}
}

func TestGR_AutoDraftsBalancedAccrual(t *testing.T) {
	s, hook := svcWithAccrual()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "10", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Approve(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	g, err := s.CreateGR(context.Background(), GRInput{
		PurchaseOrderID: o.ID, WarehouseID: 1, ReceivedDate: "2026-08-12",
		Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(10)}},
	})
	if err != nil {
		t.Fatal(err)
	}
	// 10 x 100 = DPP 1000, no tax on the PO: draft must be D 500.03 1000 /
	// K 211.03 1000 and the GR must carry the journal number back.
	if g.JournalNumber == "" {
		t.Fatal("GR carries no journal_number")
	}
	if hook.n != 1 {
		t.Fatalf("accrual hook calls = %d, want 1", hook.n)
	}
	if !hook.in.DPP.Equal(decimal.NewFromInt(1000)) {
		t.Errorf("DPP = %s, want 1000", hook.in.DPP)
	}
	if !hook.in.Tax.IsZero() {
		t.Errorf("Tax = %s, want 0", hook.in.Tax)
	}
}

func TestGR_AccrualFailureKeepsGR(t *testing.T) {
	s := svcWithFailingAccrual()
	o, err := s.Create(context.Background(), POInput{
		SupplierID: 1, WarehouseID: 1, OrderDate: "2026-08-11",
		Items: []POItemInput{in2(1, "10", "100")},
	})
	if err != nil {
		t.Fatal(err)
	}
	if _, err := s.Approve(context.Background(), o.ID, 42); err != nil {
		t.Fatal(err)
	}
	g, err := s.CreateGR(context.Background(), GRInput{
		PurchaseOrderID: o.ID, WarehouseID: 1, ReceivedDate: "2026-08-12",
		Items: []GRItemInput{{ProductID: 1, QuantityReceived: decimal.NewFromInt(10)}},
	})
	if err != nil {
		t.Fatalf("GR must survive accrual failure: %v", err)
	}
	if g.JournalNumber != "" {
		t.Errorf("journal_number = %q, want empty on hook failure", g.JournalNumber)
	}
}
