// Package reportproxy proxies PDF/HTML rendering to the NextReport Engine.
// It serves print and report generation requests using active database templates
// and authoritative data shaped from domain models.
package reportproxy

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

	"github.com/TaruLab/keluargamart-backend/internal/auth"
	"github.com/TaruLab/keluargamart-backend/internal/distribution"
	"github.com/TaruLab/keluargamart-backend/internal/finance"
	"github.com/TaruLab/keluargamart-backend/internal/purchasing"
	"github.com/TaruLab/keluargamart-backend/internal/reports/templates"
	"github.com/TaruLab/keluargamart-backend/pkg/httpresponse"
	"github.com/TaruLab/keluargamart-backend/pkg/middleware"
	"github.com/shopspring/decimal"
)

// timeout bounds every report render round-trip.
const timeout = 30 * time.Second

// templatePerms gates each report template with its own existing permission.
var templatePerms = map[string]string{
	// purchasing
	"po-print":      "purchasing.purchase-orders.view",
	"voucher-print": "purchasing.vouchers.view",
	"receipt-print": "purchasing.goods-receipts.view",
	// sales
	"b2b-order":         "cart.checkout",
	"pos-receipt":       "sales.pos.create",
	"sales-by-customer": "sales.reports.view",
	"sales-by-product":  "sales.reports.view",
	"sales-summary":     "sales.reports.view",
	"transaction-list":  "sales.sales-orders.view",
	// finance
	"balance-sheet":         "finance.reports.view",
	"payables-aging":        "finance.reports.view",
	"profit-loss":           "finance.reports.view",
	"purchases-by-product":  "finance.reports.view",
	"purchases-by-supplier": "finance.reports.view",
	"receivables-aging":     "finance.reports.view",
	"cashflow-statement":    "finance.reports.view",
	"trial-balance":         "finance.reports.view",
	"consolidated-report":   "finance.reports.view",
	// inventory
	"stock-card":      "inventory.stock.view",
	"stock-movements": "inventory.stock.view",
	"stock-summary":   "inventory.stock.view",
	"stock-valuation": "inventory.stock.view",
	// distribution
	"lhpp-print": "distribution.delivery-orders.view",
	"do-print":   "distribution.delivery-orders.view",
	// cashier
	"shift-list":  "cashier.shifts.view",
	"shift-print": "cashier.shifts.view",
	// b2b
	"buyer-invoice": "cart.checkout",
	"invoice-print": "fakturisasi.invoices.view",
}

var templateModule = map[string]string{
	"po-print": "purchasing", "voucher-print": "purchasing", "receipt-print": "purchasing",
	"b2b-order": "sales", "pos-receipt": "sales", "sales-by-customer": "sales", "sales-by-product": "sales", "sales-summary": "sales", "transaction-list": "sales",
	"balance-sheet": "finance", "cashflow-statement": "finance", "payables-aging": "finance", "profit-loss": "finance", "purchases-by-product": "finance", "purchases-by-supplier": "finance", "receivables-aging": "finance", "trial-balance": "finance", "consolidated-report": "finance",
	"stock-card": "inventory", "stock-movements": "inventory", "stock-summary": "inventory", "stock-valuation": "inventory",
	"lhpp-print": "distribution", "do-print": "distribution",
	"shift-list": "cashier", "shift-print": "cashier",
	"buyer-invoice": "b2b", "invoice-print": "b2b",
}

var formatContentType = map[string]string{
	"pdf":  "application/pdf",
	"html": "text/html; charset=utf-8",
}

// InvoiceResolver fetches authoritative invoice data.
type InvoiceResolver interface {
	GetInvoice(ctx context.Context, id int64) (*finance.Invoice, error)
}

// POResolver fetches authoritative purchase order data.
type POResolver interface {
	GetOrder(ctx context.Context, id int64) (*purchasing.PurchaseOrder, error)
}

// DOResolver fetches authoritative delivery order data.
type DOResolver interface {
	GetDeliveryOrder(ctx context.Context, id int64) (*distribution.DeliveryOrder, error)
}

// GRResolver fetches authoritative goods receipt data.
type GRResolver interface {
	GetGR(ctx context.Context, id int64) (*purchasing.GoodsReceipt, error)
}

// TemplateResolver fetches active report templates from DB.
type TemplateResolver interface {
	GetActiveTemplate(ctx context.Context, reportCode string) (*templates.ReportTemplate, error)
}

// CustomerResolver maps an authenticated user to their linked customer
// (same seam the finance/distribution /me views use).
type CustomerResolver func(ctx context.Context, userID int64) (customerID int64, ok bool, err error)

// Handler proxies render requests to NextReport engine.
type Handler struct {
	nextReportBase string
	client         *http.Client
	tplResolver    TemplateResolver
	invResolver    InvoiceResolver
	poResolver     POResolver
	doResolver     DOResolver
	grResolver     GRResolver
	custResolver   CustomerResolver
}

// NewHandler builds a Handler for the given NextReport engine base URL.
func NewHandler(nextReportBase string) *Handler {
	return &Handler{
		nextReportBase: strings.TrimRight(nextReportBase, "/"),
		client:         &http.Client{Timeout: timeout},
	}
}


// SetNextReportBase sets the NextReport engine base URL.
func (h *Handler) SetNextReportBase(url string) {
	h.nextReportBase = strings.TrimRight(url, "/")
}

// SetTemplateResolver sets the template database resolver.
func (h *Handler) SetTemplateResolver(tr TemplateResolver) {
	h.tplResolver = tr
}

// SetInvoiceResolver sets the finance invoice resolver.
func (h *Handler) SetInvoiceResolver(ir InvoiceResolver) {
	h.invResolver = ir
}

// SetPOResolver sets the purchasing PO resolver.
func (h *Handler) SetPOResolver(pr POResolver) {
	h.poResolver = pr
}

// SetDOResolver sets the distribution DO resolver.
func (h *Handler) SetDOResolver(dr DOResolver) {
	h.doResolver = dr
}

// SetGRResolver sets the purchasing goods receipt resolver.
func (h *Handler) SetGRResolver(gr GRResolver) {
	h.grResolver = gr
}

// SetCustomerResolver wires the user→customer seam used to ownership-check
// buyer-reachable templates (buyer-invoice).
func (h *Handler) SetCustomerResolver(fn CustomerResolver) {
	h.custResolver = fn
}

type renderBody struct {
	Template string          `json:"template"`
	Format   string          `json:"format"`
	Data     json.RawMessage `json:"data"`
	Options  map[string]any  `json:"options"`
}

type previewBody struct {
	ReportCode string          `json:"report_code"`
	Template   json.RawMessage `json:"template"`
	Format     string          `json:"format"`
	Data       json.RawMessage `json:"data"`
}

// Preview renders an unsaved draft schema for the visual designer.
// Unlike Render (active DB template by name), the caller posts the draft
// template_data directly plus its report_code for per-template RBAC.
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
	rid := middleware.RequestIDFrom(r.Context())
	q := r.URL.Query()

	var body previewBody
	if r.Body != nil {
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
			httpresponse.Error(w, http.StatusBadRequest, rid, httpresponse.CodeValidation, "Invalid request body", nil)
			return
		}
	}

	code := firstNonEmpty(q.Get("report_code"), body.ReportCode)
	format := strings.ToLower(firstNonEmpty(q.Get("format"), body.Format, "html"))
	_, ok := formatContentType[format]
	if code == "" || len(body.Template) == 0 || !ok {
		httpresponse.Error(w, http.StatusBadRequest, rid, httpresponse.CodeValidation, "Unknown report code, template, or format", nil)
		return
	}
	perm, known := templatePerms[code]
	if !known {
		httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Unknown report template", nil)
		return
	}
	u := auth.UserFrom(r.Context())
	if u == nil {
		httpresponse.Error(w, http.StatusUnauthorized, rid, httpresponse.CodeAuthentication, "Unauthenticated", nil)
		return
	}
	if !u.Can(perm) && !u.Can("system.reports.view") {
		httpresponse.Error(w, http.StatusForbidden, rid, httpresponse.CodeForbidden, "Forbidden", nil)
		return
	}
	if h.nextReportBase == "" {
		httpresponse.Error(w, http.StatusBadGateway, rid, httpresponse.CodeInternal, "NextReport service not configured", nil)
		return
	}

	var dataAny any = map[string]any{}
	if len(body.Data) != 0 {
		_ = json.Unmarshal(body.Data, &dataAny)
	}

	rendered, err := h.renderNextReport(r.Context(), body.Template, dataAny, format)
	if err != nil || len(rendered) == 0 {
		httpresponse.Error(w, http.StatusBadGateway, rid, httpresponse.CodeInternal, "NextReport render failed", nil)
		return
	}

	w.Header().Set("Content-Type", formatContentType[format])
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(rendered)
}
// Render serves POST /reports/render?template=<name>&format=pdf|html
func (h *Handler) Render(w http.ResponseWriter, r *http.Request) {
	rid := middleware.RequestIDFrom(r.Context())
	q := r.URL.Query()

	var body renderBody
	if r.Method != http.MethodGet && r.Body != nil {
		if err := json.NewDecoder(r.Body).Decode(&body); err != nil && err != io.EOF {
			httpresponse.Error(w, http.StatusBadRequest, rid, httpresponse.CodeValidation, "Invalid request body", nil)
			return
		}
	}

	tpl := firstNonEmpty(q.Get("template"), body.Template)
	format := strings.ToLower(firstNonEmpty(q.Get("format"), body.Format, "pdf"))
	_, ok := formatContentType[format]
	if tpl == "" || !ok {
		httpresponse.Error(w, http.StatusBadRequest, rid, httpresponse.CodeValidation, "Unknown template or format", nil)
		return
	}
	perm, known := templatePerms[tpl]
	if !known {
		httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Unknown report template", nil)
		return
	}
	u := auth.UserFrom(r.Context())
	if u == nil {
		httpresponse.Error(w, http.StatusUnauthorized, rid, httpresponse.CodeAuthentication, "Unauthenticated", nil)
		return
	}
	if !u.Can(perm) {
		httpresponse.Error(w, http.StatusForbidden, rid, httpresponse.CodeForbidden, "Forbidden", nil)
		return
	}

	// Extract data object
	data := body.Data
	if len(data) == 0 {
		data = queryData(q)
	}
	var dataAny any = map[string]any{}
	if len(data) != 0 {
		_ = json.Unmarshal(data, &dataAny)
	}

	// Render via NextReport Engine
	if h.nextReportBase == "" {
		httpresponse.Error(w, http.StatusBadGateway, rid, httpresponse.CodeInternal, "NextReport service not configured", nil)
		return
	}
	if h.tplResolver == nil {
		httpresponse.Error(w, http.StatusBadGateway, rid, httpresponse.CodeInternal, "Report template service not configured", nil)
		return
	}

	activeTplCode := tpl
	if tpl == "buyer-invoice" {
		activeTplCode = "invoice-print"
	}
	activeTpl, err := h.tplResolver.GetActiveTemplate(r.Context(), activeTplCode)
	if err != nil || activeTpl == nil || len(activeTpl.TemplateData) == 0 {
		httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Active template not found for "+tpl, nil)
		return
	}

	// Resolve and shape authoritative data for NextReport templates
	shapedData := dataAny
	switch tpl {
	case "invoice-print", "buyer-invoice":
		var ownerCustomerID int64
		if tpl == "buyer-invoice" {
			// cart.checkout is held by every buyer, so this template must
			// only ever render the caller's own invoices. Foreign or
			// customer-less callers answer 404 (ids not enumerable).
			if h.custResolver == nil {
				httpresponse.Error(w, http.StatusBadGateway, rid, httpresponse.CodeInternal, "Customer resolver not configured", nil)
				return
			}
			custID, ok, err := h.custResolver(r.Context(), u.ID)
			if err != nil {
				// Resolver/infra failure is a server error, not "not found" —
				// hiding outages behind 404s misleads monitoring.
				httpresponse.Error(w, http.StatusInternalServerError, rid, httpresponse.CodeInternal, "Unable to resolve customer", nil)
				return
			}
			if !ok {
				httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Invoice not found", nil)
				return
			}
			ownerCustomerID = custID
		}
		shaped, ok := h.resolveInvoiceData(r.Context(), q, dataAny, u.Name, ownerCustomerID)
		if !ok {
			httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "Invoice not found", nil)
			return
		}
		shapedData = shaped
	case "po-print":
		shaped, perr := h.resolvePOPrint(r.Context(), q, dataAny, u)
		if perr != nil {
			if errors.Is(perr, purchasing.ErrAlreadyPrinted) {
				// The print-once ledger (000150): the signed-approval sheet
				// exists; another copy needs an elevated caller.
				httpresponse.Error(w, http.StatusConflict, rid, httpresponse.CodeBusinessViolation,
					purchasing.ErrAlreadyPrinted.Error(), nil)
				return
			}
			httpresponse.Error(w, http.StatusNotFound, rid, httpresponse.CodeNotFound, "PO not found", nil)
			return
		}
		shapedData = shaped
	case "do-print":
		shapedData = h.resolveDOData(r.Context(), q, dataAny, u.Name)
	case "receipt-print":
		shapedData = h.resolveGRData(r.Context(), q, dataAny, u.Name)
	case "sales-summary", "sales-by-product", "sales-by-customer", "stock-summary", "receivables-aging", "payables-aging",
		"purchases-by-supplier", "purchases-by-product",
		"profit-loss", "balance-sheet", "cashflow-statement", "transaction-list", "shift-list",
		"stock-valuation", "stock-movements", "stock-card", "trial-balance", "consolidated-report":
		shapedData = h.resolveAnalyticalData(r.Context(), tpl, q, dataAny, u.Name)
	case "voucher-print", "lhpp-print", "pos-receipt", "shift-print", "b2b-order":
		shapedData = h.resolveSlipData(r.Context(), tpl, q, dataAny, u.Name)
	}

	rendered, err := h.renderNextReport(r.Context(), activeTpl.TemplateData, shapedData, format)
	if err != nil || len(rendered) == 0 {
		httpresponse.Error(w, http.StatusBadGateway, rid, httpresponse.CodeInternal, "NextReport render failed", nil)
		return
	}

	w.Header().Set("Content-Type", formatContentType[format])
	if format == "pdf" {
		w.Header().Set("Content-Disposition", `inline; filename="`+sanitizeFilename(tpl)+`.pdf"`)
	}
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write(rendered)
}


// renderNextReport sends render request to NextReport engine service.
func (h *Handler) renderNextReport(ctx context.Context, templateData json.RawMessage, data any, format string) ([]byte, error) {
	templateData = ensureDetailIteratorName(templateData)
	payload, err := json.Marshal(map[string]any{
		"template": templateData,
		"data":     data,
		"format":   format,
	})
	if err != nil {
		return nil, err
	}

	reqCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, h.nextReportBase+"/render", bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Content-Type", "application/json")

	resp, err := h.client.Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return nil, fmt.Errorf("nextreport returned status %d", resp.StatusCode)
	}

	return io.ReadAll(resp.Body)
}

// ensureDetailIteratorName ensures that detail bands with dataBinding "rows"
// specify iteratorName "item" so that templates referencing {{item.field}}
// resolve properly without falling back to an unmapped undefined.
func ensureDetailIteratorName(raw json.RawMessage) json.RawMessage {
	if len(raw) == 0 {
		return raw
	}
	var tpl map[string]any
	if err := json.Unmarshal(raw, &tpl); err != nil {
		return raw
	}
	bands, ok := tpl["bands"].([]any)
	if !ok {
		return raw
	}
	modified := false
	for _, b := range bands {
		bm, ok := b.(map[string]any)
		if !ok {
			continue
		}
		if bm["type"] == "detail" {
			itName, _ := bm["iteratorName"].(string)
			if itName == "" {
				bm["iteratorName"] = "item"
				modified = true
			}
		}
	}
	if !modified {
		return raw
	}
	tpl["bands"] = bands
	out, err := json.Marshal(tpl)
	if err != nil {
		return raw
	}
	return out
}

func (h *Handler) resolveInvoiceData(ctx context.Context, q map[string][]string, passedData any, authorName string, ownerCustomerID int64) (any, bool) {
	// Authoritative path: invoice_id always resolves via finance service.
	// Frontend numbers are never trusted for document output.
	invID := int64(0)
	for _, k := range []string{"invoice_id", "invoiceId", "id"} {
		if v := firstNonEmpty(q[k]...); v != "" {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil && id > 0 {
				invID = id
				break
			}
		}
	}
	if invID == 0 {
		if m, ok := passedData.(map[string]any); ok {
			for _, k := range []string{"invoice_id", "invoiceId", "id"} {
				if v, has := m[k]; has {
					switch val := v.(type) {
					case float64:
						invID = int64(val)
					case string:
						invID, _ = strconv.ParseInt(val, 10, 64)
					}
					if invID > 0 {
						break
					}
				}
			}
		}
	}

	if invID > 0 && h.invResolver != nil {
		inv, err := h.invResolver.GetInvoice(ctx, invID)
		if err == nil && inv != nil {
			// Buyer-reachable callers may only see their own invoices; a
			// mismatch denies with ok=false so Render answers 404.
			if ownerCustomerID > 0 && inv.CustomerID != ownerCustomerID {
				return nil, false
			}
			return ShapeInvoiceData(inv, authorName), true
		}
	}

	// No passthrough: without an invoice_id the caller only gets a shapeMapData
	// preview (zeroed totals, INV-PREVIEW number), explicitly not production data.
	if m, ok := passedData.(map[string]any); ok {
		if _, hasInv := m["invoice"]; hasInv {
			return shapeMapData(map[string]any{"notes": m["notes"]}, authorName), true
		}
		return shapeMapData(m, authorName), true
	}
	return passedData, true
}

// ShapeInvoiceData transforms authoritative finance.Invoice into the NextReport contract.
func ShapeInvoiceData(inv *finance.Invoice, authorName string) map[string]any {
	items := make([]map[string]any, 0, len(inv.Items))
	subtotalCalc := int64(0)
	for _, it := range inv.Items {
		itemSub := it.Subtotal.IntPart()
		subtotalCalc += itemSub
		items = append(items, map[string]any{
			"product_code": it.ProductCode,
			"product_name": it.ProductName,
			"quantity":     it.Quantity.StringFixed(0),
			"unit":         "Pcs",
			"unit_price":   it.UnitPrice.IntPart(),
			"discount":     it.DiscountAmount.IntPart(),
			"subtotal":     itemSub,
		})
	}

	notes := ""
	if inv.Notes != nil {
		notes = *inv.Notes
	}
	customerName := inv.CustomerName
	if customerName == "" {
		customerName = fmt.Sprintf("Customer #%d", inv.CustomerID)
	}

	subtotal := inv.Subtotal.IntPart()
	if subtotal == 0 {
		subtotal = subtotalCalc
	}

	return map[string]any{
		"company": map[string]any{
			"name":     "KeluargaMart Distributor",
			"address":  "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":    "021-5558901",
			"logo_url": "",
		},
		"invoice": map[string]any{
			"id":        inv.ID,
			"number":    inv.InvoiceNumber,
			"date":      inv.InvoiceDate,
			"due_date":  inv.DueDate,
			"status":    inv.Status,
			"so_number": inv.SoNumber,
		},
		"customer": map[string]any{
			"id":      inv.CustomerID,
			"name":    customerName,
			"address": "Alamat Customer",
			"phone":   "-",
		},
		"items": items,
		"totals": map[string]any{
			"subtotal":         subtotal,
			"discount_amount":  0,
			"tax_amount":       inv.TaxAmount.IntPart(),
			"grand_total":      inv.TotalAmount.IntPart(),
			"paid_amount":      inv.PaidAmount.IntPart(),
			"remaining_amount": inv.RemainingAmount.IntPart(),
		},
		"meta": map[string]any{
			"notes":      notes,
			"printed_at": time.Now().Format("02/01/2006 15:04"),
			"printed_by": authorName,
		},
	}
}

func shapeMapData(m map[string]any, authorName string) map[string]any {
	// Wrap raw map into standard invoice contract
	invNo, _ := m["invoice_number"].(string)
	if invNo == "" {
		invNo = "INV-PREVIEW"
	}
	custName, _ := m["customer_name"].(string)
	if custName == "" {
		custName = "Pelanggan Umum"
	}
	invDate, _ := m["invoice_date"].(string)
	if invDate == "" {
		invDate = time.Now().Format("02/01/2006")
	}
	dueDate, _ := m["due_date"].(string)
	if dueDate == "" {
		dueDate = time.Now().AddDate(0, 0, 14).Format("02/01/2006")
	}

	items, _ := m["items"].([]any)
	shapedItems := make([]map[string]any, 0, len(items))
	subtotalCalc := int64(0)
	for _, it := range items {
		if im, ok := it.(map[string]any); ok {
			qty, _ := im["quantity"].(float64)
			prc, _ := im["unit_price"].(float64)
			sub, _ := im["subtotal"].(float64)
			if sub == 0 {
				sub = qty * prc
			}
			subtotalCalc += int64(sub)
			code, _ := im["product_code"].(string)
			name, _ := im["product_name"].(string)
			unit, _ := im["unit"].(string)
			if unit == "" {
				unit = "Pcs"
			}
			shapedItems = append(shapedItems, map[string]any{
				"product_code": code,
				"product_name": name,
				"quantity":     fmt.Sprintf("%.0f", qty),
				"unit":         unit,
				"unit_price":   int64(prc),
				"discount":     0,
				"subtotal":     int64(sub),
			})
		}
	}

	tot, _ := m["total_amount"].(float64)
	if tot == 0 {
		tot = float64(subtotalCalc)
	}

	return map[string]any{
		"company": map[string]any{
			"name":     "KeluargaMart Distributor",
			"address":  "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":    "021-5558901",
			"logo_url": "",
		},
		"invoice": map[string]any{
			"number":   invNo,
			"date":     invDate,
			"due_date": dueDate,
			"status":   m["status"],
		},
		"customer": map[string]any{
			"name":    custName,
			"address": "-",
			"phone":   "-",
		},
		"items": shapedItems,
		"totals": map[string]any{
			"subtotal":         subtotalCalc,
			"discount_amount":  0,
			"tax_amount":       0,
			"grand_total":      int64(tot),
			"paid_amount":      0,
			"remaining_amount": int64(tot),
		},
		"meta": map[string]any{
			"notes":      m["notes"],
			"printed_at": time.Now().Format("02/01/2006 15:04"),
			"printed_by": authorName,
		},
	}
}

// POStamper is the optional print-once hook a POResolver implements so a
// po-print render RECORDS and GATES the print (000150). purchasing.Service
// implements it; the report proxy must keep working when a resolver does not.
type POStamper interface {
	StampPrint(ctx context.Context, orderID, userID int64, allowReprint bool) (*purchasing.PurchaseOrder, error)
}

// resolvePOPrint resolves the authoritative PO for a po-print render AND runs
// the print-once ledger through POStamper. Stamping here — the moment a sheet
// is actually produced, shared by browser print and PDF download — is what
// makes "PO hanya bisa dicetak sekali" a server fact instead of a
// localStorage marker. allowReprint mirrors the codebase's admin/owner bypass.
func (h *Handler) resolvePOPrint(ctx context.Context, q map[string][]string, passedData any, u *auth.User) (any, error) {
	poID := poIDFrom(q, passedData)
	if poID == 0 {
		if m, ok := passedData.(map[string]any); ok {
			return shapePOMapData(m, u.Name), nil
		}
		return passedData, nil
	}
	if ps, ok := h.poResolver.(POStamper); ok {
		allow := u != nil && (u.Role == auth.RoleAdmin || u.Role == auth.RoleOwner)
		uid := int64(0)
		if u != nil {
			uid = u.ID
		}
		po, err := ps.StampPrint(ctx, poID, uid, allow)
		if err != nil {
			return nil, err
		}
		return ShapePOData(po, u.Name), nil
	}
	if h.poResolver != nil {
		po, err := h.poResolver.GetOrder(ctx, poID)
		if err != nil || po == nil {
			return passedData, nil
		}
		return ShapePOData(po, u.Name), nil
	}
	if m, ok := passedData.(map[string]any); ok {
		return shapePOMapData(m, u.Name), nil
	}
	return passedData, nil
}

// poIDFrom extracts the PO id from render query params, falling back to the
// posted data map — the ERP print page posts {orderId, ...document}.
func poIDFrom(q map[string][]string, passedData any) int64 {
	for _, k := range []string{"orderId", "order_id", "po_id", "id"} {
		if v := firstNonEmpty(q[k]...); v != "" {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil && id > 0 {
				return id
			}
		}
	}
	if m, ok := passedData.(map[string]any); ok {
		for _, k := range []string{"orderId", "order_id", "po_id", "id"} {
			if v, has := m[k]; has {
				switch val := v.(type) {
				case float64:
					if int64(val) > 0 {
						return int64(val)
					}
				case string:
					if id, err := strconv.ParseInt(val, 10, 64); err == nil && id > 0 {
						return id
					}
				}
			}
		}
	}
	return 0
}

func (h *Handler) resolvePOData(ctx context.Context, q map[string][]string, passedData any, authorName string) any {
	if m, ok := passedData.(map[string]any); ok {
		if _, hasPO := m["po"]; hasPO {
			return passedData
		}
	}
	poID := int64(0)
	for _, k := range []string{"orderId", "order_id", "po_id", "id"} {
		if v := firstNonEmpty(q[k]...); v != "" {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil && id > 0 {
				poID = id
				break
			}
		}
	}
	if poID == 0 {
		if m, ok := passedData.(map[string]any); ok {
			for _, k := range []string{"orderId", "order_id", "po_id", "id"} {
				if v, has := m[k]; has {
					switch val := v.(type) {
					case float64:
						poID = int64(val)
					case string:
						poID, _ = strconv.ParseInt(val, 10, 64)
					}
					if poID > 0 {
						break
					}
				}
			}
		}
	}
	if poID > 0 && h.poResolver != nil {
		po, err := h.poResolver.GetOrder(ctx, poID)
		if err == nil && po != nil {
			return ShapePOData(po, authorName)
		}
	}
	if m, ok := passedData.(map[string]any); ok {
		return shapePOMapData(m, authorName)
	}
	return passedData
}

// ShapePOData transforms authoritative purchasing.PurchaseOrder into the NextReport contract.
func ShapePOData(po *purchasing.PurchaseOrder, authorName string) map[string]any {
	items := make([]map[string]any, 0, len(po.Items))
	for _, it := range po.Items {
		items = append(items, map[string]any{
			"product_code": it.ProductCode,
			"product_name": it.ProductName,
			"quantity":     it.Quantity.StringFixed(0),
			"unit":         it.UnitSymbol,
			"unit_price":   it.UnitPrice.IntPart(),
			"discount":     it.Discount.IntPart(),
			"subtotal":     it.Subtotal.IntPart(),
		})
	}
	notes := ""
	if po.Notes != nil {
		notes = *po.Notes
	}
	expDate := ""
	if po.ExpectedDate != nil {
		expDate = *po.ExpectedDate
	}
	supAddr := ""
	if po.SupplierAddress != nil {
		supAddr = *po.SupplierAddress
	}
	supPhone := ""
	if po.SupplierPhone != nil {
		supPhone = *po.SupplierPhone
	}
	approvedBy := po.ApprovedByName
	if approvedBy == "" {
		approvedBy = "Manager Logistik"
	}
	createdBy := po.CreatedByName
	if createdBy == "" {
		createdBy = authorName
	}
	return map[string]any{
		"company": map[string]any{
			"name":    "KeluargaMart Distributor",
			"address": "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":   "021-5558901",
		},
		"po": map[string]any{
			"id":            po.ID,
			"number":        po.PoNumber,
			"order_date":    po.OrderDate,
			"expected_date": expDate,
			"status":        po.Status,
		},
		"supplier": map[string]any{
			"name":    po.SupplierName,
			"address": supAddr,
			"phone":   supPhone,
		},
		"warehouse": map[string]any{
			"name": po.WarehouseName,
		},
		"items": items,
		"totals": map[string]any{
			"subtotal":         po.Subtotal.IntPart(),
			"discount_amount":  po.DiscountAmount.IntPart(),
			"tax_amount":       po.TaxAmount.IntPart(),
			"grand_total":      po.TotalAmount.IntPart(),
			"paid_amount":      po.PaidAmount.IntPart(),
			"remaining_amount": po.RemainingAmount.IntPart(),
		},
		"meta": map[string]any{
			"notes":       notes,
			"created_by":  createdBy,
			"approved_by": approvedBy,
			"printed_at":  time.Now().Format("02/01/2006 15:04"),
			"printed_by":  authorName,
		},
	}
}

func shapePOMapData(m map[string]any, authorName string) map[string]any {
	poNo, _ := m["po_number"].(string)
	if poNo == "" {
		poNo = "PO-PREVIEW"
	}
	supName, _ := m["supplier_name"].(string)
	if supName == "" {
		supName = "Supplier Umum"
	}
	items, _ := m["items"].([]any)
	shapedItems := make([]map[string]any, 0, len(items))
	subtotalCalc := int64(0)
	for _, it := range items {
		if im, ok := it.(map[string]any); ok {
			qty, _ := im["quantity"].(float64)
			prc, _ := im["unit_price"].(float64)
			sub, _ := im["subtotal"].(float64)
			if sub == 0 {
				sub = qty * prc
			}
			subtotalCalc += int64(sub)
			code, _ := im["product_code"].(string)
			name, _ := im["product_name"].(string)
			unit, _ := im["unit"].(string)
			if unit == "" {
				unit = "Pcs"
			}
			shapedItems = append(shapedItems, map[string]any{
				"product_code": code,
				"product_name": name,
				"quantity":     fmt.Sprintf("%.0f", qty),
				"unit":         unit,
				"unit_price":   int64(prc),
				"discount":     0,
				"subtotal":     int64(sub),
			})
		}
	}
	return map[string]any{
		"company": map[string]any{
			"name":    "KeluargaMart Distributor",
			"address": "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":   "021-5558901",
		},
		"po": map[string]any{
			"number":        poNo,
			"order_date":    time.Now().Format("02/01/2006"),
			"expected_date": time.Now().AddDate(0, 0, 7).Format("02/01/2006"),
			"status":        "approved",
		},
		"supplier": map[string]any{
			"name":    supName,
			"address": "-",
			"phone":   "-",
		},
		"warehouse": map[string]any{
			"name": "Gudang Pusat",
		},
		"items": shapedItems,
		"totals": map[string]any{
			"subtotal":         subtotalCalc,
			"discount_amount":  0,
			"tax_amount":       0,
			"grand_total":      subtotalCalc,
			"paid_amount":      0,
			"remaining_amount": subtotalCalc,
		},
		"meta": map[string]any{
			"notes":       m["notes"],
			"created_by":  authorName,
			"approved_by": "Manager Purchasing",
			"printed_at":  time.Now().Format("02/01/2006 15:04"),
			"printed_by":  authorName,
		},
	}
}

func (h *Handler) resolveDOData(ctx context.Context, q map[string][]string, passedData any, authorName string) any {
	if m, ok := passedData.(map[string]any); ok {
		if _, hasDO := m["delivery_order"]; hasDO {
			return passedData
		}
	}
	doID := int64(0)
	for _, k := range []string{"do_id", "delivery_order_id", "id"} {
		if v := firstNonEmpty(q[k]...); v != "" {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil && id > 0 {
				doID = id
				break
			}
		}
	}
	if doID == 0 {
		if m, ok := passedData.(map[string]any); ok {
			for _, k := range []string{"do_id", "delivery_order_id", "id"} {
				if v, has := m[k]; has {
					switch val := v.(type) {
					case float64:
						doID = int64(val)
					case string:
						doID, _ = strconv.ParseInt(val, 10, 64)
					}
					if doID > 0 {
						break
					}
				}
			}
		}
	}
	if doID > 0 && h.doResolver != nil {
		do, err := h.doResolver.GetDeliveryOrder(ctx, doID)
		if err == nil && do != nil {
			return ShapeDOData(do, authorName)
		}
	}
	if m, ok := passedData.(map[string]any); ok {
		return shapeDOMapData(m, authorName)
	}
	return passedData
}

// ShapeDOData transforms authoritative distribution.DeliveryOrder into the NextReport contract.
func ShapeDOData(d *distribution.DeliveryOrder, authorName string) map[string]any {
	items := make([]map[string]any, 0, len(d.Items))
	var grandTotal decimal.Decimal
	for _, it := range d.Items {
		unitPrice := it.UnitPrice
		qty := it.ShippedQuantity
		if qty.IsZero() {
			qty = it.RequestedQuantity
		}
		subtotal := it.Subtotal
		if subtotal.IsZero() && !unitPrice.IsZero() {
			subtotal = unitPrice.Mul(qty)
		}
		grandTotal = grandTotal.Add(subtotal)

		items = append(items, map[string]any{
			"product_code":       it.ProductCode,
			"product_name":       it.ProductName,
			"quantity_requested": it.RequestedQuantity.StringFixed(0),
			"quantity_shipped":   it.ShippedQuantity.StringFixed(0),
			"quantity_delivered": it.DeliveredQuantity.StringFixed(0),
			"unit":               "Pcs",
			"unit_price":         unitPrice.InexactFloat64(),
			"subtotal":           subtotal.InexactFloat64(),
		})
	}
	notes := ""
	if d.Notes != nil {
		notes = *d.Notes
	}
	shipAddr := ""
	if d.ShippingAddress != nil {
		shipAddr = *d.ShippingAddress
	}
	recipient := d.RecipientName
	if recipient == "" {
		recipient = d.CustomerName
	}
	return map[string]any{
		"company": map[string]any{
			"name":    "KeluargaMart Distributor",
			"address": "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":   "021-5558901",
		},
		"delivery_order": map[string]any{
			"id":        d.ID,
			"number":    d.DoNumber,
			"so_number": d.SoNumber,
			"date":      d.DoDate,
			"status":    d.Status,
		},
		"customer": map[string]any{
			"name":             d.CustomerName,
			"shipping_address": shipAddr,
			"recipient_name":   recipient,
		},
		"warehouse": map[string]any{
			"name": "Gudang Distribusi Pusat",
		},
		"items": items,
		"totals": map[string]any{
			"subtotal":    grandTotal.InexactFloat64(),
			"grand_total": grandTotal.InexactFloat64(),
		},
		"meta": map[string]any{
			"notes":       notes,
			"driver_name": "Kurir Ekspedisi",
			"dispatcher":  authorName,
			"printed_at":  time.Now().Format("02/01/2006 15:04"),
			"printed_by":  authorName,
		},
	}
}

func shapeDOMapData(m map[string]any, authorName string) map[string]any {
	doNo, _ := m["do_number"].(string)
	if doNo == "" {
		doNo = "DO-PREVIEW"
	}
	items, _ := m["items"].([]any)
	shapedItems := make([]map[string]any, 0, len(items))
	var grandTotal float64
	for _, it := range items {
		if im, ok := it.(map[string]any); ok {
			code, _ := im["product_code"].(string)
			name, _ := im["product_name"].(string)
			req, _ := im["requested_quantity"].(float64)
			ship, _ := im["shipped_quantity"].(float64)
			if ship == 0 {
				ship = req
			}
			unitPrice, _ := im["unit_price"].(float64)
			subtotal, _ := im["subtotal"].(float64)
			if subtotal == 0 && unitPrice > 0 {
				subtotal = unitPrice * ship
			}
			grandTotal += subtotal
			shapedItems = append(shapedItems, map[string]any{
				"product_code":       code,
				"product_name":       name,
				"quantity_requested": fmt.Sprintf("%.0f", req),
				"quantity_shipped":   fmt.Sprintf("%.0f", ship),
				"unit":               "Pcs",
				"unit_price":         unitPrice,
				"subtotal":           subtotal,
			})
		}
	}
	return map[string]any{
		"company": map[string]any{
			"name":    "KeluargaMart Distributor",
			"address": "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":   "021-5558901",
		},
		"delivery_order": map[string]any{
			"number":    doNo,
			"so_number": m["so_number"],
			"date":      time.Now().Format("02/01/2006"),
			"status":    "in_transit",
		},
		"customer": map[string]any{
			"name":             m["customer_name"],
			"shipping_address": m["shipping_address"],
			"recipient_name":   m["recipient_name"],
		},
		"warehouse": map[string]any{
			"name": "Gudang Distribusi",
		},
		"items": shapedItems,
		"totals": map[string]any{
			"subtotal":    grandTotal,
			"grand_total": grandTotal,
		},
		"meta": map[string]any{
			"notes":       m["notes"],
			"driver_name": "Kurir Ekspedisi",
			"dispatcher":  authorName,
			"printed_at":  time.Now().Format("02/01/2006 15:04"),
			"printed_by":  authorName,
		},
	}
}

func (h *Handler) resolveGRData(ctx context.Context, q map[string][]string, passedData any, authorName string) any {
	if m, ok := passedData.(map[string]any); ok {
		if _, hasRec := m["receipt"]; hasRec {
			return passedData
		}
	}
	grID := int64(0)
	for _, k := range []string{"receipt_id", "goods_receipt_id", "id"} {
		if v := firstNonEmpty(q[k]...); v != "" {
			if id, err := strconv.ParseInt(v, 10, 64); err == nil && id > 0 {
				grID = id
				break
			}
		}
	}
	if grID == 0 {
		if m, ok := passedData.(map[string]any); ok {
			for _, k := range []string{"receipt_id", "goods_receipt_id", "id"} {
				if v, has := m[k]; has {
					switch val := v.(type) {
					case float64:
						grID = int64(val)
					case string:
						grID, _ = strconv.ParseInt(val, 10, 64)
					}
					if grID > 0 {
						break
					}
				}
			}
		}
	}
	if grID > 0 && h.grResolver != nil {
		gr, err := h.grResolver.GetGR(ctx, grID)
		if err == nil && gr != nil {
			return ShapeGRData(gr, authorName)
		}
	}
	if m, ok := passedData.(map[string]any); ok {
		return shapeGRMapData(m, authorName)
	}
	return passedData
}

// ShapeGRData transforms authoritative purchasing.GoodsReceipt into the NextReport contract.
func ShapeGRData(gr *purchasing.GoodsReceipt, authorName string) map[string]any {
	items := make([]map[string]any, 0, len(gr.Items))
	for _, it := range gr.Items {
		batch := "-"
		if it.BatchNumber != nil {
			batch = *it.BatchNumber
		}
		items = append(items, map[string]any{
			"product_code":      it.ProductCode,
			"product_name":      it.ProductName,
			"quantity_ordered":  it.QuantityOrdered.StringFixed(0),
			"quantity_received": it.QuantityReceived.StringFixed(0),
			"batch_number":      batch,
		})
	}
	notes := ""
	if gr.Notes != nil {
		notes = *gr.Notes
	}
	return map[string]any{
		"company": map[string]any{
			"name":    "KeluargaMart Distributor",
			"address": "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":   "021-5558901",
		},
		"receipt": map[string]any{
			"id":             gr.ID,
			"number":         gr.GrNumber,
			"po_number":      gr.PONumber,
			"received_date":  gr.ReceivedDate,
			"supplier_name":  gr.SupplierName,
			"warehouse_name": gr.WarehouseName,
			"received_by":    authorName,
		},
		"items": items,
		"meta": map[string]any{
			"notes":       notes,
			"received_by": authorName,
			"printed_at":  time.Now().Format("02/01/2006 15:04"),
			"printed_by":  authorName,
		},
	}
}

func shapeGRMapData(m map[string]any, authorName string) map[string]any {
	grNo, _ := m["gr_number"].(string)
	if grNo == "" {
		grNo = "GR-PREVIEW"
	}
	items, _ := m["items"].([]any)
	shapedItems := make([]map[string]any, 0, len(items))
	for _, it := range items {
		if im, ok := it.(map[string]any); ok {
			code, _ := im["product_code"].(string)
			name, _ := im["product_name"].(string)
			ord, _ := im["quantity_ordered"].(float64)
			rec, _ := im["quantity_received"].(float64)
			batch, _ := im["batch_number"].(string)
			if batch == "" {
				batch = "-"
			}
			shapedItems = append(shapedItems, map[string]any{
				"product_code":      code,
				"product_name":      name,
				"quantity_ordered":  fmt.Sprintf("%.0f", ord),
				"quantity_received": fmt.Sprintf("%.0f", rec),
				"batch_number":      batch,
			})
		}
	}
	return map[string]any{
		"company": map[string]any{
			"name":    "KeluargaMart Distributor",
			"address": "Jl. Sentral Distribusi No. 88, Jakarta Barat",
			"phone":   "021-5558901",
		},
		"receipt": map[string]any{
			"number":         grNo,
			"po_number":      m["po_number"],
			"received_date":  time.Now().Format("02/01/2006"),
			"supplier_name":  m["supplier_name"],
			"warehouse_name": m["warehouse_name"],
			"received_by":    authorName,
		},
		"items": shapedItems,
		"meta": map[string]any{
			"notes":       m["notes"],
			"received_by": authorName,
			"printed_at":  time.Now().Format("02/01/2006 15:04"),
			"printed_by":  authorName,
		},
	}
}

// Health serves GET /reports/health: {"ok":bool,"nextreport":"up"|"down"}.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
	rid := middleware.RequestIDFrom(r.Context())
	nrUp := false
	if h.nextReportBase != "" {
		ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
		defer cancel()
		req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.nextReportBase+"/health", nil)
		if err == nil {
			if resp, err := h.client.Do(req); err == nil {
				resp.Body.Close()
				nrUp = resp.StatusCode < 500
			}
		}
	}

	status := http.StatusOK
	if !nrUp {
		status = http.StatusServiceUnavailable
	}

	nrState := "down"
	if nrUp {
		nrState = "up"
	}

	httpresponse.OK(w, status, rid, map[string]any{
		"ok":         nrUp,
		"nextreport": nrState,
	})
}


// queryData maps orderId/saleId/id query keys to the data object.
func queryData(q map[string][]string) json.RawMessage {
	data := map[string]string{}
	for _, k := range []string{"orderId", "saleId", "id", "invoice_id", "invoiceId"} {
		if v := firstNonEmpty(q[k]...); v != "" {
			data[k] = v
		}
	}
	if len(data) == 0 {
		return nil
	}
	raw, err := json.Marshal(data)
	if err != nil {
		return nil
	}
	return raw
}

func (h *Handler) resolveAnalyticalData(ctx context.Context, tpl string, q map[string][]string, passedData any, authorName string) any {
	m, ok := passedData.(map[string]any)
	if !ok {
		m = map[string]any{}
	}

	filters, _ := m["filters"].(map[string]any)
	if filters == nil {
		filters = map[string]any{}
	}
	if _, hasPrintedBy := filters["printed_by"]; !hasPrintedBy || filters["printed_by"] == "" || filters["printed_by"] == "-" {
		filters["printed_by"] = authorName
	}
	if _, hasPrintedAt := filters["printed_at"]; !hasPrintedAt || filters["printed_at"] == "" {
		filters["printed_at"] = time.Now().Format("02/01/2006 15:04")
	}
	if _, hasDate := filters["date"]; !hasDate && len(q["date"]) > 0 {
		filters["date"] = q["date"][0]
	}
	if _, hasPeriod := filters["period"]; !hasPeriod && len(q["period"]) > 0 {
		filters["period"] = q["period"][0]
	}
	if _, hasYear := filters["period"]; !hasYear && len(q["year"]) > 0 {
		filters["period"] = q["year"][0]
	}
	if _, hasFrom := filters["date_from"]; !hasFrom && len(q["date_from"]) > 0 {
		filters["date_from"] = q["date_from"][0]
	}
	if _, hasTo := filters["date_to"]; !hasTo && len(q["date_to"]) > 0 {
		filters["date_to"] = q["date_to"][0]
	}
	m["filters"] = filters

	if _, hasRows := m["rows"]; !hasRows {
		m["rows"] = []any{}
	}
	if rows, ok := m["rows"].([]any); ok {
		for i, r := range rows {
			rm, ok := r.(map[string]any)
			if !ok {
				continue
			}
			for _, numKey := range []string{"omset", "discount", "hpp", "profit", "qty", "orders", "retur_qty", "retur_amount", "amount", "current", "b30", "b60", "b90", "over90", "remaining", "total", "paid", "received", "stock_qty", "cost_price", "stock_value"} {
				if val, exists := rm[numKey]; exists {
					if s, isStr := val.(string); isStr {
						if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
							rm[numKey] = f
						}
					}
				}
			}
			if rm["category_name"] == nil || rm["category_name"] == "" {
				rm["category_name"] = "Umum"
			}
			if rm["unit"] == nil || rm["unit"] == "" {
				rm["unit"] = "Pcs"
			}
			rows[i] = rm
		}
		m["rows"] = rows
	}

	if _, hasTotal := m["total"]; !hasTotal {
		m["total"] = map[string]any{}
	}
	if tot, ok := m["total"].(map[string]any); ok {
		for k, val := range tot {
			if s, isStr := val.(string); isStr {
				if f, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
					tot[k] = f
				}
			}
		}
		m["total"] = tot
	}

	return m
}

func asString(v any, fallback string) string {
	if s, ok := v.(string); ok && s != "" {
		return s
	}
	return fallback
}

// firstPresent returns the first non-nil value among the given keys (key
// precedence for renamed fields, e.g. opening_cash → initial_cash).
func firstPresent(m map[string]any, keys ...string) any {
	for _, k := range keys {
		if v, has := m[k]; has && v != nil {
			return v
		}
	}
	return nil
}

// toNumber normalizes JSON-decoded numerics into int64: shopspring decimals
// marshal as strings ("5000000"), plain JSON numbers as float64.
func toNumber(v any) int64 {
	switch x := v.(type) {
	case int:
		return int64(x)
	case int64:
		return x
	case float64:
		return int64(x)
	case string:
		f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
		if err != nil {
			return 0
		}
		return int64(f)
	case json.Number:
		i, _ := x.Int64()
		return i
	}
	return 0
}

// formatIDTime renders a timestamp (RFC3339 from JSON payloads, or an
// already-formatted string) as dd/mm/yyyy HH:mm for Indonesian printouts.
func formatIDTime(v any, fallback string) string {
	s := asString(v, "")
	if s != "" {
		for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02T15:04:05", "2006-01-02 15:04:05"} {
			if t, err := time.Parse(layout, s); err == nil {
				return t.Format("02/01/2006 15:04")
			}
		}
		return s
	}
	if fallback != "" {
		return fallback
	}
	return time.Now().Format("02/01/2006 15:04")
}

func (h *Handler) resolveSlipData(ctx context.Context, tpl string, q map[string][]string, passedData any, authorName string) any {
	m, ok := passedData.(map[string]any)
	if !ok {
		m = map[string]any{}
	}

	switch tpl {
	case "voucher-print":
		if _, hasVoucher := m["voucher"]; !hasVoucher {
			vPayee := asString(m["beneficiary"], "")
			if vPayee == "" {
				vPayee = asString(m["supplier_name"], "-")
			}
			m["voucher"] = map[string]any{
				"number":  asString(m["voucher_number"], "PV-PREVIEW"),
				"date":    asString(m["voucher_date"], time.Now().Format("02/01/2006")),
				"account": asString(m["cash_account_code"], "1101 (Kas/Bank)"),
				"payee":   vPayee,
				"status":  asString(m["status"], "paid"),
			}
			if _, hasItems := m["items"]; !hasItems {
				// Decimals marshal as strings ("5000000") — normalize like
				// shift-print instead of a float64 type assertion that
				// silently yields 0.
				amt := toNumber(firstPresent(m, "amount", "voucher_amount"))
				m["items"] = []map[string]any{
					{
						"description": asString(m["description"], "Pembayaran Kas/Bank Operasional"),
						"amount":      amt,
					},
				}
				m["totals"] = map[string]any{
					"amount": amt,
				}
			}
			if _, hasMeta := m["meta"]; !hasMeta {
				m["meta"] = map[string]any{
					"notes":      asString(m["notes"], "-"),
					"created_by": authorName,
					"printed_at": time.Now().Format("02/01/2006 15:04"),
				}
			}
		}

	case "lhpp-print":
		if _, hasLHPP := m["lhpp"]; !hasLHPP {
			m["lhpp"] = map[string]any{
				"number": asString(m["lhpp_number"], "LHPP-PREVIEW"),
				"date":   asString(m["lhpp_date"], time.Now().Format("02/01/2006")),
			}
			if _, hasDriver := m["driver"]; !hasDriver {
				m["driver"] = map[string]any{
					"name": asString(m["driver_name"], authorName),
				}
			}
			if _, hasFleet := m["fleet"]; !hasFleet {
				m["fleet"] = map[string]any{
					"name":         asString(m["fleet_name"], "Armada Distribusi"),
					"plate_number": asString(m["plate_number"], "B 9999 KM"),
				}
			}
			if _, hasTotals := m["totals"]; !hasTotals {
				m["totals"] = map[string]any{
					"tagihan":  m["total_tagihan"],
					"terbayar": m["total_terbayar"],
					"sisa":     m["total_sisa"],
				}
			}
		}

	case "pos-receipt":
		if _, hasReceipt := m["receipt"]; !hasReceipt {
			cashierName := authorName
			if c, ok := m["cashier"].(map[string]any); ok {
				if cn, ok := c["name"].(string); ok && cn != "" {
					cashierName = cn
				}
			} else if cn, ok := m["cashier_name"].(string); ok && cn != "" {
				cashierName = cn
			}
			m["receipt"] = map[string]any{
				"number":         asString(m["number"], asString(m["receipt_number"], asString(m["transaction_number"], "TRX-PREVIEW"))),
				"date":           asString(m["date"], asString(m["receipt_date"], time.Now().Format("02/01/2006 15:04"))),
				"cashier":        cashierName,
				"payment_method": asString(m["paymentMethod"], asString(m["payment_method"], "Tunai")),
			}
		}
		if _, hasTotals := m["totals"]; !hasTotals {
			// Decimals marshal as strings — same normalization as shift-print;
			// raw float64 assertions printed total/paid/change as 0.
			tot := toNumber(m["total"])
			paid := toNumber(firstPresent(m, "amountPaid", "amount_paid", "paid"))
			if paid == 0 {
				paid = tot
			}
			chg := toNumber(m["change"])
			m["totals"] = map[string]any{
				"total":  tot,
				"paid":   paid,
				"change": chg,
			}
		}

	case "b2b-order":
		if _, hasSO := m["so_number"]; !hasSO {
			m["so_number"] = asString(m["so_number"], "SO-PREVIEW")
		}
		if _, hasDate := m["order_date"]; !hasDate {
			m["order_date"] = time.Now().Format("02/01/2006")
		}
		if _, hasCust := m["customer_name"]; !hasCust {
			m["customer_name"] = authorName
		}
		if _, hasAddr := m["shipping_address"]; !hasAddr {
			m["shipping_address"] = "-"
		}
		if _, hasWh := m["warehouse_name"]; !hasWh {
			m["warehouse_name"] = "Gudang Pusat"
		}
		if _, hasStatus := m["status"]; !hasStatus {
			m["status"] = "confirmed"
		}

	case "shift-print":
		if _, hasShift := m["shift"]; !hasShift {
			// The POS posts GET /cashier/shifts/{id}/summary verbatim: decimal
			// fields arrive as strings (opening_cash, cash_sales, variance…)
			// and timestamps as RFC3339. Normalize both so the template prints
			// real values instead of zeros and synthetic defaults.
			m["shift"] = map[string]any{
				"cashier_name":      firstNonEmpty(asString(m["cashier_name"], ""), authorName),
				"register_name":     asString(m["register_name"], "Kasir Utama"),
				"start_time":        formatIDTime(m["opened_at"], asString(m["start_time"], time.Now().Format("02/01/2006 08:00"))),
				"end_time":          formatIDTime(m["closed_at"], asString(m["end_time"], time.Now().Format("02/01/2006 15:04"))),
				"shift_number":      asString(m["shift_number"], ""),
				"initial_cash":      toNumber(firstPresent(m, "opening_cash", "initial_cash")),
				"cash_sales":        toNumber(m["cash_sales"]),
				"non_cash_sales":    toNumber(m["non_cash_sales"]),
				"transaction_count": toNumber(m["transaction_count"]),
				"actual_cash":       toNumber(m["actual_cash"]),
				"cash_difference":   toNumber(firstPresent(m, "variance", "cash_difference")),
			}
		}
	}

	return m
}

func firstNonEmpty(vals ...string) string {
	for _, v := range vals {
		if v != "" {
			return v
		}
	}
	return ""
}

func sanitizeFilename(s string) string {
	var b strings.Builder
	for _, c := range s {
		switch {
		case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '_':
			b.WriteRune(c)
		default:
			b.WriteByte('_')
		}
	}
	if b.Len() == 0 {
		return "report"
	}
	return b.String()
}
