package handler

import (
	"context"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log/slog"
	"math"
	"net/http"
	"runtime"
	"strconv"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/jackc/pgx/v5/pgxpool"

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

var serverStartedAt = time.Now()

// AdminHandler provides administrative endpoints for hero management,
// engine inspection, and server diagnostics.
type AdminHandler struct {
	store      *storage.HeroStore
	gearStore  *storage.GearStore
	questStore *storage.QuestStore
	engine     *game.Engine
	hub        *Hub
	pool       *pgxpool.Pool
	logger     *slog.Logger
	adminUser  string
	adminPass  string
}

// NewAdminHandler creates a new AdminHandler with all required dependencies.
func NewAdminHandler(store *storage.HeroStore, gearStore *storage.GearStore, questStore *storage.QuestStore, engine *game.Engine, hub *Hub, pool *pgxpool.Pool, logger *slog.Logger, adminUser, adminPass string) *AdminHandler {
	return &AdminHandler{
		store:      store,
		gearStore:  gearStore,
		questStore: questStore,
		engine:     engine,
		hub:        hub,
		pool:       pool,
		logger:     logger,
		adminUser:  adminUser,
		adminPass:  adminPass,
	}
}

// ── Hero Management ─────────────────────────────────────────────────

type heroSummary struct {
	ID         int64           `json:"id"`
	TelegramID int64           `json:"telegramId"`
	Name       string          `json:"name"`
	Level      int             `json:"level"`
	Gold       int64           `json:"gold"`
	HP         int             `json:"hp"`
	MaxHP      int             `json:"maxHp"`
	State      model.GameState `json:"state"`
	UpdatedAt  time.Time       `json:"updatedAt"`
}

// adminTownTourLiveJSON is a snapshot of ExcursionKindTown for the admin UI.
type adminTownTourLiveJSON struct {
	Phase           string     `json:"phase,omitempty"`
	NpcID           int64      `json:"npcId,omitempty"`
	TownTourEndsAt  *time.Time `json:"townTourEndsAt,omitempty"`
	WelcomeUntil    *time.Time `json:"townWelcomeUntil,omitempty"`
	ServiceUntil    *time.Time `json:"townServiceUntil,omitempty"`
	RestUntil       *time.Time `json:"townRestUntil,omitempty"`
	WanderNextAt    *time.Time `json:"wanderNextAt,omitempty"`
	ExitPending     bool       `json:"townExitPending,omitempty"`
	DialogOpen      bool       `json:"townTourDialogOpen,omitempty"`
	InteractionOpen bool       `json:"townTourInteractionOpen,omitempty"`
	StandX          float64    `json:"townTourStandX,omitempty"`
	StandY          float64    `json:"townTourStandY,omitempty"`
}

// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
	Online                    bool                   `json:"online"`
	RestUntil                 *time.Time             `json:"restUntil,omitempty"`
	TownLeaveAt               *time.Time             `json:"townLeaveAt,omitempty"`
	NextTownNPCRollAt         *time.Time             `json:"nextTownNPCRollAt,omitempty"`
	CurrentTownID             int64                  `json:"currentTownId,omitempty"`
	DestinationTownID         int64                  `json:"destinationTownId,omitempty"`
	WanderingMerchantDeadline *time.Time             `json:"wanderingMerchantDeadline,omitempty"`
	ExcursionKind             string                 `json:"excursionKind,omitempty"`
	ExcursionPhase            string                 `json:"excursionPhase,omitempty"`
	ExcursionWildUntil        *time.Time             `json:"excursionWildUntil,omitempty"`
	ExcursionReturnUntil      *time.Time             `json:"excursionReturnUntil,omitempty"`
	TownTour                  *adminTownTourLiveJSON `json:"townTour,omitempty"`
}

// adminHeroDetailResponse is the full admin JSON for one hero: base hero + persisted town_pause + live movement snapshot.
type adminHeroDetailResponse struct {
	model.Hero
	TownPause         *model.TownPausePersisted `json:"townPause,omitempty"`
	AdminLiveMovement *adminLiveMovementJSON    `json:"adminLiveMovement,omitempty"`
	HeroMovement      *game.HeroMovement        `json:"heroMovement,omitempty"`
}

// adminCombatLiveJSON is the active engine combat session for admin live WS (enemy is full runtime instance + tuning breakdown).
type adminCombatLiveJSON struct {
	Enemy model.Enemy `json:"enemy"`
	// EnemyStatsBasePreEncounterMult: level-scaled MaxHP/Attack/Defense before encounter multipliers.
	EnemyStatsBasePreEncounterMult *model.EncounterCombatStatsSnapshot `json:"enemyStatsBasePreEncounterMult,omitempty"`
	// EnemyStatsAfterGlobalEncounterMult: same after global encounter mult only (before unequipped scaling).
	EnemyStatsAfterGlobalEncounterMult *model.EncounterCombatStatsSnapshot    `json:"enemyStatsAfterGlobalEncounterMult,omitempty"`
	Multipliers                        game.EnemyEncounterMultiplierBreakdown `json:"multipliers"`
	HeroNextAttack                     time.Time                              `json:"heroNextAttack"`
	EnemyNextAttack                    time.Time                              `json:"enemyNextAttack"`
	StartedAt                          time.Time                              `json:"startedAt"`
}

// adminWSSnapshot is the admin live WebSocket payload: hero detail + last hero_move (client WS) sample.
type adminWSSnapshot struct {
	Hero     adminHeroDetailResponse `json:"hero"`
	HeroMove *model.HeroMovePayload  `json:"heroMove"`
	Combat   *adminCombatLiveJSON    `json:"combat,omitempty"`
}

type simulateCombatRequest struct {
	HeroID           int64  `json:"heroId"`
	EnemyType        string `json:"enemyType"`
	EnemyLevel       int    `json:"enemyLevel,omitempty"`
	TickRateMs       int64  `json:"tickRateMs,omitempty"`
	WallClockDelayMs int64  `json:"wallClockDelayMs,omitempty"`
	MaxEvents        int    `json:"maxEvents,omitempty"`
}

type simulateCombatResponse struct {
	HeroID            int64               `json:"heroId"`
	HeroName          string              `json:"heroName"`
	EnemyType         string              `json:"enemyType"`
	EnemyName         string              `json:"enemyName"`
	EnemyLevel        int                 `json:"enemyLevel"`
	Survived          bool                `json:"survived"`
	ElapsedMs         int64               `json:"elapsedMs"`
	InitialHeroHp     int                 `json:"initialHeroHp"`
	InitialHeroMaxHp  int                 `json:"initialHeroMaxHp"`
	InitialEnemyHp    int                 `json:"initialEnemyHp"`
	InitialEnemyMaxHp int                 `json:"initialEnemyMaxHp"`
	FinalHeroHP       int                 `json:"finalHeroHp"`
	FinalEnemyHP      int                 `json:"finalEnemyHp"`
	WallClockDelayMs  int64               `json:"wallClockDelayMs"`
	TickRateMs        int64               `json:"tickRateMs"`
	Events            []model.CombatEvent `json:"events"`
}

func buildAdminLiveMovementSnap(hm *game.HeroMovement) *adminLiveMovementJSON {
	if hm == nil {
		return nil
	}
	s := &adminLiveMovementJSON{
		Online: true,
	}
	if !hm.RestUntil.IsZero() {
		t := hm.RestUntil
		s.RestUntil = &t
	}
	if !hm.TownLeaveAt.IsZero() {
		t := hm.TownLeaveAt
		s.TownLeaveAt = &t
	}
	if !hm.NextTownNPCRollAt.IsZero() {
		t := hm.NextTownNPCRollAt
		s.NextTownNPCRollAt = &t
	}
	if hm.CurrentTownID != 0 {
		s.CurrentTownID = hm.CurrentTownID
	}
	if hm.DestinationTownID != 0 {
		s.DestinationTownID = hm.DestinationTownID
	}
	if !hm.WanderingMerchantDeadline.IsZero() {
		t := hm.WanderingMerchantDeadline
		s.WanderingMerchantDeadline = &t
	}
	if hm.Excursion.Active() {
		s.ExcursionKind = string(hm.Excursion.Kind)
		s.ExcursionPhase = string(hm.Excursion.Phase)
		if !hm.Excursion.WildUntil.IsZero() {
			t := hm.Excursion.WildUntil
			s.ExcursionWildUntil = &t
		}
		if !hm.Excursion.ReturnUntil.IsZero() {
			t := hm.Excursion.ReturnUntil
			s.ExcursionReturnUntil = &t
		}
	}
	if hm.Excursion.Kind == model.ExcursionKindTown {
		ex := hm.Excursion
		tt := &adminTownTourLiveJSON{
			Phase:           ex.TownTourPhase,
			NpcID:           ex.TownTourNpcID,
			ExitPending:     ex.TownExitPending,
			DialogOpen:      ex.TownTourDialogOpen,
			InteractionOpen: ex.TownTourInteractionOpen,
			StandX:          ex.TownTourStandX,
			StandY:          ex.TownTourStandY,
		}
		if !ex.TownTourEndsAt.IsZero() {
			t := ex.TownTourEndsAt
			tt.TownTourEndsAt = &t
		}
		if !ex.TownWelcomeUntil.IsZero() {
			t := ex.TownWelcomeUntil
			tt.WelcomeUntil = &t
		}
		if !ex.TownServiceUntil.IsZero() {
			t := ex.TownServiceUntil
			tt.ServiceUntil = &t
		}
		if !ex.TownRestUntil.IsZero() {
			t := ex.TownRestUntil
			tt.RestUntil = &t
		}
		if !ex.WanderNextAt.IsZero() {
			t := ex.WanderNextAt
			tt.WanderNextAt = &t
		}
		s.TownTour = tt
	}
	return s
}

func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.Hero) {
	out, err := h.buildAdminHeroDetail(hero)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, out)
}

func (h *AdminHandler) buildAdminHeroDetail(hero *model.Hero) (adminHeroDetailResponse, error) {
	if hero == nil {
		return adminHeroDetailResponse{}, fmt.Errorf("nil hero")
	}
	now := time.Now()
	hero.RefreshDerivedCombatStats(now)
	out := adminHeroDetailResponse{Hero: *hero, TownPause: hero.TownPause}
	if hm := h.engine.GetMovements(hero.ID); hm != nil && hm.Hero != nil {
		out.Hero = *hm.Hero
		out.Hero.RefreshDerivedCombatStats(now)
		out.TownPause = hm.Hero.TownPause
		out.AdminLiveMovement = buildAdminLiveMovementSnap(hm)
		out.HeroMovement = hm
	} else if rg := h.engine.RoadGraph(); rg != nil {
		out.HeroMovement = game.NewHeroMovement(hero, rg, now)
	}
	return out, nil
}

func (h *AdminHandler) buildAdminWSSnapshot(ctx context.Context, heroID int64) (adminWSSnapshot, error) {
	hero, err := h.store.GetByID(ctx, heroID)
	if err != nil {
		return adminWSSnapshot{}, err
	}
	if hero == nil {
		return adminWSSnapshot{}, fmt.Errorf("hero not found")
	}
	detail, err := h.buildAdminHeroDetail(hero)
	if err != nil {
		return adminWSSnapshot{}, err
	}
	now := time.Now()
	var move *model.HeroMovePayload
	if hm := h.engine.GetMovements(heroID); hm != nil {
		p := hm.MovePayload(now)
		move = &p
	}
	var combat *adminCombatLiveJSON
	if h.engine != nil {
		if cs, ok := h.engine.GetCombat(heroID); ok {
			multHero := cs.Hero
			if multHero == nil {
				multHero = &detail.Hero
			}
			combat = &adminCombatLiveJSON{
				Enemy:                              cs.Enemy,
				EnemyStatsBasePreEncounterMult:     cs.EnemyStatsBasePreEncounterMult,
				EnemyStatsAfterGlobalEncounterMult: cs.EnemyStatsAfterGlobalEncounterMult,
				Multipliers:                        game.EnemyEncounterMultiplierBreakdownForHero(multHero),
				HeroNextAttack:                     cs.HeroNextAttack,
				EnemyNextAttack:                    cs.EnemyNextAttack,
				StartedAt:                          cs.StartedAt,
			}
		}
	}
	return adminWSSnapshot{Hero: detail, HeroMove: move, Combat: combat}, nil
}

// ListHeroes returns a paginated list of all heroes.
// GET /admin/heroes?limit=20&offset=0
func (h *AdminHandler) ListHeroes(w http.ResponseWriter, r *http.Request) {
	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
	offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
	query := r.URL.Query().Get("query")
	if limit <= 0 {
		limit = 20
	}

	heroes, err := h.store.ListHeroesFiltered(r.Context(), limit, offset, query)
	if err != nil {
		h.logger.Error("admin: list heroes failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to list heroes",
		})
		return
	}

	summaries := make([]heroSummary, len(heroes))
	for i, hero := range heroes {
		summaries[i] = heroSummary{
			ID:         hero.ID,
			TelegramID: hero.TelegramID,
			Name:       hero.Name,
			Level:      hero.Level,
			Gold:       hero.Gold,
			HP:         hero.HP,
			MaxHP:      hero.MaxHP,
			State:      hero.State,
			UpdatedAt:  hero.UpdatedAt,
		}
	}

	writeJSON(w, http.StatusOK, map[string]any{
		"heroes": summaries,
		"limit":  limit,
		"offset": offset,
		"query":  query,
	})
}

// ListPayments returns payments with optional hero filter.
// GET /admin/payments?heroId=1&limit=50&offset=0
func (h *AdminHandler) ListPayments(w http.ResponseWriter, r *http.Request) {
	limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
	offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
	heroID, _ := strconv.ParseInt(r.URL.Query().Get("heroId"), 10, 64)

	payments, err := h.store.ListPayments(r.Context(), heroID, limit, offset)
	if err != nil {
		h.logger.Error("admin: list payments failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list payments"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"payments": payments,
		"limit":    limit,
		"offset":   offset,
		"heroId":   heroID,
	})
}

// GetPayment returns a single payment by ID.
// GET /admin/payments/{paymentId}
func (h *AdminHandler) GetPayment(w http.ResponseWriter, r *http.Request) {
	paymentID, err := parsePaymentID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid paymentId: " + err.Error()})
		return
	}
	p, err := h.store.GetPaymentByID(r.Context(), paymentID)
	if err != nil {
		h.logger.Error("admin: get payment failed", "payment_id", paymentID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load payment"})
		return
	}
	if p == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "payment not found"})
		return
	}
	writeJSON(w, http.StatusOK, p)
}

// GetHeroGear returns hero equipped and inventory items.
// GET /admin/heroes/{heroId}/gear
func (h *AdminHandler) GetHeroGear(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	equipped, err := h.gearStore.GetHeroGear(r.Context(), heroID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load equipped gear"})
		return
	}
	inventory, err := h.gearStore.GetHeroInventory(r.Context(), heroID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load inventory"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"equipped":  equipped,
		"inventory": inventory,
	})
}

type grantGearRequest struct {
	Slot         string `json:"slot"`
	FormID       string `json:"formId"`
	Rarity       string `json:"rarity"`
	Ilvl         int    `json:"ilvl"`
	SourceGearID int64  `json:"sourceGearId"` // optional: clone existing `gear` row into a new instance for the hero
}

// GrantHeroGear creates a gear item and adds it to hero inventory.
// POST /admin/heroes/{heroId}/gear/grant
func (h *AdminHandler) GrantHeroGear(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	var req grantGearRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
		return
	}
	if req.SourceGearID > 0 {
		src, err := h.gearStore.GetItem(r.Context(), req.SourceGearID)
		if err != nil {
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load source gear"})
			return
		}
		if src == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "source gear not found"})
			return
		}
		clone := *src
		clone.ID = 0
		if err := h.gearStore.CreateItem(r.Context(), &clone); err != nil {
			h.logger.Error("admin: grant gear clone", "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear copy"})
			return
		}
		if err := h.gearStore.AddToInventory(r.Context(), heroID, clone.ID); err != nil {
			_ = h.gearStore.DeleteGearItem(r.Context(), clone.ID)
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
			return
		}
		if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "granted, but failed to sync hero snapshot"})
			return
		}
		h.GetHeroGear(w, r)
		return
	}
	slot, err := parseEquipmentSlot(req.Slot)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	rarity, err := parseRarity(req.Rarity)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	if req.Ilvl <= 0 {
		req.Ilvl = 1
	}
	if req.Ilvl > 200 {
		req.Ilvl = 200
	}

	var family *model.GearFamily
	for i := range model.GearCatalog {
		gf := model.GearCatalog[i]
		if gf.Slot == slot && (req.FormID == "" || gf.FormID == req.FormID) {
			family = &gf
			break
		}
	}
	if family == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear family not found for slot/formId"})
		return
	}

	item := model.NewGearItem(family, req.Ilvl, rarity)
	if err := h.gearStore.CreateItem(r.Context(), item); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create gear item"})
		return
	}
	if err := h.gearStore.AddToInventory(r.Context(), heroID, item.ID); err != nil {
		_ = h.gearStore.DeleteGearItem(r.Context(), item.ID)
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "granted, but failed to sync hero snapshot"})
		return
	}
	h.GetHeroGear(w, r)
}

type equipGearRequest struct {
	ItemID int64 `json:"itemId"`
}

// EquipHeroGear equips an inventory item by ID.
// POST /admin/heroes/{heroId}/gear/equip
func (h *AdminHandler) EquipHeroGear(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	var req equipGearRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.ItemID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
		return
	}
	item, err := h.gearStore.GetItem(r.Context(), req.ItemID)
	if err != nil || item == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "gear item not found"})
		return
	}
	if err := h.gearStore.EquipItem(r.Context(), heroID, item.Slot, item.ID); err != nil {
		if errors.Is(err, storage.ErrInventoryFull) {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "inventory full — free a backpack slot to swap this piece",
			})
			return
		}
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to equip item"})
		return
	}
	if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "equipped, but failed to sync hero snapshot"})
		return
	}
	h.GetHeroGear(w, r)
}

type unequipGearRequest struct {
	Slot string `json:"slot"`
}

// UnequipHeroGear unequips a slot.
// POST /admin/heroes/{heroId}/gear/unequip
func (h *AdminHandler) UnequipHeroGear(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	var req unequipGearRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
		return
	}
	slot, err := parseEquipmentSlot(req.Slot)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	if err := h.gearStore.UnequipSlot(r.Context(), heroID, slot); err != nil {
		if errors.Is(err, storage.ErrInventoryFull) {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "inventory full — free a backpack slot before unequipping",
			})
			return
		}
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to unequip slot"})
		return
	}
	if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unequipped, but failed to sync hero snapshot"})
		return
	}
	h.GetHeroGear(w, r)
}

// DeleteHeroGear deletes an item row.
// DELETE /admin/heroes/{heroId}/gear/{itemId}
func (h *AdminHandler) DeleteHeroGear(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	itemID, err := parseItemID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid itemId: " + err.Error()})
		return
	}
	if err := h.gearStore.DeleteGearItem(r.Context(), itemID); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	if err := h.syncHeroSnapshot(r.Context(), heroID); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "deleted, but failed to sync hero snapshot"})
		return
	}
	h.GetHeroGear(w, r)
}

// GearCatalog returns available gear families.
// GET /admin/gear/catalog
func (h *AdminHandler) GearCatalog(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"catalog": model.GearCatalog})
}

// GetHeroQuests returns all quests assigned to a hero.
// GET /admin/heroes/{heroId}/quests
func (h *AdminHandler) GetHeroQuests(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	quests, err := h.questStore.ListHeroQuests(r.Context(), heroID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero quests"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"quests": quests})
}

// AcceptHeroQuest accepts quest for a hero.
// POST /admin/heroes/{heroId}/quests/{questId}/accept
func (h *AdminHandler) AcceptHeroQuest(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	questID, err := parseQuestID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()})
		return
	}
	if err := h.questStore.AcceptQuest(r.Context(), heroID, questID); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "accepted"})
}

// ClaimHeroQuest claims a completed quest reward for a hero.
// POST /admin/heroes/{heroId}/quests/{questId}/claim
func (h *AdminHandler) ClaimHeroQuest(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	questID, err := parseQuestID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()})
		return
	}
	reward, err := h.questStore.ClaimQuestReward(r.Context(), heroID, questID)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil || hero == nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
		return
	}
	hero.XP += reward.XP
	hero.Gold += reward.Gold
	hero.Potions += reward.Potions
	for hero.LevelUp() {
	}
	if err := h.store.Save(r.Context(), hero); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero rewards"})
		return
	}
	writeHeroJSON(w, http.StatusOK, hero)
}

// AbandonHeroQuest removes quest from hero log.
// DELETE /admin/heroes/{heroId}/quests/{questId} — questId is hero_quests.id (log row).
func (h *AdminHandler) AbandonHeroQuest(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	questID, err := parseQuestID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId: " + err.Error()})
		return
	}
	if err := h.questStore.AbandonQuest(r.Context(), heroID, questID); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "abandoned"})
}

// ListTownsForQuests returns all towns for quest management.
// GET /admin/quests/towns
func (h *AdminHandler) ListTownsForQuests(w http.ResponseWriter, r *http.Request) {
	towns, err := h.questStore.ListTowns(r.Context())
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list towns"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"towns": towns})
}

// ListTownNPCsForQuests returns NPCs in town.
// GET /admin/quests/towns/{townId}/npcs
func (h *AdminHandler) ListTownNPCsForQuests(w http.ResponseWriter, r *http.Request) {
	townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
	if err != nil || townID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
		return
	}
	npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"npcs": npcs})
}

type adminTownLayoutResponse struct {
	Town      *model.Town          `json:"town"`
	NPCs      []model.NPC          `json:"npcs"`
	Buildings []model.TownBuilding `json:"buildings"`
	Objects   []model.TownObject   `json:"objects"`
}

type adminTownLayoutNPCUpdate struct {
	ID         *int64  `json:"id,omitempty"`
	Name       string  `json:"name"`
	NameKey    string  `json:"nameKey"`
	Type       string  `json:"type"`
	OffsetX    float64 `json:"offsetX"`
	OffsetY    float64 `json:"offsetY"`
	BuildingID *int64  `json:"buildingId,omitempty"`
}

type adminTownLayoutBuildingUpdate struct {
	ID           *int64  `json:"id,omitempty"`
	BuildingType string  `json:"buildingType"`
	OffsetX      float64 `json:"offsetX"`
	OffsetY      float64 `json:"offsetY"`
	Facing       string  `json:"facing"`
	FootprintW   float64 `json:"footprintW"`
	FootprintH   float64 `json:"footprintH"`
}

type adminTownLayoutObjectUpdate struct {
	ID         *int64  `json:"id,omitempty"`
	ObjectType string  `json:"objectType"`
	Variant    int     `json:"variant"`
	OffsetX    float64 `json:"offsetX"`
	OffsetY    float64 `json:"offsetY"`
}

type adminTownLayoutRequest struct {
	NPCs              []adminTownLayoutNPCUpdate      `json:"npcs"`
	Buildings         []adminTownLayoutBuildingUpdate `json:"buildings"`
	Objects           []adminTownLayoutObjectUpdate   `json:"objects"`
	DeleteNPCIDs      []int64                         `json:"deleteNpcIds"`
	DeleteBuildingIDs []int64                         `json:"deleteBuildingIds"`
	DeleteObjectIDs   []int64                         `json:"deleteObjectIds"`
}

var adminTownNPCTypes = map[string]struct{}{
	"quest_giver":   {},
	"merchant":      {},
	"armorer":       {},
	"weapon":        {},
	"jeweler":       {},
	"bounty_hunter": {},
	"elder":         {},
	"healer":        {},
}

var adminTownBuildingTypes = map[string]struct{}{
	"house.quest_giver":   {},
	"house.merchant":      {},
	"house.armorer":       {},
	"house.weapon_smith":  {},
	"house.jeweler":       {},
	"house.bounty_hunter": {},
	"house.elder":         {},
	"house.healer":        {},
	"decoration.well":     {},
	"decoration.stall":    {},
	"decoration.signpost": {},
}

var adminTownObjectTypes = map[string]struct{}{
	"tree":     {},
	"rock":     {},
	"cart":     {},
	"barrel":   {},
	"bush":     {},
	"mushroom": {},
	"leaves":   {},
	"stump":    {},
	"bones":    {},
	"ruin":     {},
}

var adminTownBuildingFacings = map[string]struct{}{
	"north": {},
	"south": {},
	"east":  {},
	"west":  {},
}

func (h *AdminHandler) GetTownLayout(w http.ResponseWriter, r *http.Request) {
	townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
	if err != nil || townID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
		return
	}

	town, err := h.questStore.GetTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
		return
	}
	if town == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
		return
	}

	npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
		return
	}

	buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
		return
	}
	objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
		return
	}

	writeJSON(w, http.StatusOK, adminTownLayoutResponse{
		Town:      town,
		NPCs:      npcs,
		Buildings: buildings,
		Objects:   objects,
	})
}

func (h *AdminHandler) UpdateTownLayout(w http.ResponseWriter, r *http.Request) {
	townID, err := strconv.ParseInt(chi.URLParam(r, "townId"), 10, 64)
	if err != nil || townID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid townId"})
		return
	}

	town, err := h.questStore.GetTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load town"})
		return
	}
	if town == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "town not found"})
		return
	}

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

	npcUpdates := make([]storage.TownLayoutNPCUpdate, 0, len(req.NPCs))
	for _, n := range req.NPCs {
		if n.ID != nil && *n.ID <= 0 {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc id"})
			return
		}
		if _, ok := adminTownNPCTypes[n.Type]; !ok {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc type"})
			return
		}
		if math.IsNaN(n.OffsetX) || math.IsNaN(n.OffsetY) || math.IsInf(n.OffsetX, 0) || math.IsInf(n.OffsetY, 0) {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npc offsets"})
			return
		}
		if n.ID == nil && strings.TrimSpace(n.Name) == "" {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc name is required"})
			return
		}
		npcUpdates = append(npcUpdates, storage.TownLayoutNPCUpdate{
			ID:         n.ID,
			Name:       n.Name,
			NameKey:    n.NameKey,
			Type:       n.Type,
			OffsetX:    n.OffsetX,
			OffsetY:    n.OffsetY,
			BuildingID: n.BuildingID,
		})
	}

	buildingUpdates := make([]storage.TownLayoutBuildingUpsert, 0, len(req.Buildings))
	for _, b := range req.Buildings {
		if b.ID != nil && *b.ID <= 0 {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building id"})
			return
		}
		if _, ok := adminTownBuildingTypes[b.BuildingType]; !ok {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid buildingType"})
			return
		}
		if _, ok := adminTownBuildingFacings[b.Facing]; !ok {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building facing"})
			return
		}
		if math.IsNaN(b.OffsetX) || math.IsNaN(b.OffsetY) || math.IsInf(b.OffsetX, 0) || math.IsInf(b.OffsetY, 0) {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building offsets"})
			return
		}
		if b.FootprintW <= 0 || b.FootprintH <= 0 {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid building footprint"})
			return
		}
		buildingUpdates = append(buildingUpdates, storage.TownLayoutBuildingUpsert{
			ID:           b.ID,
			BuildingType: b.BuildingType,
			OffsetX:      b.OffsetX,
			OffsetY:      b.OffsetY,
			Facing:       b.Facing,
			FootprintW:   b.FootprintW,
			FootprintH:   b.FootprintH,
		})
	}

	objectUpdates := make([]storage.TownLayoutObjectUpsert, 0, len(req.Objects))
	for _, o := range req.Objects {
		if o.ID != nil && *o.ID <= 0 {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object id"})
			return
		}
		if _, ok := adminTownObjectTypes[o.ObjectType]; !ok {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object type"})
			return
		}
		if o.Variant < 0 || o.Variant > 1 {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object variant"})
			return
		}
		if math.IsNaN(o.OffsetX) || math.IsNaN(o.OffsetY) || math.IsInf(o.OffsetX, 0) || math.IsInf(o.OffsetY, 0) {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid object offsets"})
			return
		}
		objectUpdates = append(objectUpdates, storage.TownLayoutObjectUpsert{
			ID:         o.ID,
			ObjectType: o.ObjectType,
			Variant:    o.Variant,
			OffsetX:    o.OffsetX,
			OffsetY:    o.OffsetY,
		})
	}

	if err := h.questStore.UpdateTownLayout(
		r.Context(),
		townID,
		npcUpdates,
		buildingUpdates,
		objectUpdates,
		req.DeleteNPCIDs,
		req.DeleteBuildingIDs,
		req.DeleteObjectIDs,
	); err != nil {
		if errors.Is(err, storage.ErrTownLayoutMissing) {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "npc, building, or object not found in town"})
			return
		}
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to update town layout"})
		return
	}

	rg, err := game.LoadRoadGraph(r.Context(), h.pool)
	if err != nil {
		h.logger.Error("admin: reload road graph after town layout update", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to reload road graph"})
		return
	}
	h.engine.SetRoadGraph(rg)

	npcs, err := h.questStore.ListNPCsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npcs"})
		return
	}
	buildings, err := h.questStore.ListBuildingsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list buildings"})
		return
	}
	objects, err := h.questStore.ListTownObjectsByTown(r.Context(), townID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list objects"})
		return
	}

	writeJSON(w, http.StatusOK, adminTownLayoutResponse{
		Town:      town,
		NPCs:      npcs,
		Buildings: buildings,
		Objects:   objects,
	})
}

// ContentAllQuests returns all quest template rows (global content).
// GET /admin/content/quests
func (h *AdminHandler) ContentAllQuests(w http.ResponseWriter, r *http.Request) {
	quests, err := h.questStore.ListAllQuestTemplates(r.Context())
	if err != nil {
		h.logger.Error("admin: list all quest templates failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list quests"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"quests": quests})
}

// ContentGearBase returns template rows from the unified `gear` table (global item definitions).
// GET /admin/content/gear-base
// Optional query params:
//
//	query — ILIKE name/form_id/slot/subtype/rarity + exact id match;
//	slot, rarity, subtype — exact filters (AND with query when combined);
//	limit — cap rows (default 200 if only query; 500 if any filter but no limit; omit for full scan when no filters).
func (h *AdminHandler) ContentGearBase(w http.ResponseWriter, r *http.Request) {
	ctx := r.Context()
	q := strings.TrimSpace(r.URL.Query().Get("query"))
	slotF := strings.TrimSpace(r.URL.Query().Get("slot"))
	rarityF := strings.TrimSpace(r.URL.Query().Get("rarity"))
	subtypeF := strings.TrimSpace(r.URL.Query().Get("subtype"))
	limit := 0
	if v := r.URL.Query().Get("limit"); v != "" {
		if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
			limit = n
		}
	}

	var clauses []string
	var args []any
	n := 1
	if slotF != "" {
		clauses = append(clauses, fmt.Sprintf("slot = $%d", n))
		args = append(args, slotF)
		n++
	}
	if rarityF != "" {
		clauses = append(clauses, fmt.Sprintf("LOWER(rarity) = LOWER($%d)", n))
		args = append(args, rarityF)
		n++
	}
	if subtypeF != "" {
		clauses = append(clauses, fmt.Sprintf("subtype = $%d", n))
		args = append(args, subtypeF)
		n++
	}
	if q != "" {
		pat := "%" + q + "%"
		clauses = append(clauses, fmt.Sprintf("(name ILIKE $%d OR form_id ILIKE $%d OR slot ILIKE $%d OR subtype ILIKE $%d OR rarity ILIKE $%d OR CAST(id AS TEXT) = $%d)",
			n, n, n, n, n, n+1))
		args = append(args, pat, q)
		n += 2
	}

	sqlText := `
		SELECT id, slot, form_id, name, subtype, rarity, ilvl, base_primary, primary_stat, stat_type,
		       speed_modifier, crit_chance, agility_bonus, set_name, special_effect
		FROM gear`
	if len(clauses) > 0 {
		sqlText += " WHERE " + strings.Join(clauses, " AND ")
	}
	sqlText += " ORDER BY id ASC"

	hasWhere := len(clauses) > 0
	if hasWhere && limit == 0 {
		limit = 500
	}
	if q != "" && limit == 0 {
		limit = 200
	}
	if limit > 0 {
		sqlText += ` LIMIT ` + strconv.Itoa(limit)
	}

	rows, err := h.pool.Query(ctx, sqlText, args...)
	if err != nil {
		h.logger.Error("admin: load gear templates failed", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load gear templates"})
		return
	}
	defer rows.Close()

	var list []map[string]any
	for rows.Next() {
		var id int64
		var slot, formID, name, subtype, rarity, statType, setName, special string
		var ilvl, basePri, pri, agi int
		var speedMod, crit float64
		if err := rows.Scan(
			&id, &slot, &formID, &name, &subtype, &rarity, &ilvl, &basePri, &pri, &statType,
			&speedMod, &crit, &agi, &setName, &special,
		); err != nil {
			h.logger.Error("admin: scan gear row", "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "scan gear"})
			return
		}
		list = append(list, map[string]any{
			"id": id, "slot": slot, "formId": formID, "name": name, "subtype": subtype,
			"rarity": rarity, "ilvl": ilvl, "basePrimary": basePri, "primaryStat": pri, "statType": statType,
			"speedModifier": speedMod, "critChance": crit, "agilityBonus": agi,
			"setName": setName, "specialEffect": special,
		})
	}
	if err := rows.Err(); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear rows"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"gear": list})
}

// ContentCreateGear inserts a new row into `gear`.
// POST /admin/content/gear
func (h *AdminHandler) ContentCreateGear(w http.ResponseWriter, r *http.Request) {
	var item model.GearItem
	if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	item.ID = 0
	if err := h.gearStore.CreateItem(r.Context(), &item); err != nil {
		h.logger.Error("admin: create gear", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{"id": item.ID, "gear": item})
}

// ContentUpdateGear updates a `gear` row by id.
// PUT /admin/content/gear/{gearId}
func (h *AdminHandler) ContentUpdateGear(w http.ResponseWriter, r *http.Request) {
	gearID, err := parseContentGearID(r)
	if err != nil || gearID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid gearId"})
		return
	}
	var item model.GearItem
	if err := json.NewDecoder(r.Body).Decode(&item); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	item.ID = gearID
	if err := h.gearStore.UpdateItem(r.Context(), &item); err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
			return
		}
		h.logger.Error("admin: update gear", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"gear": item})
}

func normalizeAdminQuest(q *model.Quest) {
	if q.MinLevel <= 0 {
		q.MinLevel = 1
	}
	if q.MaxLevel <= 0 {
		q.MaxLevel = 100
	}
	if q.TargetCount <= 0 {
		q.TargetCount = 1
	}
}

// ContentCreateQuest inserts a quest template row.
// POST /admin/content/quests
func (h *AdminHandler) ContentCreateQuest(w http.ResponseWriter, r *http.Request) {
	var q model.Quest
	if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	q.ID = 0
	normalizeAdminQuest(&q)
	if err := h.questStore.CreateQuestTemplate(r.Context(), &q); err != nil {
		h.logger.Error("admin: create quest", "error", err)
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusCreated, map[string]any{"id": q.ID, "quest": q})
}

// ContentUpdateQuest updates a quest template by id.
// PUT /admin/content/quests/{questId}
func (h *AdminHandler) ContentUpdateQuest(w http.ResponseWriter, r *http.Request) {
	qid, err := parseQuestID(r)
	if err != nil || qid <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid questId"})
		return
	}
	var q model.Quest
	if err := json.NewDecoder(r.Body).Decode(&q); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	q.ID = qid
	normalizeAdminQuest(&q)
	if err := h.questStore.UpdateQuestTemplate(r.Context(), &q); err != nil {
		if strings.Contains(err.Error(), "not found") {
			writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
			return
		}
		h.logger.Error("admin: update quest", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"quest": q})
}

// ListNPCQuestsForAdmin returns quest templates for NPC.
// GET /admin/quests/npcs/{npcId}
func (h *AdminHandler) ListNPCQuestsForAdmin(w http.ResponseWriter, r *http.Request) {
	npcID, err := strconv.ParseInt(chi.URLParam(r, "npcId"), 10, 64)
	if err != nil || npcID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid npcId"})
		return
	}
	quests, err := h.questStore.ListQuestsByNPC(r.Context(), npcID)
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list npc quests"})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"quests": quests})
}

// GetHero returns full hero detail by database ID.
// GET /admin/heroes/{heroId}
func (h *AdminHandler) GetHero(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero failed", "hero_id", heroID, "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
	}

	h.writeAdminHeroDetail(w, hero)
}

type setLevelRequest struct {
	Level int `json:"level"`
}

// SetHeroLevel sets the hero to a target level by resetting to level 1 (base stats, buffs cleared)
// and applying LevelUp() in a loop with XP filled to the threshold each step, matching normal
// progression (gold is preserved).
// POST /admin/heroes/{heroId}/set-level
func (h *AdminHandler) SetHeroLevel(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	var req setLevelRequest
	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.Level < 1 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "level must be >= 1",
		})
		return
	}
	const maxAdminLevel = 200
	if req.Level > maxAdminLevel {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "level must be <= 200",
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for set-level", "hero_id", heroID, "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 h.isHeroInCombat(w, heroID) {
		return
	}

	savedGold := hero.Gold
	resetHeroToLevel1(hero)
	hero.Gold = savedGold

	for hero.Level < req.Level {
		hero.XP = model.XPToNextLevel(hero.Level)
		if !hero.LevelUp() {
			break
		}
	}

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

	h.logger.Info("admin: hero level set", "hero_id", heroID, "level", hero.Level)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(time.Now())
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

type setGoldRequest struct {
	Gold int64 `json:"gold"`
}

// SetHeroGold sets the hero's gold to an exact value.
// POST /admin/heroes/{heroId}/set-gold
func (h *AdminHandler) SetHeroGold(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	var req setGoldRequest
	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.Gold < 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "gold must be >= 0",
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for set-gold", "hero_id", heroID, "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 h.isHeroInCombat(w, heroID) {
		return
	}

	hero.Gold = req.Gold

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

	h.logger.Info("admin: hero gold set", "hero_id", heroID, "gold", hero.Gold)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(time.Now())
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

type addPotionsRequest struct {
	Potions int `json:"potions"`
}

// SetHeroGold sets the hero's gold to an exact value.
// POST /admin/heroes/{heroId}/add-potions
func (h *AdminHandler) AddPotions(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	var req addPotionsRequest
	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.Potions < 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "potions must be >= 1",
		})
		return
	}

	var hero = h.engine.GetMovements(heroID).Hero

	if hero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "hero not found",
		})
		return
	}

	hero.Potions += req.Potions

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

	h.logger.Info("admin: hero added potions", "hero_id", heroID, "potions", hero.Potions)
	hero.RefreshDerivedCombatStats(time.Now())
	h.engine.SyncHeroState(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

type setHPRequest struct {
	HP int `json:"hp"`
}

// SetHeroHP sets the hero's current HP, clamped to [1, maxHp].
// POST /admin/heroes/{heroId}/set-hp
func (h *AdminHandler) SetHeroHP(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

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

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for set-hp", "hero_id", heroID, "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 h.isHeroInCombat(w, heroID) {
		return
	}

	hp := req.HP
	if hp < 1 {
		hp = 1
	}
	if hp > hero.MaxHP {
		hp = hero.MaxHP
	}
	hero.HP = hp

	if hero.State == model.StateDead && hero.HP > 0 {
		hero.State = model.StateWalking
	}

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

	h.logger.Info("admin: hero HP set", "hero_id", heroID, "hp", hero.HP)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(time.Now())
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

// ReviveHero applies the same revive rules as the in-game revive button (partial HP, quota counters).
// POST /admin/heroes/{heroId}/revive
func (h *AdminHandler) ReviveHero(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for revive", "hero_id", heroID, "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
	}

	// Admin UI displays live in-engine state when the hero is online.
	// Use that same authoritative snapshot for revive validation to avoid
	// false "hero is not dead" when DB lagged behind live movement/combat.
	if h.engine != nil {
		if hm := h.engine.GetMovements(heroID); hm != nil && hm.Hero != nil {
			live := *hm.Hero
			hero = &live
		}
	}

	if !game.IsEffectivelyDead(hero) {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is not dead",
		})
		return
	}

	game.ApplyHeroReviveMechanical(hero)
	game.ApplyPlayerReviveProgressCounters(hero)

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

	h.engine.ApplyAdminHeroRevive(hero)

	h.logger.Info("admin: hero revived", "hero_id", heroID, "hp", hero.HP)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(time.Now())
	writeHeroJSON(w, http.StatusOK, hero)
}

type grantSubscriptionRequest struct {
	// Periods is how many subscription durations to add (stacking extends from current expiry). Default 1.
	Periods int `json:"periods"`
}

// GrantHeroSubscription activates or extends subscription like a purchase, without charging RUB (admin grant).
// POST /admin/heroes/{heroId}/grant-subscription
func (h *AdminHandler) GrantHeroSubscription(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	var req grantSubscriptionRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body: " + err.Error(),
		})
		return
	}
	periods := req.Periods
	if periods < 1 {
		periods = 1
	}
	if periods > 52 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "periods must be between 1 and 52",
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for grant-subscription", "hero_id", heroID, "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()
	for i := 0; i < periods; i++ {
		hero.ActivateSubscription(now)
	}

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

	payment := &model.Payment{
		HeroID:      hero.ID,
		Type:        model.PaymentType("subscription_admin"),
		AmountRUB:   0,
		Status:      model.PaymentCompleted,
		CreatedAt:   now,
		CompletedAt: &now,
	}
	if err := h.store.CreatePayment(r.Context(), payment); err != nil {
		h.logger.Error("admin: grant-subscription payment row", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to record payment",
		})
		return
	}

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

	h.logger.Info("admin: subscription granted",
		"hero_id", heroID,
		"periods", periods,
		"expires_at", hero.SubscriptionExpiresAt,
	)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(now)
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

// RevokeHeroSubscription removes subscription immediately (admin); clamps buff charges and revives to free tier.
// POST /admin/heroes/{heroId}/revoke-subscription
func (h *AdminHandler) RevokeHeroSubscription(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for revoke-subscription", "hero_id", heroID, "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.RevokeSubscription(now)

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

	h.logger.Info("admin: subscription revoked", "hero_id", heroID)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(now)
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

// ForceHeroDeath sets the hero to dead (HP 0, state dead), ends active combat, clears buffs/debuffs,
// and increments death stats when transitioning from alive.
// POST /admin/heroes/{heroId}/force-death
func (h *AdminHandler) ForceHeroDeath(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	h.engine.StopCombat(heroID)

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for force-death", "hero_id", heroID, "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
	}

	wasAlive := hero.State != model.StateDead && hero.HP > 0
	hero.HP = 0
	hero.State = model.StateDead
	hero.Buffs = nil
	hero.Debuffs = nil
	if wasAlive {
		hero.TotalDeaths++
		hero.KillsSinceDeath = 0
	}

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

	h.engine.ApplyAdminHeroDeath(hero, wasAlive)

	h.logger.Info("admin: hero force-death", "hero_id", heroID, "was_alive", wasAlive)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(time.Now())
	writeHeroJSON(w, http.StatusOK, hero)
}

// ResetHero resets a hero to fresh level 1 defaults.
// POST /admin/heroes/{heroId}/reset
func (h *AdminHandler) ResetHero(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for reset", "hero_id", heroID, "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 h.isHeroInCombat(w, heroID) {
		return
	}

	resetHeroToLevel1(hero)

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

	h.logger.Info("admin: hero reset", "hero_id", heroID)
	hero.RefreshDerivedCombatStats(time.Now())
	writeHeroJSON(w, http.StatusOK, hero)
}

// FullResetHero clears all gear and quests, equips the same random starter set as CreateHeroWithSpawn,
// and resets stats/progression to a newly created hero (100 gold, level 1, random town spawn).
// POST /admin/heroes/{heroId}/full-reset
func (h *AdminHandler) FullResetHero(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	if h.isHeroInCombat(w, heroID) {
		return
	}

	ctx := r.Context()

	if err := h.gearStore.WipeAllGearForHero(ctx, heroID); err != nil {
		h.logger.Error("admin: full-reset wipe gear", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to clear gear",
		})
		return
	}
	if err := h.store.ApplyRandomStarterGear(ctx, heroID); err != nil {
		h.logger.Error("admin: full-reset starter gear", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to create starter gear",
		})
		return
	}
	if err := h.questStore.DeleteAllHeroQuests(ctx, heroID); err != nil {
		h.logger.Error("admin: full-reset quests", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to clear quests",
		})
		return
	}

	hero, err := h.store.GetByID(ctx, heroID)
	if err != nil {
		h.logger.Error("admin: full-reset reload hero", "hero_id", heroID, "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
	}

	applyNewPlayerHeroDefaults(hero)
	if err := h.store.ApplyRandomSpawn(ctx, hero); err != nil {
		h.logger.Error("admin: full-reset spawn", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to assign spawn",
		})
		return
	}

	if err := h.store.Save(ctx, hero); err != nil {
		h.logger.Error("admin: save hero after full-reset", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save hero",
		})
		return
	}

	now := time.Now()
	h.logger.Info("admin: hero full reset", "hero_id", heroID)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(now)
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

type resetBuffChargesRequest struct {
	BuffType string `json:"buffType"` // optional — if empty, reset ALL
}

// ResetBuffCharges resets per-buff free charges to their maximums.
// If buffType is provided, only that buff is reset; otherwise all are reset.
// POST /admin/heroes/{heroId}/reset-buff-charges
func (h *AdminHandler) ResetBuffCharges(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

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

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for reset-buff-charges", "hero_id", heroID, "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 req.BuffType != "" {
		bt, ok := model.ValidBuffType(req.BuffType)
		if !ok {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "invalid buffType: " + req.BuffType,
			})
			return
		}
		hero.ResetBuffCharges(&bt, now)
		h.logger.Info("admin: buff charges reset (single)", "hero_id", heroID, "buff_type", bt)
	} else {
		hero.ResetBuffCharges(nil, now)
		h.logger.Info("admin: buff charges reset (all)", "hero_id", heroID)
	}

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

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

type applyBuffAdminRequest struct {
	BuffType string `json:"buffType"`
}

// ApplyHeroBuff applies a buff from the catalog without consuming free-charge quota (admin/testing).
// POST /admin/heroes/{heroId}/apply-buff
func (h *AdminHandler) ApplyHeroBuff(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

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

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for apply-buff", "hero_id", heroID, "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()
	if game.ApplyBuff(hero, bt, now) == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "buff could not be applied (unknown catalog entry)",
		})
		return
	}

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

	h.logger.Info("admin: buff applied", "hero_id", heroID, "buff_type", bt)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(now)
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

type applyDebuffAdminRequest struct {
	DebuffType string `json:"debuffType"`
}

// ApplyHeroDebuff applies a debuff from the catalog (admin/testing).
// POST /admin/heroes/{heroId}/apply-debuff
func (h *AdminHandler) ApplyHeroDebuff(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

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

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for apply-debuff", "hero_id", heroID, "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()
	if _, defOk := model.DebuffDefinition(dt); !defOk {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "debuff not in catalog: " + req.DebuffType,
		})
		return
	}
	game.ApplyDebuff(hero, dt, now)

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

	h.logger.Info("admin: debuff applied", "hero_id", heroID, "debuff_type", dt)
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(now)
	h.engine.ApplyAdminHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

// DeleteHero permanently removes a hero from the database.
// DELETE /admin/heroes/{heroId}
func (h *AdminHandler) DeleteHero(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for delete", "hero_id", heroID, "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
	}

	h.engine.StopCombat(heroID)

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

	h.logger.Info("admin: hero deleted", "hero_id", heroID)
	writeJSON(w, http.StatusOK, map[string]string{
		"status": "deleted",
	})
}

// ── Game Engine ─────────────────────────────────────────────────────

// EngineStatus returns operational status of the game engine.
// GET /admin/engine/status
func (h *AdminHandler) EngineStatus(w http.ResponseWriter, r *http.Request) {
	status := h.engine.Status()
	writeJSON(w, http.StatusOK, map[string]any{
		"running":         status.Running,
		"tickRateMs":      status.TickRate.Milliseconds(),
		"activeCombats":   status.ActiveCombats,
		"activeMovements": status.ActiveMovements,
		"timePaused":      status.TimePaused,
		"uptimeMs":        status.UptimeMs,
	})
}

type teleportTownRequest struct {
	TownID int64 `json:"townId"`
}

// ListTowns returns town ids from the loaded road graph (for admin teleport).
// GET /admin/towns
func (h *AdminHandler) ListTowns(w http.ResponseWriter, r *http.Request) {
	rg := h.engine.RoadGraph()
	if rg == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{
			"error": "road graph not loaded",
		})
		return
	}
	type row struct {
		ID     int64   `json:"id"`
		Name   string  `json:"name"`
		WorldX float64 `json:"worldX"`
		WorldY float64 `json:"worldY"`
	}
	out := make([]row, 0, len(rg.TownOrder))
	for _, id := range rg.TownOrder {
		if t := rg.Towns[id]; t != nil {
			out = append(out, row{ID: t.ID, Name: t.Name, WorldX: t.WorldX, WorldY: t.WorldY})
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{"towns": out})
}

// TeleportHeroTown moves the hero into a town (arrival logic: NPC tour or rest).
// POST /admin/heroes/{heroId}/teleport-town
func (h *AdminHandler) TeleportHeroTown(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	var req teleportTownRequest
	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.TownID == 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "townId is required",
		})
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for teleport", "hero_id", heroID, "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.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero must be alive and not in combat",
		})
		return
	}

	townID := req.TownID
	if hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminTeleportTown(heroID, townID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "teleport failed (unknown town or hero not online with movement)",
			})
			return
		}
		if err := h.store.Save(r.Context(), out); err != nil {
			h.logger.Error("admin: save after teleport", "hero_id", heroID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{
				"error": "failed to save hero",
			})
			return
		}
		out.RefreshDerivedCombatStats(time.Now())
		h.logger.Info("admin: teleport town", "hero_id", heroID, "town_id", townID)
		writeJSON(w, http.StatusOK, out)
		return
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		return hm.AdminPlaceInTown(rg, townID, now)
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: teleport town (offline)", "hero_id", heroID, "town_id", townID)
	writeJSON(w, http.StatusOK, hero2)
}

// StartHeroRest forces resting state (duration same as town rest).
// POST /admin/heroes/{heroId}/start-rest
func (h *AdminHandler) StartHeroRest(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for start-rest", "hero_id", heroID, "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.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero must be alive and not in combat",
		})
		return
	}

	if hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminStartRest(heroID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "cannot start rest",
			})
			return
		}
		if err := h.store.Save(r.Context(), out); err != nil {
			h.logger.Error("admin: save after start-rest", "hero_id", heroID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{
				"error": "failed to save hero",
			})
			return
		}
		h.logger.Info("admin: start rest", "hero_id", heroID)
		h.writeAdminHeroDetail(w, out)
		return
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		if !hm.AdminStartRest(now, rg) {
			return fmt.Errorf("cannot start rest")
		}
		return nil
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: start rest (offline)", "hero_id", heroID)
	h.writeAdminHeroDetail(w, hero2)
}

// TownTourApproachNPC forces npc_approach toward a specific NPC during ExcursionKindTown (online heroes only).
// POST /admin/heroes/{heroId}/town-tour-approach-npc  body: {"npcId":123}
func (h *AdminHandler) TownTourApproachNPC(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}
	var req struct {
		NpcID int64 `json:"npcId"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.NpcID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"npcId\": positive number}"})
		return
	}
	if h.engine.GetMovements(heroID) == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be online (active WS movement session)"})
		return
	}
	out, err := h.engine.ApplyAdminTownTourApproachNPC(heroID, req.NpcID)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	out.RefreshDerivedCombatStats(time.Now())
	if err := h.store.Save(r.Context(), out); err != nil {
		h.logger.Error("admin: save after town-tour-approach-npc", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
		return
	}
	h.logger.Info("admin: town tour approach npc", "hero_id", heroID, "npc_id", req.NpcID)
	heroAfter, err := h.store.GetByID(r.Context(), heroID)
	if err != nil || heroAfter == nil {
		h.writeAdminHeroDetail(w, out)
		return
	}
	h.writeAdminHeroDetail(w, heroAfter)
}

// StartHeroMeet forces a paired hero meet: primary stays anchored, other teleports beside them.
// POST /admin/heroes/{heroId}/start-hero-meet  body: {"otherHeroId":123}
func (h *AdminHandler) StartHeroMeet(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}
	var req struct {
		OtherHeroID int64 `json:"otherHeroId"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.OtherHeroID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body: need {\"otherHeroId\": positive number}"})
		return
	}
	if h.engine.GetMovements(heroID) == nil || h.engine.GetMovements(req.OtherHeroID) == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "both heroes must be online (engine movement session)"})
		return
	}
	out, ok, meetReason := h.engine.ApplyAdminStartHeroMeet(heroID, req.OtherHeroID)
	if !ok || out == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error":  "cannot start hero meet",
			"detail": meetReason,
		})
		return
	}
	out.RefreshDerivedCombatStats(time.Now())
	if err := h.store.Save(r.Context(), out); err != nil {
		h.logger.Error("admin: save after start-hero-meet", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
		return
	}
	if other, err := h.store.GetByID(r.Context(), req.OtherHeroID); err == nil && other != nil {
		_ = h.store.Save(r.Context(), other)
	}
	h.logger.Info("admin: start hero meet", "hero_id", heroID, "other_hero_id", req.OtherHeroID)
	heroAfter, err := h.store.GetByID(r.Context(), heroID)
	if err != nil || heroAfter == nil {
		h.writeAdminHeroDetail(w, out)
		return
	}
	h.writeAdminHeroDetail(w, heroAfter)
}

// ForceLeaveTown is an alias for the unified stop-rest flow (see StopHeroRest).
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(w http.ResponseWriter, r *http.Request) {
	h.stopHeroRestOrLeaveTown(w, r, "leave-town")
}

// StartHeroRoadsideRest forces a hero into roadside rest at the current road position.
// POST /admin/heroes/{heroId}/start-roadside-rest
func (h *AdminHandler) StartHeroRoadsideRest(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for start-roadside-rest", "hero_id", heroID, "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.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"})
		return
	}

	if hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminStartRoadsideRest(heroID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start roadside rest"})
			return
		}
		if err := h.store.Save(r.Context(), out); err != nil {
			h.logger.Error("admin: save after start-roadside-rest", "hero_id", heroID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
			return
		}
		h.logger.Info("admin: start roadside rest", "hero_id", heroID)
		h.writeAdminHeroDetail(w, out)
		return
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		if !hm.AdminStartRoadsideRest(now) {
			return fmt.Errorf("cannot start roadside rest")
		}
		return nil
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: start roadside rest (offline)", "hero_id", heroID)
	h.writeAdminHeroDetail(w, hero2)
}

// StopHeroRest ends any rest or in-town pause the engine recognizes (roadside, adventure-inline, town rest, town tour).
// POST /admin/heroes/{heroId}/stop-rest
func (h *AdminHandler) StopHeroRest(w http.ResponseWriter, r *http.Request) {
	h.stopHeroRestOrLeaveTown(w, r, "stop-rest")
}

// stopHeroRestOrLeaveTown implements unified “stop resting / leave town” for admin (one semantic; two routes for compatibility).
func (h *AdminHandler) stopHeroRestOrLeaveTown(w http.ResponseWriter, r *http.Request, logLabel string) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for "+logLabel, "hero_id", heroID, "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 h.engine != nil {
		if hm := h.engine.GetMovements(heroID); hm != nil {
			out, ok := h.engine.ApplyAdminStopAnyRest(heroID)
			if !ok || out == nil {
				writeJSON(w, http.StatusBadRequest, map[string]string{
					"error": "hero is not in a rest or town state that can be stopped",
				})
				return
			}
			out.RefreshDerivedCombatStats(time.Now())
			if err := h.store.Save(r.Context(), out); err != nil {
				h.logger.Error("admin: save after "+logLabel, "hero_id", heroID, "error", err)
				writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
				return
			}
			h.logger.Info("admin: "+logLabel+" (online)", "hero_id", heroID)
			if logLabel == "leave-town" {
				writeJSON(w, http.StatusOK, out)
				return
			}
			h.writeAdminHeroDetail(w, out)
			return
		}
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		if hm.AdminStopRest(now) {
			return nil
		}
		if hm.State == model.StateResting || hm.State == model.StateInTown {
			hm.LeaveTown(rg, now)
			return nil
		}
		return fmt.Errorf("hero is not in a rest or town state that can be stopped")
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: "+logLabel+" (offline)", "hero_id", heroID)
	if logLabel == "leave-town" {
		writeJSON(w, http.StatusOK, hero2)
		return
	}
	h.writeAdminHeroDetail(w, hero2)
}

// StartHeroExcursion forces a walking hero on a road into a mini-adventure (excursion) session.
// POST /admin/heroes/{heroId}/start-adventure
func (h *AdminHandler) StartHeroExcursion(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for start-adventure", "hero_id", heroID, "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.StateFighting || hero.State == model.StateDead || hero.HP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be alive and not in combat"})
		return
	}
	if hero.State != model.StateWalking {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero must be walking on the road to start an excursion"})
		return
	}

	if hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminStartExcursion(heroID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start excursion (need active road segment, or excursion already active)"})
			return
		}
		if err := h.store.Save(r.Context(), out); err != nil {
			h.logger.Error("admin: save after start-adventure", "hero_id", heroID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
			return
		}
		h.logger.Info("admin: start excursion", "hero_id", heroID)
		h.writeAdminHeroDetail(w, out)
		return
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		if !hm.AdminStartExcursion(now) {
			return fmt.Errorf("cannot start excursion (need active road segment, or excursion already active)")
		}
		return nil
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: start excursion (offline)", "hero_id", heroID)
	h.writeAdminHeroDetail(w, hero2)
}

// TriggerRandomEncounter starts server combat with a random enemy for the hero's level (same pool as road encounters).
// Requires an active engine movement session (hero connected via WebSocket). POST /admin/heroes/{heroId}/trigger-random-encounter
func (h *AdminHandler) TriggerRandomEncounter(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	if h.engine == nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine not available"})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for random encounter", "hero_id", heroID, "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
	}

	h.engine.ApplyAdminHeroSnapshot(hero)

	hm := h.engine.GetMovements(heroID)
	if hm == nil || hm.Hero == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero has no active engine session — connect the game client (WebSocket) so movement is registered",
		})
		return
	}
	if hm.State == model.StateResting || hm.State == model.StateInTown {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start combat while resting or in town"})
		return
	}
	wx, wy, okPos := h.engine.HeroWorldPositionForCombat(heroID)
	if !okPos {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active engine session — connect the game client (WebSocket) so movement is registered"})
		return
	}
	if rg := h.engine.RoadGraph(); rg != nil && rg.HeroInTownAt(wx, wy) {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot start combat inside a town radius"})
		return
	}

	enemy := game.PickEnemyForHero(hm.Hero)
	if enemy.Slug == "" || enemy.MaxHP <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "no enemy template available for this hero level"})
		return
	}

	h.engine.StartCombat(hm.Hero, &enemy)

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

	h.logger.Info("admin: random encounter started", "hero_id", heroID, "enemy", enemy.Name, "enemy_level", enemy.Level)
	h.writeAdminHeroDetail(w, hm.Hero)
}

// KillCurrentEnemy applies a lethal hero hit and completes combat like a normal victory (rewards, combat_end, persist).
// Requires active engine combat (same as random encounter). POST /admin/heroes/{heroId}/kill-current-enemy
func (h *AdminHandler) KillCurrentEnemy(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId: " + err.Error()})
		return
	}
	if h.engine == nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "engine not available"})
		return
	}
	if _, active := h.engine.GetCombat(heroID); !active {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in combat"})
		return
	}
	if h.engine.GetMovements(heroID) == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero has no active engine session — connect the game client (WebSocket)",
		})
		return
	}
	out, ok := h.engine.ApplyAdminLethalEnemyKill(heroID)
	if !ok || out == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "cannot kill current enemy"})
		return
	}
	if err := h.store.Save(r.Context(), out); err != nil {
		h.logger.Error("admin: save after kill-current-enemy", "hero_id", heroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
		return
	}
	h.logger.Info("admin: kill current enemy", "hero_id", heroID)
	h.writeAdminHeroDetail(w, out)
}

// StopHeroExcursion forces the excursion into the return leg (walk back to road / start point).
// POST /admin/heroes/{heroId}/stop-adventure
func (h *AdminHandler) StopHeroExcursion(w http.ResponseWriter, r *http.Request) {
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid heroId: " + err.Error(),
		})
		return
	}
	if h.isHeroInCombat(w, heroID) {
		return
	}

	hero, err := h.store.GetByID(r.Context(), heroID)
	if err != nil {
		h.logger.Error("admin: get hero for stop-adventure", "hero_id", heroID, "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 hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminStopExcursion(heroID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero has no active excursion"})
			return
		}
		if err := h.store.Save(r.Context(), out); err != nil {
			h.logger.Error("admin: save after stop-adventure", "hero_id", heroID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
			return
		}
		h.logger.Info("admin: stop excursion", "hero_id", heroID)
		h.writeAdminHeroDetail(w, out)
		return
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		if !hm.AdminStopExcursion(now) {
			return fmt.Errorf("hero has no active excursion")
		}
		return nil
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: stop excursion (offline)", "hero_id", heroID)
	h.writeAdminHeroDetail(w, hero2)
}

// PauseTime freezes engine ticks, offline simulation, and blocks mutating game API calls.
// POST /admin/time/pause
func (h *AdminHandler) PauseTime(w http.ResponseWriter, r *http.Request) {
	h.engine.SetTimePaused(true)
	h.logger.Info("admin: global time paused")
	writeJSON(w, http.StatusOK, map[string]any{"timePaused": true})
}

// ResumeTime resumes engine and offline simulation.
// POST /admin/time/resume
func (h *AdminHandler) ResumeTime(w http.ResponseWriter, r *http.Request) {
	h.engine.SetTimePaused(false)
	h.logger.Info("admin: global time resumed")
	writeJSON(w, http.StatusOK, map[string]any{"timePaused": false})
}

// adminMovementOffline rebuilds movement from DB, applies fn, persists.
func (h *AdminHandler) adminMovementOffline(ctx context.Context, hero *model.Hero, fn func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error) (*model.Hero, error) {
	rg := h.engine.RoadGraph()
	if rg == nil {
		return nil, fmt.Errorf("road graph not loaded")
	}
	now := time.Now()
	hm := game.NewHeroMovement(hero, rg, now)
	if err := fn(hm, rg, now); err != nil {
		return nil, err
	}
	hm.SyncToHero()
	if err := h.store.Save(ctx, hero); err != nil {
		return nil, fmt.Errorf("failed to save hero: %w", err)
	}
	hero.RefreshDerivedCombatStats(now)
	return hero, nil
}

// ActiveCombats returns all active combat sessions.
// GET /admin/engine/combats
func (h *AdminHandler) ActiveCombats(w http.ResponseWriter, r *http.Request) {
	combats := h.engine.ListActiveCombats()
	writeJSON(w, http.StatusOK, map[string]any{
		"combats": combats,
		"count":   len(combats),
	})
}

// SimulateCombat runs a combat simulation for an existing hero and a selected monster archetype.
// POST /admin/engine/simulate-combat
func (h *AdminHandler) SimulateCombat(w http.ResponseWriter, r *http.Request) {
	var req simulateCombatRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
		return
	}
	if req.HeroID <= 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "heroId is required"})
		return
	}
	slug := strings.TrimSpace(req.EnemyType)
	if slug == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "enemyType is required"})
		return
	}

	baseHero, err := h.store.GetByID(r.Context(), req.HeroID)
	if err != nil {
		h.logger.Error("admin simulate combat: load hero", "hero_id", req.HeroID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load hero"})
		return
	}
	if baseHero == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "hero not found"})
		return
	}
	// Live session (engine) is authoritative for gear/stats while online; DB can lag during combat.
	if h.engine != nil {
		if hm := h.engine.GetMovements(req.HeroID); hm != nil && hm.Hero != nil {
			baseHero = game.CloneHeroForCombatSim(hm.Hero)
		}
	}
	tmpl, ok := model.EnemyBySlug(slug)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown enemyType"})
		return
	}

	var enemy model.Enemy
	if req.EnemyLevel > 0 {
		enemy = game.BuildEnemyInstanceForLevel(tmpl, req.EnemyLevel, nil)
	} else {
		// Same level roll as live encounters (variance + hero band), not "enemy level = hero level".
		enemy = game.BuildEnemyInstanceForEncounter(tmpl, baseHero, nil)
	}
	game.ApplyEnemyEncounterHeroScaling(baseHero, &enemy)
	combatStart := game.CombatSimDeterministicStart
	hero := game.PrepareHeroForAdminCombatSim(baseHero, combatStart)

	initialHeroHp := hero.HP
	initialHeroMaxHp := hero.MaxHP
	initialEnemyHp := enemy.HP
	initialEnemyMaxHp := enemy.MaxHP
	enemyName := tmpl.Name

	tickRate := time.Duration(req.TickRateMs) * time.Millisecond
	if tickRate <= 0 {
		tickRate = 100 * time.Millisecond
	}
	wallClockDelay := time.Duration(req.WallClockDelayMs) * time.Millisecond
	if wallClockDelay < 0 {
		wallClockDelay = 0
	}
	maxEvents := req.MaxEvents
	if maxEvents <= 0 || maxEvents > 5000 {
		maxEvents = 1200
	}
	events := make([]model.CombatEvent, 0, min(maxEvents, 256))
	opts := game.CombatSimOptions{
		TickRate:       tickRate,
		WallClockDelay: wallClockDelay,
		MaxSteps:       game.CombatSimMaxStepsLong,
		OnEvent: func(evt model.CombatEvent) {
			if len(events) < maxEvents {
				events = append(events, evt)
			}
		},
	}
	survived, elapsed := game.ResolveCombatToEndWithDuration(hero, &enemy, combatStart, opts)

	writeJSON(w, http.StatusOK, simulateCombatResponse{
		HeroID:            req.HeroID,
		HeroName:          baseHero.Name,
		EnemyType:         enemy.Slug,
		EnemyName:         enemyName,
		EnemyLevel:        enemy.Level,
		Survived:          survived,
		ElapsedMs:         elapsed.Milliseconds(),
		InitialHeroHp:     initialHeroHp,
		InitialHeroMaxHp:  initialHeroMaxHp,
		InitialEnemyHp:    initialEnemyHp,
		InitialEnemyMaxHp: initialEnemyMaxHp,
		FinalHeroHP:       hero.HP,
		FinalEnemyHP:      enemy.HP,
		WallClockDelayMs:  wallClockDelay.Milliseconds(),
		TickRateMs:        tickRate.Milliseconds(),
		Events:            events,
	})
}

// ── WebSocket Hub ───────────────────────────────────────────────────

// WSConnections returns active WebSocket connection info.
// GET /admin/ws/connections
func (h *AdminHandler) WSConnections(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{
		"connectionCount": h.hub.ConnectionCount(),
		"heroIds":         h.hub.ConnectedHeroIDs(),
	})
}

// AdminHeroSnapshotWS streams hero detail + movement snapshot for admin UI.
// GET /admin-ws/hero/{heroId}?auth=BASE64(user:pass)
func (h *AdminHandler) AdminHeroSnapshotWS(w http.ResponseWriter, r *http.Request) {
	if !h.adminWSAuthorized(r) {
		w.Header().Set("WWW-Authenticate", `Basic realm="Admin", charset="UTF-8"`)
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
		return
	}
	heroID, err := parseHeroID(r)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid heroId"})
		return
	}
	conn, err := upgrader.Upgrade(w, r, nil)
	if err != nil {
		h.logger.Error("admin ws upgrade failed", "error", err)
		return
	}
	defer conn.Close()

	done := make(chan struct{})
	go func() {
		defer close(done)
		for {
			if _, _, err := conn.ReadMessage(); err != nil {
				return
			}
		}
	}()

	sendSnapshot := func() error {
		snap, err := h.buildAdminWSSnapshot(r.Context(), heroID)
		if err != nil {
			return err
		}
		conn.SetWriteDeadline(time.Now().Add(writeWait))
		return conn.WriteJSON(snap)
	}

	if err := sendSnapshot(); err != nil {
		_ = conn.WriteJSON(map[string]string{"error": err.Error()})
		return
	}

	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()
	for {
		select {
		case <-done:
			return
		case <-r.Context().Done():
			return
		case <-ticker.C:
			// Align admin live stream with engine: no periodic snapshots while global time is paused.
			if h.engine != nil && h.engine.IsTimePaused() {
				continue
			}
			if err := sendSnapshot(); err != nil {
				_ = conn.WriteJSON(map[string]string{"error": err.Error()})
				return
			}
		}
	}
}

// ── Server Info ─────────────────────────────────────────────────────

// ServerInfo returns general server diagnostics.
// GET /admin/info
func (h *AdminHandler) ServerInfo(w http.ResponseWriter, r *http.Request) {
	poolStat := h.pool.Stat()
	writeJSON(w, http.StatusOK, map[string]any{
		"version":   version.Version,
		"goVersion": runtime.Version(),
		"uptimeMs":  time.Since(serverStartedAt).Milliseconds(),
		"dbPool": map[string]any{
			"totalConns":    poolStat.TotalConns(),
			"acquiredConns": poolStat.AcquiredConns(),
			"idleConns":     poolStat.IdleConns(),
			"maxConns":      poolStat.MaxConns(),
		},
		// In-memory merged runtime tuning (defaults + last reload from DB payload).
		"effective": tuning.Get(),
	})
}

// GetRuntimeConfig returns current DB payload and effective merged config.
// GET /admin/runtime-config
func (h *AdminHandler) GetRuntimeConfig(w http.ResponseWriter, r *http.Request) {
	var payload []byte
	if err := h.pool.QueryRow(r.Context(), `SELECT payload FROM runtime_config WHERE id = TRUE`).Scan(&payload); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load runtime config",
		})
		return
	}
	var raw map[string]any
	if len(payload) > 0 {
		_ = json.Unmarshal(payload, &raw)
	}
	if raw == nil {
		raw = map[string]any{}
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"payload":   raw,
		"effective": tuning.Get(),
	})
}

// UpdateRuntimeConfig overwrites runtime_config.payload JSONB.
// POST /admin/runtime-config
func (h *AdminHandler) UpdateRuntimeConfig(w http.ResponseWriter, r *http.Request) {
	var body map[string]any
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid json body",
		})
		return
	}
	if body == nil {
		body = map[string]any{}
	}
	payload, err := json.Marshal(body)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "failed to serialize payload",
		})
		return
	}
	if _, err := h.pool.Exec(r.Context(), `
		UPDATE runtime_config
		SET payload = $1::jsonb, updated_at = now()
		WHERE id = TRUE
	`, payload); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save runtime config",
		})
		return
	}
	loader := storage.NewRuntimeConfigStore(h.pool)
	if err := tuning.ReloadNow(r.Context(), h.logger, loader); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "config saved but reload failed",
		})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"status": "ok",
	})
}

// ReloadRuntimeConfig applies DB runtime_config payload to in-memory config immediately.
// POST /admin/runtime-config/reload
func (h *AdminHandler) ReloadRuntimeConfig(w http.ResponseWriter, r *http.Request) {
	loader := storage.NewRuntimeConfigStore(h.pool)
	if err := tuning.ReloadNow(r.Context(), h.logger, loader); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to reload runtime config",
		})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"status": "reloaded",
	})
}

// GetBuffDebuffConfig returns DB payload and effective buff/debuff catalog.
// GET /admin/buff-debuff-config
func (h *AdminHandler) GetBuffDebuffConfig(w http.ResponseWriter, r *http.Request) {
	var payload []byte
	if err := h.pool.QueryRow(r.Context(), `SELECT payload FROM buff_debuff_config WHERE id = TRUE`).Scan(&payload); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load buff/debuff config",
		})
		return
	}
	var raw map[string]any
	if len(payload) > 0 {
		_ = json.Unmarshal(payload, &raw)
	}
	if raw == nil {
		raw = map[string]any{}
	}
	effB, effD := model.BuffCatalogEffectiveJSON()
	writeJSON(w, http.StatusOK, map[string]any{
		"payload":          raw,
		"effectiveBuffs":   effB,
		"effectiveDebuffs": effD,
	})
}

// UpdateBuffDebuffConfig overwrites buff_debuff_config.payload JSONB.
// POST /admin/buff-debuff-config
func (h *AdminHandler) UpdateBuffDebuffConfig(w http.ResponseWriter, r *http.Request) {
	var body map[string]any
	if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid json body",
		})
		return
	}
	if body == nil {
		body = map[string]any{}
	}
	payload, err := json.Marshal(body)
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "failed to serialize payload",
		})
		return
	}
	if _, err := h.pool.Exec(r.Context(), `
		UPDATE buff_debuff_config
		SET payload = $1::jsonb, updated_at = now()
		WHERE id = TRUE
	`, payload); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to save buff/debuff config",
		})
		return
	}
	loader := storage.NewBuffDebuffConfigStore(h.pool)
	if err := model.ReloadBuffDebuffCatalog(r.Context(), h.logger, loader); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "config saved but reload failed",
		})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"status": "ok",
	})
}

// ReloadBuffDebuffConfig reloads catalog from DB without writing.
// POST /admin/buff-debuff-config/reload
func (h *AdminHandler) ReloadBuffDebuffConfig(w http.ResponseWriter, r *http.Request) {
	loader := storage.NewBuffDebuffConfigStore(h.pool)
	if err := model.ReloadBuffDebuffCatalog(r.Context(), h.logger, loader); err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to reload buff/debuff config",
		})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"status": "reloaded",
	})
}

// ContentListEnemies returns all rows from the enemies table.
// GET /admin/content/enemies
func (h *AdminHandler) ContentListEnemies(w http.ResponseWriter, r *http.Request) {
	cs := storage.NewContentStore(h.pool)
	rows, err := cs.ListEnemyRows(r.Context())
	if err != nil {
		h.logger.Error("list enemies", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to list enemies",
		})
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"enemies": rows,
	})
}

// ContentUpdateEnemy overwrites one enemy template by type and hot-reloads in-memory templates.
// PUT /admin/content/enemies/{enemyType}
func (h *AdminHandler) ContentUpdateEnemy(w http.ResponseWriter, r *http.Request) {
	typ := strings.TrimSpace(chi.URLParam(r, "enemyType"))
	if typ == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing enemyType"})
		return
	}
	var e model.Enemy
	if err := json.NewDecoder(r.Body).Decode(&e); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"})
		return
	}
	e.Slug = typ
	e.HP = e.MaxHP
	// Backward-compatible defaults for admin clients that still send legacy enemy payloads.
	if cur, ok := model.EnemyBySlug(typ); ok {
		if e.BaseLevel <= 0 {
			e.BaseLevel = cur.BaseLevel
		}
		if e.LevelVariance <= 0 {
			e.LevelVariance = cur.LevelVariance
		}
		if e.MaxHeroLevelDiff <= 0 {
			e.MaxHeroLevelDiff = cur.MaxHeroLevelDiff
		}
		if e.HPPerLevel == 0 {
			e.HPPerLevel = cur.HPPerLevel
		}
		if e.AttackPerLevel == 0 {
			e.AttackPerLevel = cur.AttackPerLevel
		}
		if e.DefensePerLevel == 0 {
			e.DefensePerLevel = cur.DefensePerLevel
		}
		if e.XPPerLevel == 0 {
			e.XPPerLevel = cur.XPPerLevel
		}
		if e.GoldPerLevel == 0 {
			e.GoldPerLevel = cur.GoldPerLevel
		}
	}
	if e.LevelVariance <= 0 {
		e.LevelVariance = 0.30
	}
	if e.MaxHeroLevelDiff <= 0 {
		e.MaxHeroLevelDiff = 5
	}
	cs := storage.NewContentStore(h.pool)
	if err := cs.UpdateEnemyByType(r.Context(), typ, e); err != nil {
		h.logger.Error("update enemy", "type", typ, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": err.Error(),
		})
		return
	}
	m, err := cs.LoadEnemyTemplates(r.Context())
	if err != nil {
		h.logger.Error("reload enemy templates after update", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "saved but failed to reload templates",
		})
		return
	}
	model.SetEnemyTemplates(m)
	writeJSON(w, http.StatusOK, map[string]any{
		"status": "ok",
		"count":  len(m),
	})
}

// ReloadEnemyTemplates loads enemies from DB into model.EnemyTemplates (hot load).
// POST /admin/content/enemies/reload
func (h *AdminHandler) ReloadEnemyTemplates(w http.ResponseWriter, r *http.Request) {
	cs := storage.NewContentStore(h.pool)
	m, err := cs.LoadEnemyTemplates(r.Context())
	if err != nil {
		h.logger.Error("load enemy templates", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load enemies",
		})
		return
	}
	model.SetEnemyTemplates(m)
	writeJSON(w, http.StatusOK, map[string]any{
		"status": "reloaded",
		"count":  len(m),
	})
}

// ── Helpers ─────────────────────────────────────────────────────────

func parseHeroID(r *http.Request) (int64, error) {
	return strconv.ParseInt(chi.URLParam(r, "heroId"), 10, 64)
}

func (h *AdminHandler) adminWSAuthorized(r *http.Request) bool {
	if user, pass, ok := r.BasicAuth(); ok {
		if basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass) {
			return true
		}
	}
	q := r.URL.Query()
	if raw := strings.TrimSpace(q.Get("auth")); raw != "" {
		if decoded, err := base64.StdEncoding.DecodeString(raw); err == nil {
			parts := strings.SplitN(string(decoded), ":", 2)
			if len(parts) == 2 && basicAuthCredentialsMatch(parts[0], parts[1], h.adminUser, h.adminPass) {
				return true
			}
		}
	}
	user := q.Get("user")
	pass := q.Get("pass")
	return basicAuthCredentialsMatch(user, pass, h.adminUser, h.adminPass)
}

func parseQuestID(r *http.Request) (int64, error) {
	return strconv.ParseInt(chi.URLParam(r, "questId"), 10, 64)
}

func parsePaymentID(r *http.Request) (int64, error) {
	return strconv.ParseInt(chi.URLParam(r, "paymentId"), 10, 64)
}

func parseItemID(r *http.Request) (int64, error) {
	return strconv.ParseInt(chi.URLParam(r, "itemId"), 10, 64)
}

func parseContentGearID(r *http.Request) (int64, error) {
	return strconv.ParseInt(chi.URLParam(r, "gearId"), 10, 64)
}

func parseEquipmentSlot(raw string) (model.EquipmentSlot, error) {
	val := model.EquipmentSlot(strings.TrimSpace(raw))
	for _, slot := range model.AllEquipmentSlots {
		if val == slot {
			return val, nil
		}
	}
	return "", fmt.Errorf("invalid slot: %s", raw)
}

func parseRarity(raw string) (model.Rarity, error) {
	v := model.Rarity(strings.TrimSpace(strings.ToLower(raw)))
	switch v {
	case model.RarityCommon, model.RarityUncommon, model.RarityRare, model.RarityEpic, model.RarityLegendary:
		return v, nil
	default:
		return "", fmt.Errorf("invalid rarity: %s", raw)
	}
}

func (h *AdminHandler) syncHeroSnapshot(ctx context.Context, heroID int64) error {
	hero, err := h.store.GetByID(ctx, heroID)
	if err != nil {
		return err
	}
	if hero == nil {
		return fmt.Errorf("hero not found")
	}
	hero.EnsureGearMap()
	hero.RefreshDerivedCombatStats(time.Now())
	h.engine.ApplyAdminHeroSnapshot(hero)
	return nil
}

// isHeroInCombat checks if the hero is in active engine combat and writes
// a 409 Conflict response if so. Returns true when the caller should abort.
func (h *AdminHandler) isHeroInCombat(w http.ResponseWriter, heroID int64) bool {
	if _, active := h.engine.GetCombat(heroID); active {
		writeJSON(w, http.StatusConflict, map[string]string{
			"error": "hero is in active combat — stop combat first",
		})
		return true
	}
	return false
}

// applyNewPlayerHeroDefaults matches CreateHeroWithSpawn field-wise (stats, gold, counters, subscription)
// while keeping identity fields. Caller should load gear from DB before/after as needed.
func applyNewPlayerHeroDefaults(hero *model.Hero) {
	resetHeroToLevel1(hero)
	hero.Gold = 100
	hero.Potions = 0
	hero.ReviveCount = 0
	hero.TotalKills = 0
	hero.EliteKills = 0
	hero.TotalDeaths = 0
	hero.KillsSinceDeath = 0
	hero.LegendaryDrops = 0
	hero.SubscriptionActive = false
	hero.SubscriptionExpiresAt = nil
	hero.ExcursionPhase = model.ExcursionNone
	hero.RestKind = model.RestKindNone
	hero.TownPause = nil
}

// resetHeroToLevel1 restores a hero to fresh level 1 defaults,
// preserving identity fields (ID, TelegramID, Name, CreatedAt).
func resetHeroToLevel1(hero *model.Hero) {
	hero.Level = 1
	hero.XP = 0
	hero.Gold = 0
	hero.HP = 100
	hero.MaxHP = 100
	hero.Attack = 10
	hero.Defense = 5
	hero.Speed = 1.0
	hero.Strength = 1
	hero.Constitution = 1
	hero.Agility = 1
	hero.Luck = 1
	hero.State = model.StateWalking
	hero.Buffs = nil
	hero.Debuffs = nil
	now := time.Now()
	hero.ResetBuffCharges(nil, now)
}
