package game

import (
	"context"
	"fmt"
	"log/slog"
	"math"
	"math/rand"
	"time"

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

// OfflineDigestGrace is the delay after the last WS disconnect before offline events count toward the digest.
const OfflineDigestGrace = 30 * time.Second

// OfflineDigestCollecting is true when digest deltas should be applied (disconnect + grace elapsed).
func OfflineDigestCollecting(disconnect *time.Time, now time.Time) bool {
	if disconnect == nil {
		return false
	}
	return !now.Before(disconnect.Add(OfflineDigestGrace))
}

// OfflineSimulator holds dependencies for one-shot wall-time catch-up (server downtime, cold-start bootstrap).
// Live progression runs in the Engine for all resident heroes.
type OfflineSimulator struct {
	store          *storage.HeroStore
	logStore       *storage.LogStore
	questStore     *storage.QuestStore
	gearStore      *storage.GearStore
	taskStore      *storage.DailyTaskStore
	achStore       *storage.AchievementStore
	graph          *RoadGraph
	interval       time.Duration
	logger         *slog.Logger
	combatTickRate time.Duration
	// isPaused, when set, skips simulation ticks while global server time is frozen.
	isPaused func() bool
	// skipIfLive, when set, skips heroes currently registered in the online engine (WebSocket session)
	// so the same hero is not simulated twice.
	skipIfLive  func(heroID int64) bool
	digestStore *storage.OfflineDigestStore
}

// NewOfflineSimulator builds a catch-up runner used by BootstrapResidentHeroes and REST init gap recovery.
// isPaused and skipIfLive are optional filters for SimulateHeroAt callers; Run() is a no-op.
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, questStore *storage.QuestStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator {
	return &OfflineSimulator{
		store:          store,
		logStore:       logStore,
		questStore:     questStore,
		graph:          graph,
		interval:       30 * time.Second,
		logger:         logger,
		combatTickRate: 100 * time.Millisecond,
		isPaused:       isPaused,
		skipIfLive:     skipIfLive,
	}
}

// WithCombatTickRate overrides the combat tick rate used in offline simulations.
func (s *OfflineSimulator) WithCombatTickRate(tick time.Duration) *OfflineSimulator {
	if tick > 0 {
		s.combatTickRate = tick
	}
	return s
}

// WithRewardStores wires optional stores for offline reward hooks.
func (s *OfflineSimulator) WithRewardStores(gear *storage.GearStore, achievements *storage.AchievementStore, tasks *storage.DailyTaskStore) *OfflineSimulator {
	s.gearStore = gear
	s.achStore = achievements
	s.taskStore = tasks
	return s
}

// WithDigestStore wires persistent offline digest while the hero is processed by OfflineSimulator
// (no live WS session for that hero). Counters and loot are cleared when the client loads hero/init.
func (s *OfflineSimulator) WithDigestStore(d *storage.OfflineDigestStore) *OfflineSimulator {
	s.digestStore = d
	return s
}

// NonGoldLootForDigest keeps equipment/potion lines only; gold belongs in gold_gained counter.
func NonGoldLootForDigest(drops []model.LootDrop) []model.LootDrop {
	if len(drops) == 0 {
		return nil
	}
	out := make([]model.LootDrop, 0, len(drops))
	for _, d := range drops {
		if d.ItemType == "gold" {
			continue
		}
		out = append(out, d)
	}
	if len(out) == 0 {
		return nil
	}
	return out
}

// Run is a no-op waiter: progression runs in the game Engine for all resident heroes.
// Kept so callers can block on the same context lifecycle as before.
func (s *OfflineSimulator) Run(ctx context.Context) error {
	<-ctx.Done()
	if s.logger != nil {
		s.logger.Info("offline simulator stub shutting down (engine-authoritative world)")
	}
	return ctx.Err()
}

// simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now,
// then persists. Encounters resolve combat via SimulateOneFight (batch-only; live play uses Engine combat).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {

	// Auto-revive after configured downtime (autoReviveAfterMs).
	gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
	if IsEffectivelyDead(hero) && now.Sub(hero.UpdatedAt) > gap {
		ApplyHeroReviveMechanical(hero)
		s.addLog(ctx, hero.ID, model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseAutoReviveAfterSec,
				Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
			},
		})
		if s.digestStore != nil && OfflineDigestCollecting(hero.WsDisconnectedAt, now) {
			_ = s.digestStore.ApplyDelta(ctx, hero.ID, storage.OfflineDigestDelta{Revives: 1})
		}
	}

	// Dead heroes cannot move or fight.
	if hero.State == model.StateDead || hero.HP <= 0 {
		return nil
	}

	if s.graph == nil {
		s.logger.Warn("offline simulator: road graph nil, skipping movement tick", "hero_id", hero.ID)
		return nil
	}

	hm := NewHeroMovement(hero, s.graph, now)
	if hm.State == model.StateFighting {
		return nil
	}

	if hero.UpdatedAt.IsZero() {
		hm.LastMoveTick = now.Add(-movementTickRate())
	} else {
		hm.LastMoveTick = hero.UpdatedAt
	}

	encounter := func(hm *HeroMovement, enemy *model.Enemy, tickNow time.Time) {
		s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseEncounteredEnemy,
				Args: map[string]any{"enemyType": enemy.Slug},
			},
		})
		rewardDeps := s.rewardDeps(tickNow)
		levelBefore := hm.Hero.Level
		survived, en, xpGained, goldGained, drops := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, s.combatTickRate, rewardDeps)
		if s.digestStore != nil && OfflineDigestCollecting(hm.Hero.WsDisconnectedAt, tickNow) {
			if survived {
				levelGain := hm.Hero.Level - levelBefore
				_ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{
					MonstersKilled: 1,
					XPGained:       xpGained,
					GoldGained:     goldGained,
					LevelsGained:   levelGain,
					LootAppend:     NonGoldLootForDigest(drops),
				})
			} else {
				_ = s.digestStore.ApplyDelta(ctx, hm.Hero.ID, storage.OfflineDigestDelta{Deaths: 1})
			}
		}
		if survived {
			s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{
					Code: model.LogPhraseDefeatedEnemy,
					Args: map[string]any{
						"enemyType": en.Slug,
						"xp":        xpGained, "gold": goldGained,
					},
				},
			})
			hm.ResumeWalking(tickNow)
			hm.TryAdventureReturnAfterCombat(tickNow)
		} else {
			s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{
					Code: model.LogPhraseDiedFighting,
					Args: map[string]any{"enemyType": en.Slug},
				},
			})
			hm.Die()
		}
	}

	const maxOfflineMovementSteps = 200000
	step := 0
	offlineTownTour := s.offlineTownTourAtNPC(ctx)
	for hm.LastMoveTick.Before(now) && step < maxOfflineMovementSteps {
		step++
		next := hm.LastMoveTick.Add(movementTickRate())
		if next.After(now) {
			next = now
		}
		if !next.After(hm.LastMoveTick) {
			break
		}
		onMerchant := func(hm *HeroMovement, tickNow time.Time, cost int64) {
			_ = tickNow
			_ = cost
			s.addLog(ctx, hm.Hero.ID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{Code: model.LogPhraseWanderingMerchant},
			})
		}
		adventureLog := func(heroID int64, line model.AdventureLogLine) {
			s.addLog(ctx, heroID, line)
		}
		ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, nil, offlineTownTour, nil)
		if hm.Hero.State == model.StateDead || hm.Hero.HP <= 0 {
			break
		}
	}
	if step >= maxOfflineMovementSteps && hm.LastMoveTick.Before(now) {
		s.logger.Warn("offline movement step cap reached", "hero_id", hero.ID)
	}

	hm.SyncToHero()
	hero.RefreshDerivedCombatStats(now)

	if persist && s.store != nil {
		if err := s.store.Save(ctx, hero); err != nil {
			return fmt.Errorf("save hero after offline tick: %w", err)
		}
	}

	return nil
}

// SimulateHeroAt runs a single offline catch-up tick up to the given time.
func (s *OfflineSimulator) SimulateHeroAt(ctx context.Context, hero *model.Hero, now time.Time, persist bool) error {
	return s.simulateHeroTick(ctx, hero, now, persist)
}

func (s *OfflineSimulator) offlineTownTourAtNPC(ctx context.Context) TownTourOfflineAtNPC {
	return func(heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
		s.applyOfflineTownTourNPCVisit(ctx, heroID, hm, graph, npc, now, al)
	}
}

func (s *OfflineSimulator) rewardDeps(now time.Time) VictoryRewardDeps {
	return VictoryRewardDeps{
		GearStore:        s.gearStore,
		QuestProgressor:  s.questStore,
		AchievementCheck: s.achStore,
		TaskProgressor:   s.taskStore,
		LogWriter: func(heroID int64, line model.AdventureLogLine) {
			logCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
			defer cancel()
			if err := s.logStore.Add(logCtx, heroID, line); err != nil && s.logger != nil {
				s.logger.Warn("offline simulator: failed to write adventure log", "hero_id", heroID, "error", err)
			}
		},
		InTown: func(ctx context.Context, posX, posY float64) bool {
			if s.graph == nil {
				return false
			}
			return s.graph.HeroInTownAt(posX, posY)
		},
		Logger: s.logger,
	}
}

// applyOfflineTownTourNPCVisit resolves one town-tour NPC stop without UI: quest → merchant upgrade → healer heal → potion → fallbacks.
func (s *OfflineSimulator) applyOfflineTownTourNPCVisit(ctx context.Context, heroID int64, hm *HeroMovement, graph *RoadGraph, npc TownNPC, now time.Time, al AdventureLogWriter) {
	h := hm.Hero
	if h == nil {
		return
	}
	var town *model.Town
	if graph != nil {
		town = graph.Towns[hm.CurrentTownID]
	}
	townLv := TownEffectiveLevel(town)
	cfg := tuning.Get()

	tryQuest := func() bool {
		if !model.IsQuestOfferNPCType(npc.Type) || s.questStore == nil {
			return false
		}
		hqs, err := s.questStore.ListHeroQuests(ctx, heroID)
		if err != nil {
			s.logger.Warn("offline town tour: list hero quests", "error", err)
			return false
		}
		taken := make(map[int64]struct{}, len(hqs))
		for _, hq := range hqs {
			taken[hq.QuestID] = struct{}{}
		}
		offered, err := s.questStore.ListQuestsByNPCForHeroLevel(ctx, npc.ID, townLv)
		if err != nil {
			s.logger.Warn("offline town tour: list quests by npc", "error", err)
			return false
		}
		offered = model.FilterQuestTemplatesByNPCType(offered, npc.Type)
		for _, q := range offered {
			if _, ok := taken[q.ID]; ok {
				continue
			}
			ok, err := s.questStore.TryAcceptQuest(ctx, heroID, q.ID)
			if err != nil {
				s.logger.Warn("offline town tour: try accept quest", "error", err)
				return false
			}
			if ok && al != nil {
				qk := q.QuestKey
				if qk == "" {
					qk = fmt.Sprintf("quest.%d", q.ID)
				}
				al(heroID, model.AdventureLogLine{
					Event: &model.AdventureLogEvent{
						Code: model.LogPhraseQuestAccepted,
						Args: map[string]any{"questKey": qk},
					},
				})
			}
			return ok
		}
		return false
	}

	if tryQuest() {
		return
	}

	if model.IsGearVendorType(npc.Type) {
		gearCost := tuning.EffectiveTownMerchantGearCost(townLv)
		if s.gearStore != nil && gearCost > 0 && h.Gold >= gearCost {
			slots := model.GearVendorSlots(npc.Type)
			items := RollTownMerchantStockItemsForSlots(townLv, 1, slots)
			if len(items) > 0 && TownMerchantRollIsUpgrade(h, items[0], now) {
				h.Gold -= gearCost
				drop, err := ApplyPreparedTownMerchantPurchase(ctx, s.gearStore, h, items[0], now)
				if err != nil {
					h.Gold += gearCost
					s.logger.Warn("offline town tour merchant gear", "hero_id", heroID, "error", err)
				} else if al != nil && drop != nil {
					townKey := ""
					if town != nil {
						townKey = town.NameKey
					}
					al(heroID, model.AdventureLogLine{
						Event: &model.AdventureLogEvent{
							Code: model.LogPhraseBoughtGearTownMerchant,
							Args: map[string]any{
								"npcKey": npc.NameKey, "townKey": townKey, "slot": drop.ItemType,
								"rarity": string(drop.Rarity), "itemId": drop.ItemID,
							},
						},
					})
				}
				return
			}
		}
	}

	_, healCost := tuning.EffectiveNPCShopCosts()
	potionCost, _ := tuning.EffectiveNPCShopCosts()
	if npc.Type == "healer" && h.MaxHP > 0 {
		hpFrac := float64(h.HP) / float64(h.MaxHP)
		if hpFrac < 0.5 && healCost > 0 && h.Gold >= healCost && h.HP < h.MaxHP {
			h.Gold -= healCost
			h.HP = h.MaxHP
			if al != nil {
				al(heroID, model.AdventureLogLine{
					Event: &model.AdventureLogEvent{
						Code: model.LogPhrasePaidHealerFull,
						Args: map[string]any{"npcKey": npc.NameKey},
					},
				})
			}
			return
		}
		if potionCost > 0 && h.Gold >= potionCost {
			h.Gold -= potionCost
			h.Potions++
			if al != nil {
				al(heroID, model.AdventureLogLine{
					Event: &model.AdventureLogEvent{
						Code: model.LogPhrasePurchasedPotionFromNPC,
						Args: map[string]any{"npcKey": npc.NameKey},
					},
				})
			}
			return
		}
	}

	if model.IsGearVendorType(npc.Type) {
		share := cfg.MerchantTownAutoSellShare
		if share <= 0 || share > 1 {
			share = tuning.DefaultValues().MerchantTownAutoSellShare
		}
		soldItems, soldGold := AutoSellRandomInventoryShare(h, share, nil)
		if soldItems > 0 && al != nil {
			al(heroID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{
					Code: model.LogPhraseSoldItemsMerchant,
					Args: map[string]any{"count": soldItems, "npcKey": npc.NameKey, "gold": soldGold},
				},
			})
		}
		return
	}

	if model.IsQuestOfferNPCType(npc.Type) && al != nil {
		al(heroID, model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseQuestGiverChecked,
				Args: map[string]any{"npcKey": npc.NameKey},
			},
		})
	}
}

// addLog is a fire-and-forget helper that writes an adventure log entry.
func (s *OfflineSimulator) addLog(ctx context.Context, heroID int64, line model.AdventureLogLine) {
	logCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	if err := s.logStore.Add(logCtx, heroID, line); err != nil {
		s.logger.Warn("offline simulator: failed to write adventure log",
			"hero_id", heroID,
			"error", err,
		)
	}
}

// SimulateOneFight runs one combat encounter using the shared combat loop and reward logic.
// Returns whether the hero survived, the enemy fought, XP gained, and gold gained.
func SimulateOneFight(hero *model.Hero, now time.Time, encounterEnemy *model.Enemy, g *RoadGraph, tickRate time.Duration, rewardDeps VictoryRewardDeps) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64, drops []model.LootDrop) {
	if encounterEnemy != nil {
		enemy = *encounterEnemy
	} else {
		enemy = PickEnemyForHero(hero)
	}
	if rewardDeps.InTown == nil && g != nil {
		rewardDeps.InTown = func(ctx context.Context, posX, posY float64) bool {
			return g.HeroInTownAt(posX, posY)
		}
	}

	survived = ResolveCombatToEnd(hero, &enemy, now, CombatSimOptions{
		TickRate:      tickRate,
		AutoUsePotion: OfflineAutoPotionHook,
	})

	if !survived || hero.HP <= 0 {
		hero.HP = 0
		hero.State = model.StateDead
		hero.TotalDeaths++
		hero.KillsSinceDeath = 0
		return false, enemy, 0, 0, nil
	}

	xpGained = enemy.XPReward
	drops = ApplyVictoryRewards(hero, &enemy, now, rewardDeps)
	goldGained = sumGoldFromDrops(drops)
	hero.RefreshDerivedCombatStats(now)
	return true, enemy, xpGained, goldGained, drops
}

func sumGoldFromDrops(drops []model.LootDrop) int64 {
	var total int64
	for _, drop := range drops {
		if drop.ItemType == "gold" || drop.GoldAmount > 0 {
			total += drop.GoldAmount
		}
	}
	return total
}

// PickEnemyForLevel selects a random DB-loaded archetype and builds a runtime instance.
// hero is nil: no unequipped-hero weakening is applied (still uses global encounter stat multiplier).
func PickEnemyForLevel(level int) model.Enemy {
	return pickEnemyForHeroLevel(nil, level, nil)
}

// PickEnemyForHero is like PickEnemyForLevel but applies unequipped-hero monster scaling when hero has no gear.
func PickEnemyForHero(hero *model.Hero) model.Enemy {
	if hero == nil {
		return model.Enemy{}
	}
	return pickEnemyForHeroLevel(hero, hero.Level, nil)
}

// PickEnemyForLevelWithRNG is like PickEnemyForLevel but uses rng for template selection (deterministic sims).
// Pass hero when simulating a specific hero so unequipped scaling matches live encounters (may be nil).
func PickEnemyForLevelWithRNG(level int, rng *rand.Rand, hero *model.Hero) model.Enemy {
	return pickEnemyForHeroLevel(hero, level, rng)
}

func pickEnemyForHeroLevel(hero *model.Hero, level int, rng *rand.Rand) model.Enemy {
	candidates := enemyCandidatesForHeroLevel(level)
	if len(candidates) == 0 {
		return model.Enemy{}
	}
	var picked model.Enemy
	if rng != nil {
		picked = candidates[rng.Intn(len(candidates))]
	} else {
		picked = candidates[rand.Intn(len(candidates))]
	}
	e := buildEnemyInstance(picked, hero, rng)

	return e
}

func enemyCandidatesForHeroLevel(level int) []model.Enemy {
	candidates := make([]model.Enemy, 0, len(model.EnemyTemplates))
	for _, t := range model.EnemyTemplates {
		if t.MinLevel > 0 && t.MaxLevel >= t.MinLevel {
			if level >= t.MinLevel && level <= t.MaxLevel {
				candidates = append(candidates, t)
			}
			continue
		}
		base := t.BaseLevel
		if base <= 0 {
			base = 1
		}
		if absInt(level-base) <= max(1, t.MaxHeroLevelDiff) {
			candidates = append(candidates, t)
		}
	}
	if len(candidates) > 0 {
		return candidates
	}
	nearestDelta := math.MaxInt
	for _, t := range model.EnemyTemplates {
		base := t.BaseLevel
		if base <= 0 {
			base = max(1, t.MinLevel)
		}
		d := absInt(level - base)
		if d < nearestDelta {
			nearestDelta = d
			candidates = candidates[:0]
			candidates = append(candidates, t)
		} else if d == nearestDelta {
			candidates = append(candidates, t)
		}
	}
	return candidates
}

func enemyInstanceLevel(baseLevel, heroLevel int, variance float64, maxHeroDiff int, rng *rand.Rand) int {
	if baseLevel <= 0 {
		baseLevel = 1
	}
	if variance <= 0 {
		variance = 0.30
	}
	if variance > 0.95 {
		variance = 0.95
	}
	if maxHeroDiff <= 0 {
		maxHeroDiff = 5
	}
	minL := int(math.Floor(float64(baseLevel) * (1 - variance)))
	maxL := int(math.Ceil(float64(baseLevel) * (1 + variance)))
	if minL < 1 {
		minL = 1
	}
	if heroLevel > 0 {
		minL = max(minL, heroLevel-maxHeroDiff)
		maxL = min(maxL, heroLevel+maxHeroDiff)
	}
	if maxL < minL {
		fallback := baseLevel
		if heroLevel > 0 {
			fallback = min(max(fallback, heroLevel-maxHeroDiff), heroLevel+maxHeroDiff)
		}
		if fallback < 1 {
			fallback = 1
		}
		return fallback
	}
	if rng != nil {
		return minL + rng.Intn(maxL-minL+1)
	}
	return minL + rand.Intn(maxL-minL+1)
}

func buildEnemyInstance(tmpl model.Enemy,  hero *model.Hero, rng *rand.Rand,) model.Enemy {
	picked := tmpl
	instanceLevel := enemyInstanceLevel(picked.BaseLevel, hero.Level, picked.LevelVariance, picked.MaxHeroLevelDiff, rng)
	return BuildEnemyInstanceForLevel(picked, instanceLevel, hero)
}

// BuildEnemyInstanceForEncounter builds a runtime enemy like world encounters: rolls instance level
// using the template base level, LevelVariance, and MaxHeroLevelDiff vs heroLevel (see enemyInstanceLevel).
// Pass rng for deterministic runs; nil uses the global math/rand source.
func BuildEnemyInstanceForEncounter(tmpl model.Enemy, hero *model.Hero, rng *rand.Rand) model.Enemy {
	return buildEnemyInstance(tmpl, hero, rng)
}

// ScaleEnemyTemplate is kept for backward compatibility with existing call sites.
// It now builds an instance using DB-driven per-archetype progression.
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
	return BuildEnemyInstanceForLevel(tmpl, heroLevel, nil)
}

// BuildEnemyInstanceForLevelScaledOnly returns the runtime enemy after level-based progression only.
// It does not apply EnemyEncounterStatMultiplier or unequipped-hero scaling.
func BuildEnemyInstanceForLevelScaledOnly(tmpl model.Enemy, level int) model.Enemy {
	picked := tmpl
	baseLevel := picked.BaseLevel
	if baseLevel <= 0 {
		if picked.MinLevel > 0 {
			baseLevel = picked.MinLevel
		} else {
			baseLevel = 1
		}
	}
	if level <= 0 {
		level = baseLevel
	}
	levelDelta := float64(level - baseLevel)
	picked.Level = level
	picked.MaxHP = max(1, int(math.Round(float64(picked.MaxHP)+levelDelta*picked.HPPerLevel)))
	picked.HP = picked.MaxHP
	picked.Attack = max(1, int(math.Round(float64(picked.Attack)+levelDelta*picked.AttackPerLevel)))
	picked.Defense = max(0, int(math.Round(float64(picked.Defense)+levelDelta*picked.DefensePerLevel)))
	xpPerLevel := picked.XPPerLevel
	// Keep early-game kill cadence predictable (~1 XP from template base for normal mobs);
	// xp_per_level ramps from instance level 10+ (and always applies to elites).
	if level < 10 && !picked.IsElite {
		xpPerLevel = 0
	}
	picked.XPReward = max(1, int64(math.Round(float64(picked.XPReward)+levelDelta*xpPerLevel)))
	picked.GoldReward = max(0, int64(math.Round(float64(picked.GoldReward)+levelDelta*picked.GoldPerLevel)))
	return picked
}

// EnemyEncounterStatStages returns level-scaled combat stats, then the same after the global encounter multiplier only.
func EnemyEncounterStatStages(tmpl model.Enemy, level int) (baseScaled model.Enemy, afterGlobal model.Enemy) {
	baseScaled = BuildEnemyInstanceForLevelScaledOnly(tmpl, level)
	afterGlobal = baseScaled
	cfg := tuning.Get()
	gMult := cfg.EnemyEncounterStatMultiplier
	if gMult <= 0 {
		gMult = tuning.DefaultValues().EnemyEncounterStatMultiplier
	}
	if gMult > 0 && gMult != 1 {
		applyEnemyEncounterCombatMult(&afterGlobal, gMult)
	}
	return baseScaled, afterGlobal
}

// BuildEnemyInstanceForLevel creates a deterministic enemy instance at an explicit level.
func BuildEnemyInstanceForLevel(tmpl model.Enemy, level int, hero *model.Hero) model.Enemy {
	picked := BuildEnemyInstanceForLevelScaledOnly(tmpl, level)
	cfg := tuning.Get()

	var m float64
	if hero != nil && !HeroHasEquippedGearForCombat(hero) {
		m = cfg.EnemyStatMultiplierVsUnequippedHero
	} else {
		m = cfg.EnemyEncounterStatMultiplier
	}

	applyEnemyEncounterCombatMult(&picked, m)

	return picked
}

// HeroHasEquippedGear is true if the hero has at least one non-nil item in Gear.
func HeroHasEquippedGear(h *model.Hero) bool {
	if h == nil {
		return false
	}
	h.EnsureGearMap()
	for _, it := range h.Gear {
		if it != nil {
			return true
		}
	}
	return false
}

// HeroHasEquippedGearForCombat is true if the hero has any equipped item (weapon/armor/etc.).
func HeroHasEquippedGearForCombat(h *model.Hero) bool {
	h.EnsureGearMap()
	c := 0
	for _, it := range h.Gear {
		if it != nil {
			c++
		}
	}
	return c > 5
}

func applyEnemyEncounterCombatMult(e *model.Enemy, mult float64) {
	if e == nil || mult <= 0 || mult == 1 {
		return
	}
	e.MaxHP = max(1, int(math.Round(float64(e.MaxHP)*mult)))
	e.HP = e.MaxHP
	e.Attack = max(1, int(math.Round(float64(e.Attack)*mult)))
	e.Defense = max(0, int(math.Round(float64(e.Defense)*mult)))
}

// EnemyEncounterMultiplierBreakdown documents tuning multipliers used when building encounter enemies
// (global encounter strength vs hero; extra scaling when the hero has almost no gear).
type EnemyEncounterMultiplierBreakdown struct {
	GlobalEncounterStatMultiplier float64 `json:"globalEncounterStatMultiplier"`
	UnequippedHeroStatMultiplier  float64 `json:"unequippedHeroStatMultiplier"`
	UnequippedScalingApplied      bool    `json:"unequippedScalingApplied"`
}

// EnemyEncounterMultiplierBreakdownForHero returns active tuning values and whether unequipped-hero scaling would apply.
func EnemyEncounterMultiplierBreakdownForHero(hero *model.Hero) EnemyEncounterMultiplierBreakdown {
	cfg := tuning.Get()
	g := cfg.EnemyEncounterStatMultiplier
	if g <= 0 {
		g = tuning.DefaultValues().EnemyEncounterStatMultiplier
	}
	m := cfg.EnemyStatMultiplierVsUnequippedHero
	if m <= 0 {
		m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
	}
	applied := hero != nil && !HeroHasEquippedGearForCombat(hero) && m > 0 && m != 1 && m <= 10
	return EnemyEncounterMultiplierBreakdown{
		GlobalEncounterStatMultiplier: g,
		UnequippedHeroStatMultiplier:  m,
		UnequippedScalingApplied:      applied,
	}
}

// ApplyEnemyEncounterHeroScaling applies a multiplier to enemy combat stats when the hero has no equipped gear.
func ApplyEnemyEncounterHeroScaling(hero *model.Hero, enemy *model.Enemy) {
	if hero == nil || enemy == nil || HeroHasEquippedGearForCombat(hero) {
		return
	}
	cfg := tuning.Get()
	m := cfg.EnemyStatMultiplierVsUnequippedHero
	if m <= 0 {
		m = tuning.DefaultValues().EnemyStatMultiplierVsUnequippedHero
	}
	if m <= 0 || m > 10 || m == 1 {
		return
	}
	applyEnemyEncounterCombatMult(enemy, m)
}

func absInt(v int) int {
	if v < 0 {
		return -v
	}
	return v
}
