package sales

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"

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

// Handler binds the sales service to HTTP. Methods are exported so the router
// can attach RequirePermission per route in main.
type Handler struct {
	svc             *Service
	resolveCustomer CustomerResolver
}

// CustomerResolver resolves the authenticated user's linked customer id —
// the same seam the finance handler uses for buyer-scoped /me views.
type CustomerResolver func(ctx context.Context, userID int64) (int64, bool, error)

func NewHandler(svc *Service) *Handler {
	return &Handler{svc: svc}
}

// WithCustomerResolver enables buyer-role object-level authorization on the
// /sales-orders/{id} endpoints (buyers pass the sales.* permission glob, so
// the route gate alone cannot scope them).
func (h *Handler) WithCustomerResolver(fn CustomerResolver) *Handler {
	h.resolveCustomer = fn
	return h
}

// authorizeBuyerOrder enforces ownership for buyer callers: staff roles and
// the storefront admin (AppECommerceAdmin) pass untouched; a regular buyer may
// only touch orders belonging to their linked customer. Mismatch yields
// ErrNotFound so foreign order ids are not distinguishable from missing ones.
func (h *Handler) authorizeBuyerOrder(r *http.Request, orderCustomerID int64) error {
	u := auth.UserFrom(r.Context())
	if u == nil || (u.Role != auth.RoleBuyer && u.Role != "customer" && u.Role != "b2b_client") || u.CanAccessApp(auth.AppECommerceAdmin) {
		return nil
	}
	if h.resolveCustomer == nil {
		return ErrNotFound
	}
	id, ok, err := h.resolveCustomer(r.Context(), u.ID)
	if err != nil {
		return err
	}
	if !ok || id != orderCustomerID {
		return ErrNotFound
	}
	return nil
}

// loadOwnedOrder fetches the order by path id (or SO number) and applies the buyer ownership
// guard, writing the error response itself. ok=false means the response is
// already written.
func (h *Handler) loadOwnedOrder(w http.ResponseWriter, r *http.Request) (*SalesOrder, bool) {
	idParam := strings.TrimSpace(chi.URLParam(r, "id"))
	var o *SalesOrder
	var err error
	if id, parseErr := strconv.ParseInt(idParam, 10, 64); parseErr == nil && id > 0 {
		o, err = h.svc.Get(r.Context(), id)
	} else if idParam != "" {
		o, err = h.svc.ForReturnLookup(r.Context(), idParam)
	} else {
		httpresponse.Error(w, http.StatusNotFound, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeNotFound, "Not found", nil)
		return nil, false
	}
	if err != nil {
		respondErr(w, r, err)
		return nil, false
	}
	if err := h.authorizeBuyerOrder(r, o.CustomerID); err != nil {
		respondErr(w, r, err)
		return nil, false
	}
	return o, true
}

func (h *Handler) ListOrders(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	// Normalize filters: trim whitespace so "?status=" and "?status=%20"
	// are treated as no-filter (the repository maps empty strings to NULL via
	// textOrNil). Unknown status values yield no matches, not an error — the
	// endpoint always returns an (possibly empty) list.
	orderType := strings.TrimSpace(q.Get("order_type"))
	if orderType == "" {
		orderType = strings.TrimSpace(q.Get("type"))
	}
	// Alias: ?q is frontend shorthand for ?search.
	search := strings.TrimSpace(q.Get("search"))
	if search == "" {
		search = strings.TrimSpace(q.Get("q"))
	}
	sortBy := strings.TrimSpace(q.Get("sort_by"))
	sortDir := strings.TrimSpace(q.Get("sort_dir"))
	if sortBy == "" {
		if sortParam := strings.TrimSpace(q.Get("sort")); sortParam != "" {
			parts := strings.Split(sortParam, ":")
			sortBy = parts[0]
			if len(parts) > 1 {
				sortDir = parts[1]
			}
		}
	}

	// Permissive: unknown/empty status, payment_status, order_type never 400;
	// repository maps empty to no-filter and derived payment_status filtering
	// is in-memory with empty-set fallback (§2 list).
	f := SOFilter{
		Page:          queryInt(q.Get("page")),
		PerPage:       queryInt(q.Get("per_page")),
		Search:        search,
		Status:        strings.TrimSpace(q.Get("status")),
		PaymentStatus: strings.TrimSpace(q.Get("payment_status")),
		CustomerID:    parseInt64(q.Get("customer_id")),
		OrderType:     orderType,
		SourceType:    strings.TrimSpace(q.Get("source_type")),
		DateFrom:      strings.TrimSpace(q.Get("date_from")),
		DateTo:        strings.TrimSpace(q.Get("date_to")),
		StoreID:       parseInt64Ptr(q.Get("store_id")),
		// The warehouse packing list excludes store-pickup orders: their goods
		// change hands at the POS till and the picking/shipping chain never
		// runs for them. Opt-in via ?exclude_pickup=1 so every other caller
		// (sales desk, buyer list, reports) keeps its current surface.
		ExcludePickup: q.Get("exclude_pickup") == "1",
		SortBy:        sortBy,
		SortDir:       sortDir,
	}
	list, total, err := h.svc.List(r.Context(), f)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	summary, err := h.svc.OrdersSummary(r.Context(), f.AllowedStoreIDs)
	if err != nil {
		logger.FromContext(r.Context()).Error("failed to get orders summary", "err", err)
	}
	extra := map[string]any{
		"summary": summary,
	}
	httpresponse.OKPaginatedWithMeta(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list, pageOrDefault(f.Page), perPageOrDefault(f.PerPage), total, extra)
}

func (h *Handler) GetOrder(w http.ResponseWriter, r *http.Request) {
	o, ok := h.loadOwnedOrder(w, r)
	if !ok {
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

// ForReturnLookup serves GET /sales-orders/for-return?number= for the POS
// return dialog. Same narrow contract as the service: own stores only,
// exact SO number, 404 otherwise.
func (h *Handler) ForReturnLookup(w http.ResponseWriter, r *http.Request) {
	o, err := h.svc.ForReturnLookup(r.Context(), r.URL.Query().Get("number"))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	if storeID := parseInt64(r.URL.Query().Get("store_id")); storeID > 0 {
		if o.StoreID == nil || *o.StoreID != storeID {
			respondErr(w, r, ErrNotFound)
			return
		}
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

// ForPickupLookup serves GET /sales-orders/for-pickup for POS store pickup management.
func (h *Handler) ForPickupLookup(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	search := strings.TrimSpace(q.Get("q"))
	if search == "" {
		search = strings.TrimSpace(q.Get("search"))
	}
	f := PickupFilter{
		Number:  strings.TrimSpace(q.Get("number")),
		Search:  search,
		Status:  strings.TrimSpace(q.Get("status")),
		Page:    queryInt(q.Get("page")),
		PerPage: queryInt(q.Get("per_page")),
	}
	if f.Page < 1 {
		f.Page = 1
	}
	if f.PerPage < 1 || f.PerPage > 100 {
		f.PerPage = 20
	}
	list, total, err := h.svc.ForPickupLookup(r.Context(), f)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OKPaginated(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list, f.Page, f.PerPage, total)
}
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
	var in SOInput
	if !decode(w, r, &in) {
		return
	}
	o, err := h.svc.Create(r.Context(), in)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusCreated, middleware.RequestIDFrom(r.Context()), o)
}

func (h *Handler) ListSupervisors(w http.ResponseWriter, r *http.Request) {
	list, err := h.svc.ListSupervisors(r.Context())
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list)
}

func (h *Handler) UpdateOrder(w http.ResponseWriter, r *http.Request) {
	var in SOInput
	if !decode(w, r, &in) {
		return
	}
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	o, err := h.svc.Update(r.Context(), pathID(r), in)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

func (h *Handler) DeleteOrder(w http.ResponseWriter, r *http.Request) {
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	if err := h.svc.Delete(r.Context(), pathID(r)); err != nil {
		respondErr(w, r, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

func (h *Handler) ConfirmOrder(w http.ResponseWriter, r *http.Request) {
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	o, err := h.svc.Confirm(r.Context(), pathID(r))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

func (h *Handler) CancelOrder(w http.ResponseWriter, r *http.Request) {
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	o, err := h.svc.Cancel(r.Context(), pathID(r))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

// VoidPOS handles POST /pos/{id}/void — admin/owner recovery for a mis-rung
// cashier transaction (000151 flow audit item 7). Body {reason}. Money is
// refunded (negative payment row + drawer cash_out for cash), goods restock,
// the invoice cancels — all in one transaction. Content-free 200 on success.
func (h *Handler) VoidPOS(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Reason string `json:"reason"`
	}
	if !decode(w, r, &in) {
		return
	}
	o, err := h.svc.VoidPOS(r.Context(), pathID(r), in.Reason)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

// PayPOSPickup handles POST /pos/{id}/pay — the till-side payment record for
// an unpaid pickup order (transfer/QRIS proof verified by the store head, or
// cash). Body {payment_method, reference_number}. Settles the invoice and
// flips the pickup card to "Lunas" so Serahkan Barang appears.
func (h *Handler) PayPOSPickup(w http.ResponseWriter, r *http.Request) {
	var in PayPOSPickupInput
	if !decode(w, r, &in) {
		return
	}
	o, err := h.svc.PayPOSPickup(r.Context(), pathID(r), in)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

func (h *Handler) UpdateSalesOrderStatus(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Status string  `json:"status"`
		Note   *string `json:"note"`
	}
	if !decode(w, r, &in) {
		return
	}
	status := strings.ToLower(strings.TrimSpace(in.Status))
	// Both transitions delegate to Confirm/Cancel; guard once up front so a
	// buyer cannot move someone else's order through either branch.
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	switch status {
	case "approved", "confirmed":
		// Approval is a supervisor act and must ride the SAME permission POST
		// /{id}/confirm requires. This route admits .view/.update holders (for
		// fulfillment) and the buyer/cashier carve-outs (for receipt and till
		// handover), so without this check a .view-only caller — sales,
		// cashier, store manager — or the order's OWN BUYER could self-approve
		// through this alias (reported in the 2 Nov flow audit). admin/owner
		// bypass inside Can(); warehouse holds neither view nor confirm, so
		// the old warehouse-only role check is subsumed by this gate.
		if u := auth.UserFrom(r.Context()); u == nil || !u.Can("sales.sales-orders.confirm") {
			httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
				httpresponse.CodeForbidden, "hanya atasan (SPV/BM/GM) yang dapat menyetujui sales order", nil)
			return
		}
		o, err := h.svc.Confirm(r.Context(), pathID(r))
		if err != nil {
			respondErr(w, r, err)
			return
		}
		httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
		return
	case "cancelled", "canceled":
		if u := auth.UserFrom(r.Context()); u != nil && u.Role == auth.RoleWarehouse {
			httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
				httpresponse.CodeForbidden, "warehouse role cannot cancel sales orders", nil)
			return
		}
		o, err := h.svc.Cancel(r.Context(), pathID(r))
		if err != nil {
			respondErr(w, r, err)
			return
		}
		httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
		return
	case "picking", "shipped", "delivered":
		u := auth.UserFrom(r.Context())
		if u != nil && u.Role != auth.RoleWarehouse && u.Role != auth.RoleAdmin && u.Role != auth.RoleOwner && !u.Can("distribution.packing-orders.update") {
			// A buyer can confirm receipt of their own delivered/shipped order,
			// and a cashier/store manager can confirm customer handover of a store pickup order.
			if status == "delivered" && ((u.Role == auth.RoleBuyer && u.CanAccessApp(auth.AppECommerce)) || u.Role == auth.RoleCashier || u.Role == auth.RoleStoreManager) {
				// allowed
			} else {
				httpresponse.Error(w, http.StatusForbidden, middleware.RequestIDFrom(r.Context()),
					httpresponse.CodeForbidden, "Hanya staf gudang, kasir, atau superadmin yang berhak memproses pengemasan dan penyelesaian pengiriman pesanan", nil)
				return
			}
		}
		// Forward-only fulfilment step driven by a warehouse operator.
		o, err := h.svc.AdvanceFulfillment(r.Context(), pathID(r), status)
		if err != nil {
			respondErr(w, r, err)
			return
		}
		httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
		return
	default:
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "status must be confirmed, picking, shipped, delivered or cancelled", nil)
		return
	}
}
func (h *Handler) AddSalesOrderNote(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Note string `json:"note"`
	}
	if !decode(w, r, &in) {
		return
	}
	if strings.TrimSpace(in.Note) == "" {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "note is required", nil)
		return
	}
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	o, err := h.svc.AddNote(r.Context(), pathID(r), strings.TrimSpace(in.Note))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

// UpdateOrderNotes replaces the order's notes (PUT /sales-orders/{id}/notes).
// Empty string or null clears the field; cancelled orders are rejected with
// BUSINESS_RULE_VIOLATION.
func (h *Handler) UpdateOrderNotes(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Notes *string `json:"notes"`
	}
	if !decode(w, r, &in) {
		return
	}
	if _, ok := h.loadOwnedOrder(w, r); !ok {
		return
	}
	o, err := h.svc.UpdateNotes(r.Context(), pathID(r), in.Notes)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), o)
}

func (h *Handler) ProcessPOS(w http.ResponseWriter, r *http.Request) {
	var in POSInput
	if !decode(w, r, &in) {
		return
	}
	o, inv, err := h.svc.ProcessPOS(r.Context(), in)
	if err != nil {

		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusCreated, middleware.RequestIDFrom(r.Context()), map[string]any{
		"sales_order": o,
		"invoice":     inv,
	})
}

// SnapPOSStatus serves the till-side recovery poll for deferred-gateway POS
// (QRIS) orders. See Service.SnapPOSStatus.
func (h *Handler) SnapPOSStatus(w http.ResponseWriter, r *http.Request) {
	out, err := h.svc.SnapPOSStatus(r.Context(), pathID(r))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), out)
}

// MyOrders lists the authenticated buyer's own e-commerce orders.
func (h *Handler) MyOrders(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	user := UserFrom(r)
	f := SOFilter{
		Page:    queryInt(q.Get("page")),
		PerPage: queryInt(q.Get("per_page")),
		Status:  q.Get("status"),
	}
	list, total, err := h.svc.MyOrders(r.Context(), user.ID, f)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OKPaginated(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list, pageOrDefault(f.Page), perPageOrDefault(f.PerPage), total)
}

// MyOrdersSummary returns order counts by lifecycle status for the buyer.
func (h *Handler) MyOrdersSummary(w http.ResponseWriter, r *http.Request) {
	user := UserFrom(r)
	summary, err := h.svc.MyOrdersSummary(r.Context(), user.ID)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), summary)
}

// PayCheckout settles the buyer's own e-commerce order.
func (h *Handler) PayCheckout(w http.ResponseWriter, r *http.Request) {
	var in PayCheckoutInput
	if !decode(w, r, &in) {
		return
	}
	user := UserFrom(r)
	o, inv, err := h.svc.PayCheckout(r.Context(), pathID(r), user.ID, in)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
		"sales_order": o,
		"invoice":     inv,
	})
}

// SnapToken issues a Midtrans Snap transaction for the buyer's own order.
// The request body can optionally provide a finish_url or callback_url;
// otherwise Origin / Referer headers or server config are used.
func (h *Handler) SnapToken(w http.ResponseWriter, r *http.Request) {
	var in struct {
		FinishURL   string `json:"finish_url"`
		CallbackURL string `json:"callback_url"`
	}
	// The body is optional here (an empty one falls back to Origin/Referer), so
	// this decodes by hand rather than through DecodeJSON. SafeMessage keeps the
	// Go type json.Unmarshal names off the wire.
	if r.Body != nil {
		raw, err := io.ReadAll(r.Body)
		if err != nil {
			httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
				httpresponse.CodeValidation, httpresponse.SafeMessage(err), nil)
			return
		}
		if len(bytes.TrimSpace(raw)) > 0 {
			if err := json.Unmarshal(raw, &in); err != nil {
				httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
					httpresponse.CodeValidation, httpresponse.SafeMessage(err), nil)
				return
			}
		}
	}
	var finishURL string
	if strings.TrimSpace(in.FinishURL) != "" {
		finishURL = strings.TrimSpace(in.FinishURL)
	} else if strings.TrimSpace(in.CallbackURL) != "" {
		finishURL = strings.TrimSpace(in.CallbackURL)
	} else if origin := strings.TrimSpace(r.Header.Get("Origin")); origin != "" {
		finishURL = strings.TrimRight(origin, "/") + "/checkout/confirmation"
	} else if ref := strings.TrimSpace(r.Header.Get("Referer")); ref != "" {
		if u, err := url.Parse(ref); err == nil && u.Host != "" {
			finishURL = u.Scheme + "://" + u.Host + "/checkout/confirmation"
		}
	}
	user := UserFrom(r)
	out, err := h.svc.SnapToken(r.Context(), pathID(r), user.ID, finishURL)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), out)
}

// OrderQRIS serves the in-page QRIS charge for the ERP pay dialog — the
// back-office sibling of the till's deferred-QRIS: the dialog draws the QR
// itself, there is no Snap page and no second tab. Route-gated exactly like
// SnapToken's staff path (ecomOrPermGate on sales.sales-orders.view); the
// required idempotency key comes from the route group.
func (h *Handler) OrderQRIS(w http.ResponseWriter, r *http.Request) {
	out, err := h.svc.OrderQRIS(r.Context(), pathID(r))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), out)
}

// MidtransNotification receives Midtrans Payment Notifications (webhooks).
// PUBLIC route — authenticity comes from the HMAC signature check in the
// service, never from session auth. Responses follow Midtrans guidance:
// 200 only after verified processing or a deliberate recorded no-op;
// 401 bad signature, 404 unknown order ref, 409 amount mismatch — those
// non-2xx codes make Midtrans retry delivery.
func (h *Handler) MidtransNotification(w http.ResponseWriter, r *http.Request) {
	var n finance.MidtransNotification
	if !decode(w, r, &n) {
		return
	}
	outcome, err := h.svc.HandleMidtransNotification(r.Context(), n)
	if err != nil {
		rid := middleware.RequestIDFrom(r.Context())
		switch {
		case errors.Is(err, ErrGatewayDisabled):
			httpresponse.Error(w, http.StatusServiceUnavailable, rid, httpresponse.CodeInternal, "Payment gateway is unavailable", nil)
		case errors.Is(err, finance.ErrMidtransBadSignature):
			httpresponse.Error(w, http.StatusUnauthorized, rid, httpresponse.CodeAuthentication, "Invalid notification signature", nil)
		case errors.Is(err, finance.ErrMidtransUnknownOrder):
			httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Unknown order reference", nil)
		case errors.Is(err, finance.ErrMidtransAmountMismatch):
			httpresponse.Error(w, http.StatusConflict, rid, httpresponse.CodeBusinessViolation, "Amount mismatch", nil)
		default:
			httpresponse.Error(w, http.StatusInternalServerError, rid, httpresponse.CodeInternal, httpresponse.MsgInternal, nil)
		}
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]string{"outcome": outcome})
}

// SnapPaymentStatus is the buyer's missed-webhook recovery poll: it looks the
// transaction up at Midtrans server-side and settles the invoice when the
// provider reports captured/settled — the browser result is never trusted.
func (h *Handler) SnapPaymentStatus(w http.ResponseWriter, r *http.Request) {
	user := UserFrom(r)
	out, err := h.svc.SnapPaymentStatus(r.Context(), pathID(r), user.ID)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), out)
}

// --- helpers

func pathID(r *http.Request) int64 {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	return id
}

// UserFrom returns the authenticated user, or nil.
func UserFrom(r *http.Request) *auth.User {
	return auth.UserFrom(r.Context())
}

func decode(w http.ResponseWriter, r *http.Request, dst any) bool {
	return httpresponse.DecodeJSON(w, r, middleware.RequestIDFrom(r.Context()), dst)
}

func respondErr(w http.ResponseWriter, r *http.Request, err error) {
	var constraint *pgconn.PgError
	if errors.As(err, &constraint) && constraint.ConstraintName == "cashier_shift_open" {
		httpresponse.Error(w, http.StatusConflict, middleware.RequestIDFrom(r.Context()), httpresponse.CodeBusinessViolation, "Cashier shift is closed; use the authorized correction workflow", nil)
		return
	}
	rid := middleware.RequestIDFrom(r.Context())
	switch {
	case errors.Is(err, ErrNotFound):
		httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Not found", nil)
	case errors.Is(err, ErrGatewayDisabled):
		// Gateway keys unset: safe message, never the secret.
		httpresponse.Error(w, http.StatusServiceUnavailable, rid, httpresponse.CodeInternal, "Payment gateway is unavailable", nil)
	case errors.Is(err, ErrForbidden), errors.Is(err, ErrFulfillmentForbidden):
		httpresponse.Error(w, http.StatusForbidden, rid, httpresponse.CodeForbidden, err.Error(), nil)
	case errors.Is(err, ErrNotPOSOrder), errors.Is(err, ErrNotPickupOrder):
		httpresponse.Error(w, http.StatusConflict, rid, httpresponse.CodeBusinessViolation, err.Error(), nil)
	case errors.Is(err, ErrUnpaidOrderFulfillment):
		httpresponse.Error(w, http.StatusConflict, rid, httpresponse.CodeBusinessViolation, err.Error(), nil)
	case errors.Is(err, ErrPaymentNotAllowed):
		httpresponse.Error(w, http.StatusBadRequest, rid, httpresponse.CodeValidation, err.Error(), nil)
	case errors.Is(err, ErrInvalidStatus) || errors.Is(err, ErrCannotModify) || errors.Is(err, ErrCannotCancel) || errors.Is(err, ErrPaidCannotCancel) || errors.Is(err, ErrCancelled) || errors.Is(err, ErrInsufficientStock):
		httpresponse.Error(w, http.StatusConflict, rid, httpresponse.CodeBusinessViolation, err.Error(), nil)
	case errors.Is(err, ErrVoucherNotUsable):
		// Unknown/expired/already-used voucher at redemption time.
		httpresponse.Error(w, http.StatusConflict, rid, httpresponse.CodeBusinessViolation, err.Error(), nil)
	case errors.Is(err, ErrItemRequired) || errors.Is(err, ErrCustomerRequired) || errors.Is(err, ErrCheckoutValidation):
		httpresponse.Error(w, http.StatusBadRequest, rid, httpresponse.CodeValidation, err.Error(), nil)
	case err != nil:
		httpresponse.SafeError(w, rid, logger.FromContext(r.Context()), http.StatusBadRequest, httpresponse.CodeValidation, err)
	}
}

func queryInt(s string) int {
	n, _ := strconv.Atoi(s)
	return n
}

func parseInt64(s string) int64 {
	n, _ := strconv.ParseInt(s, 10, 64)
	return n
}

func parseInt64Ptr(s string) *int64 {
	if s == "" {
		return nil
	}
	if n, err := strconv.ParseInt(s, 10, 64); err == nil {
		return &n
	}
	return nil
}

func pageOrDefault(n int) int {
	if n < 1 {
		return 1
	}
	return n
}

func perPageOrDefault(n int) int {
	if n < 1 || n > 100 {
		return 20
	}
	return n
}

// --- sales vouchers ------------------------------------------------------------

func (h *Handler) CreateSalesVoucher(w http.ResponseWriter, r *http.Request) {
	in := SalesVoucherInput{}
	if !decode(w, r, &in) {
		return
	}
	svcSV, err := h.svc.CreateSalesVoucher(r.Context(), in)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusCreated, middleware.RequestIDFrom(r.Context()), svcSV)
}

func (h *Handler) GetSalesVoucherByID(w http.ResponseWriter, r *http.Request) {
	id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
	svcSV, err := h.svc.GetSalesVoucherByID(r.Context(), id)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), svcSV)
}

func (h *Handler) GetSalesVoucherByNumber(w http.ResponseWriter, r *http.Request) {
	voucherNumber := chi.URLParam(r, "voucher_number")
	svcSV, err := h.svc.GetSalesVoucherByNumber(r.Context(), voucherNumber)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), svcSV)
}

func (h *Handler) UpdateSalesVoucherStatus(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Status string `json:"status"`
	}
	if !decode(w, r, &in) {
		return
	}
	svcSV, err := h.svc.UpdateSalesVoucherStatus(r.Context(), pathID(r), in.Status)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), svcSV)
}

// UpdateSalesVoucher is alias for PUT /sales-vouchers/{id} expected by POS.
// Accepts {status: "..."} and delegates to UpdateSalesVoucherStatus.
func (h *Handler) UpdateSalesVoucher(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Status string `json:"status"`
	}
	if !decode(w, r, &in) {
		return
	}
	if in.Status == "" {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()), httpresponse.CodeValidation, "status is required", nil)
		return
	}
	svcSV, err := h.svc.UpdateSalesVoucherStatus(r.Context(), pathID(r), in.Status)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), svcSV)
}

// DeleteSalesVoucher soft-deletes via status transition to expired (permissive).
func (h *Handler) DeleteSalesVoucher(w http.ResponseWriter, r *http.Request) {
	// Try to transition to expired; if already expired treat as success.
	svcSV, err := h.svc.UpdateSalesVoucherStatus(r.Context(), pathID(r), "expired")
	if err != nil {
		if errors.Is(err, ErrNotFound) {
			respondErr(w, r, err)
			return
		}
		// Already expired or used->expired is allowed; other invalid transitions -> 409 via respondErr
		// If idempotent delete on already-expired, return 204.
		if err.Error() == "invalid status transition: expired -> expired" {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), svcSV)
}

// ValidateSalesVoucher checks whether a voucher code is usable and returns
// the applicable discount amount for the given subtotal.
func (h *Handler) ValidateSalesVoucher(w http.ResponseWriter, r *http.Request) {
	var in struct {
		Code     string `json:"code"`
		Subtotal int64  `json:"subtotal"`
	}
	if !decode(w, r, &in) {
		return
	}
	if in.Code == "" {
		httpresponse.Error(w, http.StatusBadRequest, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeValidation, "code is required", nil)
		return
	}
	v, err := h.svc.GetSalesVoucherByNumber(r.Context(), in.Code)
	if err != nil {
		// An unknown code is a VALIDATION ANSWER, not a missing resource: the
		// cashier asked "is this voucher usable?", so the till must render
		// "voucher tidak ditemukan" instead of a 404 that reads like a broken
		// endpoint. Other failures keep their real status.
		if errors.Is(err, ErrNotFound) {
			httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
				"valid":  false,
				"reason": "voucher tidak ditemukan",
			})
			return
		}
		respondErr(w, r, err)
		return
	}
	// Simple validation: voucher must be "available" and not expired.
	valid := v.Status == "available"
	if valid && v.ExpiredAt != nil {
		// expiry checked at handler level (service doesn't mutate status)
		// keep it simple: just pass the voucher through
	}
	discount := v.Amount
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), map[string]any{
		"valid":           valid,
		"voucher":         v,
		"discount_amount": discount,
	})
}

func (h *Handler) ListSalesVouchers(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	// Aliases for POS/ERP wiring mismatches: ?q -> search/voucher_number; ?code -> voucher_number.
	search := strings.TrimSpace(q.Get("search"))
	if search == "" {
		search = strings.TrimSpace(q.Get("q"))
	}
	voucherNumber := strings.TrimSpace(q.Get("voucher_number"))
	if voucherNumber == "" {
		voucherNumber = strings.TrimSpace(q.Get("code"))
	}
	if voucherNumber == "" && search != "" {
		voucherNumber = search
	}
	f := SalesVoucherFilter{
		Page:          queryInt(q.Get("page")),
		PerPage:       queryInt(q.Get("per_page")),
		Status:        strings.TrimSpace(q.Get("status")),
		Type:          strings.TrimSpace(q.Get("type")),
		VoucherNumber: voucherNumber,
	}
	list, total, err := h.svc.ListSalesVouchers(r.Context(), f)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OKPaginated(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list, pageOrDefault(f.Page), perPageOrDefault(f.PerPage), total)
}

// --- sales returns (§11.4) ---------------------------------------------------

func (h *Handler) CreateReturn(w http.ResponseWriter, r *http.Request) {
	var in ReturnInput
	if !decode(w, r, &in) {
		return
	}
	// Buyers may only return their own orders (sales.returns.create passes
	// the buyer's sales.* glob, so the route gate alone cannot scope it).
	target, err := h.svc.Get(r.Context(), in.SalesOrderID)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	if err := h.authorizeBuyerOrder(r, target.CustomerID); err != nil {
		respondErr(w, r, err)
		return
	}
	ret, err := h.svc.CreateReturn(r.Context(), in)
	if err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusCreated, middleware.RequestIDFrom(r.Context()), ret)
}

func (h *Handler) GetReturn(w http.ResponseWriter, r *http.Request) {
	ret, err := h.svc.GetReturn(r.Context(), pathID(r))
	if err != nil {
		respondErr(w, r, err)
		return
	}
	if err := h.authorizeBuyerOrder(r, ret.CustomerID); err != nil {
		respondErr(w, r, err)
		return
	}
	httpresponse.OK(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), ret)
}

func (h *Handler) ListReturns(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	storeID := parseInt64(q.Get("store_id"))
	f := ReturnFilter{
		Page:         queryInt(q.Get("page")),
		PerPage:      queryInt(q.Get("per_page")),
		SalesOrderID: parseInt64(q.Get("sales_order_id")),
		CustomerID:   parseInt64(q.Get("customer_id")),
		StoreID:      storeID,
	}
	if storeID > 0 {
		f.AllowedStoreIDs = []int64{storeID}
	}
	list, total, err := h.svc.ListReturns(r.Context(), f)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OKPaginated(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list, pageOrDefault(f.Page), perPageOrDefault(f.PerPage), total)
}

// MyReturns lists the authenticated buyer's own returns. Buyers hold
// sales.returns.create but deliberately not .view (RBAC audit 2026-08-25),
// so the storefront history needs this scoped view.
func (h *Handler) MyReturns(w http.ResponseWriter, r *http.Request) {
	u := UserFrom(r)
	if u == nil || h.resolveCustomer == nil {
		httpresponse.Error(w, http.StatusNotFound, middleware.RequestIDFrom(r.Context()),
			httpresponse.CodeNotFound, "Not found", nil)
		return
	}
	customerID, ok, err := h.resolveCustomer(r.Context(), u.ID)
	if err != nil || !ok {
		httpresponse.OKPaginated(w, http.StatusOK, middleware.RequestIDFrom(r.Context()),
			[]Return{}, 1, 20, 0)
		return
	}
	q := r.URL.Query()
	f := ReturnFilter{Page: queryInt(q.Get("page")), PerPage: queryInt(q.Get("per_page")), CustomerID: customerID}
	list, total, err := h.svc.ListReturns(r.Context(), f)
	if err != nil {
		httpresponse.Internal(w, middleware.RequestIDFrom(r.Context()), logger.FromContext(r.Context()), err)
		return
	}
	httpresponse.OKPaginated(w, http.StatusOK, middleware.RequestIDFrom(r.Context()), list, pageOrDefault(f.Page), perPageOrDefault(f.PerPage), total)
}
