package handler

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"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"
)

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
}

// 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) *AdminHandler {
	return &AdminHandler{
		store:      store,
		gearStore:  gearStore,
		questStore: questStore,
		engine:     engine,
		hub:        hub,
		pool:       pool,
		logger:     logger,
	}
}

// ── 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"`
}

// adminLiveMovementJSON exposes in-memory movement timers for the admin UI (online heroes only).
type adminLiveMovementJSON struct {
	Online                    bool       `json:"online"`
	MoveState                 string     `json:"moveState,omitempty"`
	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"`
	ExcursionPhase            string     `json:"excursionPhase,omitempty"`
	ExcursionWildUntil        *time.Time `json:"excursionWildUntil,omitempty"`
	ExcursionReturnUntil      *time.Time `json:"excursionReturnUntil,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"`
}

func buildAdminLiveMovementSnap(hm *game.HeroMovement, now time.Time) *adminLiveMovementJSON {
	if hm == nil {
		return nil
	}
	s := &adminLiveMovementJSON{
		Online:    true,
		MoveState: string(hm.State),
	}
	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.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
		}
	}
	return s
}

func (h *AdminHandler) writeAdminHeroDetail(w http.ResponseWriter, hero *model.Hero) {
	if hero == nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "nil hero"})
		return
	}
	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, now)
	}
	writeJSON(w, http.StatusOK, out)
}

// 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
	}
	writeJSON(w, http.StatusOK, hero)
}

// AbandonHeroQuest removes quest from hero log.
// DELETE /admin/heroes/{heroId}/quests/{questId}
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})
}

// 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 specific level, recalculating stats.
// 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)
	writeJSON(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)
	writeJSON(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)
	writeJSON(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)
	writeJSON(w, http.StatusOK, hero)
}

// ReviveHero force-revives a hero to full HP regardless of current state.
// 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
	}

	hero.HP = hero.MaxHP
	hero.State = model.StateWalking
	hero.Buffs = nil
	hero.Debuffs = nil

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

// ForceLeaveTown ends resting or in-town NPC pause, puts the hero back on the road, persists, and notifies WS if online.
// POST /admin/heroes/{heroId}/leave-town
func (h *AdminHandler) ForceLeaveTown(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 leave-town", "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.StateResting && hero.State != model.StateInTown {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is not resting or in town",
		})
		return
	}

	if hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminForceLeaveTown(heroID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "cannot leave town (movement state changed?)",
			})
			return
		}
		out.RefreshDerivedCombatStats(time.Now())
		h.logger.Info("admin: force leave town", "hero_id", heroID)
		writeJSON(w, http.StatusOK, out)
		return
	}

	hero2, err := h.adminMovementOffline(r.Context(), hero, func(hm *game.HeroMovement, rg *game.RoadGraph, now time.Time) error {
		if hm.State != model.StateResting && hm.State != model.StateInTown {
			return fmt.Errorf("hero is not resting or in town")
		}
		hm.LeaveTown(rg, now)
		return nil
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: force leave town (offline)", "hero_id", heroID)
	writeJSON(w, http.StatusOK, hero2)
}

// 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 exits a hero from non-town rest (roadside or adventure-inline) back to walking.
// POST /admin/heroes/{heroId}/stop-rest
func (h *AdminHandler) StopHeroRest(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 stop-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 hm := h.engine.GetMovements(heroID); hm != nil {
		out, ok := h.engine.ApplyAdminStopRest(heroID)
		if !ok || out == nil {
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": "hero is not in roadside/adventure rest"})
			return
		}
		if err := h.store.Save(r.Context(), out); err != nil {
			h.logger.Error("admin: save after stop-rest", "hero_id", heroID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to save hero"})
			return
		}
		h.logger.Info("admin: stop 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.AdminStopRest(now) {
			return fmt.Errorf("hero is not in roadside/adventure rest")
		}
		return nil
	})
	if err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
		return
	}
	h.logger.Info("admin: stop rest (offline)", "hero_id", heroID)
	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)
}

// StopHeroExcursion ends the hero's mini-adventure session immediately.
// 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),
	})
}

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

// ── 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":   "0.1.0-dev",
		"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",
	})
}

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

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

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
}

// 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
	hero.BuffCharges = nil
	hero.BuffFreeChargesRemaining = model.FreeBuffActivationsPerPeriodRuntime()
	hero.BuffQuotaPeriodEnd = nil
}
