package repositories

import (
	"encoding/json"
	"errors"
	"fmt"
	"strconv"
	"strings"
	"time"

	"nearle/models"

	"gorm.io/gorm"
)

// Ingestion for the Nearle POS terminal.
//
// Bills arrive here already rung up and paid for — the till is the system of
// record until we say otherwise, and it holds its own copy for a week on the
// strength of our acknowledgement. Two consequences shape everything below.
//
// **A duplicate is a success.** Delivery is at-least-once: a lost ack makes a
// terminal re-send bills that are already banked. Reporting those as failures
// would strand a day of takings on the till for ever. So a bill we already hold
// is accepted, silently, without touching stock again.
//
// **Acknowledge only after the commit.** A bill named in the ack is one the
// terminal is entitled to delete. Saying so before the transaction lands would
// trade a real sale for a queue position.
//
// The commit itself is deliberately not reimplemented here. Each bill runs
// through createOrderTx, the same path an app order and a spreadsheet import
// take, so stock deduction, the per-product row locks that prevent overselling,
// the ledger entries and sequence allocation stay shared rather than forked.

type PosRepository interface {
	IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error)
	IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error)
	Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error)

	// Reading counter sales back out. Without these a committed bill is
	// unreachable from every screen in the product.
	Sales(f models.PosSalesFilter) (*models.PosSalesPage, error)
	SaleDetail(locationID int, reference string) (*models.PosOrders, error)
	SalesSummary(f models.PosSalesFilter) (*models.PosSalesSummary, error)
}

type posRepository struct {
	db *gorm.DB
	// Held rather than embedded so the order machinery is reached explicitly.
	orders *orderRepository
}

func NewPosRepository(db *gorm.DB) PosRepository {
	return &posRepository{db: db, orders: &orderRepository{db: db}}
}

// resolvePosStore turns the terminal's store_id into an authorised outlet.
//
// The till sends a location and nothing else. The tenant is looked up from it
// here and never accepted from the wire: a terminal that could name its own
// tenant could post sales into somebody else's books.
func (r *posRepository) resolvePosStore(storeID string) (*offlineLocationContext, error) {
	locationID, err := strconv.Atoi(strings.TrimSpace(storeID))
	if err != nil || locationID <= 0 {
		return nil, fmt.Errorf("store_id %q is not a location id; configure the terminal's Store ID with the numeric locationid", storeID)
	}

	var tenantID int
	err = r.db.Raw(
		`SELECT COALESCE(MIN(tenantid), 0) FROM tenantlocations WHERE locationid = ?`,
		locationID,
	).Scan(&tenantID).Error
	if err != nil {
		return nil, err
	}
	if tenantID <= 0 {
		return nil, fmt.Errorf("no outlet is registered with locationid %d", locationID)
	}

	return r.orders.resolveOfflineLocationContext(tenantID, locationID)
}

// IngestOrders commits a batch of counter bills and reports what landed.
//
// A failure to resolve the outlet at all returns an error rather than an ack,
// so the terminal treats the outcome as unknown and retries. A failure on one
// bill is reported against that bill alone and the rest still commit.
func (r *posRepository) IngestOrders(batch models.PosOrderBatch) (*models.PosAck, error) {
	ack := models.NewPosAck(batch.Batchid)
	if len(batch.Orders) == 0 {
		return ack, nil
	}

	ctx, err := r.resolvePosStore(batch.Storeid)
	if err != nil {
		return nil, err
	}

	products, err := r.orders.loadOfflineProducts(ctx.Tenantid, ctx.Locationid)
	if err != nil {
		return nil, err
	}
	if len(products) == 0 {
		return nil, fmt.Errorf("outlet '%s' has no products stocked against it", ctx.Locationname)
	}

	for _, order := range batch.Orders {
		if strings.TrimSpace(order.Id) == "" {
			// Nothing to key on, so it can never be deduplicated. Refusing it
			// is safer than admitting a bill that would double on every retry.
			ack.Reject("", "order is missing its id")
			continue
		}
		if reason := r.importPosOrder(ctx, products, batch.Batchid, order); reason != "" {
			ack.Reject(order.Id, reason)
			continue
		}
		ack.Accept(order.Id)
	}

	return ack, nil
}

// importPosOrder commits one bill, or leaves nothing behind.
//
// Returns an empty string on success — including the case where the bill was
// already held, which is a success from the terminal's point of view.
//
// The bill lands in pos_orders at full fidelity, and the stock it consumed goes
// through the same productstocks ledger an app order uses. Those two facts pull
// in opposite directions and both matter: the bill is its own kind of document
// and deserves its own table, but stock is one number per shelf and must not be
// tracked twice.
func (r *posRepository) importPosOrder(
	ctx *offlineLocationContext,
	products map[int]offlineProduct,
	batchID string,
	order models.PosOrder,
) string {
	if len(order.Items) == 0 {
		return "bill has no items"
	}

	saleDate, err := parsePosSaleDate(order.Createdat)
	if err != nil {
		return err.Error()
	}

	// The till has already apportioned bill-level discounts across its lines to
	// get the tax right, but it sends each line at its own pre-apportionment
	// value. Left alone, the item rows would sum to the subtotal while the
	// header carried the total, and every report that adds up lines would
	// disagree with the one that reads the header.
	//
	// So the lines are scaled onto what was actually collected. The till's own
	// figures stay authoritative for the bill as a whole; this only decides how
	// that whole is attributed across the lines inside it.
	netAmount := order.Total - order.Roundoff
	lineSum := 0.0
	taxSum := 0.0
	for _, item := range order.Items {
		lineSum += item.Linetotal
		taxSum += item.Tax
	}

	amountFactor := 1.0
	if lineSum > 0 && netAmount > 0 {
		amountFactor = netAmount / lineSum
	}
	taxFactor := 1.0
	if taxSum > 0 && order.Tax > 0 {
		taxFactor = order.Tax / taxSum
	}

	items := make([]models.PosOrderItems, 0, len(order.Items))
	lines := make([]stockLine, 0, len(order.Items))
	var taxTotal float64

	for _, raw := range order.Items {
		productID, err := strconv.Atoi(strings.TrimSpace(raw.Productid))
		if err != nil || productID <= 0 {
			return fmt.Sprintf("line '%s' has product_id %q, which is not a catalogue id", raw.Name, raw.Productid)
		}

		// Membership of this map is the ownership check. A product absent from
		// it is either another tenant's or not stocked here, and either way the
		// bill is refused rather than posted against a catalogue it has no
		// claim on.
		product, ok := products[productID]
		if !ok {
			return fmt.Sprintf("product %d is not stocked at %s", productID, ctx.Locationname)
		}
		if raw.Quantity <= 0 {
			return fmt.Sprintf("product '%s' has a quantity of %g; it must be greater than zero", product.Productname, raw.Quantity)
		}

		landing := raw.Linetotal * amountFactor
		taxAmount := raw.Tax * taxFactor
		gross := raw.Unitprice * raw.Quantity
		discount := gross - landing
		if discount < 0 {
			discount = 0
		}

		taxTotal += taxAmount

		items = append(items, models.PosOrderItems{
			Tenantid:       ctx.Tenantid,
			Locationid:     ctx.Locationid,
			Productid:      productID,
			Productname:    product.Productname,
			Barcode:        raw.Barcode,
			Unitname:       product.Productunit,
			Quantity:       raw.Quantity,
			Unitprice:      raw.Unitprice,
			Discountamount: discount,
			Gstrate:        raw.Gstrate,
			Taxamount:      taxAmount,
			Linetotal:      landing,
		})

		lines = append(lines, stockLine{
			Productid:   productID,
			Locationid:  ctx.Locationid,
			Productname: product.Productname,
			Quantity:    raw.Quantity,
		})
	}

	paymentMode := "cash"
	if len(order.Payments) > 0 {
		// The largest tender names the bill. A split paid mostly by card with
		// ten rupees of change in cash is a card sale in every report anyone
		// actually reads — the full split is kept in Paymentsjson regardless.
		largest := order.Payments[0]
		for _, p := range order.Payments[1:] {
			if p.Amount > largest.Amount {
				largest = p
			}
		}
		if m := strings.ToLower(strings.TrimSpace(largest.Method)); m != "" {
			paymentMode = m
		}
	}

	tx := r.db.Begin()
	if tx.Error != nil {
		return fmt.Sprintf("could not start a transaction: %v", tx.Error)
	}

	// Held for the life of the transaction, so a redelivery arriving at the
	// same moment waits here and then sees the committed row rather than racing
	// past the check below and banking the sale twice. The unique index on
	// terminalorderid would catch it either way; this turns a constraint
	// violation into an orderly "already held".
	lockKey := "possale:" + strings.ToUpper(strings.TrimSpace(order.Id))
	if err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext(?))`, lockKey).Error; err != nil {
		tx.Rollback()
		return fmt.Sprintf("could not lock bill %s: %v", order.Invoicenumber, err)
	}

	var already int
	err = tx.Raw(
		`SELECT COALESCE(COUNT(*), 0) FROM pos_orders WHERE terminalorderid = ?`,
		strings.TrimSpace(order.Id),
	).Scan(&already).Error
	if err != nil {
		tx.Rollback()
		return fmt.Sprintf("could not check whether bill %s was already held: %v", order.Invoicenumber, err)
	}
	if already > 0 {
		// Already banked. Accepted, not rejected — this is the ordinary result
		// of a lost ack, and calling it a failure would leave the till holding
		// a bill we have had all along. Stock is deliberately untouched.
		tx.Rollback()
		return ""
	}

	// Locks first, then availability, then the writes — the same order an app
	// order takes, and the reason two tills selling the last unit cannot both
	// succeed.
	if err := lockStockRows(tx, ctx.Tenantid, lines); err != nil {
		tx.Rollback()
		return err.Error()
	}
	if err := assertStockAvailable(tx, ctx.Tenantid, lines, func(l stockLine) int {
		return roundStockQty(l.Quantity)
	}); err != nil {
		tx.Rollback()
		return err.Error()
	}

	customerID, err := r.orders.resolveOfflineCustomer(tx, ctx, posCustomerName(order), posCustomerMobile(order))
	if err != nil {
		tx.Rollback()
		return fmt.Sprintf("could not resolve the customer: %v", err)
	}

	bill := models.PosOrders{
		Terminalorderid: strings.TrimSpace(order.Id),
		Invoicenumber:   order.Invoicenumber,
		Tenantid:        ctx.Tenantid,
		Locationid:      ctx.Locationid,
		Terminalid:      order.Terminalid,
		Cashiername:     order.Cashier,
		Customerid:      customerID,
		Customermobile:  posCustomerMobile(order),
		Customername:    posCustomerName(order),
		Billedat:        saleDate,
		// The day the sale was rung, not the day it arrived. A till that was
		// offline overnight uploads yesterday's bills this morning, and every
		// daily figure has to follow the sale rather than the upload.
		Businessdate:   saleDate.Format("2006-01-02"),
		Subtotal:       order.Subtotal,
		Discount:       order.Discount,
		Taxamount:      taxTotal,
		Roundoff:       order.Roundoff,
		Total:          order.Total,
		Pointsearned:   order.Pointsearned,
		Pointsredeemed: order.Pointsredeemed,
		Itemcount:      len(items),
		Paymentmode:    paymentMode,
		Paymentsjson:   posJSON(order.Payments),
		Promosjson:     posJSON(order.Promos),
		// Every jsonb column must carry valid JSON. Left at Go's zero value an
		// empty string reaches Postgres and the whole insert fails with
		// "invalid input syntax for type json" — taking the bill down with it.
		Taxbreakdownjson: posJSON(order.Taxbreakdown),
		Batchid:          batchID,
		Receivedat:       time.Now(),
	}

	if err := tx.Create(&bill).Error; err != nil {
		tx.Rollback()
		return fmt.Sprintf("could not write bill %s: %v", order.Invoicenumber, err)
	}

	for i := range items {
		items[i].Posorderid = bill.Posorderid
		if err := tx.Create(&items[i]).Error; err != nil {
			tx.Rollback()
			return fmt.Sprintf("could not write a line of bill %s: %v", order.Invoicenumber, err)
		}

		// The same ledger an app order writes to. A second stock ledger for
		// counter sales would mean the catalogue pull sends a till figures that
		// ignore the till's own trading.
		if err := recordStockOut(tx, ctx.Tenantid, lines[i], roundStockQty(lines[i].Quantity)); err != nil {
			tx.Rollback()
			return fmt.Sprintf("could not deduct stock for bill %s: %v", order.Invoicenumber, err)
		}
	}

	if err := tx.Commit().Error; err != nil {
		return fmt.Sprintf("could not commit bill %s: %v", order.Invoicenumber, err)
	}

	return ""
}

// posJSON encodes a payload column.
//
// Falls back to a JSON null rather than failing the bill: these columns exist
// to be read back later, and losing one is not a reason to refuse a sale the
// shopper has already paid for.
func posJSON(v any) string {
	body, err := json.Marshal(v)
	if err != nil || len(body) == 0 {
		return "null"
	}
	return string(body)
}

func posCustomerName(order models.PosOrder) string {
	if order.Customer == nil {
		return ""
	}
	return order.Customer.Name
}

func posCustomerMobile(order models.PosOrder) string {
	if order.Customer == nil {
		return ""
	}
	return order.Customer.Mobile
}

// parsePosSaleDate reads the till's timestamp.
//
// The terminal sends ISO-8601. A blank one falls back to now; an unparseable
// one is refused, because importing a sale under the wrong date corrupts every
// daily revenue figure that reads it.
func parsePosSaleDate(raw string) (time.Time, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" {
		return time.Now(), nil
	}
	for _, layout := range []string{
		time.RFC3339Nano,
		time.RFC3339,
		"2006-01-02T15:04:05.999999",
		"2006-01-02 15:04:05",
	} {
		if t, err := time.Parse(layout, raw); err == nil {
			return t, nil
		}
	}
	return time.Time{}, fmt.Errorf("unrecognised created_at %q", raw)
}

// IngestCustomers records shoppers registered at a till.
//
// Insert-if-absent, never an update. A registration is replayed freely, and a
// profile corrected at head office must not be reverted by a terminal replaying
// what it captured months ago.
func (r *posRepository) IngestCustomers(batch models.PosCustomerBatch) (*models.PosAck, error) {
	ack := models.NewPosAck(batch.Batchid)
	if len(batch.Customers) == 0 {
		return ack, nil
	}

	ctx, err := r.resolvePosStore(batch.Storeid)
	if err != nil {
		return nil, err
	}

	for _, customer := range batch.Customers {
		mobile := strings.TrimSpace(customer.Mobile)
		if strings.TrimSpace(customer.Id) == "" || mobile == "" {
			ack.Reject(customer.Id, "registration is missing its id or mobile number")
			continue
		}

		if err := r.upsertPosCustomer(ctx, customer, mobile); err != nil {
			ack.Reject(customer.Id, err.Error())
			continue
		}
		ack.Accept(customer.Id)
	}

	return ack, nil
}

// upsertPosCustomer attaches the shopper to this outlet's app location.
//
// Matched on contactno, which is what the rest of the system already keys a
// shopper on, so a shopper registered at a till and one who installed the app
// end up as one row rather than two.
func (r *posRepository) upsertPosCustomer(
	ctx *offlineLocationContext,
	customer models.PosCustomer,
	mobile string,
) error {
	name := strings.TrimSpace(customer.Name)
	if name == "" {
		name = "Counter Customer"
	}

	var existing int
	err := r.db.Raw(
		`SELECT COALESCE(MIN(customerid), 0) FROM customers WHERE contactno = ? AND applocationid = ?`,
		mobile, ctx.Applocationid,
	).Scan(&existing).Error
	if err != nil {
		return err
	}
	if existing > 0 {
		// Already known. Accepted without a write — the terminal's copy is not
		// newer than ours in any way we can establish.
		return nil
	}

	// status 0 mirrors every other customer row in production, including ones
	// actively placing orders. A different value here would make a shopper
	// registered at the counter behave unlike all the others.
	var created int
	err = r.db.Raw(`
		INSERT INTO customers (configid, firstname, lastname, contactno, email, gender, dob, applocationid, locationid, status, created, updated)
		VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, NOW(), NOW())
		RETURNING customerid`,
		ctx.Configid, name, mobile,
		strings.TrimSpace(customer.Email),
		strings.TrimSpace(customer.Gender),
		strings.TrimSpace(customer.Dateofbirth),
		ctx.Applocationid, ctx.Locationid,
	).Scan(&created).Error
	if err != nil {
		return err
	}
	if created <= 0 {
		return errors.New("failed to create the customer row")
	}
	return nil
}

// posRevisionLayout is the timestamp inside a catalogue revision.
//
// The revision is the terminal's memory of when it last pulled: it stores what
// we send and hands it back on the next request, and the time encoded in it is
// the cutoff for what has changed since. Colons are avoided so the whole string
// stays safe in a URL query without escaping.
const posRevisionLayout = "20060102T150405Z"

// posRevisionFor mints the revision a terminal will send back to us.
func posRevisionFor(locationID int, at time.Time) string {
	return fmt.Sprintf("loc%d-%s", locationID, at.UTC().Format(posRevisionLayout))
}

// posRevisionCutoff reads the timestamp back out of a revision.
//
// Returns the zero time when the revision is missing, malformed, or belongs to
// a different outlet — and a zero cutoff means "send everything". Falling back
// to a full snapshot is the only safe direction: answering an unreadable
// revision with a *delta* would leave the terminal quietly missing every change
// it had not already seen, with nothing to indicate it.
func posRevisionCutoff(locationID int, revision string) time.Time {
	revision = strings.TrimSpace(revision)
	prefix := fmt.Sprintf("loc%d-", locationID)
	if !strings.HasPrefix(revision, prefix) {
		return time.Time{}
	}

	at, err := time.Parse(posRevisionLayout, strings.TrimPrefix(revision, prefix))
	if err != nil {
		return time.Time{}
	}
	return at
}

// Catalogue answers a terminal's pull, as a snapshot or as a change set.
//
// ### The rule this function exists to keep
//
// A response with `is_delta: false` is treated as a full snapshot, and the
// terminal **withdraws every product the response does not mention**. So a
// filtered result labelled `false` empties the shop's shelf.
//
// The two are therefore decided together, from one value: a zero cutoff means
// no filter and `is_delta: false`; a non-zero cutoff means filtered and
// `is_delta: true`. There is no path through this function that filters without
// also setting the flag.
//
// ### What counts as a change
//
// A product is included when any of three things moved since the cutoff: the
// product row itself (name, tax, brand), its row at this location (price,
// availability), or its stock ledger. Stock is included because a shop's count
// drifts from the till's on every sale rung elsewhere, and a delta that omitted
// it would let that drift persist until someone forced a full pull.
//
// ### What a delta cannot do
//
// A product *deleted* from productlocations leaves no tombstone, so a change set
// cannot know to withdraw it. Only a full snapshot collects those. A terminal
// should pull without a revision periodically — the morning import is the
// natural moment — and this is why.
func (r *posRepository) Catalogue(storeID, since string, page, pageSize int) (*models.PosCatalogueResponse, error) {
	ctx, err := r.resolvePosStore(storeID)
	if err != nil {
		return nil, err
	}

	if pageSize <= 0 || pageSize > 1000 {
		pageSize = 500
	}
	if page < 0 {
		page = 0
	}

	// The single decision. Everything downstream reads this rather than
	// re-deriving it, so the filter and the flag cannot disagree.
	cutoff := posRevisionCutoff(ctx.Locationid, since)
	isDelta := !cutoff.IsZero()

	type row struct {
		Productid    int
		Productname  string
		Productsku   string
		Categoryname string
		Productunit  string
		Productbrand string
		Price        float64
		Retailprice  float64
		Taxpercent   float64
		Stock        float64
		Status       string
	}

	// A product counts as changed if the product row, its row at this location,
	// or its stock ledger moved. Written as one predicate so a delta cannot
	// miss a price change simply because the product row was untouched.
	changed := ""
	params := []interface{}{ctx.Tenantid, ctx.Locationid}
	if isDelta {
		changed = `AND (
			a.updated >= ?
			OR b.updated >= ?
			OR EXISTS (SELECT 1 FROM productstocks s2
			           WHERE s2.productid = a.productid AND s2.tenantid = a.tenantid
			             AND s2.locationid = b.locationid
			             AND (s2.stockdate >= ? OR s2.updated >= ?))
		)`
		params = append(params, cutoff, cutoff, cutoff, cutoff)
	}

	rows := make([]row, 0)
	query := fmt.Sprintf(`
		SELECT a.productid,
		       COALESCE(a.productname, '')   AS productname,
		       COALESCE(a.productsku, '')    AS productsku,
		       COALESCE(c.categoryname, '')  AS categoryname,
		       COALESCE(a.productunit, '')   AS productunit,
		       COALESCE(a.productbrand, '')  AS productbrand,
		       CASE WHEN COALESCE(b.price, 0) > 0 THEN b.price ELSE COALESCE(a.retailprice, 0) END AS price,
		       COALESCE(a.retailprice, 0)    AS retailprice,
		       COALESCE(a.taxpercent, 0)     AS taxpercent,
		       COALESCE((
		           SELECT SUM(CASE WHEN LOWER(s.stocktype) = 'in'  THEN s.quantity ELSE 0 END) -
		                  SUM(CASE WHEN LOWER(s.stocktype) = 'out' THEN s.quantity ELSE 0 END)
		           FROM productstocks s
		           WHERE s.productid = a.productid AND s.tenantid = a.tenantid AND s.locationid = b.locationid
		       ), 0)                          AS stock,
		       COALESCE(b.status, '')        AS status
		FROM products a
		INNER JOIN productlocations b ON a.productid = b.productid AND a.tenantid = b.tenantid
		LEFT JOIN productcategories c ON a.categoryid = c.categoryid
		WHERE a.tenantid = ? AND b.locationid = ? AND a.productid > 0 %s
		ORDER BY a.productid
		LIMIT ? OFFSET ?`, changed)

	// One row past the page, purely so has_more can be answered without a
	// second count query.
	params = append(params, pageSize+1, page*pageSize)

	if err := r.db.Raw(query, params...).Scan(&rows).Error; err != nil {
		return nil, err
	}

	// One row beyond the page was requested purely to answer has_more without a
	// second count query.
	hasMore := len(rows) > pageSize
	if hasMore {
		rows = rows[:pageSize]
	}

	products := make([]models.PosCatalogueProduct, 0, len(rows))
	for _, p := range rows {
		// Rows with productid <= 0 are excluded in SQL rather than here. Live
		// data has at least one — almost certainly an insert that never got a
		// sequence value — and it can never be billed, because the ingest
		// refuses any line whose id is not positive. Filtering it in the query
		// also keeps pagination exact: skipped after the LIMIT, it would eat a
		// slot and hand back a short page.
		mrp := p.Retailprice
		if mrp <= p.Price {
			mrp = 0
		}

		// Indian GST is 0/5/12/18/28, but the column holds 3, 4, 6, 7, 9, 10,
		// 15 and even -1 across live data. A negative rate would put negative
		// tax on a bill and a negative figure in a slab on a filed return, so
		// it is floored here rather than trusted.
		gstRate := p.Taxpercent / 100
		if gstRate < 0 {
			gstRate = 0
		}

		// Withdrawn from sale when there is no selling price. Neither
		// productlocations.price nor retailprice is set on much of the estate —
		// only productcost is — and a till that can ring an item up at ₹0 is
		// worse than one that cannot ring it up at all. Pricing the product
		// makes it sellable; nothing here needs changing.
		sellable := p.Price > 0 &&
			!strings.EqualFold(strings.TrimSpace(p.Status), "outofstock")

		products = append(products, models.PosCatalogueProduct{
			Id:       strconv.Itoa(p.Productid),
			Name:     p.Productname,
			Barcode:  posBarcode(p.Productid, p.Productsku),
			Sku:      p.Productsku,
			Category: posCategory(p.Categoryname),
			Price:    p.Price,
			Mrp:      mrp,
			Stock:    p.Stock,
			Unit:     posUnit(p.Productunit),
			Gstrate:  gstRate,
			Brand:    p.Productbrand,
			Isactive: sellable,
		})
	}

	// The revision only advances on the final page.
	//
	// A terminal that gives up half way through a paginated pull — a dropped
	// connection, a till switched off — must not be left holding a revision
	// that claims it has seen pages it never received. Every one of those
	// products would then be excluded from the next delta and stay stale
	// indefinitely, with nothing anywhere to indicate it.
	//
	// So mid-pull we echo back whatever the terminal already had: unchanged if
	// it sent one, empty if it did not, and empty means the next pull is a full
	// snapshot. Both are recoverable; a prematurely advanced revision is not.
	//
	// The stamp is taken a second in the past. A product written during the
	// same second this query ran could otherwise land on the wrong side of the
	// next cutoff and be skipped for good — overlapping by a second costs one
	// redundant row and cannot lose one.
	revision := strings.TrimSpace(since)
	if !hasMore {
		revision = posRevisionFor(ctx.Locationid, time.Now().Add(-time.Second))
	}

	return &models.PosCatalogueResponse{
		Revision: revision,
		// Decided with the filter, never separately. False here would tell the
		// terminal to withdraw every product this response omits.
		Isdelta:   isDelta,
		Hasmore:   hasMore,
		Products:  products,
		Customers: make([]models.PosCatalogueCustomer, 0),
		// A product deleted from productlocations leaves no tombstone, so a
		// change set cannot know to withdraw it. Only a full snapshot collects
		// those, which is why a terminal should pull without a revision
		// periodically.
		Retiredids: make([]string, 0),
	}, nil
}

// posBarcode decides what the till scans this product by.
//
// The terminal holds a **unique** index on barcode, so whatever this returns has
// to be distinct across the whole catalogue or the import fails outright.
//
// `products.productsku` cannot be trusted for that. Measured against live data:
// 6,245 products carry only 93 distinct SKUs, and the single value "1" is used
// by 5,794 of them. Mapping SKU straight to barcode would collapse most of the
// catalogue onto one row.
//
// So a SKU is used only when it looks like a real scannable code — 8 to 14
// digits, the shape of an EAN-8, UPC-A or EAN-13 — and otherwise the product id
// stands in. The id is unique by construction, which keeps the import working
// today; the day real barcodes are populated, scanning starts working on its own
// with no change here.
//
// Until then, scanning a physical barcode at the till will not find anything.
// That is a data problem, not a code one.
func posBarcode(productID int, sku string) string {
	sku = strings.TrimSpace(sku)

	if len(sku) >= 8 && len(sku) <= 14 {
		digitsOnly := true
		for _, r := range sku {
			if r < '0' || r > '9' {
				digitsOnly = false
				break
			}
		}
		if digitsOnly {
			return sku
		}
	}

	return strconv.Itoa(productID)
}

// posCategory maps a category name onto one of the terminal's fixed buckets.
//
// The till ships a closed enum, so anything unrecognised has to land somewhere;
// grocery is the catch-all it already uses for uncategorised stock.
func posCategory(name string) string {
	switch strings.ToLower(strings.TrimSpace(name)) {
	case "dairy":
		return "dairy"
	case "fruits", "fruit":
		return "fruits"
	case "vegetables", "vegetable":
		return "vegetables"
	case "beverages", "beverage", "drinks":
		return "beverages"
	case "snacks", "snack":
		return "snacks"
	case "personal care", "personalcare":
		return "personalCare"
	case "household", "home care", "homecare":
		return "household"
	default:
		return "grocery"
	}
}

// posUnit maps a unit of measure onto the terminal's enum, defaulting to pieces.
func posUnit(unit string) string {
	switch strings.ToLower(strings.TrimSpace(unit)) {
	case "kg", "kilogram", "kilo":
		return "kilogram"
	case "g", "gram", "grams":
		return "gram"
	case "l", "litre", "liter":
		return "litre"
	case "ml", "millilitre", "milliliter":
		return "millilitre"
	case "pack", "packet":
		return "pack"
	default:
		return "piece"
	}
}
