package routes

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

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

type productSeriesDefinition struct {
	ID           int64  `json:"id"`
	Code         string `json:"code"`
	Title        string `json:"title"`
	IsActive     bool   `json:"is_active"`
	ParentFilter string `json:"parent_filter"`
	SortOrder    int    `json:"sort_order"`
	Notes        string `json:"notes"`
}

type productSeriesStockRawRow 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 productSeriesMappingRow struct {
	RowKey             string                    `json:"row_key"`
	ProductCode        string                    `json:"product_code"`
	ProductDescription string                    `json:"product_description"`
	ColorCode          string                    `json:"color_code"`
	ColorTitle         string                    `json:"color_title"`
	Dim3Code           string                    `json:"dim3_code"`
	UrunAnaGrubu       string                    `json:"urun_ana_grubu"`
	UrunAltGrubu       string                    `json:"urun_alt_grubu"`
	Marka              string                    `json:"marka"`
	DropVal            string                    `json:"drop_val"`
	Fit                string                    `json:"fit"`
	UrunIcerigi        string                    `json:"urun_icerigi"`
	Kategori           string                    `json:"kategori"`
	SizeQty            map[string]float64        `json:"size_qty"`
	TotalQty           float64                   `json:"total_qty"`
	SeriesIDs          []int64                   `json:"series_ids"`
	Series             []productSeriesDefinition `json:"series"`
	MmitemID           int64                     `json:"mmitem_id"`
	Dim1ID             int64                     `json:"dim1_id"`
	Dim3ID             int64                     `json:"dim3_id"`
	MappingReady       bool                      `json:"mapping_ready"`
	MappingWarning     string                    `json:"mapping_warning"`
}

type productSeriesMappingsResponse struct {
	Rows        []productSeriesMappingRow `json:"rows"`
	SizeColumns []string                  `json:"size_columns"`
	Definitions []productSeriesDefinition `json:"definitions"`
}

func GetProductSeriesDefinitionsHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
		defer cancel()

		defs, err := listProductSeriesDefinitions(ctx, pg, false)
		if err != nil {
			http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		writeJSON(w, defs)
	}
}

func PostProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req productSeriesDefinition
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "Gecersiz istek", http.StatusBadRequest)
			return
		}
		req.Code = strings.TrimSpace(req.Code)
		req.Title = strings.TrimSpace(req.Title)
		if req.Code == "" {
			http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
		defer cancel()
		err := pg.QueryRowContext(ctx, `
WITH updated AS (
  UPDATE dfgrp
  SET title=$2,
      is_active=COALESCE($3, TRUE),
      parent_filter=$4,
      sort_order=$5,
      notes=$6
  WHERE master='zbggseri' AND code=$1
  RETURNING id
),
inserted AS (
  INSERT INTO dfgrp (code, title, is_active, typ, master, parent_filter, sort_order, is_required, notes)
  SELECT $1, $2, COALESCE($3, TRUE), 'opt', 'zbggseri', $4, $5, FALSE, $6
  WHERE NOT EXISTS (SELECT 1 FROM updated)
  RETURNING id
)
SELECT id FROM updated
UNION ALL
SELECT id FROM inserted
LIMIT 1
`, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes).Scan(&req.ID)
		if err != nil {
			if isPostgresUniqueViolation(err, "ndx_dfgrp_uq4") {
				if selErr := pg.QueryRowContext(ctx, `
UPDATE dfgrp
SET title=$2,
    is_active=COALESCE($3, TRUE),
    parent_filter=$4,
    sort_order=$5,
    notes=$6
WHERE master='zbggseri' AND code=$1
RETURNING id
`, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes).Scan(&req.ID); selErr == nil {
					writeJSON(w, req)
					return
				}
			}
			http.Error(w, "Seri tanimi eklenemedi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		writeJSON(w, req)
	}
}

func PutProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
		if id <= 0 {
			http.Error(w, "Gecersiz id", http.StatusBadRequest)
			return
		}
		var req productSeriesDefinition
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "Gecersiz istek", http.StatusBadRequest)
			return
		}
		req.Code = strings.TrimSpace(req.Code)
		req.Title = strings.TrimSpace(req.Title)
		if req.Code == "" {
			http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest)
			return
		}

		ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
		defer cancel()
		res, err := pg.ExecContext(ctx, `
UPDATE dfgrp
SET code=$2,
    title=$3,
    is_active=$4,
    parent_filter=$5,
    sort_order=$6,
    notes=$7
WHERE id=$1 AND master='zbggseri'
`, id, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes)
		if err != nil {
			if isPostgresUniqueViolation(err, "ndx_dfgrp_uq4") {
				http.Error(w, "Bu seri kodu zaten var. Mevcut seri satirini duzenleyin veya farkli kod kullanin.", http.StatusConflict)
				return
			}
			http.Error(w, "Seri tanimi guncellenemedi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		if n, _ := res.RowsAffected(); n == 0 {
			http.Error(w, "Seri tanimi bulunamadi", http.StatusNotFound)
			return
		}
		req.ID = id
		writeJSON(w, req)
	}
}

func DeleteProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
		if id <= 0 {
			http.Error(w, "Gecersiz id", http.StatusBadRequest)
			return
		}
		ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
		defer cancel()
		if _, err := pg.ExecContext(ctx, `UPDATE dfgrp SET is_active=FALSE WHERE id=$1 AND master='zbggseri'`, id); err != nil {
			http.Error(w, "Seri tanimi silinemedi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		writeJSON(w, map[string]any{"ok": true})
	}
}

func GetProductSeriesMappingsHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		f := readStockAttrFilters(r)
		limit := 0
		if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
			if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
				limit = n
			}
		}
		search := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("code"), r.URL.Query().Get("product_code"))

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

		msRows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery,
			f.kategori,
			f.urunAnaGrubu,
			joinFilterValues(f.urunAltGrubu),
			joinFilterValues(f.renk),
			joinFilterValues(f.renk2),
			joinFilterValues(f.urunIcerigi),
			joinFilterValues(f.fit),
			joinFilterValues(f.drop),
			joinFilterValues(f.beden),
			strconv.Itoa(limit),
			search,
		)
		if err != nil {
			http.Error(w, "Stok seri listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defer msRows.Close()

		grouped := map[string]*productSeriesMappingRow{}
		codeSet := map[string]struct{}{}
		colorSet := map[string]struct{}{}
		dim3Set := map[string]struct{}{}
		sizeSet := map[string]struct{}{}
		for msRows.Next() {
			var raw productSeriesStockRawRow
			if err := msRows.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 {
				http.Error(w, "Stok satiri okunamadi: "+err.Error(), http.StatusInternalServerError)
				return
			}
			raw.ProductCode = strings.TrimSpace(raw.ProductCode)
			raw.ColorCode = strings.TrimSpace(raw.ColorCode)
			raw.Dim3Code = strings.TrimSpace(raw.Dim3Code)
			raw.SizeCode = strings.TrimSpace(raw.SizeCode)
			key := productSeriesKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code)
			row := grouped[key]
			if row == nil {
				row = &productSeriesMappingRow{
					RowKey:             key,
					ProductCode:        raw.ProductCode,
					ProductDescription: strings.TrimSpace(raw.ProductDescription),
					ColorCode:          raw.ColorCode,
					ColorTitle:         strings.TrimSpace(raw.ColorTitle),
					Dim3Code:           raw.Dim3Code,
					UrunAnaGrubu:       strings.TrimSpace(raw.UrunAnaGrubu),
					UrunAltGrubu:       strings.TrimSpace(raw.UrunAltGrubu),
					Marka:              strings.TrimSpace(raw.Marka),
					DropVal:            strings.TrimSpace(raw.DropVal),
					Fit:                strings.TrimSpace(raw.Fit),
					UrunIcerigi:        strings.TrimSpace(raw.UrunIcerigi),
					Kategori:           strings.TrimSpace(raw.Kategori),
					SizeQty:            map[string]float64{},
					SeriesIDs:          []int64{},
					Series:             []productSeriesDefinition{},
				}
				grouped[key] = row
				codeSet[raw.ProductCode] = struct{}{}
				colorSet[raw.ColorCode] = struct{}{}
				if raw.Dim3Code != "" {
					dim3Set[raw.Dim3Code] = struct{}{}
				}
			}
			if raw.SizeCode != "" {
				row.SizeQty[raw.SizeCode] = raw.Qty
				sizeSet[raw.SizeCode] = struct{}{}
			}
			row.TotalQty += raw.Qty
		}
		if err := msRows.Err(); err != nil {
			http.Error(w, "Stok satirlari okunamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}

		defs, err := listProductSeriesDefinitions(ctx, pg, true)
		if err != nil {
			http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defByID := make(map[int64]productSeriesDefinition, len(defs))
		for _, d := range defs {
			defByID[d.ID] = d
		}

		// Strict token->dim_id resolver (no inference/persistence). UI should not invent dim mappings.
		loadDimTokenIDsStrict := func(ctx context.Context, pg *sql.DB, column string, tokens []string) (map[string]int64, error) {
			out := map[string]int64{}
			if len(tokens) == 0 {
				return out, nil
			}
			rows, err := pg.QueryContext(ctx, `
SELECT token, dim_id
FROM mk_dim_token_map
WHERE dim_column=$1 AND token = ANY($2)
`, column, pq.Array(tokens))
			if err != nil {
				return out, err
			}
			defer rows.Close()
			for rows.Next() {
				var tok string
				var id int64
				if err := rows.Scan(&tok, &id); err != nil {
					return out, err
				}
				out[strings.TrimSpace(tok)] = id
			}
			return out, rows.Err()
		}

		// Authoritative dims/combos are resolved via mmitem_dim + dfgrp.code (see helpers below).

		codes := setToSortedSlice(codeSet)
		mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes)
		dim3ByToken, _ := loadDimTokenIDsStrict(ctx, pg, "dimval3", setToSortedSlice(dim3Set))
		mmitemIDs := make([]int64, 0, len(mmitemByCode))
		for _, c := range codes {
			if id := mmitemByCode[c]; id > 0 {
				mmitemIDs = append(mmitemIDs, id)
			}
		}
		// Normalize color tokens to UPPER/TRIM to match dfgrp.code.
		colorTokens := setToSortedSlice(colorSet)
		for i := range colorTokens {
			colorTokens[i] = strings.ToUpper(strings.TrimSpace(colorTokens[i]))
		}
		dim1ByMmitemToken, _ := loadDim1ByMmitemAndToken(ctx, pg, mmitemIDs, colorTokens)
		combos, _ := loadProductDimCombosFromMmitemDim(ctx, pg, mmitemIDs)
		existing, _ := loadProductSeriesAssignments(ctx, pg, codes)

		// Per-request cache for per-mmitem dimval3 inference to avoid repeated dfblob scans.
		inferCache := map[string]int64{}
		inferDim3ForMmitem := func(mmitemID int64, token string) int64 {
			mmitemID = int64(mmitemID)
			tok := strings.ToUpper(normalizeDimParam(token))
			if mmitemID <= 0 || tok == "" {
				return 0
			}
			key := fmt.Sprintf("%d|%s", mmitemID, tok)
			if v, ok := inferCache[key]; ok {
				return v
			}
			patterns := buildNameLikePatterns(tok)
			if len(patterns) == 0 {
				inferCache[key] = 0
				return 0
			}
			var dimv string
			err := pg.QueryRowContext(ctx, `
SELECT x.dimv
FROM (
    SELECT COALESCE(dimval3::text, '') AS dimv, COUNT(*) AS cnt
    FROM dfblob
    WHERE src_table='mmitem'
      AND typ='img'
      AND src_id=$7
      AND COALESCE(dimval3::text, '') <> ''
      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(dimval3::text, '')
) x
ORDER BY x.cnt DESC, x.dimv
LIMIT 1
`, patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5], mmitemID).Scan(&dimv)
			if err != nil {
				inferCache[key] = 0
				return 0
			}
			dimv = strings.TrimSpace(dimv)
			if dimv == "" || dimv == "0" {
				inferCache[key] = 0
				return 0
			}
			id, err := strconv.ParseInt(dimv, 10, 64)
			if err != nil || id <= 0 {
				inferCache[key] = 0
				return 0
			}
			inferCache[key] = id
			return id
		}

		out := make([]productSeriesMappingRow, 0, len(grouped))
		for _, row := range grouped {
			row.MmitemID = mmitemByCode[row.ProductCode]
			row.Dim1ID = dim1ByMmitemToken[fmt.Sprintf("%d|%s", row.MmitemID, strings.ToUpper(strings.TrimSpace(row.ColorCode)))]
			if row.Dim3Code != "" {
				// Prefer token map; fallback to per-item inference (do not persist).
				if v := dim3ByToken[row.Dim3Code]; v > 0 {
					row.Dim3ID = v
				} else if inferred := inferDim3ForMmitem(row.MmitemID, row.Dim3Code); inferred > 0 {
					row.Dim3ID = inferred
				}
			}
			// Only show variants that are also known/valid on the PG side (product_code + dim1 + dim3_key).
			// This prevents UI from listing stock variants that don't exist in PG's authoritative item-dim combos.
			row.MappingReady = row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0)
			dim3Key := int64(0)
			if row.Dim3Code != "" {
				dim3Key = row.Dim3ID
			}
			if row.MappingReady {
				_, ok := combos[fmt.Sprintf("%d|%d|%d", row.MmitemID, row.Dim1ID, dim3Key)]
				row.MappingReady = ok
			}
			if !row.MappingReady {
				// Skip: do not surface "orphan" variants in the UI.
				continue
			}
			assignKey := assignmentKey(row.ProductCode, row.Dim1ID, row.Dim3ID)
			appendSeries := func(key string) {
				for _, id := range existing[key] {
					if d, ok := defByID[id]; ok {
						row.SeriesIDs = append(row.SeriesIDs, id)
						row.Series = append(row.Series, d)
					}
				}
			}
			appendSeries(assignKey)
			sort.Slice(row.Series, func(i, j int) bool { return row.Series[i].Code < row.Series[j].Code })
			sort.Slice(row.SeriesIDs, func(i, j int) bool { return row.SeriesIDs[i] < row.SeriesIDs[j] })
			out = append(out, *row)
		}
		productTotals := productSeriesTotalQtyByCode(out)
		sort.Slice(out, func(i, j int) bool {
			totalI := productTotals[out[i].ProductCode]
			totalJ := productTotals[out[j].ProductCode]
			if totalI != totalJ {
				return totalI > totalJ
			}
			if out[i].ProductCode != out[j].ProductCode {
				return out[i].ProductCode < out[j].ProductCode
			}
			if out[i].ColorCode != out[j].ColorCode {
				return out[i].ColorCode < out[j].ColorCode
			}
			return out[i].Dim3Code < out[j].Dim3Code
		})

		writeJSON(w, productSeriesMappingsResponse{
			Rows:        out,
			SizeColumns: sortSizeColumns(setToSortedSlice(sizeSet)),
			Definitions: defs,
		})
	}
}

func GetProductSeriesOrphanMappingsHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		f := readStockAttrFilters(r)
		limit := 0
		if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
			if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
				limit = n
			}
		}
		search := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("code"), r.URL.Query().Get("product_code"))

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

		msRows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery,
			f.kategori,
			f.urunAnaGrubu,
			joinFilterValues(f.urunAltGrubu),
			joinFilterValues(f.renk),
			joinFilterValues(f.renk2),
			joinFilterValues(f.urunIcerigi),
			joinFilterValues(f.fit),
			joinFilterValues(f.drop),
			joinFilterValues(f.beden),
			strconv.Itoa(limit),
			search,
		)
		if err != nil {
			http.Error(w, "Stok seri listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		defer msRows.Close()

		grouped := map[string]*productSeriesMappingRow{}
		codeSet := map[string]struct{}{}
		colorSet := map[string]struct{}{}
		dim3Set := map[string]struct{}{}
		sizeSet := map[string]struct{}{}
		for msRows.Next() {
			var raw productSeriesStockRawRow
			if err := msRows.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 {
				http.Error(w, "Stok satiri okunamadi: "+err.Error(), http.StatusInternalServerError)
				return
			}
			raw.ProductCode = strings.TrimSpace(raw.ProductCode)
			raw.ColorCode = strings.TrimSpace(raw.ColorCode)
			raw.Dim3Code = strings.TrimSpace(raw.Dim3Code)
			raw.SizeCode = strings.TrimSpace(raw.SizeCode)
			key := productSeriesKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code)
			row := grouped[key]
			if row == nil {
				row = &productSeriesMappingRow{
					RowKey:             key,
					ProductCode:        raw.ProductCode,
					ProductDescription: strings.TrimSpace(raw.ProductDescription),
					ColorCode:          raw.ColorCode,
					ColorTitle:         strings.TrimSpace(raw.ColorTitle),
					Dim3Code:           raw.Dim3Code,
					UrunAnaGrubu:       strings.TrimSpace(raw.UrunAnaGrubu),
					UrunAltGrubu:       strings.TrimSpace(raw.UrunAltGrubu),
					Marka:              strings.TrimSpace(raw.Marka),
					DropVal:            strings.TrimSpace(raw.DropVal),
					Fit:                strings.TrimSpace(raw.Fit),
					UrunIcerigi:        strings.TrimSpace(raw.UrunIcerigi),
					Kategori:           strings.TrimSpace(raw.Kategori),
					SizeQty:            map[string]float64{},
					SeriesIDs:          []int64{},
					Series:             []productSeriesDefinition{},
				}
				grouped[key] = row
				codeSet[raw.ProductCode] = struct{}{}
				colorSet[raw.ColorCode] = struct{}{}
				if raw.Dim3Code != "" {
					dim3Set[raw.Dim3Code] = struct{}{}
				}
			}
			if raw.SizeCode != "" {
				row.SizeQty[raw.SizeCode] = raw.Qty
				sizeSet[raw.SizeCode] = struct{}{}
			}
			row.TotalQty += raw.Qty
		}
		if err := msRows.Err(); err != nil {
			http.Error(w, "Stok satirlari okunamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}

		defs, err := listProductSeriesDefinitions(ctx, pg, true)
		if err != nil {
			http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
			return
		}

		// Strict token map lookups (no inference).
		loadDimTokenIDsStrict := func(ctx context.Context, pg *sql.DB, column string, tokens []string) (map[string]int64, error) {
			out := map[string]int64{}
			if len(tokens) == 0 {
				return out, nil
			}
			rows, err := pg.QueryContext(ctx, `
SELECT token, dim_id
FROM mk_dim_token_map
WHERE dim_column=$1 AND token = ANY($2)
`, column, pq.Array(tokens))
			if err != nil {
				return out, err
			}
			defer rows.Close()
			for rows.Next() {
				var tok string
				var id int64
				if err := rows.Scan(&tok, &id); err != nil {
					return out, err
				}
				out[strings.TrimSpace(tok)] = id
			}
			return out, rows.Err()
		}

		codes := setToSortedSlice(codeSet)
		mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes)
		dim3ByToken, _ := loadDimTokenIDsStrict(ctx, pg, "dimval3", setToSortedSlice(dim3Set))
		mmitemIDs := make([]int64, 0, len(mmitemByCode))
		for _, c := range codes {
			if id := mmitemByCode[c]; id > 0 {
				mmitemIDs = append(mmitemIDs, id)
			}
		}
		colorTokens := setToSortedSlice(colorSet)
		for i := range colorTokens {
			colorTokens[i] = strings.ToUpper(strings.TrimSpace(colorTokens[i]))
		}
		dim1ByMmitemToken, _ := loadDim1ByMmitemAndToken(ctx, pg, mmitemIDs, colorTokens)
		combos, _ := loadProductDimCombosFromMmitemDim(ctx, pg, mmitemIDs)

		// Per-request cache for per-mmitem dimval3 inference (dfblob scan).
		inferCache := map[string]int64{}
		inferDim3ForMmitem := func(mmitemID int64, token string) int64 {
			mmitemID = int64(mmitemID)
			tok := strings.ToUpper(normalizeDimParam(token))
			if mmitemID <= 0 || tok == "" {
				return 0
			}
			key := fmt.Sprintf("%d|%s", mmitemID, tok)
			if v, ok := inferCache[key]; ok {
				return v
			}
			patterns := buildNameLikePatterns(tok)
			if len(patterns) == 0 {
				inferCache[key] = 0
				return 0
			}
			var dimv string
			err := pg.QueryRowContext(ctx, `
SELECT x.dimv
FROM (
    SELECT COALESCE(dimval3::text, '') AS dimv, COUNT(*) AS cnt
    FROM dfblob
    WHERE src_table='mmitem'
      AND typ='img'
      AND src_id=$7
      AND COALESCE(dimval3::text, '') <> ''
      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(dimval3::text, '')
) x
ORDER BY x.cnt DESC, x.dimv
LIMIT 1
`, patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5], mmitemID).Scan(&dimv)
			if err != nil {
				inferCache[key] = 0
				return 0
			}
			dimv = strings.TrimSpace(dimv)
			if dimv == "" || dimv == "0" {
				inferCache[key] = 0
				return 0
			}
			id, err := strconv.ParseInt(dimv, 10, 64)
			if err != nil || id <= 0 {
				inferCache[key] = 0
				return 0
			}
			inferCache[key] = id
			return id
		}

		out := make([]productSeriesMappingRow, 0, len(grouped))
		for _, row := range grouped {
			if row.TotalQty <= 0 {
				continue
			}
			row.MmitemID = mmitemByCode[row.ProductCode]
			row.Dim1ID = dim1ByMmitemToken[fmt.Sprintf("%d|%s", row.MmitemID, strings.ToUpper(strings.TrimSpace(row.ColorCode)))]
			if row.Dim3Code != "" {
				if v := dim3ByToken[row.Dim3Code]; v > 0 {
					row.Dim3ID = v
				} else if inferred := inferDim3ForMmitem(row.MmitemID, row.Dim3Code); inferred > 0 {
					row.Dim3ID = inferred
				}
			}

			// Determine orphan reason.
			baseReady := row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0)
			dim3Key := int64(0)
			if row.Dim3Code != "" {
				dim3Key = row.Dim3ID
			}
			_, comboOK := combos[fmt.Sprintf("%d|%d|%d", row.MmitemID, row.Dim1ID, dim3Key)]

			row.MappingReady = false
			switch {
			case row.MmitemID <= 0:
				row.MappingWarning = "B2B'de urun yok"
			case row.Dim1ID <= 0:
				row.MappingWarning = "B2B'de bu urun icin renk yok"
			case row.Dim3Code != "" && row.Dim3ID <= 0:
				row.MappingWarning = "B2B'de ikinci renk eslesmesi yok"
			case !comboOK:
				row.MappingWarning = "B2B'de bu urun/renk/ikinci renk kombinasyonu yok"
			default:
				// Not an orphan; skip.
				if baseReady && comboOK {
					continue
				}
				row.MappingWarning = "B2B varyant eslesmesi bulunamadi"
			}

			out = append(out, *row)
		}

		productTotals := productSeriesTotalQtyByCode(out)
		sort.Slice(out, func(i, j int) bool {
			totalI := productTotals[out[i].ProductCode]
			totalJ := productTotals[out[j].ProductCode]
			if totalI != totalJ {
				return totalI > totalJ
			}
			if out[i].ProductCode != out[j].ProductCode {
				return out[i].ProductCode < out[j].ProductCode
			}
			if out[i].ColorCode != out[j].ColorCode {
				return out[i].ColorCode < out[j].ColorCode
			}
			return out[i].Dim3Code < out[j].Dim3Code
		})

		writeJSON(w, productSeriesMappingsResponse{
			Rows:        out,
			SizeColumns: sortSizeColumns(setToSortedSlice(sizeSet)),
			Definitions: defs,
		})
	}
}

func productSeriesTotalQtyByCode(rows []productSeriesMappingRow) map[string]float64 {
	out := make(map[string]float64, len(rows))
	for _, row := range rows {
		out[row.ProductCode] += row.TotalQty
	}
	return out
}

func loadDim1ByMmitemAndToken(ctx context.Context, pg *sql.DB, mmitemIDs []int64, colorTokens []string) (map[string]int64, error) {
	out := map[string]int64{}
	if len(mmitemIDs) == 0 || len(colorTokens) == 0 {
		return out, nil
	}
	rows, err := pg.QueryContext(ctx, `
SELECT md.mmitem_id, UPPER(BTRIM(d.code)) AS code, md.val1
FROM mmitem_dim md
JOIN dfgrp d ON d.id = md.val1
WHERE md.mmitem_id = ANY($1)
  AND md.is_active = TRUE
  AND UPPER(BTRIM(d.code)) = ANY($2)
GROUP BY md.mmitem_id, UPPER(BTRIM(d.code)), md.val1
`, pq.Array(mmitemIDs), pq.Array(colorTokens))
	if err != nil {
		return out, err
	}
	defer rows.Close()
	for rows.Next() {
		var mmid, dim1 int64
		var code string
		if err := rows.Scan(&mmid, &code, &dim1); err != nil {
			return out, err
		}
		key := fmt.Sprintf("%d|%s", mmid, strings.TrimSpace(code))
		if _, ok := out[key]; !ok && dim1 > 0 {
			out[key] = dim1
		}
	}
	return out, rows.Err()
}

func loadProductDimCombosFromMmitemDim(ctx context.Context, pg *sql.DB, mmitemIDs []int64) (map[string]struct{}, error) {
	out := map[string]struct{}{}
	if len(mmitemIDs) == 0 {
		return out, nil
	}
	rows, err := pg.QueryContext(ctx, `
SELECT
  mmitem_id,
  val1 AS dim1,
  CASE WHEN val3 IS NOT NULL AND val3 > 1000 THEN val3 ELSE 0 END AS dim3_key
FROM mmitem_dim
WHERE mmitem_id = ANY($1)
  AND is_active = TRUE
  AND val1 IS NOT NULL
GROUP BY mmitem_id, val1, CASE WHEN val3 IS NOT NULL AND val3 > 1000 THEN val3 ELSE 0 END
`, pq.Array(mmitemIDs))
	if err != nil {
		return out, err
	}
	defer rows.Close()
	for rows.Next() {
		var mmitemID, dim1, dim3Key int64
		if err := rows.Scan(&mmitemID, &dim1, &dim3Key); err != nil {
			return out, err
		}
		out[fmt.Sprintf("%d|%d|%d", mmitemID, dim1, dim3Key)] = struct{}{}
	}
	return out, rows.Err()
}

type saveProductSeriesMappingsRequest struct {
	Items []struct {
		ProductCode string  `json:"product_code"`
		ColorCode   string  `json:"color_code"`
		Dim3Code    string  `json:"dim3_code"`
		SeriesIDs   []int64 `json:"series_ids"`
	} `json:"items"`
}

func PostProductSeriesMappingsSaveHandler(pg *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req saveProductSeriesMappingsRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, "Gecersiz istek", 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, "Transaction baslatilamadi", http.StatusInternalServerError)
			return
		}
		defer tx.Rollback()

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

		// Safety defaults:
		// - Don't allow accidental clearing when UI sends empty series_ids.
		// - Don't allow saving fallback code=1 from UI unless explicitly enabled.
		allowClear := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("clear"))) == "true"
		allowFallback := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("allow_fallback"))) == "true"

		log.Printf("[ProductSeriesMappingsSave] items=%d allow_clear=%t allow_fallback=%t remote=%s ua=%s",
			len(req.Items), allowClear, allowFallback, strings.TrimSpace(r.RemoteAddr), strings.TrimSpace(r.UserAgent()))

		var fallbackSeriesID int64 = 0
		_ = tx.QueryRowContext(ctx, `SELECT id FROM dfgrp WHERE master='zbggseri' AND code='1'`).Scan(&fallbackSeriesID)

		saved := 0
		skipped := 0
		for _, item := range req.Items {
			code := strings.TrimSpace(item.ProductCode)
			color := strings.TrimSpace(item.ColorCode)
			dim3Token := strings.TrimSpace(item.Dim3Code)
			if code == "" || color == "" {
				skipped++
				continue
			}
			mmitemID, err := resolveMmitemIDTx(ctx, tx, code)
			if err != nil || mmitemID <= 0 {
				http.Error(w, "B2B urun bulunamadi: "+code, http.StatusBadRequest)
				return
			}
			// Authoritative dim1 resolver: only allow saving against a color that exists for this product in mmitem_dim.
			var dim1ID int64
			{
				tok := strings.ToUpper(strings.TrimSpace(color))
				if tok == "" || tok == "0" {
					http.Error(w, "Renk token eslesmesi bulunamadi: "+color, http.StatusBadRequest)
					return
				}
				if err := tx.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 || dim1ID <= 0 {
					http.Error(w, "Renk bu urunde yok: "+color, http.StatusBadRequest)
					return
				}
			}
			var dim3ID sql.NullInt64
			if dim3Token != "" {
				id, err := resolveDimTokenIDTx(ctx, tx, "dimval3", dim3Token)
				if err != nil || id <= 0 {
					http.Error(w, "Dim3 token eslesmesi bulunamadi: "+dim3Token, http.StatusBadRequest)
					return
				}
				dim3ID = sql.NullInt64{Int64: id, Valid: true}
			}

			// Normalize/validate series ids.
			seen := map[int64]struct{}{}
			seriesIDs := make([]int64, 0, len(item.SeriesIDs))
			for _, seriesID := range item.SeriesIDs {
				if seriesID <= 0 {
					continue
				}
				if fallbackSeriesID > 0 && seriesID == fallbackSeriesID && !allowFallback {
					continue
				}
				if _, ok := seen[seriesID]; ok {
					continue
				}
				seen[seriesID] = struct{}{}
				seriesIDs = append(seriesIDs, seriesID)
			}

			// Prevent accidental destructive writes from UI.
			if len(seriesIDs) == 0 && !allowClear {
				log.Printf("[ProductSeriesMappingsSave] skip_empty_series_ids product=%s color=%s dim3=%s", code, color, dim3Token)
				skipped++
				continue
			}

			{
				out := make([]string, 0, len(seriesIDs))
				for _, id := range seriesIDs {
					out = append(out, fmt.Sprintf("%d", id))
				}
				log.Printf("[ProductSeriesMappingsSave] write product=%s color=%s dim3=%s series_ids=%s",
					code, color, dim3Token, strings.Join(out, ","))
			}

			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, nullableInt64Arg(dim3ID)); err != nil {
				http.Error(w, "Seri eslesmesi temizlenemedi: "+err.Error(), http.StatusInternalServerError)
				return
			}
			for _, seriesID := range seriesIDs {
				if _, err := tx.ExecContext(ctx, `
INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3)
VALUES ($1, $2, $3, $4)
`, mmitemID, dim1ID, seriesID, nullableInt64Arg(dim3ID)); err != nil {
					http.Error(w, "Seri eslesmesi kaydedilemedi: "+err.Error(), http.StatusInternalServerError)
					return
				}
			}
			saved++
		}

		if err := tx.Commit(); err != nil {
			http.Error(w, "Seri eslesmeleri kaydedilemedi: "+err.Error(), http.StatusInternalServerError)
			return
		}
		writeJSON(w, map[string]any{"saved": saved, "skipped": skipped})
	}
}

func listProductSeriesDefinitions(ctx context.Context, pg *sql.DB, activeOnly bool) ([]productSeriesDefinition, error) {
	whereActive := ""
	if activeOnly {
		whereActive = " AND is_active=TRUE"
	}
	rows, err := pg.QueryContext(ctx, `
SELECT id, COALESCE(code,''), COALESCE(title,''), COALESCE(is_active, TRUE),
       COALESCE(parent_filter,''), COALESCE(sort_order, 0), COALESCE(notes,'')
FROM dfgrp
WHERE master='zbggseri'`+whereActive+`
ORDER BY sort_order,
         CASE WHEN code ~ '^[0-9]+$' THEN code::int ELSE NULL END NULLS LAST,
         code,
         id
`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	out := []productSeriesDefinition{}
	for rows.Next() {
		var item productSeriesDefinition
		if err := rows.Scan(&item.ID, &item.Code, &item.Title, &item.IsActive, &item.ParentFilter, &item.SortOrder, &item.Notes); err != nil {
			return nil, err
		}
		out = append(out, item)
	}
	return out, rows.Err()
}

func loadMmitemIDs(ctx context.Context, pg *sql.DB, codes []string) (map[string]int64, error) {
	out := map[string]int64{}
	if len(codes) == 0 {
		return out, nil
	}
	rows, err := pg.QueryContext(ctx, `SELECT code, id FROM mmitem WHERE code = ANY($1)`, pq.Array(codes))
	if err != nil {
		return out, err
	}
	defer rows.Close()
	for rows.Next() {
		var code string
		var id int64
		if err := rows.Scan(&code, &id); err != nil {
			return out, err
		}
		out[strings.TrimSpace(code)] = id
	}
	return out, rows.Err()
}

func loadDimTokenIDs(ctx context.Context, pg *sql.DB, column string, tokens []string) (map[string]int64, error) {
	out := map[string]int64{}
	if len(tokens) == 0 {
		return out, nil
	}
	rows, err := pg.QueryContext(ctx, `
SELECT token, dim_id
FROM mk_dim_token_map
WHERE dim_column=$1 AND token = ANY($2)
`, column, pq.Array(tokens))
	if err != nil {
		return out, err
	}
	defer rows.Close()
	for rows.Next() {
		var token string
		var id int64
		if err := rows.Scan(&token, &id); err != nil {
			return out, err
		}
		out[strings.TrimSpace(token)] = id
	}
	if err := rows.Err(); err != nil {
		return out, err
	}

	// Best-effort fallback: infer missing token->dim_id from dfblob file_name patterns.
	// NOTE: For dimval3, the same token can map to different dim ids per product in this
	// installation, so we do NOT infer/persist globally here. Per-product inference is
	// handled in the row loop (using mmitem_id) to avoid wrong matches.
	for _, rawTok := range tokens {
		tok := strings.ToUpper(normalizeDimParam(rawTok))
		if tok == "" {
			continue
		}
		if _, ok := out[tok]; ok {
			continue
		}
		if column == "dimval3" {
			// avoid global inference for dimval3
			continue
		}
		v := resolveDimvalFromFileNameToken(pg, column, tok)
		if v == "" {
			continue
		}
		id, err := strconv.ParseInt(v, 10, 64)
		if err != nil || id <= 0 {
			continue
		}
		// Persist for future requests (best-effort).
		_, _ = 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, tok, id)
		out[tok] = id
	}

	return out, nil
}

func loadProductSeriesAssignments(ctx context.Context, pg *sql.DB, codes []string) (map[string][]int64, error) {
	out := map[string][]int64{}
	if len(codes) == 0 {
		return out, nil
	}
	rows, err := pg.QueryContext(ctx, `
SELECT m.code, z.dim1, COALESCE(z.dim3, 0), z.seri_id
FROM zbggseri z
JOIN mmitem m ON m.id = z.mmitem_id
WHERE m.code = ANY($1)
ORDER BY m.code, z.dim1, z.dim3, z.seri_id
`, pq.Array(codes))
	if err != nil {
		return out, err
	}
	defer rows.Close()
	for rows.Next() {
		var code string
		var dim1, dim3, seriesID int64
		if err := rows.Scan(&code, &dim1, &dim3, &seriesID); err != nil {
			return out, err
		}
		key := assignmentKey(strings.TrimSpace(code), dim1, dim3)
		out[key] = append(out[key], seriesID)
	}
	return out, rows.Err()
}

func resolveMmitemIDTx(ctx context.Context, tx *sql.Tx, code string) (int64, error) {
	var id int64
	err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, code).Scan(&id)
	return id, err
}

func resolveDimTokenIDTx(ctx context.Context, tx *sql.Tx, column string, token string) (int64, error) {
	var id int64
	err := tx.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, token).Scan(&id)
	return id, err
}

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

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

func assignmentKey(productCode string, dim1ID int64, dim3ID int64) string {
	return fmt.Sprintf("%s|%d|%d", strings.TrimSpace(productCode), dim1ID, dim3ID)
}

func setToSortedSlice(set map[string]struct{}) []string {
	out := make([]string, 0, len(set))
	for v := range set {
		v = strings.TrimSpace(v)
		if v != "" {
			out = append(out, v)
		}
	}
	sort.Strings(out)
	return out
}

func sortSizeColumns(values []string) []string {
	sort.Slice(values, func(i, j int) bool {
		ai, ae := strconv.Atoi(values[i])
		bi, be := strconv.Atoi(values[j])
		if ae == nil && be == nil {
			return ai < bi
		}
		return values[i] < values[j]
	})
	return values
}

func writeJSON(w http.ResponseWriter, payload any) {
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	_ = json.NewEncoder(w).Encode(payload)
}

func isPostgresUniqueViolation(err error, constraint string) bool {
	if err == nil {
		return false
	}
	pqErr, ok := err.(*pq.Error)
	if !ok {
		return false
	}
	return string(pqErr.Code) == "23505" && (constraint == "" || pqErr.Constraint == constraint)
}
