package purchasing

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

	"github.com/shopspring/decimal"

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/db"
	"github.com/TaruLab/keluargamart-backend/internal/inventory"
	"github.com/TaruLab/keluargamart-backend/pkg/logger"
	"github.com/TaruLab/keluargamart-backend/pkg/seq"
	"github.com/jackc/pgx/v5/pgtype"
)

// Sentinel errors mapped to HTTP in the handler.
var (
	ErrNotFound         = errors.New("not found")
	ErrItemRequired     = errors.New("items are required")
	ErrCannotModify     = errors.New("po is not draft")
	ErrCannotCancel     = errors.New("po cannot be cancelled")
	ErrSupplierRequired = errors.New("supplier is required")
	ErrPaymentTooLarge  = errors.New("Jumlah pembayaran melebihi sisa hutang")
	ErrVoucherUsed      = errors.New("voucher tidak tersedia")
	ErrPONotApproved    = errors.New("po is not approved")
	ErrPONotPayable     = errors.New("po belum disetujui — hutang belum ada")
	ErrAlreadyPrinted   = errors.New("PO ini sudah pernah dicetak — cetak ulang hanya oleh admin/owner")
)

const purchaseOrderModel = "App\\Models\\PurchaseOrder"

// acctCashDefault mirrors accounting.acctPettyCashOffice (1000.01.02 Kas Kecil
// Operasional) — the default cash COA counterpart when a voucher does not specify one.
// Akun kas laci POS (1000.01.01) ditolak pada jurnal pengeluaran kas (BKK).
const acctCashDefault = "1000.01.02"

// Seq allocates atomic counters (pkg/seq) — injected so tests fake it.
type Seq interface {
	Sequence(ctx context.Context, prefix string, date time.Time, tx any) (int64, error)
}

// SupplierPaymentJournal posts a debit-AP/credit-cash journal entry for a
// supplier debt payment and returns the posted journal number (mirrors the
// WithPricing/WithShipmentLedger DI pattern in main.go) so purchasing does
// not need to mock accounting's Store surface in unit tests.
type SupplierPaymentJournal func(ctx context.Context, poNumber, voucherNumber, cashAccountCode string, amount decimal.Decimal, userID *int64) (journalNumber string, err error)

// GRAccrualInput carries one goods receipt's purchase-accrual data across
// the purchasing→accounting boundary (DI hook below): the GR identity, the
// linked PO number for reference/memo, the accrual amounts, and the cost
// center stamped on every line (departments table, keputusan #2).
type GRAccrualInput struct {
	GRID         int64
	GRNumber     string
	PONumber     string
	ReceivedDate string
	DPP          decimal.Decimal
	Tax          decimal.Decimal
	Department   string
}

// GRAccrualJournal drafts (never posts) the purchase-accrual journal for a
// goods receipt and returns its journal number. Draft-only per keputusan #4:
// posting stays a manual finance action so a tax/unbalanced dispute never
// auto-hits the ledger.
type GRAccrualJournal func(ctx context.Context, in GRAccrualInput, userID *int64) (journalNumber string, err error)

// Service implements purchasing use cases.
type Service struct {
	store   Store
	seq     Seq
	loc     *time.Location
	journal SupplierPaymentJournal
	accrual GRAccrualJournal
}

func NewService(store Store, seq Seq, loc *time.Location) *Service {
	return &Service{store: store, seq: seq, loc: loc}
}

// WithAccounting wires the supplier-payment journal poster (called from main).
func (s *Service) WithAccounting(fn SupplierPaymentJournal) *Service {
	s.journal = fn
	return s
}

// WithAccrual wires the GR purchase-accrual draft creator (called from main).
func (s *Service) WithAccrual(fn GRAccrualJournal) *Service {
	s.accrual = fn
	return s
}

func (s *Service) ListOrders(ctx context.Context, f POFilter) ([]PurchaseOrder, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	rows, total, err := s.store.ListOrders(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]PurchaseOrder, 0, len(rows))
	for _, row := range rows {
		out = append(out, orderFromListRow(row))
	}
	return out, total, nil
}

func (s *Service) GetOrder(ctx context.Context, id int64) (*PurchaseOrder, error) {
	row, err := s.store.OrderByID(ctx, id)
	if err != nil {
		return nil, err
	}
	items, err := s.store.ItemsByOrder(ctx, id)
	if err != nil {
		return nil, err
	}
	o := orderFromRow(row)
	o.Items = make([]POItem, 0, len(items))
	for _, item := range items {
		o.Items = append(o.Items, itemFromRow(item))
	}
	return o, nil
}

// --- PO lifecycle -----------------------------------------------------------

// Create validates and stores a draft PO (§4.1). tax_amount is integer-rounded
// (PHP round half-up on subtotal*tax/100); total = max(0, subtotal+tax-discount).
func (s *Service) Create(ctx context.Context, in POInput) (*PurchaseOrder, error) {
	if in.SupplierID == 0 {
		return nil, ErrSupplierRequired
	}
	ok, err := s.store.SupplierExists(ctx, in.SupplierID)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, ErrSupplierRequired
	}
	if in.WarehouseID == 0 {
		return nil, errors.New("warehouse is required")
	}
	ok, err = s.store.WarehouseExists(ctx, in.WarehouseID)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, errors.New("warehouse not found")
	}
	if in.OrderDate == "" {
		return nil, errors.New("order_date is required")
	}
	if err := validateDate("order_date", in.OrderDate); err != nil {
		return nil, err
	}
	if err := validateOptionalDate("expected_date", in.ExpectedDate); err != nil {
		return nil, err
	}
	if err := validateOptionalDate("due_date", in.DueDate); err != nil {
		return nil, err
	}
	if in.ExpectedDate != nil && *in.ExpectedDate != "" && *in.ExpectedDate < in.OrderDate {
		return nil, errors.New("expected_date must be >= order_date")
	}
	if in.TaxPercent.IsNegative() || in.TaxPercent.GreaterThan(decimal.NewFromInt(100)) {
		return nil, errors.New("tax_percent must be 0-100")
	}
	if in.DiscountAmount.IsNegative() {
		return nil, errors.New("discount_amount must be >= 0")
	}
	if len(in.Items) == 0 {
		return nil, ErrItemRequired
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)

	subtotal := decimal.Zero
	items := make([]*POItem, 0, len(in.Items))
	for _, li := range in.Items {
		if li.ProductID == 0 || li.Quantity.IsZero() {
			continue // §4.1: skip empty product_id / quantity
		}
		if li.Quantity.LessThan(decimal.NewFromFloat(0.001)) {
			return nil, errors.New("quantity must be >= 0.001")
		}
		if li.UnitPrice.IsNegative() {
			return nil, errors.New("unit_price must be >= 0")
		}
		if li.Discount.IsNegative() {
			return nil, errors.New("discount must be >= 0")
		}
		exists, err := s.store.ProductExists(ctx, li.ProductID)
		if err != nil {
			return nil, err
		}
		if !exists {
			return nil, ErrNotFound
		}
		sub := li.UnitPrice.Mul(li.Quantity).Sub(li.Discount)
		if sub.IsNegative() {
			sub = decimal.Zero // max(0, ...) §4.1.3
		}
		subtotal = subtotal.Add(sub)
		items = append(items, &POItem{
			ProductID: li.ProductID,
			Quantity:  li.Quantity,
			UnitPrice: li.UnitPrice,
			Discount:  li.Discount,
			Subtotal:  sub,
		})
	}
	if len(items) == 0 {
		return nil, ErrItemRequired
	}

	// PHP round(x) precision 0, half-up -> integer rounding
	tax := subtotal.Mul(in.TaxPercent.Div(decimal.NewFromInt(100))).Round(0)
	total := subtotal.Add(tax).Sub(in.DiscountAmount)
	if total.IsNegative() {
		total = decimal.Zero
	}

	poNumber, err := s.poCode(ctx)
	if err != nil {
		return nil, err
	}
	o := &PurchaseOrder{
		CreatedBy:       in.CreatedBy,
		PoNumber:        poNumber,
		SupplierID:      in.SupplierID,
		WarehouseID:     in.WarehouseID,
		OrderDate:       in.OrderDate,
		ExpectedDate:    in.ExpectedDate,
		DueDate:         in.DueDate,
		Status:          "draft",
		Subtotal:        subtotal,
		TaxAmount:       tax,
		DiscountAmount:  in.DiscountAmount,
		TotalAmount:     total,
		PaidAmount:      decimal.Zero,
		RemainingAmount: total,
		Notes:           in.Notes,
	}
	created, err := s.store.CreateOrder(ctx, tx, createOrderParams(o))
	if err != nil {
		return nil, err
	}
	o.ID = created.ID
	o.CreatedAt = created.CreatedAt.Time
	o.UpdatedAt = created.UpdatedAt.Time
	for _, it := range items {
		it.PurchaseOrderID = o.ID
		it.ID, err = s.store.CreateItem(ctx, tx, createItemParams(it))
		if err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetOrder(ctx, o.ID)
}

// Update replaces a draft PO header and its items in one transaction.
func (s *Service) Update(ctx context.Context, id int64, in POInput) (*PurchaseOrder, error) {
	if in.SupplierID == 0 {
		return nil, ErrSupplierRequired
	}
	if ok, err := s.store.SupplierExists(ctx, in.SupplierID); err != nil {
		return nil, err
	} else if !ok {
		return nil, ErrSupplierRequired
	}
	if in.WarehouseID == 0 {
		return nil, errors.New("warehouse is required")
	}
	if ok, err := s.store.WarehouseExists(ctx, in.WarehouseID); err != nil {
		return nil, err
	} else if !ok {
		return nil, errors.New("warehouse not found")
	}
	if in.OrderDate == "" {
		return nil, errors.New("order_date is required")
	}
	if err := validateDate("order_date", in.OrderDate); err != nil {
		return nil, err
	}
	if err := validateOptionalDate("expected_date", in.ExpectedDate); err != nil {
		return nil, err
	}
	if err := validateOptionalDate("due_date", in.DueDate); err != nil {
		return nil, err
	}
	if in.ExpectedDate != nil && *in.ExpectedDate != "" && *in.ExpectedDate < in.OrderDate {
		return nil, errors.New("expected_date must be >= order_date")
	}
	if in.TaxPercent.IsNegative() || in.TaxPercent.GreaterThan(decimal.NewFromInt(100)) {
		return nil, errors.New("tax_percent must be 0-100")
	}
	if in.DiscountAmount.IsNegative() {
		return nil, errors.New("discount_amount must be >= 0")
	}

	subtotal := decimal.Zero
	items := make([]*POItem, 0, len(in.Items))
	for _, line := range in.Items {
		if line.ProductID == 0 || line.Quantity.IsZero() {
			continue
		}
		if line.Quantity.LessThan(decimal.NewFromFloat(0.001)) {
			return nil, errors.New("quantity must be >= 0.001")
		}
		if line.UnitPrice.IsNegative() {
			return nil, errors.New("unit_price must be >= 0")
		}
		if line.Discount.IsNegative() {
			return nil, errors.New("discount must be >= 0")
		}
		if exists, err := s.store.ProductExists(ctx, line.ProductID); err != nil {
			return nil, err
		} else if !exists {
			return nil, ErrNotFound
		}
		lineSubtotal := line.UnitPrice.Mul(line.Quantity).Sub(line.Discount)
		if lineSubtotal.IsNegative() {
			lineSubtotal = decimal.Zero
		}
		subtotal = subtotal.Add(lineSubtotal)
		items = append(items, &POItem{PurchaseOrderID: id, ProductID: line.ProductID, Quantity: line.Quantity,
			UnitPrice: line.UnitPrice, Discount: line.Discount, Subtotal: lineSubtotal})
	}
	if len(items) == 0 {
		return nil, ErrItemRequired
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	locked, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if locked.Status != "draft" {
		return nil, ErrCannotModify
	}
	o := orderFromLockedRow(locked)
	o.SupplierID = in.SupplierID
	o.WarehouseID = in.WarehouseID
	o.OrderDate = in.OrderDate
	o.ExpectedDate = in.ExpectedDate
	o.DueDate = in.DueDate
	o.Subtotal = subtotal
	o.TaxAmount = subtotal.Mul(in.TaxPercent.Div(decimal.NewFromInt(100))).Round(0)
	o.DiscountAmount = in.DiscountAmount
	o.TotalAmount = subtotal.Add(o.TaxAmount).Sub(in.DiscountAmount)
	if o.TotalAmount.IsNegative() {
		o.TotalAmount = decimal.Zero
	}
	o.RemainingAmount = o.TotalAmount.Sub(o.PaidAmount)
	if o.RemainingAmount.IsNegative() {
		o.RemainingAmount = decimal.Zero
	}
	o.Notes = in.Notes
	if err := s.store.UpdateOrder(ctx, tx, updateOrderParams(o)); err != nil {
		return nil, err
	}
	if err := s.store.DeleteItems(ctx, tx, id); err != nil {
		return nil, err
	}
	for _, item := range items {
		if _, err := s.store.CreateItem(ctx, tx, createItemParams(item)); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetOrder(ctx, id)
}

// Approve moves draft->approved (§4.2).
func (s *Service) Approve(ctx context.Context, id, approverID int64) (*PurchaseOrder, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if row.Status != "draft" {
		return nil, ErrCannotModify
	}
	// Record the approver identity for the formal approval block on the
	// printable PO document (client feedback 2026-08-26 section 1).
	if err := s.store.ApproveOrder(ctx, tx, db.ApprovePurchaseOrderParams{
		ID:         id,
		ApprovedBy: pgtype.Int8{Int64: approverID, Valid: approverID > 0},
	}); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetOrder(ctx, id)
}

// StampPrint is the print-once ledger for the signed-approval PO sheet
// (000150). The reportproxy calls it at the moment a po-print sheet is
// actually produced — the single chokepoint both the browser print and the
// PDF download share. First print stamps and counts; any later print needs
// allowReprint (admin/owner bypass, decided by the caller from the auth
// user), otherwise ErrAlreadyPrinted. The atomic WHERE
// (print_count = 0 OR allow_reprint) makes two concurrent first prints pick
// exactly one winner.
func (s *Service) StampPrint(ctx context.Context, id, userID int64, allowReprint bool) (*PurchaseOrder, error) {
	n, err := s.store.StampPrint(ctx, db.PurchaseOrdersStampPrintParams{
		ID:           id,
		PrintedBy:    pgtype.Int8{Int64: userID, Valid: userID > 0},
		AllowReprint: allowReprint,
	})
	if err != nil {
		return nil, err
	}
	if n == 0 {
		return nil, ErrAlreadyPrinted
	}
	return s.GetOrder(ctx, id)
}

// Cancel marks status=cancelled; blocked when received/cancelled (§4.2).
func (s *Service) Cancel(ctx context.Context, id int64) (*PurchaseOrder, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if row.Status == "received" || row.Status == "cancelled" {
		return nil, ErrCannotCancel
	}
	if err := s.store.SetOrderStatus(ctx, tx, db.SetPurchaseOrderStatusParams{ID: id, Status: "cancelled"}); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetOrder(ctx, id)
}

// Delete removes a draft PO only (§4.2).
func (s *Service) Delete(ctx context.Context, id int64) error {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, id)
	if err != nil {
		return err
	}
	if row.Status != "draft" {
		return ErrCannotModify
	}
	if err := s.store.DeleteItems(ctx, tx, id); err != nil {
		return err
	}
	if err := s.store.DeleteOrder(ctx, tx, id); err != nil {
		return err
	}
	return tx.Commit(ctx)
}

// --- GR ---------------------------------------------------------------------

// CreateGR records the receipt, its stock increments and canonical movements
// together while locking the purchase order against concurrent over-receipt.
func (s *Service) CreateGR(ctx context.Context, in GRInput) (*GoodsReceipt, error) {
	if in.PurchaseOrderID == 0 {
		return nil, errors.New("purchase_order is required")
	}
	if in.WarehouseID == 0 {
		return nil, errors.New("warehouse is required")
	}
	ok, err := s.store.WarehouseExists(ctx, in.WarehouseID)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, errors.New("warehouse not found")
	}
	if in.ReceivedDate == "" {
		return nil, errors.New("received_date is required")
	}
	if err := validateDate("received_date", in.ReceivedDate); err != nil {
		return nil, err
	}
	if len(in.Items) == 0 {
		return nil, ErrItemRequired
	}
	// Total nilai barang cacat/ditolak pada penerimaan ini. Menurunkan nilai
	// PO sehingga hutang supplier hanya mencerminkan barang yang benar-benar
	// diterima — bukan barang yang ditolak di pintu gudang.
	rejectedTotal := decimal.Zero
	for _, item := range in.Items {
		if err := validateOptionalDate("expired_date", item.ExpiredDate); err != nil {
			return nil, err
		}
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	if response, err := inventory.ClaimMovementRequest(ctx, tx); err != nil {
		return nil, err
	} else if response != nil {
		var receipt GoodsReceipt
		if err := json.Unmarshal(response, &receipt); err != nil {
			return nil, err
		}
		return &receipt, nil
	}
	if scope := auth.StoreScopeFrom(ctx); !scope.PlatformWide() {
		whs, err := db.New(tx).WarehousesForStores(ctx, scope.IDs())
		if err != nil {
			return nil, err
		}
		allowed := false
		for _, id := range whs {
			if id == in.WarehouseID {
				allowed = true
				break
			}
		}
		if !allowed {
			return nil, ErrNotFound
		}
	}
	row, err := s.store.OrderByIDForUpdate(ctx, tx, in.PurchaseOrderID)
	if err != nil {
		return nil, err
	}
	po := orderFromLockedRow(row)
	poRow := row
	if poRow.WarehouseID != in.WarehouseID {
		return nil, errors.New("receipt warehouse must match purchase order")
	}
	if poRow.Status == "cancelled" {
		return nil, ErrCannotCancel
	}
	// 'paid' is a legacy fully-prepaid flag (pre-2026-09 PayDebt collapsed the
	// lifecycle into it) — goods for a prepaid PO must still be receivable.
	if poRow.Status != "approved" && poRow.Status != "partial" && poRow.Status != "paid" {
		return nil, ErrPONotApproved
	}
	itemRows, err := s.store.ItemsByOrderForUpdate(ctx, tx, in.PurchaseOrderID)
	if err != nil {
		return nil, err
	}
	poItems := make([]POItem, 0, len(itemRows))
	byProduct := map[int64]*POItem{}
	for _, row := range itemRows {
		poItems = append(poItems, itemFromLockedRow(row))
	}
	for i := range poItems {
		byProduct[poItems[i].ProductID] = &poItems[i]
	}

	grNumber, err := s.grCode(ctx)
	if err != nil {
		return nil, err
	}
	g := &GoodsReceipt{
		GrNumber:        grNumber,
		PurchaseOrderID: in.PurchaseOrderID,
		WarehouseID:     in.WarehouseID,
		ReceivedDate:    in.ReceivedDate,
		Notes:           in.Notes,
		ReceivedBy:      accrualUserID(ctx),
		Status:          "confirmed",
	}
	created, err := s.store.CreateGR(ctx, tx, createGRParams(g))
	if err != nil {
		return nil, err
	}
	g.ID = created.ID
	g.CreatedAt = created.CreatedAt.Time

	received := map[int64]decimal.Decimal{} // product -> total received in this GR
	// Lock stock rows in product order across simultaneous multi-product receipts.
	items := append([]GRItemInput(nil), in.Items...)
	sort.SliceStable(items, func(i, j int) bool { return items[i].ProductID < items[j].ProductID })
	for _, li := range items {
		// Skip empty lines only: a line with rejected>0 but received=0 is a
		// FULL REJECTION and must still flow — it reduces the PO value even
		// though nothing entered the warehouse (reported 2 Nov: a full
		// rejection never reached the deduction).
		if li.ProductID == 0 || (li.QuantityReceived.IsZero() && li.QuantityRejected.IsZero()) {
			continue
		}
		if li.QuantityReceived.IsNegative() || li.QuantityRejected.IsNegative() {
			return nil, errors.New("quantity_received / quantity_rejected must be >= 0")
		}
		pi, exists := byProduct[li.ProductID]
		if !exists {
			return nil, ErrNotFound
		}
		if pi.ReceivedQuantity.Add(received[li.ProductID]).Add(li.QuantityReceived).Add(li.QuantityRejected).GreaterThan(pi.Quantity) {
			return nil, errors.New("quantity_received + quantity_rejected exceeds outstanding purchase quantity")
		}
		if li.QuantityRejected.IsNegative() {
			return nil, errors.New("quantity_rejected must be >= 0")
		}
		it := &GRItem{
			GoodsReceiptID:      g.ID,
			PurchaseOrderItemID: pi.ID,
			ProductID:           li.ProductID,
			QuantityOrdered:     pi.Quantity,
			QuantityReceived:    li.QuantityReceived,
			QuantityRejected:    li.QuantityRejected,
			DefectNotes:         li.DefectNotes,
			BatchNumber:         li.BatchNumber,
			ExpiredDate:         li.ExpiredDate,
		}
		it.ID, err = s.store.CreateGRItem(ctx, tx, createGRItemParams(it))
		if err != nil {
			return nil, err
		}
		g.Items = append(g.Items, *it)
		if err := s.store.ReceiveStock(ctx, tx, db.RawStockAddParams{ProductID: li.ProductID, WarehouseID: in.WarehouseID, Quantity: li.QuantityReceived}, g.ID, it.ID, accrualUserID(ctx)); err != nil {
			return nil, err
		}
		if err := s.store.AddReceived(ctx, tx, db.AddReceivedQuantityParams{ID: pi.ID, ReceivedQuantity: li.QuantityReceived}); err != nil {
			return nil, err
		}
		received[li.ProductID] = received[li.ProductID].Add(li.QuantityReceived)
		// Retur pembelian (barang cacat ditolak): kurangi nilai PO. Sebelum ini
		// kolom qty_rejected di form hanya jadi catatan teks — PO tetap
		// berutang penuh atas barang yang tidak pernah diterima, dan tidak ada
		// catatan terstruktur atas penolakannya.
		if li.QuantityRejected.GreaterThan(decimal.Zero) {
			// PO unit price menang atas harga input: harga adalah kontrak PO.
			deduct := li.QuantityRejected.Mul(pi.UnitPrice)
			po.TotalAmount = po.TotalAmount.Sub(deduct)
			po.Subtotal = po.Subtotal.Sub(deduct)
			po.RemainingAmount = po.RemainingAmount.Sub(deduct)
			if po.RemainingAmount.IsNegative() {
				po.RemainingAmount = decimal.Zero
			}
			rejectedTotal = rejectedTotal.Add(deduct)
		}
	}
	if len(g.Items) == 0 {
		return nil, errors.New("at least one positive quantity_received is required")
	}

	// recompute PO status: full when every ordered qty is covered
	allFull := true
	for i := range poItems {
		acc := poItems[i].ReceivedQuantity.Add(received[poItems[i].ProductID])
		if acc.LessThan(poItems[i].Quantity) {
			allFull = false
			break
		}
	}
	status := "partial"
	if allFull {
		status = "received"
	}
	// Retur menurunkan nilai PO di header SEBELUM status disimpan: angka
	// hutang yang dilihat Finance berasal dari sini, jadi harus commit dalam
	// transaksi yang sama dengan penerimaan.
	if rejectedTotal.GreaterThan(decimal.Zero) {
		if err := s.store.UpdateOrder(ctx, tx, updateOrderParams(po)); err != nil {
			return nil, err
		}
	}
	if err := s.store.SetOrderStatus(ctx, tx, db.SetPurchaseOrderStatusParams{ID: poRow.ID, Status: status}); err != nil {
		return nil, err
	}
	if err := inventory.CompleteMovementRequest(ctx, tx, g); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	// Auto-draft the purchase-accrual journal AFTER commit (keputusan #4):
	// the GR + stock movement must never roll back because drafting failed.
	// DPP = Σ received qty × PO unit price; tax is prorated from the PO
	// header (tax/subtotal ratio) so the draft balances by construction.
	if s.accrual != nil && len(g.Items) > 0 {
		dpp := decimal.Zero
		for _, it := range g.Items {
			dpp = dpp.Add(byProduct[it.ProductID].UnitPrice.Mul(it.QuantityReceived))
		}
		tax := decimal.Zero
		if !poRow.Subtotal.IsZero() && !poRow.TaxAmount.IsZero() {
			tax = dpp.Mul(poRow.TaxAmount.Div(poRow.Subtotal)).Round(0)
		}
		userID := accrualUserID(ctx)
		if jn, err := s.accrual(ctx, GRAccrualInput{
			GRID: g.ID, GRNumber: g.GrNumber, PONumber: poRow.PoNumber, ReceivedDate: g.ReceivedDate,
			DPP: dpp, Tax: tax, Department: in.DepartmentCode,
		}, userID); err == nil {
			g.JournalNumber = jn
		} else {
			var uid any
			if userID != nil {
				uid = *userID
			}
			logger.FromContext(ctx).Warn("accrual draft failed (GR committed, journal missing)",
				"gr", g.GrNumber, "po", poRow.PoNumber, "user", uid, "error", err.Error())
		}
	}
	return g, nil
}

// accrualUserID extracts the acting user id for journal created_by; nil
// when unauthenticated (tests, system jobs).
func accrualUserID(ctx context.Context) *int64 {
	if u := auth.UserFrom(ctx); u != nil {
		id := u.ID
		return &id
	}
	return nil
}

func (s *Service) GetGR(ctx context.Context, id int64) (*GoodsReceipt, error) {
	row, err := s.store.GRByID(ctx, id)
	if err != nil {
		return nil, err
	}
	items, err := s.store.GRItems(ctx, id)
	if err != nil {
		return nil, err
	}
	g := grFromRow(row)
	g.Items = make([]GRItem, 0, len(items))
	for _, item := range items {
		g.Items = append(g.Items, grItemFromRow(item))
	}
	return g, nil
}

func (s *Service) ListGRs(ctx context.Context, f GRFilter) ([]GoodsReceipt, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	rows, total, err := s.store.ListGRs(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]GoodsReceipt, 0, len(rows))
	for _, row := range rows {
		out = append(out, grFromListRow(row))
	}
	return out, total, nil
}

// --- debt -------------------------------------------------------------------

func (s *Service) ListDebts(ctx context.Context, f DebtFilter) ([]DebtRow, int, DebtSummary, error) {
	if f.DueDate != "" {
		if err := validateDate("due_date", f.DueDate); err != nil {
			return nil, 0, DebtSummary{}, err
		}
	}
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	rows, count, summary, err := s.store.ListDebts(ctx, f)
	if err != nil {
		return nil, 0, DebtSummary{}, err
	}
	out := make([]DebtRow, 0, len(rows))
	for _, row := range rows {
		out = append(out, debtFromRow(row))
	}
	return out, count, summary, nil
}

// PayDebt applies a payment against a PO (§4.5). The parity category remains
// 'sales'; the cashflow carries the canonical purchase-order reference.
func (s *Service) PayDebt(ctx context.Context, poID int64, amount decimal.Decimal, voucherID *int64, userID *int64) (*PurchaseOrder, error) {
	if amount.LessThan(decimal.NewFromInt(1)) {
		return nil, errors.New("amount must be >= 1")
	}

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.OrderByIDForUpdate(ctx, tx, poID)
	if err != nil {
		return nil, err
	}
	o := orderFromLockedRow(row)
	// STATE MACHINE (bug sweep 2026-09): a PO has no enforceable debt before
	// approval, and a cancelled PO must never take money. Payment is money
	// AGAINST goods — it must also never block receiving, so a fully prepaid
	// PO keeps its lifecycle status instead of collapsing into a terminal
	// 'paid' that CreateGR rejects.
	switch o.Status {
	case "draft", "pending_approval", "cancelled":
		return nil, ErrPONotPayable
	}
	if amount.GreaterThan(o.RemainingAmount) {
		return nil, ErrPaymentTooLarge
	}
	var voucherNumber, cashAccountCode string
	if voucherID != nil {
		vrow, err := s.store.VoucherByIDForUpdate(ctx, tx, *voucherID)
		if err != nil {
			return nil, err
		}
		if vrow.Status != "available" {
			return nil, ErrVoucherUsed
		}
		voucherNumber = vrow.VoucherNumber
		cashAccountCode = orEmptyPgText(vrow.CashAccountCode)
	}
	if cashAccountCode == "" || cashAccountCode == "1000.01.01" {
		cashAccountCode = acctCashDefault
	}

	o.PaidAmount = o.PaidAmount.Add(amount)
	o.RemainingAmount = o.RemainingAmount.Sub(amount)
	if o.RemainingAmount.IsNegative() {
		o.RemainingAmount = decimal.Zero
	}
	// Payment status rides BEHIND the goods lifecycle: 'partial' flags an
	// outstanding balance without stealing the terminal state from
	// draft→approved→(partial→)received. Fully paid received/ cancelled POs
	// keep their lifecycle status; the debt list filters on remaining_amount,
	// so no 'paid' status is needed to clear a row.
	if o.Status != "received" && o.Status != "cancelled" {
		o.Status = "partial"
	}
	if err := s.store.UpdateOrder(ctx, tx, updateOrderParams(o)); err != nil {
		return nil, err
	}

	if voucherID != nil {
		if err := s.store.UpdateVoucherStatus(ctx, tx, db.UpdateVoucherStatusParams{ID: *voucherID, Status: "used"}); err != nil {
			return nil, err
		}
	}

	// Post the double-entry AP journal (debit 210102 Hutang Dagang Supplier /
	// credit the cash COA) as the last step before commit: if accounting
	// rejects or fails, the whole PO update rolls back with it.
	// accounting.Service commits its own transaction internally — CreateJournal
	// then PostJournal are the only exported composition points available
	// across the package boundary — so the one residual atomicity gap is a
	// failure of tx.Commit itself immediately below, which would leave a
	// posted journal against an uncommitted PO payment. That is judged an
	// acceptable, vanishingly rare tradeoff versus duplicating accounting's
	// journal-posting logic inside this package.
	if s.journal != nil {
		if _, err := s.journal(ctx, o.PoNumber, voucherNumber, cashAccountCode, amount, userID); err != nil {
			return nil, fmt.Errorf("posting jurnal pembayaran hutang: %w", err)
		}
	}

	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetOrder(ctx, poID)
}

// --- vouchers ---------------------------------------------------------------

func normalizeVoucherStatus(s string) (string, bool) {
	switch s {
	case "available", "pending":
		return "available", true
	case "used", "paid":
		return "used", true
	case "":
		return "", true
	default:
		return "", false
	}
}

func (s *Service) CreateVoucher(ctx context.Context, in VoucherInput) (*Voucher, error) {
	if in.Amount.IsNegative() {
		return nil, errors.New("amount must be >= 0")
	}
	if in.VoucherDate == "" {
		in.VoucherDate = time.Now().In(s.loc).Format("2006-01-02")
	}
	if err := validateDate("voucher_date", in.VoucherDate); err != nil {
		return nil, err
	}
	if in.Type == "" {
		in.Type = "payment"
	}
	// Voucher state transitions are server-owned (business-rules §27): a
	// new voucher always starts "available"; marking it used/paid happens
	// only through UpdateVoucherStatus (approval flow) or PayDebt
	// (allocation). The client-supplied status field is intentionally
	// ignored — a create with status=paid previously minted an
	// already-consumed voucher with no payment record behind it.
	_ = in.Status
	status := "available"
	voucherNumber, err := s.voucherCode(ctx)
	if err != nil {
		return nil, err
	}
	cashAccountCode := in.CashAccountCode
	if cashAccountCode == "" || cashAccountCode == "1000.01.01" {
		cashAccountCode = acctCashDefault
	}
	v := &Voucher{
		VoucherNumber:   voucherNumber,
		Type:            in.Type,
		Description:     in.Description,
		VoucherDate:     in.VoucherDate,
		Amount:          in.Amount,
		Status:          status,
		SupplierID:      in.SupplierID,
		POID:            in.POID,
		CashAccountCode: cashAccountCode,
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	// Resolve supplier_id from the PO server-side rather than trusting a
	// client-supplied value, so the voucher<->debt link can't be spoofed
	// to the wrong supplier when a po_id is given.
	if v.POID != nil {
		supplierID, err := s.store.PurchaseOrderSupplierID(ctx, tx, *v.POID)
		if err != nil {
			return nil, err
		}
		v.SupplierID = &supplierID
	}
	v.ID, err = s.store.CreateVoucher(ctx, tx, createVoucherParams(v))
	if err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return v, nil
}

func (s *Service) ListVouchers(ctx context.Context, f VoucherFilter) ([]Voucher, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	if f.Status != "" {
		if norm, ok := normalizeVoucherStatus(f.Status); ok {
			f.Status = norm
		} else {
			// unknown filter -> no results rather than leaking invalid query
			return []Voucher{}, 0, nil
		}
		if f.Status == "" {
			// alias mapped to empty (should not happen)
			f.Status = ""
		}
	}
	rows, total, err := s.store.ListVouchers(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]Voucher, 0, len(rows))
	for _, row := range rows {
		out = append(out, voucherFromListRow(row))
	}
	return out, total, nil
}

func (s *Service) UpdateVoucher(ctx context.Context, id int64, in VoucherInput) (*Voucher, error) {
	if in.Amount.IsNegative() {
		return nil, errors.New("amount must be >= 0")
	}
	if in.VoucherDate != "" {
		if err := validateDate("voucher_date", in.VoucherDate); err != nil {
			return nil, err
		}
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	existing, err := s.store.VoucherByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	v := voucherFromRow(db.GetVoucherByIDRow(existing))

	// patch fields if provided
	if in.Type != "" {
		v.Type = in.Type
	}
	if in.Description != nil {
		v.Description = in.Description
	}
	if in.VoucherDate != "" {
		v.VoucherDate = in.VoucherDate
	}
	v.Amount = in.Amount
	if in.Status != nil && *in.Status != "" {
		norm, ok := normalizeVoucherStatus(*in.Status)
		if !ok {
			return nil, errors.New("status must be available/used (pending/paid aliases accepted)")
		}
		v.Status = norm
	}
	if err := s.store.UpdateVoucher(ctx, tx, updateVoucherParams(v)); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetVoucher(ctx, id)
}

func (s *Service) UpdateVoucherStatus(ctx context.Context, id int64, status string) (*Voucher, error) {
	norm, ok := normalizeVoucherStatus(status)
	if !ok {
		return nil, errors.New("status must be available/used (pending/paid aliases accepted)")
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	if _, err := s.store.VoucherByIDForUpdate(ctx, tx, id); err != nil {
		return nil, err
	}
	if err := s.store.UpdateVoucherStatusAny(ctx, tx, db.UpdateVoucherStatusAnyParams{ID: id, Status: norm}); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return s.GetVoucher(ctx, id)
}

func stringPtr(value string) *string { return &value }

func (s *Service) GetVoucher(ctx context.Context, id int64) (*Voucher, error) {
	row, err := s.store.VoucherByID(ctx, id)
	if err != nil {
		return nil, err
	}
	return voucherFromRow(row), nil
}

// MarkVoucherPrinted records printed_at/printed_by on the first call; repeat
// calls are idempotent no-ops (audit/informational, never a hard reprint
// block) — the caller renders "already printed on {date} by {user} — reprint?".
func (s *Service) MarkVoucherPrinted(ctx context.Context, id, userID int64) (*Voucher, bool, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, false, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.VoucherByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, false, err
	}
	alreadyPrinted := row.PrintedAt.Valid
	if !alreadyPrinted {
		if err := s.store.MarkVoucherPrinted(ctx, tx, db.MarkVoucherPrintedParams{ID: id, PrintedBy: positiveInt8(userID)}); err != nil {
			return nil, false, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, false, err
	}
	v, err := s.GetVoucher(ctx, id)
	return v, alreadyPrinted, err
}

// --- codes ------------------------------------------------------------------

const (
	seqKeyPO = "po"
	seqKeyGR = "gr"
	seqKeyPV = "pv"
)

func (s *Service) poCode(ctx context.Context) (string, error) {
	return s.code(ctx, seqKeyPO, "PO")
}

func (s *Service) grCode(ctx context.Context) (string, error) {
	return s.code(ctx, seqKeyGR, "GR")
}

func (s *Service) voucherCode(ctx context.Context) (string, error) {
	return s.code(ctx, seqKeyPV, "PV")
}

// code allocates a monthly document number (PO-YYYYMM-0001, GR-YYYYMM-0001, etc.).
// Errors are propagated so an empty number can never be stored.
func (s *Service) code(ctx context.Context, seqKey, display string) (string, error) {
	now := time.Now().In(s.loc)
	n, err := s.seq.Sequence(ctx, seqKey, seq.MonthStart(now), nil)
	if err != nil {
		return "", fmt.Errorf("%s number sequence: %w", display, err)
	}
	return fmt.Sprintf("%s-%s-%04d", display, now.Format("20060102"), n), nil
}

func validateDate(field, value string) error {
	if _, err := time.Parse("2006-01-02", value); err != nil {
		return fmt.Errorf("%s must be YYYY-MM-DD", field)
	}
	return nil
}

func validateOptionalDate(field string, value *string) error {
	if value == nil || *value == "" {
		return nil
	}
	return validateDate(field, *value)
}
