package routes

import (
	"bssapp-backend/auth"
	"bssapp-backend/queries"
	"context"
	"database/sql"
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"sort"
	"strconv"
	"strings"
	"time"

	"bssapp-backend/internal/mailer"

	"github.com/gorilla/mux"
	"github.com/lib/pq"
)

type wholesaleCampaignRow struct {
	ID           int64   `json:"id"`
	Code         string  `json:"code"`
	Title        string  `json:"title"`
	IsActive     bool    `json:"is_active"`
	Dtst         string  `json:"dtst,omitempty"`
	Dtfn         string  `json:"dtfn,omitempty"`
	DiscountRate float64 `json:"discount_rate"`
	Notes        string  `json:"notes,omitempty"`
}

// GET /api/pricing/wholesale-campaigns
func GetWholesaleCampaignsHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
		defer cancel()

		rows, err := pg.QueryContext(ctx, `
SELECT
  id,
  COALESCE(NULLIF(BTRIM(code),''),'') AS code,
  COALESCE(NULLIF(BTRIM(title),''),'') AS title,
  COALESCE(is_active, TRUE) AS is_active,
  COALESCE(to_char(dtst, 'DD.MM.YYYY'), '') AS dtst,
  COALESCE(to_char(dtfn, 'DD.MM.YYYY'), '') AS dtfn,
  COALESCE(discount_rate, 0)::float8 AS discount_rate,
  COALESCE(NULLIF(BTRIM(notes),''),'') AS notes
FROM sdcampaign
WHERE COALESCE(is_active, TRUE) = TRUE
ORDER BY discount_rate ASC, id ASC;
`)
		if err != nil {
			http.Error(w, "campaign list error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		out := make([]wholesaleCampaignRow, 0, 64)
		for rows.Next() {
			var it wholesaleCampaignRow
			var dtst, dtfn string
			if err := rows.Scan(&it.ID, &it.Code, &it.Title, &it.IsActive, &dtst, &dtfn, &it.DiscountRate, &it.Notes); err != nil {
				http.Error(w, "campaign scan error", http.StatusInternalServerError)
				return
			}
			it.Dtst = strings.TrimSpace(dtst)
			it.Dtfn = strings.TrimSpace(dtfn)
			out = append(out, it)
		}

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(out)
		log.Printf("[WholesaleCampaigns] trace=%s user=%s id=%d count=%d", traceID, claims.Username, claims.ID, len(out))
	}
}

type campaignAssignmentRow struct {
	ProductCode   string  `json:"product_code"`
	CampaignID    *int64  `json:"campaign_id"`
	CampaignCode  string  `json:"campaign_code"`
	DiscountRate  float64 `json:"discount_rate"`
	IsMixed       bool    `json:"is_mixed"`
	VariantRows   int     `json:"variant_rows"`
	AssignedDim1s int     `json:"assigned_dim1s"`
	AssignedDim3s int     `json:"assigned_dim3s"`
	Notes         string  `json:"notes,omitempty"`
}

// GET /api/pricing/wholesale-campaigns/assignments?product_code=A,B,C
func GetWholesaleCampaignAssignmentsHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		codes := splitCSVParam(r.URL.Query().Get("product_code"))
		if len(codes) == 0 {
			w.Header().Set("Content-Type", "application/json; charset=utf-8")
			_ = json.NewEncoder(w).Encode([]campaignAssignmentRow{})
			return
		}
		if len(codes) > 500 {
			http.Error(w, "product_code too many", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
		defer cancel()

		rows, err := pg.QueryContext(ctx, `
WITH inp AS (
  SELECT UNNEST($1::text[]) AS code
),
mm AS (
  SELECT m.id AS mmitem_id, m.code
  FROM mmitem m
  JOIN inp ON inp.code = m.code
),
latest AS (
  -- "Current" assignment = latest row per variant key (mmitem_id, dim1, dim3_key).
  SELECT DISTINCT ON (z.mmitem_id, z.dim1, COALESCE(z.dim3, 0))
    z.mmitem_id,
    z.dim1,
    COALESCE(z.dim3, 0) AS dim3_key,
    z.sdcampaign_id
  FROM zbggcampaign z
  JOIN mm ON mm.mmitem_id = z.mmitem_id
  ORDER BY z.mmitem_id, z.dim1, COALESCE(z.dim3, 0), z.id DESC
),
agg AS (
  SELECT
    mm.code AS product_code,
    COUNT(*)::int AS variant_rows,
    COUNT(DISTINCT l.dim1) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim1s,
    COUNT(DISTINCT l.dim3_key) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim3s,
    COUNT(DISTINCT l.sdcampaign_id) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS distinct_campaigns,
    MAX(l.sdcampaign_id)::bigint AS any_campaign_id
  FROM mm
  LEFT JOIN latest l
    ON l.mmitem_id = mm.mmitem_id
  GROUP BY mm.code
),
single AS (
  SELECT
    a.product_code,
    CASE WHEN a.distinct_campaigns = 1 THEN a.any_campaign_id ELSE NULL END AS campaign_id,
    CASE WHEN a.distinct_campaigns > 1 THEN TRUE ELSE FALSE END AS is_mixed,
    a.variant_rows,
    a.assigned_dim1s,
    a.assigned_dim3s
  FROM agg a
)
SELECT
  s.product_code,
  s.campaign_id,
  COALESCE(sc.code,'') AS campaign_code,
  COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
  s.is_mixed,
  s.variant_rows,
  s.assigned_dim1s,
  s.assigned_dim3s,
  COALESCE(NULLIF(BTRIM(sc.notes),''),'') AS notes
FROM single s
LEFT JOIN sdcampaign sc
  ON sc.id = s.campaign_id
ORDER BY s.product_code;
`, pq.Array(codes))
		if err != nil {
			http.Error(w, "assignment list error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		out := make([]campaignAssignmentRow, 0, len(codes))
		for rows.Next() {
			var it campaignAssignmentRow
			var cid sql.NullInt64
			if err := rows.Scan(&it.ProductCode, &cid, &it.CampaignCode, &it.DiscountRate, &it.IsMixed, &it.VariantRows, &it.AssignedDim1s, &it.AssignedDim3s, &it.Notes); err != nil {
				http.Error(w, "assignment scan error", http.StatusInternalServerError)
				return
			}
			if cid.Valid {
				v := cid.Int64
				it.CampaignID = &v
			}
			out = append(out, it)
		}

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(out)
		log.Printf("[WholesaleCampaignAssignments] trace=%s user=%s id=%d products=%d", traceID, claims.Username, claims.ID, len(out))
	}
}

type saveWholesaleCampaignItem struct {
	ProductCode string `json:"product_code"`
	Dim1        int64  `json:"dim1"`
	Dim3        *int64 `json:"dim3"`
	CampaignID  *int64 `json:"campaign_id"`
}

type saveWholesaleCampaignPayload struct {
	Items []saveWholesaleCampaignItem `json:"items"`
}

// POST /api/pricing/wholesale-campaigns/save
// Appends a new row to zbggcampaign per variant (dim1+dim3), preserving history.
func SaveWholesaleCampaignAssignmentsHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		started := time.Now()
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}

		var payload saveWholesaleCampaignPayload
		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
			http.Error(w, "invalid payload", http.StatusBadRequest)
			return
		}
		if len(payload.Items) == 0 {
			w.Header().Set("Content-Type", "application/json; charset=utf-8")
			_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0})
			return
		}
		if len(payload.Items) > 500 {
			http.Error(w, "too many items", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
		defer cancel()

		tx, err := pg.BeginTx(ctx, nil)
		if err != nil {
			http.Error(w, "tx begin error", http.StatusInternalServerError)
			return
		}
		defer tx.Rollback()

		// Resolve mmitem ids in bulk.
		codeList := make([]string, 0, len(payload.Items))
		seenCode := make(map[string]struct{}, len(payload.Items))
		for _, it := range payload.Items {
			code := strings.TrimSpace(it.ProductCode)
			if code == "" {
				continue
			}
			if _, ok := seenCode[code]; ok {
				continue
			}
			seenCode[code] = struct{}{}
			codeList = append(codeList, code)
		}

		codeToItemID := make(map[string]int64, len(codeList))
		if len(codeList) > 0 {
			rows, err := tx.QueryContext(ctx, `
SELECT code, id
FROM mmitem
WHERE code = ANY($1::text[])
`, pq.Array(codeList))
			if err != nil {
				http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			for rows.Next() {
				var code string
				var id int64
				if err := rows.Scan(&code, &id); err != nil {
					rows.Close()
					http.Error(w, "mmitem scan error", http.StatusInternalServerError)
					return
				}
				code = strings.TrimSpace(code)
				if code != "" && id > 0 {
					codeToItemID[code] = id
				}
			}
			rows.Close()
		}

		saved := 0
		for _, it := range payload.Items {
			code := strings.TrimSpace(it.ProductCode)
			if code == "" {
				continue
			}
			mmitemID := codeToItemID[code]
			if mmitemID <= 0 {
				continue
			}
			if it.Dim1 <= 0 {
				continue
			}
			d3k := int64(0)
			if it.Dim3 != nil && *it.Dim3 > 0 {
				d3k = *it.Dim3
			}

			// Normalize requested campaign id (nullable).
			var requested any = nil
			if it.CampaignID != nil && *it.CampaignID > 0 {
				requested = *it.CampaignID
			}

			// Skip write if "current" assignment is already the same (latest row).
			{
				var cur sql.NullInt64
				err := tx.QueryRowContext(ctx, `
SELECT sdcampaign_id
FROM zbggcampaign
WHERE mmitem_id = $1 AND dim1 = $2 AND COALESCE(dim3, 0) = $3
ORDER BY id DESC
LIMIT 1
`, mmitemID, it.Dim1, d3k).Scan(&cur)
				if err != nil && err != sql.ErrNoRows {
					http.Error(w, "current campaign lookup error: "+err.Error(), http.StatusInternalServerError)
					return
				}
				if err == sql.ErrNoRows && requested == nil {
					// Clearing a non-existent assignment: no-op.
					continue
				}
				if err == nil {
					// requested == nil means "clear"
					if requested == nil && !cur.Valid {
						continue
					}
					if requested != nil && cur.Valid && cur.Int64 == requested.(int64) {
						continue
					}
				}
			}

			if _, err := tx.ExecContext(ctx, `
INSERT INTO zbggcampaign (mmitem_id, dim1, dim3, sdcampaign_id)
VALUES ($1,$2,$3,$4)
`, mmitemID, it.Dim1, func() any {
				if d3k > 0 {
					return d3k
				}
				return nil
			}(), requested); err != nil {
				http.Error(w, "insert campaign row error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			saved++
		}

		if err := tx.Commit(); err != nil {
			http.Error(w, "commit error", http.StatusInternalServerError)
			return
		}

		// Send campaign mail (post-commit, best-effort).
		if ml != nil {
			codes := make([]string, 0, len(payload.Items))
			seen := map[string]struct{}{}
			for _, it := range payload.Items {
				c := strings.TrimSpace(it.ProductCode)
				if c == "" {
					continue
				}
				if _, ok := seen[c]; ok {
					continue
				}
				seen[c] = struct{}{}
				codes = append(codes, c)
			}
			go sendWholesaleCampaignChangeMails(context.Background(), ml, codes, claims.Username)
		}

		log.Printf("[WholesaleCampaignSave] trace=%s user=%s id=%d items=%d saved=%d duration_ms=%d",
			traceID, claims.Username, claims.ID, len(payload.Items), saved, time.Since(started).Milliseconds(),
		)
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(map[string]any{
			"success": true,
			"saved":   saved,
		})
	}
}

type wholesaleVariantStockRow struct {
	ProductCode string  `json:"product_code"`
	VariantCode string  `json:"variant_code"`
	StockQty    float64 `json:"stock_qty"`
}

type wholesaleVariantRow struct {
	ProductCode   string  `json:"product_code"`
	VariantCode   string  `json:"variant_code"`
	StockQty      float64 `json:"stock_qty"`
	Dim1          int64   `json:"dim1"`
	Dim3          *int64  `json:"dim3"`
	CampaignID    *int64  `json:"campaign_id"`
	CampaignCode  string  `json:"campaign_code"`
	CampaignTitle string  `json:"campaign_title"`
	DiscountRate  float64 `json:"discount_rate"`
	CampaignLast  string  `json:"campaign_last_dttm"`
}

func buildNebimVariantDisplayCode(colorCode string, dim3Code string) string {
	colorCode = strings.TrimSpace(colorCode)
	dim3Code = strings.TrimSpace(dim3Code)
	if colorCode != "" && dim3Code != "" {
		return colorCode + "-" + dim3Code
	}
	if colorCode != "" {
		return colorCode
	}
	return dim3Code
}

func chooseDisplayDimToken(raw string, resolvedID int64, reverse map[int64]string) string {
	raw = strings.TrimSpace(raw)
	if raw != "" {
		return raw
	}
	if resolvedID > 0 {
		if tok := strings.TrimSpace(reverse[resolvedID]); tok != "" {
			return tok
		}
	}
	return raw
}

type wholesaleCampaignHistoryRow struct {
	ID           int64   `json:"id"`
	CampaignID   *int64  `json:"campaign_id"`
	CampaignCode string  `json:"campaign_code"`
	Title        string  `json:"campaign_title"`
	DiscountRate float64 `json:"discount_rate"`
	At           string  `json:"at"`
}

type wholesaleCampaignHistoryResponse struct {
	Rows []wholesaleCampaignHistoryRow `json:"rows"`
}

type deleteSelectedIDsPayload struct {
	IDs []int64 `json:"ids"`
}

// GET /api/pricing/wholesale-campaigns/{code}/campaign-history?dim1=..&dim3=..
func GetWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		code := strings.TrimSpace(mux.Vars(r)["code"])
		if code == "" {
			http.Error(w, "missing code", http.StatusBadRequest)
			return
		}

		dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64)
		dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64)
		if dim1 <= 0 {
			http.Error(w, "missing dim1", http.StatusBadRequest)
			return
		}
		d3k := dim3
		if d3k < 0 {
			d3k = 0
		}

		ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
		defer cancel()

		var mmitemID int64
		if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil {
			if err == sql.ErrNoRows {
				w.Header().Set("Content-Type", "application/json; charset=utf-8")
				_ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: []wholesaleCampaignHistoryRow{}})
				return
			}
			http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
			return
		}

		rows, err := pg.QueryContext(ctx, `
SELECT
  z.id,
  z.sdcampaign_id,
  COALESCE(sc.code,'') AS code,
  COALESCE(sc.title,'') AS title,
  COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
  COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'DD.MM.YYYY'), '') AS at
FROM zbggcampaign z
LEFT JOIN sdcampaign sc
  ON sc.id = z.sdcampaign_id
WHERE z.mmitem_id = $1
  AND z.dim1 = $2
  AND COALESCE(z.dim3, 0) = $3
ORDER BY z.id DESC
LIMIT 200;
`, mmitemID, dim1, d3k)
		if err != nil {
			http.Error(w, "campaign history query error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		out := make([]wholesaleCampaignHistoryRow, 0, 64)
		for rows.Next() {
			var it wholesaleCampaignHistoryRow
			var cid sql.NullInt64
			if err := rows.Scan(&it.ID, &cid, &it.CampaignCode, &it.Title, &it.DiscountRate, &it.At); err != nil {
				http.Error(w, "campaign history scan error", http.StatusInternalServerError)
				return
			}
			if cid.Valid && cid.Int64 > 0 {
				v := cid.Int64
				it.CampaignID = &v
			}
			it.CampaignCode = strings.TrimSpace(it.CampaignCode)
			it.Title = strings.TrimSpace(it.Title)
			it.At = strings.TrimSpace(it.At)
			out = append(out, it)
		}

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: out})
		log.Printf("[WholesaleCampaignHistory] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d rows=%d",
			traceID, claims.Username, claims.ID, code, dim1, d3k, len(out),
		)
	}
}

// POST /api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected?dim1=..&dim3=..
func PostDeleteSelectedWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		code := strings.TrimSpace(mux.Vars(r)["code"])
		if code == "" {
			http.Error(w, "missing code", http.StatusBadRequest)
			return
		}
		dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64)
		dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64)
		if dim1 <= 0 {
			http.Error(w, "missing dim1", http.StatusBadRequest)
			return
		}
		d3k := dim3
		if d3k < 0 {
			d3k = 0
		}

		var payload deleteSelectedIDsPayload
		if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
			http.Error(w, "invalid payload", http.StatusBadRequest)
			return
		}
		if len(payload.IDs) == 0 {
			w.Header().Set("Content-Type", "application/json; charset=utf-8")
			_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": 0})
			return
		}
		if len(payload.IDs) > 500 {
			http.Error(w, "too many ids", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
		defer cancel()

		var mmitemID int64
		if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil {
			if err == sql.ErrNoRows {
				http.Error(w, "unknown code", http.StatusBadRequest)
				return
			}
			http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
			return
		}

		res, err := pg.ExecContext(ctx, `
DELETE FROM zbggcampaign
WHERE id = ANY($1::bigint[])
  AND mmitem_id = $2
  AND dim1 = $3
  AND COALESCE(dim3, 0) = $4
`, pq.Array(payload.IDs), mmitemID, dim1, d3k)
		if err != nil {
			http.Error(w, "delete error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		deleted, _ := res.RowsAffected()

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": deleted})
		log.Printf("[WholesaleCampaignHistoryDelete] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d deleted=%d",
			traceID, claims.Username, claims.ID, code, dim1, d3k, deleted,
		)
	}
}

// GET /api/pricing/wholesale-campaigns/variant-rows?product_code=A,B,C
// Returns variant-level rows with resolved PG dims and current campaign assignment (if any).
func GetWholesaleCampaignVariantRowsHandler(pg *sql.DB, mssql *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		if pg == nil {
			http.Error(w, "pg not ready", http.StatusServiceUnavailable)
			return
		}
		if mssql == nil {
			http.Error(w, "mssql not ready", http.StatusServiceUnavailable)
			return
		}

		codes := splitCSVParam(r.URL.Query().Get("product_code"))
		if len(codes) == 0 {
			w.Header().Set("Content-Type", "application/json; charset=utf-8")
			_ = json.NewEncoder(w).Encode([]wholesaleVariantRow{})
			return
		}
		if len(codes) > 250 {
			http.Error(w, "product_code too many", http.StatusBadRequest)
			return
		}

		// MSSQL 2008 + stock breakdown over many items can be slow; keep a generous timeout.
		ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
		defer cancel()

		// Resolve mmitem ids in bulk.
		codeToItem := make(map[string]int64, len(codes))
		itemToCode := make(map[int64]string, len(codes))
		{
			rows, err := pg.QueryContext(ctx, `
SELECT code, id
FROM mmitem
WHERE code = ANY($1::text[])
`, pq.Array(codes))
			if err != nil {
				http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			for rows.Next() {
				var c string
				var id int64
				if err := rows.Scan(&c, &id); err != nil {
					rows.Close()
					http.Error(w, "mmitem scan error", http.StatusInternalServerError)
					return
				}
				c = strings.TrimSpace(c)
				if c != "" && id > 0 {
					codeToItem[c] = id
					itemToCode[id] = c
				}
			}
			rows.Close()
		}

		// Dim token -> id resolver (fast path: mk_dim_token_map; fallback: dfblob file_name token inference).
		dimCache := make(map[string]int64, 1024)
		parseDimID := func(s string) (int64, bool) {
			s = strings.TrimSpace(s)
			if s == "" {
				return 0, false
			}
			s2 := strings.TrimLeft(s, "0")
			if s2 == "" {
				s2 = "0"
			}
			n, err := strconv.ParseInt(s2, 10, 64)
			if err != nil || n <= 0 {
				return 0, false
			}
			return n, true
		}
		resolveDimID := func(column, token string) (int64, bool) {
			token = strings.ToUpper(normalizeDimParam(token))
			if token == "" {
				return 0, false
			}
			k := column + "|" + token
			if v, ok := dimCache[k]; ok {
				return v, v > 0
			}
			// persistent cache
			{
				var id int64
				if err := pg.QueryRowContext(ctx, `
SELECT dim_id
FROM mk_dim_token_map
WHERE dim_column = $1 AND token = $2
`, column, token).Scan(&id); err == nil && id > 0 {
					dimCache[k] = id
					return id, true
				}
			}
			// fallback: infer id from dfblob metadata (token -> dimval id)
			v := resolveDimvalFromFileNameToken(pg, column, token)
			if v == "" {
				dimCache[k] = 0
				return 0, false
			}
			id, ok := parseDimID(v)
			if !ok {
				dimCache[k] = 0
				return 0, false
			}
			_, _ = pg.ExecContext(ctx, `
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (dim_column, token)
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
`, column, token, id)
			dimCache[k] = id
			return id, true
		}

		type tmpRow struct {
			ProductCode string
			VariantCode string
			StockQty    float64
			ItemID      int64
			Dim1        int64
			Dim3Key     int64
			HasMSSQL    bool
		}

		// Build base variant keys from PG's authoritative table (mmitem_dim).
		itemIDs := make([]int64, 0, len(codeToItem))
		for _, id := range codeToItem {
			itemIDs = append(itemIDs, id)
		}
		tmpMap := make(map[string]tmpRow, 4096)
		hasMMItemDim := make(map[int64]bool, len(itemIDs))
		dim1IDs := make([]int64, 0, 8192)
		dim3IDs := make([]int64, 0, 8192)
		itemDim1Candidates := make(map[int64][]int64, len(itemIDs))
		itemDim3Candidates := make(map[int64][]int64, len(itemIDs))
		addCandidate := func(dst map[int64][]int64, itemID int64, id int64) {
			if itemID <= 0 || id <= 0 {
				return
			}
			for _, existing := range dst[itemID] {
				if existing == id {
					return
				}
			}
			dst[itemID] = append(dst[itemID], id)
		}
		if len(itemIDs) > 0 {
			rows, err := pg.QueryContext(ctx, `
SELECT mmitem_id, mmdim_id, val1, val2, val3
FROM mmitem_dim
WHERE mmitem_id = ANY($1::bigint[])
  AND COALESCE(is_active, TRUE) = TRUE
`, pq.Array(itemIDs))
			if err != nil {
				http.Error(w, "mmitem_dim lookup error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			for rows.Next() {
				var itemID int64
				var mmdimID sql.NullInt64
				var v1 sql.NullInt64
				var v2 sql.NullInt64
				var v3 sql.NullInt64
				if err := rows.Scan(&itemID, &mmdimID, &v1, &v2, &v3); err != nil {
					rows.Close()
					http.Error(w, "mmitem_dim scan error", http.StatusInternalServerError)
					return
				}
				hasMMItemDim[itemID] = true
				if !v1.Valid || v1.Int64 <= 0 {
					continue
				}
				// Variant key in this installation: (val1=color, val3=ItemDim2Code/yaka_if_any). Ignore val2 (size).
				d1 := v1.Int64
				_ = v2
				addCandidate(itemDim1Candidates, itemID, d1)
				d3k := int64(0)
				if mmdimID.Valid && mmdimID.Int64 == 3 {
					if !v3.Valid || v3.Int64 <= 1000 {
						continue
					}
				}
				if v3.Valid && v3.Int64 > 1000 {
					d3k = v3.Int64
					addCandidate(itemDim3Candidates, itemID, d3k)
				}

				code := strings.TrimSpace(itemToCode[itemID])
				if code == "" {
					continue
				}
				key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
				if _, ok := tmpMap[key]; ok {
					continue
				}
				tmpMap[key] = tmpRow{
					ProductCode: code,
					VariantCode: "",
					StockQty:    0,
					ItemID:      itemID,
					Dim1:        d1,
					Dim3Key:     d3k,
				}
				dim1IDs = append(dim1IDs, d1)
				if d3k > 0 {
					dim3IDs = append(dim3IDs, d3k)
				}
			}
			rows.Close()
		}

		// Resolve dim ids -> tokens for a fallback readable VariantCode.
		// MSSQL/Nebim tokens override this below; PG ids are only storage keys.
		idToDim1Token := map[int64]string{}
		idToDim3Token := map[int64]string{}
		loadReverseTokens := func(column string, ids []int64, out map[int64]string) {
			if len(ids) == 0 {
				return
			}
			// uniq
			uniq := make([]int64, 0, len(ids))
			seen := make(map[int64]struct{}, len(ids))
			for _, id := range ids {
				if id <= 0 {
					continue
				}
				if _, ok := seen[id]; ok {
					continue
				}
				seen[id] = struct{}{}
				uniq = append(uniq, id)
			}
			if len(uniq) == 0 {
				return
			}
			rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT ON (dim_id) dim_id, token
FROM mk_dim_token_map
WHERE dim_column = $1
  AND dim_id = ANY($2::bigint[])
ORDER BY dim_id, updated_at DESC;
`, column, pq.Array(uniq))
			if err == nil {
				for rows.Next() {
					var id int64
					var tok string
					_ = rows.Scan(&id, &tok)
					tok = strings.TrimSpace(tok)
					if tok != "" {
						out[id] = tok
					}
				}
				rows.Close()
			}
		}
		loadReverseTokens("dimval1", dim1IDs, idToDim1Token)
		loadReverseTokens("dimval3", dim3IDs, idToDim3Token)
		for k, v := range tmpMap {
			t1 := strings.TrimSpace(idToDim1Token[v.Dim1])
			if t1 == "" {
				t1 = fmt.Sprintf("%d", v.Dim1)
			}
			if v.Dim3Key > 0 {
				t3 := strings.TrimSpace(idToDim3Token[v.Dim3Key])
				if t3 == "" {
					t3 = fmt.Sprintf("%d", v.Dim3Key)
				}
				v.VariantCode = t1 + "-" + t3
			} else {
				v.VariantCode = t1
			}
			tmpMap[k] = v
		}
		canonicalToken := func(column string, id int64) string {
			if id <= 0 {
				return ""
			}
			target := idToDim1Token
			if column == "dimval3" {
				target = idToDim3Token
			}
			if tok := strings.TrimSpace(target[id]); tok != "" {
				return tok
			}
			var tok string
			if err := pg.QueryRowContext(ctx, `
SELECT token
FROM mk_dim_token_map
WHERE dim_column = $1
  AND dim_id = $2
ORDER BY
  CASE WHEN token ~ '^[0-9]{3}$' THEN 0 ELSE 1 END,
  length(token),
  updated_at DESC
LIMIT 1
`, column, id).Scan(&tok); err == nil {
				tok = strings.TrimSpace(tok)
				if tok != "" {
					target[id] = tok
					return tok
				}
			}
			return ""
		}

		sortDimIDs := func(ids []int64) []int64 {
			out := append([]int64(nil), ids...)
			sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
			return out
		}
		sortTokens := func(tokens []string) []string {
			out := append([]string(nil), tokens...)
			sort.Slice(out, func(i, j int) bool {
				li := strings.TrimLeft(out[i], "0")
				lj := strings.TrimLeft(out[j], "0")
				if li == "" {
					li = "0"
				}
				if lj == "" {
					lj = "0"
				}
				ni, ei := strconv.ParseInt(li, 10, 64)
				nj, ej := strconv.ParseInt(lj, 10, 64)
				if ei == nil && ej == nil && ni != nj {
					return ni < nj
				}
				return out[i] < out[j]
			})
			return out
		}
		addToken := func(dst map[int64][]string, itemID int64, token string) {
			token = strings.ToUpper(normalizeDimParam(token))
			if itemID <= 0 || token == "" {
				return
			}
			for _, existing := range dst[itemID] {
				if existing == token {
					return
				}
			}
			dst[itemID] = append(dst[itemID], token)
		}
		buildInferredMap := func(column string, tokenByItem map[int64][]string, idsByItem map[int64][]int64) map[string]int64 {
			out := make(map[string]int64, 128)
			for itemID, tokens := range tokenByItem {
				sortedTokens := sortTokens(tokens)
				sortedIDs := sortDimIDs(idsByItem[itemID])
				if len(sortedTokens) == 0 || len(sortedIDs) == 0 {
					continue
				}
				limit := len(sortedTokens)
				if len(sortedIDs) < limit {
					limit = len(sortedIDs)
				}
				for i := 0; i < limit; i++ {
					token := sortedTokens[i]
					id := sortedIDs[i]
					if token == "" || id <= 0 {
						continue
					}
					key := fmt.Sprintf("%s|%d|%s", column, itemID, token)
					if _, exists := out[key]; !exists {
						out[key] = id
					}
				}
			}
			return out
		}
		persistDimToken := func(column string, token string, id int64) {
			token = strings.ToUpper(normalizeDimParam(token))
			if column == "" || token == "" || id <= 0 {
				return
			}
			_, _ = pg.ExecContext(ctx, `
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
VALUES ($1,$2,$3,now())
ON CONFLICT (dim_column, token)
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
`, column, token, id)
		}
		type msVariantRow struct {
			ItemCode  string
			ColorCode string
			Dim1Code  string
			Dim3Code  string
			Qty       sql.NullFloat64
		}

		// MSSQL: stock list for selected products; map to (mmitem_id, dim1, dim3_key) via token->id mapping.
		joined := strings.Join(codes, ",")
		msRows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined)
		if err != nil {
			http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		msVariants := make([]msVariantRow, 0, 1024)
		colorTokensByItem := make(map[int64][]string, len(itemIDs))
		dim3TokensByItem := make(map[int64][]string, len(itemIDs))
		itemHasMssqlDim3 := make(map[int64]bool, len(itemIDs))
		for msRows.Next() {
			var itemCode, colorCode, dim1Code, dim3Code string
			var qty sql.NullFloat64
			if err := msRows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
				msRows.Close()
				http.Error(w, "variant stock scan error", http.StatusInternalServerError)
				return
			}
			itemCode = strings.TrimSpace(itemCode)
			if itemCode == "" {
				continue
			}
			itemID := codeToItem[itemCode]
			if itemID <= 0 {
				continue
			}
			msVariants = append(msVariants, msVariantRow{
				ItemCode:  itemCode,
				ColorCode: colorCode,
				Dim1Code:  dim1Code,
				Dim3Code:  dim3Code,
				Qty:       qty,
			})
			addToken(colorTokensByItem, itemID, colorCode)
			addToken(dim3TokensByItem, itemID, dim3Code)
			if strings.TrimSpace(dim3Code) != "" {
				itemHasMssqlDim3[itemID] = true
			}
		}
		if err := msRows.Err(); err != nil {
			msRows.Close()
			http.Error(w, "variant stock rows error", http.StatusInternalServerError)
			return
		}
		msRows.Close()

		inferredDim1 := buildInferredMap("dimval1", colorTokensByItem, itemDim1Candidates)
		inferredDim3 := buildInferredMap("dimval3", dim3TokensByItem, itemDim3Candidates)
		for key, row := range tmpMap {
			if itemHasMssqlDim3[row.ItemID] && row.Dim3Key == 0 {
				delete(tmpMap, key)
			}
		}
		resolveProductDimID := func(itemID int64, column string, token string, inferred map[string]int64) (int64, bool) {
			token = strings.ToUpper(normalizeDimParam(token))
			if token == "" {
				return 0, false
			}
			if id := inferred[fmt.Sprintf("%s|%d|%s", column, itemID, token)]; id > 0 {
				persistDimToken(column, token, id)
				return id, true
			}
			if id, ok := resolveDimID(column, token); ok {
				return id, true
			}
			return 0, false
		}

		for _, ms := range msVariants {
			itemCode := ms.ItemCode
			colorCode := ms.ColorCode
			dim1Code := ms.Dim1Code
			dim3Code := ms.Dim3Code
			qty := ms.Qty
			itemID := codeToItem[itemCode]
			if itemID <= 0 {
				continue
			}

			// Map Nebim tokens to PG integer ids. Color and yaka must use separate token namespaces,
			// because the same visible token (for example "001") can exist in both dimensions.
			d1 := int64(0)
			if id, ok := resolveProductDimID(itemID, "dimval1", colorCode, inferredDim1); ok {
				d1 = id
			}
			if d1 <= 0 {
				continue
			}
			d3k := int64(0)
			if id, ok := resolveProductDimID(itemID, "dimval3", dim3Code, inferredDim3); ok {
				d3k = id
			}
			if strings.TrimSpace(dim3Code) != "" && d3k <= 0 {
				continue
			}
			key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
			prev, ok := tmpMap[key]
			if !ok {
				// Seed missing MSSQL combos even when the product already has some mmitem_dim rows.
				// PG remains the storage key source, but MSSQL may reveal new/missing color or dim3 combos.
				var v2 any = nil
				if sizeID, ok := resolveDimID("dimval1", dim1Code); ok && sizeID > 0 {
					v2 = sizeID
				}
				v3 := int64(0)
				if id, ok := resolveProductDimID(itemID, "dimval3", dim3Code, inferredDim3); ok {
					v3 = id
				}
				mmdimID := int64(2)
				var v3any any = nil
				if v3 > 0 {
					mmdimID = 3
					v3any = v3
				}
				_, _ = pg.ExecContext(ctx, `
INSERT INTO mmitem_dim (mmitem_id, mmdim_id, val1, val2, val3, is_active, qty)
SELECT $1, $2, $3, $4, $5, TRUE, 0
WHERE NOT EXISTS (
  SELECT 1
  FROM mmitem_dim
  WHERE mmitem_id = $1
    AND mmdim_id = $2
    AND val1 = $3
    AND COALESCE(val2, 0) = COALESCE($4::bigint, 0)
    AND COALESCE(val3, 0) = COALESCE($5::bigint, 0)
  LIMIT 1
);
`, itemID, mmdimID, d1, v2, v3any)
				hasMMItemDim[itemID] = true

				code := strings.TrimSpace(itemToCode[itemID])
				if code != "" {
					tmpMap[key] = tmpRow{
						ProductCode: code,
						VariantCode: "",
						StockQty:    0,
						ItemID:      itemID,
						Dim1:        d1,
						Dim3Key:     d3k,
					}
					// Keep dim token cache for VariantCode formatting.
					dim1IDs = append(dim1IDs, d1)
					if d3k > 0 {
						dim3IDs = append(dim3IDs, d3k)
					}
					prev = tmpMap[key]
					ok = true
				}
				if !ok {
					continue
				}
			}
			q := 0.0
			if qty.Valid {
				q = qty.Float64
			}
			prev.StockQty += q
			displayColor := chooseDisplayDimToken(colorCode, d1, map[int64]string{d1: canonicalToken("dimval1", d1)})
			displayDim3 := chooseDisplayDimToken(dim3Code, d3k, map[int64]string{d3k: canonicalToken("dimval3", d3k)})
			prev.VariantCode = buildNebimVariantDisplayCode(displayColor, displayDim3)
			prev.HasMSSQL = true
			tmpMap[key] = prev
			_ = colorCode // display-only
		}

		tmp := make([]tmpRow, 0, len(tmpMap))
		for _, v := range tmpMap {
			tmp = append(tmp, v)
		}
		sort.SliceStable(tmp, func(i, j int) bool {
			if tmp[i].ProductCode != tmp[j].ProductCode {
				return tmp[i].ProductCode < tmp[j].ProductCode
			}
			if tmp[i].HasMSSQL != tmp[j].HasMSSQL {
				return tmp[i].HasMSSQL
			}
			return strings.TrimSpace(tmp[i].VariantCode) < strings.TrimSpace(tmp[j].VariantCode)
		})

		// Bulk load campaign assignment for each (mmitem_id, dim1, dim3_key)
		type keyRec struct {
			ItemID  int64 `json:"mmitem_id"`
			Dim1    int64 `json:"dim1"`
			Dim3Key int64 `json:"dim3_key"`
		}
		keys := make([]keyRec, 0, len(tmp))
		seenKey := make(map[string]struct{}, len(tmp))
		for _, t := range tmp {
			k := fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)
			if _, ok := seenKey[k]; ok {
				continue
			}
			seenKey[k] = struct{}{}
			keys = append(keys, keyRec{ItemID: t.ItemID, Dim1: t.Dim1, Dim3Key: t.Dim3Key})
		}
		rawKeys, _ := json.Marshal(keys)

		type campAgg struct {
			CampaignID    sql.NullInt64
			CampaignCode  string
			CampaignTitle string
			DiscountRate  float64
			CampaignLast  string
		}
		campMap := make(map[string]campAgg, len(keys))
		if len(keys) > 0 {
			rows, err := pg.QueryContext(ctx, `
WITH input AS (
  SELECT *
  FROM jsonb_to_recordset($1::jsonb) AS x(mmitem_id bigint, dim1 int, dim3_key int)
),
latest AS (
  SELECT
    i.mmitem_id,
    i.dim1,
    i.dim3_key,
    MAX(z.id)::bigint AS z_id
  FROM input i
  LEFT JOIN zbggcampaign z
    ON z.mmitem_id = i.mmitem_id
   AND z.dim1 = i.dim1
   AND COALESCE(z.dim3, 0) = i.dim3_key
  GROUP BY i.mmitem_id, i.dim1, i.dim3_key
)
SELECT
  l.mmitem_id,
  l.dim1,
  l.dim3_key,
  z.sdcampaign_id,
  COALESCE(sc.code,'') AS code,
  COALESCE(sc.title,'') AS title,
  COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
  COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'DD.MM.YYYY'), '') AS campaign_last_dttm
 FROM latest l
 LEFT JOIN zbggcampaign z
   ON z.id = l.z_id
 LEFT JOIN sdcampaign sc
   ON sc.id = z.sdcampaign_id
`, rawKeys)
			if err != nil {
				http.Error(w, "campaign lookup error: "+err.Error(), http.StatusInternalServerError)
				return
			}
			for rows.Next() {
				var itemID, d1, d3k int64
				var cid sql.NullInt64
				var code, title string
				var rate float64
				var last string
				if err := rows.Scan(&itemID, &d1, &d3k, &cid, &code, &title, &rate, &last); err != nil {
					rows.Close()
					http.Error(w, "campaign scan error", http.StatusInternalServerError)
					return
				}
				campMap[fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)] = campAgg{
					CampaignID:    cid,
					CampaignCode:  strings.TrimSpace(code),
					CampaignTitle: strings.TrimSpace(title),
					DiscountRate:  rate,
					CampaignLast:  strings.TrimSpace(last),
				}
			}
			rows.Close()
		}

		out := make([]wholesaleVariantRow, 0, len(tmp))
		for _, t := range tmp {
			agg := campMap[fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)]
			var cidp *int64
			if agg.CampaignID.Valid && agg.CampaignID.Int64 > 0 {
				v := agg.CampaignID.Int64
				cidp = &v
			}
			var d3p *int64
			if t.Dim3Key > 0 {
				v := t.Dim3Key
				d3p = &v
			}
			out = append(out, wholesaleVariantRow{
				ProductCode:   t.ProductCode,
				VariantCode:   t.VariantCode,
				StockQty:      t.StockQty,
				Dim1:          t.Dim1,
				Dim3:          d3p,
				CampaignID:    cidp,
				CampaignCode:  agg.CampaignCode,
				CampaignTitle: agg.CampaignTitle,
				DiscountRate:  agg.DiscountRate,
				CampaignLast:  agg.CampaignLast,
			})
		}

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(out)
		log.Printf("[WholesaleCampaignVariantRows] trace=%s user=%s id=%d products=%d rows=%d",
			traceID, claims.Username, claims.ID, len(codes), len(out),
		)
	}
}

// GET /api/pricing/wholesale-campaigns/variants?product_code=A,B,C
func GetWholesaleCampaignVariantStockHandler(mssql *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		traceID := buildPricingTraceID(r)
		w.Header().Set("X-Trace-ID", traceID)

		claims, ok := auth.GetClaimsFromContext(r.Context())
		if !ok || claims == nil {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		if mssql == nil {
			http.Error(w, "mssql not ready", http.StatusServiceUnavailable)
			return
		}

		codes := splitCSVParam(r.URL.Query().Get("product_code"))
		if len(codes) == 0 {
			w.Header().Set("Content-Type", "application/json; charset=utf-8")
			_ = json.NewEncoder(w).Encode([]wholesaleVariantStockRow{})
			return
		}
		if len(codes) > 300 {
			http.Error(w, "product_code too many", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
		defer cancel()

		joined := strings.Join(codes, ",")
		rows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined)
		if err != nil {
			http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		out := make([]wholesaleVariantStockRow, 0, 2048)
		for rows.Next() {
			var itemCode, colorCode, dim1Code, dim3Code string
			var qty sql.NullFloat64
			if err := rows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
				http.Error(w, "variant stock scan error", http.StatusInternalServerError)
				return
			}
			itemCode = strings.TrimSpace(itemCode)
			if itemCode == "" {
				continue
			}
			// Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute.
			t1 := strings.TrimSpace(colorCode)
			if t1 == "" || t1 == "0" {
				t1 = strings.TrimSpace(dim1Code)
			}
			t3 := strings.TrimSpace(dim3Code)
			varCode := t1
			if t1 != "" && t3 != "" && t3 != "0" {
				varCode = t1 + "-" + t3
			}
			if varCode == "" {
				continue
			}
			q := 0.0
			if qty.Valid {
				q = qty.Float64
			}
			out = append(out, wholesaleVariantStockRow{
				ProductCode: itemCode,
				VariantCode: varCode,
				StockQty:    q,
			})
		}

		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		_ = json.NewEncoder(w).Encode(out)
		log.Printf("[WholesaleCampaignVariants] trace=%s user=%s id=%d products=%d rows=%d",
			traceID, claims.Username, claims.ID, len(codes), len(out),
		)
	}
}
