package lhpp

import (
	"context"
	"errors"
	"fmt"
	"sort"
	"strconv"
	"strings"

	"github.com/shopspring/decimal"

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

var (
	ErrNotFound           = errors.New("lhpp tidak ditemukan")
	ErrGroupNotFound      = errors.New("LHPP tidak ditemukan")
	ErrDOTidakDitemukan   = errors.New("DO tidak ditemukan dalam LHPP ini")
	ErrInvoiceBelumTerbit = errors.New("invoice untuk DO ini belum terbit")
	ErrInvalidAmount      = errors.New("nominal pembayaran tidak valid")
	ErrAmountExceedsSisa  = errors.New("nominal melebihi sisa tagihan DO ini")
	ErrDriverRequired     = errors.New("driver_id wajib diisi")

	ErrInvoiceTidakDitemukan = errors.New("invoice tidak ditemukan dalam LHPP ini")
	ErrNoEmployee            = errors.New("employee record required")
)

// encodeGroupID encodes a SALES LHPP group's synthetic id as
// (YYYYMMDD * groupIDDriverSpace) + salesID. Sales LHPP is still computed on
// read from sales_visits (it is a scheduled-visit worksheet, not a delivery
// order sheet), so it has no table and keeps a derived id. The driver-side LHPP
// groups are PERSISTED and use lhpp_groups.id instead — see the package doc.
//
// groupIDDriverSpace is sized to 1_000_000 so that (YYYYMMDD * 1_000_000 + salesID)
// fits safely within JavaScript's Number.MAX_SAFE_INTEGER (9,007,199,254,740,991).
// The legacy 1e9 multiplier created 17-digit integers exceeding 53-bit float precision,
// causing browser JSON.parse to truncate the lowest digits and 404 on GET /lhpp/sales/{id}.
const groupIDDriverSpace = 1_000_000
const legacyGroupIDDriverSpace = 1_000_000_000

// WarehouseDepositLookup is the slice of warehousedeposit.Service this
// package needs: given a Setoran's id (as linked from payments.deposit_id),
// fetch its status so LHPP can show whether that cash has reached the
// cashier yet.
type WarehouseDepositLookup interface {
	Get(ctx context.Context, id int64) (*warehousedeposit.WarehouseDeposit, error)
}

// EmployeeLookup resolves an authenticated user id to their employee id —
// the same seam salestracking.Service uses, kept as a separate type here to
// avoid importing that package just for one function signature.
type EmployeeLookup func(ctx context.Context, userID int64) (int64, error)

// Service maintains LHPP groups and posts payments through the invoice ledger
// (finance.Service.PayInvoice) — see package doc for the full picture.
//
// Reads never write. List/Get only fold rows that already belong to a group, so
// a GET can not 409 or 500 on a grouping conflict; every membership change goes
// through Merge/Split/Void/ChangeDriver or the dispatch seam, each in its own
// transaction.
type Service struct {
	repo     *Repository
	finance  *finance.Service
	deposits WarehouseDepositLookup
	lookup   EmployeeLookup
}

func NewService(repo *Repository, financeSvc *finance.Service) *Service {
	return &Service{repo: repo, finance: financeSvc}
}

// WithWarehouseDeposits wires the Setoran Tunai Driver lookup in — set from
// main.go after warehousedeposit.Service is constructed (it depends on
// cashier.Service, which is built later than lhpp.Service). Deposit status
// enrichment is skipped entirely when this is never called.
func (s *Service) WithWarehouseDeposits(d WarehouseDepositLookup) {
	s.deposits = d
}

// WithEmployeeLookup wires the user->employee resolver, needed by
// MySalesGroups (GET /lhpp/sales/me) to scope results to the caller.
func (s *Service) WithEmployeeLookup(fn EmployeeLookup) *Service {
	if fn != nil {
		s.lookup = fn
	}
	return s
}

func encodeGroupID(driverID int64, doDate string) int64 {
	compact := strings.ReplaceAll(doDate, "-", "")
	dateInt, _ := strconv.ParseInt(compact, 10, 64)
	return dateInt*groupIDDriverSpace + driverID
}

type groupAccumulator struct {
	groupID     int64
	lhppNumber  string
	lhppDate    string
	groupStatus string
	driverID    int64
	driverName  string
	dateFrom    string
	dateTo      string
	fleetName   string
	plate       string
	items       []Item

	// Sheet handover (000149): carried from the first member row — every row
	// of a group repeats the same lhpp_groups header columns, so the seed
	// holds the truth and later rows need no comparison.
	assignedTo   *int64
	assigneeName string
	assignedAt   string

	// hasCancelled / hasCollectable track which invoices this group carries:
	// a cancelled one contributes nothing to the totals, so a group made up
	// entirely of cancellations is "dibatalkan" rather than any payment
	// status. hasCollectable covers both a live invoice and a DO with no
	// invoice yet — a not-yet-invoiced DO is still a real pending receivable,
	// so it keeps the group at "belum_ditagih" instead of "dibatalkan".
	hasCancelled   bool
	hasCollectable bool
}

func (s *Service) buildGroups(ctx context.Context) ([]Group, error) {
	rows, err := s.repo.CandidateRows(ctx)
	if err != nil {
		return nil, err
	}
	return foldCandidateRows(rows), nil
}

// invoiceCancelled is the invoices.status value sales' CancelOrderInvoices
// writes when it voids an order. That query zeroes remaining_amount but
// leaves total_amount alone, so a cancelled row still carries the full billed
// figure — which is exactly why it must not be summed into an LHPP total.
const invoiceCancelled = "cancelled"

// foldCandidateRows turns the flat candidate rows into LHPP sheets. Split out
// of buildGroups (and free of any repository dependency) so the aggregation
// rules below are testable from fixture rows without a database.
//
// Grouping is by lhpp_groups.id — the persisted sheet — NOT by driver+date.
// That is the whole point of the rework: two DOs for one driver on one day are
// two sheets until a human merges them, and merging can span days.
//
// date_from/date_to are the min/max do_date across the members and are NOT
// stored: they change on every merge, so persisting them would guarantee drift.
// The vehicle fields (fleet_name, plate_number) are the EARLIEST member's — a
// multi-day sheet is driven by the vehicle that started the route, and showing
// the last member's instead would silently relabel the truck.
//
// Cancellation rule: a cancelled invoice contributes ZERO to Tagihan,
// Terbayar and Sisa. A written-off debt is not a collectable amount, and
// summing it would make the group look paid (its total_amount is still > 0
// while remaining_amount was zeroed). InvoiceID/InvoiceNumber are still
// carried through so the sheet records *which* invoice was written off
// instead of pretending the DO was never invoiced; Sisa stays 0, so
// RecordPayment rejects any attempt to collect against it.
func foldCandidateRows(rows []db.ListLHPPCandidateRowsRow) []Group {
	var order []int64
	acc := map[int64]*groupAccumulator{}
	for _, r := range rows {
		// driver_id is NOT NULL on lhpp_groups, so a row that reaches here
		// always has one; GroupID == 0 would mean the inner join did not hold,
		// which cannot happen, and is skipped rather than folded into a sheet
		// with no identity.
		if r.GroupID == 0 {
			continue
		}
		g, ok := acc[r.GroupID]
		if !ok {
			g = &groupAccumulator{
				groupID:     r.GroupID,
				lhppNumber:  r.LhppNumber,
				lhppDate:    r.LhppDate,
				groupStatus: r.GroupStatus,
				driverID:    r.DriverID,
				driverName:  r.DriverName,
				dateFrom:    r.DoDate,
				dateTo:      r.DoDate,
				fleetName:   r.FleetName,
				plate:       r.PlateNumber,
			}
			if r.AssignedTo.Valid {
				a := r.AssignedTo.Int64
				g.assignedTo = &a
				g.assigneeName = r.AssigneeName
				g.assignedAt = r.AssignedAt
			}
			acc[r.GroupID] = g
			order = append(order, r.GroupID)
		}
		// Widen the day range, and re-home the vehicle fields to the earliest
		// member when this row beats the one that seeded them. The query
		// orders members by do_date ASC, so the seed is the earliest, but the
		// comparison keeps the fold correct for any input order.
		if r.DoDate < g.dateFrom {
			g.dateFrom = r.DoDate
			g.fleetName = r.FleetName
			g.plate = r.PlateNumber
		}
		if r.DoDate > g.dateTo {
			g.dateTo = r.DoDate
		}

		item := Item{
			DOID:            r.DoID,
			DONumber:        r.DoNumber,
			CustomerName:    r.CustomerName,
			ShippingAddress: r.CustomerAddress,
			Tagihan:         decimal.Zero,
			Terbayar:        decimal.Zero,
			Sisa:            decimal.Zero,
		}
		if r.InvoiceID.Valid {
			invID := r.InvoiceID.Int64
			item.InvoiceID = &invID
			if r.InvoiceNumber.Valid {
				num := r.InvoiceNumber.String
				item.InvoiceNumber = &num
			}
			if r.InvoiceStatus.Valid && r.InvoiceStatus.String == invoiceCancelled {
				// Written off: leave Tagihan/Terbayar/Sisa at zero (see
				// foldCandidateRows) but remember the invoice existed.
				g.hasCancelled = true
			} else {
				g.hasCollectable = true
				if r.TotalAmount.Valid {
					item.Tagihan = r.TotalAmount.Decimal
				}
				if r.PaidAmount.Valid {
					item.Terbayar = r.PaidAmount.Decimal
				}
				if r.RemainingAmount.Valid {
					item.Sisa = r.RemainingAmount.Decimal
				}
			}
		} else {
			g.hasCollectable = true
		}
		g.items = append(g.items, item)
	}

	groups := make([]Group, 0, len(order))
	for _, id := range order {
		g := acc[id]
		totalTagihan, totalTerbayar, totalSisa := decimal.Zero, decimal.Zero, decimal.Zero
		for _, it := range g.items {
			totalTagihan = totalTagihan.Add(it.Tagihan)
			totalTerbayar = totalTerbayar.Add(it.Terbayar)
			totalSisa = totalSisa.Add(it.Sisa)
		}
		status := StatusBelumDitagih
		switch {
		case g.hasCancelled && !g.hasCollectable:
			// Every DO in this group had its invoice written off — nothing
			// left to collect, and "belum_ditagih" would be a false claim
			// that invoicing is still pending.
			status = StatusDibatalkan
		case totalTagihan.IsPositive() && totalSisa.IsZero():
			status = StatusLunas
		case totalTerbayar.IsPositive():
			status = StatusSebagian
		}
		groups = append(groups, Group{
			ID:            g.groupID,
			LHPPNumber:    g.lhppNumber,
			LHPPDate:      g.lhppDate,
			DateFrom:      g.dateFrom,
			DateTo:        g.dateTo,
			DriverID:      g.driverID,
			DriverName:    g.driverName,
			FleetName:     g.fleetName,
			PlateNumber:   g.plate,
			DOCount:       len(g.items),
			TotalTagihan:  totalTagihan,
			TotalTerbayar: totalTerbayar,
			TotalSisa:     totalSisa,
			Status:        status,
			GroupStatus:   g.groupStatus,
			Items:         g.items,
			AssignedTo:    g.assignedTo,
			AssigneeName:  g.assigneeName,
			AssignedAt:    g.assignedAt,
		})
	}

	sort.SliceStable(groups, func(i, j int) bool {
		if groups[i].LHPPDate != groups[j].LHPPDate {
			return groups[i].LHPPDate > groups[j].LHPPDate
		}
		return groups[i].ID > groups[j].ID
	})
	return groups
}

// List returns the paginated LHPP header rows (no item detail) for GET /lhpp.
func (s *Service) List(ctx context.Context, f ListFilter) ([]Group, int, error) {
	groups, err := s.buildGroups(ctx)
	if err != nil {
		return nil, 0, err
	}

	search := strings.ToLower(strings.TrimSpace(f.Search))
	filtered := make([]Group, 0, len(groups))
	for _, g := range groups {
		if f.Status != "" && g.Status != f.Status {
			continue
		}
		if search != "" &&
			!strings.Contains(strings.ToLower(g.LHPPNumber), search) &&
			!strings.Contains(strings.ToLower(g.DriverName), search) {
			continue
		}
		g.Items = nil
		filtered = append(filtered, g)
	}

	total := len(filtered)
	page := f.Page
	if page < 1 {
		page = 1
	}
	perPage := f.PerPage
	if perPage < 1 || perPage > 100 {
		perPage = 15
	}
	start := (page - 1) * perPage
	if start > total {
		start = total
	}
	end := start + perPage
	if end > total {
		end = total
	}
	pageItems := filtered[start:end]
	for i := range pageItems {
		s.enrichDeposit(ctx, &pageItems[i])
	}
	return pageItems, total, nil
}

// Get returns one LHPP group with its item detail for GET /lhpp/{id}.
//
// A voided group is still reachable by id (with no items — a void releases its
// DOs for re-batching) so an old LHPP number stays auditable. The extra header
// query runs only on the miss path, never for the normal active-sheet read.
func (s *Service) Get(ctx context.Context, id int64) (*Group, error) {
	groups, err := s.buildGroups(ctx)
	if err != nil {
		return nil, err
	}
	for i := range groups {
		if groups[i].ID == id {
			g := groups[i]
			s.enrichDeposit(ctx, &g)
			return &g, nil
		}
	}
	row, err := s.repo.GroupByID(ctx, s.repo.q, id)
	if err != nil {
		return nil, ErrNotFound
	}
	g := Group{
		ID:          row.ID,
		LHPPNumber:  row.LhppNumber,
		LHPPDate:    row.LhppDate,
		DateFrom:    row.LhppDate,
		DateTo:      row.LhppDate,
		DriverID:    row.DriverID,
		Status:      StatusBelumDitagih,
		GroupStatus: row.Status,
	}
	return &g, nil
}

// depositRank orders deposit statuses so the worst case (least progressed)
// wins when an item has more than one cash/giro payment in different states.
var depositRank = map[string]int{"belum_disetor": 3, "pending": 2, "received": 1}

// enrichDeposit fills in Setoran Tunai Driver status for one group's items
// (and rolls the worst case up to the group), by looking at every cash/giro
// payment recorded against this group's invoices and following each one's
// deposit_id (if any) to the Setoran's current status. Deliberately called
// only for the rows a caller actually renders (List's current page, Get's
// single group) — never across the full unpaginated buildGroups() result —
// so it doesn't turn into an unbounded N+1 over LHPP history. Best-effort:
// lookup errors are ignored, leaving items' deposit fields at zero value.
func (s *Service) enrichDeposit(ctx context.Context, g *Group) {
	if s.deposits == nil {
		return
	}
	var invoiceIDs []int64
	for _, it := range g.Items {
		if it.InvoiceID != nil {
			invoiceIDs = append(invoiceIDs, *it.InvoiceID)
		}
	}
	if len(invoiceIDs) == 0 {
		return
	}
	rows, err := s.repo.CashGiroPaymentsForInvoices(ctx, invoiceIDs)
	if err != nil || len(rows) == 0 {
		return
	}

	depositIDsByInvoice := map[int64][]*int64{}
	for _, r := range rows {
		var depID *int64
		if r.DepositID.Valid {
			id := r.DepositID.Int64
			depID = &id
		}
		depositIDsByInvoice[r.InvoiceID] = append(depositIDsByInvoice[r.InvoiceID], depID)
	}

	statusCache := map[int64]string{}
	statusOf := func(id int64) string {
		if st, ok := statusCache[id]; ok {
			return st
		}
		st := ""
		if dep, err := s.deposits.Get(ctx, id); err == nil && dep != nil {
			st = dep.Status
		}
		statusCache[id] = st
		return st
	}

	for i := range g.Items {
		it := &g.Items[i]
		if it.InvoiceID == nil {
			continue
		}
		ids, ok := depositIDsByInvoice[*it.InvoiceID]
		if !ok {
			continue
		}
		worst := ""
		for _, depID := range ids {
			var status string
			if depID == nil {
				status = "belum_disetor"
			} else if s := statusOf(*depID); s == "received" {
				status = "received"
			} else {
				status = "pending" // covers "pending" and any unexpected/cancelled status
			}
			if worst == "" || depositRank[status] > depositRank[worst] {
				worst = status
			}
		}
		it.DepositStatus = worst
		if worst != "" && (g.DepositStatus == "" || depositRank[worst] > depositRank[g.DepositStatus]) {
			g.DepositStatus = worst
		}
	}
}

// RecordPayment resolves do_id -> invoice and posts through
// finance.Service.PayInvoice — the same path ReceivablesPage uses — so the
// invoice, sales order mirror and cashflow ledger stay single-source-of-truth
// regardless of which screen collected the money.
func (s *Service) RecordPayment(ctx context.Context, groupID int64, in PaymentInput) (*Group, error) {
	group, err := s.Get(ctx, groupID)
	if err != nil {
		return nil, err
	}
	var item *Item
	for i := range group.Items {
		if group.Items[i].DOID == in.DOID {
			item = &group.Items[i]
			break
		}
	}
	if item == nil {
		return nil, ErrDOTidakDitemukan
	}
	if item.InvoiceID == nil {
		return nil, ErrInvoiceBelumTerbit
	}
	if in.Amount.LessThanOrEqual(decimal.Zero) {
		return nil, ErrInvalidAmount
	}
	if in.Amount.GreaterThan(item.Sisa) {
		return nil, ErrAmountExceedsSisa
	}

	notes := in.Notes
	if notes == nil || *notes == "" {
		n := fmt.Sprintf("LHPP %s — %s", group.LHPPNumber, item.DONumber)
		notes = &n
	}
	method := in.PaymentMethod
	if method == "" {
		method = "cash"
	}

	ref := group.LHPPNumber
	if _, err := s.finance.PayInvoice(ctx, *item.InvoiceID, finance.PaymentInput{
		Amount:          in.Amount,
		PaymentMethod:   method,
		ReferenceNumber: &ref,
		Notes:           notes,
	}); err != nil {
		return nil, err
	}

	return s.Get(ctx, groupID)
}

// --- Sales LHPP (billing visits scheduled by Finance) ------------------------

type salesGroupAccumulator struct {
	salesID   int64
	salesName string
	lhppDate  string
	items     []SalesItem
}

func (s *Service) buildSalesGroups(ctx context.Context) ([]SalesGroup, error) {
	rows, err := s.repo.SalesCandidateRows(ctx)
	if err != nil {
		return nil, err
	}
	return foldSalesCandidateRows(rows), nil
}

func foldSalesCandidateRows(rows []db.ListLHPPSalesCandidateRowsRow) []SalesGroup {
	var order []string
	acc := map[string]*salesGroupAccumulator{}
	for _, r := range rows {
		if !r.SalesID.Valid {
			continue
		}
		salesID := r.SalesID.Int64
		key := fmt.Sprintf("%d|%s", salesID, r.VisitDate)
		g, ok := acc[key]
		if !ok {
			g = &salesGroupAccumulator{
				salesID:   salesID,
				salesName: r.SalesName,
				lhppDate:  r.VisitDate,
			}
			acc[key] = g
			order = append(order, key)
		}

		item := SalesItem{
			VisitID:         r.VisitID,
			CustomerID:      r.CustomerID,
			CustomerName:    r.CustomerName,
			ShippingAddress: r.CustomerAddress,
			Purpose:         r.Purpose,
			Tagihan:         decimal.Zero,
			Terbayar:        decimal.Zero,
			Sisa:            decimal.Zero,
		}
		if r.InvoiceID.Valid {
			invID := r.InvoiceID.Int64
			item.InvoiceID = &invID
			if r.InvoiceNumber.Valid {
				num := r.InvoiceNumber.String
				item.InvoiceNumber = &num
			}
			if r.TotalAmount.Valid {
				item.Tagihan = r.TotalAmount.Decimal
			}
			if r.PaidAmount.Valid {
				item.Terbayar = r.PaidAmount.Decimal
			}
			if r.RemainingAmount.Valid {
				item.Sisa = r.RemainingAmount.Decimal
			}
		}
		g.items = append(g.items, item)
	}

	groups := make([]SalesGroup, 0, len(order))
	for _, key := range order {
		g := acc[key]
		totalTagihan, totalTerbayar, totalSisa := decimal.Zero, decimal.Zero, decimal.Zero
		for _, it := range g.items {
			totalTagihan = totalTagihan.Add(it.Tagihan)
			totalTerbayar = totalTerbayar.Add(it.Terbayar)
			totalSisa = totalSisa.Add(it.Sisa)
		}
		status := "belum_ditagih"
		switch {
		case totalTagihan.IsPositive() && totalSisa.IsZero():
			status = "lunas"
		case totalTerbayar.IsPositive():
			status = "sebagian"
		}
		compactDate := strings.ReplaceAll(g.lhppDate, "-", "")
		groups = append(groups, SalesGroup{
			ID:            encodeGroupID(g.salesID, g.lhppDate),
			LHPPNumber:    fmt.Sprintf("LHPP-SLS-%s-%03d", compactDate, g.salesID),
			LHPPDate:      g.lhppDate,
			SalesID:       g.salesID,
			SalesName:     g.salesName,
			VisitCount:    len(g.items),
			TotalTagihan:  totalTagihan,
			TotalTerbayar: totalTerbayar,
			TotalSisa:     totalSisa,
			Status:        status,
			Items:         g.items,
		})
	}

	sort.SliceStable(groups, func(i, j int) bool {
		if groups[i].LHPPDate != groups[j].LHPPDate {
			return groups[i].LHPPDate > groups[j].LHPPDate
		}
		return groups[i].ID > groups[j].ID
	})
	return groups
}

func filterSalesGroups(groups []SalesGroup, f SalesListFilter) []SalesGroup {
	search := strings.ToLower(strings.TrimSpace(f.Search))
	filtered := make([]SalesGroup, 0, len(groups))
	for _, g := range groups {
		if f.SalesID != nil && g.SalesID != *f.SalesID {
			continue
		}
		if f.Status != "" && g.Status != f.Status {
			continue
		}
		if search != "" &&
			!strings.Contains(strings.ToLower(g.LHPPNumber), search) &&
			!strings.Contains(strings.ToLower(g.SalesName), search) {
			continue
		}
		g.Items = nil
		filtered = append(filtered, g)
	}
	return filtered
}

// ListSales returns the paginated Sales LHPP header rows for GET /lhpp/sales
// (no SalesID filter — Finance/admin view across all sales reps).
func (s *Service) ListSales(ctx context.Context, f SalesListFilter) ([]SalesGroup, int, error) {
	groups, err := s.buildSalesGroups(ctx)
	if err != nil {
		return nil, 0, err
	}
	filtered := filterSalesGroups(groups, f)
	return paginateSalesGroups(filtered, f)
}

// MySalesGroups resolves the caller to their employee id and returns only
// their own groups, for GET /lhpp/sales/me — the self-service worksheet a
// sales rep prints for their own penagihan visits.
func (s *Service) MySalesGroups(ctx context.Context, userID int64, f SalesListFilter) ([]SalesGroup, int, error) {
	if s.lookup == nil {
		return nil, 0, ErrNoEmployee
	}
	eid, err := s.lookup(ctx, userID)
	if err != nil {
		return nil, 0, ErrNoEmployee
	}
	f.SalesID = &eid
	groups, err := s.buildSalesGroups(ctx)
	if err != nil {
		return nil, 0, err
	}
	filtered := filterSalesGroups(groups, f)
	return paginateSalesGroups(filtered, f)
}

func paginateSalesGroups(filtered []SalesGroup, f SalesListFilter) ([]SalesGroup, int, error) {
	total := len(filtered)
	page := f.Page
	if page < 1 {
		page = 1
	}
	perPage := f.PerPage
	if perPage < 1 || perPage > 100 {
		perPage = 15
	}
	start := (page - 1) * perPage
	if start > total {
		start = total
	}
	end := start + perPage
	if end > total {
		end = total
	}
	return filtered[start:end], total, nil
}

// GetSales returns one Sales LHPP group with its item detail for
// GET /lhpp/sales/{id}.
func (s *Service) GetSales(ctx context.Context, id int64) (*SalesGroup, error) {
	groups, err := s.buildSalesGroups(ctx)
	if err != nil {
		return nil, err
	}
	for i := range groups {
		if groups[i].ID == id {
			g := groups[i]
			return &g, nil
		}
	}
	// Fallback tolerance: if an unrefreshed client sends a legacy 1e9 ID or an integer
	// that was precision-truncated by JavaScript float64 (within ±16 of legacyID):
	for i := range groups {
		compact := strings.ReplaceAll(groups[i].LHPPDate, "-", "")
		dateInt, _ := strconv.ParseInt(compact, 10, 64)
		legacyID := dateInt*legacyGroupIDDriverSpace + groups[i].SalesID
		diff := legacyID - id
		if diff < 0 {
			diff = -diff
		}
		if diff <= 16 {
			g := groups[i]
			return &g, nil
		}
	}
	return nil, ErrNotFound
}

// RecordSalesPayment resolves invoice_id -> item and posts through
// finance.Service.PayInvoice, exactly like RecordPayment does for the driver
// flow — so a payment collected on a penagihan visit lands on the same
// invoice/cashflow ledger ReceivablesPage reads.
func (s *Service) RecordSalesPayment(ctx context.Context, groupID int64, in SalesPaymentInput) (*SalesGroup, error) {
	group, err := s.GetSales(ctx, groupID)
	if err != nil {
		return nil, err
	}
	var item *SalesItem
	for i := range group.Items {
		if group.Items[i].InvoiceID != nil && *group.Items[i].InvoiceID == in.InvoiceID {
			item = &group.Items[i]
			break
		}
	}
	if item == nil {
		return nil, ErrInvoiceTidakDitemukan
	}
	if in.Amount.LessThanOrEqual(decimal.Zero) {
		return nil, ErrInvalidAmount
	}
	if in.Amount.GreaterThan(item.Sisa) {
		return nil, ErrAmountExceedsSisa
	}

	notes := in.Notes
	if notes == nil || *notes == "" {
		n := fmt.Sprintf("LHPP %s — %s", group.LHPPNumber, item.CustomerName)
		notes = &n
	}
	method := in.PaymentMethod
	if method == "" {
		method = "cash"
	}

	ref := group.LHPPNumber
	if _, err := s.finance.PayInvoice(ctx, *item.InvoiceID, finance.PaymentInput{
		Amount:          in.Amount,
		PaymentMethod:   method,
		ReferenceNumber: &ref,
		Notes:           notes,
	}); err != nil {
		return nil, err
	}

	return s.GetSales(ctx, groupID)
}
