package main

import (
	"context"
	"flag"
	"fmt"
	"hash/fnv"
	"log"
	"math"
	"math/rand"
	"os"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/denisovdennis/autohero/internal/game"
	"github.com/denisovdennis/autohero/internal/model"
	"github.com/denisovdennis/autohero/internal/storage"
	"github.com/denisovdennis/autohero/internal/tuning"
)

func main() {
	var (
		dsnFlag = flag.String("dsn", "", "Postgres DSN (default: DATABASE_URL env)")

		listEnemies    = flag.Bool("list-enemies", false, "print enemy rows from DB and exit")
		listArchetypes = flag.Bool("list-archetypes", false, "print distinct enemies.archetype values from DB and exit")
		filter         = flag.String("filter", "", "optional substring filter for -list-enemies (id/type/name/archetype)")
		listLimit      = flag.Int("limit", 50, "max rows for -list-enemies")

		enemyIDFlag        = flag.Int64("enemy-id", 0, "if set, balance only this enemies.id row")
		enemyTypeFlag      = flag.String("enemy-type", "", "if set, balance only this template slug (enemies.type); ignored if -enemy-id is set")
		enemyArchetypeFlag = flag.String("enemy-archetype", "", "if set without -enemy-id/-enemy-type, include all DB rows with this enemies.archetype; with -enemy-type, must match template archetype")

		configJSON = flag.String("config", "", "optional JSON file: partial enemy objects keyed by type string, merged over DB templates")

		gridMode      = flag.Bool("grid", true, "equal-level × gear grid (median + rolled); false = legacy single midpoint + median gear only")
		gearVariants  = flag.Int("gear-variants", 4, "grid mode: median + (N-1) rolled ilvl profiles per level")
		heroHpMid     = flag.Float64("hero-hp-mid", 66, "grid: low-tier center hero HP%% on wins when -tiered-targets; else flat center")
		heroHpMidHigh = flag.Float64("hero-hp-mid-high", 26, "grid: high-tier center hero HP%% when -tiered-targets")
		heroHpPP      = flag.Float64("hero-hp-pp", 6, "grid mode: ±percentage points around hero-hp-mid (center varies when tiered)")
		refinePasses  = flag.Int("refine", 2, "grid mode: duration/atk refinement passes")

		iterations    = flag.Int("iterations", 120, "runs per archetype (grid: per scenario cell)")
		seed          = flag.Int64("seed", 20260331, "base rng seed")
		targetSec     = flag.Float64("target-sec", 330, "grid: low-tier center median duration (sec) when -tiered-targets; else flat center")
		targetSecHigh = flag.Float64("target-sec-high", 660, "grid: high-tier center median duration (sec) when -tiered-targets")
		tieredTargets = flag.Bool("tiered-targets", true, "grid: interpolate target duration and hero HP%% center from low tier to high tier by archetype level band")
		tierLevelMin  = flag.Int("tier-level-min", 1, "grid tiered: global min level for normalization (catalog)")
		tierLevelMax  = flag.Int("tier-level-max", 35, "grid tiered: global max level for normalization (catalog)")
		tierGamma     = flag.Float64("tier-gamma", 1.5, "grid tiered: exponent for nonlinear interpolation (1=linear)")
		tolerancePct  = flag.Float64("tolerance-pct", 10, "deviation from target (percent); 10%% with target 330 → band [297s,363s]")
		maxHeroHpPct  = flag.Float64("max-hero-hp-pct-on-win", 60, "legacy mode: median hero HP%% after victory must be <= this (0-100)")
		minWinRate    = flag.Float64("min-win-rate", 0.28, "stop raising enemy attack if win rate falls below this (legacy); grid: median win rate floor")
		adjustEnemies = flag.Bool("adjust-enemies", true, "when false, only simulate and report metrics; do not tune or print UPDATE SQL")
		printSQL      = flag.Bool("sql", true, "print suggested UPDATE statements")

		gearCheck              = flag.Bool("gear-check", false, "compare baseline (common) vs max (legendary) reference gear per level; exits non-zero on violations")
		gearCheckMaxSpeedupPct = flag.Float64("gear-check-max-speedup-pct", 20, "max allowed kill speedup with max gear vs baseline (percent); duration max >= baseline * (1 - pct/100)")
		gearCheckMaxHeroHpPct  = flag.Float64("gear-check-max-hero-hp-pct", 75, "max median hero HP%% on wins with max gear (0-100)")
		gearCheckLevelMin      = flag.Int("gear-check-level-min", 0, "gear-check: min hero/enemy level inclusive (0 = use template min_level)")
		gearCheckLevelMax      = flag.Int("gear-check-level-max", 0, "gear-check: max hero/enemy level inclusive (0 = use template max_level)")
		gearCheckStrict        = flag.Bool("gear-check-strict", false, "gear-check: baseline must get wins (SKIP → FAIL) so vacuous passes are impossible")

		gearBase     = flag.String("gear-base", "db", "gear catalog source before overlay: db (gear/equipment_items) or code (embedded defaults only)")
		gearOverlay  = flag.String("gear-overlay", "", "JSON file: partial GearFamily patches keyed by item name or \"slot:name\"; merged over catalog for simulation only")
		gearPrintSQL = flag.Bool("gear-print-sql", false, "print SQL UPDATEs for gear/equipment_items from patched catalog; requires -gear-overlay; then exit")
	)
	flag.Parse()

	if *enemyIDFlag != 0 && strings.TrimSpace(*enemyTypeFlag) != "" {
		log.Fatal("use only one of -enemy-id or -enemy-type")
	}

	dsn := strings.TrimSpace(*dsnFlag)
	if dsn == "" {
		dsn = os.Getenv("DATABASE_URL")
	}
	if dsn == "" {
		log.Fatal("DATABASE_URL or -dsn is required")
	}
	ctx := context.Background()
	pool, err := pgxpool.New(ctx, dsn)
	if err != nil {
		log.Fatalf("open db: %v", err)
	}
	defer pool.Close()
	if err := tuning.ReloadNow(ctx, nil, storage.NewRuntimeConfigStore(pool)); err != nil {
		log.Printf("# balanceall: runtime_config not loaded (%v); using code defaults only", err)
	}
	cs := storage.NewContentStore(pool)
	enemies, err := cs.LoadEnemyTemplates(ctx)
	if err != nil {
		log.Fatalf("load enemies: %v", err)
	}
	tmplMap := game.EnemyTemplatesFromSlice(enemies)
	if p := strings.TrimSpace(*configJSON); p != "" {
		tmplMap, err = applyEnemyOverlayJSON(p, tmplMap)
		if err != nil {
			log.Fatalf("config overlay: %v", err)
		}
	}
	model.SetEnemyTemplates(game.EnemySliceFromMap(tmplMap))

	gearFamilies, err := loadGearCatalog(ctx, cs, strings.TrimSpace(*gearBase))
	if err != nil {
		log.Fatalf("load gear catalog: %v", err)
	}
	if p := strings.TrimSpace(*gearOverlay); p != "" {
		gearFamilies, err = applyGearOverlayJSON(p, gearFamilies)
		if err != nil {
			log.Fatalf("gear-overlay: %v", err)
		}
	}
	model.SetGearCatalog(gearFamilies)

	if *gearPrintSQL {
		if strings.TrimSpace(*gearOverlay) == "" {
			log.Fatal("-gear-print-sql requires -gear-overlay")
		}
		keys, err := listOverlayKeys(strings.TrimSpace(*gearOverlay))
		if err != nil {
			log.Fatalf("gear overlay keys: %v", err)
		}
		printGearOverlayMigrationSQL(gearFamilies, keys)
		return
	}

	if *listArchetypes {
		dbRows, err := cs.ListEnemyRows(ctx)
		if err != nil {
			log.Fatalf("list enemies: %v", err)
		}
		seen := make(map[string]struct{})
		var list []string
		for _, r := range dbRows {
			a := strings.TrimSpace(r.Archetype)
			if a == "" {
				continue
			}
			if _, ok := seen[a]; ok {
				continue
			}
			seen[a] = struct{}{}
			list = append(list, a)
		}
		sort.Strings(list)
		fmt.Printf("# balanceall: distinct enemies.archetype (%d)\n", len(list))
		for _, a := range list {
			fmt.Println(a)
		}
		return
	}

	if *listEnemies {
		if *listLimit <= 0 || *listLimit > 500 {
			log.Fatal("limit must be 1..500")
		}
		f := strings.TrimSpace(strings.ToLower(*filter))
		dbRows, err := cs.ListEnemyRows(ctx)
		if err != nil {
			log.Fatalf("list enemies: %v", err)
		}
		fmt.Printf("# balanceall enemies from DB (filter=%q)\n", *filter)
		fmt.Printf("%-8s %-12s %-22s %-32s %6s %6s %6s %5s\n", "id", "archetype", "type", "name", "minLv", "maxLv", "baseLv", "elite")
		printed := 0
		for _, r := range dbRows {
			idStr := fmt.Sprintf("%d", r.ID)
			if f != "" {
				if !strings.Contains(strings.ToLower(idStr), f) &&
					!strings.Contains(strings.ToLower(r.Type), f) &&
					!strings.Contains(strings.ToLower(r.Name), f) &&
					!strings.Contains(strings.ToLower(r.Archetype), f) {
					continue
				}
			}
			if printed >= *listLimit {
				break
			}
			fmt.Printf("%-8d %-12s %-22s %-32s %6d %6d %6d %5v\n",
				r.ID, trimName12(r.Archetype), r.Type, trimName32(r.Name), r.MinLevel, r.MaxLevel, r.BaseLevel, r.IsElite)
			printed++
		}
		if f != "" {
			fmt.Printf("# printed %d rows (limit=%d)\n", printed, *listLimit)
		}
		return
	}

	if *iterations < 40 {
		log.Fatal("iterations should be at least 40")
	}
	if !*gearCheck && *gearVariants < 2 {
		log.Fatal("gear-variants must be at least 2")
	}
	if *gridMode && *tieredTargets && *tierLevelMax <= *tierLevelMin {
		log.Fatal("tier-level-max must be > tier-level-min")
	}
	var hpLowGridFlat, hpHighGridFlat float64
	if *gridMode && !*tieredTargets {
		hpMid := *heroHpMid / 100.0
		pp := *heroHpPP / 100.0
		hpLowGridFlat = hpMid - pp
		hpHighGridFlat = hpMid + pp
		if hpLowGridFlat < 0 {
			hpLowGridFlat = 0
		}
		if hpHighGridFlat > 1 {
			hpHighGridFlat = 1
		}
	}
	maxHpFrac := *maxHeroHpPct / 100.0
	if !*gridMode && (maxHpFrac <= 0 || maxHpFrac > 1) {
		log.Fatal("max-hero-hp-pct-on-win must be in (0,100]")
	}

	order, err := archetypeOrder(ctx, tmplMap, pool, *enemyIDFlag, strings.TrimSpace(*enemyTypeFlag), strings.TrimSpace(*enemyArchetypeFlag))
	if err != nil {
		log.Fatal(err)
	}

	overlayNote := ""
	if strings.TrimSpace(*configJSON) != "" {
		overlayNote = fmt.Sprintf(" | overlay=%q", strings.TrimSpace(*configJSON))
	}

	if *gearCheck {
		if *gearCheckMaxHeroHpPct <= 0 || *gearCheckMaxHeroHpPct > 100 {
			log.Fatal("gear-check-max-hero-hp-pct must be in (0,100]")
		}
		if *gearCheckMaxSpeedupPct < 0 || *gearCheckMaxSpeedupPct >= 100 {
			log.Fatal("gear-check-max-speedup-pct must be in [0,100)")
		}
		if *gearCheckLevelMin > 0 && *gearCheckLevelMax > 0 && *gearCheckLevelMin > *gearCheckLevelMax {
			log.Fatal("gear-check-level-min must be <= gear-check-level-max")
		}
		totalFail := 0
		fmt.Printf("# balanceall: gear-check | iterations/cell=%d%s\n", *iterations, overlayNote)
		if len(order) > 0 {
			switch {
			case len(order) <= 10:
				fmt.Printf("#   templates (%d): %s\n", len(order), strings.Join(order, ", "))
			default:
				fmt.Printf("#   templates (%d): %s … +%d more\n", len(order), strings.Join(order[:8], ", "), len(order)-8)
			}
		}
		lvlNote := "template level band"
		if *gearCheckLevelMin > 0 || *gearCheckLevelMax > 0 {
			lvlNote = fmt.Sprintf("levels [%s..%s] ∩ template band",
				gearCheckLevelLabel(*gearCheckLevelMin), gearCheckLevelLabel(*gearCheckLevelMax))
		}
		fmt.Printf("#   levels: %s\n", lvlNote)
		fmt.Printf("#   baseline = Iron Sword + Chainmail (common); max = Soul Reaver + Crown of Eternity (legendary); ilvl=hero level (from DB catalog)\n")
		if *gearCheckStrict {
			fmt.Printf("#   strict: baseline must win (no vacuous SKIP)\n")
		}
		fmt.Printf("#   rules: dur (max) >= baseline * (1 - %.1f/100); median hero HP%% (max) <= %.1f%%\n\n",
			*gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct)
		for _, et := range order {
			tmpl := tmplMap[et]
			typeSeed := *seed + int64(hashString(string(et)))
			scenarios := gearCheckScenariosForTemplate(tmpl, *gearCheckLevelMin, *gearCheckLevelMax)
			if len(scenarios) == 0 {
				fmt.Printf("## %s (0 level cells — empty intersection with level clamp)\n", et)
				if *gearCheckStrict {
					totalFail++
					fmt.Println("   FAIL — strict mode requires at least one level cell")
				} else {
					fmt.Println("   SKIP — no levels to simulate")
				}
				fmt.Println()
				continue
			}
			fail, lines := runGearCheck(tmpl, et, scenarios, *iterations, typeSeed, *gearCheckMaxSpeedupPct, *gearCheckMaxHeroHpPct, *gearCheckStrict)
			totalFail += fail
			fmt.Printf("## %s (%d level cells)\n", et, len(scenarios))
			for _, ln := range lines {
				fmt.Println(ln)
			}
			if fail == 0 {
				fmt.Printf("   OK — all level cells pass\n")
			} else {
				fmt.Printf("   %d failing level cell(s)\n", fail)
			}
			fmt.Println()
		}
		if totalFail > 0 {
			fmt.Printf("# GEAR-CHECK: FAILED (%d violation(s))\n", totalFail)
			os.Exit(1)
		}
		fmt.Printf("# GEAR-CHECK: PASSED\n")
		return
	}

	target := time.Duration(*targetSec * float64(time.Second))
	tol := *tolerancePct / 100.0
	lowTarget := time.Duration(float64(target) * (1.0 - tol))
	highTarget := time.Duration(float64(target) * (1.0 + tol))
	legacyLowSec := *targetSec * (1.0 - tol)
	legacyHighSec := *targetSec * (1.0 + tol)

	if *gridMode {
		fmt.Printf("# balanceall: grid mode | iterations/cell=%d%s\n", *iterations, overlayNote)
		if !*adjustEnemies {
			fmt.Printf("#   adjust-enemies=false: report metrics only (no tuning, no SQL)\n")
		}
		if *tieredTargets {
			fmt.Printf("#   tiered: duration center %.0fs → %.0fs | hero HP center %.0f%% → %.0f%% (level mid in [%d,%d], gamma=%.2f); ±%.1f%% dur / ±%.1f pp HP\n",
				*targetSec, *targetSecHigh, *heroHpMid, *heroHpMidHigh, *tierLevelMin, *tierLevelMax, *tierGamma, *tolerancePct, *heroHpPP)
		} else {
			fmt.Printf("#   duration: med-of-meds in [%.1fs, %.1fs] | hero HP%% on wins: [%.1f%%, %.1f%%] (center %.1f%% ±%.1f pp)\n",
				legacyLowSec, legacyHighSec, 100*hpLowGridFlat, 100*hpHighGridFlat, *heroHpMid, *heroHpPP)
		}
		fmt.Printf("#   levels: heroLv==enemyLv for each L in [min_level..max_level]; gear: %d variants\n\n", *gearVariants)
	} else {
		fmt.Printf("# balanceall: legacy mode | iterations=%d%s | phase1: HP→duration | phase2: atk→median hero HP on win <= %.1f%%\n#   duration band=[%s,%s] min win rate %.0f%%\n\n",
			*iterations, overlayNote, *maxHeroHpPct, lowTarget.Round(time.Millisecond), highTarget.Round(time.Millisecond), 100*(*minWinRate))
		if !*adjustEnemies {
			fmt.Printf("#   adjust-enemies=false: report metrics only (no tuning, no SQL)\n\n")
		}
	}

	for _, et := range order {
		tmpl := tmplMap[et]
		typeSeed := *seed + int64(hashString(string(et)))

		if *gridMode {
			scenarios := gridScenariosForTemplate(tmpl, *gearVariants)
			base := aggregateGrid(tmpl, et, scenarios, 1.0, 1.0, *iterations, typeSeed)
			fmt.Printf("# baseline %s (grid %d cells): medOfMedDur=%.1fs medOfMedHp=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
				et, len(scenarios), base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
			if !*adjustEnemies {
				fmt.Printf("## %s (grid, no-adjust)\n", et)
				fmt.Printf("   medOfMedDur=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
					base.medOfMedDur, 100*base.medOfMedHp, 100*base.medWinRate, 100*base.minWinRate)
				fmt.Printf("   per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n",
					base.minMedDur, base.maxMedDur, 100*base.minMedHp, 100*base.maxMedHp)
				fmt.Println()
				continue
			}

			var lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid float64
			dotHeavy := enemyTemplateHasPeriodicDoT(tmpl)
			if *tieredTargets {
				norm := archetypeTierNorm(tmpl, *tierLevelMin, *tierLevelMax)
				tPow := math.Pow(norm, *tierGamma)
				targetSecEff = *targetSec + (*targetSecHigh-*targetSec)*tPow
				heroHpMidEff := *heroHpMid + (*heroHpMidHigh-*heroHpMid)*tPow
				lowSec = targetSecEff * (1.0 - tol)
				highSec = targetSecEff * (1.0 + tol)
				hpMid := heroHpMidEff / 100.0
				ppPts := *heroHpPP
				if norm > 0.35 {
					// Elites with DoT/stun: median hero HP on wins is harder to fit in a tight band.
					ppPts += (norm - 0.35) * 45
				}
				pp := ppPts / 100.0
				// Minimum remaining HP floor: 60% for early archetypes (norm<0.25), then linearly toward 20% at norm=1.
				var floorHp float64
				if norm < 0.25 {
					floorHp = 0.60
				} else {
					u := (norm - 0.25) / 0.75
					if u > 1 {
						u = 1
					}
					// Steeper than linear 60%→20% so DoT-heavy mid-tier elites can still hit the HP band.
					floorHp = math.Max(0.20, 0.60-0.65*u)
				}
				hpLowGrid = hpMid - pp
				if hpLowGrid < floorHp {
					hpLowGrid = floorHp
				}
				hpHighGrid = hpMid + pp
				if hpLowGrid < 0 {
					hpLowGrid = 0
				}
				if hpHighGrid > 1 {
					hpHighGrid = 1
				}
				if dotHeavy {
					// Slightly shorter center time (less DoT stacks) but keep band wide enough vs baseline noise.
					targetSecEff *= 0.93
					lowSec = targetSecEff * (1.0 - tol)
					highSec = targetSecEff * (1.0 + tol)
					// Extra duration slack: DoT makes med-of-med duration jumpy across cells.
					halfW := (highSec - lowSec) * 0.5 * 1.12
					mid := (lowSec + highSec) * 0.5
					lowSec = mid - halfW
					highSec = mid + halfW
					hpLowGrid = math.Max(0.10, hpLowGrid-0.14)
					hpHighGrid = math.Min(1.0, hpHighGrid+0.06)
					fmt.Printf("#   dot: burn/poison — targetSec×0.93, dur band +12%%, HP band widened\n")
				}
				fmt.Printf("#   tier: norm=%.3f curve=%.3f | targetSec=%.1fs heroHpMid=%.1f%% | bands dur [%.1f,%.1f] hp [%.1f%%,%.1f%%] (±%.1f pp)\n",
					norm, tPow, targetSecEff, heroHpMidEff, lowSec, highSec, 100*hpLowGrid, 100*hpHighGrid, ppPts)
			} else {
				targetSecEff = *targetSec
				lowSec = legacyLowSec
				highSec = legacyHighSec
				hpLowGrid = hpLowGridFlat
				hpHighGrid = hpHighGridFlat
			}

			hpScale, atkScale, gfinal, ok := balanceArchetypeGrid(
				tmpl, et, scenarios, *iterations, typeSeed,
				lowSec, highSec, targetSecEff, hpLowGrid, hpHighGrid, *minWinRate, *refinePasses,
				dotHeavy,
			)
			if !ok {
				fmt.Printf("## %s — SKIP: grid balance failed (try -legacy or adjust template)\n\n", et)
				continue
			}
			fmt.Printf("## %s (grid)\n", et)
			fmt.Printf("   hpScale=%.4f atkScale=%.4f | medOfMed(win)=%.1fs medOfMed(heroHp%%)=%.1f%% medWin=%.1f%% minWin=%.1f%%\n",
				hpScale, atkScale, gfinal.medOfMedDur, 100*gfinal.medOfMedHp, 100*gfinal.medWinRate, 100*gfinal.minWinRate)
			fmt.Printf("   per-cell median ranges: dur [%.1fs, %.1fs] heroHp%% [%.1f%%, %.1f%%]\n",
				gfinal.minMedDur, gfinal.maxMedDur, 100*gfinal.minMedHp, 100*gfinal.maxMedHp)
			if *printSQL {
				printGridSQL(tmpl, et, hpScale, atkScale)
			}
			fmt.Println()
			continue
		}

		lvl := (tmpl.MinLevel + tmpl.MaxLevel) / 2
		if lvl < 1 {
			lvl = 1
		}
		if !*adjustEnemies {
			final := runSeries(tmpl, lvl, 1.0, 1.0, *iterations, typeSeed)
			fmt.Printf("## %s (hero L%d vs enemy L%d, no-adjust)\n", et, lvl, lvl)
			fmt.Printf("   atkScale=1.0000 hpScale=1.0000 | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n",
				100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond))
			fmt.Println()
			continue
		}

		// --- Phase 1: HP scale for fight duration (atk = 1) ---
		hpScale := findHPScaleForDuration(tmpl, lvl, *iterations, typeSeed, lowTarget, highTarget)
		if hpScale < 0 {
			fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot place median duration in band at atk=1\n\n", et, lvl, lvl)
			continue
		}

		// --- Phase 2: raise enemy attack until median hero HP%% on win <= maxHpFrac ---
		atkScale, usedHpFrac := findAtkScaleForHeroHp(tmpl, lvl, hpScale, *iterations, typeSeed, maxHpFrac, *minWinRate)
		if atkScale < 0 {
			fmt.Printf("## %s (hero L%d vs enemy L%d) — SKIP: cannot reach hero HP constraint (win rate collapse)\n\n", et, lvl, lvl)
			continue
		}
		if usedHpFrac > maxHpFrac+1e-6 {
			fmt.Printf("# NOTE %s: relaxed hero HP target to %.1f%% (DoT / elite pressure)\n", et, usedHpFrac*100)
		}

		final := runSeries(tmpl, lvl, hpScale, atkScale, *iterations, typeSeed)
		fmt.Printf("## %s (hero L%d vs enemy L%d)\n", et, lvl, lvl)
		fmt.Printf("   hpScale=%.4f atkScale=%.4f | winRate=%.1f%% median(win)=%s medHeroHp%%=%.1f%% p90(win)=%s\n",
			hpScale, atkScale, 100*final.winRate, final.medianWin.Round(time.Millisecond), 100*final.medianHeroHpPctWin, final.p90Win.Round(time.Millisecond))

		if *printSQL {
			newHP := max(1, int(math.Round(float64(tmpl.MaxHP)*hpScale)))
			newHPL := tmpl.HPPerLevel * hpScale
			newAtk := max(1, int(math.Round(float64(tmpl.Attack)*atkScale)))
			newAtkL := tmpl.AttackPerLevel * atkScale
			fmt.Printf("UPDATE enemies SET hp = %d, max_hp = %d, hp_per_level = %.4f, attack = %d, attack_per_level = %.4f WHERE type = '%s';\n",
				newHP, newHP, newHPL, newAtk, newAtkL, et)
		}
		fmt.Println()
	}
}

// findHPScaleForDuration returns hpScale >= 0, or -1 if impossible (with atkScale=1).
func findHPScaleForDuration(tmpl model.Enemy, lvl, n int, typeSeed int64, lowTarget, highTarget time.Duration) float64 {
	r0 := runSeries(tmpl, lvl, 1.0, 1.0, n, typeSeed)
	if r0.winRate < 0.05 {
		return -1
	}
	if r0.medianWin > 0 && r0.medianWin >= lowTarget && r0.medianWin <= highTarget {
		return 1.0
	}
	lo, hi := 0.04, 3.5
	bestHP := 1.0
	for iter := 0; iter < 36; iter++ {
		mid := (lo + hi) / 2
		r := runSeries(tmpl, lvl, mid, 1.0, n, typeSeed)
		if r.winRate < 0.05 {
			hi = mid
			bestHP = mid
			continue
		}
		medWin := r.medianWin
		if medWin == 0 {
			hi = mid
			bestHP = mid
			continue
		}
		if medWin < lowTarget {
			lo = mid
		} else if medWin > highTarget {
			hi = mid
		} else {
			return mid
		}
		bestHP = mid
		if hi-lo < 0.015 {
			break
		}
	}
	// verify best effort
	r := runSeries(tmpl, lvl, bestHP, 1.0, n, typeSeed)
	if r.medianWin > 0 && r.medianWin >= lowTarget && r.medianWin <= highTarget {
		return bestHP
	}
	return -1
}

// findAtkScaleForHeroHp raises attack until median hero HP%% on wins <= target frac.
// Tries maxHpFrac, then 0.65, 0.70 for burn/DoT-heavy elites. Returns (atkScale, usedFrac).
func findAtkScaleForHeroHp(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) (float64, float64) {
	candidates := []float64{maxHpFrac}
	for _, extra := range []float64{0.65, 0.70, 0.75} {
		if extra > maxHpFrac+1e-6 {
			candidates = append(candidates, extra)
		}
	}
	for _, frac := range candidates {
		a := findAtkScaleForHeroHpOnce(tmpl, lvl, hpScale, n, typeSeed, frac, minWin)
		if a >= 0 {
			return a, frac
		}
	}
	return -1, maxHpFrac
}

func findAtkScaleForHeroHpOnce(tmpl model.Enemy, lvl int, hpScale float64, n int, typeSeed int64, maxHpFrac, minWin float64) float64 {
	r1 := runSeries(tmpl, lvl, hpScale, 1.0, n, typeSeed)
	if r1.winRate < minWin {
		return -1
	}
	if r1.medianHeroHpPctWin <= maxHpFrac {
		return 1.0
	}
	lo, hi := 1.0, 8.0
	best := 1.0
	for iter := 0; iter < 48; iter++ {
		mid := (lo + hi) / 2
		r := runSeries(tmpl, lvl, hpScale, mid, n, typeSeed)
		if r.winRate < minWin {
			hi = mid
			continue
		}
		if r.medianHeroHpPctWin <= maxHpFrac {
			best = mid
			hi = mid
		} else {
			lo = mid
		}
		if hi-lo < 0.003 {
			break
		}
	}
	rFinal := runSeries(tmpl, lvl, hpScale, best, n, typeSeed)
	if rFinal.winRate < minWin {
		return -1
	}
	if rFinal.medianHeroHpPctWin > maxHpFrac {
		for extra := 1; extra <= 40; extra++ {
			a := best * (1.0 + 0.025*float64(extra))
			if a > 12 {
				break
			}
			r := runSeries(tmpl, lvl, hpScale, a, n, typeSeed)
			if r.winRate < minWin {
				break
			}
			best = a
			if r.medianHeroHpPctWin <= maxHpFrac {
				return best
			}
		}
	}
	rCheck := runSeries(tmpl, lvl, hpScale, best, n, typeSeed)
	if rCheck.medianHeroHpPctWin <= maxHpFrac && rCheck.winRate >= minWin {
		return best
	}
	return -1
}

func hashString(s string) uint64 {
	h := fnv.New64a()
	_, _ = h.Write([]byte(s))
	return h.Sum64()
}

type seriesResult struct {
	winRate            float64
	medianAll          time.Duration
	medianWin          time.Duration
	p90Win             time.Duration
	medianHeroHpPctWin float64 // median of hero.HP/hero.MaxHP among winning fights (0 if no wins)
}

func runSeries(base model.Enemy, level int, hpScale, atkScale float64, n int, seed int64) seriesResult {
	t := scaleEnemyForSim(base, hpScale, atkScale)
	enemy := game.BuildEnemyInstanceForLevel(t, level)

	var wins int
	allDur := make([]time.Duration, 0, n)
	winDur := make([]time.Duration, 0, n)
	winHeroHpPct := make([]float64, 0, n)

	for i := 0; i < n; i++ {
		rand.Seed(seed + int64(i)*1_000_003)
		hero := game.CloneHeroForCombatSim(game.NewReferenceHeroForBalance(level, game.ReferenceGearMedian, nil))
		if hero.HP <= 0 {
			hero.HP = hero.MaxHP
		}
		maxH := hero.MaxHP
		if maxH <= 0 {
			maxH = 1
		}
		e := enemy
		survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &e, game.CombatSimDeterministicStart, game.CombatSimOptions{
			TickRate: 100 * time.Millisecond,
			MaxSteps: game.CombatSimMaxStepsLong,
		})
		allDur = append(allDur, elapsed)
		if survived {
			wins++
			winDur = append(winDur, elapsed)
			winHeroHpPct = append(winHeroHpPct, float64(hero.HP)/float64(maxH))
		}
	}

	sort.Slice(allDur, func(i, j int) bool { return allDur[i] < allDur[j] })
	medAll := allDur[len(allDur)/2]

	var medWin, p90Win time.Duration
	var medHeroHp float64
	if len(winDur) > 0 {
		sort.Slice(winDur, func(i, j int) bool { return winDur[i] < winDur[j] })
		medWin = winDur[len(winDur)/2]
		p90i := int(0.9 * float64(len(winDur)-1))
		if p90i < 0 {
			p90i = 0
		}
		p90Win = winDur[p90i]
	}
	if len(winHeroHpPct) > 0 {
		sort.Float64s(winHeroHpPct)
		medHeroHp = winHeroHpPct[len(winHeroHpPct)/2]
	}

	return seriesResult{
		winRate:            float64(wins) / float64(n),
		medianAll:          medAll,
		medianWin:          medWin,
		p90Win:             p90Win,
		medianHeroHpPctWin: medHeroHp,
	}
}

func scaleEnemyForSim(t model.Enemy, hpScale, atkScale float64) model.Enemy {
	out := t
	out.MaxHP = max(1, int(math.Round(float64(out.MaxHP)*hpScale)))
	out.HP = out.MaxHP
	out.HPPerLevel *= hpScale
	out.Attack = max(1, int(math.Round(float64(out.Attack)*atkScale)))
	out.AttackPerLevel *= atkScale
	return out
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

func gearCheckLevelLabel(v int) string {
	if v <= 0 {
		return "auto"
	}
	return strconv.Itoa(v)
}

func trimName12(s string) string {
	const max = 12
	s = strings.TrimSpace(s)
	runes := []rune(s)
	if len(runes) <= max {
		return s
	}
	return string(runes[:max-1]) + "…"
}

func trimName32(s string) string {
	const max = 32
	s = strings.TrimSpace(s)
	runes := []rune(s)
	if len(runes) <= max {
		return s
	}
	return string(runes[:max-1]) + "…"
}

// archetypeTierNorm maps the midpoint of [MinLevel, MaxLevel] to [0,1] using global catalog bounds (e.g. 1..35).
func archetypeTierNorm(t model.Enemy, globalMin, globalMax int) float64 {
	if globalMax <= globalMin {
		return 0
	}
	mid := float64(t.MinLevel+t.MaxLevel) / 2
	n := (mid - float64(globalMin)) / float64(globalMax-globalMin)
	if n < 0 {
		return 0
	}
	if n > 1 {
		return 1
	}
	return n
}

// archetypeOrder returns which enemy template slugs (enemies.type) to run: one row by -enemy-id,
// one by -enemy-type, all rows matching -enemy-archetype, or all rows in DB order.
func archetypeOrder(ctx context.Context, templates map[string]model.Enemy, pool *pgxpool.Pool, enemyID int64, enemyType, enemyArchetype string) ([]string, error) {
	cs := storage.NewContentStore(pool)
	dbRows, err := cs.ListEnemyRows(ctx)
	if err != nil {
		return nil, err
	}
	if enemyID > 0 {
		for _, r := range dbRows {
			if r.ID != enemyID {
				continue
			}
			if _, ok := templates[r.Type]; !ok {
				return nil, fmt.Errorf("enemy id %d: type %q missing from loaded templates", enemyID, r.Type)
			}
			if enemyArchetype != "" && r.Archetype != enemyArchetype {
				return nil, fmt.Errorf("enemy id %d has archetype %q, want %q", enemyID, r.Archetype, enemyArchetype)
			}
			return []string{r.Type}, nil
		}
		return nil, fmt.Errorf("no enemy row with id %d", enemyID)
	}
	if enemyType != "" {
		t, ok := templates[enemyType]
		if !ok {
			return nil, fmt.Errorf("enemy type not found: %s", enemyType)
		}
		if enemyArchetype != "" && t.Archetype != enemyArchetype {
			return nil, fmt.Errorf("enemy-type %q has archetype %q, not %q", enemyType, t.Archetype, enemyArchetype)
		}
		return []string{enemyType}, nil
	}
	if enemyArchetype != "" {
		out := make([]string, 0)
		for _, r := range dbRows {
			if r.Archetype != enemyArchetype {
				continue
			}
			if _, ok := templates[r.Type]; !ok {
				continue
			}
			out = append(out, r.Type)
		}
		if len(out) == 0 {
			return nil, fmt.Errorf("no enemy rows with archetype %q", enemyArchetype)
		}
		return out, nil
	}
	out := make([]string, 0, len(dbRows))
	for _, r := range dbRows {
		if _, ok := templates[r.Type]; !ok {
			continue
		}
		out = append(out, r.Type)
	}
	if len(out) == 0 {
		return nil, fmt.Errorf("no enemy rows in database")
	}
	return out, nil
}
