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"
)

// OfflineSimulator runs periodic background ticks for heroes that are offline,
// advancing movement the same way as the online engine (without WebSocket payloads)
// and resolving random encounters with SimulateOneFight.
type OfflineSimulator struct {
	store    *storage.HeroStore
	logStore *storage.LogStore
	graph    *RoadGraph
	interval time.Duration
	logger   *slog.Logger
	// 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
}

// NewOfflineSimulator creates a new OfflineSimulator that ticks every 30 seconds.
// isPaused may be nil; if it returns true, offline catch-up is skipped (aligned with engine pause).
// skipIfLive may be nil; if it returns true for a hero id, that hero is skipped this tick.
func NewOfflineSimulator(store *storage.HeroStore, logStore *storage.LogStore, graph *RoadGraph, logger *slog.Logger, isPaused func() bool, skipIfLive func(heroID int64) bool) *OfflineSimulator {
	return &OfflineSimulator{
		store:      store,
		logStore:   logStore,
		graph:      graph,
		interval:   30 * time.Second,
		logger:     logger,
		isPaused:   isPaused,
		skipIfLive: skipIfLive,
	}
}

// Run starts the offline simulation loop. It blocks until the context is cancelled.
func (s *OfflineSimulator) Run(ctx context.Context) error {
	ticker := time.NewTicker(s.interval)
	defer ticker.Stop()

	s.logger.Info("offline simulator started", "interval", s.interval)

	for {
		select {
		case <-ctx.Done():
			s.logger.Info("offline simulator shutting down")
			return ctx.Err()
		case <-ticker.C:
			s.processTick(ctx)
		}
	}
}

// processTick finds all offline heroes and simulates one fight for each.
func (s *OfflineSimulator) processTick(ctx context.Context) {
	if s.isPaused != nil && s.isPaused() {
		return
	}
	heroes, err := s.store.ListOfflineHeroes(ctx, s.interval*2, 100)
	if err != nil {
		s.logger.Error("offline simulator: failed to list offline heroes", "error", err)
		return
	}

	if len(heroes) == 0 {
		return
	}

	s.logger.Debug("offline simulator tick", "offline_heroes", len(heroes))

	for _, hero := range heroes {
		if s.skipIfLive != nil && s.skipIfLive(hero.ID) {
			continue
		}
		if err := s.simulateHeroTick(ctx, hero); err != nil {
			s.logger.Error("offline simulator: hero tick failed",
				"hero_id", hero.ID,
				"error", err,
			)
			// Continue with other heroes — don't crash on one failure.
		}
	}
}

// simulateHeroTick catches up movement in configured movement-tick steps from hero.UpdatedAt to now,
// then persists. Random encounters use the same rolls as online; combat is resolved
// synchronously via SimulateOneFight (no WebSocket).
func (s *OfflineSimulator) simulateHeroTick(ctx context.Context, hero *model.Hero) error {
	now := time.Now()

	// Auto-revive after configured downtime (autoReviveAfterMs).
	gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
	if (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > gap {
		hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
		if hero.HP < 1 {
			hero.HP = 1
		}
		hero.State = model.StateWalking
		hero.Debuffs = nil
		s.addLog(ctx, hero.ID, fmt.Sprintf("Auto-revived after %s", gap.Round(time.Second)))
	}

	// 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, fmt.Sprintf("Encountered %s", enemy.Name))
		survived, en, xpGained, goldGained := SimulateOneFight(hm.Hero, tickNow, enemy, s.graph, func(msg string) {
			s.addLog(ctx, hm.Hero.ID, msg)
		})
		if survived {
			s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", en.Name, xpGained, goldGained))
			hm.ResumeWalking(tickNow)
		} else {
			s.addLog(ctx, hm.Hero.ID, fmt.Sprintf("Died fighting %s", en.Name))
			hm.Die()
		}
	}

	const maxOfflineMovementSteps = 200000
	step := 0
	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, "Encountered a Wandering Merchant on the road")
		}
		adventureLog := func(heroID int64, msg string) {
			s.addLog(ctx, heroID, msg)
		}
		ProcessSingleHeroMovementTick(hero.ID, hm, s.graph, next, nil, encounter, onMerchant, adventureLog, 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 err := s.store.Save(ctx, hero); err != nil {
		return fmt.Errorf("save hero after offline tick: %w", err)
	}

	return nil
}

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

// SimulateOneFight runs one combat encounter for an offline hero.
// It mutates the hero (HP, XP, gold, potions, level, equipment, state).
// If encounterEnemy is non-nil, that enemy is used (same as movement encounter roll);
// otherwise a new enemy is picked for the hero's level.
// onInventoryDiscard is called when a gear drop cannot be equipped and the backpack is full (may be nil).
// 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, onInventoryDiscard func(string)) (survived bool, enemy model.Enemy, xpGained int64, goldGained int64) {
	if encounterEnemy != nil {
		enemy = *encounterEnemy
	} else {
		enemy = PickEnemyForLevel(hero.Level)
	}

	allowSell := g != nil && g.HeroInTownAt(hero.PositionX, hero.PositionY)

	heroDmgPerHit := hero.EffectiveAttackAt(now) - enemy.Defense
	if heroDmgPerHit < 1 {
		heroDmgPerHit = 1
	}
	enemyDmgPerHit := enemy.Attack - hero.EffectiveDefenseAt(now)
	if enemyDmgPerHit < 1 {
		enemyDmgPerHit = 1
	}

	hitsToKill := (enemy.MaxHP + heroDmgPerHit - 1) / heroDmgPerHit
	dmgTaken := enemyDmgPerHit * hitsToKill

	hero.HP -= dmgTaken

	// Use potion if HP drops below 30% and hero has potions.
	if hero.HP > 0 && hero.HP < int(float64(hero.MaxHP)*tuning.Get().PotionAutoUseThreshold) && hero.Potions > 0 {
		healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
		if healAmount < 1 {
			healAmount = 1
		}
		hero.HP += healAmount
		if hero.HP > hero.MaxHP {
			hero.HP = hero.MaxHP
		}
		hero.Potions--
	}

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

	// Hero survived — apply rewards and stat tracking.
	hero.TotalKills++
	hero.KillsSinceDeath++
	if enemy.IsElite {
		hero.EliteKills++
	}

	xpGained = enemy.XPReward
	hero.XP += xpGained

	// Loot generation.
	luckMult := LuckMultiplier(hero, now)
	drops := model.GenerateLoot(enemy.Type, luckMult)
	for _, drop := range drops {
		// Track legendary equipment drops for achievements.
		if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" {
			hero.LegendaryDrops++
		}
		switch drop.ItemType {
		case "gold":
			hero.Gold += drop.GoldAmount
			goldGained += drop.GoldAmount
		case "potion":
			hero.Potions++
		default:
			// All equipment drops go through the unified gear system.
			slot := model.EquipmentSlot(drop.ItemType)
			family := model.PickGearFamily(slot)
			if family != nil {
				ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
				item := model.NewGearItem(family, ilvl, drop.Rarity)
				TryEquipOrStashOffline(hero, item, now, onInventoryDiscard)
			} else if allowSell {
				price := model.AutoSellPrice(drop.Rarity)
				hero.Gold += price
				goldGained += price
			}
		}
	}

	// Also add the base gold reward from the enemy.
	hero.Gold += enemy.GoldReward
	goldGained += enemy.GoldReward

	// Level-up loop.
	for hero.LevelUp() {
	}

	hero.RefreshDerivedCombatStats(now)
	return true, enemy, xpGained, goldGained
}

// PickEnemyForLevel selects a random enemy appropriate for the hero's level
// and scales its stats. Exported for use by both the offline simulator and handler.
func PickEnemyForLevel(level int) model.Enemy {
	candidates := make([]model.Enemy, 0, len(model.EnemyTemplates))
	for _, t := range model.EnemyTemplates {
		if level >= t.MinLevel && level <= t.MaxLevel {
			candidates = append(candidates, t)
		}
	}
	if len(candidates) == 0 {
		// Hero exceeds all level bands — pick enemies from the highest band.
		highestMin := 0
		for _, t := range model.EnemyTemplates {
			if t.MinLevel > highestMin {
				highestMin = t.MinLevel
			}
		}
		for _, t := range model.EnemyTemplates {
			if t.MinLevel >= highestMin {
				candidates = append(candidates, t)
			}
		}
	}
	picked := candidates[rand.Intn(len(candidates))]
	return ScaleEnemyTemplate(picked, level)
}

// ScaleEnemyTemplate applies band-based level scaling to stats and rewards.
// Exported for reuse across handler and offline simulation.
func ScaleEnemyTemplate(tmpl model.Enemy, heroLevel int) model.Enemy {
	picked := tmpl

	bandLevel := heroLevel
	if bandLevel < tmpl.MinLevel {
		bandLevel = tmpl.MinLevel
	}
	if bandLevel > tmpl.MaxLevel {
		bandLevel = tmpl.MaxLevel
	}
	bandDelta := float64(bandLevel - tmpl.MinLevel)
	overcapDelta := float64(heroLevel - tmpl.MaxLevel)
	if overcapDelta < 0 {
		overcapDelta = 0
	}

	cfg := tuning.Get()
	hpMul := 1.0 + bandDelta*cfg.EnemyScaleBandHP + overcapDelta*cfg.EnemyScaleOvercapHP
	atkMul := 1.0 + bandDelta*cfg.EnemyScaleBandATK + overcapDelta*cfg.EnemyScaleOvercapATK
	defMul := 1.0 + bandDelta*cfg.EnemyScaleBandDEF + overcapDelta*cfg.EnemyScaleOvercapDEF

	picked.MaxHP = max(1, int(float64(picked.MaxHP)*hpMul))
	picked.HP = picked.MaxHP
	picked.Attack = max(1, int(float64(picked.Attack)*atkMul))
	picked.Defense = max(0, int(float64(picked.Defense)*defMul))

	xpMul := 1.0 + bandDelta*cfg.EnemyScaleBandXP + overcapDelta*cfg.EnemyScaleOvercapXP
	goldMul := 1.0 + bandDelta*cfg.EnemyScaleBandGold + overcapDelta*cfg.EnemyScaleOvercapGold
	picked.XPReward = int64(math.Round(float64(picked.XPReward) * xpMul))
	picked.GoldReward = int64(math.Round(float64(picked.GoldReward) * goldMul))

	return picked
}

