package repositories

import (
	"fmt"
	"math"
	"sort"
	"time"

	"nearle/models"

	"gorm.io/gorm"
)

// Shared stock machinery.
//
// Extracted from createOrderTx so that an order placed in the app and a bill
// rung up at a counter deduct stock through exactly the same code. Two
// implementations of the rule that stops overselling would drift, and the first
// anyone would know about it is a shelf that is empty in the database and full
// in the shop, or the reverse.
//
// None of these commit or roll back — the caller owns the transaction boundary,
// because what should happen to the rest of the work on failure is the caller's
// business, not the ledger's.

// stockLine is the minimum the ledger needs to know about one sold line.
//
// Deliberately not models.OrderDetail: the POS ingest writes its own tables and
// has no OrderDetail to hand, and coupling the ledger to one caller's row type
// is what forced the duplication this file removes.
type stockLine struct {
	Productid   int
	Locationid  int
	Productname string

	// Units sold. Fractional because a counter sells 1.5 kg of onions; the
	// ledger itself is integer-only, and roundStockQty explains the gap.
	Quantity float64
}

// roundStockQty turns a sold quantity into a ledger quantity.
//
// productstocks.quantity is an integer column, so fractional sales cannot be
// represented exactly. Rounding *up* is the conservative direction: 1.5 kg
// deducts 2, so the recorded stock is never higher than what is physically on
// the shelf. Truncating instead would under-deduct on every fractional sale and
// let the shop oversell a little more each time.
//
// This is a workaround, not a fix. A shop that sells much by weight needs the
// column to be numeric.
func roundStockQty(quantity float64) int {
	if quantity <= 0 {
		return 1
	}
	return int(math.Ceil(quantity - 1e-9))
}

// lockStockRows takes a row lock on every (tenant, location, product) the sale
// touches, before anything reads availability.
//
// Without it two concurrent sales of the same product can both read "in stock"
// before either commits its deduction, oversell the item and drive the balance
// negative. Locking productlocations — the row the stock computation is already
// keyed against — serialises conflicting sales instead.
//
// Locks are taken in a fixed (productid, locationid) order so two sales sharing
// products always contend in the same sequence. Without that ordering they
// deadlock against each other rather than merely blocking.
func lockStockRows(tx *gorm.DB, tenantID int, lines []stockLine) error {
	type lockTarget struct {
		productid  int
		locationid int
	}

	seen := make(map[lockTarget]bool, len(lines))
	locks := make([]lockTarget, 0, len(lines))
	for _, line := range lines {
		lt := lockTarget{productid: line.Productid, locationid: line.Locationid}
		if !seen[lt] {
			seen[lt] = true
			locks = append(locks, lt)
		}
	}

	sort.Slice(locks, func(a, b int) bool {
		if locks[a].productid != locks[b].productid {
			return locks[a].productid < locks[b].productid
		}
		return locks[a].locationid < locks[b].locationid
	})

	for _, lt := range locks {
		var locked int
		const q = `SELECT productlocationid FROM productlocations
		           WHERE tenantid = ? AND locationid = ? AND productid = ? FOR UPDATE`
		if err := tx.Raw(q, tenantID, lt.locationid, lt.productid).Scan(&locked).Error; err != nil {
			return fmt.Errorf("failed to lock stock for product %d: %w", lt.productid, err)
		}
	}

	return nil
}

// availableStock is the ledger balance for one product at one location.
func availableStock(tx *gorm.DB, tenantID, locationID, productID int) (int, error) {
	var available int
	const q = `
		SELECT COALESCE(
			SUM(CASE WHEN LOWER(stocktype) = 'in'  THEN quantity ELSE 0 END) -
			SUM(CASE WHEN LOWER(stocktype) = 'out' THEN quantity ELSE 0 END),
			0
		)
		FROM productstocks
		WHERE productid = ? AND tenantid = ? AND locationid = ?`

	if err := tx.Raw(q, productID, tenantID, locationID).Scan(&available).Error; err != nil {
		return 0, fmt.Errorf("failed to verify stock for product %d: %w", productID, err)
	}
	return available, nil
}

// assertStockAvailable refuses the whole sale if any line cannot be met.
//
// Checked for every line before any is written, so a sale never lands
// half-deducted. Call it only with the locks from lockStockRows already held —
// otherwise the balance it reads can change before the deduction is written.
func assertStockAvailable(tx *gorm.DB, tenantID int, lines []stockLine, qtyOf func(stockLine) int) error {
	for _, line := range lines {
		available, err := availableStock(tx, tenantID, line.Locationid, line.Productid)
		if err != nil {
			return err
		}

		requested := qtyOf(line)
		if available < requested {
			name := line.Productname
			if name == "" {
				name = fmt.Sprintf("ID %d", line.Productid)
			}
			return fmt.Errorf(
				"insufficient stock for product '%s': requested %d, available %d",
				name, requested, available,
			)
		}
	}
	return nil
}

// recordStockOut writes the ledger entry for one sold line and re-derives the
// location's availability flag from the balance it just produced.
func recordStockOut(tx *gorm.DB, tenantID int, line stockLine, quantity int) error {
	stock := models.Productstock{
		Tenantid:   tenantID,
		Stockdate:  time.Now(),
		Locationid: line.Locationid,
		Productid:  line.Productid,
		Quantity:   quantity,
		Stocktype:  "out",
		Status:     "Active",
	}
	if err := tx.Table("productstocks").Create(&stock).Error; err != nil {
		return err
	}

	syncProductLocationStatus(tx, tenantID, line.Locationid, line.Productid)
	return nil
}

// legacyOrderQty is how createOrderTx has always turned an order quantity into
// a ledger quantity: truncate, then floor at 1.
//
// Preserved exactly rather than corrected, because changing it would silently
// alter stock deduction for every app order in production. It under-deducts a
// fractional line — 1.5 becomes 1 — which is why the POS path uses
// roundStockQty instead. Worth reconciling once someone owns the decision.
func legacyOrderQty(quantity float64) int {
	q := int(quantity)
	if q <= 0 {
		q = 1
	}
	return q
}
