package sales

import (
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"strings"
	"time"

	"github.com/TaruLab/keluargamart-backend/internal/finance"
	"github.com/TaruLab/keluargamart-backend/pkg/httpresponse"
	"github.com/TaruLab/keluargamart-backend/pkg/middleware"
	"github.com/jackc/pgx/v5/pgconn"
)

// --- background Snap sweeper ---------------------------------------------------
//
// The recovery poll (SnapPaymentStatus) only runs while a buyer still has the
// page open. A buyer who closes the tab before Midtrans' capture webhook lands
// — Midtrans stops retrying about 5.7h after the capture — leaves an invoice
// that looks unpaid forever. The sweeper is the automated rescue for every
// FUTURE invoice: it selects the open Snap invoices inside a 12h horizon, asks
// Midtrans whether they settled, and settles the ones that did.
//
// It is deliberately a BACKGROUND path, not a second buyer-facing one. It runs
// on cron over HTTP, takes no identifiers, and is gated on a shared secret
// (see SweepSnapInvoices' HTTP counterpart and the runbook at
// deploy/MIDTRANS-SWEEPER.md). Nothing a caller can say selects WHICH invoice
// gets probed or settled; the only input is "sweep the candidate set", and the
// service derives that set from the database predicate alone. That is the whole
// security property: there is no parameter through which one caller reaches
// another caller's order.
//
// The provider numbers (cadence, batch cap, throttle) are argued where the
// other operational numbers live — deploy/MIDTRANS-SWEEPER.md §3 and §4. In
// short, and repeated here because the constants are the claim:
//
//   - snapSweepBatchCap = 50 per pass, concurrency 1. At most two status calls
//     per candidate (the stored ref and the canonical one, and only on invoices
//     that carry a ref), so a full pass is ~100 calls. At the documented */15
//     cadence that is 4,800 calls a day at the cap, spread evenly, from one IP.
//     Midtrans publishes NO rate limit for GET /v2/{order_id}/status, so the
//     posture is to stay far enough below any plausible limit that being flagged
//     is not a failure mode — and a burst from a single IP is exactly what gets
//     flagged, which is why there is no worker pool here.
//   - The per-invoice throttle is snapSweepThrottle = 10 minutes, enforced in
//     SQL by the candidate predicate, so one stuck invoice cannot be re-probed
//     on every pass. It is well under the 15-minute cadence on purpose: a
//     probe that hit a hard provider error is not stamped (see below) and must
//     be retried, and the throttle only needs to be short enough that it never
//     becomes the thing standing between an invoice and its next attempt.

const (
	// snapSweepBatchCap bounds one pass. It is the SERVICE's policy clamp; the
	// repository carries its own, higher, backstop so a caller bug cannot turn a
	// capped scan into an unbounded one.
	snapSweepBatchCap = 50

	// snapSweepThrottle is how long a probed invoice backs off after a committed
	// no-settlement. Mirrored by the candidate predicate's
	// `snap_last_checked_at < now() - interval '10 minutes'`; the two are the
	// same rule written once in Go for the clamp and once in SQL for the
	// predicate, and this constant is the Go half of it. Kept deliberately under
	// the */15 cron cadence so the throttle is never what stands between an
	// invoice and its next attempt.
	snapSweepThrottle = 10 * time.Minute

	// snapSweepRunTimeout bounds a WHOLE pass, on top of the per-call
	// midtransHTTPTimeout. Without it a pass against a degraded provider is
	// 50 invoices x 2 refs x 5s = up to 500s of blocked cron, and the operator
	// watching sweep.log has no idea whether the job died or is still working.
	// Two minutes is ~10x the pass time against a healthy provider (~50 calls
	// at a few hundred ms) and cuts a pathological pass off well short of the
	// 15-minute cadence. A pass that hits it stops cleanly: the remaining
	// candidates are left uncounted and untouched, and DurationMS in the report
	// is how an operator tells a truncated run from a normal one. The run is
	// SEQUENTIAL by design: at ~100 calls per sweep, parallelism buys nothing
	// and a burst from one IP is the easiest way to get flagged.
	snapSweepRunTimeout = 2 * time.Minute
)

// SnapSweepReport is one pass's outcome. It is a count per outcome rather than
// a per-invoice list on purpose: a few hundred invoices an hour times one row
// per probe is a log nobody reads, and the runbook already tells an operator
// where to get the per-invoice detail (GET /sales-orders/payment-reconciliation).
//
// The counters are mutually exclusive; every candidate the pass REACHED lands
// in exactly one of them. A pass that hits snapSweepRunTimeout leaves the
// remaining candidates in none of them: nothing was written for them, their
// throttle stamps rolled back with the aborted transaction, and the next pass
// picks them up.
type SnapSweepReport struct {
	// Candidates is the size of the set the pass selected.
	Candidates int `json:"candidates"`
	// Settled is money captured at Midtrans and booked here by this pass. This
	// is the only counter that means the sweeper did its job.
	Settled int `json:"settled"`
	// AlreadySettled is an invoice somebody else already closed — the webhook,
	// the buyer's poll, or a concurrent sweep. Counted, never re-settled. The
	// probe is never made: there is nothing left to ask about.
	AlreadySettled int `json:"already_settled"`
	// WrittenOff is money CAPTURED against an invoice whose debt was already
	// forgiven. It earns its own counter because it is the most consequential
	// number in the report: a customer was charged for an order that was
	// written off, and it needs a human (refund at Midtrans, or re-book the
	// invoice). It is a count and never an error — see sweepSnapInvoice.
	WrittenOff int `json:"written_off"`
	// NotAttempted is an invoice Midtrans has never been asked to charge: no
	// snap_order_ref, so there is no ref to probe.
	NotAttempted int `json:"not_attempted"`
	// Pending is a transaction the provider holds that is not settled — the
	// buyer genuinely has not finished paying. Commits the throttle stamp, so
	// the invoice backs off instead of being re-probed every pass.
	Pending int `json:"pending"`
	// UnknownRef is the provider positively answering "I have never seen this
	// ref" for every ref on the invoice. SOFT and final for those refs: the
	// buyer abandoned Snap without paying, and re-asking will not change it.
	UnknownRef int `json:"unknown_ref"`
	// HardErrors is a provider or database failure: transient, no stamp, the
	// invoice is re-probed on the next pass. The number to watch when a sweep
	// starts failing.
	HardErrors int `json:"hard_errors"`
	// AmountMismatch is the provider reporting a gross we cannot account for.
	// Fails closed — nothing is written — and the invoice stays open for a
	// human. An alarm, not a transient.
	AmountMismatch int `json:"amount_mismatch"`
	// Skipped is a candidate that vanished or no longer matched the identity
	// the pass selected under it (deleted, or the order/invoice pairing moved).
	// A race with a cancel, and an ordinary outcome.
	Skipped int `json:"skipped"`
	// StrandedOldest is the invoice number of the OLDEST candidate the pass
	// could not close, or nil when it closed them all. The candidate set is
	// ordered oldest-activity-first, so the first one that survives the pass is
	// the oldest. It points an operator at the row that has been sitting
	// longest.
	StrandedOldest *string `json:"stranded_oldest,omitempty"`
	// DurationMS is the wall time of the pass, so an operator can tell a
	// truncated run (hit snapSweepRunTimeout) from a normal one.
	DurationMS int64 `json:"duration_ms"`
}

// snapSweepOutcome is the per-invoice decision, so the counters live in exactly
// one place (SnapSweepReport.record) instead of being bumped ad hoc.
type snapSweepOutcome int

const (
	snapSweepSettled snapSweepOutcome = iota
	snapSweepAlreadySettled
	snapSweepWrittenOff
	snapSweepNotAttempted
	snapSweepPending
	snapSweepUnknownRef
	snapSweepHardError
	snapSweepAmountMismatch
	snapSweepSkipped
)

// record folds one outcome into the report. The single switch is what keeps the
// counters and the outcome enum from drifting apart.
func (r *SnapSweepReport) record(o snapSweepOutcome) {
	switch o {
	case snapSweepSettled:
		r.Settled++
	case snapSweepAlreadySettled:
		r.AlreadySettled++
	case snapSweepWrittenOff:
		r.WrittenOff++
	case snapSweepNotAttempted:
		r.NotAttempted++
	case snapSweepPending:
		r.Pending++
	case snapSweepUnknownRef:
		r.UnknownRef++
	case snapSweepHardError:
		r.HardErrors++
	case snapSweepAmountMismatch:
		r.AmountMismatch++
	case snapSweepSkipped:
		r.Skipped++
	}
}

// clampSnapSweepLimit is the server-side clamp on a caller-supplied batch size.
// Zero, negative and over-cap values all take the cap rather than being
// rejected: a crontab that asks for a silly number should still sweep, and a
// caller can neither turn the pass into an unbounded scan nor make it cheap to
// starve. There is no lower bound other than "one", because the candidate
// predicate is what decides the work — this only ever makes a pass shorter.
func clampSnapSweepLimit(limit int) int {
	if limit <= 0 || limit > snapSweepBatchCap {
		return snapSweepBatchCap
	}
	return limit
}

// SweepSnapInvoices runs one pass of the background reconciler and reports what
// happened. See the file comment for why it exists and why it takes no
// identifiers.
//
// Returns ErrGatewayDisabled when the Midtrans server key is unset: there is
// nothing to probe with, and that is an honest misconfiguration of THIS
// service, so it keeps the ErrGatewayDisabled 503 convention. (The 404
// departure lives on the token gate in the HTTP handler, not here.)
func (s *Service) SweepSnapInvoices(ctx context.Context, limit int) (*SnapSweepReport, error) {
	if s.midtrans.ServerKey == "" {
		return nil, ErrGatewayDisabled
	}
	limit = clampSnapSweepLimit(limit)
	started := time.Now()
	// The whole-run deadline sits ON TOP of the per-call one (snapTimeout).
	// Per-call bounds one HTTP round trip; this bounds the pass, so a provider
	// that is up but pathologically slow cannot hold a cron slot open.
	runCtx, cancel := context.WithTimeout(ctx, snapSweepRunTimeout)
	defer cancel()

	candidates, err := s.store.ListSnapSweepCandidates(runCtx, limit)
	if err != nil {
		return nil, err
	}
	rep := &SnapSweepReport{Candidates: len(candidates)}
	for i, c := range candidates {
		if runCtx.Err() != nil {
			// Deliberately not counted anywhere: the run stopped, so these
			// invoices have no outcome yet. Their stamps rolled back with the
			// pass and the next one re-probes them.
			slog.Warn("snap sweep hit its run deadline; the rest of the candidate set is retried on the next pass",
				"reached", i, "remaining", len(candidates)-i)
			break
		}
		outcome, stillOpen, err := s.sweepSnapInvoice(runCtx, c)
		if err != nil {
			// Logged here rather than returned: one invoice's failure must not
			// abort the pass, or a single bad row would stop the sweeper
			// reaching the rest of the candidate set on every future run.
			slog.Warn("snap sweep candidate failed", "outcome", outcomeName(outcome),
				"invoice_id", c.ID, "invoice_number", c.InvoiceNumber, "error", err)
		}
		rep.record(outcome)
		if stillOpen && rep.StrandedOldest == nil {
			rep.StrandedOldest = ptrString(c.InvoiceNumber)
		}
	}
	rep.DurationMS = time.Since(started).Milliseconds()
	slog.Info("snap sweep pass complete",
		"candidates", rep.Candidates, "settled", rep.Settled, "already_settled", rep.AlreadySettled,
		"written_off", rep.WrittenOff, "pending", rep.Pending, "unknown_ref", rep.UnknownRef,
		"hard_errors", rep.HardErrors, "amount_mismatch", rep.AmountMismatch, "skipped", rep.Skipped,
		"duration_ms", rep.DurationMS)
	return rep, nil
}

func outcomeName(o snapSweepOutcome) string {
	switch o {
	case snapSweepSettled:
		return "settled"
	case snapSweepAlreadySettled:
		return "already_settled"
	case snapSweepWrittenOff:
		return "written_off"
	case snapSweepNotAttempted:
		return "not_attempted"
	case snapSweepPending:
		return "pending"
	case snapSweepUnknownRef:
		return "unknown_ref"
	case snapSweepHardError:
		return "hard_error"
	case snapSweepAmountMismatch:
		return "amount_mismatch"
	default:
		return "skipped"
	}
}

// sweepSnapInvoice is the whole per-invoice decision, in the exact order the
// guards must run. Returns the outcome, whether the invoice is STILL OPEN
// afterwards (drives StrandedOldest), and the error for the log.
//
// The order is the design; it is not incidental:
//
//  1. sales_orders FOR UPDATE          <- lock 1
//  2. invoices       FOR UPDATE        <- lock 2
//  3. identity check against the candidate
//  4. already-settled  -> counted, provider NEVER contacted
//  5. written-off      -> counted, provider NEVER contacted, never resurrected
//  6. no ref           -> counted, nothing to ask about
//  7. provider probe (the only network call, bounded per call AND by the pass)
//  8. hard error       -> rollback, NO stamp, retried next pass
//  9. nothing settled  -> stamp + COMMIT (backs off for the throttle)
//  10. amount check, BEFORE any write, fails closed
//  11. MarkInvoicePaid; a zero-row result is "someone else won" -> rollback
//  12. payment row + order advance, ONE figure, + stamp, then COMMIT
//
// Every error path returns WITHOUT writing the throttle stamp, and the deferred
// Rollback undoes whatever the transaction already touched. That single
// transaction boundary is what produces the correct retry behaviour for free: a
// hard provider error and a failed settle both roll the stamp back, so the
// invoice is re-probed on the next pass, while a committed "no settlement"
// keeps the stamp and backs off. There is deliberately no stamp on a separate
// transaction and none on an error path.
func (s *Service) sweepSnapInvoice(ctx context.Context, c SnapSweepCandidate) (snapSweepOutcome, bool, error) {
	tx, err := s.store.Begin(ctx)
	if err != nil {
		return snapSweepHardError, false, err
	}
	defer tx.Rollback(ctx)

	// LOCK ORDER: the sales_orders row FIRST, then the invoice row — the same
	// order Cancel, PayCheckout and SnapPaymentStatus take. Taking the invoice
	// first and updating the order afterwards is a textbook ABBA cycle against
	// CancelOrderInvoices (order lock, then the invoice row): it works for
	// months and then deadlocks in production during a cancel. Do not "reorder
	// this because the invoice is the thing being settled".
	row, err := s.store.OrderByIDForUpdate(ctx, tx, c.SalesOrderID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return snapSweepSkipped, false, nil
		}
		return snapSweepHardError, false, err
	}
	o := orderFromLockedRow(row)

	// InvoiceForOrder, NOT OpenInvoiceForOrder. The sweeper reports to nobody,
	// so it has no use for the two-step that tells a settled invoice from a
	// written-off one — and OpenInvoiceForOrder's status predicate would hide a
	// 'paid' invoice as ErrNotFound, so an already-settled invoice the pass
	// should have COUNTED would be indistinguishable from a missing row.
	inv, err := s.store.InvoiceForOrder(ctx, tx, o.ID)
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			return snapSweepSkipped, false, nil
		}
		return snapSweepHardError, false, err
	}
	// The candidate was selected by id; settle the invoice THAT WAS SELECTED.
	// If the order's invoice has since become a different row, the selection
	// this pass acted on is gone, and settling the row that replaced it would
	// be settling something nobody selected.
	if inv.ID != c.ID {
		return snapSweepSkipped, false, nil
	}

	// Guard: already settled. Counted, and the provider is never contacted —
	// there is no open balance, so there is nothing to ask about and no second
	// payment row to risk. Checked BEFORE invoiceSettleable because both
	// return false here and the counters must not collapse into one.
	if inv.Status == "paid" {
		return snapSweepAlreadySettled, false, nil
	}
	// Guard: written off. CancelOrderInvoices leaves the invoice's
	// total_amount AND its snap_order_ref in place, so a capture that settles
	// after the order was cancelled lands here: money captured against a debt
	// that was deliberately forgiven. This NEVER resurrects the invoice, and it
	// is a COUNT, never an error — an error would make this a redelivery loop
	// against a provider that has nothing left to tell us. Warn loudly, because
	// the capture needs a human (refund at Midtrans, or re-book by hand).
	if !invoiceSettleable(inv) {
		slog.Warn("snap sweep: captured money against a written-off invoice needs manual reconciliation",
			"invoice_id", inv.ID, "invoice_number", inv.InvoiceNumber, "invoice_status", inv.Status,
			"sales_order_id", inv.SalesOrderID)
		return snapSweepWrittenOff, false, nil
	}
	// Nothing to ask about: the candidate predicate requires a non-null ref, so
	// this is defence in depth. No probe means no stamp.
	if inv.SnapOrderRef == nil || strings.TrimSpace(*inv.SnapOrderRef) == "" {
		return snapSweepNotAttempted, true, nil
	}

	// The provider call, on the refs the other settle paths use: the stored
	// (newest) ref AND the canonical first-issue one, because a SnapToken retry
	// overwrites the stored ref and the buyer may have paid on the attempt it
	// replaced. Bounded per call by snapTimeout, and by the pass deadline.
	// `reported` is the recovery poll's payment_status vocabulary; the sweeper
	// reports per-invoice counters, so it reads `known` instead (see below).
	status, gross, known, _, refErr := s.snapProbeRefs(ctx, *inv.SnapOrderRef, canonicalSnapRef(inv.InvoiceNumber, o.ID))
	if refErr != nil {
		// HARD and transient, errGatewayTimeout (a missed deadline) included: a
		// timeout says nothing about whether the buyer paid, so it must never be
		// recorded as "no settlement". No stamp, and the deferred Rollback drops
		// everything — the next pass re-probes this invoice.
		return snapSweepHardError, true, refErr
	}
	if status == nil {
		// SOFT: nothing settled. Two different facts, counted apart.
		//
		//   known > 0: the gateway holds a transaction for this invoice and has
		//     not settled it — the buyer is mid-payment. Pending.
		//   known == 0: the gateway has never heard of ANY ref for this invoice.
		//     The buyer abandoned Snap, and re-asking will not change that.
		//     UnknownRef.
		//
		// `known` is what separates them. Deriving it from `reported` would not:
		// a pending transaction maps to MidtransActionIgnored, the same value
		// snapSettleCandidate substitutes when it knew of no ref at all, so the
		// two would collapse into one counter and every abandoned invoice would
		// be reported as a buyer still paying.
		outcome := snapSweepPending
		if known == 0 {
			outcome = snapSweepUnknownRef
		}
		// COMMIT the throttle stamp. A probe that completed and found nothing is
		// a fact about the invoice's backoff, and it is the only thing that
		// keeps an abandoned invoice from being re-probed on every pass.
		if err := s.store.SetInvoiceSnapChecked(ctx, tx, inv.ID); err != nil {
			return snapSweepHardError, true, err
		}
		if err := tx.Commit(ctx); err != nil {
			return snapSweepHardError, true, err
		}
		return outcome, true, nil
	}

	// SETTLED at the provider. Reconcile the amount BEFORE any write, through
	// the same helper and with the same semantics as the webhook and the
	// recovery poll, so all three paths accept and refuse exactly the same
	// figures. A mismatch fails closed: no payment row, no paid invoice, no
	// order movement, and no stamp, so a human (or a fixed provider figure) gets
	// another look on the next pass.
	payable, aErr := reconcileSnapGross(inv, "sweeper", gross)
	if aErr != nil {
		return snapSweepAmountMismatch, true, aErr
	}
	payNumber, err := s.payCode(ctx, tx)
	if err != nil {
		return snapSweepHardError, true, err
	}
	marked, err := s.store.MarkInvoicePaid(ctx, tx, inv.ID, inv.TotalAmount)
	if err != nil {
		return snapSweepHardError, true, err
	}
	if marked == 0 {
		// Someone else won the row. MarkInvoicePaid's WHERE clause is
		// `status IN ('unpaid','partial')`, so a zero-row result means the
		// invoice is no longer open: the webhook, the buyer's poll, or another
		// sweep closed it after this transaction's read and before this write.
		// Roll back and report it as already-settled. Creating the payment row
		// anyway would book a second collection of the same money.
		//
		// This is the backstop that makes a THIRD concurrent caller safe by
		// construction rather than by convention: the row locks serialise the
		// reads, and this refuses to write when the serialisation says the work
		// is already done.
		slog.Info("snap sweep: invoice settled by another caller while probing",
			"invoice_id", inv.ID, "invoice_number", inv.InvoiceNumber, "sales_order_id", inv.SalesOrderID)
		return snapSweepAlreadySettled, false, nil
	}
	// THE FIGURE. payable is what reconcileSnapGross RETURNED — invoicePayableAmount
	// — and it is the ONLY definition of what the payment row records. Reading
	// inv.RemainingAmount here instead books a zero-or-negative row for a sale
	// that completed and advances the order by it while the invoice goes to
	// paid: that exact bug shipped once already and was found and fixed in two
	// places (SnapPaymentStatus and PayCheckout). One variable, used for the
	// payment row AND the order advance, so the two cannot disagree.
	pay := &Payment{
		PaymentNumber: payNumber,
		PayableType:   InvoiceModel,
		PayableID:     inv.ID,
		PaymentDate:   time.Now().In(s.loc).Format("2006-01-02"),
		Amount:        payable,
		PaymentMethod: finance.MidtransMethod(status.PaymentType),
		// The payment row's notes carry the Midtrans transaction id. That IS the
		// audit record for a swept settle, and it is why this needs no audit
		// table of its own.
		Notes: ptrString("Midtrans " + status.TransactionID),
	}
	if _, err := s.store.CreateGatewayPayment(ctx, tx, createPaymentParams(pay), "midtrans", status.TransactionID, status.TransactionStatus); err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return snapSweepAlreadySettled, false, nil
		}
		return snapSweepHardError, true, err
	}
	if err := s.store.SettleOrderPayment(ctx, tx, o.ID, payable); err != nil {
		return snapSweepHardError, true, err
	}
	if err := s.store.SetInvoiceSnapChecked(ctx, tx, inv.ID); err != nil {
		return snapSweepHardError, true, err
	}
	if err := tx.Commit(ctx); err != nil {
		return snapSweepHardError, true, err
	}
	if s.onConfirm != nil {
		_ = s.onConfirm(ctx, o.ID)
	}
	return snapSweepSettled, false, nil
}

// SweepSnapTokenGate authenticates the cron caller: the route takes no user
// session, so this is the whole gate. It is wired as a ROUTE middleware (not a
// per-request header read in the handler) for one reason that matters: a gate
// that only exists inside the handler means any future route added to
// /api/v1/midtrans/ forgets it. Here the middleware covers every route in the
// group.
//
// The two rejection codes are the design, not a convention:
//   - unset token  -> 404. There is no sweeper deployed, and a 503 would
//     advertise one to a scanner. See the comment in the handler.
//   - wrong token  -> 401, so a crontab whose env line has drifted from the
//     server's says so instead of silently reporting "no such route".
//
// The header read is delegated to the handler's compare so the 404 branch (which
// must run BEFORE any comparison) stays in one place.
func (h *Handler) SnapSweepTokenGate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		// Do not read h.svc.snapSweepToken here to compare: the compare belongs
		// to the handler, and reading it in two places is how the two drift.
		if h.svc.snapSweepToken == "" {
			httpresponse.Error(w, http.StatusNotFound, middleware.RequestIDFrom(r.Context()),
				httpresponse.CodeNotFound, "Not found", nil)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// sha256Hex is the fixed-length digest both sides of the token compare are
// reduced to. subtle.ConstantTimeCompare returns 0 immediately for slices of
// DIFFERENT lengths, so comparing the raw tokens is length-dependent by
// construction: a caller could tell a 1-byte guess from a 64-byte one by
// timing. Hashing first makes both operands 32 bytes and the compare genuinely
// constant-time regardless of how close the guess was. It is a comparison
// hygiene measure, not a password hash — the token is a high-entropy shared
// secret held in the environment, not a credential anyone types.
func sha256Hex(s string) string {
	sum := sha256.Sum256([]byte(s))
	return hex.EncodeToString(sum[:])
}

// snapSweepRequest is the optional body. `limit` is a server-CLAMPED batch size
// and nothing else; every other field is ignored, and the route has no way to
// name an invoice, an order or a ref.
//
// A malformed body is IGNORED rather than rejected: the caller is a crontab
// line, and a pass that refuses to run because of a stray byte in a JSON body
// is a sweeper that silently stops protecting money. The clamp is the security
// control; the body is a convenience.
type snapSweepRequest struct {
	Limit int `json:"limit"`
}

// SweepSnapInvoices serves POST /midtrans/sweep: the token-gated background
// pass. It is registered in main.go next to the sibling Midtrans webhook and,
// for the same reason, is authenticated by a Midtrans-specific secret rather
// than by a user session — its caller is crontab, which holds no session.
func (h *Handler) SweepSnapInvoices(w http.ResponseWriter, r *http.Request) {
	rid := middleware.RequestIDFrom(r.Context())
	// 404 when MIDTRANS_SWEEP_TOKEN is unset is DELIBERATE, not the
	// ErrGatewayDisabled convention this codebase uses everywhere else, and the
	// same departure is enforced by SnapSweepTokenGate for any sibling route
	// added to this group. Do not "fix" it to 503.
	//
	// Everywhere else an unconfigured gateway is a misconfiguration of this
	// service, so the honest answer names it and the caller can go fix it. Here
	// the interesting question is not "is our gateway configured" but "IS A
	// SWEEPER DEPLOYED HERE AT ALL". A 503 on this route is an advertisement: a
	// scanner that finds one learns free of charge that the deployment runs a
	// payment sweeper, that it is currently disabled, and exactly which URL to
	// keep probing. A 404 leaks none of that — it is indistinguishable from a
	// route this build never had. The cost of the right answer is that a
	// misconfigured cron job sees a 404 in its log instead of a 503, and the
	// runbook is what tells an operator their job is not running.
	if h.svc.snapSweepToken == "" {
		httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Not found", nil)
		return
	}
	// Constant time, always. A byte-by-byte compare leaks the prefix length and
	// the position of the first difference, which is enough to recover a token
	// one request at a time. The comparison is over the SHA-256 digests because
	// subtle.ConstantTimeCompare returns 0 immediately for two slices of
	// DIFFERENT lengths, so comparing the raw tokens would be length-dependent
	// by construction. It is defence in depth over the route middleware's
	// existence check, not a replacement for it.
	presented := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")
	if subtle.ConstantTimeCompare([]byte(sha256Hex(presented)), []byte(sha256Hex(h.svc.snapSweepToken))) != 1 {
		slog.Warn("snap sweep rejected: bad or missing token", "request_id", rid, "path", r.URL.Path)
		httpresponse.Error(w, http.StatusUnauthorized, rid, httpresponse.CodeAuthentication, "Unauthenticated", nil)
		return
	}
	var body snapSweepRequest
	if r.Body != nil {
		// Errors are deliberately swallowed — see snapSweepRequest.
		_ = json.NewDecoder(r.Body).Decode(&body)
	}
	rep, err := h.svc.SweepSnapInvoices(r.Context(), body.Limit)
	if err != nil {
		if errors.Is(err, ErrGatewayDisabled) {
			httpresponse.Error(w, http.StatusServiceUnavailable, rid, httpresponse.CodeInternal, "Payment gateway is unavailable", nil)
			return
		}
		// Candidate-selection failure is a database error, and respondErr's
		// fallback would answer 400 with err.Error() — driver text on a money
		// surface. 500 with a safe message; the request_id carries the
		// correlation to the logs.
		slog.Error("snap sweep could not start", "request_id", rid, "error", err)
		httpresponse.Error(w, http.StatusInternalServerError, rid, httpresponse.CodeInternal, httpresponse.MsgInternal, nil)
		return
	}
	httpresponse.OK(w, http.StatusOK, rid, rep)
}
