package inventory

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

	"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"
)

// Sentinel errors mapped to HTTP in the handler.
var (
	ErrNotFound                 = errors.New("not found")
	ErrInsufficient             = errors.New("stock insufficient")
	ErrUnsupportedState         = errors.New("invalid state transition")
	ErrItemRequired             = errors.New("items are required")
	ErrAlreadyFinalized         = errors.New("opname already completed")
	ErrForbiddenWarehouse       = errors.New("warehouse outside your outlet")
	ErrOnlyDestinationWarehouse = errors.New("Hanya gudang tujuan yang berhak menyelesaikan penerimaan barang")
)

// outletWarehouses resolves the caller's tenant store scope (auth.StoreScope,
// loaded by the scope middleware) into the warehouse ids that back those
// stores. Returns (nil, false) for a platform-wide caller — no scoping — and
// (ids, true) for a store-bound caller such as a kepala toko.
func (s *Service) outletWarehouses(ctx context.Context) ([]int64, bool, error) {
	sc := auth.StoreScopeFrom(ctx)
	if sc.PlatformWide() {
		return nil, false, nil
	}
	ids, err := s.store.WarehousesForStores(ctx, sc.IDs())
	if err != nil {
		return nil, false, err
	}
	return ids, true, nil
}

func containsInt64(xs []int64, v int64) bool {
	for _, x := range xs {
		if x == v {
			return true
		}
	}
	return false
}

// guardWarehouses rejects a write that targets a warehouse outside the
// store-bound caller's outlet. A no-op for platform-wide callers.
func (s *Service) guardWarehouses(ctx context.Context, warehouseIDs ...int64) error {
	whs, scoped, err := s.outletWarehouses(ctx)
	if err != nil {
		return err
	}
	if !scoped {
		return nil
	}
	for _, id := range warehouseIDs {
		if id != 0 && !containsInt64(whs, id) {
			return ErrForbiddenWarehouse
		}
	}
	return nil
}

// guardTransferWarehouses verifies that a store-bound caller touches at least
// one warehouse in its assigned outlet (either as origin or destination).
// A no-op for platform-wide / unscoped callers.
func (s *Service) guardTransferWarehouses(ctx context.Context, fromID, toID int64) error {
	whs, scoped, err := s.outletWarehouses(ctx)
	if err != nil {
		return err
	}
	if !scoped {
		return nil
	}
	if containsInt64(whs, fromID) || containsInt64(whs, toID) {
		return nil
	}
	return ErrForbiddenWarehouse
}

// 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)
}

const (
	MovementStockIn     = "stock_in"
	MovementStockOut    = "stock_out"
	MovementOpname      = "opname"
	MovementTransferIn  = "transfer_in"
	MovementTransferOut = "transfer_out"
)

// Service implements inventory use cases.
type Service struct {
	store              Store
	seq                Seq
	loc                *time.Location
	allowNegativeStock bool
	journal            OpnameAdjustmentJournal
}

// OpnameAdjustmentJournal drafts/posts an adjustment journal for an opname discrepancy.
type OpnameAdjustmentJournal func(ctx context.Context, opnameNumber string, warehouseID int64, diffValue decimal.Decimal, userID *int64) (journalNumber string, err error)

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

func (s *Service) WithAccounting(fn OpnameAdjustmentJournal) *Service {
	s.journal = fn
	return s
}

// WithAllowNegativeStock toggles the §11.3 guard for adjustStock-driven
// movements (stock-in/out, opname, GR). Default (false) rejects any mutation
// that would drive stock below zero; true restores the legacy permissive
// behavior for parity testing only.
func (s *Service) WithAllowNegativeStock(allow bool) *Service {
	s.allowNegativeStock = allow
	return s
}

// StockInInput mirrors the stock-in request.
type StockInInput struct {
	ProductID   int64           `json:"product_id"`
	WarehouseID int64           `json:"warehouse_id"`
	Quantity    decimal.Decimal `json:"quantity"`
	Notes       *string         `json:"notes"`
}

// StockOutInput mirrors the stock-out request.
type StockOutInput struct {
	ProductID   int64           `json:"product_id"`
	WarehouseID int64           `json:"warehouse_id"`
	Quantity    decimal.Decimal `json:"quantity"`
	Notes       *string         `json:"notes"`
}

// --- queries -----------------------------------------------------------------

// canSeeCost reports whether the caller may see purchase_price / hpp / value.
// A nil user is an internal call (no authenticated identity on ctx) and is
// NOT treated as privileged: it resolves to the same false as an
// authenticated caller without the key, so a missing user can never widen
// visibility. Fail closed, because the alternative is a code path that
// accidentally serves the margin to everyone.
func canSeeCost(ctx context.Context) bool {
	u := auth.UserFrom(ctx)
	return u != nil && u.Can("inventory.cost.view")
}

// redactStockCosts strips cost from every row unless the caller may see it.
// It MUTATES the slice's elements: the pointers are per-element, so setting a
// field to nil does not alias a neighbouring row's value.
func redactStockCosts(ctx context.Context, lines []StockLine) []StockLine {
	if canSeeCost(ctx) {
		return lines
	}
	for i := range lines {
		lines[i].PurchasePrice = nil
		lines[i].HPP = nil
		lines[i].Value = nil
	}
	return lines
}

var allowedStockSortFields = map[string]string{
	"product_name":   "product_name",
	"name":           "product_name",
	"product_code":   "product_code",
	"code":           "product_code",
	"category_name":  "category_name",
	"category":       "category_name",
	"quantity":       "quantity",
	"qty":            "quantity",
	"min_stock":      "min_stock",
	"purchase_price": "purchase_price",
	"hpp":            "purchase_price",
	"value":          "value",
	"is_low_stock":   "is_low_stock",
}

func normalizeStockSort(ctx context.Context, sortBy, sortDir string) (string, string) {
	normBy, ok := allowedStockSortFields[strings.TrimSpace(strings.ToLower(sortBy))]
	if !ok {
		normBy = "product_name"
	}
	// Redact cost sorting if caller lacks permission
	if (normBy == "purchase_price" || normBy == "value") && !canSeeCost(ctx) {
		normBy = "product_name"
	}
	normDir := strings.ToLower(strings.TrimSpace(sortDir))
	if normDir != "desc" {
		normDir = "asc"
	}
	return normBy, normDir
}

func (s *Service) ListStock(ctx context.Context, f StockFilter) ([]StockLine, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 {
		f.PerPage = 20
	} else if f.PerPage > 1000 {
		f.PerPage = 1000
	}
	f.SortBy, f.SortDir = normalizeStockSort(ctx, f.SortBy, f.SortDir)
	// A store-bound caller (kepala toko) only ever sees its own outlet's
	// warehouse; any client-supplied warehouse_id is overridden.
	if whs, scoped, err := s.outletWarehouses(ctx); err != nil {
		return nil, 0, err
	} else if scoped {
		if len(whs) == 0 {
			return []StockLine{}, 0, nil
		}
		if f.Warehouse != 0 && !containsInt64(whs, f.Warehouse) {
			return []StockLine{}, 0, nil
		}
		if f.Warehouse == 0 {
			f.Warehouse = whs[0]
		}
	}
	rows, total, err := s.store.ListStock(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]StockLine, 0, len(rows))
	for _, r := range rows {
		row := stockLineFromRow(r)
		if canSeeCost(ctx) {
			// Pointers, so a genuine zero cost still serialises as 0 and a
			// denied field serialises as absent. The two are distinguishable,
			// which a decimal.Decimal field could not manage.
			pp, hpp, val := r.PurchasePrice, r.PurchasePrice, r.Value
			row.PurchasePrice, row.HPP, row.Value = &pp, &hpp, &val
		}
		out = append(out, row)
	}
	return out, total, nil
}

// StockSummary is the aggregate twin of ListStock and carries the same
// permission split. TotalValue is the SUM of the cost figures, so serving it
// to a caller denied the per-row cost would leak the aggregate through the back
// door — the same numbers, one number instead of fifty.
func (s *Service) StockSummary(ctx context.Context, f StockFilter) (StockSummary, error) {
	if whs, scoped, err := s.outletWarehouses(ctx); err != nil {
		return StockSummary{}, err
	} else if scoped {
		if len(whs) == 0 || (f.Warehouse != 0 && !containsInt64(whs, f.Warehouse)) {
			return StockSummary{}, nil
		}
		if f.Warehouse == 0 {
			f.Warehouse = whs[0]
		}
	}
	raw, err := s.store.StockSummary(ctx, f)
	if err != nil {
		return StockSummary{}, err
	}
	sum := StockSummary{
		TotalProducts: raw.TotalProducts,
		TotalQty:      raw.TotalQty,
		LowStock:      raw.LowStock,
	}
	if canSeeCost(ctx) {
		tv := raw.TotalValue
		sum.TotalValue = &tv
	}
	return sum, nil
}

func (s *Service) ListMovements(ctx context.Context, f MovementFilter) ([]Movement, int, error) {
	if f.DateFrom != "" {
		if _, err := time.Parse("2006-01-02", f.DateFrom); err != nil {
			return nil, 0, errors.New("date_from must be YYYY-MM-DD")
		}
	}
	if f.DateTo != "" {
		if _, err := time.Parse("2006-01-02", f.DateTo); err != nil {
			return nil, 0, errors.New("date_to must be YYYY-MM-DD")
		}
	}
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	if whs, scoped, err := s.outletWarehouses(ctx); err != nil {
		return nil, 0, err
	} else if scoped {
		if len(whs) == 0 || (f.WarehouseID != 0 && !containsInt64(whs, f.WarehouseID)) {
			return []Movement{}, 0, nil
		}
		f.WarehouseIDs = whs
	}
	rows, total, err := s.store.ListMovements(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]Movement, 0, len(rows))
	for _, r := range rows {
		out = append(out, movementFromRow(r))
	}
	return out, total, nil
}

// --- manual stock-in/out -----------------------------------------------------

// StockIn validates product+warehouse and +delta via adjustStock.
func (s *Service) StockIn(ctx context.Context, in StockInInput) (*Movement, error) {
	if in.ProductID == 0 {
		return nil, errors.New("product is required")
	}
	if in.WarehouseID == 0 {
		return nil, errors.New("warehouse is required")
	}
	if !in.Quantity.IsPositive() {
		return nil, errors.New("quantity must be greater than 0")
	}
	if err := s.guardWarehouses(ctx, in.WarehouseID); err != nil {
		return nil, err
	}
	return s.adjustStock(ctx, in.ProductID, in.WarehouseID, in.Quantity, MovementStockIn, nil, nil, in.Notes)
}

// StockOut locks the row and rejects when quantity would go negative.
func (s *Service) StockOut(ctx context.Context, in StockOutInput) (*Movement, error) {
	if in.ProductID == 0 {
		return nil, errors.New("product is required")
	}
	if in.WarehouseID == 0 {
		return nil, errors.New("warehouse is required")
	}
	if !in.Quantity.IsPositive() {
		return nil, errors.New("quantity must be greater than 0")
	}
	if err := s.guardWarehouses(ctx, in.WarehouseID); err != nil {
		return nil, err
	}
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	if response, err := ClaimMovementRequest(ctx, tx); err != nil {
		return nil, err
	} else if response != nil {
		var m Movement
		if err := json.Unmarshal(response, &m); err != nil {
			return nil, err
		}
		return &m, nil
	}

	cur, err := s.store.LockStockRow(ctx, tx, in.ProductID, in.WarehouseID)
	if err != nil {
		return nil, err
	}
	// Reserved stock is committed to sales orders; a manual StockOut must not
	// consume it. Available = on-hand minus reserved.
	available := cur.Quantity.Sub(cur.ReservedQuantity)
	if available.LessThan(in.Quantity) {
		return nil, ErrInsufficient
	}
	after := cur.Quantity.Sub(in.Quantity)
	created, err := s.createMovementWith(ctx, tx, in.ProductID, in.WarehouseID,
		cur.Quantity, after, in.Quantity, MovementStockOut, nil, nil, in.Notes)
	if err != nil {
		return nil, err
	}
	if err := s.store.UpdateStock(ctx, tx, db.UpdateStockRowParams{
		ID:               cur.ID,
		Quantity:         after,
		ReservedQuantity: cur.ReservedQuantity,
	}); err != nil {
		return nil, err
	}
	if err := CompleteMovementRequest(ctx, tx, created); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return created, nil
}

// adjustStock: moves a delta on (product, warehouse) inside one tx and records
// a StockMovement. Fails with ErrInsufficient when the resulting qty < 0.
func (s *Service) adjustStock(ctx context.Context, productID, warehouseID int64,
	delta decimal.Decimal, mtype string, refType *string, refID *int64, notes *string) (*Movement, error) {

	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	if response, err := ClaimMovementRequest(ctx, tx); err != nil {
		return nil, err
	} else if response != nil {
		var m Movement
		if err := json.Unmarshal(response, &m); err != nil {
			return nil, err
		}
		return &m, nil
	}

	m, err := s.adjustStockOn(ctx, tx, productID, warehouseID, delta, mtype, refType, refID, notes)
	if err != nil {
		return nil, err
	}
	if err := CompleteMovementRequest(ctx, tx, m); err != nil {
		return nil, err
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return m, nil
}

// adjustStockOn runs the same adjustment on the caller's transaction (q).
// OpnameFinalize uses it so all item adjustments share one atomic commit.
func (s *Service) adjustStockOn(ctx context.Context, q db.DBTX, productID, warehouseID int64,
	delta decimal.Decimal, mtype string, refType *string, refID *int64, notes *string) (*Movement, error) {
	// Freeze stock: while an opname for this warehouse is open, warehouse-side
	// movements (manual in/out, transfers, goods receipts) are refused so the
	// baseline the team is counting stays put.
	//
	// Scope: this guard sits on adjustStockOn, which manual stock-in/out,
	// transfers and goods receipts all route through. Sales and POS deduct
	// stock through internal/sales' own locked path and never reach here, so a
	// till keeps selling while a count is open — an abandoned opname must not
	// freeze the store. The opname's OWN adjustment is the exception; without
	// it finalizing could never write the corrections it measured.
	if mtype != MovementOpname {
		frozen, err := s.store.WarehouseStockFrozen(ctx, q, warehouseID)
		if err != nil {
			return nil, err
		}
		if frozen {
			return nil, ErrStockFrozen
		}
	}

	stock, err := s.store.LockStockRow(ctx, q, productID, warehouseID)
	if err != nil && !errors.Is(err, ErrNotFound) {
		return nil, err
	}
	if errors.Is(err, ErrNotFound) {
		if err := s.store.EnsureStockRow(ctx, q, productID, warehouseID); err != nil {
			return nil, err
		}
		stock, err = s.store.LockStockRow(ctx, q, productID, warehouseID)
		if err != nil {
			return nil, err
		}
	}

	before := stock.Quantity
	after := before.Add(delta)
	if !s.allowNegativeStock {
		if mtype == MovementTransferOut || mtype == MovementStockOut {
			available := before.Sub(stock.ReservedQuantity)
			if available.LessThan(delta.Abs()) {
				return nil, ErrInsufficient
			}
		} else if after.IsNegative() {
			return nil, ErrInsufficient
		}
	}
	created, err := s.createMovementWith(ctx, q, productID, warehouseID,
		before, after, delta.Abs(), mtype, refType, refID, notes)
	if err != nil {
		return nil, err
	}
	if err := s.store.UpdateStock(ctx, q, db.UpdateStockRowParams{
		ID:               stock.ID,
		Quantity:         after,
		ReservedQuantity: stock.ReservedQuantity,
	}); err != nil {
		return nil, err
	}
	return created, nil
}

func (s *Service) createMovementWith(ctx context.Context, q db.DBTX, productID, warehouseID int64,
	before, after, qty decimal.Decimal, mtype string, refType *string, refID *int64, notes *string) (*Movement, error) {
	m, err := s.newMovement(ctx, q, productID, warehouseID, before, after, qty, mtype, refType, refID, notes)
	if err != nil {
		return nil, err
	}
	if err := s.createMovementOn(ctx, q, m); err != nil {
		return nil, err
	}
	return m, nil
}

func (s *Service) createMovementOn(ctx context.Context, q db.DBTX, m *Movement) error {
	row, err := s.store.CreateMovement(ctx, q, createMovementParams(m))
	if err != nil {
		return err
	}
	m.ID = row.ID
	m.CreatedAt = row.CreatedAt.Time
	return nil
}

func (s *Service) newMovement(ctx context.Context, q db.DBTX, productID, warehouseID int64,
	before, after, qty decimal.Decimal, mtype string, refType *string, refID *int64, notes *string) (*Movement, error) {
	now := time.Now().In(s.loc)
	n, err := s.seq.Sequence(ctx, "sm", now, q)
	if err != nil {
		return nil, fmt.Errorf("movement code sequence: %w", err)
	}
	return &Movement{
		MovementNumber: fmt.Sprintf("SM-%s-%04d", now.Format("20060102"), n),
		ProductID:      productID,
		WarehouseID:    warehouseID,
		MovementType:   mtype,
		Quantity:       qty.Round(4),
		QuantityBefore: before.Round(4),
		QuantityAfter:  after.Round(4),
		ReferenceType:  refType,
		ReferenceID:    refID,
		CreatedBy:      movementActor(ctx),
		Notes:          notes,
	}, nil
}

// MovementInTx records an already-computed movement on the given querier (a
// caller-owned tx). Used by the sales POS ledger so a stock_out movement stays
// in the same transaction as the order. The movement number is allocated
// outside by the caller.
func (s *Service) MovementInTx(ctx context.Context, q db.DBTX, m *Movement) error {
	return s.createMovementOn(ctx, q, m)
}

func movementActor(ctx context.Context) *int64 {
	if u := auth.UserFrom(ctx); u != nil {
		return &u.ID
	}
	return nil
}

// --- opname ------------------------------------------------------------------

// OpnameCreate snapshots every stock row of the warehouse into items,
// status=in_progress (draft is reserved for future editing).
func (s *Service) OpnameCreate(ctx context.Context, in OpnameInput) (*Opname, error) {
	if in.WarehouseID == 0 {
		return nil, errors.New("warehouse is required")
	}
	if err := s.guardWarehouses(ctx, in.WarehouseID); err != nil {
		return nil, err
	}
	date := in.OpnameDate
	if date == "" {
		date = time.Now().In(s.loc).Format("2006-01-02")
	}
	opnameNumber, err := s.opnameCode(ctx)
	if err != nil {
		return nil, err
	}
	o := &Opname{
		OpnameNumber: opnameNumber,
		WarehouseID:  in.WarehouseID,
		OpnameDate:   date,
		Status:       "in_progress",
		Notes:        in.Notes,
	}
	// M9: the snapshot (opname header + every item's system quantity) must commit
	// atomically. Without a wrapping tx a failure mid-loop would leave a header
	// with a partial snapshot, corrupting the later reconciliation baseline.
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback(ctx)
	id, err := s.store.CreateOpnameOn(ctx, tx, createOpnameParams(o))
	if err != nil {
		return nil, err
	}
	o.ID = id
	// Snapshot system quantity for every product the warehouse can hold.
	// ListStockOn LEFT JOINs stocks, so a product with no row in this warehouse
	// still comes back with quantity 0 — which is exactly the honest baseline
	// ("nothing recorded here"). Reading only rows that HAVE a stock row would
	// silently drop products from the count sheet.
	filter := StockFilter{Page: 1, PerPage: 100000, Warehouse: in.WarehouseID}
	lines, _, err := s.store.ListStockOn(ctx, tx, filter)
	if err != nil {
		return nil, err
	}
	for _, l := range lines {
		it := &OpnameItem{
			StockOpnameID:  o.ID,
			ProductID:      l.ID,
			SystemQuantity: l.Quantity,
		}
		if _, err := s.store.CreateOpnameItemOn(ctx, tx, createOpnameItemParams(it)); err != nil {
			return nil, err
		}
	}
	if err := tx.Commit(ctx); err != nil {
		return nil, err
	}
	return o, nil
}

// OpnameSetPhysical writes physical counts back (only while in_progress).
func (s *Service) OpnameSetPhysical(ctx context.Context, id int64, items []OpnameItemInput) error {
	o, err := s.store.OpnameByID(ctx, id)
	if err != nil {
		return err
	}
	// Same outlet check as OpnameByID: without it a store-bound caller could
	// write counts into another outlet's opname while the detail view hid it.
	if err := s.guardWarehouses(ctx, o.WarehouseID); err != nil {
		return err
	}
	if o.Status != "in_progress" {
		return ErrUnsupportedState
	}
	return s.store.SetOpnameItemsPhysical(ctx, id, items)
}

// OpnameFinalize computes differences and adjusts stock for each item with a
// physical count; then marks completed. The opname row is locked FOR UPDATE so
// two concurrent finalize calls cannot both run, and all item adjustments
// commit atomically in one transaction (no partial finalize on failure).
func (s *Service) OpnameFinalize(ctx context.Context, id int64) (int, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return 0, err
	}
	defer tx.Rollback(ctx)

	o, err := s.store.LockOpnameByID(ctx, tx, id)
	if err != nil {
		return 0, err
	}
	// Outlet check before any adjustment: finalizing another outlet's opname
	// would move its stock, which is a write, not a read. OpnameByID already
	// guards the detail view; the finalize path must not be the way around it.
	if err := s.guardWarehouses(ctx, o.WarehouseID); err != nil {
		return 0, err
	}
	if o.Status == "completed" {
		return 0, ErrAlreadyFinalized
	}
	rows, err := s.store.OpnameItemsOn(ctx, tx, id)
	if err != nil {
		return 0, err
	}
	items := make([]OpnameItem, 0, len(rows))
	for _, r := range rows {
		items = append(items, opnameItemFromRow(r))
	}
	sort.Slice(items, func(i, j int) bool { return items[i].ProductID < items[j].ProductID })
	adjusted := 0
	totalDiff := decimal.Zero
	refType := "stock_opname"
	for _, it := range items {
		if it.PhysicalQty == nil {
			continue // skipped per §3.5
		}
		diff := it.PhysicalQty.Sub(it.SystemQuantity)
		if diff.IsZero() {
			continue
		}
		totalDiff = totalDiff.Add(diff)
		note := fmt.Sprintf("Penyesuaian stok opname %s", o.OpnameNumber)
		if it.Notes != nil && *it.Notes != "" {
			note += fmt.Sprintf(" (%s)", *it.Notes)
		}
		if _, err := s.adjustStockOn(ctx, tx, it.ProductID, o.WarehouseID, diff, MovementOpname, &refType, &o.ID, &note); err != nil {
			return adjusted, err
		}
		adjusted++
	}
	if err := s.store.UpdateOpnameStatusOn(ctx, tx, id, "completed"); err != nil {
		return adjusted, err
	}
	if err := tx.Commit(ctx); err != nil {
		return adjusted, err
	}
	if s.journal != nil && !totalDiff.IsZero() {
		_, _ = s.journal(ctx, o.OpnameNumber, o.WarehouseID, totalDiff, nil)
	}
	return adjusted, nil
}

func (s *Service) ListOpnames(ctx context.Context, f OpnameFilter) ([]Opname, int, error) {
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	if whs, scoped, err := s.outletWarehouses(ctx); err != nil {
		return nil, 0, err
	} else if scoped && len(whs) > 0 {
		f.WarehouseID = whs[0]
	}
	rows, total, err := s.store.ListOpnames(ctx, f)
	if err != nil {
		return nil, 0, err
	}
	out := make([]Opname, 0, len(rows))
	for _, r := range rows {
		out = append(out, opnameFromListRow(r))
	}
	return out, total, nil
}

func (s *Service) OpnameByID(ctx context.Context, id int64) (*Opname, error) {
	row, err := s.store.OpnameByID(ctx, id)
	if err != nil {
		return nil, err
	}
	o := opnameFromRow(row)
	if err := s.guardWarehouses(ctx, o.WarehouseID); err != nil {
		return nil, err
	}
	return o, nil
}

func (s *Service) OpnameItems(ctx context.Context, id int64) ([]OpnameItem, error) {
	// Routed through OpnameByID first so the outlet check applies here too:
	// items without it were readable for an opname whose header was hidden.
	if _, err := s.OpnameByID(ctx, id); err != nil {
		return nil, err
	}
	rows, err := s.store.OpnameItems(ctx, id)
	if err != nil {
		return nil, err
	}
	out := make([]OpnameItem, 0, len(rows))
	for _, r := range rows {
		out = append(out, opnameItemFromRow(r))
	}
	return out, nil
}

// opnameCode allocates a monthly OPN number (OPN-YYYYMM-0001).
func (s *Service) opnameCode(ctx context.Context) (string, error) {
	now := time.Now().In(s.loc)
	n, err := s.seq.Sequence(ctx, "opn", seq.MonthStart(now), nil)
	if err != nil {
		return "", fmt.Errorf("OPN number sequence: %w", err)
	}
	return fmt.Sprintf("OPN-%s-%04d", now.Format("20060102"), n), nil
}
