package handler

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

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

// NPCHandler serves NPC interaction API endpoints.
type NPCHandler struct {
	questStore *storage.QuestStore
	heroStore  *storage.HeroStore
	gearStore  *storage.GearStore
	logStore   *storage.LogStore
	logger     *slog.Logger
	engine     *game.Engine
	hub        *Hub
}

// merchantStockRow is one town merchant shelf row (stats + per-item gold fixed at open).
type merchantStockRow struct {
	model.GearItem
	Cost int64 `json:"cost"`
}

// NewNPCHandler creates a new NPCHandler.
func NewNPCHandler(questStore *storage.QuestStore, heroStore *storage.HeroStore, gearStore *storage.GearStore, logStore *storage.LogStore, logger *slog.Logger, eng *game.Engine, hub *Hub) *NPCHandler {
	return &NPCHandler{
		questStore: questStore,
		heroStore:  heroStore,
		gearStore:  gearStore,
		logStore:   logStore,
		logger:     logger,
		engine:     eng,
		hub:        hub,
	}
}

func (h *NPCHandler) sendMerchantLootWS(heroID int64, cost int64, drop *model.LootDrop) {
	if h.hub == nil || drop == nil {
		return
	}
	h.hub.SendToHero(heroID, "merchant_loot", model.MerchantLootPayload{
		GoldSpent:  cost,
		ItemType:   drop.ItemType,
		ItemName:   drop.ItemName,
		Rarity:     string(drop.Rarity),
		GoldAmount: drop.GoldAmount,
	})
}

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

// dist2D calculates the Euclidean distance between two 2D points.
func dist2D(x1, y1, x2, y2 float64) float64 {
	dx := x1 - x2
	dy := y1 - y2
	return math.Sqrt(dx*dx + dy*dy)
}

// loadHeroNPCInTown loads the hero, NPC row, town, and checks hero stand position is inside the town radius.
// If allowedTypes is non-empty, npc.Type must match one of them.
func (h *NPCHandler) loadHeroNPCInTown(ctx context.Context, telegramID, npcID int64, posX, posY float64, allowedTypes ...string) (*model.Hero, *model.NPC, *model.Town, error) {
	if npcID == 0 {
		return nil, nil, nil, fmt.Errorf("npcId is required")
	}
	hero, err := h.heroStore.GetByTelegramID(ctx, telegramID)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("failed to load hero")
	}
	if hero == nil {
		return nil, nil, nil, fmt.Errorf("hero not found")
	}
	npc, err := h.questStore.GetNPCByID(ctx, npcID)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("failed to load npc")
	}
	if npc == nil {
		return nil, nil, nil, fmt.Errorf("npc not found")
	}
	if len(allowedTypes) > 0 {
		ok := false
		for _, t := range allowedTypes {
			if npc.Type == t {
				ok = true
				break
			}
		}
		if !ok {
			return nil, nil, nil, fmt.Errorf("npc type mismatch")
		}
	}
	town, err := h.questStore.GetTown(ctx, npc.TownID)
	if err != nil {
		return nil, nil, nil, fmt.Errorf("failed to load town")
	}
	if town == nil {
		return nil, nil, nil, fmt.Errorf("town not found")
	}
	if dist2D(posX, posY, town.WorldX, town.WorldY) > town.Radius {
		return nil, nil, nil, fmt.Errorf("hero is too far from the town")
	}
	return hero, npc, town, nil
}

// InteractNPC handles POST /api/v1/hero/npc-interact.
// The hero interacts with a specific NPC; checks proximity to the NPC's town.
func (h *NPCHandler) InteractNPC(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req struct {
		NPCID     int64   `json:"npcId"`
		PositionX float64 `json:"positionX"`
		PositionY float64 `json:"positionY"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body",
		})
		return
	}
	if req.NPCID == 0 {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "npcId is required",
		})
		return
	}

	hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for npc interact", "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
	}

	// Load NPC.
	npc, err := h.questStore.GetNPCByID(r.Context(), req.NPCID)
	if err != nil {
		h.logger.Error("failed to get npc", "npc_id", req.NPCID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load npc",
		})
		return
	}
	if npc == nil {
		writeJSON(w, http.StatusNotFound, map[string]string{
			"error": "npc not found",
		})
		return
	}

	// Load the NPC's town.
	town, err := h.questStore.GetTown(r.Context(), npc.TownID)
	if err != nil {
		h.logger.Error("failed to get town for npc", "town_id", npc.TownID, "error", err)
		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
	}

	// Check proximity: hero must be within the town's radius.
	d := dist2D(req.PositionX, req.PositionY, town.WorldX, town.WorldY)
	if d > town.Radius {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "hero is too far from the town",
		})
		return
	}

	// Build actions based on NPC type.
	var actions []model.NPCInteractAction

	switch npc.Type {
	case model.NPCTypeBounty, model.NPCTypeElder, model.NPCTypeQuestGiver:
		refreshHours := tuning.EffectiveQuestOfferRefreshHours()
		if refreshHours <= 0 {
			refreshHours = 2
		}
		refreshSeconds := int64(time.Duration(refreshHours) * time.Hour / time.Second)
		timeBucket := time.Now().UTC().Unix() / refreshSeconds
		limit := tuning.EffectiveQuestOffersPerNPC()
		townOfferLevel := game.TownEffectiveLevel(town)
		quests, err := h.questStore.ListOfferableQuestsForNPC(r.Context(), hero.ID, npc.ID, townOfferLevel, limit, timeBucket)
		if err != nil {
			h.logger.Error("failed to list quests for npc interaction", "npc_id", npc.ID, "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{
				"error": "failed to load quests",
			})
			return
		}
		for _, q := range quests {
			qk := q.QuestKey
			if qk == "" {
				qk = fmt.Sprintf("quest.%d", q.ID)
			}
			actions = append(actions, model.NPCInteractAction{
				ActionType:  "quest",
				QuestID:     q.ID,
				QuestKey:    qk,
				QuestTitle:  q.Title,
				Description: q.Description,
			})
		}

	case model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler:
		gearCost := tuning.EffectiveTownMerchantGearCost(game.TownEffectiveLevel(town))
		actions = append(actions, model.NPCInteractAction{
			ActionType:  "shop_item",
			ItemKey:     "shop.merchant_gear_rows",
			ItemName:    "Town gear",
			ItemCost:    gearCost,
			Description: "Stock is rolled when you open the shop (town-tier stats shown before purchase).",
		})

	case "healer":
		potionCost, healCost := tuning.EffectiveNPCShopCosts()
		actions = append(actions, model.NPCInteractAction{
			ActionType:  "shop_item",
			ItemKey:     "shop.healing_potion",
			ItemName:    "Healing Potion",
			ItemCost:    potionCost,
			Description: "Restores health in combat.",
		})
		actions = append(actions, model.NPCInteractAction{
			ActionType:  "heal",
			ItemKey:     "shop.full_heal",
			ItemName:    "Full Heal",
			ItemCost:    healCost,
			Description: "Restore hero to full HP.",
		})
	}

	// Log the meeting.
	h.addLogLine(hero.ID, model.AdventureLogLine{
		Event: &model.AdventureLogEvent{
			Code: model.LogPhraseMetNPC,
			Args: map[string]any{"npcKey": npc.NameKey, "townKey": town.NameKey},
		},
	})

	resp := model.NPCInteractResponse{
		NPCName:     npc.Name,
		NPCNameKey:  npc.NameKey,
		NPCType:     npc.Type,
		TownName:    town.Name,
		TownNameKey: town.NameKey,
		Actions:     actions,
	}
	if resp.Actions == nil {
		resp.Actions = []model.NPCInteractAction{}
	}
	writeJSON(w, http.StatusOK, resp)
}

// NearbyNPCs handles GET /api/v1/hero/nearby-npcs.
// Returns NPCs within 3 world units of the given position.
func (h *NPCHandler) NearbyNPCs(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	posXStr := r.URL.Query().Get("posX")
	posYStr := r.URL.Query().Get("posY")
	posX, errX := strconv.ParseFloat(posXStr, 64)
	posY, errY := strconv.ParseFloat(posYStr, 64)
	if errX != nil || errY != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "posX and posY are required numeric parameters",
		})
		return
	}

	// Verify hero exists.
	hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for nearby npcs", "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
	}

	// Load all towns and NPCs, then filter by distance.
	towns, err := h.questStore.ListTowns(r.Context())
	if err != nil {
		h.logger.Error("failed to list towns for nearby npcs", "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to load towns",
		})
		return
	}

	cfg := tuning.Get()
	nearbyRadius := cfg.NPCCostNearbyRadius
	var result []model.NearbyNPCEntry

	for _, town := range towns {
		npcs, err := h.questStore.ListNPCsByTown(r.Context(), town.ID)
		if err != nil {
			h.logger.Warn("failed to list npcs for town", "town_id", town.ID, "error", err)
			continue
		}
		for _, npc := range npcs {
			npcWorldX := town.WorldX + npc.OffsetX
			npcWorldY := town.WorldY + npc.OffsetY
			d := dist2D(posX, posY, npcWorldX, npcWorldY)
			if d <= nearbyRadius {
				result = append(result, model.NearbyNPCEntry{
					ID:                   npc.ID,
					Name:                 npc.Name,
					NameKey:              npc.NameKey,
					Type:                 npc.Type,
					WorldX:               npcWorldX,
					WorldY:               npcWorldY,
					InteractionAvailable: true,
				})
			}
		}
	}

	if result == nil {
		result = []model.NearbyNPCEntry{}
	}
	writeJSON(w, http.StatusOK, result)
}

// npcPersistGearEquip writes hero_gear when a merchant drop is equipped.
func (h *NPCHandler) npcPersistGearEquip(heroID int64, item *model.GearItem) error {
	if h.gearStore == nil || item == nil || item.ID == 0 {
		return nil
	}
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()
	return h.gearStore.EquipItem(ctx, heroID, item.Slot, item.ID)
}

// grantMerchantLoot rolls one random gear piece; auto-equips if better.
// refLevel drives ilvl (hero level for wandering merchant, town tier for static shops).
// Cost must already be deducted from hero.Gold.
func (h *NPCHandler) grantMerchantLoot(ctx context.Context, hero *model.Hero, now time.Time, refLevel int) (*model.LootDrop, error) {
	slots := model.AllEquipmentSlots
	if h.gearStore == nil {
		return nil, errors.New("failed to roll gear")
	}
	var family *model.GearFamily
	for _, idx := range rand.Perm(len(slots)) {
		slot := slots[idx]
		family = model.PickGearFamily(slot)
		if family != nil {
			break
		}
	}
	if family == nil {
		return nil, errors.New("failed to roll gear")
	}

	rarity := model.RollRarity()
	ilvl := model.RollIlvl(refLevel, false)
	item := model.NewGearItem(family, ilvl, rarity)

	ctxCreate, cancel := context.WithTimeout(ctx, 2*time.Second)
	err := h.gearStore.CreateItem(ctxCreate, item)
	cancel()
	if err != nil {
		h.logger.Warn("failed to create alms gear item", "slot", family.Slot, "error", err)
		return nil, err
	}

	hero.EnsureGearMap()
	slot := item.Slot
	var prev *model.GearItem
	if hero.Gear != nil {
		prev = hero.Gear[slot]
	}
	equipped := game.TryAutoEquipInMemory(hero, item, now)
	if equipped {
		if err := h.npcPersistGearEquip(hero.ID, item); err != nil {
			if prev == nil {
				delete(hero.Gear, slot)
			} else {
				hero.Gear[slot] = prev
			}
			hero.RefreshDerivedCombatStats(now)
			if errors.Is(err, storage.ErrInventoryFull) {
				h.logger.Warn("merchant gear equip skipped: inventory full",
					"hero_id", hero.ID, "slot", item.Slot)
			} else {
				h.logger.Warn("failed to persist merchant gear equip", "hero_id", hero.ID, "slot", item.Slot, "error", err)
			}
			equipped = false
		} else {
			if prev != nil && prev.ID != item.ID {
				hero.EnsureInventorySlice()
				hero.Inventory = append(hero.Inventory, prev)
			}
			h.addLogLine(hero.ID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{
					Code: model.LogPhraseWanderingAlmsEquipped,
					Args: map[string]any{
						"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID,
					},
				},
			})
		}
	}
	if !equipped {
		hero.EnsureInventorySlice()
		if len(hero.Inventory) >= model.MaxInventorySlots {
			ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
			if item.ID != 0 {
				if err := h.gearStore.DeleteGearItem(ctxDel, item.ID); err != nil {
					h.logger.Warn("failed to delete merchant gear (inventory full)", "gear_id", item.ID, "error", err)
				}
			}
			cancelDel()
			h.addLogLine(hero.ID, model.AdventureLogLine{
				Event: &model.AdventureLogEvent{
					Code: model.LogPhraseWanderingAlmsDropped,
					Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
				},
			})
		} else {
			ctxInv, cancelInv := context.WithTimeout(ctx, 2*time.Second)
			err := h.gearStore.AddToInventory(ctxInv, hero.ID, item.ID)
			cancelInv()
			if err != nil {
				h.logger.Warn("failed to stash merchant gear", "hero_id", hero.ID, "error", err)
				ctxDel, cancelDel := context.WithTimeout(ctx, 2*time.Second)
				_ = h.gearStore.DeleteGearItem(ctxDel, item.ID)
				cancelDel()
			} else {
				hero.Inventory = append(hero.Inventory, item)
				h.addLogLine(hero.ID, model.AdventureLogLine{
					Event: &model.AdventureLogEvent{
						Code: model.LogPhraseWanderingAlmsStashed,
						Args: map[string]any{"itemId": item.ID, "slot": string(slot), "rarity": string(item.Rarity), "formId": item.FormID},
					},
				})
			}
		}
	}

	drop := &model.LootDrop{
		ItemType: string(item.Slot),
		ItemID:   item.ID,
		ItemName: item.Name,
		Rarity:   rarity,
	}
	return drop, nil
}

// ProcessAlmsByHeroID applies wandering merchant rewards for a DB hero id (WebSocket npc_alms_accept).
func (h *NPCHandler) ProcessAlmsByHeroID(ctx context.Context, heroID int64) error {
	hero, err := h.heroStore.GetByID(ctx, heroID)
	if err != nil {
		h.logger.Error("failed to get hero for ws npc alms", "hero_id", heroID, "error", err)
		return errors.New("failed to load hero")
	}
	if hero == nil {
		return errors.New("hero not found")
	}

	cost := game.WanderingMerchantCost(hero.Level)
	if hero.Gold < cost {
		return fmt.Errorf("not enough gold (need %d, have %d)", cost, hero.Gold)
	}

	hero.Gold -= cost
	now := time.Now()
	drop, err := h.grantMerchantLoot(ctx, hero, now, hero.Level)
	if err != nil {
		hero.Gold += cost
		return err
	}

	hero.RefreshDerivedCombatStats(now)
	if err := h.heroStore.Save(ctx, hero); err != nil {
		h.logger.Error("failed to save hero after alms", "hero_id", hero.ID, "error", err)
		return errors.New("failed to save hero")
	}

	if h.engine != nil {
		h.engine.ApplyHeroAlmsUpdate(hero)
	}
	h.sendMerchantLootWS(hero.ID, cost, drop)
	return nil
}

// NPCAlms handles POST /api/v1/hero/npc-alms.
// The hero pays for one random equipment roll; better items equip, worse are sold for gold.
func (h *NPCHandler) NPCAlms(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req struct {
		Accept bool `json:"accept"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		// Empty body used to be sent by the web client; treat as accept (mysterious item purchase).
		if errors.Is(err, io.EOF) {
			req.Accept = true
		} else {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "invalid request body",
			})
			return
		}
	}

	hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("failed to get hero for npc alms", "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 !req.Accept {
		writeJSON(w, http.StatusOK, model.AlmsResponse{
			Accepted: false,
			Message:  "You declined the wandering merchant's offer.",
		})
		return
	}

	cost := game.WanderingMerchantCost(hero.Level)
	if hero.Gold < cost {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": fmt.Sprintf("not enough gold (need %d, have %d)", cost, hero.Gold),
		})
		return
	}

	hero.Gold -= cost
	now := time.Now()
	drop, err := h.grantMerchantLoot(r.Context(), hero, now, hero.Level)
	if err != nil {
		hero.Gold += cost
		writeJSON(w, http.StatusInternalServerError, map[string]string{
			"error": "failed to generate reward",
		})
		return
	}

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

	if h.engine != nil {
		h.engine.ApplyHeroAlmsUpdate(hero)
	}
	h.sendMerchantLootWS(hero.ID, cost, drop)

	msg := fmt.Sprintf("You gave %d gold to the wandering merchant and received %s.", cost, drop.ItemName)

	writeJSON(w, http.StatusOK, model.AlmsResponse{
		Accepted:  true,
		GoldSpent: cost,
		ItemDrop:  drop,
		Hero:      hero,
		Message:   msg,
	})
}

// HealHero handles POST /api/v1/hero/npc-heal.
// A healer NPC restores the hero to full HP for the runtime-configured gold cost.
func (h *NPCHandler) HealHero(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req struct {
		NPCID     int64   `json:"npcId"`
		PositionX float64 `json:"positionX"`
		PositionY float64 `json:"positionY"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body",
		})
		return
	}

	var hero *model.Hero
	if req.NPCID != 0 {
		var err error
		hero, _, _, err = h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer")
		if err != nil {
			msg := err.Error()
			switch msg {
			case "hero not found":
				writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
			case "npc not found", "town not found":
				writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
			case "failed to load hero", "failed to load npc", "failed to load town":
				h.logger.Error("npc heal lookup", "error", err)
				writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
			default:
				writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
			}
			return
		}
	} else {
		var err error
		hero, err = h.heroStore.GetByTelegramID(r.Context(), telegramID)
		if err != nil {
			h.logger.Error("failed to get hero for heal", "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
		}
	}

	_, healCost := tuning.EffectiveNPCShopCosts()
	if hero.Gold < healCost {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": fmt.Sprintf("not enough gold (need %d, have %d)", healCost, hero.Gold),
		})
		return
	}

	hero.Gold -= healCost
	hero.HP = hero.MaxHP

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

	h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseHealedFullTown}})
	if h.engine != nil {
		h.engine.ApplyPersistedHeroSnapshot(hero)
	}
	writeHeroJSON(w, http.StatusOK, hero)
}

// BuyPotion handles POST /api/v1/hero/npc-buy-potion.
// A healer NPC sells a healing potion (hero must stand in town near the NPC's town).
func (h *NPCHandler) BuyPotion(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req struct {
		NPCID     int64   `json:"npcId"`
		PositionX float64 `json:"positionX"`
		PositionY float64 `json:"positionY"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body",
		})
		return
	}

	hero, _, _, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY, "healer")
	if err != nil {
		msg := err.Error()
		switch msg {
		case "hero not found":
			writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
		case "npc not found", "town not found":
			writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
		case "failed to load hero", "failed to load npc", "failed to load town":
			h.logger.Error("buy potion lookup", "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
		default:
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
		}
		return
	}

	potionCost, _ := tuning.EffectiveNPCShopCosts()
	if hero.Gold < potionCost {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": fmt.Sprintf("not enough gold (need %d, have %d)", potionCost, hero.Gold),
		})
		return
	}

	hero.Gold -= potionCost
	hero.Potions++

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

	h.addLogLine(hero.ID, model.AdventureLogLine{Event: &model.AdventureLogEvent{Code: model.LogPhraseBoughtPotionTown}})
	if h.engine != nil {
		h.engine.ApplyPersistedHeroSnapshot(hero)
	}
	writeHeroJSON(w, http.StatusOK, hero)
}

// BuyTownMerchantGear handles POST /api/v1/hero/npc-buy-town-gear.
// Purchases one row from the current merchant stock (see POST .../npc-merchant-stock); equips immediately.
func (h *NPCHandler) BuyTownMerchantGear(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "missing telegramId",
		})
		return
	}

	var req struct {
		NPCID      int64   `json:"npcId"`
		PositionX  float64 `json:"positionX"`
		PositionY  float64 `json:"positionY"`
		OfferIndex int     `json:"offerIndex"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid request body",
		})
		return
	}

	hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY,
		model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler)
	if err != nil {
		msg := err.Error()
		switch msg {
		case "hero not found":
			writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
		case "npc not found", "town not found":
			writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
		case "failed to load hero", "failed to load npc", "failed to load town":
			h.logger.Error("buy town gear lookup", "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
		default:
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
		}
		return
	}

	if h.gearStore == nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "gear store unavailable"})
		return
	}
	if h.engine == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"})
		return
	}

	item, price, ok := h.engine.TakeMerchantOffer(hero.ID, req.NPCID, req.OfferIndex)
	if !ok || item == nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": "invalid or expired shop offer — reopen the merchant",
		})
		return
	}
	if hero.Gold < price {
		h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price)
		writeJSON(w, http.StatusBadRequest, map[string]string{
			"error": fmt.Sprintf("not enough gold (need %d, have %d)", price, hero.Gold),
		})
		return
	}

	hero.Gold -= price
	now := time.Now()
	drop, err := game.ApplyPreparedTownMerchantPurchase(r.Context(), h.gearStore, hero, item, now)
	if err != nil {
		hero.Gold += price
		h.engine.UnshiftMerchantOffer(hero.ID, npc.ID, town.ID, item, price)
		if errors.Is(err, storage.ErrInventoryFull) {
			writeJSON(w, http.StatusBadRequest, map[string]string{
				"error": "inventory full — free a backpack slot to swap gear",
			})
			return
		}
		h.logger.Warn("town merchant gear failed", "hero_id", hero.ID, "error", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to grant gear"})
		return
	}

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

	h.addLogLine(hero.ID, model.AdventureLogLine{
		Event: &model.AdventureLogEvent{
			Code: model.LogPhraseBoughtGearTownMerchant,
			Args: map[string]any{
				"npcKey": npc.NameKey, "townKey": town.NameKey, "slot": drop.ItemType, "rarity": string(drop.Rarity), "itemId": drop.ItemID,
			},
		},
	})
	h.engine.ApplyPersistedHeroSnapshot(hero)
	writeHeroJSON(w, http.StatusOK, hero)
}

// NPCDialogPause handles POST /api/v1/hero/npc-dialog-pause.
// While open, the engine freezes town NPC visit narration timers (shop / quest UI).
func (h *NPCHandler) NPCDialogPause(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
		return
	}
	var req struct {
		Open bool `json:"open"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
		return
	}
	hero, err := h.heroStore.GetByTelegramID(r.Context(), telegramID)
	if err != nil {
		h.logger.Error("npc dialog pause: load hero", "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 {
		h.engine.SetTownNPCUILock(hero.ID, req.Open)
		if !req.Open {
			h.engine.ClearMerchantStock(hero.ID)
			h.engine.SkipTownNPCNarrationAfterDialog(hero.ID)
		}
	}
	writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}

// MerchantStock handles POST /api/v1/hero/npc-merchant-stock.
// Rolls town-tier gear rows (not persisted until purchase) and caches them on the engine.
func (h *NPCHandler) MerchantStock(w http.ResponseWriter, r *http.Request) {
	telegramID, ok := resolveTelegramID(r)
	if !ok {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing telegramId"})
		return
	}
	var req struct {
		NPCID     int64   `json:"npcId"`
		PositionX float64 `json:"positionX"`
		PositionY float64 `json:"positionY"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
		return
	}
	if h.engine == nil {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "world engine unavailable"})
		return
	}
	hero, npc, town, err := h.loadHeroNPCInTown(r.Context(), telegramID, req.NPCID, req.PositionX, req.PositionY,
		model.NPCTypeMerchant, model.NPCTypeArmorer, model.NPCTypeWeapon, model.NPCTypeJeweler)
	if err != nil {
		msg := err.Error()
		switch msg {
		case "hero not found":
			writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
		case "npc not found", "town not found":
			writeJSON(w, http.StatusNotFound, map[string]string{"error": msg})
		case "failed to load hero", "failed to load npc", "failed to load town":
			h.logger.Error("merchant stock lookup", "error", err)
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load data"})
		default:
			writeJSON(w, http.StatusBadRequest, map[string]string{"error": msg})
		}
		return
	}
	townLv := game.TownEffectiveLevel(town)
	n := tuning.EffectiveMerchantTownStockCount()
	slots := model.GearVendorSlots(npc.Type)
	items := game.RollTownMerchantStockItemsForSlots(townLv, n, slots)
	costs := make([]int64, len(items))
	for i, it := range items {
		if it == nil {
			continue
		}
		costs[i] = game.RollTownMerchantOfferGold(it.Ilvl, it.Rarity, townLv)
	}
	h.engine.SetTownNPCUILock(hero.ID, true)
	h.engine.SetMerchantStock(hero.ID, npc.ID, town.ID, items, costs)
	rows := make([]merchantStockRow, len(items))
	for i, it := range items {
		if it == nil {
			continue
		}
		rows[i].GearItem = *it
		rows[i].Cost = costs[i]
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"items": rows,
	})
}
