package game

import (
	"container/heap"
	"context"
	"encoding/json"
	"fmt"
	"log/slog"
	"sync"
	"time"

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

// MessageSender is the interface the engine uses to push WS messages.
// Implemented by handler.Hub (injected to avoid import cycle).
type MessageSender interface {
	SendToHero(heroID int64, msgType string, payload any)
	BroadcastEvent(event model.CombatEvent)
}

// NearbySubscriptionManager can attach nearby-hero movement subscriptions for a viewer.
type NearbySubscriptionManager interface {
	SetNearbySubscriptions(viewerID int64, targetIDs []int64)
}

// EnemyDeathCallback runs when an enemy dies (loot/XP applied). Returns processed loot drops for combat_end WS.
type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop

type merchantOfferSession struct {
	NPCID   int64
	TownID  int64
	Items   []*model.GearItem
	Costs   []int64 // parallel to Items — rolled when stock opens
	Created time.Time
}

// EngineStatus contains a snapshot of the engine's operational state.
type EngineStatus struct {
	Running         bool          `json:"running"`
	TickRate        time.Duration `json:"tickRate"`
	ActiveCombats   int           `json:"activeCombats"`
	ActiveMovements int           `json:"activeMovements"`
	UptimeMs        int64         `json:"uptimeMs"`
	TimePaused      bool          `json:"timePaused"`
}

// CombatInfo is a read-only snapshot of a single active combat.
type CombatInfo struct {
	HeroID    int64     `json:"heroId"`
	EnemyName string    `json:"enemyName"`
	EnemyType string    `json:"enemyType"`
	HeroHP    int       `json:"heroHp"`
	EnemyHP   int       `json:"enemyHp"`
	StartedAt time.Time `json:"startedAt"`
}

// IncomingMessage is a client command received from the WS layer.
type IncomingMessage struct {
	HeroID  int64
	Type    string
	Payload json.RawMessage
}

// Engine is the tick-based game loop that drives combat simulation and hero movement.
type Engine struct {
	tickRate     time.Duration
	combats      map[int64]*model.CombatState // keyed by hero ID
	queue        model.AttackQueue
	movements    map[int64]*HeroMovement // keyed by hero ID
	roadGraph    *RoadGraph
	sender       MessageSender
	heroStore    *storage.HeroStore
	townSession  *storage.TownSessionStore
	questStore   *storage.QuestStore
	incomingCh   chan IncomingMessage // client commands
	mu           sync.RWMutex
	eventCh      chan model.CombatEvent
	logger       *slog.Logger
	onEnemyDeath EnemyDeathCallback
	adventureLog AdventureLogWriter
	startedAt    time.Time
	running      bool
	// timePaused: when true, combat/movement/sync ticks and WS game commands are no-ops.
	timePaused bool
	// pauseStartedAt is wall clock when global pause began (zero when running).
	pauseStartedAt time.Time

	// npcAlmsHandler runs when the client accepts a wandering merchant offer (WS).
	npcAlmsHandler func(context.Context, int64) error

	digestStore *storage.OfflineDigestStore
	// heroSubscriber reports whether the hero has at least one WebSocket client (optional).
	heroSubscriber func(heroID int64) bool
	// lastDisconnectedFullSave tracks periodic DB full saves for heroes without a WS subscriber.
	lastDisconnectedFullSave map[int64]time.Time

	// merchantStock: ephemeral town merchant rows (heroID) until purchase or dialog close.
	merchantStock map[int64]*merchantOfferSession

	heroMeetLastRoll map[int64]time.Time
	heroMeetLastMsg  map[int64]time.Time
}

// offlineDisconnectedFullSaveInterval is how often we persist a full hero row when no WS client is connected.
const offlineDisconnectedFullSaveInterval = 30 * time.Second

// restHealPersistInterval is how often we persist the full hero row while resting with active HP regen.
const restHealPersistInterval = 5 * time.Second

// NewEngine creates a new game engine with the given tick rate.
func NewEngine(tickRate time.Duration, eventCh chan model.CombatEvent, logger *slog.Logger) *Engine {
	e := &Engine{
		tickRate:                 tickRate,
		combats:                  make(map[int64]*model.CombatState),
		queue:                    make(model.AttackQueue, 0),
		movements:                make(map[int64]*HeroMovement),
		incomingCh:               make(chan IncomingMessage, 256),
		eventCh:                  eventCh,
		logger:                   logger,
		lastDisconnectedFullSave: make(map[int64]time.Time),
		merchantStock:            make(map[int64]*merchantOfferSession),
		heroMeetLastRoll:         make(map[int64]time.Time),
		heroMeetLastMsg:          make(map[int64]time.Time),
	}
	heap.Init(&e.queue)
	return e
}

func (e *Engine) GetMovements(heroId int64) *HeroMovement {
	return e.movements[heroId]
}

// LiveHeroByTelegramID returns the resident in-memory hero for an active movement session, or nil.
func (e *Engine) LiveHeroByTelegramID(telegramID int64) *model.Hero {
	if e == nil || telegramID == 0 {
		return nil
	}
	e.mu.RLock()
	defer e.mu.RUnlock()
	for _, hm := range e.movements {
		if hm != nil && hm.Hero != nil && hm.Hero.TelegramID == telegramID {
			return hm.Hero
		}
	}
	return nil
}

// MergeResidentHeroState copies the authoritative in-engine hero into dst after SyncToHero.
// Returns false if the hero is not resident. Used by REST init so the client sees the same state the Engine simulates.
func (e *Engine) MergeResidentHeroState(dst *model.Hero) bool {
	if dst == nil {
		return false
	}
	e.mu.RLock()
	hm := e.movements[dst.ID]
	e.mu.RUnlock()
	if hm == nil || hm.Hero == nil {
		return false
	}
	hm.SyncToHero()
	*dst = *hm.Hero
	return true
}

// HeroHasActiveMovement is true while the hero has an in-engine movement session (resident world actor).
func (e *Engine) HeroHasActiveMovement(heroID int64) bool {
	e.mu.RLock()
	defer e.mu.RUnlock()
	_, ok := e.movements[heroID]
	return ok
}

// HeroWorldPositionForCombat returns world X,Y for town/combat checks (includes movement display offset).
func (e *Engine) HeroWorldPositionForCombat(heroID int64) (x, y float64, ok bool) {
	e.mu.RLock()
	defer e.mu.RUnlock()
	hm, found := e.movements[heroID]
	if !found || hm == nil || hm.Hero == nil {
		return 0, 0, false
	}
	now := time.Now()
	ox, oy := hm.displayOffset(now)
	return hm.CurrentX + ox, hm.CurrentY + oy, true
}

// OverlayResidentWorldPositionsOnNearby overwrites each summary's PositionX/Y when that hero
// has an active movement session (authoritative in-engine coords). Used by GET /hero/nearby so
// clients see meet-stand / frozen positions instead of stale DB rows.
func (e *Engine) OverlayResidentWorldPositionsOnNearby(heroes []storage.HeroSummary) {
	if len(heroes) == 0 {
		return
	}
	e.mu.RLock()
	defer e.mu.RUnlock()
	now := time.Now()
	for i := range heroes {
		hm := e.movements[heroes[i].ID]
		if hm == nil || hm.Hero == nil {
			continue
		}
		ox, oy := hm.displayOffset(now)
		heroes[i].PositionX = hm.CurrentX + ox
		heroes[i].PositionY = hm.CurrentY + oy
	}
}

// RoadGraph returns the loaded world graph (for admin tools), or nil.
func (e *Engine) RoadGraph() *RoadGraph {
	e.mu.RLock()
	defer e.mu.RUnlock()
	return e.roadGraph
}

// SetTimePaused freezes all engine simulation ticks and client command handling.
// On resume, movement/combat timers are shifted so wall time spent paused does not advance the sim
// (no invisible travel or burst combat).
func (e *Engine) SetTimePaused(paused bool) {
	e.mu.Lock()
	defer e.mu.Unlock()

	if paused {
		if !e.timePaused {
			e.pauseStartedAt = time.Now()
			e.timePaused = true
			if e.logger != nil {
				e.logger.Info("game time paused")
			}
		}
		return
	}

	if !e.timePaused {
		return
	}

	now := time.Now()
	var pauseDur time.Duration
	if !e.pauseStartedAt.IsZero() {
		pauseDur = now.Sub(e.pauseStartedAt)
	}
	e.timePaused = false
	e.pauseStartedAt = time.Time{}

	if pauseDur > 0 {
		for _, hm := range e.movements {
			hm.ShiftGameDeadlines(pauseDur, now)
			if hm.Hero != nil {
				model.ShiftHeroEffectDeadlines(hm.Hero, pauseDur)
			}
		}
		e.resyncCombatAfterPauseLocked(now, pauseDur)
		if e.logger != nil {
			e.logger.Info("game time resumed", "paused_wall_ms", pauseDur.Milliseconds())
		}
	}
}

// resyncCombatAfterPauseLocked shifts combat scheduling by pauseDur and rebuilds the attack heap.
// Caller must hold e.mu (write lock).
func (e *Engine) resyncCombatAfterPauseLocked(now time.Time, pauseDur time.Duration) {
	if len(e.combats) == 0 {
		return
	}
	for _, cs := range e.combats {
		cs.LastTickAt = now
		cs.StartedAt = cs.StartedAt.Add(pauseDur)

		hna := cs.HeroNextAttack.Add(pauseDur)
		ena := cs.EnemyNextAttack.Add(pauseDur)
		if cs.Hero != nil {
			if hna.Before(now) {
				hna = now.Add(attackInterval(cs.Hero.EffectiveSpeed()))
			}
		} else if hna.Before(now) {
			cfg := tuning.Get()
			minAttack := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond
			if cfg.CombatPaceMultiplier < 1 {
				cfg.CombatPaceMultiplier = 1
			}
			hna = now.Add(minAttack * time.Duration(cfg.CombatPaceMultiplier))
		}
		if ena.Before(now) {
			ena = now.Add(attackIntervalEnemy(cs.Enemy.Speed))
		}
		cs.HeroNextAttack = hna
		cs.EnemyNextAttack = ena
	}

	e.queue = make(model.AttackQueue, 0)
	for heroID, cs := range e.combats {
		heap.Push(&e.queue, &model.AttackEvent{NextAttackAt: cs.HeroNextAttack, IsHero: true, CombatID: heroID})
		heap.Push(&e.queue, &model.AttackEvent{NextAttackAt: cs.EnemyNextAttack, IsHero: false, CombatID: heroID})
	}
	heap.Init(&e.queue)
}

// IsTimePaused reports whether global simulation time is frozen.
func (e *Engine) IsTimePaused() bool {
	e.mu.RLock()
	defer e.mu.RUnlock()
	return e.timePaused
}

// SetSender sets the WS message sender (typically handler.Hub).
func (e *Engine) SetSender(s MessageSender) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.sender = s
}

// SetRoadGraph sets the road graph used for hero movement.
func (e *Engine) SetRoadGraph(rg *RoadGraph) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.roadGraph = rg
}

// SetHeroStore sets the hero store used for persisting hero state on disconnect.
func (e *Engine) SetHeroStore(hs *storage.HeroStore) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.heroStore = hs
}

// SetTownSessionStore sets the Redis-backed mirror for in-town NPC tour state (reconnect recovery).
func (e *Engine) SetTownSessionStore(ts *storage.TownSessionStore) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.townSession = ts
}

// SetQuestStore sets the quest store used for visit_town progress on town arrival.
func (e *Engine) SetQuestStore(qs *storage.QuestStore) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.questStore = qs
}

// SetOnEnemyDeath registers a callback for enemy death events (e.g. loot generation).
func (e *Engine) SetOnEnemyDeath(cb EnemyDeathCallback) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.onEnemyDeath = cb
}

// SetNPCAlmsHandler registers the handler for npc_alms_accept WebSocket commands.
func (e *Engine) SetNPCAlmsHandler(h func(context.Context, int64) error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.npcAlmsHandler = h
}

// SetAdventureLog registers a writer for town NPC visit lines (optional).
func (e *Engine) SetAdventureLog(w AdventureLogWriter) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.adventureLog = w
}

// SetDigestStore wires persistent offline digest accumulation (after disconnect grace).
func (e *Engine) SetDigestStore(d *storage.OfflineDigestStore) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.digestStore = d
}

// SetHeroSubscriber sets an optional callback: return true if the hero has at least one WebSocket client.
// Used for periodic full saves when the world keeps simulating without a subscriber.
func (e *Engine) SetHeroSubscriber(fn func(heroID int64) bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.heroSubscriber = fn
}

func (e *Engine) applyOfflineDigest(ctx context.Context, heroID int64, hero *model.Hero, now time.Time, delta storage.OfflineDigestDelta) {
	if e.digestStore == nil || hero == nil || !OfflineDigestCollecting(hero.WsDisconnectedAt, now) {
		return
	}
	_ = e.digestStore.ApplyDelta(ctx, heroID, delta)
}

// IncomingCh returns the channel for routing client WS commands into the engine.
func (e *Engine) IncomingCh() chan<- IncomingMessage {
	return e.incomingCh
}

// Run starts the game loop. It blocks until the context is cancelled.
func (e *Engine) Run(ctx context.Context) error {
	combatTicker := time.NewTicker(e.tickRate)
	moveTicker := time.NewTicker(movementTickRate())
	syncTicker := time.NewTicker(positionSyncRate())
	defer combatTicker.Stop()
	defer moveTicker.Stop()
	defer syncTicker.Stop()

	e.mu.Lock()
	e.startedAt = time.Now()
	e.running = true
	e.mu.Unlock()

	e.logger.Info("game engine started", "tick_rate", e.tickRate)

	defer func() {
		e.mu.Lock()
		e.running = false
		e.mu.Unlock()
	}()

	for {
		select {
		case <-ctx.Done():
			e.logger.Info("game engine shutting down")
			return ctx.Err()
		case now := <-combatTicker.C:
			if !e.IsTimePaused() {
				e.processCombatTick(now)
			}
		case now := <-moveTicker.C:
			if !e.IsTimePaused() {
				e.processMovementTick(now)
			}
		case now := <-syncTicker.C:
			if !e.IsTimePaused() {
				e.processPositionSync(now)
			}
		case msg := <-e.incomingCh:
			e.handleClientMessage(msg)
		}
	}
}

// handleClientMessage routes a single inbound client command.
func (e *Engine) handleClientMessage(msg IncomingMessage) {
	if e.IsTimePaused() {
		e.sendError(msg.HeroID, "time_paused", "server time is paused")
		return
	}
	switch msg.Type {
	case "request_nearby_heroes":
		e.handleNearbyHeroesRequest(msg)
	case "activate_buff":
		e.handleActivateBuff(msg)
	case "use_potion":
		e.handleUsePotion(msg)
	case "revive":
		e.handleRevive(msg)
	case "npc_alms_accept":
		e.handleNPCAlmsAccept(msg)
	case "npc_alms_decline":
		e.handleNPCAlmsDecline(msg)
	case "town_tour_npc_dialog_closed":
		e.handleTownTourNPCDialogClosed(msg)
	case "town_tour_npc_interaction_opened":
		e.handleTownTourNPCInteractionOpened(msg)
	case "town_tour_npc_interaction_closed":
		e.handleTownTourNPCInteractionClosed(msg)
	case "hero_meet_send_message":
		e.handleHeroMeetSendMessage(msg)
	case "hero_meet_end_conversation":
		e.handleHeroMeetEndConversation(msg)
	default:
		// Commands like accept_quest, claim_quest, npc_interact etc.
		// are handled by their respective REST handlers for now.
		e.logger.Debug("unhandled client ws message", "type", msg.Type, "hero_id", msg.HeroID)
	}
}

func (e *Engine) handleNearbyHeroesRequest(msg IncomingMessage) {
	if e.heroStore == nil || e.sender == nil {
		return
	}
	var req model.NearbyHeroesRequestPayload
	if len(msg.Payload) > 0 {
		if err := json.Unmarshal(msg.Payload, &req); err != nil {
			e.sendError(msg.HeroID, "invalid_payload", "invalid request_nearby_heroes payload")
			return
		}
	}
	radius := 50.0
	if req.Radius > 0 {
		radius = req.Radius
	}
	if radius > 100 {
		radius = 100
	}
	limit := 5
	if req.Limit > 0 {
		limit = req.Limit
	}
	if limit > 5 {
		limit = 5
	}

	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()
	hero, err := e.heroStore.GetByID(ctx, msg.HeroID)
	if err != nil || hero == nil {
		e.sendError(msg.HeroID, "no_hero", "hero not found")
		return
	}

	posX, posY := hero.PositionX, hero.PositionY
	if wx, wy, ok := e.HeroWorldPositionForCombat(hero.ID); ok {
		posX, posY = wx, wy
	}

	nearby, err := e.heroStore.GetNearbyHeroes(ctx, hero.ID, posX, posY, radius, limit)
	if err != nil {
		e.logger.Error("failed to load nearby heroes", "hero_id", hero.ID, "error", err)
		e.sendError(msg.HeroID, "nearby_load_failed", "failed to load nearby heroes")
		return
	}
	e.OverlayResidentWorldPositionsOnNearby(nearby)

	summaries := make([]model.NearbyHeroSummary, 0, len(nearby))
	ids := make([]int64, 0, len(nearby))
	for _, h := range nearby {
		summaries = append(summaries, model.NearbyHeroSummary{
			ID:           h.ID,
			Name:         h.Name,
			Level:        h.Level,
			ModelVariant: h.ModelVariant,
			PositionX:    h.PositionX,
			PositionY:    h.PositionY,
		})
		ids = append(ids, h.ID)
	}

	e.sender.SendToHero(msg.HeroID, "nearby_heroes", model.NearbyHeroesPayload{
		Heroes: summaries,
	})

	if sub, ok := e.sender.(NearbySubscriptionManager); ok {
		sub.SetNearbySubscriptions(msg.HeroID, ids)
	}
}

// handleActivateBuff processes the activate_buff client command.
func (e *Engine) handleActivateBuff(msg IncomingMessage) {
	var payload model.ActivateBuffPayload
	if err := json.Unmarshal(msg.Payload, &payload); err != nil {
		e.sendError(msg.HeroID, "invalid_payload", "invalid activate_buff payload")
		return
	}

	e.mu.Lock()
	defer e.mu.Unlock()

	hm, ok := e.movements[msg.HeroID]
	if !ok {
		e.sendError(msg.HeroID, "no_hero", "hero not connected")
		return
	}

	bt, ok := model.ValidBuffType(payload.BuffType)
	if !ok {
		e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType))
		return
	}

	hero := hm.Hero
	now := time.Now()
	hero.RefreshSubscription(now)
	hero.EnsureBuffChargesPopulated(now)
	if err := hero.ConsumeBuffCharge(bt, now); err != nil {
		e.sendError(msg.HeroID, "buff_quota_exhausted", err.Error())
		return
	}

	ab := ApplyBuff(hero, bt, now)
	if ab == nil {
		hero.RefundBuffCharge(bt)
		e.sendError(msg.HeroID, "invalid_buff", fmt.Sprintf("unknown buff type: %s", payload.BuffType))
		return
	}

	hero.RefreshDerivedCombatStats(now)
	hm.refreshSpeed(now)

	if cs, ok := e.combats[msg.HeroID]; ok {
		cs.Hero = hero
	}

	if e.heroStore != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
			e.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err)
		}
	}

	if e.sender != nil {
		e.sender.SendToHero(msg.HeroID, "hero_state", hero)
		e.sender.SendToHero(msg.HeroID, "buff_applied", model.BuffAppliedPayload{
			BuffType:  payload.BuffType,
			Duration:  ab.Buff.Duration.Seconds(),
			Magnitude: ab.Buff.Magnitude,
		})
	}
}

// handleUsePotion processes the use_potion client command.
func (e *Engine) handleUsePotion(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()

	hm, ok := e.movements[msg.HeroID]
	if !ok {
		e.sendError(msg.HeroID, "no_hero", "hero not connected")
		return
	}
	hero := hm.Hero

	// Validate: hero is in combat, has potions, is alive.
	if hm.State != model.StateFighting {
		e.sendError(msg.HeroID, "not_fighting", "hero is not in combat")
		return
	}
	if hero.Potions <= 0 {
		e.sendError(msg.HeroID, "no_potions", "no potions available")
		return
	}
	if hero.HP <= 0 {
		e.sendError(msg.HeroID, "dead", "hero is dead")
		return
	}

	hero.Potions--
	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
	}

	hm.SyncToHero()

	// Keep combat state's hero pointer aligned with movement (authoritative live hero).
	if cs, ok := e.combats[msg.HeroID]; ok {
		cs.Hero = hm.Hero
	}

	if e.heroStore != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := e.heroStore.Save(ctx, hero); err != nil && e.logger != nil {
			e.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)
		}
	}

	if e.adventureLog != nil {
		e.adventureLog(msg.HeroID, model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseUsedHealingPotion,
				Args: map[string]any{"amount": healAmount},
			},
		})
	}

	// Emit as an attack-like event so the client shows it.
	cs, hasCombat := e.combats[msg.HeroID]
	enemyHP := 0
	if hasCombat {
		enemyHP = cs.Enemy.HP
	}

	if e.sender != nil {
		e.sender.SendToHero(msg.HeroID, "attack", model.AttackPayload{
			Source:  "potion",
			Damage:  -healAmount, // negative = heal
			HeroHP:  hero.HP,
			EnemyHP: enemyHP,
		})
		hero.EnsureGearMap()
		hero.RefreshDerivedCombatStats(time.Now())
		e.sender.SendToHero(msg.HeroID, "hero_state", hero)
	}
}

func (e *Engine) handleNPCAlmsAccept(msg IncomingMessage) {
	e.mu.RLock()
	h := e.npcAlmsHandler
	e.mu.RUnlock()
	if h == nil {
		e.sendError(msg.HeroID, "not_supported", "wandering merchant is not available")
		return
	}
	if err := h(context.Background(), msg.HeroID); err != nil {
		e.sendError(msg.HeroID, "alms_failed", err.Error())
	}
}

func (e *Engine) handleNPCAlmsDecline(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[msg.HeroID]
	if !ok {
		return
	}
	if hm.WanderingMerchantDeadline.IsZero() {
		return
	}
	hm.WanderingMerchantDeadline = time.Time{}
	if e.sender != nil {
		e.sender.SendToHero(msg.HeroID, "npc_encounter_end", model.NPCEncounterEndPayload{Reason: "declined"})
	}
}

// handleRevive processes the revive client command (same rules as POST /api/v1/hero/revive;
// both paths use the resident in-memory hero only).
func (e *Engine) handleRevive(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()

	hm, ok := e.movements[msg.HeroID]
	if !ok {
		e.sendError(msg.HeroID, "no_hero", "hero not connected")
		return
	}
	hero := hm.Hero

	if !IsEffectivelyDead(hero) {
		e.sendError(msg.HeroID, "not_dead", "hero is not dead")
		return
	}
	if err := CheckPlayerReviveQuota(hero); err != nil {
		e.sendError(msg.HeroID, "revive_quota", "free revive limit reached (subscribe for unlimited revives)")
		return
	}

	ApplyHeroReviveMechanical(hero)
	ApplyPlayerReviveProgressCounters(hero)

	// Persist revive to DB immediately so disconnect doesn't revert it.
	if e.heroStore != nil {
		if err := e.heroStore.Save(context.Background(), hero); err != nil {
			e.logger.Error("failed to save hero after revive", "hero_id", hero.ID, "error", err)
		}
	}

	if e.adventureLog != nil {
		e.adventureLog(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHeroRevived}})
	}

	e.applyResidentReviveSyncLocked(hero)
}

// sendError sends an error envelope to a hero.
func (e *Engine) sendError(heroID int64, code, message string) {
	if e.sender != nil {
		e.sender.SendToHero(heroID, "error", model.ErrorPayload{Code: code, Message: message})
	}
}

// RegisterHeroMovement creates a HeroMovement for an online hero and sends initial state.
// Called when a WS client connects.
func (e *Engine) RegisterHeroMovement(hero *model.Hero) {
	if hero == nil {
		return
	}
	e.mergeTownSessionFromRedis(hero)

	e.mu.Lock()
	defer e.mu.Unlock()

	if e.roadGraph == nil {
		e.logger.Warn("cannot register movement: road graph not loaded", "hero_id", hero.ID)
		return
	}

	now := time.Now()

	// Reconnect while the previous socket is still tearing down: keep live movement so we
	// do not replace (x,y) and route with a stale DB snapshot.
	if existing, ok := e.movements[hero.ID]; ok {
		existing.Hero.WsDisconnectedAt = hero.WsDisconnectedAt
		existing.Hero.EnsureGearMap()
		existing.Hero.RefreshDerivedCombatStats(now)
		e.logger.Info("hero movement reattached (existing session)",
			"hero_id", hero.ID,
			"state", existing.State,
			"pos_x", existing.CurrentX,
			"pos_y", existing.CurrentY,
		)
		if e.sender != nil {
			e.sender.SendToHero(hero.ID, "hero_state", existing.Hero)
			if route := existing.RoutePayload(); route != nil {
				e.sender.SendToHero(hero.ID, "route_assigned", route)
			}
			if cs, ok := e.combats[hero.ID]; ok {
				e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{
					Enemy: enemyToInfo(&cs.Enemy),
				})
			}
			e.pushHeroMeetIfActiveLocked(hero.ID)
		}
		return
	}

	hm := NewHeroMovement(hero, e.roadGraph, now)
	e.movements[hero.ID] = hm
	hm.MarkTownPausePersisted(hm.townPausePersistSignature())
	hm.SyncToHero()

	// DB said fighting but engine has no combat (e.g. after restart): attach a new encounter.
	if hm.State == model.StateFighting {
		if _, exists := e.combats[hero.ID]; !exists {
			en := PickEnemyForHero(hero)
			if en.Slug != "" {
				e.startCombatLocked(hm.Hero, &en)
			} else {
				hm.State = model.StateWalking
				hm.Hero.State = model.StateWalking
			}
		}
	}

	e.logger.Info("hero movement registered",
		"hero_id", hero.ID,
		"state", hm.State,
		"pos_x", hm.CurrentX,
		"pos_y", hm.CurrentY,
	)

	// Send initial state via WS.
	if e.sender != nil {
		hm.Hero.EnsureGearMap()
		hm.Hero.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)

		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(hero.ID, "route_assigned", route)
		}

		// If mid-combat, send combat_start so client can resume UI.
		if cs, ok := e.combats[hero.ID]; ok {
			e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{
				Enemy: enemyToInfo(&cs.Enemy),
			})
		}
		e.pushHeroMeetIfActiveLocked(hero.ID)
	}
}

// HeroSocketDetached persists hero state on every WS disconnect. Movement and combat stay in the engine
// so the world keeps simulating; disconnectedAt is stored on the in-memory hero for offline digest timing.
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool, disconnectedAt time.Time) {
	e.mu.Lock()
	hm, ok := e.movements[heroID]
	if ok {
		hm.SyncToHero()
		if lastConnection && !disconnectedAt.IsZero() && hm.Hero != nil {
			t := disconnectedAt
			hm.Hero.WsDisconnectedAt = &t
		}
	}
	var heroSnap *model.Hero
	if ok {
		heroSnap = hm.Hero
	}
	e.mu.Unlock()

	if ok && e.heroStore != nil && heroSnap != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := e.heroStore.Save(ctx, heroSnap); err != nil {
			e.logger.Error("failed to save hero on ws disconnect", "hero_id", heroID, "error", err)
		} else {
			e.logger.Info("hero state persisted on ws disconnect",
				"hero_id", heroID,
				"last_connection", lastConnection,
			)
			e.syncTownSessionRedisFromHero(heroID, heroSnap)
		}
	}
}

// Status returns a snapshot of the engine's current operational state.
func (e *Engine) Status() EngineStatus {
	e.mu.RLock()
	defer e.mu.RUnlock()
	var uptimeMs int64
	if e.running {
		uptimeMs = time.Since(e.startedAt).Milliseconds()
	}
	return EngineStatus{
		Running:         e.running,
		TickRate:        e.tickRate,
		ActiveCombats:   len(e.combats),
		ActiveMovements: len(e.movements),
		UptimeMs:        uptimeMs,
		TimePaused:      e.timePaused,
	}
}

// TickRate returns the combat tick rate configured for the engine.
func (e *Engine) TickRate() time.Duration {
	e.mu.RLock()
	defer e.mu.RUnlock()
	return e.tickRate
}

// ApplyAdminTeleportTown places an online hero at the given town (same state as walking arrival).
func (e *Engine) ApplyAdminTeleportTown(heroID int64, townID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok || e.roadGraph == nil {
		return nil, false
	}
	now := time.Now()
	if err := hm.AdminPlaceInTown(e.roadGraph, townID, now); err != nil {
		return nil, false
	}
	delete(e.combats, heroID)
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
		town := e.roadGraph.Towns[hm.CurrentTownID]
		if town != nil {
			npcInfos := e.roadGraph.TownNPCInfos(hm.CurrentTownID)
			buildingInfos := e.roadGraph.TownBuildingInfos(hm.CurrentTownID)
			var restMs int64
			if hm.State == model.StateResting {
				restMs = hm.RestUntil.Sub(now).Milliseconds()
			}
			e.sender.SendToHero(heroID, "town_enter", model.TownEnterPayload{
				TownID:         town.ID,
				TownName:       town.Name,
				Biome:          town.Biome,
				NPCs:           npcInfos,
				Buildings:      buildingInfos,
				RestDurationMs: restMs,
			})
		}
	}
	e.applyVisitTownQuestProgress(h)
	return h, true
}

// ApplyAdminForceLeaveTown ends resting or in-town pause, assigns a new road, and notifies the client.
func (e *Engine) ApplyAdminForceLeaveTown(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok || e.roadGraph == nil {
		return nil, false
	}
	if hm.State != model.StateResting && hm.State != model.StateInTown {
		return nil, false
	}
	now := time.Now()
	hm.LeaveTown(e.roadGraph, now)
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
		e.sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(heroID, "route_assigned", route)
		}
	}
	if e.heroStore != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
			e.logger.Error("persist hero after force leave town", "hero_id", h.ID, "error", err)
		}
	}
	return h, true
}

// ApplyAdminTownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (hero must be online).
func (e *Engine) ApplyAdminTownTourApproachNPC(heroID, npcID int64) (*model.Hero, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok || e.roadGraph == nil {
		return nil, fmt.Errorf("hero not online or graph missing")
	}
	now := time.Now()
	if err := hm.AdminTownTourApproachNPC(e.roadGraph, npcID, now); err != nil {
		return nil, err
	}
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		NotifyTownTourClients(e.sender, heroID, hm, e.roadGraph, now)
	}
	return h, nil
}

// ApplyAdminStartRest puts an online hero into town-style rest at the current location.
func (e *Engine) ApplyAdminStartRest(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok || e.roadGraph == nil {
		return nil, false
	}
	now := time.Now()
	if !hm.AdminStartRest(now, e.roadGraph) {
		return nil, false
	}
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
	}
	return h, true
}

// ApplyAdminStartRoadsideRest puts an online hero into roadside rest at the current road position.
func (e *Engine) ApplyAdminStartRoadsideRest(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok {
		return nil, false
	}
	now := time.Now()
	if !hm.AdminStartRoadsideRest(now) {
		return nil, false
	}
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
		e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
	}
	return h, true
}

// ApplyAdminStopRest exits a hero from non-town rest (roadside / adventure-inline) back to walking.
func (e *Engine) ApplyAdminStopRest(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok {
		return nil, false
	}
	now := time.Now()
	if !hm.AdminStopRest(now) {
		return nil, false
	}
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
		e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(heroID, "route_assigned", route)
		}
	}
	return h, true
}

// ApplyAdminStopAnyRest ends whichever rest or town pause applies: first roadside/adventure-inline
// (must not use LeaveTown — that would corrupt excursion state), otherwise town rest or in-town flow.
func (e *Engine) ApplyAdminStopAnyRest(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok || e.roadGraph == nil {
		return nil, false
	}
	now := time.Now()
	if hm.AdminStopRest(now) {
		hm.SyncToHero()
		h := hm.Hero
		if e.sender != nil {
			h.EnsureGearMap()
			h.RefreshDerivedCombatStats(now)
			e.sender.SendToHero(heroID, "hero_state", h)
			e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
			if route := hm.RoutePayload(); route != nil {
				e.sender.SendToHero(heroID, "route_assigned", route)
			}
		}
		return h, true
	}
	if hm.State != model.StateResting && hm.State != model.StateInTown {
		return nil, false
	}
	hm.LeaveTown(e.roadGraph, now)
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
		e.sender.SendToHero(heroID, "town_exit", model.TownExitPayload{})
		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(heroID, "route_assigned", route)
		}
	}
	if e.heroStore != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer cancel()
		if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
			e.logger.Error("persist hero after admin stop any rest (leave town)", "hero_id", heroID, "error", err)
		}
	}
	return h, true
}

// ApplyAdminLethalEnemyKill applies a killing blow from the hero and runs the normal victory path (rewards, WS, persist).
func (e *Engine) ApplyAdminLethalEnemyKill(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	cs, ok := e.combats[heroID]
	if !ok || cs == nil || cs.Hero == nil || !cs.Enemy.IsAlive() {
		return nil, false
	}
	now := time.Now()
	dmg := cs.Enemy.HP
	cs.Enemy.HP = 0
	combatEvt := model.CombatEvent{
		Type:      "attack",
		HeroID:    heroID,
		Damage:    dmg,
		Source:    "hero",
		Outcome:   attackOutcomeHit,
		HeroHP:    cs.Hero.HP,
		EnemyHP:   0,
		Timestamp: now,
	}
	e.emitEvent(combatEvt)
	e.logCombatAttack(cs, combatEvt)
	if e.sender != nil {
		e.sender.SendToHero(heroID, "attack", model.AttackPayload{
			Source:  combatEvt.Source,
			Damage:  combatEvt.Damage,
			IsCrit:  false,
			Outcome: combatEvt.Outcome,
			HeroHP:  combatEvt.HeroHP,
			EnemyHP: 0,
		})
	}
	e.handleEnemyDeath(cs, now)
	if hm, ok := e.movements[heroID]; ok && hm.Hero != nil {
		return hm.Hero, true
	}
	if cs.Hero != nil {
		return cs.Hero, true
	}
	return nil, false
}

// ApplyAdminStartExcursion forces an online hero into a mini-adventure session on the current road.
func (e *Engine) ApplyAdminStartExcursion(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok {
		return nil, false
	}
	now := time.Now()
	if !hm.AdminStartExcursion(now) {
		return nil, false
	}
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "hero_state", h)
		e.sender.SendToHero(heroID, "excursion_start", model.ExcursionStartPayload{
			DepthWorldUnits: hm.Excursion.DepthWorldUnits,
		})
		e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
	}
	return h, true
}

// ApplyAdminStopExcursion forces the return leg of an active excursion (admin "stop adventure").
func (e *Engine) ApplyAdminStopExcursion(heroID int64) (*model.Hero, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[heroID]
	if !ok {
		return nil, false
	}
	now := time.Now()
	if !hm.AdminStopExcursion(now) {
		return nil, false
	}
	hm.SyncToHero()
	h := hm.Hero
	if e.sender != nil {
		h.EnsureGearMap()
		h.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(heroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
		e.sender.SendToHero(heroID, "hero_state", h)
		e.sender.SendToHero(heroID, "hero_move", hm.MovePayload(now))
		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(heroID, "route_assigned", route)
		}
	}
	return h, true
}

// ListActiveCombats returns a snapshot of all active combat sessions.
func (e *Engine) ListActiveCombats() []CombatInfo {
	e.mu.RLock()
	defer e.mu.RUnlock()
	out := make([]CombatInfo, 0, len(e.combats))
	for _, cs := range e.combats {
		heroHP := 0
		if cs.Hero != nil {
			heroHP = cs.Hero.HP
		}
		out = append(out, CombatInfo{
			HeroID:    cs.HeroID,
			EnemyName: cs.Enemy.Name,
			EnemyType: cs.Enemy.Slug,
			HeroHP:    heroHP,
			EnemyHP:   cs.Enemy.HP,
			StartedAt: cs.StartedAt,
		})
	}
	return out
}

// StartCombat registers a new combat encounter between a hero and an enemy.
func (e *Engine) StartCombat(hero *model.Hero, enemy *model.Enemy) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.startCombatLocked(hero, enemy)
}

// startCombatLocked is the internal version that assumes the lock is already held.
func (e *Engine) startCombatLocked(hero *model.Hero, enemy *model.Enemy) {
	now := time.Now()

	if _, exists := e.combats[hero.ID]; exists {
		e.logger.Debug("skip combat start: already in combat", "hero_id", hero.ID)
		return
	}

	if hm, ok := e.movements[hero.ID]; ok {
		if hm.State == model.StateResting || hm.State == model.StateInTown {
			e.logger.Debug("skip combat start: hero in town", "hero_id", hero.ID)
			return
		}
	}
	if e.roadGraph != nil {
		var wx, wy float64
		if hm, ok := e.movements[hero.ID]; ok && hm.Hero != nil {
			ox, oy := hm.displayOffset(now)
			wx, wy = hm.CurrentX+ox, hm.CurrentY+oy
		}
		if e.roadGraph.HeroInTownAt(wx, wy) {
			e.logger.Debug("skip combat start: hero inside town radius", "hero_id", hero.ID)
			return
		}
	}

	cs := &model.CombatState{
		HeroID:          hero.ID,
		Hero:            hero,
		Enemy:           *enemy,
		HeroNextAttack:  now.Add(attackInterval(hero.EffectiveSpeed())),
		EnemyNextAttack: now.Add(attackIntervalEnemy(enemy.Speed)),
		StartedAt:       now,
		LastTickAt:      now,
	}
	if tmpl, ok := model.EnemyBySlug(enemy.Slug); ok {
		baseScaled, afterGlobal := EnemyEncounterStatStages(tmpl, enemy.Level)
		cs.EnemyStatsBasePreEncounterMult = &model.EncounterCombatStatsSnapshot{
			MaxHP:   baseScaled.MaxHP,
			Attack:  baseScaled.Attack,
			Defense: baseScaled.Defense,
		}
		cs.EnemyStatsAfterGlobalEncounterMult = &model.EncounterCombatStatsSnapshot{
			MaxHP:   afterGlobal.MaxHP,
			Attack:  afterGlobal.Attack,
			Defense: afterGlobal.Defense,
		}
	}
	e.combats[hero.ID] = cs

	hero.State = model.StateFighting

	// Update movement state.
	if hm, ok := e.movements[hero.ID]; ok {
		hm.StartFighting()
		hm.SyncToHero()
	}

	heap.Push(&e.queue, &model.AttackEvent{
		NextAttackAt: cs.HeroNextAttack,
		IsHero:       true,
		CombatID:     hero.ID,
	})
	heap.Push(&e.queue, &model.AttackEvent{
		NextAttackAt: cs.EnemyNextAttack,
		IsHero:       false,
		CombatID:     hero.ID,
	})

	// New: send typed combat_start envelope.
	if e.sender != nil {
		e.sender.SendToHero(hero.ID, "combat_start", model.CombatStartPayload{
			Enemy: enemyToInfo(enemy),
		})
	}

	if e.adventureLog != nil {
		e.adventureLog(hero.ID, model.AdventureLogLine{
			Event: &model.AdventureLogEvent{
				Code: model.LogPhraseEncounteredEnemy,
				Args: map[string]any{"enemyType": enemy.Slug},
			},
		})
	}

	if e.logger != nil {
		mult := EnemyEncounterMultiplierBreakdownForHero(hero)
		e.logger.Info("combat started",
			"hero_id", hero.ID,
			"hero_level", hero.Level,
			"enemy_slug", enemy.Slug,
			"enemy_name", enemy.Name,
			"enemy_level", enemy.Level,
			"enemy_hp", enemy.HP,
			"enemy_max_hp", enemy.MaxHP,
			"enemy_attack", enemy.Attack,
			"enemy_defense", enemy.Defense,
			"enemy_speed", enemy.Speed,
			"enemy_crit_chance", enemy.CritChance,
			"enemy_is_elite", enemy.IsElite,
			"enemy_xp_reward", enemy.XPReward,
			"enemy_gold_reward", enemy.GoldReward,
			"mult_global_encounter", mult.GlobalEncounterStatMultiplier,
			"mult_unequipped_config", mult.UnequippedHeroStatMultiplier,
			"mult_unequipped_applied", mult.UnequippedScalingApplied,
		)
	}
}

// StopCombat removes a combat session.
func (e *Engine) StopCombat(heroID int64) {
	e.mu.Lock()
	defer e.mu.Unlock()
	delete(e.combats, heroID)
}

func (e *Engine) SyncHeroState(hero *model.Hero) {
	if hero == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	if e.sender == nil {
		return
	}
	hero.EnsureGearMap()
	e.sender.SendToHero(hero.ID, "hero_state", hero)
}

// ApplyAdminHeroSnapshot merges a persisted hero (e.g. after admin set-hp) into the live
// movement session and pushes hero_state (+ route_assigned when a new road was bound).
func (e *Engine) ApplyAdminHeroSnapshot(hero *model.Hero) {
	if hero == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()

	hm, ok := e.movements[hero.ID]
	if !ok {
		if e.sender != nil {
			now := time.Now()
			hero.EnsureGearMap()
			hero.RefreshDerivedCombatStats(now)
			e.sender.SendToHero(hero.ID, "hero_state", hero)
		}
		return
	}

	now := time.Now()
	*hm.Hero = *hero
	hm.State = hero.State
	hm.CurrentX = hero.PositionX
	hm.CurrentY = hero.PositionY
	hm.LastMoveTick = now
	hm.refreshSpeed(now)

	routeAssigned := false
	if e.roadGraph != nil && hm.State == model.StateWalking && hm.Road == nil {
		hm.pickDestination(e.roadGraph)
		hm.assignRoad(e.roadGraph, false)
		routeAssigned = true
	}

	if e.sender == nil {
		return
	}
	hm.Hero.EnsureGearMap()
	hm.Hero.RefreshDerivedCombatStats(now)
	e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
	if routeAssigned {
		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(hero.ID, "route_assigned", route)
		}
	}
}

// ApplyPersistedHeroSnapshot copies a DB-persisted hero onto the live movement session and pushes hero_state.
func (e *Engine) ApplyPersistedHeroSnapshot(hero *model.Hero) {
	if hero == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()

	hm, ok := e.movements[hero.ID]
	if ok {
		now := time.Now()
		*hm.Hero = *hero
		hm.Hero.EnsureGearMap()
		hm.Hero.RefreshDerivedCombatStats(now)
	}
	if e.sender == nil {
		return
	}
	if ok {
		e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
	} else {
		hero.EnsureGearMap()
		e.sender.SendToHero(hero.ID, "hero_state", hero)
	}
}

// ApplyHeroAlmsUpdate merges a persisted hero after wandering merchant rewards into
// the live movement session and pushes hero_state when a sender is configured.
func (e *Engine) ApplyHeroAlmsUpdate(hero *model.Hero) {
	if hero == nil {
		return
	}
	e.mu.Lock()
	if hm, ok := e.movements[hero.ID]; ok {
		hm.WanderingMerchantDeadline = time.Time{}
	}
	e.mu.Unlock()

	e.ApplyPersistedHeroSnapshot(hero)
}

// applyResidentReviveSyncLocked clears combat, merges a persisted hero into the live session,
// and pushes hero_state + hero_revived. Caller must hold e.mu.
func (e *Engine) applyResidentReviveSyncLocked(hero *model.Hero) {
	if hero == nil {
		return
	}

	delete(e.combats, hero.ID)

	hm, ok := e.movements[hero.ID]
	if !ok {
		return
	}

	now := time.Now()
	*hm.Hero = *hero
	hm.CurrentX = hero.PositionX
	hm.CurrentY = hero.PositionY
	hm.State = hero.State
	hm.TownNPCQueue = nil
	hm.NextTownNPCRollAt = time.Time{}
	hm.TownLastNPCLingerUntil = time.Time{}
	hm.LastMoveTick = now
	hm.refreshSpeed(now)

	routeAssigned := false
	if hm.State == model.StateWalking && hm.Road == nil && e.roadGraph != nil {
		hm.pickDestination(e.roadGraph)
		hm.assignRoad(e.roadGraph, false)
		routeAssigned = true
	}

	if e.sender == nil {
		return
	}
	hm.Hero.EnsureGearMap()
	hm.Hero.RefreshDerivedCombatStats(now)
	// Full snapshot first so clients never briefly drop gear after hero_revived.
	e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
	e.sender.SendToHero(hero.ID, "hero_revived", model.HeroRevivedPayload{HP: hm.Hero.HP})
	if routeAssigned {
		if route := hm.RoutePayload(); route != nil {
			e.sender.SendToHero(hero.ID, "route_assigned", route)
		}
	}
}

// ApplyAdminHeroRevive updates the live engine state after POST /admin/.../revive persisted
// the hero. Clears combat, copies the saved snapshot onto the in-memory hero (if online),
// restores movement/route when needed, and pushes WS events so the client matches the DB.
func (e *Engine) ApplyAdminHeroRevive(hero *model.Hero) {
	if hero == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	e.applyResidentReviveSyncLocked(hero)
}

// ApplyAdminHeroDeath merges a persisted dead hero after POST /admin/.../force-death, clears combat,
// updates live movement (if any), and pushes hero_state; optionally hero_died for clients.
func (e *Engine) ApplyAdminHeroDeath(hero *model.Hero, sendDiedEvent bool) {
	if hero == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()

	delete(e.combats, hero.ID)

	hm, ok := e.movements[hero.ID]
	if !ok {
		if e.sender != nil {
			now := time.Now()
			hero.EnsureGearMap()
			hero.RefreshDerivedCombatStats(now)
			e.sender.SendToHero(hero.ID, "hero_state", hero)
			if sendDiedEvent {
				e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{
					KilledBy: "admin",
				})
			}
		}
		return
	}

	now := time.Now()
	hm.TownNPCQueue = nil
	hm.NextTownNPCRollAt = time.Time{}
	hm.TownLastNPCLingerUntil = time.Time{}
	*hm.Hero = *hero
	hm.State = model.StateDead
	hm.Hero.State = model.StateDead
	hm.Hero.HP = 0
	hm.Die()

	if e.sender == nil {
		return
	}
	hm.Hero.EnsureGearMap()
	hm.Hero.RefreshDerivedCombatStats(now)
	e.sender.SendToHero(hero.ID, "hero_state", hm.Hero)
	if sendDiedEvent {
		e.sender.SendToHero(hero.ID, "hero_died", model.HeroDiedPayload{
			KilledBy: "admin",
		})
	}
}

// GetCombat returns the current combat state for a hero, if any.
func (e *Engine) GetCombat(heroID int64) (*model.CombatState, bool) {
	e.mu.RLock()
	defer e.mu.RUnlock()
	cs, ok := e.combats[heroID]
	return cs, ok
}

// processCombatTick is the combat processing tick (typically 100ms cadence).
func (e *Engine) processCombatTick(now time.Time) {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.processCombatTickLocked(now)
}

// processCombatTickLocked runs combat logic; caller must hold e.mu.
func (e *Engine) processCombatTickLocked(now time.Time) {
	// Heroes resting or touring town must not keep fighting in the background.
	var purgeCombat []int64
	for heroID := range e.combats {
		if hm, ok := e.movements[heroID]; ok {
			if hm.State == model.StateResting || hm.State == model.StateInTown {
				purgeCombat = append(purgeCombat, heroID)
			}
		}
	}
	for _, heroID := range purgeCombat {
		delete(e.combats, heroID)
		if hm, ok := e.movements[heroID]; ok {
			hm.Hero.State = hm.State
		}
	}

	// Apply periodic effects (debuff DoT, enemy regen, summon damage) for all active combats.
	for heroID, cs := range e.combats {
		if cs.Hero == nil {
			continue
		}
		tickDur := now.Sub(cs.LastTickAt)
		if tickDur <= 0 {
			continue
		}

		dotDmg := ProcessDebuffDamage(cs.Hero, tickDur, now)
		regenHealed := ProcessEnemyRegen(&cs.Enemy, tickDur, &cs.EnemyRegenRemainder)
		summonDmg := ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
		cs.LastTickAt = now
		if e.sender != nil {
			if dotDmg > 0 {
				e.sender.SendToHero(heroID, "attack", model.AttackPayload{
					Source:  "dot",
					Damage:  dotDmg,
					HeroHP:  cs.Hero.HP,
					EnemyHP: cs.Enemy.HP,
				})
			}
			if regenHealed > 0 {
				e.sender.SendToHero(heroID, "enemy_regen", model.EnemyRegenPayload{
					Amount:  regenHealed,
					EnemyHP: cs.Enemy.HP,
				})
			}
			if summonDmg > 0 {
				e.sender.SendToHero(heroID, "attack", model.AttackPayload{
					Source:  "summon",
					Damage:  summonDmg,
					HeroHP:  cs.Hero.HP,
					EnemyHP: cs.Enemy.HP,
				})
			}
		}

		if CheckDeath(cs.Hero, now) {
			e.emitEvent(model.CombatEvent{
				Type: "death", HeroID: heroID, Source: "hero",
				HeroHP: 0, EnemyHP: cs.Enemy.HP, Timestamp: now,
			})
			if e.sender != nil {
				e.sender.SendToHero(heroID, "hero_died", model.HeroDiedPayload{
					KilledBy: cs.Enemy.Name,
				})
			}
			// Update movement state to dead.
			if hm, ok := e.movements[heroID]; ok {
				hm.Die()
			}
			e.persistHeroDeathLocked(heroID, cs.Hero)
			dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
			e.applyOfflineDigest(dctx, heroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
			cancel()
			delete(e.combats, heroID)
		}
	}

	// Process all attacks that are due.
	for e.queue.Len() > 0 {
		next := e.queue[0]
		if next.NextAttackAt.After(now) {
			break
		}
		evt := heap.Pop(&e.queue).(*model.AttackEvent)

		cs, ok := e.combats[evt.CombatID]
		if !ok {
			continue // combat ended
		}

		e.processAttackEvent(evt, cs, now)
	}
}

func (e *Engine) processAttackEvent(evt *model.AttackEvent, cs *model.CombatState, now time.Time) {
	if evt.IsHero {
		e.processHeroAttack(cs, now)
	} else {
		e.processEnemyAttack(cs, now)
	}
}

// persistHeroDeathLocked writes the dead hero snapshot immediately so DB state
// never lags behind the live in-memory death state.
// Caller must hold e.mu.
func (e *Engine) persistHeroDeathLocked(heroID int64, hero *model.Hero) {
	if e.heroStore == nil || hero == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	err := e.heroStore.Save(ctx, hero)
	cancel()
	if err != nil && e.logger != nil {
		e.logger.Error("persist hero after death", "hero_id", heroID, "error", err)
	}
}

// sendDebuffAppliedForString pushes debuff_applied when a debuff proc string is non-empty.
func (e *Engine) sendDebuffAppliedForString(heroID int64, debuffTypeStr string, now time.Time) {
	if e.sender == nil || debuffTypeStr == "" {
		return
	}
	dt, ok := model.ValidDebuffType(debuffTypeStr)
	if !ok {
		return
	}
	def, ok := model.DebuffDefinition(dt)
	if !ok {
		return
	}
	e.sender.SendToHero(heroID, "debuff_applied", model.DebuffAppliedPayload{
		DebuffType: string(dt),
		DurationMs: def.Duration.Milliseconds(),
		Magnitude:  def.Magnitude,
		ExpiresAt:  now.Add(def.Duration),
	})
}

// rescheduleHeroAttackAfterSlowDebuff stretches the hero's pending swing when attack speed drops (freeze, ice_slow).
func (e *Engine) rescheduleHeroAttackAfterSlowDebuff(cs *model.CombatState, speedBefore float64, now time.Time) {
	if cs.Hero == nil {
		return
	}
	speedAfter := cs.Hero.EffectiveSpeedAt(now)
	if speedAfter >= speedBefore || speedBefore <= 0 {
		return
	}
	oldInt := attackInterval(speedBefore)
	newInt := attackInterval(speedAfter)
	if oldInt <= 0 || newInt <= 0 {
		return
	}
	ratio := float64(newInt) / float64(oldInt)
	if cs.HeroNextAttack.After(now) {
		remaining := cs.HeroNextAttack.Sub(now)
		scaled := time.Duration(float64(remaining) * ratio)
		cs.HeroNextAttack = now.Add(scaled)
	} else {
		cs.HeroNextAttack = now.Add(newInt)
	}
	for i := range e.queue {
		if e.queue[i].CombatID == cs.HeroID && e.queue[i].IsHero {
			e.queue[i].NextAttackAt = cs.HeroNextAttack
			heap.Fix(&e.queue, i)
			return
		}
	}
}

func (e *Engine) processHeroAttack(cs *model.CombatState, now time.Time) {
	if cs.Hero == nil {
		e.logger.Error("processHeroAttack: nil hero reference", "hero_id", cs.HeroID)
		return
	}

	combatEvt := ProcessAttack(cs.Hero, &cs.Enemy, now)
	e.emitEvent(combatEvt)
	e.logCombatAttack(cs, combatEvt)

	// Push attack envelope.
	if e.sender != nil {
		e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
			Source:        combatEvt.Source,
			Damage:        combatEvt.Damage,
			IsCrit:        combatEvt.IsCrit,
			Outcome:       combatEvt.Outcome,
			HeroHP:        combatEvt.HeroHP,
			EnemyHP:       combatEvt.EnemyHP,
			DebuffApplied: combatEvt.DebuffApplied,
		})
		e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now)
	}

	if !cs.Enemy.IsAlive() {
		e.handleEnemyDeath(cs, now)
		return
	}

	// Reschedule hero's next attack using actual effective speed.
	cs.HeroNextAttack = now.Add(attackInterval(cs.Hero.EffectiveSpeed()))
	heap.Push(&e.queue, &model.AttackEvent{
		NextAttackAt: cs.HeroNextAttack,
		IsHero:       true,
		CombatID:     cs.HeroID,
	})
}

func (e *Engine) processEnemyAttack(cs *model.CombatState, now time.Time) {
	if cs.Hero == nil {
		e.logger.Error("processEnemyAttack: nil hero reference", "hero_id", cs.HeroID)
		return
	}

	speedBefore := cs.Hero.EffectiveSpeedAt(now)
	combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
	e.emitEvent(combatEvt)
	e.logCombatAttack(cs, combatEvt)

	// Push attack envelope.
	if e.sender != nil {
		e.sender.SendToHero(cs.HeroID, "attack", model.AttackPayload{
			Source:        combatEvt.Source,
			Damage:        combatEvt.Damage,
			IsCrit:        combatEvt.IsCrit,
			Outcome:       combatEvt.Outcome,
			HeroHP:        combatEvt.HeroHP,
			EnemyHP:       combatEvt.EnemyHP,
			DebuffApplied: combatEvt.DebuffApplied,
		})
		e.sendDebuffAppliedForString(cs.HeroID, combatEvt.DebuffApplied, now)
	}
	e.rescheduleHeroAttackAfterSlowDebuff(cs, speedBefore, now)

	// Check if the hero died from this attack.
	if CheckDeath(cs.Hero, now) {
		e.emitEvent(model.CombatEvent{
			Type:      "death",
			HeroID:    cs.HeroID,
			Source:    "hero",
			HeroHP:    0,
			EnemyHP:   cs.Enemy.HP,
			Timestamp: now,
		})
		if e.sender != nil {
			e.sender.SendToHero(cs.HeroID, "hero_died", model.HeroDiedPayload{
				KilledBy: cs.Enemy.Name,
			})
		}
		if hm, ok := e.movements[cs.HeroID]; ok {
			hm.Die()
		}
		e.persistHeroDeathLocked(cs.HeroID, cs.Hero)
		dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		e.applyOfflineDigest(dctx, cs.HeroID, cs.Hero, now, storage.OfflineDigestDelta{Deaths: 1})
		cancel()
		delete(e.combats, cs.HeroID)

		e.logger.Info("hero died",
			"hero_id", cs.HeroID,
			"enemy", cs.Enemy.Name,
		)
		return
	}

	// Reschedule enemy's next attack.
	cs.EnemyNextAttack = now.Add(attackIntervalEnemy(cs.Enemy.Speed))
	heap.Push(&e.queue, &model.AttackEvent{
		NextAttackAt: cs.EnemyNextAttack,
		IsHero:       false,
		CombatID:     cs.HeroID,
	})
}

func (e *Engine) logCombatAttack(cs *model.CombatState, evt model.CombatEvent) {
	if e.adventureLog == nil || cs == nil {
		return
	}
	args := map[string]any{
		"damage":    evt.Damage,
		"isCrit":    evt.IsCrit,
		"enemyType": cs.Enemy.Slug,
	}
	if evt.DebuffApplied != "" {
		args["debuffType"] = evt.DebuffApplied
	}
	e.adventureLog(cs.HeroID, model.AdventureLogLine{
		Event: &model.AdventureLogEvent{
			Code: combatLogPhraseKey(evt.Source, evt.Outcome),
			Args: args,
		},
	})
}

func (e *Engine) handleEnemyDeath(cs *model.CombatState, now time.Time) {
	hero := cs.Hero
	enemy := &cs.Enemy
	oldLevel := hero.Level

	// Rewards (XP, gold, loot, level-ups) are handled by the onEnemyDeath callback
	// via processVictoryRewards -- the single source of truth.
	var victoryDrops []model.LootDrop
	if e.onEnemyDeath != nil {
		victoryDrops = e.onEnemyDeath(hero, enemy, now)
	}

	dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	e.applyOfflineDigest(dctx, cs.HeroID, hero, now, storage.OfflineDigestDelta{
		MonstersKilled: 1,
		XPGained:       enemy.XPReward,
		GoldGained:     model.SumGoldFromLootDrops(victoryDrops),
		LevelsGained:   hero.Level - oldLevel,
		LootAppend:     NonGoldLootForDigest(victoryDrops),
	})
	cancel()

	e.emitEvent(model.CombatEvent{
		Type:      "combat_end",
		HeroID:    cs.HeroID,
		Source:    "system",
		EnemyHP:   0,
		Timestamp: now,
	})

	leveledUp := hero.Level > oldLevel

	delete(e.combats, cs.HeroID)

	// Resume walking before hero_state so positions match hero_move.
	if hm, ok := e.movements[cs.HeroID]; ok {
		hm.ResumeWalking(now)
		prevExcPhase := hm.Excursion.Phase
		hm.TryAdventureReturnAfterCombat(now)
		if e.sender != nil && hm.Excursion.Phase != prevExcPhase && hm.Excursion.Phase == model.ExcursionReturn {
			e.sender.SendToHero(cs.HeroID, "excursion_phase", model.ExcursionPhasePayload{Phase: string(hm.Excursion.Phase)})
			e.sender.SendToHero(cs.HeroID, "hero_move", hm.MovePayload(now))
		}
		hm.SyncToHero()
	}

	// Persist progression (XP, gold, level/stats after level-up, inventory, world state)
	// so a disconnect or crash does not roll back combat rewards.
	if e.heroStore != nil {
		ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
		err := e.heroStore.Save(ctx, hero)
		cancel()
		if err != nil && e.logger != nil {
			e.logger.Error("persist hero after combat victory", "hero_id", hero.ID, "error", err)
		}
	}

	// Push typed combat_end envelope (gold from loot rolls, not enemy template column).
	if e.sender != nil {
		goldFromLoot := model.SumGoldFromLootDrops(victoryDrops)
		e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{
			XPGained:   enemy.XPReward,
			GoldGained: goldFromLoot,
			Loot:       model.LootDropsToLootItems(victoryDrops),
			LeveledUp:  leveledUp,
			NewLevel:   hero.Level,
		})

		if leveledUp {
			e.sender.SendToHero(cs.HeroID, "level_up", model.LevelUpPayload{
				NewLevel: hero.Level,
			})
		}
		hero.EnsureGearMap()
		hero.EnsureInventorySlice()
		hero.RefreshDerivedCombatStats(now)
		e.sender.SendToHero(cs.HeroID, "hero_state", hero)
	}

	e.logger.Info("enemy defeated",
		"hero_id", cs.HeroID,
		"enemy", enemy.Name,
	)
}

// processAutoReviveLocked revives dead heroes after AutoReviveAfterMs downtime. Caller holds e.mu.
func (e *Engine) processAutoReviveLocked(now time.Time) {
	if e.heroStore == nil {
		return
	}
	gap := time.Duration(tuning.Get().AutoReviveAfterMs) * time.Millisecond
	for heroID, hm := range e.movements {
		if hm == nil || hm.Hero == nil {
			continue
		}
		h := hm.Hero
		if h.State != model.StateDead && h.HP > 0 {
			continue
		}
		if now.Sub(h.UpdatedAt) <= gap {
			continue
		}
		ApplyHeroReviveMechanical(h)
		hm.State = model.StateWalking
		hm.SyncToHero()
		dctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
		e.applyOfflineDigest(dctx, heroID, h, now, storage.OfflineDigestDelta{Revives: 1})
		cancel()
		if e.adventureLog != nil {
			e.adventureLog(heroID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{
					Code: model.LogPhraseAutoReviveAfterSec,
					Args: map[string]any{"seconds": int64(gap.Round(time.Second) / time.Second)},
				},
			})
		}
		ctx, cancelSave := context.WithTimeout(context.Background(), 5*time.Second)
		if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
			e.logger.Error("persist hero after auto-revive", "hero_id", heroID, "error", err)
		}
		cancelSave()
		e.applyResidentReviveSyncLocked(h)
	}
}

// processMovementTick advances all walking heroes and checks for encounters.
// Runs on the configured movement cadence.
func (e *Engine) processMovementTick(now time.Time) {
	e.mu.Lock()
	defer e.mu.Unlock()

	if e.roadGraph == nil {
		return
	}

	e.processAutoReviveLocked(now)

	startCombat := func(hm *HeroMovement, enemy *model.Enemy, t time.Time) {
		e.startCombatLocked(hm.Hero, enemy)
	}

	for heroID, hm := range e.movements {
		if hm == nil {
			continue
		}
		// Do not run movement FSM, AdvanceTick, or encounters for dead heroes.
		if hm.skipMovementSimulation() {
			continue
		}
		ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter, nil, e.logger)
		if hm.State != model.StateResting {
			hm.lastRestHealPersistAt = time.Time{}
		}
		if e.heroStore == nil || hm.Hero == nil {
			continue
		}
		if sig, ok := hm.TownPausePersistDue(); ok {
			hm.SyncToHero()
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			err := e.heroStore.Save(ctx, hm.Hero)
			cancel()
			if err != nil {
				if e.logger != nil {
					e.logger.Error("persist hero excursion/rest failed", "hero_id", heroID, "error", err)
				}
				continue
			}
			hm.MarkTownPausePersisted(sig)
			if hm.State == model.StateResting {
				hm.lastRestHealPersistAt = now
			}
			e.syncTownSessionRedis(heroID, hm)
		}
		if hm.State == model.StateResting && hm.restHPRegenActive() {
			if hm.lastRestHealPersistAt.IsZero() || now.Sub(hm.lastRestHealPersistAt) >= restHealPersistInterval {
				hm.SyncToHero()
				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
				err := e.heroStore.Save(ctx, hm.Hero)
				cancel()
				if err != nil {
					if e.logger != nil {
						e.logger.Error("persist hero during rest heal", "hero_id", heroID, "error", err)
					}
				} else {
					hm.lastRestHealPersistAt = now
					e.syncTownSessionRedis(heroID, hm)
				}
			}
		}
		if e.heroStore != nil && e.heroSubscriber != nil && hm.Hero != nil && !e.heroSubscriber(heroID) {
			last := e.lastDisconnectedFullSave[heroID]
			if last.IsZero() || now.Sub(last) >= offlineDisconnectedFullSaveInterval {
				hm.SyncToHero()
				ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
				if err := e.heroStore.Save(ctx, hm.Hero); err != nil {
					if e.logger != nil {
						e.logger.Error("persist disconnected resident hero", "hero_id", heroID, "error", err)
					}
				} else {
					e.lastDisconnectedFullSave[heroID] = now
				}
				cancel()
			}
		}
	}

	e.checkHeroMeetApproachArrivalLocked(now)
	e.checkHeroMeetReturnArrivalLocked(now)
	e.tryRandomHeroMeetProximityLocked(now)
	e.processHeroMeetTickLocked(now)
	for heroID, hm := range e.movements {
		if hm == nil || e.heroStore == nil || hm.Hero == nil {
			continue
		}
		if sig, ok := hm.TownPausePersistDue(); ok {
			hm.SyncToHero()
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			err := e.heroStore.Save(ctx, hm.Hero)
			cancel()
			if err != nil {
				if e.logger != nil {
					e.logger.Error("persist hero after hero meet tick", "hero_id", heroID, "error", err)
				}
				continue
			}
			hm.MarkTownPausePersisted(sig)
			e.syncTownSessionRedis(heroID, hm)
		}
	}
}

// mergeTownSessionFromRedis overlays a fresher in-town snapshot when Postgres row is stale (e.g. missed town_pause save).
func (e *Engine) mergeTownSessionFromRedis(hero *model.Hero) {
	if e.townSession == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	snap, err := e.townSession.Load(ctx, hero.ID)
	if err != nil {
		if e.logger != nil {
			e.logger.Warn("town session redis load failed", "hero_id", hero.ID, "error", err)
		}
		return
	}
	if snap == nil || snap.State != model.StateInTown || snap.TownPause == nil {
		return
	}
	if snap.CurrentTownID > 0 && hero.CurrentTownID != nil && *hero.CurrentTownID != snap.CurrentTownID {
		return
	}
	if snap.SavedAtUnixNano <= hero.UpdatedAt.UnixNano() {
		return
	}
	hero.State = model.StateInTown
	hero.TownPause = snap.TownPause
	hero.PositionX = snap.PositionX
	hero.PositionY = snap.PositionY
	if snap.CurrentTownID > 0 {
		tid := snap.CurrentTownID
		if hero.CurrentTownID == nil {
			hero.CurrentTownID = new(int64)
		}
		*hero.CurrentTownID = tid
	}
}

func (e *Engine) syncTownSessionRedis(heroID int64, hm *HeroMovement) {
	if e.townSession == nil || hm == nil || hm.Hero == nil {
		return
	}
	hm.SyncToHero()
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if hm.State == model.StateInTown {
		if err := e.townSession.Save(ctx, heroID, hm.Hero); err != nil && e.logger != nil {
			e.logger.Warn("town session redis save failed", "hero_id", heroID, "error", err)
		}
		return
	}
	if err := e.townSession.Delete(ctx, heroID); err != nil && e.logger != nil {
		e.logger.Warn("town session redis delete failed", "hero_id", heroID, "error", err)
	}
}

func (e *Engine) syncTownSessionRedisFromHero(heroID int64, h *model.Hero) {
	if e.townSession == nil || h == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if h.State == model.StateInTown {
		if err := e.townSession.Save(ctx, heroID, h); err != nil && e.logger != nil {
			e.logger.Warn("town session redis save failed", "hero_id", heroID, "error", err)
		}
		return
	}
	if err := e.townSession.Delete(ctx, heroID); err != nil && e.logger != nil {
		e.logger.Warn("town session redis delete failed", "hero_id", heroID, "error", err)
	}
}

// persistHeroAfterTownEnter writes the hero row after a walk-in town arrival (town_pause + state).
func (e *Engine) persistHeroAfterTownEnter(h *model.Hero) {
	if e.heroStore == nil || h == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := e.heroStore.Save(ctx, h); err != nil && e.logger != nil {
		e.logger.Error("persist hero after town enter", "hero_id", h.ID, "error", err)
		return
	}
	e.syncTownSessionRedisFromHero(h.ID, h)
	e.applyVisitTownQuestProgress(h)
}

// applyVisitTownQuestProgress advances visit_town quests when the hero is in a town (matches quests.target_town_id).
func (e *Engine) applyVisitTownQuestProgress(h *model.Hero) {
	if e.questStore == nil || h == nil || h.CurrentTownID == nil || *h.CurrentTownID <= 0 {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	if err := e.questStore.IncrementVisitTownProgress(ctx, h.ID, *h.CurrentTownID); err != nil && e.logger != nil {
		e.logger.Warn("visit town quest progress failed", "hero_id", h.ID, "town_id", *h.CurrentTownID, "error", err)
	}
}

// processPositionSync sends drift-correction position_sync messages and persists world (x,y).
// Called at low cadence (see tuning positionSyncRateMs).
func (e *Engine) processPositionSync(now time.Time) {
	type posSnap struct {
		id int64
		x  float64
		y  float64
	}
	var snaps []posSnap

	e.mu.RLock()
	sender := e.sender
	for heroID, hm := range e.movements {
		if hm == nil {
			continue
		}
		if hm.skipMovementSimulation() {
			continue
		}
		if sender != nil && hm.State == model.StateWalking {
			sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
		}
		shouldPersistPos := hm.State == model.StateWalking || hm.State == model.StateResting || hm.Excursion.Active()
		if shouldPersistPos && hm.Hero != nil {
			hm.SyncToHero()
			snaps = append(snaps, posSnap{id: heroID, x: hm.Hero.PositionX, y: hm.Hero.PositionY})
		}
	}
	heroStore := e.heroStore
	e.mu.RUnlock()

	if heroStore == nil || len(snaps) == 0 {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()
	for _, p := range snaps {
		if err := heroStore.SavePosition(ctx, p.id, p.x, p.y); err != nil && e.logger != nil {
			e.logger.Error("position sync persist failed", "hero_id", p.id, "error", err)
		}
	}
}

func (e *Engine) emitEvent(evt model.CombatEvent) {
	select {
	case e.eventCh <- evt:
	default:
		e.logger.Warn("combat event channel full, dropping event",
			"type", evt.Type,
			"hero_id", evt.HeroID,
		)
	}
}

// attackInterval converts an attacks-per-second speed to a duration between attacks.
func attackInterval(speed float64) time.Duration {
	cfg := tuning.Get()
	minAttack := time.Duration(cfg.MinAttackIntervalMs) * time.Millisecond
	if cfg.CombatPaceMultiplier < 1 {
		cfg.CombatPaceMultiplier = 1
	}
	pace := time.Duration(cfg.CombatPaceMultiplier)
	if speed <= 0 {
		return time.Second * pace // fallback: 1 attack per second, scaled
	}
	interval := time.Duration(float64(time.Second)/speed) * pace
	if interval < minAttack*pace {
		return minAttack * pace
	}
	return interval
}

// attackIntervalEnemy applies EnemyAttackIntervalMultiplier only to monsters (slower, heavier swings vs hero cadence).
func attackIntervalEnemy(speed float64) time.Duration {
	base := attackInterval(speed)
	m := tuning.EffectiveEnemyAttackIntervalMultiplier()
	return time.Duration(float64(base) * m)
}

// enemyToInfo converts a model.Enemy to the WS payload info struct.
func enemyToInfo(e *model.Enemy) model.CombatEnemyInfo {
	return model.CombatEnemyInfo{
		Name:      e.Name,
		Type:      e.Slug,
		Archetype: e.Archetype,
		Biome:     e.Biome,
		Level:     e.Level,
		HP:        e.HP,
		MaxHP:     e.MaxHP,
		Attack:    e.Attack,
		Defense:   e.Defense,
		Speed:     e.Speed,
		IsElite:   e.IsElite,
	}
}

// SetTownNPCUILock freezes town tour welcome/service timers while the client shows NPCDialog.
func (e *Engine) SetTownNPCUILock(heroID int64, locked bool) {
	if e == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	hm := e.movements[heroID]
	if hm == nil {
		return
	}
	if hm.Excursion.Kind == model.ExcursionKindTown {
		hm.townTourSetDialogOpen(locked)
		return
	}
	hm.TownNPCUILock = locked
}

// SkipTownNPCNarrationAfterDialog applies town tour dialog-closed semantics (legacy name for REST).
func (e *Engine) SkipTownNPCNarrationAfterDialog(heroID int64) {
	if e == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	hm := e.movements[heroID]
	if hm == nil || e.roadGraph == nil {
		return
	}
	hm.townTourNPCDialogClosed(time.Now(), e.roadGraph)
}

func (e *Engine) handleTownTourNPCDialogClosed(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[msg.HeroID]
	if !ok || e.roadGraph == nil {
		return
	}
	hm.townTourNPCDialogClosed(time.Now(), e.roadGraph)
	if e.sender != nil && hm.Hero != nil {
		e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
	}
}

func (e *Engine) handleTownTourNPCInteractionOpened(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[msg.HeroID]
	if !ok || e.roadGraph == nil {
		return
	}
	hm.townTourNPCInteractionOpened(time.Now(), e.roadGraph)
	if e.sender != nil && hm.Hero != nil {
		e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
	}
}

func (e *Engine) handleTownTourNPCInteractionClosed(msg IncomingMessage) {
	e.mu.Lock()
	defer e.mu.Unlock()
	hm, ok := e.movements[msg.HeroID]
	if !ok || e.roadGraph == nil {
		return
	}
	hm.townTourNPCInteractionClosed(time.Now(), e.roadGraph)
	if e.sender != nil && hm.Hero != nil {
		e.sender.SendToHero(msg.HeroID, "hero_state", hm.Hero)
	}
}

// SetMerchantStock replaces ephemeral merchant offers for a hero (copies items, ids cleared).
// costs must have the same length as items (gold price locked at roll time).
func (e *Engine) SetMerchantStock(heroID, npcID, townID int64, items []*model.GearItem, costs []int64) {
	if e == nil {
		return
	}
	if len(costs) != len(items) {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	if e.merchantStock == nil {
		e.merchantStock = make(map[int64]*merchantOfferSession)
	}
	copies := make([]*model.GearItem, len(items))
	prices := make([]int64, len(costs))
	for i, it := range items {
		copies[i] = model.CloneGearItem(it)
		prices[i] = costs[i]
	}
	e.merchantStock[heroID] = &merchantOfferSession{
		NPCID: npcID, TownID: townID, Items: copies, Costs: prices, Created: time.Now(),
	}
}

// ClearMerchantStock drops cached merchant rows (e.g. dialog closed).
func (e *Engine) ClearMerchantStock(heroID int64) {
	if e == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	delete(e.merchantStock, heroID)
}

// TakeMerchantOffer validates npc and index, removes the row, returns a template for DB insert (id 0) and locked price.
func (e *Engine) TakeMerchantOffer(heroID, npcID int64, index int) (*model.GearItem, int64, bool) {
	if e == nil {
		return nil, 0, false
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	s, ok := e.merchantStock[heroID]
	if !ok || s == nil || s.NPCID != npcID || index < 0 || index >= len(s.Items) {
		return nil, 0, false
	}
	if len(s.Costs) != len(s.Items) || index >= len(s.Costs) {
		return nil, 0, false
	}
	item := model.CloneGearItem(s.Items[index])
	price := s.Costs[index]
	s.Items = append(s.Items[:index], s.Items[index+1:]...)
	s.Costs = append(s.Costs[:index], s.Costs[index+1:]...)
	if len(s.Items) == 0 {
		delete(e.merchantStock, heroID)
	}
	return item, price, true
}

// UnshiftMerchantOffer puts an offer row back (e.g. failed persist after TakeMerchantOffer).
func (e *Engine) UnshiftMerchantOffer(heroID, npcID, townID int64, item *model.GearItem, cost int64) {
	if e == nil || item == nil {
		return
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	if e.merchantStock == nil {
		e.merchantStock = make(map[int64]*merchantOfferSession)
	}
	s := e.merchantStock[heroID]
	clone := model.CloneGearItem(item)
	if s == nil || s.NPCID != npcID {
		e.merchantStock[heroID] = &merchantOfferSession{
			NPCID: npcID, TownID: townID, Items: []*model.GearItem{clone}, Costs: []int64{cost}, Created: time.Now(),
		}
		return
	}
	s.Items = append([]*model.GearItem{clone}, s.Items...)
	s.Costs = append([]int64{cost}, s.Costs...)
}
