package galon

import (
	"context"
	"errors"
	"fmt"
	"time"

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

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

// Service implements galon use cases.
type Service struct {
	store Store
	seq   Seq
	loc   *time.Location
	// prices returns the server-authoritative galon price list (settings).
	// Nil = feature off (tests, deployments without settings): client prices
	// pass through unchecked, the pre-fix behavior.
	prices PriceListSource
	// payments writes the money row when a transaction completes. Nil =
	// feature off (tests): completion flips status only, the pre-fix behavior.
	payments PaymentsWriter
	// journalFn posts the ledger entry when a transaction completes. Nil =
	// no journal (pre-journal behavior): money exists in payments, the buku
	// besar does not see galon.
	journalFn GalonJournalFn
	// Ledger accounts used by journalFn. Overridable for deployments whose
	// chart differs; defaults match the shipped chart of accounts.
	cashAccount    string
	qrisAccount    string
	revenueAccount string
	// qris charges and polls Midtrans for deferred-QRIS till transactions.
	qris QRISGateway
}

// Ledger account defaults (chart of accounts): Kas Tunai Toko for the cash
// side, Kas Bank / QRIS for the gateway side, Pendapatan Depot Galon Air for
// the revenue side, Uang Muka Penjualan / Deposit Customer for the deposit liability.
const (
	DefaultGalonCashAccount    = "1000.01.01"
	DefaultGalonQrisAccount    = "1000.02.01"
	DefaultGalonRevenueAccount = "4000.02"
	DefaultGalonDepositAcc     = "2000.02"
)

// PriceList is the server-authoritative galon price list (settings.GalonConfig,
// copied field-for-field so galon never imports the settings package).
type PriceList struct {
	HargaIsi   int
	HargaGalon int
	Deposit    int
	BiayaAntar int
}

// PriceListSource returns the current server price list.
type PriceListSource func() PriceList

// WithPrices wires the server price list (main.go adapts settings.Handler).
func (s *Service) WithPrices(fn PriceListSource) *Service {
	if fn != nil {
		s.prices = fn
	}
	return s
}

// PaymentsWriter persists the completion payment row. Implementations must
// accept the galon tx (db.DBTX) so the status flip + payment commit atomically.
type PaymentsWriter interface {
	CreatePayment(ctx context.Context, q db.DBTX, p db.CreatePaymentParams) (int64, error)
}

// WithPayments wires the payment writer (main.go provides finance repository).
func (s *Service) WithPayments(w PaymentsWriter) *Service {
	if w != nil {
		s.payments = w
	}
	return s
}

// serverUnitPrice resolves the authoritative unit price for a transaction:
// refill→harga isi, buy_galon→harga galon, buy+contents→galon+isi,
// borrow→0 (deposit only). Delivery adds biaya antar. No list wired →
// ok=false and the caller keeps the client value (pre-fix passthrough).
func serverUnitPrice(list *PriceList, typ, fulfillment string) (decimal.Decimal, bool) {
	if list == nil {
		return decimal.Zero, false
	}
	var base int
	switch typ {
	case TypeRefill:
		base = list.HargaIsi
	case TypeBuyGalon:
		base = list.HargaGalon
	case TypeBuyGalonPlusContent:
		base = list.HargaGalon + list.HargaIsi
	case TypeBorrowGalon:
		base = 0
	default:
		return decimal.Zero, false
	}
	if fulfillment == FulfillmentDelivery {
		base += list.BiayaAntar
	}
	return decimal.NewFromInt(int64(base)), true
}

func NewService(store Store, seq Seq, loc *time.Location) *Service {
	return &Service{
		store:          store,
		seq:            seq,
		loc:            loc,
		cashAccount:    DefaultGalonCashAccount,
		qrisAccount:    DefaultGalonQrisAccount,
		revenueAccount: DefaultGalonRevenueAccount,
	}
}

// WithQRIS wires the Midtrans QRIS charge and status checker.
func (s *Service) WithQRIS(gw QRISGateway) *Service {
	s.qris = gw
	return s
}

func isValidType(t string) bool {
	switch t {
	case TypeRefill, TypeBuyGalon, TypeBuyGalonPlusContent, TypeBorrowGalon:
		return true
	}
	return false
}

func isValidFulfillment(m string) bool {
	switch m {
	case FulfillmentPickup, FulfillmentDelivery:
		return true
	}
	return false
}

func isValidStatus(s string) bool {
	switch s {
	case "pending", "completed", "cancelled":
		return true
	}
	return false
}

// Create records a new galon transaction (§14). Deposit defaults to 0 until
// the business confirms the deposit/loan policy (§20 #3).
func (s *Service) Create(ctx context.Context, in TransactionInput) (*Transaction, error) {
	if in.CustomerID == 0 {
		return nil, errors.New("customer_id is required")
	}
	if !isValidType(in.Type) {
		return nil, errors.New("type must be refill, buy_galon, buy_galon_plus_contents or borrow_galon")
	}
	if !isValidFulfillment(in.FulfillmentMode) {
		return nil, errors.New("fulfillment_mode must be pickup or delivery")
	}
	if in.Quantity.LessThanOrEqual(decimal.Zero) {
		return nil, errors.New("quantity must be > 0")
	}
	if in.UnitPrice.IsNegative() {
		return nil, errors.New("unit_price must be >= 0")
	}
	if in.DepositAmount.IsNegative() {
		return nil, errors.New("deposit_amount must be >= 0")
	}
	// Server-authoritative pricing: the client sends a price for display, but
	// the stored unit_price comes from the settings price list. Overwrite
	// (never reject) so older POS/B2B clients sending stale prices keep
	// working and the money stays correct. Deposit is untouched: its
	// liability semantics are still pending business confirmation (§20 #3).
	if s.prices != nil {
		list := s.prices()
		if p, ok := serverUnitPrice(&list, in.Type, in.FulfillmentMode); ok {
			in.UnitPrice = p
		}
	}

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

	ok, err := s.store.CustomerExists(ctx, tx, in.CustomerID)
	if err != nil {
		return nil, err
	}
	if !ok {
		return nil, errors.New("customer not found")
	}
	if in.ProductID != nil && *in.ProductID > 0 {
		pok, err := s.store.ProductExists(ctx, tx, *in.ProductID)
		if err != nil {
			return nil, err
		}
		if !pok {
			return nil, errors.New("product not found")
		}
	}

	number, err := s.txCode(ctx, tx)
	if err != nil {
		return nil, err
	}
	bound, ok := auth.StoreScopeFrom(ctx).ResolveStoreBinding(in.StoreID)
	if !ok {
		return nil, ErrNotFound
	}
	in.StoreID = bound
	row, err := s.store.CreateTransaction(ctx, tx, db.CreateGalonTransactionParams{
		TransactionNumber: number,
		CustomerID:        in.CustomerID,
		Type:              in.Type,
		FulfillmentMode:   in.FulfillmentMode,
		ProductID:         int8OrNil(in.ProductID),
		Quantity:          in.Quantity,
		UnitPrice:         in.UnitPrice,
		DepositAmount:     in.DepositAmount,
		Status:            "pending",
		Notes:             textOrNil(in.Notes),
		CreatedBy:         int8OrNil(in.CreatedBy),
		StoreID:           int8OrNil(in.StoreID),
		GallonCode:        textOrNil(in.GallonCode),
	})
	if err != nil {
		return nil, err
	}
	// Borrowed containers are a loan secured by the deposit (§20 #3 default):
	// record the loan value and that it is not yet returned.
	if in.Type == TypeBorrowGalon {
		r, err := s.store.SetGalonLoan(ctx, tx, db.SetGalonLoanParams{
			ID:           row.ID,
			LoanAmount:   in.DepositAmount,
			LoanReturned: false,
		})
		if err != nil {
			return nil, err
		}
		row = r
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	t := fromRow(row)
	return &t, nil
}

// Get returns a galon transaction by ID.
func (s *Service) Get(ctx context.Context, id int64) (*Transaction, error) {
	row, err := s.store.GetByID(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	if sc := auth.StoreScopeFrom(ctx); !visibleIn(sc, row.StoreID) {
		return nil, ErrNotFound
	}
	t := fromRow(row)
	return &t, nil
}

// Complete moves pending -> completed with default cash tender.
func (s *Service) Complete(ctx context.Context, id int64) (*Transaction, error) {
	return s.transitionWithPayment(ctx, id, "completed", "cash", "")
}

// CompleteWithPayment moves pending -> completed with explicit paymentMethod ('qris', 'transfer', etc.) and ref.
func (s *Service) CompleteWithPayment(ctx context.Context, id int64, paymentMethod, refNum string) (*Transaction, error) {
	return s.transitionWithPayment(ctx, id, "completed", paymentMethod, refNum)
}

// Cancel moves pending -> cancelled.
func (s *Service) Cancel(ctx context.Context, id int64) (*Transaction, error) {
	return s.transitionWithPayment(ctx, id, "cancelled", "", "")
}

// RequestQRISCharge requests a dynamic Midtrans QR code for an open pending galon order.
func (s *Service) RequestQRISCharge(ctx context.Context, id int64) (*QRISResult, error) {
	if s.qris == nil {
		return nil, errors.New("payment gateway is disabled")
	}
	row, err := s.store.GetByID(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	if sc := auth.StoreScopeFrom(ctx); !visibleIn(sc, row.StoreID) {
		return nil, ErrNotFound
	}
	if row.Status != "pending" {
		return nil, ErrInvalidStatus
	}
	gross := row.Quantity.Mul(row.UnitPrice).Add(row.DepositAmount)
	return s.qris.ChargeQRIS(ctx, row.TransactionNumber, gross)
}

// PaymentStatus polls Midtrans for deferred-QRIS settlement and auto-settles when paid.
func (s *Service) PaymentStatus(ctx context.Context, id int64) (*GalonPaymentStatusResult, error) {
	row, err := s.store.GetByID(ctx, nil, id)
	if err != nil {
		return nil, err
	}
	if sc := auth.StoreScopeFrom(ctx); !visibleIn(sc, row.StoreID) {
		return nil, ErrNotFound
	}
	if row.Status == "completed" {
		t := fromRow(row)
		return &GalonPaymentStatusResult{
			OrderStatus:   "completed",
			PaymentStatus: "settled",
			Transaction:   &t,
		}, nil
	}
	if row.Status == "cancelled" {
		t := fromRow(row)
		return &GalonPaymentStatusResult{
			OrderStatus:   "cancelled",
			PaymentStatus: "failed",
			Transaction:   &t,
		}, nil
	}
	if s.qris == nil {
		t := fromRow(row)
		return &GalonPaymentStatusResult{
			OrderStatus:   row.Status,
			PaymentStatus: "pending",
			Transaction:   &t,
		}, nil
	}
	action, txnID, err := s.qris.CheckQRISStatus(ctx, row.TransactionNumber)
	if err != nil {
		return nil, err
	}
	if action == "settled" {
		t, err := s.CompleteWithPayment(ctx, id, "qris", txnID)
		if err != nil {
			return nil, err
		}
		return &GalonPaymentStatusResult{
			OrderStatus:   "completed",
			PaymentStatus: "settled",
			Transaction:   t,
		}, nil
	}
	if action == "failed" {
		t, err := s.Cancel(ctx, id)
		if err != nil {
			return nil, err
		}
		return &GalonPaymentStatusResult{
			OrderStatus:   "cancelled",
			PaymentStatus: "failed",
			Transaction:   t,
		}, nil
	}
	t := fromRow(row)
	return &GalonPaymentStatusResult{
		OrderStatus:   "pending",
		PaymentStatus: "pending",
		Transaction:   &t,
	}, nil
}

func (s *Service) transition(ctx context.Context, id int64, to string) (*Transaction, error) {
	return s.transitionWithPayment(ctx, id, to, "cash", "")
}

func (s *Service) transitionWithPayment(ctx context.Context, id int64, to string, paymentMethod, refNum string) (*Transaction, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.GetByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if sc := auth.StoreScopeFrom(ctx); !visibleIn(sc, row.StoreID) {
		return nil, ErrNotFound
	}
	if row.Status != "pending" {
		return nil, ErrInvalidStatus
	}
	if err := s.store.SetStatus(ctx, tx, id, to); err != nil {
		return nil, err
	}
	// Money moment: completing the handover moves real money, so the status
	// flip and the payment row commit atomically. Only 'completed' pays;
	// cancel pays nothing. Zero-amount completions (borrow with no price)
	// write no row. Re-completing is impossible (status gate above), so the
	// payment cannot double-post.
	if to == "completed" && s.payments != nil {
		amount := row.Quantity.Mul(row.UnitPrice)
		if amount.GreaterThan(decimal.Zero) {
			if err := s.recordCompletionPaymentWithMethod(ctx, tx, row,
				GalonPayableType, amount,
				"Otomatis: galon "+row.TransactionNumber+" selesai", paymentMethod, refNum); err != nil {
				return nil, err
			}
		}
		if row.DepositAmount.GreaterThan(decimal.Zero) {
			if err := s.recordCompletionPaymentWithMethod(ctx, tx, row,
				GalonDepositPayableType, row.DepositAmount,
				"Otomatis: deposit galon "+row.TransactionNumber+" diterima", paymentMethod, refNum); err != nil {
				return nil, err
			}
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}

	// Ledger moment: after the money rows commit, post the journal.
	if to == "completed" && s.journalFn != nil {
		amount := row.Quantity.Mul(row.UnitPrice)
		rev := 0.0
		if amount.GreaterThan(decimal.Zero) {
			f, _ := amount.Float64()
			rev = f
		}
		dep := 0.0
		if row.DepositAmount.GreaterThan(decimal.Zero) {
			f, _ := row.DepositAmount.Float64()
			dep = f
		}
		if rev > 0 || dep > 0 {
			cashAcc := s.cashAccount
			if paymentMethod == "qris" {
				cashAcc = s.qrisAccount
				if cashAcc == "" {
					cashAcc = DefaultGalonQrisAccount
				}
			}
			if _, jerr := s.journalFn(ctx, GalonJournalInput{
				TransactionNumber: row.TransactionNumber,
				Type:              row.Type,
				CashAcc:           cashAcc,
				Revenue:           rev,
				DepositAmount:     dep,
				RevenueAccount:    s.revenueAccount,
				DepositAccount:    DefaultGalonDepositAcc,
			}); jerr != nil {
				return nil, jerr
			}
		}
	}

	return s.Get(ctx, id)
}

// GalonPayableType identifies galon completion payments in the morph payments
// table. A plain string (not a Laravel class): galon has no Eloquent model,
// and inventing one would fake a join that does not exist.
const GalonPayableType = "galon"

// GalonDepositPayableType separates the deposit from the sale.
//
// A deposit is not revenue and must never be added into what the store earned
// — it is customer money the store holds and gives back. Recording it under
// the same payable_type as the item payment makes the two indistinguishable in
// any report that groups by payable, so the liability cannot be stated: the
// number of galons on loan would be wrong, and so would the cash.
//
// It is a separate row rather than a second amount on the first, for the same
// reason: the refund is a different event at a different time, and netting
// them destroys the trail of when the money came in and when it went back out.
const GalonDepositPayableType = "galon_deposit"

// recordCompletionPayment writes one money row against a galon transaction with default cash tender.
func (s *Service) recordCompletionPayment(ctx context.Context, tx Tx, row db.GalonTransaction, payableType string, amount decimal.Decimal, notes string) error {
	return s.recordCompletionPaymentWithMethod(ctx, tx, row, payableType, amount, notes, "cash", "")
}

// recordCompletionPaymentWithMethod writes one money row with explicit payment method ('qris', 'transfer', etc.).
func (s *Service) recordCompletionPaymentWithMethod(ctx context.Context, tx Tx, row db.GalonTransaction, payableType string, amount decimal.Decimal, notes string, paymentMethod string, refNum string) error {
	n, err := s.seq.Sequence(ctx, "pay", seq.MonthStart(time.Now().In(s.loc)), tx)
	if err != nil {
		return fmt.Errorf("galon payment number sequence: %w", err)
	}
	var createdBy *int64
	if u := auth.UserFrom(ctx); u != nil && u.ID != 0 {
		id := u.ID
		createdBy = &id
	}
	if paymentMethod == "" {
		paymentMethod = "cash"
	}
	_, err = s.payments.CreatePayment(ctx, tx, db.CreatePaymentParams{
		PaymentNumber:   fmt.Sprintf("PAY-%s-%06d", time.Now().In(s.loc).Format("200601"), n),
		PayableType:     payableType,
		PayableID:       row.ID,
		PaymentDate:     pgtype.Date{Time: time.Now().In(s.loc), Valid: true},
		Amount:          amount,
		PaymentMethod:   paymentMethod,
		ReferenceNumber: pgtype.Text{String: refNum, Valid: refNum != ""},
		Notes:           pgtype.Text{String: notes, Valid: true},
		CreatedBy:       int8OrNil(createdBy),
	})
	return err
}

// ReturnGalon records that a borrow_galon transaction's containers were
// returned (§20 #3 default). It sets loan_returned=true; the deposit refund is
// handled by the finance flow that calls this. Only borrow_galon can be returned.
func (s *Service) ReturnGalon(ctx context.Context, id int64) (*Transaction, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	row, err := s.store.GetByIDForUpdate(ctx, tx, id)
	if err != nil {
		return nil, err
	}
	if sc := auth.StoreScopeFrom(ctx); !visibleIn(sc, row.StoreID) {
		return nil, ErrNotFound
	}
	if row.Type != TypeBorrowGalon {
		return nil, errors.New("only borrow_galon transactions can be returned")
	}
	if row.LoanReturned {
		return nil, errors.New("loan already returned")
	}
	r, err := s.store.SetGalonLoan(ctx, tx, db.SetGalonLoanParams{
		ID:           id,
		LoanAmount:   row.LoanAmount,
		LoanReturned: true,
	})
	if err != nil {
		return nil, err
	}
	// Liability cleared: the deposit collected at Complete goes back to the
	// customer now. Recorded as a negative-amount galon_deposit payment row
	// (payments.amount has no CHECK) so Neraca nets it while revenue reports
	// keyed on payable_type keep excluding it. Same tx as the loan flip;
	// the LoanReturned gate above makes double-refund impossible.
	if row.DepositAmount.GreaterThan(decimal.Zero) && s.payments != nil {
		n, err := s.seq.Sequence(ctx, "pay", seq.MonthStart(time.Now().In(s.loc)), tx)
		if err != nil {
			return nil, fmt.Errorf("galon refund number sequence: %w", err)
		}
		var createdBy *int64
		if u := auth.UserFrom(ctx); u != nil && u.ID != 0 {
			id := u.ID
			createdBy = &id
		}
		if _, err := s.payments.CreatePayment(ctx, tx, db.CreatePaymentParams{
			PaymentNumber: fmt.Sprintf("PAY-%s-%06d", time.Now().In(s.loc).Format("200601"), n),
			PayableType:   GalonDepositPayableType,
			PayableID:     row.ID,
			PaymentDate:   pgtype.Date{Time: time.Now().In(s.loc), Valid: true},
			Amount:        row.DepositAmount.Neg(),
			PaymentMethod: "cash",
			Notes:         pgtype.Text{String: "Refund deposit galon " + row.TransactionNumber, Valid: true},
			CreatedBy:     int8OrNil(createdBy),
		}); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	t := fromRow(r)
	return &t, nil
}

// List returns a paginated list of galon transactions.
func (s *Service) List(ctx context.Context, f Filter) ([]Transaction, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	filter := db.CountGalonTransactionsParams{
		Status:     textOrNilStr(f.Status),
		Type:       textOrNilStr(f.Type),
		CustomerID: int8OrZero(f.CustomerID),
	}
	// Tenant isolation (§5.4): restricted callers only see their stores'
	// galon transactions.
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		filter.AllowedStoreIds = sc.IDs()
	}
	total, err := s.store.Count(ctx, nil, filter)
	if err != nil {
		return nil, 0, err
	}
	rows, err := s.store.List(ctx, nil, db.ListGalonTransactionsParams{
		Limit:           int32(f.PerPage),
		Offset:          int32((f.Page - 1) * f.PerPage),
		Status:          filter.Status,
		Type:            filter.Type,
		CustomerID:      filter.CustomerID,
		AllowedStoreIds: filter.AllowedStoreIds,
	})
	if err != nil {
		return nil, 0, err
	}
	out := make([]Transaction, 0, len(rows))
	for _, r := range rows {
		out = append(out, fromRow(r))
	}
	return out, int(total), nil
}

// GallonInfo returns the barcode-tracker summary for one physical gallon.
func (s *Service) GallonInfo(ctx context.Context, code string) (*GallonInfo, error) {
	sc := auth.StoreScopeFrom(ctx)
	var ids []int64
	if !sc.PlatformWide() {
		ids = sc.IDs()
	}
	return s.store.GallonInfoByCode(ctx, nil, code, ids)
}

// GallonHistory returns the recent transactions bound to a physical gallon.
func (s *Service) GallonHistory(ctx context.Context, code string) ([]Transaction, error) {
	sc := auth.StoreScopeFrom(ctx)
	var ids []int64
	if !sc.PlatformWide() {
		ids = sc.IDs()
	}
	rows, err := s.store.TransactionsByCode(ctx, nil, code, ids)
	if err != nil {
		return nil, err
	}
	out := make([]Transaction, 0, len(rows))
	for _, r := range rows {
		out = append(out, fromRow(r))
	}
	return out, nil
}

// txCode allocates the monthly galon transaction number (§17-style monthly
// sequence): GLN-YYYYMM-NNNNNN.
func (s *Service) txCode(ctx context.Context, tx any) (string, error) {
	n, err := s.seq.Sequence(ctx, "gln", seq.MonthStart(time.Now().In(s.loc)), tx)
	if err != nil {
		return "", fmt.Errorf("galon number sequence: %w", err)
	}
	return fmt.Sprintf("GLN-%s-%06d", time.Now().In(s.loc).Format("200601"), n), nil
}

func fromRow(r db.GalonTransaction) Transaction {
	return Transaction{
		ID:                r.ID,
		TransactionNumber: r.TransactionNumber,
		CustomerID:        r.CustomerID,
		Type:              r.Type,
		FulfillmentMode:   r.FulfillmentMode,
		ProductID:         int8ToPtr(r.ProductID),
		Quantity:          r.Quantity,
		UnitPrice:         r.UnitPrice,
		DepositAmount:     r.DepositAmount,
		LoanAmount:        r.LoanAmount,
		LoanReturned:      r.LoanReturned,
		Status:            r.Status,
		Notes:             textToStrPtr(r.Notes),
		CreatedBy:         int8ToPtr(r.CreatedBy),
		StoreID:           int8ToPtr(r.StoreID),
		GallonCode:        textToStrPtr(r.GallonCode),
		CreatedAt:         r.CreatedAt.Time,
		UpdatedAt:         r.UpdatedAt.Time,
	}
}

// visibleIn reports whether a fetched row lies within the caller's store
// scope (§5.4). NULL-store rows are platform-level: platform-wide scopes only.
func visibleIn(sc auth.StoreScope, storeID pgtype.Int8) bool {
	return sc.PlatformWide() || sc.Allows(int8ToPtr(storeID))
}

// --- pgtype helpers ----------------------------------------------------------

func int8OrNil(v *int64) pgtype.Int8 {
	if v == nil {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: *v, Valid: true}
}

func int8OrZero(v int64) pgtype.Int8 {
	if v <= 0 {
		return pgtype.Int8{}
	}
	return pgtype.Int8{Int64: v, Valid: true}
}

func int8ToPtr(v pgtype.Int8) *int64 {
	if !v.Valid {
		return nil
	}
	x := v.Int64
	return &x
}

func textOrNil(v *string) pgtype.Text {
	if v == nil {
		return pgtype.Text{}
	}
	return pgtype.Text{String: *v, Valid: true}
}

func textOrNilStr(v string) pgtype.Text {
	if v == "" {
		return pgtype.Text{}
	}
	return pgtype.Text{String: v, Valid: true}
}

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

// InventoryStatus backs GET /galon/status.
//
// This used to be a literal in the handler:
//
//	status := map[string]int64{"diToko": 45, "dipinjam": 12, "pengiriman": 3}
//
// The POS renders those as stat tiles on its main screen, so a cashier saw a
// store holding ~60 galons while the table held three galon transactions
// in total. Nothing errored and nothing looked wrong; the numbers were simply
// invented, and a number on a dashboard reads as a measurement.
//
// Two of the three are derivable and are now computed, tenant-scoped the same
// way List is. The third is not, and is returned as null rather than guessed —
// see InventoryStatus.AtStore. A wrong number in a live count is worse than no
// number, because there is nothing on screen to tell the two apart.
func (s *Service) InventoryStatus(ctx context.Context) (InventoryStatus, error) {
	var allowed []int64
	if sc := auth.StoreScopeFrom(ctx); !sc.PlatformWide() {
		allowed = sc.IDs()
	}
	row, err := s.store.InventoryStatus(ctx, nil, allowed)
	if err != nil {
		return InventoryStatus{}, err
	}
	return InventoryStatus{
		AtStore:    nil, // no unit registry exists; see the type's doc comment
		OnLoan:     row.OutstandingLoans,
		InDelivery: row.InDelivery,
	}, nil
}
