package handler

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"math/rand"
	"net/http"
	"strconv"
	"strings"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"

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

type GameHandler struct {
	engine           *game.Engine
	store            *storage.HeroStore
	logStore         *storage.LogStore
	hub              *Hub
	questStore       *storage.QuestStore
	gearStore        *storage.GearStore
	achievementStore *storage.AchievementStore
	taskStore        *storage.DailyTaskStore
	logger           *slog.Logger
	world            *world.Service
	lootMu           sync.RWMutex
	lootCache        map[int64][]model.LootHistory // keyed by hero ID
	serverStartedAt  time.Time

	encounterMu            sync.Mutex
	lastCombatEncounterAt  map[int64]time.Time // per-hero; in-memory only
}

type encounterEnemyResponse struct {
	ID          int64          `json:"id"`
	Name        string         `json:"name"`
	HP          int            `json:"hp"`
	MaxHP       int            `json:"maxHp"`
	Attack      int            `json:"attack"`
	Defense     int            `json:"defense"`
	Speed       float64        `json:"speed"`
	EnemyType   model.EnemyType `json:"enemyType"`
}

func NewGameHandler(engine *game.Engine, store *storage.HeroStore, logStore *storage.LogStore, worldSvc *world.Service, logger *slog.Logger, serverStartedAt time.Time, questStore *storage.QuestStore, gearStore *storage.GearStore, achievementStore *storage.AchievementStore, taskStore *storage.DailyTaskStore, hub *Hub) *GameHandler {
	h := &GameHandler{
		engine:                engine,
		store:                 store,
		logStore:              logStore,
		hub:                   hub,
		questStore:            questStore,
		gearStore:             gearStore,
		achievementStore:      achievementStore,
		taskStore:             taskStore,
		logger:                logger,
		world:                 worldSvc,
		lootCache:             make(map[int64][]model.LootHistory),
		serverStartedAt:       serverStartedAt,
		lastCombatEncounterAt: make(map[int64]time.Time),
	}
	engine.SetOnEnemyDeath(h.onEnemyDeath)
	return h
}

// addLog is a fire-and-forget helper that writes an adventure log entry and mirrors it to the client via WS.
func (h *GameHandler) addLog(heroID int64, message string) {
	if h.logStore == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	if err := h.logStore.Add(ctx, heroID, message); err != nil {
		h.logger.Warn("failed to write adventure log", "hero_id", heroID, "error", err)
		return
	}
	if h.hub != nil {
		h.hub.SendToHero(heroID, "adventure_log_line", model.AdventureLogLinePayload{Message: message})
	}
}

// onEnemyDeath is called by the engine when an enemy is defeated.
// Delegates to processVictoryRewards for canonical reward logic.
func (h *GameHandler) onEnemyDeath(hero *model.Hero, enemy *model.Enemy, now time.Time) {
	h.processVictoryRewards(hero, enemy, now)
}

// processVictoryRewards is the single source of truth for post-kill rewards.
// It awards XP, generates loot (gold is guaranteed via GenerateLoot — no separate
// enemy.GoldReward add), processes equipment drops (auto-equip, else stash up to
// MaxInventorySlots, else discard + adventure log), runs the level-up loop,
// sets hero state to walking, and records loot history.
// Returns the drops for API response building.
func (h *GameHandler) processVictoryRewards(hero *model.Hero, enemy *model.Enemy, now time.Time) []model.LootDrop {
	oldLevel := hero.Level
	hero.XP += enemy.XPReward
	levelsGained := 0
	for hero.LevelUp() {
		levelsGained++
	}
	hero.State = model.StateWalking

	luckMult := game.LuckMultiplier(hero, now)
	drops := model.GenerateLoot(enemy.Type, luckMult)

	ctxTown, cancelTown := context.WithTimeout(context.Background(), 2*time.Second)
	inTown := h.isHeroInTown(ctxTown, hero.PositionX, hero.PositionY)
	cancelTown()

	h.lootMu.Lock()
	defer h.lootMu.Unlock()

	for i := range drops {
		drop := &drops[i]

		switch drop.ItemType {
		case "gold":
			hero.Gold += drop.GoldAmount

		case "potion":
			hero.Potions++

		default:
			// All equipment drops go through the unified gear system.
			slot := model.EquipmentSlot(drop.ItemType)
			family := model.PickGearFamily(slot)
			if family != nil {
				ilvl := model.RollIlvl(enemy.MinLevel, enemy.IsElite)
				item := model.NewGearItem(family, ilvl, drop.Rarity)

				// Persist the gear item to DB.
				if h.gearStore != nil {
					ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
					if err := h.gearStore.CreateItem(ctx, item); err != nil {
						h.logger.Warn("failed to create gear item", "slot", slot, "error", err)
						cancel()
						if inTown {
							sellPrice := model.AutoSellPrice(drop.Rarity)
							hero.Gold += sellPrice
							drop.GoldAmount = sellPrice
						} else {
							drop.GoldAmount = 0
						}
						goto recordLoot
					}
					cancel()
				}

				drop.ItemID = item.ID
				drop.ItemName = item.Name

				equipped := h.tryAutoEquipGear(hero, item, now)
				if equipped {
					h.addLog(hero.ID, fmt.Sprintf("Equipped new %s: %s", slot, item.Name))
				} else {
					hero.EnsureInventorySlice()
					if len(hero.Inventory) >= model.MaxInventorySlots {
						ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
						if h.gearStore != nil && item.ID != 0 {
							if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil {
								h.logger.Warn("failed to delete gear (inventory full)", "gear_id", item.ID, "error", err)
							}
						}
						cancelDel()
						drop.ItemID = 0
						drop.ItemName = ""
						drop.GoldAmount = 0
						h.addLog(hero.ID, fmt.Sprintf("Inventory full — dropped %s (%s)", item.Name, item.Rarity))
					} else {
						ctxInv, cancelInv := context.WithTimeout(context.Background(), 2*time.Second)
						var err error
						if h.gearStore != nil {
							err = h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
						}
						cancelInv()
						if err != nil {
							h.logger.Warn("failed to stash gear", "hero_id", hero.ID, "gear_id", item.ID, "error", err)
							ctxDel, cancelDel := context.WithTimeout(context.Background(), 2*time.Second)
							if h.gearStore != nil && item.ID != 0 {
								_ = h.gearStore.DeleteGearItem(ctxDel, item.ID)
							}
							cancelDel()
							drop.ItemID = 0
							drop.ItemName = ""
							drop.GoldAmount = 0
						} else {
							hero.Inventory = append(hero.Inventory, item)
							drop.GoldAmount = 0
						}
					}
				}
			} else if inTown {
				sellPrice := model.AutoSellPrice(drop.Rarity)
				hero.Gold += sellPrice
				drop.GoldAmount = sellPrice
			}
		}

	recordLoot:
		entry := model.LootHistory{
			HeroID:     hero.ID,
			EnemyType:  string(enemy.Type),
			ItemType:   drop.ItemType,
			ItemID:     drop.ItemID,
			Rarity:     drop.Rarity,
			GoldAmount: drop.GoldAmount,
			CreatedAt:  now,
		}
		h.lootCache[hero.ID] = append(h.lootCache[hero.ID], entry)
		lootHistoryLimit := int(tuning.Get().LootHistoryLimit)
		if lootHistoryLimit < 1 {
			lootHistoryLimit = int(tuning.DefaultValues().LootHistoryLimit)
		}
		if len(h.lootCache[hero.ID]) > lootHistoryLimit {
			h.lootCache[hero.ID] = h.lootCache[hero.ID][len(h.lootCache[hero.ID])-lootHistoryLimit:]
		}
	}

	// Log the victory.
	h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, enemy.XPReward, enemy.GoldReward))

	// Log level-ups.
	for l := oldLevel + 1; l <= oldLevel+levelsGained; l++ {
		h.addLog(hero.ID, fmt.Sprintf("Leveled up to %d!", l))
	}

	// Stat tracking for achievements.
	hero.TotalKills++
	hero.KillsSinceDeath++
	if enemy.IsElite {
		hero.EliteKills++
	}
	// Track legendary drops for achievement conditions.
	for _, drop := range drops {
		if drop.Rarity == model.RarityLegendary && drop.ItemType != "gold" {
			hero.LegendaryDrops++
		}
	}

	// Quest progress hooks (fire-and-forget, errors logged but not fatal).
	h.progressQuestsAfterKill(hero.ID, enemy)

	// Achievement check (fire-and-forget).
	h.checkAchievementsAfterKill(hero)

	// Daily/weekly task progress (fire-and-forget).
	h.progressTasksAfterKill(hero.ID, enemy, drops)

	return drops
}

// resolveTelegramID extracts the Telegram user ID from auth context,
// falling back to a "telegramId" query param for dev mode (when auth middleware is disabled).
// On localhost, defaults to telegram_id=1 when no ID is provided.
func resolveTelegramID(r *http.Request) (int64, bool) {
	if id, ok := TelegramIDFromContext(r.Context()); ok {
		return id, true
	}
	// Dev fallback: accept telegramId query param.
	idStr := r.URL.Query().Get("telegramId")
	if idStr != "" {
		id, err := strconv.ParseInt(idStr, 10, 64)
		if err == nil {
			return id, true
		}
	}
	// Localhost fallback: default to telegram_id 1 for testing.
	host := r.Host
	if strings.HasPrefix(host, "localhost") || strings.HasPrefix(host, "127.0.0.1") || strings.HasPrefix(host, "192.168.0.53") {
		return 1, true
	}
	return 0, false
}

// GetHero returns the current hero state.
// GET /api/v1/hero
func (h *GameHandler) GetHero(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	now := time.Now()
	needsSave := false
	if h.engine == nil || !h.engine.IsTimePaused() {
		needsSave = hero.EnsureBuffChargesPopulated(now)
		if hero.ApplyBuffQuotaRollover(now) {
			needsSave = true
		}
	}
	if needsSave {
		if err := h.store.Save(r.Context(), hero); err != nil {
			h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err)
		}
	}
	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, hero)
}

// ActivateBuff activates a buff on the hero.
// POST /api/v1/hero/buff/{buffType}
func (h *GameHandler) ActivateBuff(w http.ResponseWriter, r *http.Request) {
	buffTypeStr := chi.URLParam(r, "buffType")

	bt, ok := model.ValidBuffType(buffTypeStr)
	if !ok {
		h.logger.Warn("invalid buff type requested", "buff_type", buffTypeStr)
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid buff type: " + buffTypeStr,
		})
		return
	}

	telegramID, ok := resolveTelegramID(r)
	if !ok {
		h.logger.Warn("buff request missing telegramId", "buff_type", buffTypeStr)
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for buff", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	now := time.Now()
	hero.EnsureBuffChargesPopulated(now)

	if err := consumeFreeBuffCharge(hero, bt, now); err != nil {
		writeJSON(w, http.StatusForbidden, map[string]string{
			"error": err.Error(),
		})
		return
	}

	ab := game.ApplyBuff(hero, bt, now)
	if ab == nil {
		refundFreeBuffCharge(hero, bt)
		h.logger.Error("ApplyBuff returned nil", "hero_id", hero.ID, "buff_type", bt)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to apply buff",
		})
		return
	}

	if err := h.store.Save(r.Context(), hero); err != nil {
		h.logger.Error("failed to save hero after buff", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save hero",
		})
		return
	}

	h.logger.Info("buff activated",
		"hero_id", hero.ID,
		"buff", bt,
		"expires_at", ab.ExpiresAt,
	)
	h.addLog(hero.ID, fmt.Sprintf("Activated %s", ab.Buff.Name))

	// Daily/weekly task progress: use_buff.
	if h.taskStore != nil {
		taskCtx, taskCancel := context.WithTimeout(context.Background(), 2*time.Second)
		defer taskCancel()
		_ = h.taskStore.EnsureHeroTasks(taskCtx, hero.ID, now)
		if err := h.taskStore.IncrementTaskProgress(taskCtx, hero.ID, "use_buff", 1); err != nil {
			h.logger.Warn("task use_buff progress failed", "hero_id", hero.ID, "error", err)
		}
	}

	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, map[string]any{
		"buff":      ab,
		"heroBuffs": hero.Buffs,
		"hero":      hero,
	})
}

// ReviveHero revives an effectively dead hero (StateDead or HP <= 0) with base HP.
// POST /api/v1/hero/revive
func (h *GameHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for revive", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	if hero.State != model.StateDead && hero.HP > 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is alive (state is not dead and hp > 0)",
		})
		return
	}

	if !hero.SubscriptionActive && hero.ReviveCount >= 2 {
		writeJSON(w, http.StatusForbidden, map[string]string{
			"error": "free revive limit reached (subscribe for unlimited revives)",
		})
		return
	}

	// Track death stats (the hero is dead, this is the first time we process it server-side).
	hero.TotalDeaths++
	hero.KillsSinceDeath = 0

	hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
	if hero.HP < 1 {
		hero.HP = 1
	}
	hero.State = model.StateWalking
	now := time.Now()
	hero.Buffs = model.RemoveBuffType(hero.Buffs, model.BuffResurrection)
	hero.Debuffs = nil
	hero.ReviveCount++

	if err := h.store.Save(r.Context(), hero); err != nil {
		h.logger.Error("failed to save hero after revive", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save hero",
		})
		return
	}

	h.logger.Info("hero revived", "hero_id", hero.ID, "hp", hero.HP)
	h.addLog(hero.ID, "Hero revived")

	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, hero)
}

// RequestEncounter picks a backend-generated enemy for the hero's current level.
// POST /api/v1/hero/encounter
func (h *GameHandler) RequestEncounter(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for encounter", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}
	if hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is dead",
		})
		return
	}

	// Check if hero is inside a town (no combat in towns)
	posX := hero.PositionX
	posY := hero.PositionY
	if px := r.URL.Query().Get("posX"); px != "" {
		if v, err := strconv.ParseFloat(px, 64); err == nil {
			posX = v
		}
	}
	if py := r.URL.Query().Get("posY"); py != "" {
		if v, err := strconv.ParseFloat(py, 64); err == nil {
			posY = v
		}
	}
	if h.isHeroInTown(r.Context(), posX, posY) {
		writeJSON(w, http.StatusOK, map[string]string{
			"type": "no_encounter",
			"reason": "in_town",
		})
		return
	}

	now := time.Now()
	cfg := tuning.Get()
	h.encounterMu.Lock()
	if t, ok := h.lastCombatEncounterAt[hero.ID]; ok && now.Sub(t) < time.Duration(cfg.RESTEncounterCooldownMs)*time.Millisecond {
		h.encounterMu.Unlock()
		writeJSON(w, http.StatusOK, map[string]string{
			"type":   "no_encounter",
			"reason": "cooldown",
		})
		return
	}
	h.encounterMu.Unlock()

	// 10% chance to encounter a wandering NPC instead of an enemy.
	if rand.Float64() < cfg.RESTEncounterNPCChance {
		cost := game.WanderingMerchantCost(hero.Level)
		h.addLog(hero.ID, "Encountered a Wandering Merchant on the road")
		h.encounterMu.Lock()
		h.lastCombatEncounterAt[hero.ID] = now
		h.encounterMu.Unlock()
		writeJSON(w, http.StatusOK, model.NPCEventResponse{
			Type: "npc_event",
			NPC: model.NPCEventNPC{
				Name: "Wandering Merchant",
				Role: "alms",
			},
			Cost:   cost,
			Reward: "random_equipment",
		})
		return
	}

	enemy := pickEnemyForLevel(hero.Level)
	h.encounterMu.Lock()
	h.lastCombatEncounterAt[hero.ID] = now
	h.encounterMu.Unlock()
	h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
	writeJSON(w, http.StatusOK, encounterEnemyResponse{
		ID:        time.Now().UnixNano(),
		Name:      enemy.Name,
		HP:        enemy.MaxHP,
		MaxHP:     enemy.MaxHP,
		Attack:    enemy.Attack,
		Defense:   enemy.Defense,
		Speed:     enemy.Speed,
		EnemyType: enemy.Type,
	})
}

// isHeroInTown checks if the position is within any town's radius.
func (h *GameHandler) isHeroInTown(ctx context.Context, posX, posY float64) bool {
	towns, err := h.questStore.ListTowns(ctx)
	if err != nil || len(towns) == 0 {
		return false
	}
	for _, t := range towns {
		dx := posX - t.WorldX
		dy := posY - t.WorldY
		if dx*dx+dy*dy <= t.Radius*t.Radius {
			return true
		}
	}
	return false
}

// pickEnemyForLevel delegates to the canonical implementation in the game package.
func pickEnemyForLevel(level int) model.Enemy {
	return game.PickEnemyForLevel(level)
}

// tryAutoEquipGear uses the in-memory combat rating comparison to decide whether
// to equip a new gear item. If it clears the runtime-configured improvement threshold, equips it
// (persisting to DB if gearStore is available). Returns true if equipped.
func (h *GameHandler) tryAutoEquipGear(hero *model.Hero, item *model.GearItem, now time.Time) bool {
	hero.EnsureGearMap()
	slot := item.Slot
	var prev *model.GearItem
	if hero.Gear != nil {
		prev = hero.Gear[slot]
	}
	if !game.TryAutoEquipInMemory(hero, item, now) {
		return false
	}
	err := h.persistGearEquip(hero.ID, item)
	if err != nil {
		if prev == nil {
			delete(hero.Gear, slot)
		} else {
			hero.Gear[slot] = prev
		}
		hero.RefreshDerivedCombatStats(now)
		if errors.Is(err, storage.ErrInventoryFull) {
			h.logger.Warn("persist gear equip skipped: inventory full (free a slot to swap)",
				"hero_id", hero.ID, "slot", item.Slot)
		} else {
			h.logger.Warn("failed to persist gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err)
		}
		return false
	}
	if prev != nil && prev.ID != item.ID {
		hero.EnsureInventorySlice()
		hero.Inventory = append(hero.Inventory, prev)
	}
	return true
}

// persistGearEquip saves the equip to the hero_gear table if gearStore is available.
func (h *GameHandler) persistGearEquip(heroID int64, item *model.GearItem) error {
	if h.gearStore == nil || item.ID == 0 {
		return nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID)
}

// pickEnemyByType returns a scaled enemy instance for loot/XP rewards matching encounter stats.
func pickEnemyByType(level int, t model.EnemyType) model.Enemy {
	tmpl, ok := model.EnemyTemplates[t]
	if !ok {
		tmpl = model.EnemyTemplates[model.EnemyWolf]
	}
	return game.ScaleEnemyTemplate(tmpl, level)
}

type victoryRequest struct {
	EnemyType string  `json:"enemyType"`
	HeroHP    int     `json:"heroHp"`
	PositionX float64 `json:"positionX"`
	PositionY float64 `json:"positionY"`
}

type victoryDropJSON struct {
	ItemType   string `json:"itemType"`
	ItemID     int64  `json:"itemId,omitempty"`
	ItemName   string `json:"itemName,omitempty"`
	Rarity     string `json:"rarity"`
	GoldAmount int64  `json:"goldAmount"`
}

type victoryResponse struct {
	Hero  *model.Hero       `json:"hero"`
	Drops []victoryDropJSON `json:"drops"`
}

// ReportVictory applies authoritative combat rewards after a client-resolved kill.
// POST /api/v1/hero/victory
// Hero HP after the fight is taken from the client and remains persisted across fights.
func (h *GameHandler) ReportVictory(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req victoryRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body: " + err.Error(),
		})
		return
	}

	if req.EnemyType == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "enemyType is required",
		})
		return
	}

	et := model.EnemyType(req.EnemyType)
	if _, ok := model.EnemyTemplates[et]; !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "unknown enemyType: " + req.EnemyType,
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for victory", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}
	if hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is dead",
		})
		return
	}

	now := time.Now()
	hpAfterFight := req.HeroHP
	if hpAfterFight < 1 {
		hpAfterFight = 1
	}
	if hpAfterFight > hero.MaxHP {
		hpAfterFight = hero.MaxHP
	}
	if hpAfterFight > hero.HP {
		h.logger.Warn("client reported HP higher than server HP, clamping",
			"hero_id", hero.ID,
			"client_hp", req.HeroHP,
			"server_hp", hero.HP,
		)
		hpAfterFight = hero.HP
	}

	enemy := pickEnemyByType(hero.Level, et)

	drops := h.processVictoryRewards(hero, &enemy, now)

	outDrops := make([]victoryDropJSON, 0, len(drops))
	for _, drop := range drops {
		outDrops = append(outDrops, victoryDropJSON{
			ItemType:   drop.ItemType,
			ItemID:     drop.ItemID,
			ItemName:   drop.ItemName,
			Rarity:     string(drop.Rarity),
			GoldAmount: drop.GoldAmount,
		})
	}

	// Level-up does NOT restore HP (spec §3.3). Always persist the post-fight HP.
	hero.HP = hpAfterFight
	hero.PositionX = req.PositionX
	hero.PositionY = req.PositionY

	// Update online status for shared world.
	nowPtr := time.Now()
	hero.LastOnlineAt = &nowPtr

	if err := h.store.Save(r.Context(), hero); err != nil {
		h.logger.Error("failed to save hero after victory", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save hero",
		})
		return
	}

	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, victoryResponse{
		Hero:  hero,
		Drops: outDrops,
	})
}

// offlineReport describes what happened while the hero was offline.
type offlineReport struct {
	OfflineSeconds int      `json:"offlineSeconds"`
	MonstersKilled int      `json:"monstersKilled"`
	XPGained       int64    `json:"xpGained"`
	GoldGained     int64    `json:"goldGained"`
	LevelsGained   int      `json:"levelsGained"`
	PotionsUsed    int      `json:"potionsUsed"`
	PotionsFound   int      `json:"potionsFound"`
	HPBefore       int      `json:"hpBefore"`
	Message        string   `json:"message"`
	Log            []string `json:"log"`
}

// buildOfflineReport constructs an offline report from real adventure log entries
// written by the offline simulator (and catch-up). Parses log messages to count
// defeats, XP, gold, levels, and deaths.
func (h *GameHandler) buildOfflineReport(ctx context.Context, hero *model.Hero, offlineDuration time.Duration) *offlineReport {
	if offlineDuration < 30*time.Second {
		return nil
	}

	// Query log entries since hero was last updated (with a small buffer).
	since := hero.UpdatedAt.Add(-5 * time.Minute)
	entries, err := h.logStore.GetSince(ctx, hero.ID, since, 200)
	if err != nil {
		h.logger.Error("failed to get offline log entries", "hero_id", hero.ID, "error", err)
		return nil
	}

	if len(entries) == 0 {
		// No offline activity recorded.
		if hero.State == model.StateDead {
			return &offlineReport{
				OfflineSeconds: int(offlineDuration.Seconds()),
				HPBefore:       0,
				Message:        "Your hero remains dead. Revive to continue progression.",
				Log:            []string{},
			}
		}
		return nil
	}

	report := &offlineReport{
		OfflineSeconds: int(offlineDuration.Seconds()),
		HPBefore:       hero.HP,
		Log:            make([]string, 0, len(entries)),
	}

	for _, entry := range entries {
		report.Log = append(report.Log, entry.Message)

		// Parse structured log messages to populate summary counters.
		// Messages written by the offline simulator follow known patterns.
		if matched, _ := parseDefeatedLog(entry.Message); matched {
			report.MonstersKilled++
		}
		if xp, gold, ok := parseGainsLog(entry.Message); ok {
			report.XPGained += xp
			report.GoldGained += gold
		}
		if isLevelUpLog(entry.Message) {
			report.LevelsGained++
		}
		if isDeathLog(entry.Message) {
			// Death was recorded
		}
		if isPotionLog(entry.Message) {
			report.PotionsUsed++
		}
	}

	if hero.State == model.StateDead {
		report.Message = "Your hero died while offline. Revive to continue progression."
	} else if report.MonstersKilled > 0 {
		report.Message = "Your hero fought while you were away!"
	} else {
		report.Message = "Your hero rested while you were away."
	}

	return report
}

// catchUpOfflineGap simulates the gap between hero.UpdatedAt and serverStartedAt.
// This covers the period when the server was down and the offline simulator wasn't running.
// Returns true if any simulation was performed.
func (h *GameHandler) catchUpOfflineGap(ctx context.Context, hero *model.Hero) bool {
	if h.engine != nil && h.engine.IsTimePaused() {
		return false
	}
	gapDuration := h.serverStartedAt.Sub(hero.UpdatedAt)
	if gapDuration < 30*time.Second {
		return false
	}

	// Cap at 8 hours.
	if gapDuration > 8*time.Hour {
		gapDuration = 8 * time.Hour
	}

	// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
	if (hero.State == model.StateDead || hero.HP <= 0) && gapDuration > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond {
		hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
		if hero.HP < 1 {
			hero.HP = 1
		}
		hero.State = model.StateWalking
		hero.Debuffs = nil
		h.addLog(hero.ID, "Auto-revived after 1 hour")
	}

	totalFights := int(gapDuration.Seconds()) / 10
	if totalFights <= 0 {
		return false
	}

	now := time.Now()
	performed := false

	for i := 0; i < totalFights; i++ {
		if hero.HP <= 0 || hero.State == model.StateDead {
			break
		}

		var rg *game.RoadGraph
		if h.engine != nil {
			rg = h.engine.RoadGraph()
		}
		survived, enemy, xpGained, goldGained := game.SimulateOneFight(hero, now, nil, rg, func(msg string) {
			h.addLog(hero.ID, msg)
		})
		performed = true

		h.addLog(hero.ID, fmt.Sprintf("Encountered %s", enemy.Name))
		if survived {
			h.addLog(hero.ID, fmt.Sprintf("Defeated %s, gained %d XP and %d gold", enemy.Name, xpGained, goldGained))
		} else {
			h.addLog(hero.ID, fmt.Sprintf("Died fighting %s", enemy.Name))
		}
	}

	return performed
}

// parseDefeatedLog checks if a message matches "Defeated X, gained ..." pattern.
func parseDefeatedLog(msg string) (bool, string) {
	if len(msg) > 9 && msg[:9] == "Defeated " {
		return true, msg[9:]
	}
	return false, ""
}

// parseGainsLog parses "Defeated X, gained N XP and M gold" to extract XP and gold.
func parseGainsLog(msg string) (xp int64, gold int64, ok bool) {
	// Pattern: "Defeated ..., gained %d XP and %d gold"
	// Find ", gained " as the separator since enemy names may contain spaces.
	const sep = ", gained "
	idx := -1
	for i := 0; i <= len(msg)-len(sep); i++ {
		if msg[i:i+len(sep)] == sep {
			idx = i
			break
		}
	}
	if idx < 0 {
		return 0, 0, false
	}
	tail := msg[idx+len(sep):]
	n, _ := fmt.Sscanf(tail, "%d XP and %d gold", &xp, &gold)
	if n >= 2 {
		return xp, gold, true
	}
	return 0, 0, false
}

// isLevelUpLog checks if a message is a level-up log.
func isLevelUpLog(msg string) bool {
	return len(msg) > 12 && msg[:12] == "Leveled up t"
}

// isDeathLog checks if a message is a death log.
func isDeathLog(msg string) bool {
	return len(msg) > 14 && msg[:14] == "Died fighting "
}

// isPotionLog checks if a message is a potion usage log.
func isPotionLog(msg string) bool {
	return len(msg) > 20 && msg[:20] == "Used healing potion,"
}

// InitHero returns the hero for the given Telegram user, creating one with defaults if needed.
// Also simulates offline progress based on time since last update.
// GET /api/v1/hero/init
func (h *GameHandler) InitHero(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to init hero", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to init hero",
		})
		return
	}

	if hero == nil {
		townsWithNPCs := h.buildTownsWithNPCs(r.Context())
		writeJSON(w, http.StatusOK, map[string]any{
			"hero":          nil,
			"needsName":     true,
			"offlineReport": nil,
			"mapRef":        h.world.RefForLevel(1),
			"towns":         townsWithNPCs,
		})
		return
	}

	hero.XPToNext = model.XPToNextLevel(hero.Level)

	now := time.Now()
	simFrozen := h.engine != nil && h.engine.IsTimePaused()
	if !simFrozen {
		chargesInit := hero.EnsureBuffChargesPopulated(now)
		quotaRolled := hero.ApplyBuffQuotaRollover(now)
		if chargesInit || quotaRolled {
			if err := h.store.Save(r.Context(), hero); err != nil {
				h.logger.Warn("failed to persist buff charges init/rollover", "hero_id", hero.ID, "error", err)
			}
		}
	}

	// Catch-up simulation: cover the gap between hero.UpdatedAt and serverStartedAt
	// (the period when the server was down and the offline simulator wasn't running).
	offlineDuration := time.Since(hero.UpdatedAt)
	var catchUpPerformed bool
	if !simFrozen && hero.UpdatedAt.Before(h.serverStartedAt) && hero.State == model.StateWalking && hero.HP > 0 {
		catchUpPerformed = h.catchUpOfflineGap(r.Context(), hero)
	}

	// Build offline report from real adventure log entries (written by the
	// offline simulator and/or the catch-up above).
	report := h.buildOfflineReport(r.Context(), hero, offlineDuration)

	if catchUpPerformed {
		if err := h.store.Save(r.Context(), hero); err != nil {
			h.logger.Error("failed to save hero after catch-up sim", "hero_id", hero.ID, "error", err)
		}
	}

	// Auto-revive if hero has been dead for more than 1 hour (spec section 3.3).
	if !simFrozen && (hero.State == model.StateDead || hero.HP <= 0) && time.Since(hero.UpdatedAt) > time.Duration(tuning.Get().AutoReviveAfterMs)*time.Millisecond {
		hero.HP = int(float64(hero.MaxHP) * tuning.Get().ReviveHpPercent)
		if hero.HP < 1 {
			hero.HP = 1
		}
		hero.State = model.StateWalking
		hero.Debuffs = nil
		h.addLog(hero.ID, "Auto-revived after 1 hour")
		if err := h.store.Save(r.Context(), hero); err != nil {
			h.logger.Error("failed to save hero after auto-revive", "hero_id", hero.ID, "error", err)
		}
	}

	needsName := hero.Name == "" || hero.Name == "Hero"

	hero.RefreshDerivedCombatStats(now)

	// Build towns with NPCs for the frontend map.
	townsWithNPCs := h.buildTownsWithNPCs(r.Context())

	writeJSON(w, http.StatusOK, map[string]any{
		"hero":          hero,
		"needsName":     needsName,
		"offlineReport": report,
		"mapRef":        h.world.RefForLevel(hero.Level),
		"towns":         townsWithNPCs,
	})
}


// buildTownsWithNPCs loads all towns and their NPCs, returning a slice of
// TownWithNPCs suitable for the frontend map render.
func (h *GameHandler) buildTownsWithNPCs(ctx context.Context) []model.TownWithNPCs {
	towns, err := h.questStore.ListTowns(ctx)
	if err != nil {
		h.logger.Warn("failed to load towns for init response", "error", err)
		return []model.TownWithNPCs{}
	}

	allNPCs, err := h.questStore.ListAllNPCs(ctx)
	if err != nil {
		h.logger.Warn("failed to load npcs for init response", "error", err)
		return []model.TownWithNPCs{}
	}

	// Group NPCs by town ID for O(1) lookup.
	npcsByTown := make(map[int64][]model.NPC, len(towns))
	for _, n := range allNPCs {
		npcsByTown[n.TownID] = append(npcsByTown[n.TownID], n)
	}

	result := make([]model.TownWithNPCs, 0, len(towns))
	for _, t := range towns {
		tw := model.TownWithNPCs{
			ID:     t.ID,
			Name:   t.Name,
			Biome:  t.Biome,
			WorldX: t.WorldX,
			WorldY: t.WorldY,
			Radius: t.Radius,
			Size:   model.TownSizeFromRadius(t.Radius),
			NPCs:   make([]model.NPCView, 0),
		}
		for _, n := range npcsByTown[t.ID] {
			tw.NPCs = append(tw.NPCs, model.NPCView{
				ID:     n.ID,
				Name:   n.Name,
				Type:   n.Type,
				WorldX: t.WorldX + n.OffsetX,
				WorldY: t.WorldY + n.OffsetY,
			})
		}
		result = append(result, tw)
	}
	return result
}

// heroNameRequest is the JSON body for the set-name endpoint.
type heroNameRequest struct {
	Name string `json:"name"`
}

// isValidHeroName checks that a name is 2-16 chars, only latin/cyrillic letters and digits,
// no leading/trailing spaces.
func isValidHeroName(name string) bool {
	if len(name) < 2 || len(name) > 16 {
		return false
	}
	if name[0] == ' ' || name[len(name)-1] == ' ' {
		return false
	}
	for _, r := range name {
		if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
			continue
		}
		if (r >= '0' && r <= '9') {
			continue
		}
		// Cyrillic block: U+0400 to U+04FF
		if r >= 0x0400 && r <= 0x04FF {
			continue
		}
		return false
	}
	return true
}

// SetHeroName sets the hero's name (first-time setup only).
// POST /api/v1/hero/name
func (h *GameHandler) SetHeroName(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req heroNameRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body",
		})
		return
	}

	if !isValidHeroName(req.Name) {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid name: must be 2-16 characters, letters (latin/cyrillic) and digits only",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for name", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		hero, err = h.store.CreateHeroWithSpawn(r.Context(), telegramID, req.Name)
		if err != nil {
			errStr := err.Error()
			if containsUniqueViolation(errStr) {
				writeJSON(w, http.StatusConflict, map[string]string{
					"error": "HERO_NAME_TAKEN",
				})
				return
			}
			h.logger.Error("failed to create hero", "telegram_id", telegramID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{
				"error": "failed to create hero",
			})
			return
		}
		now := time.Now()
		chargesInit := hero.EnsureBuffChargesPopulated(now)
		if chargesInit {
			if err := h.store.Save(r.Context(), hero); err != nil {
				h.logger.Warn("failed to persist buff charges after create", "hero_id", hero.ID, "error", err)
			}
		}
		hero.RefreshDerivedCombatStats(now)
		h.logger.Info("hero created with spawn", "hero_id", hero.ID, "name", req.Name)
		writeJSON(w, http.StatusOK, hero)
		return
	}

	// Only allow name change if it hasn't been set yet.
	if hero.Name != "" && hero.Name != "Hero" {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "name already set",
		})
		return
	}

	if err := h.store.SaveName(r.Context(), hero.ID, req.Name); err != nil {
		// Check for unique constraint violation (pgx wraps the error with code 23505).
		errStr := err.Error()
		if containsUniqueViolation(errStr) {
			writeJSON(w, http.StatusConflict, map[string]string{
				"error": "HERO_NAME_TAKEN",
			})
			return
		}
		h.logger.Error("failed to save hero name", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save name",
		})
		return
	}

	hero.Name = req.Name
	h.logger.Info("hero name set", "hero_id", hero.ID, "name", req.Name)

	now := time.Now()
	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, hero)
}

// containsUniqueViolation checks if an error message indicates a PostgreSQL unique violation.
func containsUniqueViolation(errStr string) bool {
	// pgx includes the SQLSTATE code 23505 for unique_violation.
	for _, marker := range []string{"23505", "unique", "UNIQUE", "duplicate key"} {
		for i := 0; i <= len(errStr)-len(marker); i++ {
			if errStr[i:i+len(marker)] == marker {
				return true
			}
		}
	}
	return false
}

// buffRefillRequest is the JSON body for the purchase-buff-refill endpoint.
type buffRefillRequest struct {
	BuffType string `json:"buffType"`
}

// PurchaseBuffRefill purchases a buff charge refill.
// POST /api/v1/hero/purchase-buff-refill
func (h *GameHandler) PurchaseBuffRefill(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req buffRefillRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body",
		})
		return
	}

	bt, ok := model.ValidBuffType(req.BuffType)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid buff type: " + req.BuffType,
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for buff refill", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	// Determine price.
	priceRUB := model.BuffRefillPrice()
	paymentType := model.PaymentBuffReplenish
	if bt == model.BuffResurrection {
		priceRUB = model.ResurrectionRefillPrice()
		paymentType = model.PaymentResurrectionReplenish
	}

	now := time.Now()

	// Create a payment record and auto-complete it (no real payment gateway yet).
	payment := &model.Payment{
		HeroID:      hero.ID,
		Type:        paymentType,
		BuffType:    string(bt),
		AmountRUB:   priceRUB,
		Status:      model.PaymentCompleted,
		CreatedAt:   now,
		CompletedAt: &now,
	}
	if err := h.store.CreatePayment(r.Context(), payment); err != nil {
		h.logger.Error("failed to create payment", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to process payment",
		})
		return
	}

	// Refill the specific buff's charges to max.
	hero.ResetBuffCharges(&bt, now)

	if err := h.store.Save(r.Context(), hero); err != nil {
		h.logger.Error("failed to save hero after buff refill", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save hero",
		})
		return
	}

	h.logger.Info("purchased buff refill",
		"hero_id", hero.ID,
		"buff_type", bt,
		"price_rub", priceRUB,
	)
	h.addLog(hero.ID, fmt.Sprintf("Purchased buff refill: %s", bt))

	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, hero)
}

// PurchaseSubscription purchases the configured subscription duration (x2 buffs, x2 revives).
// POST /api/v1/hero/purchase-subscription
func (h *GameHandler) PurchaseSubscription(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("subscription: load hero failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
		return
	}

	now := time.Now()

	payment := &model.Payment{
		HeroID:      hero.ID,
		Type:        "subscription_weekly",
		AmountRUB:   int(model.SubscriptionWeeklyPrice()),
		Status:      model.PaymentCompleted,
		CreatedAt:   now,
		CompletedAt: &now,
	}
	if err := h.store.CreatePayment(r.Context(), payment); err != nil {
		h.logger.Error("subscription: payment failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to process payment"})
		return
	}

	hero.ActivateSubscription(now)

	// Upgrade buff charges to subscriber limits immediately.
	hero.EnsureBuffChargesPopulated(now)
	for bt := range model.BuffFreeChargesPerType {
		state := hero.GetBuffCharges(bt, now)
		subMax := hero.MaxBuffCharges(bt)
		if state.Remaining < subMax {
			state.Remaining = subMax
			hero.BuffCharges[string(bt)] = state
		}
	}

	if err := h.store.Save(r.Context(), hero); err != nil {
		h.logger.Error("subscription: save failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save"})
		return
	}

	h.logger.Info("subscription purchased", "hero_id", hero.ID, "expires_at", hero.SubscriptionExpiresAt)
	h.addLog(hero.ID, fmt.Sprintf("Subscribed for %s (%d₽) — x2 buffs & revives!", model.SubscriptionDurationLabel(), model.SubscriptionWeeklyPrice()))

	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, map[string]any{
		"hero":      hero,
		"expiresAt": hero.SubscriptionExpiresAt,
		"priceRub":  model.SubscriptionWeeklyPrice(),
	})
}

// GetLoot returns the hero's recent loot history.
// GET /api/v1/hero/loot
func (h *GameHandler) GetLoot(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil || hero == nil {
		writeJSON(w, http.StatusOK, map[string]any{
			"loot": []model.LootHistory{},
		})
		return
	}

	h.lootMu.RLock()
	loot := h.lootCache[hero.ID]
	h.lootMu.RUnlock()

	if loot == nil {
		loot = []model.LootHistory{}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"loot": loot,
	})
}

// UsePotion consumes a healing potion, restoring 30% of maxHP.
// POST /api/v1/hero/use-potion
func (h *GameHandler) UsePotion(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	storeHero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil || storeHero == nil {
		h.logger.Error("failed to get hero for potion", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	var hero = h.engine.GetMovements(storeHero.ID).Hero
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	if hero.Potions <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "no potions available",
		})
		return
	}

	if hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is dead",
		})
		return
	}

	// Heal 30% of maxHP, capped at maxHP.
	healAmount := int(float64(hero.MaxHP) * tuning.Get().PotionHealPercent)
	if healAmount < 1 {
		healAmount = 1
	}
	hero.HP += healAmount
	if hero.HP > hero.MaxHP {
		hero.HP = hero.MaxHP
	}
	hero.Potions--

	if err := h.store.Save(r.Context(), hero); err != nil {
		h.logger.Error("failed to save hero after potion", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save hero",
		})
		return
	}

	h.addLog(hero.ID, fmt.Sprintf("Used healing potion, restored %d HP", healAmount))

	now := time.Now()
	hero.RefreshDerivedCombatStats(now)
	writeJSON(w, http.StatusOK, hero)
}

// GetAdventureLog returns the hero's recent adventure log entries.
// GET /api/v1/hero/log
func (h *GameHandler) GetAdventureLog(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil || hero == nil {
		writeJSON(w, http.StatusOK, map[string]any{
			"log": []storage.LogEntry{},
		})
		return
	}

	limitStr := r.URL.Query().Get("limit")
	limit := 50
	if limitStr != "" {
		if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
			limit = parsed
		}
	}

	entries, err := h.logStore.GetRecent(r.Context(), hero.ID, limit)
	if err != nil {
		h.logger.Error("failed to get adventure log", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load adventure log",
		})
		return
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"log": entries,
	})
}

// GetWeapons returns gear catalog entries for the main_hand slot (backward-compatible).
// GET /api/v1/weapons
func (h *GameHandler) GetWeapons(w http.ResponseWriter, r *http.Request) {
	var items []model.GearFamily
	for _, gf := range model.GearCatalog {
		if gf.Slot == model.SlotMainHand {
			items = append(items, gf)
		}
	}
	writeJSON(w, http.StatusOK, items)
}

// GetArmor returns gear catalog entries for the chest slot (backward-compatible).
// GET /api/v1/armor
func (h *GameHandler) GetArmor(w http.ResponseWriter, r *http.Request) {
	var items []model.GearFamily
	for _, gf := range model.GearCatalog {
		if gf.Slot == model.SlotChest {
			items = append(items, gf)
		}
	}
	writeJSON(w, http.StatusOK, items)
}

// GetGearCatalog returns the full unified gear catalog.
// GET /api/v1/gear/catalog
func (h *GameHandler) GetGearCatalog(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, model.GearCatalog)
}

// GetHeroGear returns all equipped gear for the hero organized by slot.
// GET /api/v1/hero/gear
func (h *GameHandler) GetHeroGear(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for gear", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	writeJSON(w, http.StatusOK, hero.Gear)
}

// NearbyHeroes returns other heroes near the requesting hero for shared world rendering.
// GET /api/v1/hero/nearby
func (h *GameHandler) NearbyHeroes(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	hero, err := h.store.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for nearby", "telegram_id", telegramID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load hero",
		})
		return
	}
	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	// Default radius: 500 units, max 50 heroes.
	radius := 500.0
	if rStr := r.URL.Query().Get("radius"); rStr != "" {
		if parsed, err := strconv.ParseFloat(rStr, 64); err == nil && parsed > 0 {
			radius = parsed
		}
	}
	if radius > 2000 {
		radius = 2000
	}

	nearby, err := h.store.GetNearbyHeroes(r.Context(), hero.ID, hero.PositionX, hero.PositionY, radius, 50)
	if err != nil {
		h.logger.Error("failed to get nearby heroes", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load nearby heroes",
		})
		return
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"heroes": nearby,
	})
}

func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	json.NewEncoder(w).Encode(v)
}

// checkAchievementsAfterKill runs achievement condition checks and applies rewards.
func (h *GameHandler) checkAchievementsAfterKill(hero *model.Hero) {
	if h.achievementStore == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	newlyUnlocked, err := h.achievementStore.CheckAndUnlock(ctx, hero)
	if err != nil {
		h.logger.Warn("achievement check failed", "hero_id", hero.ID, "error", err)
		return
	}

	for _, a := range newlyUnlocked {
		// Apply reward.
		switch a.RewardType {
		case "gold":
			hero.Gold += int64(a.RewardAmount)
		case "potion":
			hero.Potions += a.RewardAmount
		}
		h.addLog(hero.ID, fmt.Sprintf("Achievement unlocked: %s! (+%d %s)", a.Title, a.RewardAmount, a.RewardType))
	}
}

// progressTasksAfterKill increments daily/weekly task progress after a kill.
func (h *GameHandler) progressTasksAfterKill(heroID int64, enemy *model.Enemy, drops []model.LootDrop) {
	if h.taskStore == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// Ensure tasks exist for current period.
	if err := h.taskStore.EnsureHeroTasks(ctx, heroID, time.Now()); err != nil {
		h.logger.Warn("task ensure failed", "hero_id", heroID, "error", err)
		return
	}

	// kill_count tasks.
	if err := h.taskStore.IncrementTaskProgress(ctx, heroID, "kill_count", 1); err != nil {
		h.logger.Warn("task kill_count progress failed", "hero_id", heroID, "error", err)
	}

	// elite_kill tasks.
	if enemy.IsElite {
		if err := h.taskStore.IncrementTaskProgress(ctx, heroID, "elite_kill", 1); err != nil {
			h.logger.Warn("task elite_kill progress failed", "hero_id", heroID, "error", err)
		}
	}

	// collect_gold tasks: sum gold gained from all drops.
	var goldGained int64
	for _, drop := range drops {
		if drop.ItemType == "gold" {
			goldGained += drop.GoldAmount
		} else if drop.GoldAmount > 0 {
			goldGained += drop.GoldAmount // auto-sell gold
		}
	}
	if goldGained > 0 {
		if err := h.taskStore.IncrementTaskProgress(ctx, heroID, "collect_gold", int(goldGained)); err != nil {
			h.logger.Warn("task collect_gold progress failed", "hero_id", heroID, "error", err)
		}
	}
}

// HandleHeroDeath updates stat tracking when a hero dies.
// Should be called whenever a hero transitions to dead state.
func (h *GameHandler) HandleHeroDeath(hero *model.Hero) {
	hero.TotalDeaths++
	hero.KillsSinceDeath = 0
}

// progressQuestsAfterKill updates quest progress for kill_count and collect_item quests.
func (h *GameHandler) progressQuestsAfterKill(heroID int64, enemy *model.Enemy) {
	if h.questStore == nil {
		return
	}
	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
	defer cancel()

	// kill_count quests: increment with the specific enemy type.
	if err := h.questStore.IncrementQuestProgress(ctx, heroID, "kill_count", string(enemy.Type), 1); err != nil {
		h.logger.Warn("quest kill_count progress failed", "hero_id", heroID, "error", err)
	}

	// collect_item quests: roll per-quest drop chance.
	if err := h.questStore.IncrementCollectItemProgress(ctx, heroID, string(enemy.Type)); err != nil {
		h.logger.Warn("quest collect_item progress failed", "hero_id", heroID, "error", err)
	}
}

// NOTE: processExtendedEquipmentDrop and tryAutoEquipExtended have been removed.
// All equipment slot drops are now handled uniformly in processVictoryRewards
// via the unified GearCatalog and tryAutoEquipGear.
