package storage

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

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/denisovdennis/autohero/internal/model"
)

// heroSelectQuery is the shared SELECT used by all hero-loading methods.
// Gear is loaded separately via GearStore.GetHeroGear after the hero row is loaded.
const heroSelectQuery = `
	SELECT
		h.id, h.telegram_id, h.name,
		h.hp, h.max_hp, h.attack, h.defense, h.speed,
		h.strength, h.constitution, h.agility, h.luck,
		h.state, h.weapon_id, h.armor_id,
		h.gold, h.xp, h.level,
		h.revive_count, h.subscription_active, h.subscription_expires_at,
		h.buff_free_charges_remaining, h.buff_quota_period_end, h.buff_charges,
		h.position_x, h.position_y, h.potions,
		h.total_kills, h.elite_kills, h.total_deaths, h.kills_since_death, h.legendary_drops,
		h.current_town_id, h.destination_town_id, h.move_state, h.town_pause,
		h.last_online_at,
		h.created_at, h.updated_at
	FROM heroes h
`

// HeroStore handles all hero CRUD operations against PostgreSQL.
type HeroStore struct {
	pool      *pgxpool.Pool
	gearStore *GearStore
	logger    *slog.Logger
}

// NewHeroStore creates a new HeroStore backed by the given connection pool.
func NewHeroStore(pool *pgxpool.Pool, logger *slog.Logger) *HeroStore {
	return &HeroStore{
		pool:      pool,
		gearStore: NewGearStore(pool),
		logger:    logger,
	}
}

// GetHeroIDByTelegramID returns the DB hero ID for a given Telegram user ID.
// Returns (0, nil) if not found.
func (s *HeroStore) GetHeroIDByTelegramID(ctx context.Context, telegramID int64) (int64, error) {
	var id int64
	err := s.pool.QueryRow(ctx, "SELECT id FROM heroes WHERE telegram_id = $1", telegramID).Scan(&id)
	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return 0, nil
		}
		return 0, err
	}
	return id, nil
}

// GearStore returns the embedded gear store for direct access by handlers.
func (s *HeroStore) GearStore() *GearStore {
	return s.gearStore
}

// GetByTelegramID loads a hero by Telegram user ID, including weapon and armor via LEFT JOIN.
// Returns (nil, nil) if no hero is found.
func (s *HeroStore) GetByTelegramID(ctx context.Context, telegramID int64) (*model.Hero, error) {
	query := heroSelectQuery + ` WHERE h.telegram_id = $1`

	row := s.pool.QueryRow(ctx, query, telegramID)
	hero, err := scanHeroRow(row)
	if err != nil || hero == nil {
		return hero, err
	}
	if err := s.loadHeroGear(ctx, hero); err != nil {
		return nil, fmt.Errorf("get hero by telegram_id gear: %w", err)
	}
	if err := s.loadHeroInventory(ctx, hero); err != nil {
		return nil, fmt.Errorf("get hero by telegram_id inventory: %w", err)
	}
	if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil {
		return nil, fmt.Errorf("get hero by telegram_id buffs: %w", err)
	}
	return hero, nil
}

// ListHeroes returns a paginated list of heroes ordered by updated_at DESC.
func (s *HeroStore) ListHeroes(ctx context.Context, limit, offset int) ([]*model.Hero, error) {
	return s.ListHeroesFiltered(ctx, limit, offset, "")
}

// ListHeroesFiltered returns a paginated hero list with optional query across
// hero name, DB id, and Telegram id.
func (s *HeroStore) ListHeroesFiltered(ctx context.Context, limit, offset int, query string) ([]*model.Hero, error) {
	if limit <= 0 {
		limit = 20
	}
	if limit > 200 {
		limit = 200
	}
	if offset < 0 {
		offset = 0
	}

	base := heroSelectQuery
	var args []any
	if q := strings.TrimSpace(query); q != "" {
		// Pure numeric query: exact match by DB id or telegram id (single hero in normal cases).
		if n, err := strconv.ParseInt(q, 10, 64); err == nil && n > 0 {
			base += ` WHERE (h.id = $1 OR h.telegram_id = $1)`
			args = append(args, n)
		} else {
			search := "%" + strings.ToLower(q) + "%"
			base += ` WHERE LOWER(h.name) LIKE $1`
			args = append(args, search)
		}
	}
	base += ` ORDER BY h.updated_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
	args = append(args, limit, offset)

	rows, err := s.pool.Query(ctx, base, args...)
	if err != nil {
		return nil, fmt.Errorf("list heroes: %w", err)
	}
	defer rows.Close()

	var heroes []*model.Hero
	for rows.Next() {
		h, err := scanHeroFromRows(rows)
		if err != nil {
			return nil, fmt.Errorf("list heroes scan: %w", err)
		}
		heroes = append(heroes, h)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("list heroes rows: %w", err)
	}
	for _, h := range heroes {
		if err := s.loadHeroGear(ctx, h); err != nil {
			return nil, fmt.Errorf("list heroes load gear: %w", err)
		}
		if err := s.loadHeroInventory(ctx, h); err != nil {
			return nil, fmt.Errorf("list heroes load inventory: %w", err)
		}
		if err := s.loadHeroBuffsAndDebuffs(ctx, h); err != nil {
			return nil, fmt.Errorf("list heroes load buffs: %w", err)
		}
	}
	return heroes, nil
}

// ListPayments returns payment rows ordered by created_at DESC.
func (s *HeroStore) ListPayments(ctx context.Context, heroID int64, limit, offset int) ([]*model.Payment, error) {
	if limit <= 0 {
		limit = 50
	}
	if limit > 200 {
		limit = 200
	}
	if offset < 0 {
		offset = 0
	}

	query := `
		SELECT id, hero_id, type, buff_type, amount_rub, status, created_at, completed_at
		FROM payments
	`
	args := []any{}
	if heroID > 0 {
		query += ` WHERE hero_id = $1`
		args = append(args, heroID)
	}
	query += ` ORDER BY created_at DESC LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` OFFSET $` + fmt.Sprintf("%d", len(args)+2)
	args = append(args, limit, offset)

	rows, err := s.pool.Query(ctx, query, args...)
	if err != nil {
		return nil, fmt.Errorf("list payments: %w", err)
	}
	defer rows.Close()

	out := make([]*model.Payment, 0, limit)
	for rows.Next() {
		var p model.Payment
		var pType string
		var status string
		if err := rows.Scan(&p.ID, &p.HeroID, &pType, &p.BuffType, &p.AmountRUB, &status, &p.CreatedAt, &p.CompletedAt); err != nil {
			return nil, fmt.Errorf("scan payment: %w", err)
		}
		p.Type = model.PaymentType(pType)
		p.Status = model.PaymentStatus(status)
		out = append(out, &p)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("payments rows: %w", err)
	}
	return out, nil
}

// GetPaymentByID loads one payment row by ID.
func (s *HeroStore) GetPaymentByID(ctx context.Context, paymentID int64) (*model.Payment, error) {
	var p model.Payment
	var pType string
	var status string
	err := s.pool.QueryRow(ctx, `
		SELECT id, hero_id, type, buff_type, amount_rub, status, created_at, completed_at
		FROM payments
		WHERE id = $1
	`, paymentID).Scan(&p.ID, &p.HeroID, &pType, &p.BuffType, &p.AmountRUB, &status, &p.CreatedAt, &p.CompletedAt)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, nil
		}
		return nil, fmt.Errorf("get payment: %w", err)
	}
	p.Type = model.PaymentType(pType)
	p.Status = model.PaymentStatus(status)
	return &p, nil
}

// DeleteByID removes a hero by its primary key. Returns nil if the hero didn't exist.
func (s *HeroStore) DeleteByID(ctx context.Context, id int64) error {
	_, err := s.pool.Exec(ctx, `DELETE FROM heroes WHERE id = $1`, id)
	if err != nil {
		return fmt.Errorf("delete hero: %w", err)
	}
	return nil
}

// GetByID loads a hero by its primary key, including weapon and armor.
// Returns (nil, nil) if not found.
func (s *HeroStore) GetByID(ctx context.Context, id int64) (*model.Hero, error) {
	query := heroSelectQuery + ` WHERE h.id = $1`

	row := s.pool.QueryRow(ctx, query, id)
	hero, err := scanHeroRow(row)
	if err != nil || hero == nil {
		return hero, err
	}
	if err := s.loadHeroGear(ctx, hero); err != nil {
		return nil, fmt.Errorf("get hero by id gear: %w", err)
	}
	if err := s.loadHeroInventory(ctx, hero); err != nil {
		return nil, fmt.Errorf("get hero by id inventory: %w", err)
	}
	if err := s.loadHeroBuffsAndDebuffs(ctx, hero); err != nil {
		return nil, fmt.Errorf("get hero by id buffs: %w", err)
	}
	return hero, nil
}

// insertNewHeroRow inserts a hero row and sets hero.ID. Does not create gear.
// Default weapon_id=1 and armor_id=1 satisfy FK to legacy weapons/armor tables.
func (s *HeroStore) insertNewHeroRow(ctx context.Context, hero *model.Hero) error {
	now := time.Now()

	var weaponID int64 = 1
	var armorID int64 = 1
	hero.WeaponID = &weaponID
	hero.ArmorID = &armorID
	hero.CreatedAt = now
	hero.UpdatedAt = now

	if hero.MoveState == "" {
		hero.MoveState = string(model.StateWalking)
	}

	buffChargesJSON := marshalBuffCharges(hero.BuffCharges)

	query := `
		INSERT INTO heroes (
			telegram_id, name,
			hp, max_hp, attack, defense, speed,
			strength, constitution, agility, luck,
			state, weapon_id, armor_id,
			gold, xp, level,
			revive_count, subscription_active, subscription_expires_at,
			buff_free_charges_remaining, buff_quota_period_end, buff_charges,
			position_x, position_y, potions,
			total_kills, elite_kills, total_deaths, kills_since_death, legendary_drops,
			last_online_at,
			created_at, updated_at,
			current_town_id, destination_town_id, move_state
		) VALUES (
			$1, $2,
			$3, $4, $5, $6, $7,
			$8, $9, $10, $11,
			$12, $13, $14,
			$15, $16, $17,
			$18, $19, $20,
			$21, $22, $23,
			$24, $25, $26,
			$27, $28, $29, $30, $31,
			$32,
			$33, $34,
			$35, $36, $37
		) RETURNING id
	`

	err := s.pool.QueryRow(ctx, query,
		hero.TelegramID, hero.Name,
		hero.HP, hero.MaxHP, hero.Attack, hero.Defense, hero.Speed,
		hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
		string(hero.State), hero.WeaponID, hero.ArmorID,
		hero.Gold, hero.XP, hero.Level,
		hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
		hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
		hero.PositionX, hero.PositionY, hero.Potions,
		hero.TotalKills, hero.EliteKills, hero.TotalDeaths, hero.KillsSinceDeath, hero.LegendaryDrops,
		hero.LastOnlineAt,
		hero.CreatedAt, hero.UpdatedAt,
		hero.CurrentTownID, hero.DestinationTownID, hero.MoveState,
	).Scan(&hero.ID)
	if err != nil {
		return fmt.Errorf("insert hero: %w", err)
	}

	return nil
}

// Create inserts a new hero into the database with legacy default starter gear (dagger + leather).
func (s *HeroStore) Create(ctx context.Context, hero *model.Hero) error {
	if err := s.insertNewHeroRow(ctx, hero); err != nil {
		return err
	}
	if err := s.createDefaultGear(ctx, hero.ID); err != nil {
		return fmt.Errorf("create default gear: %w", err)
	}
	return nil
}

func (s *HeroStore) randomTownCoords(ctx context.Context) (id int64, wx, wy float64, err error) {
	err = s.pool.QueryRow(ctx, `
		SELECT id, world_x, world_y FROM towns ORDER BY random() LIMIT 1`,
	).Scan(&id, &wx, &wy)
	if err != nil {
		return 0, 0, 0, fmt.Errorf("random town: %w", err)
	}
	return id, wx, wy, nil
}

func (s *HeroStore) randomOutgoingTownID(ctx context.Context, fromTownID int64) (int64, error) {
	var to int64
	err := s.pool.QueryRow(ctx, `
		SELECT to_town_id FROM roads WHERE from_town_id = $1 ORDER BY random() LIMIT 1`, fromTownID,
	).Scan(&to)
	return to, err
}

func (s *HeroStore) pickBirthTownAndDestination(ctx context.Context) (birthID, destID int64, bx, by float64, err error) {
	for i := 0; i < 12; i++ {
		birthID, bx, by, err = s.randomTownCoords(ctx)
		if err != nil {
			return 0, 0, 0, 0, err
		}
		destID, err = s.randomOutgoingTownID(ctx, birthID)
		if err == nil && destID != 0 && destID != birthID {
			return birthID, destID, bx, by, nil
		}
	}
	err = s.pool.QueryRow(ctx, `
		SELECT r.from_town_id, r.to_town_id, tf.world_x, tf.world_y
		FROM roads r
		JOIN towns tf ON tf.id = r.from_town_id
		ORDER BY random() LIMIT 1`,
	).Scan(&birthID, &destID, &bx, &by)
	if err != nil {
		return 0, 0, 0, 0, fmt.Errorf("pick spawn on random road: %w", err)
	}
	return birthID, destID, bx, by, nil
}

// CreateHeroWithSpawn creates a new hero after the player chose a name: random birth town,
// 100 gold, random common ilvl-1 sword and armor, destination a town reachable by road.
func (s *HeroStore) CreateHeroWithSpawn(ctx context.Context, telegramID int64, name string) (*model.Hero, error) {
	birthID, destID, bx, by, err := s.pickBirthTownAndDestination(ctx)
	if err != nil {
		return nil, err
	}

	birth := birthID
	dest := destID
	hero := &model.Hero{
		TelegramID:               telegramID,
		Name:                     name,
		HP:                       100,
		MaxHP:                    100,
		Attack:                   10,
		Defense:                  5,
		Speed:                    1.0,
		Strength:                 1,
		Constitution:             1,
		Agility:                  1,
		Luck:                     1,
		State:                    model.StateWalking,
		Gold:                     100,
		XP:                       0,
		Level:                    1,
		PositionX:                bx,
		PositionY:                by,
		CurrentTownID:            &birth,
		DestinationTownID:        &dest,
		MoveState:                string(model.StateWalking),
		BuffFreeChargesRemaining: model.FreeBuffActivationsPerPeriodRuntime(),
	}

	if err := s.insertNewHeroRow(ctx, hero); err != nil {
		return nil, err
	}
	if err := s.createRandomStarterGear(ctx, hero.ID); err != nil {
		return nil, fmt.Errorf("create starter gear: %w", err)
	}

	return s.GetByID(ctx, hero.ID)
}

func (s *HeroStore) createRandomStarterGear(ctx context.Context, heroID int64) error {
	swords := []struct {
		name string
	}{
		{"Worn Shortsword"},
		{"Traveler's Blade"},
		{"Notched Sword"},
		{"Training Sword"},
	}
	sw := swords[rand.Intn(len(swords))]
	starterWeapon := &model.GearItem{
		Slot:          model.SlotMainHand,
		FormID:        "gear.form.main_hand.sword",
		Name:          sw.name,
		Subtype:       "sword",
		Rarity:        model.RarityCommon,
		Ilvl:          1,
		BasePrimary:   4,
		PrimaryStat:   4,
		StatType:      "attack",
		SpeedModifier: 1.0,
		CritChance:    0.05,
	}
	if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil {
		return fmt.Errorf("create starter sword: %w", err)
	}
	if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil {
		return fmt.Errorf("equip starter sword: %w", err)
	}

	armors := []struct {
		name       string
		formID     string
		subtype    string
		speed      float64
		agilityBon int
		defense    int
	}{
		{"Worn Leather Jack", "gear.form.chest.leather", "light", 1.05, 2, 3},
		{"Rusty Hauberk", "gear.form.chest.mail", "medium", 1.0, 0, 4},
		{"Worn Plate", "gear.form.chest.plate", "heavy", 0.7, 0, 5},
	}
	ar := armors[rand.Intn(len(armors))]
	starterArmor := &model.GearItem{
		Slot:          model.SlotChest,
		FormID:        ar.formID,
		Name:          ar.name,
		Subtype:       ar.subtype,
		Rarity:        model.RarityCommon,
		Ilvl:          1,
		BasePrimary:   ar.defense,
		PrimaryStat:   ar.defense,
		StatType:      "defense",
		SpeedModifier: ar.speed,
		AgilityBonus:  ar.agilityBon,
	}
	if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil {
		return fmt.Errorf("create starter armor: %w", err)
	}
	if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil {
		return fmt.Errorf("equip starter armor: %w", err)
	}

	return nil
}

// createDefaultGear creates starter weapon (Rusty Dagger) and armor (Leather Armor)
// as gear items and equips them for a new hero.
func (s *HeroStore) createDefaultGear(ctx context.Context, heroID int64) error {
	starterWeapon := &model.GearItem{
		Slot:          model.SlotMainHand,
		FormID:        "gear.form.main_hand.daggers",
		Name:          "Rusty Dagger",
		Subtype:       "daggers",
		Rarity:        model.RarityCommon,
		Ilvl:          1,
		BasePrimary:   3,
		PrimaryStat:   3,
		StatType:      "attack",
		SpeedModifier: 1.3,
		CritChance:    0.05,
	}
	if err := s.gearStore.CreateItem(ctx, starterWeapon); err != nil {
		return fmt.Errorf("create starter weapon: %w", err)
	}
	if err := s.gearStore.EquipItem(ctx, heroID, model.SlotMainHand, starterWeapon.ID); err != nil {
		return fmt.Errorf("equip starter weapon: %w", err)
	}

	starterArmor := &model.GearItem{
		Slot:          model.SlotChest,
		FormID:        "gear.form.chest.light",
		Name:          "Leather Armor",
		Subtype:       "light",
		Rarity:        model.RarityCommon,
		Ilvl:          1,
		BasePrimary:   3,
		PrimaryStat:   3,
		StatType:      "defense",
		SpeedModifier: 1.05,
		AgilityBonus:  3,
	}
	if err := s.gearStore.CreateItem(ctx, starterArmor); err != nil {
		return fmt.Errorf("create starter armor: %w", err)
	}
	if err := s.gearStore.EquipItem(ctx, heroID, model.SlotChest, starterArmor.ID); err != nil {
		return fmt.Errorf("equip starter armor: %w", err)
	}

	return nil
}

// Save updates a hero's mutable fields in the database.
func (s *HeroStore) Save(ctx context.Context, hero *model.Hero) error {
	hero.UpdatedAt = time.Now()
	buffChargesJSON := marshalBuffCharges(hero.BuffCharges)

	query := `
		UPDATE heroes SET
			hp = $1, max_hp = $2,
			attack = $3, defense = $4, speed = $5,
			strength = $6, constitution = $7, agility = $8, luck = $9,
			state = $10, weapon_id = $11, armor_id = $12,
			gold = $13, xp = $14, level = $15,
			revive_count = $16, subscription_active = $17, subscription_expires_at = $18,
			buff_free_charges_remaining = $19, buff_quota_period_end = $20, buff_charges = $21,
			position_x = $22, position_y = $23, potions = $24,
			total_kills = $25, elite_kills = $26, total_deaths = $27,
			kills_since_death = $28, legendary_drops = $29,
			last_online_at = $30,
			updated_at = $31,
			destination_town_id = $32,
			current_town_id = $33,
			move_state = $34,
			town_pause = $35
		WHERE id = $36
	`

	townPauseJSON := marshalTownPause(hero.TownPause)
	tag, err := s.pool.Exec(ctx, query,
		hero.HP, hero.MaxHP,
		hero.Attack, hero.Defense, hero.Speed,
		hero.Strength, hero.Constitution, hero.Agility, hero.Luck,
		string(hero.State), hero.WeaponID, hero.ArmorID,
		hero.Gold, hero.XP, hero.Level,
		hero.ReviveCount, hero.SubscriptionActive, hero.SubscriptionExpiresAt,
		hero.BuffFreeChargesRemaining, hero.BuffQuotaPeriodEnd, buffChargesJSON,
		hero.PositionX, hero.PositionY, hero.Potions,
		hero.TotalKills, hero.EliteKills, hero.TotalDeaths,
		hero.KillsSinceDeath, hero.LegendaryDrops,
		hero.LastOnlineAt,
		hero.UpdatedAt,
		hero.DestinationTownID,
		hero.CurrentTownID,
		hero.MoveState,
		townPauseJSON,
		hero.ID,
	)
	if err != nil {
		return fmt.Errorf("update hero: %w", err)
	}

	if tag.RowsAffected() == 0 {
		return fmt.Errorf("update hero: no rows affected (id=%d)", hero.ID)
	}

	if err := s.saveHeroBuffsAndDebuffs(ctx, hero); err != nil {
		return fmt.Errorf("update hero buffs/debuffs: %w", err)
	}

	inv := hero.Inventory
	if inv == nil {
		inv = []*model.GearItem{}
	}
	if err := s.gearStore.ReplaceHeroInventory(ctx, hero.ID, inv); err != nil {
		return fmt.Errorf("update hero inventory: %w", err)
	}

	s.logger.Info("saved hero", "hero", hero)

	return nil
}

// SavePosition is a lightweight UPDATE that persists only the hero's world position.
// Called frequently as the hero moves around the map.
func (s *HeroStore) SavePosition(ctx context.Context, heroID int64, x, y float64) error {
	_, err := s.pool.Exec(ctx, `UPDATE heroes SET position_x = $1, position_y = $2, updated_at = now() WHERE id = $3`, x, y, heroID)
	if err != nil {
		return fmt.Errorf("save position: %w", err)
	}
	return nil
}

// ListOfflineHeroes returns heroes that need catch-up: walking heroes stale on the map,
// or heroes resting / in town whose DB row has not been updated recently (offline town timers).
// Heroes with an active WebSocket session are filtered out by the offline simulator (skipIfLive).
func (s *HeroStore) ListOfflineHeroes(ctx context.Context, offlineThreshold time.Duration, limit int) ([]*model.Hero, error) {
	if limit <= 0 {
		limit = 100
	}
	if limit > 500 {
		limit = 500
	}

	cutoff := time.Now().Add(-offlineThreshold)

	query := heroSelectQuery + `
		WHERE h.hp > 0 AND h.updated_at < $1
		AND (
			(h.state = 'walking'
				AND (h.move_state IS NULL OR h.move_state NOT IN ('in_town', 'resting')))
			OR h.state IN ('resting', 'in_town')
		)
		ORDER BY h.updated_at ASC
		LIMIT $2
	`

	rows, err := s.pool.Query(ctx, query, cutoff, limit)
	if err != nil {
		return nil, fmt.Errorf("list offline heroes: %w", err)
	}
	defer rows.Close()

	var heroes []*model.Hero
	for rows.Next() {
		h, err := scanHeroFromRows(rows)
		if err != nil {
			return nil, fmt.Errorf("list offline heroes scan: %w", err)
		}
		heroes = append(heroes, h)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("list offline heroes rows: %w", err)
	}
	for _, h := range heroes {
		if err := s.loadHeroGear(ctx, h); err != nil {
			return nil, fmt.Errorf("list offline heroes load gear: %w", err)
		}
		if err := s.loadHeroInventory(ctx, h); err != nil {
			return nil, fmt.Errorf("list offline heroes load inventory: %w", err)
		}
	}
	return heroes, nil
}

// scanHeroFromRows scans the current row from pgx.Rows into a Hero struct.
// Gear is loaded separately via loadHeroGear after scanning.
func scanHeroFromRows(rows pgx.Rows) (*model.Hero, error) {
	var h model.Hero
	var state string
	var buffChargesRaw []byte
	var townPauseRaw []byte

	err := rows.Scan(
		&h.ID, &h.TelegramID, &h.Name,
		&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
		&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
		&state, &h.WeaponID, &h.ArmorID,
		&h.Gold, &h.XP, &h.Level,
		&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
		&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
		&h.PositionX, &h.PositionY, &h.Potions,
		&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
		&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
		&h.LastOnlineAt,
		&h.CreatedAt, &h.UpdatedAt,
	)
	if err != nil {
		return nil, fmt.Errorf("scan hero from rows: %w", err)
	}
	h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
	h.State = model.GameState(state)
	h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
	h.TownPause = unmarshalTownPause(townPauseRaw)

	return &h, nil
}

// scanHeroRow scans a single row from the hero query into a Hero struct.
// Returns (nil, nil) when the row is pgx.ErrNoRows.
// Gear is loaded separately via loadHeroGear after scanning.
func scanHeroRow(row pgx.Row) (*model.Hero, error) {
	var h model.Hero
	var state string
	var buffChargesRaw []byte
	var townPauseRaw []byte

	err := row.Scan(
		&h.ID, &h.TelegramID, &h.Name,
		&h.HP, &h.MaxHP, &h.Attack, &h.Defense, &h.Speed,
		&h.Strength, &h.Constitution, &h.Agility, &h.Luck,
		&state, &h.WeaponID, &h.ArmorID,
		&h.Gold, &h.XP, &h.Level,
		&h.ReviveCount, &h.SubscriptionActive, &h.SubscriptionExpiresAt,
		&h.BuffFreeChargesRemaining, &h.BuffQuotaPeriodEnd, &buffChargesRaw,
		&h.PositionX, &h.PositionY, &h.Potions,
		&h.TotalKills, &h.EliteKills, &h.TotalDeaths, &h.KillsSinceDeath, &h.LegendaryDrops,
		&h.CurrentTownID, &h.DestinationTownID, &h.MoveState, &townPauseRaw,
		&h.LastOnlineAt,
		&h.CreatedAt, &h.UpdatedAt,
	)
	if err != nil {
		if err == pgx.ErrNoRows {
			return nil, nil
		}
		return nil, fmt.Errorf("scan hero row: %w", err)
	}
	h.BuffCharges = unmarshalBuffCharges(buffChargesRaw)
	h.State = model.GameState(state)
	h.Gear = make(map[model.EquipmentSlot]*model.GearItem)
	h.TownPause = unmarshalTownPause(townPauseRaw)

	return &h, nil
}

func marshalTownPause(p *model.TownPausePersisted) []byte {
	if p == nil {
		return nil
	}
	b, err := json.Marshal(p)
	if err != nil {
		return nil
	}
	return b
}

func unmarshalTownPause(raw []byte) *model.TownPausePersisted {
	if len(raw) == 0 {
		return nil
	}
	var p model.TownPausePersisted
	if err := json.Unmarshal(raw, &p); err != nil {
		return nil
	}
	return &p
}

// loadHeroGear populates the hero's Gear map from the hero_gear table.
func (s *HeroStore) loadHeroGear(ctx context.Context, hero *model.Hero) error {
	gear, err := s.gearStore.GetHeroGear(ctx, hero.ID)
	if err != nil {
		return fmt.Errorf("load hero gear: %w", err)
	}
	hero.Gear = gear
	return nil
}

// loadHeroInventory populates the hero's backpack from hero_inventory.
func (s *HeroStore) loadHeroInventory(ctx context.Context, hero *model.Hero) error {
	inv, err := s.gearStore.GetHeroInventory(ctx, hero.ID)
	if err != nil {
		return fmt.Errorf("load hero inventory: %w", err)
	}
	hero.Inventory = inv
	if hero.Inventory == nil {
		hero.Inventory = []*model.GearItem{}
	}
	return nil
}

// loadHeroBuffsAndDebuffs populates the hero's Buffs and Debuffs from the
// hero_active_buffs / hero_active_debuffs tables, filtering out expired entries.
func (s *HeroStore) loadHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error {
	now := time.Now()

	// Active buffs.
	buffRows, err := s.pool.Query(ctx, `
		SELECT b.id, b.type, b.name, b.duration_ms, b.magnitude, b.cooldown_ms,
		       hab.applied_at, hab.expires_at
		FROM hero_active_buffs hab
		JOIN buffs b ON hab.buff_id = b.id
		WHERE hab.hero_id = $1 AND hab.expires_at > $2
		ORDER BY hab.applied_at
	`, hero.ID, now)
	if err != nil {
		return fmt.Errorf("load active buffs: %w", err)
	}
	defer buffRows.Close()

	for buffRows.Next() {
		var ab model.ActiveBuff
		var durationMs, cooldownMs int64
		if err := buffRows.Scan(
			&ab.Buff.ID, &ab.Buff.Type, &ab.Buff.Name,
			&durationMs, &ab.Buff.Magnitude, &cooldownMs,
			&ab.AppliedAt, &ab.ExpiresAt,
		); err != nil {
			return fmt.Errorf("scan active buff: %w", err)
		}
		ab.Buff.Duration = time.Duration(durationMs) * time.Millisecond
		ab.Buff.CooldownDuration = time.Duration(cooldownMs) * time.Millisecond
		hero.Buffs = append(hero.Buffs, ab)
	}
	if err := buffRows.Err(); err != nil {
		return fmt.Errorf("load active buffs rows: %w", err)
	}

	// Active debuffs.
	debuffRows, err := s.pool.Query(ctx, `
		SELECT d.id, d.type, d.name, d.duration_ms, d.magnitude,
		       had.applied_at, had.expires_at
		FROM hero_active_debuffs had
		JOIN debuffs d ON had.debuff_id = d.id
		WHERE had.hero_id = $1 AND had.expires_at > $2
		ORDER BY had.applied_at
	`, hero.ID, now)
	if err != nil {
		return fmt.Errorf("load active debuffs: %w", err)
	}
	defer debuffRows.Close()

	for debuffRows.Next() {
		var ad model.ActiveDebuff
		var durationMs int64
		if err := debuffRows.Scan(
			&ad.Debuff.ID, &ad.Debuff.Type, &ad.Debuff.Name,
			&durationMs, &ad.Debuff.Magnitude,
			&ad.AppliedAt, &ad.ExpiresAt,
		); err != nil {
			return fmt.Errorf("scan active debuff: %w", err)
		}
		ad.Debuff.Duration = time.Duration(durationMs) * time.Millisecond
		hero.Debuffs = append(hero.Debuffs, ad)
	}
	if err := debuffRows.Err(); err != nil {
		return fmt.Errorf("load active debuffs rows: %w", err)
	}

	return nil
}

// saveHeroBuffsAndDebuffs replaces the hero's active buff/debuff rows in the DB.
// Expired entries are pruned. Uses a transaction for consistency.
func (s *HeroStore) saveHeroBuffsAndDebuffs(ctx context.Context, hero *model.Hero) error {
	now := time.Now()

	tx, err := s.pool.Begin(ctx)
	if err != nil {
		return fmt.Errorf("save buffs/debuffs begin tx: %w", err)
	}
	defer tx.Rollback(ctx)

	// Replace active buffs.
	if _, err := tx.Exec(ctx, `DELETE FROM hero_active_buffs WHERE hero_id = $1`, hero.ID); err != nil {
		return fmt.Errorf("delete active buffs: %w", err)
	}
	for _, ab := range hero.Buffs {
		if ab.IsExpired(now) {
			continue
		}
		_, err := tx.Exec(ctx, `
			INSERT INTO hero_active_buffs (hero_id, buff_id, applied_at, expires_at)
			VALUES ($1, (SELECT id FROM buffs WHERE type = $2 LIMIT 1), $3, $4)
		`, hero.ID, string(ab.Buff.Type), ab.AppliedAt, ab.ExpiresAt)
		if err != nil {
			return fmt.Errorf("insert active buff %s: %w", ab.Buff.Type, err)
		}
	}

	// Replace active debuffs.
	if _, err := tx.Exec(ctx, `DELETE FROM hero_active_debuffs WHERE hero_id = $1`, hero.ID); err != nil {
		return fmt.Errorf("delete active debuffs: %w", err)
	}
	for _, ad := range hero.Debuffs {
		if ad.IsExpired(now) {
			continue
		}
		_, err := tx.Exec(ctx, `
			INSERT INTO hero_active_debuffs (hero_id, debuff_id, applied_at, expires_at)
			VALUES ($1, (SELECT id FROM debuffs WHERE type = $2 LIMIT 1), $3, $4)
		`, hero.ID, string(ad.Debuff.Type), ad.AppliedAt, ad.ExpiresAt)
		if err != nil {
			return fmt.Errorf("insert active debuff %s: %w", ad.Debuff.Type, err)
		}
	}

	return tx.Commit(ctx)
}

// marshalBuffCharges converts the in-memory buff charges map to JSON bytes for
// storage in the JSONB column. Returns "{}" for nil/empty maps.
func marshalBuffCharges(m map[string]model.BuffChargeState) []byte {
	if len(m) == 0 {
		return []byte("{}")
	}
	b, err := json.Marshal(m)
	if err != nil {
		return []byte("{}")
	}
	return b
}

// unmarshalBuffCharges parses raw JSON bytes from the buff_charges JSONB column
// into the in-memory map. Returns an empty map on nil/empty/invalid input.
func unmarshalBuffCharges(raw []byte) map[string]model.BuffChargeState {
	if len(raw) == 0 {
		return make(map[string]model.BuffChargeState)
	}
	var m map[string]model.BuffChargeState
	if err := json.Unmarshal(raw, &m); err != nil || m == nil {
		return make(map[string]model.BuffChargeState)
	}
	return m
}

// HeroSummary is a lightweight projection of a hero for nearby-heroes queries.
type HeroSummary struct {
	ID        int64   `json:"id"`
	Name      string  `json:"name"`
	Level     int     `json:"level"`
	PositionX float64 `json:"positionX"`
	PositionY float64 `json:"positionY"`
}

// UpdateOnlineStatus updates last_online_at and position for shared-world presence.
func (s *HeroStore) UpdateOnlineStatus(ctx context.Context, heroID int64, posX, posY float64) error {
	_, err := s.pool.Exec(ctx,
		`UPDATE heroes SET last_online_at = now(), position_x = $1, position_y = $2 WHERE id = $3`,
		posX, posY, heroID,
	)
	if err != nil {
		return fmt.Errorf("update online status: %w", err)
	}
	return nil
}

// GetNearbyHeroes returns other heroes within radius who were online recently (< 2 min).
func (s *HeroStore) GetNearbyHeroes(ctx context.Context, heroID int64, posX, posY, radius float64, limit int) ([]HeroSummary, error) {
	if limit <= 0 {
		limit = 20
	}
	if limit > 100 {
		limit = 100
	}

	cutoff := time.Now().Add(-2 * time.Minute)

	rows, err := s.pool.Query(ctx, `
		SELECT id, name, level, position_x, position_y
		FROM heroes
		WHERE id != $1
		  AND last_online_at > $2
		  AND sqrt(power(position_x - $3, 2) + power(position_y - $4, 2)) <= $5
		ORDER BY last_online_at DESC
		LIMIT $6
	`, heroID, cutoff, posX, posY, radius, limit)
	if err != nil {
		return nil, fmt.Errorf("get nearby heroes: %w", err)
	}
	defer rows.Close()

	var heroes []HeroSummary
	for rows.Next() {
		var h HeroSummary
		if err := rows.Scan(&h.ID, &h.Name, &h.Level, &h.PositionX, &h.PositionY); err != nil {
			return nil, fmt.Errorf("scan nearby hero: %w", err)
		}
		heroes = append(heroes, h)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("nearby heroes rows: %w", err)
	}
	if heroes == nil {
		heroes = []HeroSummary{}
	}
	return heroes, nil
}

// SaveName updates only the hero's name field. Returns an error wrapping
// "UNIQUE" if the name violates the case-insensitive uniqueness constraint.
func (s *HeroStore) SaveName(ctx context.Context, heroID int64, name string) error {
	_, err := s.pool.Exec(ctx,
		`UPDATE heroes SET name = $1, updated_at = now() WHERE id = $2`,
		name, heroID,
	)
	if err != nil {
		return fmt.Errorf("save hero name: %w", err)
	}
	return nil
}

// CreatePayment inserts a payment record and returns the generated ID.
func (s *HeroStore) CreatePayment(ctx context.Context, p *model.Payment) error {
	query := `
		INSERT INTO payments (hero_id, type, buff_type, amount_rub, status, created_at, completed_at)
		VALUES ($1, $2, $3, $4, $5, $6, $7)
		RETURNING id
	`
	return s.pool.QueryRow(ctx, query,
		p.HeroID, string(p.Type), p.BuffType, p.AmountRUB, string(p.Status),
		p.CreatedAt, p.CompletedAt,
	).Scan(&p.ID)
}

func derefStr(p *string) string {
	if p == nil {
		return ""
	}
	return *p
}

func derefInt(p *int) int {
	if p == nil {
		return 0
	}
	return *p
}

func derefFloat(p *float64) float64 {
	if p == nil {
		return 0
	}
	return *p
}
