package main

import (
	"bssapp-backend/db"
	"bssapp-backend/queries"
	"context"
	"crypto/sha1"
	"database/sql"
	"encoding/hex"
	"fmt"
	"log"
	"math"
	"os"
	"sort"
	"strconv"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"github.com/lib/pq"
)

type productSeriesAutoStockRaw struct {
	ProductCode        string
	ProductDescription string
	ColorCode          string
	ColorTitle         string
	Dim3Code           string
	SizeCode           string
	Qty                float64
	UrunAnaGrubu       string
	UrunAltGrubu       string
	Marka              string
	DropVal            string
	Fit                string
	UrunIcerigi        string
	Kategori           string
}

type productSeriesAutoVariant struct {
	RowKey       string
	ProductCode  string
	ColorCode    string
	Dim3Code     string
	SizeQty      map[string]float64
	TotalQty     float64
	UrunAnaGrubu string
	UrunAltGrubu string
	Kategori     string
	GroupKey     string
	StockHash    string
}

type productSeriesAutoRule struct {
	SeriesID int64
	Code     string
	Title    string
	GroupKey string
	Ratio    map[string]int
	Priority int
}

type productSeriesQueueItem struct {
	ID          int64
	RowKey      string
	ProductCode string
	ColorCode   string
	Dim3Code    string
	Attempts    int
}

type productSeriesAutoStats struct {
	Scanned   int
	Changed   int
	Processed int
	Written   int
	Skipped   int
}

var productSeriesFallbackMu sync.Mutex
var productSeriesFallbackCachedCode string
var productSeriesFallbackCachedID int64
var productSeriesFallbackCachedAt time.Time

func productSeriesFallbackEnabled() bool {
	// Default on: user explicitly requested "stok var ama seri yoksa 1- ata".
	raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_ON_EMPTY")))
	if raw == "" {
		return true
	}
	return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
}

func productSeriesFallbackAutoCreateEnabled() bool {
	// Default on: avoid manual intervention by auto-creating the fallback dfgrp row when missing.
	raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_AUTO_CREATE")))
	if raw == "" {
		return true
	}
	return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
}

func productSeriesFallbackLogEnabled() bool {
	raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_LOG")))
	if raw == "" {
		return false
	}
	return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
}

func productSeriesFallbackOverrideExisting() bool {
	// Default off: do not override existing (possibly manual) assignments.
	raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_OVERRIDE_EXISTING")))
	if raw == "" {
		return false
	}
	return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
}

func productSeriesDebugMatch(v productSeriesAutoVariant) bool {
	p := strings.TrimSpace(os.Getenv("PRODUCT_SERIES_DEBUG_PRODUCT"))
	if p != "" && !strings.EqualFold(strings.TrimSpace(v.ProductCode), p) {
		return false
	}
	c := strings.TrimSpace(os.Getenv("PRODUCT_SERIES_DEBUG_COLOR"))
	if c != "" && !strings.EqualFold(strings.TrimSpace(v.ColorCode), c) {
		return false
	}
	d := strings.TrimSpace(os.Getenv("PRODUCT_SERIES_DEBUG_DIM3"))
	if d != "" && !strings.EqualFold(strings.TrimSpace(v.Dim3Code), d) {
		return false
	}
	return p != "" || c != "" || d != ""
}

func productSeriesFormatSizeQty(m map[string]float64) string {
	if len(m) == 0 {
		return "{}"
	}
	keys := make([]string, 0, len(m))
	for k := range m {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, fmt.Sprintf("%s=%.0f", k, m[k]))
	}
	return "{" + strings.Join(parts, ",") + "}"
}

func productSeriesFallbackCode() string {
	code := strings.TrimSpace(os.Getenv("PRODUCT_SERIES_FALLBACK_SERIES_CODE"))
	if code == "" {
		// In this installation, the default series code is "1" (not "1-").
		code = "1"
	}
	return code
}

func productSeriesResolveFallbackSeries(ctx context.Context, pg *sql.DB) (int64, string, error) {
	if !productSeriesFallbackEnabled() {
		return 0, "", nil
	}
	code := productSeriesFallbackCode()
	if code == "" {
		return 0, "", nil
	}

	productSeriesFallbackMu.Lock()
	// Cache for a bit to avoid repeated lookups under high throughput.
	if productSeriesFallbackCachedCode == code && productSeriesFallbackCachedAt.After(time.Now().Add(-10*time.Minute)) {
		id := productSeriesFallbackCachedID
		productSeriesFallbackMu.Unlock()
		return id, code, nil
	}
	productSeriesFallbackMu.Unlock()

	var id int64
	var gotCode string
	err := pg.QueryRowContext(ctx, `
SELECT id, COALESCE(code,'')
FROM dfgrp
WHERE master='zbggseri'
  AND COALESCE(is_active, TRUE)=TRUE
  AND (
    code = $1
    OR title = $1
    OR title LIKE $1 || '%'
  )
ORDER BY
  CASE
    WHEN code = $1 THEN 0
    WHEN title = $1 THEN 1
    ELSE 2
  END,
  id
LIMIT 1
`, code).Scan(&id, &gotCode)
	if err != nil {
		if err == sql.ErrNoRows {
			if !productSeriesFallbackAutoCreateEnabled() {
				// cache negative for a short period too
				productSeriesFallbackMu.Lock()
				productSeriesFallbackCachedCode = code
				productSeriesFallbackCachedID = 0
				productSeriesFallbackCachedAt = time.Now()
				productSeriesFallbackMu.Unlock()
				return 0, code, nil
			}

			// Auto-create the fallback series definition (best-effort) to avoid manual steps.
			// We guard with the same mutex to avoid duplicate inserts within this process.
			productSeriesFallbackMu.Lock()
			defer productSeriesFallbackMu.Unlock()

			// Re-check under lock in case another goroutine created it while we were waiting.
			if productSeriesFallbackCachedCode == code && productSeriesFallbackCachedID > 0 &&
				productSeriesFallbackCachedAt.After(time.Now().Add(-10*time.Minute)) {
				return productSeriesFallbackCachedID, code, nil
			}

			var createdID int64
			createErr := pg.QueryRowContext(ctx, `
INSERT INTO dfgrp (code, title, is_active, typ, master, parent_filter, sort_order, is_required, notes)
SELECT $1, $1, TRUE, 'opt', 'zbggseri', '', 0, FALSE, 'auto-created fallback series'
WHERE NOT EXISTS (
  SELECT 1 FROM dfgrp WHERE master='zbggseri' AND code=$1
)
RETURNING id
`, code).Scan(&createdID)
			if createErr != nil {
				// If RETURNING didn't return because it already exists, select it now.
				var sid int64
				var scode string
				selErr := pg.QueryRowContext(ctx, `
SELECT id, COALESCE(code,'')
FROM dfgrp
WHERE master='zbggseri' AND code=$1
ORDER BY id
LIMIT 1
`, code).Scan(&sid, &scode)
				if selErr != nil {
					// still missing; treat as disabled for now
					productSeriesFallbackCachedCode = code
					productSeriesFallbackCachedID = 0
					productSeriesFallbackCachedAt = time.Now()
					return 0, code, nil
				}
				createdID = sid
			}

			productSeriesFallbackCachedCode = code
			productSeriesFallbackCachedID = createdID
			productSeriesFallbackCachedAt = time.Now()
			return createdID, code, nil
		}
		return 0, code, err
	}
	if gotCode != "" {
		code = gotCode
	}
	productSeriesFallbackMu.Lock()
	productSeriesFallbackCachedCode = code
	productSeriesFallbackCachedID = id
	productSeriesFallbackCachedAt = time.Now()
	productSeriesFallbackMu.Unlock()
	return id, code, nil
}

var productSeriesSizeGroups = map[string][]string{
	"tak":        {"44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64", "66", "68", "70", "72", "74"},
	"ayk":        {"39", "40", "41", "42", "43", "44", "45", "46"},
	"ayk_garson": {"22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37"},
	"yas":        {"2", "4", "6", "8", "10", "12", "14"},
	"pan":        {"38", "40", "42", "44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64", "66", "68"},
	"gom":        {"XS", "S", "M", "L", "XL", "2XL", "3XL", "4XL", "5XL", "6XL", "7XL"},
	"aks":        {"44", "STD", "110", "115", "120", "125", "130", "135"},
}

func startProductSeriesAutoSchedulers(pgDB *sql.DB) {
	enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_AUTO_ENABLED")))
	if enabled == "0" || enabled == "false" || enabled == "off" {
		log.Println("Product series auto scheduler disabled")
		return
	}
	if pgDB == nil || db.MssqlDB == nil {
		return
	}

	apply := envBool("PRODUCT_SERIES_AUTO_APPLY", false)
	deltaIntervalMin := envIntRange("PRODUCT_SERIES_DELTA_INTERVAL_MIN", 60, 5, 1440)
	queueBatch := envIntRange("PRODUCT_SERIES_DELTA_BATCH_SIZE", 300, 10, 2000)
	fullHH, fullMM := envHHMM("PRODUCT_SERIES_FULL_HHMM", 5, 0)
	// Deploys should not trigger heavy jobs. Startup runs are gated behind an extra allow flag so
	// a stale/old env var value can't accidentally bring back "runs on every deploy" behavior.
	allowStartupRuns := envBool("PRODUCT_SERIES_ALLOW_STARTUP_RUNS", false)
	runFullOnStartup := allowStartupRuns && envBool("PRODUCT_SERIES_FULL_RUN_ON_STARTUP", false)
	runDeltaOnStartup := allowStartupRuns && envBool("PRODUCT_SERIES_DELTA_RUN_ON_STARTUP", false)

	var deltaRunning int32
	var fullRunning int32

	runDelta := func(reason string) {
		if !atomic.CompareAndSwapInt32(&deltaRunning, 0, 1) {
			log.Printf("[ProductSeriesDelta] skip (%s): already running", reason)
			return
		}
		defer atomic.StoreInt32(&deltaRunning, 0)

		ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
		defer cancel()

		stats, err := productSeriesDetectStockChanges(ctx, pgDB, reason)
		if err != nil {
			log.Printf("[ProductSeriesDelta] detect_error (%s): %v", reason, err)
			return
		}
		processed, written, skipped, err := productSeriesProcessQueue(ctx, pgDB, queueBatch, apply)
		stats.Processed += processed
		stats.Written += written
		stats.Skipped += skipped
		if err != nil {
			log.Printf("[ProductSeriesDelta] process_error (%s): %v", reason, err)
			return
		}
		log.Printf("[ProductSeriesDelta] ok (%s): scanned=%d changed=%d processed=%d written=%d skipped=%d apply=%t interval_min=%d",
			reason, stats.Scanned, stats.Changed, stats.Processed, stats.Written, stats.Skipped, apply, deltaIntervalMin)
	}

	runFull := func(reason string) {
		if !atomic.CompareAndSwapInt32(&fullRunning, 0, 1) {
			log.Printf("[ProductSeriesFull] skip (%s): already running", reason)
			return
		}
		defer atomic.StoreInt32(&fullRunning, 0)

		ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
		defer cancel()

		stats, err := productSeriesFullRebuild(ctx, pgDB, apply, reason)
		if err != nil {
			log.Printf("[ProductSeriesFull] error (%s): %v", reason, err)
			return
		}
		log.Printf("[ProductSeriesFull] ok (%s): scanned=%d processed=%d written=%d skipped=%d apply=%t hhmm=%02d:%02d",
			reason, stats.Scanned, stats.Processed, stats.Written, stats.Skipped, apply, fullHH, fullMM)
	}

	go func() {
		time.Sleep(3 * time.Second)
		if runDeltaOnStartup {
			runDelta("startup")
		}
		ticker := time.NewTicker(time.Duration(deltaIntervalMin) * time.Minute)
		defer ticker.Stop()
		for range ticker.C {
			runDelta("scheduled")
		}
	}()

	go func() {
		time.Sleep(5 * time.Second)
		if runFullOnStartup {
			runFull("startup_manual")
		}
		for {
			next := nextProductSeriesDailyRun(time.Now(), fullHH, fullMM)
			wait := time.Until(next)
			if wait < 0 {
				wait = time.Minute
			}
			log.Printf("[ProductSeriesFull] scheduled next_at=%s in=%s", next.Format(time.RFC3339), wait.Round(time.Second))
			time.Sleep(wait)
			runFull("daily")
		}
	}()
}

func productSeriesDetectStockChanges(ctx context.Context, pg *sql.DB, reason string) (productSeriesAutoStats, error) {
	var stats productSeriesAutoStats
	variants, scanned, err := productSeriesLoadMSSQLStockVariants(ctx)
	stats.Scanned = scanned
	if err != nil {
		return stats, err
	}

	tx, err := pg.BeginTx(ctx, nil)
	if err != nil {
		return stats, err
	}
	defer tx.Rollback()

	currentKeys := make([]string, 0, len(variants))
	for key, v := range variants {
		currentKeys = append(currentKeys, key)
		var oldHash string
		err := tx.QueryRowContext(ctx, `SELECT stock_hash FROM mk_product_series_stock_state WHERE row_key=$1`, key).Scan(&oldHash)
		if err != nil && err != sql.ErrNoRows {
			return stats, err
		}
		if err == sql.ErrNoRows || oldHash != v.StockHash {
			if _, err := productSeriesEnqueueTx(ctx, tx, v.ProductCode, v.ColorCode, v.Dim3Code, reason); err != nil {
				return stats, err
			}
			stats.Changed++
		}
		if _, err := tx.ExecContext(ctx, `
INSERT INTO mk_product_series_stock_state (row_key, product_code, color_code, dim3_code, stock_hash, total_qty, last_seen_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, now(), now())
ON CONFLICT (row_key) DO UPDATE
SET stock_hash=EXCLUDED.stock_hash,
    total_qty=EXCLUDED.total_qty,
    last_seen_at=now(),
    updated_at=now()
`, key, v.ProductCode, v.ColorCode, v.Dim3Code, v.StockHash, v.TotalQty); err != nil {
			return stats, err
		}
	}

	rows, err := tx.QueryContext(ctx, `
SELECT row_key, product_code, color_code, dim3_code
FROM mk_product_series_stock_state
WHERE NOT (row_key = ANY($1))
`, pq.Array(currentKeys))
	if err != nil {
		return stats, err
	}
	missing := []productSeriesQueueItem{}
	for rows.Next() {
		var it productSeriesQueueItem
		if err := rows.Scan(&it.RowKey, &it.ProductCode, &it.ColorCode, &it.Dim3Code); err != nil {
			_ = rows.Close()
			return stats, err
		}
		missing = append(missing, it)
	}
	_ = rows.Close()
	for _, it := range missing {
		if _, err := productSeriesEnqueueTx(ctx, tx, it.ProductCode, it.ColorCode, it.Dim3Code, "stock_zero_"+reason); err != nil {
			return stats, err
		}
		stats.Changed++
	}
	retryCount, err := productSeriesEnqueueUnmatchedStateTx(ctx, tx, reason)
	if err != nil {
		return stats, err
	}
	stats.Changed += retryCount

	return stats, tx.Commit()
}

func productSeriesFullRebuild(ctx context.Context, pg *sql.DB, apply bool, reason string) (productSeriesAutoStats, error) {
	var stats productSeriesAutoStats
	variants, scanned, err := productSeriesLoadMSSQLStockVariants(ctx)
	stats.Scanned = scanned
	if err != nil {
		return stats, err
	}
	rules, err := productSeriesLoadRules(ctx, pg)
	if err != nil {
		return stats, err
	}
	for _, v := range variants {
		written, skipped, err := productSeriesApplyVariant(ctx, pg, v, rules, apply)
		if err != nil {
			return stats, err
		}
		stats.Processed++
		stats.Written += written
		stats.Skipped += skipped
	}
	_, _ = pg.ExecContext(ctx, `
INSERT INTO mk_product_series_job_log (job_name, reason, finished_at, status, scanned_rows, processed_rows, written_rows, skipped_rows)
VALUES ('product_series_full', $1, now(), 'ok', $2, $3, $4, $5)
`, reason, stats.Scanned, stats.Processed, stats.Written, stats.Skipped)
	return stats, nil
}

func productSeriesProcessQueue(ctx context.Context, pg *sql.DB, batchSize int, apply bool) (int, int, int, error) {
	rules, err := productSeriesLoadRules(ctx, pg)
	if err != nil {
		return 0, 0, 0, err
	}
	variants, _, err := productSeriesLoadMSSQLStockVariants(ctx)
	if err != nil {
		return 0, 0, 0, err
	}
	totalProcessed, totalWritten, totalSkipped := 0, 0, 0
	for {
		tx, err := pg.BeginTx(ctx, nil)
		if err != nil {
			return totalProcessed, totalWritten, totalSkipped, err
		}
		items, err := productSeriesClaimQueue(ctx, tx, batchSize)
		if err != nil {
			_ = tx.Rollback()
			return totalProcessed, totalWritten, totalSkipped, err
		}
		if err := tx.Commit(); err != nil {
			return totalProcessed, totalWritten, totalSkipped, err
		}
		if len(items) == 0 {
			break
		}

		for _, it := range items {
			variant, ok := variants[productSeriesAutoKey(it.ProductCode, it.ColorCode, it.Dim3Code)]
			if !ok {
				variant = productSeriesAutoVariant{
					RowKey:      productSeriesAutoKey(it.ProductCode, it.ColorCode, it.Dim3Code),
					ProductCode: it.ProductCode,
					ColorCode:   it.ColorCode,
					Dim3Code:    it.Dim3Code,
					SizeQty:     map[string]float64{},
				}
			}
			written, skipped, err := productSeriesApplyVariant(ctx, pg, variant, rules, apply)
			if err != nil {
				_ = productSeriesMarkQueueFailed(ctx, pg, it, err.Error())
				return totalProcessed, totalWritten, totalSkipped, err
			}
			totalProcessed++
			totalWritten += written
			totalSkipped += skipped
			_ = productSeriesMarkQueueDone(ctx, pg, it.ID)
		}
	}
	return totalProcessed, totalWritten, totalSkipped, nil
}

func productSeriesApplyVariant(ctx context.Context, pg *sql.DB, v productSeriesAutoVariant, rules []productSeriesAutoRule, apply bool) (int, int, error) {
	mmitemID, dim1ID, dim3ID, ready, err := productSeriesResolvePGVariant(ctx, pg, v.ProductCode, v.ColorCode, v.Dim3Code)
	if err != nil {
		return 0, 1, err
	}
	appliedFallback := false
	if !ready {
		if productSeriesFallbackLogEnabled() {
			log.Printf("[ProductSeriesFallback] skip_not_ready product=%s color=%s dim3=%s", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code))
		}
		return 0, 1, nil
	}
	if productSeriesDebugMatch(v) {
		log.Printf("[ProductSeriesDebug] variant product=%s color=%s dim3=%s mmitem_id=%d dim1=%d dim3_id=%v group=%s total=%.0f sizes=%s",
			strings.TrimSpace(v.ProductCode),
			strings.TrimSpace(v.ColorCode),
			strings.TrimSpace(v.Dim3Code),
			mmitemID,
			dim1ID,
			func() any {
				if dim3ID.Valid {
					return dim3ID.Int64
				}
				return nil
			}(),
			strings.TrimSpace(v.GroupKey),
			math.Round(v.TotalQty),
			productSeriesFormatSizeQty(v.SizeQty),
		)
	}
	selected := productSeriesSelectRules(v, rules)
	if productSeriesDebugMatch(v) {
		if len(selected) == 0 {
			log.Printf("[ProductSeriesDebug] selected=0 product=%s color=%s dim3=%s", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code))
		} else {
			ids := make([]string, 0, len(selected))
			for _, r := range selected {
				ids = append(ids, fmt.Sprintf("%d", r.SeriesID))
			}
			log.Printf("[ProductSeriesDebug] selected=%d product=%s color=%s dim3=%s series_ids=%s",
				len(selected),
				strings.TrimSpace(v.ProductCode),
				strings.TrimSpace(v.ColorCode),
				strings.TrimSpace(v.Dim3Code),
				strings.Join(ids, ","),
			)
		}
	}
	if len(selected) == 0 {
		// Apply fallback based on per-size availability, not total.
		// If none of the sizes has qty >= 1 (default), do nothing.
		minSizeQty := envFloat64("PRODUCT_SERIES_FALLBACK_MIN_SIZE_QTY", 1)
		if !productSeriesHasAnySizeAtLeast(v.SizeQty, minSizeQty) {
			if productSeriesFallbackLogEnabled() {
				log.Printf("[ProductSeriesFallback] skip_insufficient_stock product=%s color=%s dim3=%s min_size_qty=%.2f sizes=%s",
					strings.TrimSpace(v.ProductCode),
					strings.TrimSpace(v.ColorCode),
					strings.TrimSpace(v.Dim3Code),
					minSizeQty,
					productSeriesFormatSizeQty(v.SizeQty),
				)
			}
			return 0, 1, nil
		}

		fallbackID, fallbackCode, err := productSeriesResolveFallbackSeries(ctx, pg)
		if err != nil {
			return 0, 1, err
		}
		if fallbackID > 0 {
			if !productSeriesFallbackOverrideExisting() {
				// By default we don't override existing assignments. However, if the existing assignment(s)
				// are no longer compatible with current stock (e.g., stock sold out), keeping them causes
				// stale/wrong series to stick forever (already_exists). In that case, we should replace
				// them with fallback=1.
				existingIDs, err := productSeriesLoadExistingSeriesIDs(ctx, pg, mmitemID, dim1ID, dim3ID)
				if err != nil {
					return 0, 1, err
				}
				if len(existingIDs) > 0 {
					// Optional grace window: if we recently applied non-fallback rules for this variant,
					// don't immediately flap back to fallback due to a transient/partial stock snapshot.
					graceMin := envIntRange("PRODUCT_SERIES_FALLBACK_GRACE_MINUTES", 2, 0, 60)
					if graceMin > 0 {
						rowKey := productSeriesAutoKey(v.ProductCode, v.ColorCode, v.Dim3Code)
						kind, at, err := productSeriesLoadLastApplyState(ctx, pg, rowKey)
						if err != nil {
							return 0, 1, err
						}
						if kind == "rules" && time.Since(at) < time.Duration(graceMin)*time.Minute {
							// keep existing mapping as-is for now
							if productSeriesFallbackLogEnabled() {
								log.Printf("[ProductSeriesFallback] grace_skip product=%s color=%s dim3=%s grace_min=%d last_rules_at=%s",
									strings.TrimSpace(v.ProductCode),
									strings.TrimSpace(v.ColorCode),
									strings.TrimSpace(v.Dim3Code),
									graceMin,
									at.Format(time.RFC3339),
								)
							}
							return 0, 0, nil
						}
					}

					anyStillValid := false
					for _, sid := range existingIDs {
						// If we can't find the rule definition in memory, assume it's a manual/unknown mapping and keep it.
						rule, ok := productSeriesFindRuleByID(rules, sid)
						if !ok {
							anyStillValid = true
							break
						}
						if productSeriesCanConsume(v.SizeQty, rule) {
							anyStillValid = true
							break
						}
					}
					if anyStillValid {
						_ = productSeriesResetInvalidStreak(ctx, pg, productSeriesAutoKey(v.ProductCode, v.ColorCode, v.Dim3Code))
						if productSeriesFallbackLogEnabled() {
							log.Printf("[ProductSeriesFallback] already_exists product=%s color=%s dim3=%s fallback=%s(%d)", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode), fallbackID)
						}
						// keep existing manual/previous assignment; nothing to do
						return 0, 0, nil
					}

					// Guardrail: if stock snapshots are partial/transient, "existing invalid" can flap.
					// Require N consecutive invalid observations before overwriting existing mapping.
					confirmations := envIntRange("PRODUCT_SERIES_EXISTING_INVALID_CONFIRMATIONS", 3, 1, 20)
					rowKey := productSeriesAutoKey(v.ProductCode, v.ColorCode, v.Dim3Code)
					streak, err := productSeriesBumpInvalidStreak(ctx, pg, rowKey)
					if err != nil {
						return 0, 1, err
					}
					if streak < confirmations {
						if productSeriesFallbackLogEnabled() {
							log.Printf("[ProductSeriesFallback] existing_invalid_defer product=%s color=%s dim3=%s streak=%d/%d fallback=%s(%d)",
								strings.TrimSpace(v.ProductCode),
								strings.TrimSpace(v.ColorCode),
								strings.TrimSpace(v.Dim3Code),
								streak,
								confirmations,
								strings.TrimSpace(fallbackCode),
								fallbackID,
							)
						}
						// keep existing mapping for now
						return 0, 0, nil
					}

					if productSeriesFallbackLogEnabled() {
						log.Printf("[ProductSeriesFallback] existing_invalid_apply product=%s color=%s dim3=%s fallback=%s(%d)", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode), fallbackID)
					}
				}
			}
			if productSeriesFallbackLogEnabled() {
				if productSeriesFallbackOverrideExisting() {
					log.Printf("[ProductSeriesFallback] override_apply product=%s color=%s dim3=%s fallback=%s(%d)", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode), fallbackID)
				} else {
					log.Printf("[ProductSeriesFallback] apply product=%s color=%s dim3=%s fallback=%s(%d)", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode), fallbackID)
				}
			}
			// Use the fallback series as the single selected rule.
			selected = []productSeriesAutoRule{{SeriesID: fallbackID}}
			appliedFallback = true
		} else {
			if productSeriesFallbackLogEnabled() {
				log.Printf("[ProductSeriesFallback] missing_fallback product=%s color=%s dim3=%s fallback_code=%s", strings.TrimSpace(v.ProductCode), strings.TrimSpace(v.ColorCode), strings.TrimSpace(v.Dim3Code), strings.TrimSpace(fallbackCode))
			}
			return 0, 1, nil
		}
	}
	if !apply {
		return len(selected), 0, nil
	}

	tx, err := pg.BeginTx(ctx, nil)
	if err != nil {
		return 0, 0, err
	}
	defer tx.Rollback()

	// Tag the DB session for audit attribution (scheduler vs http handler).
	// This affects only statements in this transaction.
	_, _ = tx.ExecContext(ctx, `SELECT set_config('application_name', current_setting('application_name') || '|series-auto', true)`)

	if _, err := tx.ExecContext(ctx, `
DELETE FROM zbggseri
WHERE mmitem_id=$1
  AND dim1=$2
  AND (($3::bigint IS NULL AND dim3 IS NULL) OR dim3=$3::bigint)
`, mmitemID, dim1ID, nullableInt64ForAuto(dim3ID)); err != nil {
		return 0, 0, err
	}
	for _, rule := range selected {
		if _, err := tx.ExecContext(ctx, `
INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3)
VALUES ($1, $2, $3, $4)
`, mmitemID, dim1ID, rule.SeriesID, nullableInt64ForAuto(dim3ID)); err != nil {
			return 0, 0, err
		}
	}
	if err := tx.Commit(); err != nil {
		return 0, 0, err
	}
	// Track what we last applied for this variant to reduce flapping and help ops debugging.
	rowKey := productSeriesAutoKey(v.ProductCode, v.ColorCode, v.Dim3Code)
	kind := "rules"
	if appliedFallback {
		kind = "fallback"
	}
	ids := make([]string, 0, len(selected))
	for _, r := range selected {
		ids = append(ids, fmt.Sprintf("%d", r.SeriesID))
	}
	_, _ = pg.ExecContext(ctx, `
INSERT INTO mk_product_series_apply_state (row_key, last_apply_at, last_apply_kind, last_series_ids, updated_at)
VALUES ($1, now(), $2, $3, now())
ON CONFLICT (row_key) DO UPDATE
SET last_apply_at=EXCLUDED.last_apply_at,
    last_apply_kind=EXCLUDED.last_apply_kind,
    last_series_ids=EXCLUDED.last_series_ids,
    invalid_streak=0,
    last_invalid_at=NULL,
    updated_at=now()
`, rowKey, kind, strings.Join(ids, ","))

	if productSeriesDebugMatch(v) {
		log.Printf("[ProductSeriesDebug] wrote=%d product=%s color=%s dim3=%s series_ids=%s",
			len(selected),
			strings.TrimSpace(v.ProductCode),
			strings.TrimSpace(v.ColorCode),
			strings.TrimSpace(v.Dim3Code),
			strings.Join(ids, ","),
		)
	}
	return len(selected), 0, nil
}

func productSeriesLoadLastApplyState(ctx context.Context, pg *sql.DB, rowKey string) (kind string, at time.Time, err error) {
	var k string
	var t time.Time
	err = pg.QueryRowContext(ctx, `
SELECT COALESCE(last_apply_kind,''), COALESCE(last_apply_at, now())
FROM mk_product_series_apply_state
WHERE row_key=$1
`, rowKey).Scan(&k, &t)
	if err == sql.ErrNoRows {
		return "", time.Time{}, nil
	}
	if err != nil {
		return "", time.Time{}, err
	}
	return k, t, nil
}

func productSeriesBumpInvalidStreak(ctx context.Context, pg *sql.DB, rowKey string) (int, error) {
	var next int
	err := pg.QueryRowContext(ctx, `
INSERT INTO mk_product_series_apply_state (row_key, last_apply_at, last_apply_kind, last_series_ids, invalid_streak, last_invalid_at, updated_at)
VALUES ($1, now(), '', '', 1, now(), now())
ON CONFLICT (row_key) DO UPDATE
SET invalid_streak=mk_product_series_apply_state.invalid_streak + 1,
    last_invalid_at=now(),
    updated_at=now()
RETURNING invalid_streak
`, rowKey).Scan(&next)
	if err != nil {
		return 0, err
	}
	return next, nil
}

func productSeriesResetInvalidStreak(ctx context.Context, pg *sql.DB, rowKey string) error {
	_, err := pg.ExecContext(ctx, `
UPDATE mk_product_series_apply_state
SET invalid_streak=0,
    last_invalid_at=NULL,
    updated_at=now()
WHERE row_key=$1
`, rowKey)
	return err
}

func productSeriesFindRuleByID(rules []productSeriesAutoRule, seriesID int64) (productSeriesAutoRule, bool) {
	for _, r := range rules {
		if r.SeriesID == seriesID {
			return r, true
		}
	}
	return productSeriesAutoRule{}, false
}

func productSeriesLoadExistingSeriesIDs(ctx context.Context, pg *sql.DB, mmitemID int64, dim1ID int64, dim3ID sql.NullInt64) ([]int64, error) {
	rows, err := pg.QueryContext(ctx, `
SELECT seri_id
FROM zbggseri
WHERE mmitem_id=$1
  AND dim1=$2
  AND (($3::bigint IS NULL AND dim3 IS NULL) OR dim3=$3::bigint)
ORDER BY seri_id
`, mmitemID, dim1ID, nullableInt64ForAuto(dim3ID))
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []int64{}
	for rows.Next() {
		var id int64
		if err := rows.Scan(&id); err != nil {
			return nil, err
		}
		if id > 0 {
			out = append(out, id)
		}
	}
	return out, rows.Err()
}

func productSeriesLoadMSSQLStockVariants(ctx context.Context) (map[string]productSeriesAutoVariant, int, error) {
	rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery,
		"", "", "", "", "", "", "", "", "", "0", "",
	)
	if err != nil {
		return nil, 0, err
	}
	defer rows.Close()

	out := map[string]productSeriesAutoVariant{}
	scanned := 0
	for rows.Next() {
		scanned++
		var raw productSeriesAutoStockRaw
		if err := rows.Scan(
			&raw.ProductCode,
			&raw.ProductDescription,
			&raw.ColorCode,
			&raw.ColorTitle,
			&raw.Dim3Code,
			&raw.SizeCode,
			&raw.Qty,
			&raw.UrunAnaGrubu,
			&raw.UrunAltGrubu,
			&raw.Marka,
			&raw.DropVal,
			&raw.Fit,
			&raw.UrunIcerigi,
			&raw.Kategori,
		); err != nil {
			return nil, scanned, err
		}
		raw.ProductCode = strings.TrimSpace(raw.ProductCode)
		raw.ColorCode = strings.TrimSpace(raw.ColorCode)
		raw.Dim3Code = strings.TrimSpace(raw.Dim3Code)
		raw.SizeCode = normalizeProductSeriesSize(raw.SizeCode)
		if raw.ProductCode == "" || raw.ColorCode == "" || raw.SizeCode == "" {
			continue
		}
		key := productSeriesAutoKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code)
		v := out[key]
		if v.SizeQty == nil {
			v = productSeriesAutoVariant{
				RowKey:       key,
				ProductCode:  raw.ProductCode,
				ColorCode:    raw.ColorCode,
				Dim3Code:     raw.Dim3Code,
				SizeQty:      map[string]float64{},
				UrunAnaGrubu: strings.TrimSpace(raw.UrunAnaGrubu),
				UrunAltGrubu: strings.TrimSpace(raw.UrunAltGrubu),
				Kategori:     strings.TrimSpace(raw.Kategori),
			}
		}
		v.SizeQty[raw.SizeCode] += raw.Qty
		v.TotalQty += raw.Qty
		out[key] = v
	}
	if err := rows.Err(); err != nil {
		return nil, scanned, err
	}
	for key, v := range out {
		v.GroupKey = productSeriesDetectGroup(v)
		v.StockHash = productSeriesStockHash(v.SizeQty)
		out[key] = v
	}
	return out, scanned, nil
}

func productSeriesLoadRules(ctx context.Context, pg *sql.DB) ([]productSeriesAutoRule, error) {
	rows, err := pg.QueryContext(ctx, `
SELECT d.id, COALESCE(d.code,''), COALESCE(d.title,''),
       COALESCE(r.size_group,''), COALESCE(r.size_code,''), COALESCE(r.ratio_qty,1), COALESCE(r.priority,0)
FROM dfgrp d
LEFT JOIN mk_product_series_rule r ON r.series_id=d.id AND r.is_active=TRUE
WHERE d.master='zbggseri'
  AND COALESCE(d.is_active, TRUE)=TRUE
ORDER BY d.id, r.priority DESC, r.id
`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	byID := map[int64]*productSeriesAutoRule{}
	for rows.Next() {
		var id int64
		var code, title, group, size string
		var ratio, priority int
		if err := rows.Scan(&id, &code, &title, &group, &size, &ratio, &priority); err != nil {
			return nil, err
		}
		r := byID[id]
		if r == nil {
			r = &productSeriesAutoRule{SeriesID: id, Code: strings.TrimSpace(code), Title: strings.TrimSpace(title), GroupKey: strings.TrimSpace(group), Ratio: map[string]int{}}
			byID[id] = r
		}
		size = normalizeProductSeriesSize(size)
		if size != "" {
			if ratio <= 0 {
				ratio = 1
			}
			r.Ratio[size] = ratio
			if priority > r.Priority {
				r.Priority = priority
			}
		}
	}
	if err := rows.Err(); err != nil {
		return nil, err
	}
	out := make([]productSeriesAutoRule, 0, len(byID))
	for _, r := range byID {
		// Always augment ratio map from the dfgrp.title tokens.
		// mk_product_series_rule can be partially populated (or manually edited), which would allow
		// a "subset" rule (e.g. only M+XL) to match stock and incorrectly assign a series like
		// "XS-S-M-L-XL-2XL". Title tokens are the canonical definition of a series in this setup.
		for _, part := range strings.Split(r.Title, "-") {
			size := normalizeProductSeriesSize(part)
			if size == "" {
				continue
			}
			if _, ok := r.Ratio[size]; ok {
				continue
			}
			r.Ratio[size] = 1
		}
		if len(r.Ratio) > 0 {
			out = append(out, *r)
		}
	}
	sort.Slice(out, func(i, j int) bool {
		if out[i].Priority != out[j].Priority {
			return out[i].Priority > out[j].Priority
		}
		if len(out[i].Ratio) != len(out[j].Ratio) {
			return len(out[i].Ratio) > len(out[j].Ratio)
		}
		return out[i].Code < out[j].Code
	})
	return out, nil
}

func productSeriesSelectRules(v productSeriesAutoVariant, rules []productSeriesAutoRule) []productSeriesAutoRule {
	selectionMode := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_SELECTION_MODE")))
	if selectionMode == "" {
		selectionMode = "dfs"
	}

	candidates := make([]productSeriesAutoRule, 0, len(rules))
	for _, rule := range rules {
		if !productSeriesRuleFitsGroup(v.GroupKey, rule) {
			continue
		}
		if productSeriesCanConsume(v.SizeQty, rule) {
			candidates = append(candidates, rule)
		}
	}
	if len(candidates) == 0 {
		return nil
	}

	if selectionMode == "greedy" {
		// Greedy: start from the widest series (most sizes), then narrower, until nothing fits.
		// This matches the operational expectation: cover as much of the stock-size space as possible
		// using broad series first, then progressively narrower series; fallback=1 only if nothing matches.
		sort.Slice(candidates, func(i, j int) bool {
			li := len(candidates[i].Ratio)
			lj := len(candidates[j].Ratio)
			if li != lj {
				return li > lj
			}
			if candidates[i].Priority != candidates[j].Priority {
				return candidates[i].Priority > candidates[j].Priority
			}
			// Prefer numeric codes ascending when comparable, otherwise lexicographic.
			ai, ei := strconv.Atoi(strings.TrimSpace(candidates[i].Code))
			aj, ej := strconv.Atoi(strings.TrimSpace(candidates[j].Code))
			if ei == nil && ej == nil && ai != aj {
				return ai < aj
			}
			return candidates[i].Code < candidates[j].Code
		})

		stock := copyProductSeriesStock(v.SizeQty)
		picked := make([]productSeriesAutoRule, 0, 8)
		used := make([]bool, len(candidates))
		for {
			pickedOne := false
			for i := range candidates {
				if used[i] {
					continue
				}
				rule := candidates[i]
				if productSeriesCanConsume(stock, rule) {
					used[i] = true
					picked = append(picked, rule)
					productSeriesConsume(stock, rule, -1)
					pickedOne = true
					break // restart from widest again
				}
			}
			if !pickedOne {
				break
			}
		}
		if len(picked) == 0 {
			return nil
		}
		sort.Slice(picked, func(i, j int) bool { return picked[i].Code < picked[j].Code })
		return picked
	}

	if len(candidates) > 24 {
		candidates = candidates[:24]
	}

	type bestState struct {
		score int
		rules []productSeriesAutoRule
	}
	best := bestState{score: math.MinInt}
	stock := copyProductSeriesStock(v.SizeQty)
	var picked []productSeriesAutoRule
	var dfs func(int)
	dfs = func(idx int) {
		if idx >= len(candidates) {
			score := productSeriesSelectionScore(stock, picked)
			if score > best.score {
				best.score = score
				best.rules = append([]productSeriesAutoRule(nil), picked...)
			}
			return
		}
		dfs(idx + 1)
		rule := candidates[idx]
		if productSeriesCanConsume(stock, rule) {
			productSeriesConsume(stock, rule, -1)
			picked = append(picked, rule)
			dfs(idx + 1)
			picked = picked[:len(picked)-1]
			productSeriesConsume(stock, rule, 1)
		}
	}
	dfs(0)
	sort.Slice(best.rules, func(i, j int) bool { return best.rules[i].Code < best.rules[j].Code })
	return best.rules
}

func productSeriesRuleFitsGroup(group string, rule productSeriesAutoRule) bool {
	if rule.GroupKey != "" && rule.GroupKey != group {
		return false
	}
	// NOTE: In this installation, Nebim ItemDim1Code (size_code) can be numeric tokens that do not match the
	// hardcoded group size lists (e.g. "XS/S/M..."). Enforcing those lists causes valid rules to be rejected
	// and series assignment to stay blank across many products.
	//
	// We therefore rely only on explicit rule.GroupKey when provided, and otherwise allow the rule to compete
	// based on actual stock consumption.
	return true
}

func productSeriesCanConsume(stock map[string]float64, rule productSeriesAutoRule) bool {
	for size, ratio := range rule.Ratio {
		if stock[size] < float64(ratio) {
			return false
		}
	}
	return true
}

func productSeriesConsume(stock map[string]float64, rule productSeriesAutoRule, sign float64) {
	for size, ratio := range rule.Ratio {
		stock[size] += sign * float64(ratio)
	}
}

func productSeriesSelectionScore(remaining map[string]float64, picked []productSeriesAutoRule) int {
	usedQty := 0
	covered := map[string]struct{}{}
	priority := 0
	for _, rule := range picked {
		priority += rule.Priority
		for size, ratio := range rule.Ratio {
			usedQty += ratio
			covered[size] = struct{}{}
		}
	}
	leftQty := 0
	for _, qty := range remaining {
		leftQty += int(math.Round(qty))
	}
	return usedQty*100 + len(covered)*20 + priority - leftQty*8 - len(picked)*5
}

func productSeriesResolvePGVariant(ctx context.Context, pg *sql.DB, productCode, colorCode, dim3Code string) (int64, int64, sql.NullInt64, bool, error) {
	var mmitemID int64
	if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, strings.TrimSpace(productCode)).Scan(&mmitemID); err != nil {
		if err == sql.ErrNoRows {
			return 0, 0, sql.NullInt64{}, false, nil
		}
		return 0, 0, sql.NullInt64{}, false, err
	}
	// Authoritative dim1 resolver: for this installation, "color_code" tokens correspond to dfgrp.code on
	// the dim values referenced by mmitem_dim.val1. Do NOT rely on mk_dim_token_map for dimval1 because
	// it can be polluted and conflate tokens.
	var dim1ID int64
	{
		tok := strings.ToUpper(strings.TrimSpace(colorCode))
		if tok == "" || tok == "0" {
			return 0, 0, sql.NullInt64{}, false, nil
		}
		if err := pg.QueryRowContext(ctx, `
SELECT md.val1
FROM mmitem_dim md
JOIN dfgrp d ON d.id = md.val1
WHERE md.mmitem_id = $1
  AND md.is_active = TRUE
  AND UPPER(BTRIM(d.code)) = $2
GROUP BY md.val1
LIMIT 1
`, mmitemID, tok).Scan(&dim1ID); err != nil {
			if err == sql.ErrNoRows {
				return 0, 0, sql.NullInt64{}, false, nil
			}
			return 0, 0, sql.NullInt64{}, false, err
		}
		if dim1ID <= 0 {
			return 0, 0, sql.NullInt64{}, false, nil
		}
	}
	var dim3ID sql.NullInt64
	if strings.TrimSpace(dim3Code) != "" {
		id, ok, err := productSeriesResolveDimTokenID(ctx, pg, "dimval3", dim3Code, mmitemID)
		if err != nil {
			return 0, 0, sql.NullInt64{}, false, err
		}
		if !ok || id <= 0 {
			return 0, 0, sql.NullInt64{}, false, nil
		}
		dim3ID = sql.NullInt64{Int64: id, Valid: true}
	}

	// Only treat variants as ready if they exist in authoritative item-dim combos (mmitem_dim).
	// This prevents writing series assignments for stock variants that don't exist on the PG side.
	dim3Key := int64(0)
	if dim3ID.Valid && dim3ID.Int64 > 1000 {
		dim3Key = dim3ID.Int64
	}
	var exists int
	if err := pg.QueryRowContext(ctx, `
SELECT 1
FROM mmitem_dim
WHERE mmitem_id=$1
  AND is_active=TRUE
  AND val1=$2
  AND COALESCE(CASE WHEN val3 IS NOT NULL AND val3 > 1000 THEN val3 ELSE 0 END, 0) = $3
LIMIT 1
`, mmitemID, dim1ID, dim3Key).Scan(&exists); err != nil {
		if err == sql.ErrNoRows {
			return 0, 0, sql.NullInt64{}, false, nil
		}
		return 0, 0, sql.NullInt64{}, false, err
	}
	return mmitemID, dim1ID, dim3ID, true, nil
}

func productSeriesResolveDimTokenID(ctx context.Context, pg *sql.DB, column string, token string, mmitemID int64) (int64, bool, error) {
	tok := strings.ToUpper(strings.TrimSpace(token))
	if tok == "" || tok == "0" {
		return 0, false, nil
	}

	var id int64
	err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, tok).Scan(&id)
	if err == nil {
		// mk_dim_token_map can become polluted by heuristic inference/persistence (especially for dimval1).
		// If we can cross-check against item-scoped images, prefer the item-specific inference to avoid
		// conflating different tokens onto the same dim_id.
		if column == "dimval1" && mmitemID > 0 {
			if inferred, ok := productSeriesInferDimIDFromImages(pg, mmitemID, column, tok); ok && inferred > 0 && inferred != id {
				log.Printf("[DimTokenConflict] column=dimval1 token=%s mmitem_id=%d mapped=%d inferred=%d", tok, mmitemID, id, inferred)
				return inferred, true, nil
			}
		}
		return id, id > 0, nil
	}
	if err != sql.ErrNoRows {
		return 0, false, err
	}

	// Fallback: infer from dfblob filenames.
	// Prefer item-scoped inference when we have mmitem_id. We intentionally avoid persisting these
	// inferred rows into mk_dim_token_map to prevent global pollution.
	if mmitemID > 0 {
		if inferred, ok := productSeriesInferDimIDFromImages(pg, mmitemID, column, tok); ok && inferred > 0 {
			return inferred, true, nil
		}
	}

	// As a last resort, do a global inference. This is intentionally disabled for dimval1 because it is
	// too easy to mis-infer and conflate tokens across the whole catalog.
	if column == "dimval1" {
		return 0, false, nil
	}

	v := productSeriesResolveDimvalFromFileNameToken(pg, column, tok, 0)
	if v == "" {
		return 0, false, nil
	}
	parsed, perr := strconv.ParseInt(v, 10, 64)
	if perr != nil || parsed <= 0 {
		return 0, false, nil
	}
	return parsed, true, nil
}

func productSeriesBuildNameLikePatterns(token string) []string {
	t := strings.ToUpper(strings.TrimSpace(token))
	if t == "" {
		return nil
	}
	return []string{
		"% " + t + " %",
		"%-" + t + "-%",
		"%-" + t + "_%",
		"%_" + t + "_%",
		"%(" + t + ")%",
		t + " %",
	}
}

func productSeriesResolveDimvalFromFileNameToken(pg *sql.DB, column, token string, mmitemID int64) string {
	patterns := productSeriesBuildNameLikePatterns(token)
	if len(patterns) == 0 {
		return ""
	}
	srcFilter := ""
	args := []any{patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5]}
	if mmitemID > 0 {
		srcFilter = " AND src_id=$7"
		args = append(args, mmitemID)
	}
	query := fmt.Sprintf(`
SELECT x.dimv
FROM (
    SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt
    FROM dfblob
    WHERE src_table='mmitem'
      AND typ='img'
      AND COALESCE(%s::text, '') <> ''
      %s
      AND (
        UPPER(COALESCE(file_name,'')) LIKE $1 OR
        UPPER(COALESCE(file_name,'')) LIKE $2 OR
        UPPER(COALESCE(file_name,'')) LIKE $3 OR
        UPPER(COALESCE(file_name,'')) LIKE $4 OR
        UPPER(COALESCE(file_name,'')) LIKE $5 OR
        UPPER(COALESCE(file_name,'')) LIKE $6
      )
    GROUP BY COALESCE(%s::text, '')
) x
ORDER BY x.cnt DESC, x.dimv
LIMIT 1
`, column, column, srcFilter, column)
	var v string
	if err := pg.QueryRow(query, args...).Scan(&v); err != nil {
		return ""
	}
	v = strings.TrimSpace(v)
	if v == "" || v == "0" {
		return ""
	}
	return v
}

func productSeriesInferDimIDFromImages(pg *sql.DB, mmitemID int64, column, token string) (int64, bool) {
	v := productSeriesResolveDimvalFromFileNameToken(pg, column, token, mmitemID)
	if v == "" {
		return 0, false
	}
	id, err := strconv.ParseInt(v, 10, 64)
	if err != nil || id <= 0 {
		return 0, false
	}
	return id, true
}

func productSeriesClaimQueue(ctx context.Context, tx *sql.Tx, limit int) ([]productSeriesQueueItem, error) {
	rows, err := tx.QueryContext(ctx, `
WITH picked AS (
  SELECT id
  FROM mk_product_series_recalc_queue
  WHERE status='pending'
    AND available_at <= now()
  ORDER BY queued_at
  LIMIT $1
  FOR UPDATE SKIP LOCKED
)
UPDATE mk_product_series_recalc_queue q
SET status='processing', updated_at=now()
FROM picked
WHERE q.id=picked.id
RETURNING q.id, q.row_key, q.product_code, q.color_code, q.dim3_code, q.attempts
`, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []productSeriesQueueItem{}
	for rows.Next() {
		var it productSeriesQueueItem
		if err := rows.Scan(&it.ID, &it.RowKey, &it.ProductCode, &it.ColorCode, &it.Dim3Code, &it.Attempts); err != nil {
			return nil, err
		}
		out = append(out, it)
	}
	return out, rows.Err()
}

func productSeriesEnqueueTx(ctx context.Context, tx *sql.Tx, productCode, colorCode, dim3Code, reason string) (int, error) {
	productCode = strings.TrimSpace(productCode)
	colorCode = strings.TrimSpace(colorCode)
	dim3Code = strings.TrimSpace(dim3Code)
	if productCode == "" || colorCode == "" {
		return 0, nil
	}
	_, err := tx.ExecContext(ctx, `
INSERT INTO mk_product_series_recalc_queue (row_key, product_code, color_code, dim3_code, reason, status, attempts, available_at, queued_at, last_error, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 'pending', 0, now(), now(), '', now(), now())
ON CONFLICT (row_key) WHERE status IN ('pending','processing') DO NOTHING
`, productSeriesAutoKey(productCode, colorCode, dim3Code), productCode, colorCode, dim3Code, strings.TrimSpace(reason))
	if err != nil {
		return 0, err
	}
	return 1, nil
}

func productSeriesEnqueueUnmatchedStateTx(ctx context.Context, tx *sql.Tx, reason string) (int, error) {
	rows, err := tx.QueryContext(ctx, `
WITH candidates AS (
  SELECT
    s.row_key,
    s.product_code,
    s.color_code,
    s.dim3_code
  FROM mk_product_series_stock_state s
  LEFT JOIN mmitem m
    ON BTRIM(m.code) = BTRIM(s.product_code)
  LEFT JOIN mk_dim_token_map d1
    ON d1.dim_column='dimval1'
   AND BTRIM(d1.token) = BTRIM(s.color_code)
  LEFT JOIN mk_dim_token_map d3
    ON d3.dim_column='dimval3'
   AND BTRIM(d3.token) = BTRIM(s.dim3_code)
  LEFT JOIN zbggseri z
    ON z.mmitem_id = m.id
   AND z.dim1 = d1.dim_id
   AND (
     (NULLIF(BTRIM(s.dim3_code), '') IS NULL AND z.dim3 IS NULL)
     OR z.dim3 = d3.dim_id
   )
  WHERE s.total_qty > 0
    AND s.last_seen_at > now() - interval '30 days'
    AND (
      m.id IS NULL
      OR d1.dim_id IS NULL
      OR (NULLIF(BTRIM(s.dim3_code), '') IS NOT NULL AND d3.dim_id IS NULL)
      OR z.id IS NULL
    )
  ORDER BY s.updated_at DESC
  LIMIT 2000
),
inserted AS (
  INSERT INTO mk_product_series_recalc_queue (row_key, product_code, color_code, dim3_code, reason, status, attempts, available_at, queued_at, last_error, created_at, updated_at)
  SELECT row_key, product_code, color_code, dim3_code, $1, 'pending', 0, now(), now(), '', now(), now()
  FROM candidates
  ON CONFLICT (row_key) WHERE status IN ('pending','processing') DO NOTHING
  RETURNING id
)
SELECT COUNT(*) FROM inserted
`, "unmatched_retry_"+strings.TrimSpace(reason))
	if err != nil {
		return 0, err
	}
	defer rows.Close()
	var count int
	if rows.Next() {
		if err := rows.Scan(&count); err != nil {
			return 0, err
		}
	}
	return count, rows.Err()
}

func productSeriesMarkQueueDone(ctx context.Context, pg *sql.DB, id int64) error {
	_, err := pg.ExecContext(ctx, `
UPDATE mk_product_series_recalc_queue
SET status='done', processed_at=now(), updated_at=now(), last_error=''
WHERE id=$1
`, id)
	return err
}

func productSeriesMarkQueueFailed(ctx context.Context, pg *sql.DB, it productSeriesQueueItem, errText string) error {
	if len(errText) > 900 {
		errText = errText[:900]
	}
	delay := 5 * time.Minute
	if it.Attempts >= 1 {
		delay = 15 * time.Minute
	}
	if it.Attempts >= 2 {
		delay = 60 * time.Minute
	}
	_, err := pg.ExecContext(ctx, `
UPDATE mk_product_series_recalc_queue
SET status='failed',
    attempts=attempts+1,
    processed_at=now(),
    updated_at=now(),
    last_error=$2,
    available_at=now() + $3::interval
WHERE id=$1
`, it.ID, strings.TrimSpace(errText), fmt.Sprintf("%d seconds", int(delay.Seconds())))
	return err
}

func productSeriesDetectGroup(v productSeriesAutoVariant) string {
	text := strings.ToUpper(v.Kategori + " " + v.UrunAnaGrubu + " " + v.UrunAltGrubu)
	switch {
	case strings.Contains(text, "AYAKKABI") && strings.Contains(text, "GARSON"):
		return "ayk_garson"
	case strings.Contains(text, "AYAKKABI"):
		return "ayk"
	case strings.Contains(text, "PANTOLON"):
		return "pan"
	case strings.Contains(text, "TAKIM") || strings.Contains(text, "DAMATLIK") || strings.Contains(text, "CEKET") || strings.Contains(text, "KABAN") || strings.Contains(text, "MONT") || strings.Contains(text, "YELEK"):
		return "tak"
	case strings.Contains(text, "GOMLEK") || strings.Contains(text, "GÖMLEK"):
		return "gom"
	case strings.Contains(text, "YAS") || strings.Contains(text, "YAŞ") || strings.Contains(text, "COCUK") || strings.Contains(text, "ÇOCUK"):
		return "yas"
	case strings.Contains(text, "AKSESUAR") || strings.Contains(text, "KRAVAT") || strings.Contains(text, "KEMER") || strings.Contains(text, "PAPYON"):
		return "aks"
	default:
		return productSeriesDetectGroupBySizes(v.SizeQty)
	}
}

func productSeriesDetectGroupBySizes(sizeQty map[string]float64) string {
	bestKey := "tak"
	bestCount := -1
	for _, key := range []string{"tak", "pan", "gom", "ayk", "ayk_garson", "yas", "aks"} {
		sizes := productSeriesSizeGroups[key]
		set := map[string]struct{}{}
		for _, size := range sizes {
			set[normalizeProductSeriesSize(size)] = struct{}{}
		}
		count := 0
		miss := 0
		for size := range sizeQty {
			if _, ok := set[size]; ok {
				count++
			} else {
				miss++
			}
		}
		if miss == 0 && count > bestCount {
			bestKey = key
			bestCount = count
		}
	}
	return bestKey
}

func productSeriesStockHash(sizeQty map[string]float64) string {
	keys := make([]string, 0, len(sizeQty))
	for k := range sizeQty {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	parts := make([]string, 0, len(keys))
	for _, k := range keys {
		parts = append(parts, fmt.Sprintf("%s=%.2f", k, sizeQty[k]))
	}
	sum := sha1.Sum([]byte(strings.Join(parts, "|")))
	return hex.EncodeToString(sum[:])
}

func productSeriesAutoKey(productCode, colorCode, dim3Code string) string {
	return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(dim3Code)
}

func normalizeProductSeriesSize(v string) string {
	s := strings.ToUpper(strings.TrimSpace(v))
	// Some installations store series size tokens with a "seriesCode/size" prefix in title/rules
	// (e.g. "2/48-50-52"). Treat "2/48" as size "48".
	if i := strings.IndexByte(s, '/'); i > 0 {
		prefix := strings.TrimSpace(s[:i])
		suffix := strings.TrimSpace(s[i+1:])
		if suffix != "" {
			allDigits := true
			for _, r := range prefix {
				if r < '0' || r > '9' {
					allDigits = false
					break
				}
			}
			if allDigits {
				s = suffix
			}
		}
	}
	return s
}

func copyProductSeriesStock(in map[string]float64) map[string]float64 {
	out := make(map[string]float64, len(in))
	for k, v := range in {
		out[k] = v
	}
	return out
}

func nullableInt64ForAuto(v sql.NullInt64) any {
	if !v.Valid {
		return nil
	}
	return v.Int64
}

func nextProductSeriesDailyRun(now time.Time, hh int, mm int) time.Time {
	loc := now.Location()
	base := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc)
	if base.After(now) {
		return base
	}
	return base.AddDate(0, 0, 1)
}

func envBool(key string, fallback bool) bool {
	raw := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
	if raw == "" {
		return fallback
	}
	return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
}

func envIntRange(key string, fallback int, min int, max int) int {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	n, err := strconv.Atoi(raw)
	if err != nil || n < min || n > max {
		return fallback
	}
	return n
}

func envFloat64(key string, fallback float64) float64 {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallback
	}
	v, err := strconv.ParseFloat(raw, 64)
	if err != nil {
		return fallback
	}
	return v
}

func productSeriesHasAnySizeAtLeast(sizeQty map[string]float64, min float64) bool {
	for _, q := range sizeQty {
		if q >= min {
			return true
		}
	}
	return false
}

func envHHMM(key string, fallbackHH int, fallbackMM int) (int, int) {
	raw := strings.TrimSpace(os.Getenv(key))
	if raw == "" {
		return fallbackHH, fallbackMM
	}
	parts := strings.Split(raw, ":")
	if len(parts) != 2 {
		return fallbackHH, fallbackMM
	}
	hh, errH := strconv.Atoi(strings.TrimSpace(parts[0]))
	mm, errM := strconv.Atoi(strings.TrimSpace(parts[1]))
	if errH != nil || errM != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 {
		return fallbackHH, fallbackMM
	}
	return hh, mm
}
