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

// EnemyDeathCallback is invoked when an enemy dies, passing the hero and enemy type.
// Used to wire loot generation without coupling the engine to the handler layer.
type EnemyDeathCallback func(hero *model.Hero, enemy *model.Enemy, now 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
	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
}

// 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,
	}
	heap.Init(&e.queue)
	return e
}

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

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

// 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(attackInterval(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
}

// 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
}

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

// 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
	}

	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, "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, fmt.Sprintf("Used healing potion, restored %d HP", 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)
	}
}

// handleRevive processes the revive client command.
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"})
	}
}

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 hero.HP > 0 && hm.State != model.StateDead {
		e.sendError(msg.HeroID, "not_dead", "hero is not dead")
		return
	}

	hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
	if hero.HP < 1 {
		hero.HP = 1
	}
	hero.State = model.StateWalking
	hero.Debuffs = nil
	hero.ReviveCount++

	hm.State = model.StateWalking
	hm.LastMoveTick = time.Now()
	hm.refreshSpeed(time.Now())

	// Remove any active combat.
	delete(e.combats, msg.HeroID)

	// 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.sender != nil {
		hero.EnsureGearMap()
		hero.RefreshDerivedCombatStats(time.Now())
		e.sender.SendToHero(msg.HeroID, "hero_state", hero)
		e.sender.SendToHero(msg.HeroID, "hero_revived", model.HeroRevivedPayload{HP: hero.HP})
	}
}

// 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) {
	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.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),
				})
			}
		}
		return
	}

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

	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),
			})
		}
	}
}

// HeroSocketDetached persists hero state on every WS disconnect and removes in-memory
// movement only when lastConnection is true (no other tabs/sockets for this hero).
func (e *Engine) HeroSocketDetached(heroID int64, lastConnection bool) {
	e.mu.Lock()
	hm, ok := e.movements[heroID]
	if ok {
		hm.SyncToHero()
		if lastConnection {
			delete(e.movements, heroID)
		}
	}
	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,
			)
		}
	}
}

// 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,
	}
}

// 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,
			})
		}
	}
	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
}

// 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
}

// 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 ends an online hero's excursion immediately.
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_end", model.ExcursionEndPayload{})
		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: string(cs.Enemy.Type),
			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 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
		}
	}

	cs := &model.CombatState{
		HeroID:          hero.ID,
		Hero:            hero,
		Enemy:           *enemy,
		HeroNextAttack:  now.Add(attackInterval(hero.EffectiveSpeed())),
		EnemyNextAttack: now.Add(attackInterval(enemy.Speed)),
		StartedAt:       now,
		LastTickAt:      now,
	}
	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,
	})

	// Legacy event channel (for backward compat bridge).
	e.emitEvent(model.CombatEvent{
		Type:      "combat_start",
		HeroID:    hero.ID,
		Source:    "system",
		HeroHP:   hero.HP,
		EnemyHP:  enemy.HP,
		Timestamp: now,
	})

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

	e.logger.Info("combat started",
		"hero_id", hero.ID,
		"enemy", enemy.Name,
	)
}

// 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)
		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)
		}
	}
}

// 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()
	defer e.mu.Unlock()

	hm, ok := e.movements[hero.ID]
	if ok {
		now := time.Now()
		hm.WanderingMerchantDeadline = time.Time{}
		*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)
	}
}

// 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()

	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.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)
		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)
		}
	}
}

// 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 100ms combat processing tick.
func (e *Engine) processCombatTick(now time.Time) {
	e.mu.Lock()
	defer e.mu.Unlock()

	// 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
		}

		ProcessDebuffDamage(cs.Hero, tickDur, now)
		ProcessEnemyRegen(&cs.Enemy, tickDur)
		ProcessSummonDamage(cs.Hero, &cs.Enemy, cs.StartedAt, cs.LastTickAt, now)
		cs.LastTickAt = now

		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()
			}
			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)
	}
}

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)

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

	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
	}

	combatEvt := ProcessEnemyAttack(cs.Hero, &cs.Enemy, now)
	e.emitEvent(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,
			HeroHP:        combatEvt.HeroHP,
			EnemyHP:       combatEvt.EnemyHP,
			DebuffApplied: combatEvt.DebuffApplied,
		})
	}

	// 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()
		}
		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(attackInterval(cs.Enemy.Speed))
	heap.Push(&e.queue, &model.AttackEvent{
		NextAttackAt: cs.EnemyNextAttack,
		IsHero:       false,
		CombatID:     cs.HeroID,
	})
}

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.
	if e.onEnemyDeath != nil && hero != nil {
		e.onEnemyDeath(hero, enemy, now)
	}

	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 (road + forest offset).
	if hm, ok := e.movements[cs.HeroID]; ok {
		hm.ResumeWalking(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 && hero != 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.
	if e.sender != nil {
		e.sender.SendToHero(cs.HeroID, "combat_end", model.CombatEndPayload{
			XPGained:   enemy.XPReward,
			GoldGained: enemy.GoldReward,
			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,
	)
}

// 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
	}

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

	for heroID, hm := range e.movements {
		ProcessSingleHeroMovementTick(heroID, hm, e.roadGraph, now, e.sender, startCombat, nil, e.adventureLog, e.persistHeroAfterTownEnter)
	}
}

// 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)
	}
}

// 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.State != model.StateWalking {
			continue
		}
		if sender != nil {
			sender.SendToHero(heroID, "position_sync", hm.PositionSyncPayload(now))
		}
		if 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
}

// 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:    string(e.Type),
		HP:      e.HP,
		MaxHP:   e.MaxHP,
		Attack:  e.Attack,
		Defense: e.Defense,
		Speed:   e.Speed,
		IsElite: e.IsElite,
	}
}
