package plugin

import (
	"database/sql"
	"errors"
	"fmt"
	"log/slog"
	"math"
	"os"
	"regexp"
	"strings"
	"sync"
	"time"

	"gogobee/internal/db"

	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/event"
	"maunium.net/go/mautrix/id"
)

// fmtEuro formats a currency value with thousand separators, e.g. "€12,500".
func fmtEuro[T int | int64 | float64](v T) string {
	n := int64(v)
	if n < 0 {
		return "-" + fmtEuro(-n)
	}
	s := fmt.Sprintf("%d", n)
	if len(s) <= 3 {
		return "€" + s
	}
	var out []byte
	for i, c := range s {
		if i > 0 && (len(s)-i)%3 == 0 {
			out = append(out, ',')
		}
		out = append(out, byte(c))
	}
	return "€" + string(out)
}

// gamesRoom returns the configured GAMES_ROOM, or empty if unset.
func gamesRoom() id.RoomID {
	return id.RoomID(os.Getenv("GAMES_ROOM"))
}

// isGamesRoom checks whether the given room is the games room.
// Returns true if no games room is configured (unrestricted).
func isGamesRoom(roomID id.RoomID) bool {
	gr := gamesRoom()
	return gr == "" || roomID == gr
}

// ---------------------------------------------------------------------------
// Euro config
// ---------------------------------------------------------------------------

type euroConfig struct {
	CooldownSeconds int
	DebtReminder    bool
	StartingCap     float64
}

func loadEuroConfig() euroConfig {
	return euroConfig{
		CooldownSeconds: envInt("EURO_COOLDOWN_SECONDS", 30),
		DebtReminder:    envOrDefault("EURO_DEBT_REMINDER", "true") == "true",
		StartingCap:     envFloat("EURO_STARTING_CAP", 2500),
	}
}

// ---------------------------------------------------------------------------
// Combo system — activity streaks that multiply passive euro earning.
//
// Three independent combo categories: messages, links, images.
// Each combo increments with qualifying activity and resets after 10 min idle.
// Multiplier: 1 + (combo_step * 2.0) — so step 1 = 3x, step 5 = 11x, etc.
// Daily cap per category prevents unbounded farming.
// ---------------------------------------------------------------------------

const (
	comboTimeout     = 10 * time.Minute
	comboMsgCap      = 50
	comboLinkCap     = 10
	comboImageCap    = 15
	comboMultPerStep = 2.0 // +200% per step
	comboLinkBase    = 5.0
	comboImageBase   = 3.0
)

type comboState struct {
	Step    int
	LastAt  time.Time
	Today   string
	DayHits int
}

var urlPattern = regexp.MustCompile(`https?://\S+`)

// ---------------------------------------------------------------------------
// Euro Plugin
// ---------------------------------------------------------------------------

type EuroPlugin struct {
	Base
	cfg       euroConfig
	cooldowns map[id.UserID]time.Time
	combos    map[id.UserID]map[string]*comboState // category → state
	mu        sync.Mutex
}

func NewEuroPlugin(client *mautrix.Client) *EuroPlugin {
	return &EuroPlugin{
		Base:      NewBase(client),
		cfg:       loadEuroConfig(),
		cooldowns: make(map[id.UserID]time.Time),
		combos:    make(map[id.UserID]map[string]*comboState),
	}
}

func (p *EuroPlugin) Name() string    { return "euro" }
func (p *EuroPlugin) Version() string { return "1.1.0" }

func (p *EuroPlugin) Commands() []CommandDef {
	return []CommandDef{
		{Name: "balance", Description: "Check your euro balance", Usage: "!balance", Category: "Economy"},
		{Name: "baltop", Description: "Euro leaderboard", Usage: "!baltop", Category: "Economy"},
		{Name: "baltransfer", Description: "Send euros to another player", Usage: "!baltransfer @user €amount", Category: "Economy"},
		{Name: "combo", Description: "View your current activity combo streaks", Usage: "!combo", Category: "Economy"},
		{Name: "eurogrant", Description: "Grant euros to a user (admin)", Usage: "!eurogrant @user amount [reason]", Category: "Admin", AdminOnly: true},
	}
}

func (p *EuroPlugin) Init() error { return nil }

func (p *EuroPlugin) OnReaction(_ ReactionContext) error { return nil }

func (p *EuroPlugin) OnMessage(ctx MessageContext) error {
	// Passive euro earning (all rooms, not just games room)
	if !ctx.IsCommand {
		p.awardPassiveEuros(ctx)
	}

	switch {
	case p.IsCommand(ctx.Body, "balance"):
		return p.handleBalance(ctx)
	case p.IsCommand(ctx.Body, "baltop"):
		return p.handleBaltop(ctx)
	case p.IsCommand(ctx.Body, "baltransfer"):
		return p.handleTransfer(ctx)
	case p.IsCommand(ctx.Body, "combo"):
		return p.handleCombo(ctx)
	case p.IsCommand(ctx.Body, "eurogrant"):
		return p.handleGrant(ctx)
	}
	return nil
}

// ---------------------------------------------------------------------------
// Passive earning
// ---------------------------------------------------------------------------

func (p *EuroPlugin) awardPassiveEuros(ctx MessageContext) {
	p.mu.Lock()
	last, ok := p.cooldowns[ctx.Sender]
	now := time.Now()
	if ok && now.Sub(last) < time.Duration(p.cfg.CooldownSeconds)*time.Second {
		p.mu.Unlock()
		return
	}
	p.cooldowns[ctx.Sender] = now
	if len(p.cooldowns) > 1000 {
		for uid, t := range p.cooldowns {
			if now.Sub(t) > time.Duration(p.cfg.CooldownSeconds)*time.Second {
				delete(p.cooldowns, uid)
			}
		}
	}
	p.mu.Unlock()

	p.ensureBalance(ctx.Sender)

	// Message combo — base amount from word count, multiplied by combo.
	words := len(strings.Fields(ctx.Body))
	var baseMsg float64
	switch {
	case words >= 51:
		baseMsg = 20.00
	case words >= 26:
		baseMsg = 10.00
	case words >= 11:
		baseMsg = 5.00
	case words >= 4:
		baseMsg = 2.50
	default:
		baseMsg = 1.00
	}
	msgMult := p.advanceCombo(ctx.Sender, "message", comboMsgCap)
	p.credit(ctx.Sender, baseMsg*msgMult, "message")

	// Link combo — bonus per URL in the message.
	if urls := urlPattern.FindAllString(ctx.Body, -1); len(urls) > 0 {
		linkMult := p.advanceCombo(ctx.Sender, "link", comboLinkCap)
		p.credit(ctx.Sender, comboLinkBase*linkMult*float64(len(urls)), "link_combo")
	}

	// Image combo — bonus for image/video/file attachments.
	if mc := ctx.Event.Content.AsMessage(); mc != nil {
		if mc.MsgType == event.MsgImage || mc.MsgType == event.MsgVideo || mc.MsgType == event.MsgFile {
			imgMult := p.advanceCombo(ctx.Sender, "image", comboImageCap)
			p.credit(ctx.Sender, comboImageBase*imgMult, "image_combo")
		}
	}
}

// advanceCombo increments a user's combo for a category, resets if timed out
// or daily cap reached, and returns the current multiplier.
func (p *EuroPlugin) advanceCombo(userID id.UserID, category string, dailyCap int) float64 {
	p.mu.Lock()
	defer p.mu.Unlock()

	now := time.Now()
	today := now.UTC().Format("2006-01-02")

	userCombos, ok := p.combos[userID]
	if !ok {
		userCombos = make(map[string]*comboState)
		p.combos[userID] = userCombos
	}

	cs, ok := userCombos[category]
	if !ok {
		cs = &comboState{}
		userCombos[category] = cs
	}

	// Reset on new day.
	if cs.Today != today {
		cs.Step = 0
		cs.DayHits = 0
		cs.Today = today
	}

	// Reset if combo timed out.
	if !cs.LastAt.IsZero() && now.Sub(cs.LastAt) > comboTimeout {
		cs.Step = 0
	}

	// At daily cap, return base multiplier (no combo bonus).
	if cs.DayHits >= dailyCap {
		return 1.0
	}

	cs.DayHits++
	cs.Step++
	cs.LastAt = now

	return 1.0 + float64(cs.Step)*comboMultPerStep
}

// ---------------------------------------------------------------------------
// !combo command
// ---------------------------------------------------------------------------

func (p *EuroPlugin) handleCombo(ctx MessageContext) error {
	p.mu.Lock()
	userCombos := p.combos[ctx.Sender]
	now := time.Now()
	today := now.UTC().Format("2006-01-02")

	type info struct {
		name    string
		step    int
		dayHits int
		cap     int
		active  bool
	}
	cats := []info{
		{"Messages", 0, 0, comboMsgCap, false},
		{"Links", 0, 0, comboLinkCap, false},
		{"Images", 0, 0, comboImageCap, false},
	}
	keys := []string{"message", "link", "image"}

	if userCombos != nil {
		for i, k := range keys {
			if cs, ok := userCombos[k]; ok && cs.Today == today {
				cats[i].step = cs.Step
				cats[i].dayHits = cs.DayHits
				cats[i].active = !cs.LastAt.IsZero() && now.Sub(cs.LastAt) < comboTimeout
			}
		}
	}
	p.mu.Unlock()

	var sb strings.Builder
	sb.WriteString("🔥 **Activity Combo**\n\n")

	for _, c := range cats {
		mult := 1.0 + float64(c.step)*comboMultPerStep
		status := "💤 inactive"
		if c.active {
			status = fmt.Sprintf("⚡ **%.0fx** multiplier", mult)
		} else if c.step > 0 {
			status = "⏳ expired (resets on next activity)"
		}
		sb.WriteString(fmt.Sprintf("**%s**: step %d/%d — %s\n", c.name, c.dayHits, c.cap, status))
	}

	sb.WriteString(fmt.Sprintf("\nCombo grows +%.0f%% per step. Resets after %d min idle.", comboMultPerStep*100, int(comboTimeout.Minutes())))

	return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}

// ---------------------------------------------------------------------------
// Balance management
// ---------------------------------------------------------------------------

// ensureBalance creates a balance row if none exists, seeding from corpus.
// Uses INSERT OR IGNORE + RowsAffected to avoid duplicate starting_balance logs.
func (p *EuroPlugin) ensureBalance(userID id.UserID) {
	d := db.Get()

	// Calculate starting balance from corpus character count
	var totalChars float64
	if err := d.QueryRow("SELECT COALESCE(SUM(total_chars), 0) FROM user_stats WHERE user_id = ?",
		string(userID)).Scan(&totalChars); err != nil {
		totalChars = 0
	}

	starting := totalChars / 1000.0
	if starting > p.cfg.StartingCap {
		starting = p.cfg.StartingCap
	}

	result, err := d.Exec(
		"INSERT OR IGNORE INTO euro_balances (user_id, balance) VALUES (?, ?)",
		string(userID), starting,
	)
	if err != nil {
		slog.Error("euro: failed to create balance", "user", userID, "err", err)
		return
	}

	// Only log transaction if a row was actually inserted (not ignored)
	affected, _ := result.RowsAffected()
	if affected > 0 && starting > 0 {
		p.logTransaction(userID, starting, "starting_balance")
	}
}

func (p *EuroPlugin) credit(userID id.UserID, amount float64, reason string) {
	d := db.Get()
	_, err := d.Exec(
		"UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP WHERE user_id = ?",
		amount, string(userID),
	)
	if err != nil {
		slog.Error("euro: credit failed", "user", userID, "amount", amount, "err", err)
		return
	}
	p.logTransaction(userID, amount, reason)
}

// Debit subtracts euros atomically. Returns false if this would exceed debt limit.
// Uses a conditional UPDATE to prevent race conditions (check-and-act in one statement).
func (p *EuroPlugin) Debit(userID id.UserID, amount float64, reason string) bool {
	if amount <= 0 {
		slog.Warn("euro: Debit called with non-positive amount", "user", userID, "amount", amount)
		return false
	}
	p.ensureBalance(userID)
	d := db.Get()

	debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)

	// Atomic: only debit if the result stays within debt limit
	result, err := d.Exec(
		`UPDATE euro_balances SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP
		 WHERE user_id = ? AND (balance - ?) >= ?`,
		amount, string(userID), amount, -debtLimit,
	)
	if err != nil {
		slog.Error("euro: debit failed", "user", userID, "amount", amount, "err", err)
		return false
	}

	affected, _ := result.RowsAffected()
	if affected == 0 {
		return false // balance check failed or user doesn't exist
	}

	p.logTransaction(userID, -amount, reason)
	return true
}

// Credit adds euros (exported for other plugins).
func (p *EuroPlugin) Credit(userID id.UserID, amount float64, reason string) {
	if amount <= 0 {
		slog.Warn("euro: Credit called with non-positive amount", "user", userID, "amount", amount)
		return
	}
	p.ensureBalance(userID)
	p.credit(userID, amount, reason)
}

// GetBalance returns current balance for a user.
func (p *EuroPlugin) GetBalance(userID id.UserID) float64 {
	p.ensureBalance(userID)
	d := db.Get()
	var balance float64
	if err := d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?",
		string(userID)).Scan(&balance); err != nil {
		slog.Error("euro: failed to get balance", "user", userID, "err", err)
	}
	return balance
}

// HasExternalTx reports whether a money move with this externalID has already
// been applied. It lets a retrying web caller tell a first attempt from a replay:
// on a first attempt the affordability gate must run, but on a retry of an order
// already debited the gate must be skipped — the debit is a settled fact, and
// re-reading the now-lower balance would wrongly bounce a hit the buyer paid for.
func (p *EuroPlugin) HasExternalTx(externalID string) bool {
	if externalID == "" {
		return false
	}
	var one int
	err := db.Get().QueryRow(
		`SELECT 1 FROM euro_transactions WHERE external_id = ? LIMIT 1`, externalID).Scan(&one)
	return err == nil
}

// ---------------------------------------------------------------------------
// Idempotent money moves
//
// Every caller of Credit/Debit above is a Matrix message, which arrives exactly
// once. Money that moves over a retrying wire — the Pete games escrow poll loop
// — has no such guarantee: a claim that succeeds but whose ack is lost gets
// retried, and the player pays twice. CreditIdem/DebitIdem take an externalID
// (the escrow GUID), do the balance mutation and the transaction log in one
// tx, and lean on idx_euro_tx_external so a replay is a no-op rather than a
// second debit.
//
// Anything web-initiated goes through these. Nothing else should.
// ---------------------------------------------------------------------------

// DebitIdem subtracts euros exactly once for a given externalID. Returns ok=false
// only when the debit would breach the debt limit; a replay of an already-applied
// externalID returns ok=true without moving money again. balanceAfter is the
// balance as of the decision, and is meaningless when ok is false.
func (p *EuroPlugin) DebitIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
	if amount <= 0 {
		return false, 0, fmt.Errorf("euro: DebitIdem non-positive amount %v", amount)
	}
	if externalID == "" {
		return false, 0, fmt.Errorf("euro: DebitIdem requires an external id")
	}
	debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)
	return p.applyIdem(userID, -amount, reason, externalID, -debtLimit)
}

// CreditIdem adds euros exactly once for a given externalID. A replay is a no-op
// that still reports ok.
func (p *EuroPlugin) CreditIdem(userID id.UserID, amount float64, reason, externalID string) (ok bool, balanceAfter float64, err error) {
	if amount <= 0 {
		return false, 0, fmt.Errorf("euro: CreditIdem non-positive amount %v", amount)
	}
	if externalID == "" {
		return false, 0, fmt.Errorf("euro: CreditIdem requires an external id")
	}
	// A credit has no floor to breach — math.Inf would do, but the balance can
	// never land below the current one, so any sentinel below it works.
	return p.applyIdem(userID, amount, reason, externalID, math.Inf(-1))
}

// applyIdem is the shared body: signed delta, applied once, never letting the
// balance fall below floor. Balance mutation and transaction log commit together
// or not at all.
func (p *EuroPlugin) applyIdem(userID id.UserID, delta float64, reason, externalID string, floor float64) (bool, float64, error) {
	// Outside the tx: db is capped at a single connection, so a nested Exec on
	// the pool would deadlock against our own open transaction.
	p.ensureBalance(userID)

	d := db.Get()
	tx, err := d.Begin()
	if err != nil {
		return false, 0, fmt.Errorf("euro: begin: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op after a successful Commit

	// Already applied? Report the current balance and move on.
	var prior int
	switch err := tx.QueryRow(
		`SELECT 1 FROM euro_transactions WHERE external_id = ?`, externalID,
	).Scan(&prior); {
	case err == nil:
		bal, err := txBalance(tx, userID)
		if err != nil {
			return false, 0, err
		}
		slog.Info("euro: idempotent replay ignored", "user", userID, "external_id", externalID, "reason", reason)
		return true, bal, nil
	case errors.Is(err, sql.ErrNoRows):
		// Not applied yet — carry on.
	default:
		return false, 0, fmt.Errorf("euro: idem lookup: %w", err)
	}

	res, err := tx.Exec(
		`UPDATE euro_balances SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP
		 WHERE user_id = ? AND (balance + ?) >= ?`,
		delta, string(userID), delta, floor,
	)
	if err != nil {
		return false, 0, fmt.Errorf("euro: idem balance update: %w", err)
	}
	if affected, _ := res.RowsAffected(); affected == 0 {
		return false, 0, nil // would breach the floor
	}

	if _, err := tx.Exec(
		`INSERT INTO euro_transactions (user_id, amount, reason, external_id) VALUES (?, ?, ?, ?)`,
		string(userID), delta, reason, externalID,
	); err != nil {
		// A racing caller won with the same externalID. The unique index is the
		// backstop the SELECT above can't be: roll back our half-applied delta
		// and report theirs.
		if strings.Contains(err.Error(), "UNIQUE constraint failed") {
			if rbErr := tx.Rollback(); rbErr != nil {
				return false, 0, fmt.Errorf("euro: rollback after idem race: %w", rbErr)
			}
			slog.Info("euro: idempotent race lost, delta rolled back", "user", userID, "external_id", externalID)
			return true, p.GetBalance(userID), nil
		}
		return false, 0, fmt.Errorf("euro: idem tx log: %w", err)
	}

	bal, err := txBalance(tx, userID)
	if err != nil {
		return false, 0, err
	}
	if err := tx.Commit(); err != nil {
		return false, 0, fmt.Errorf("euro: commit: %w", err)
	}
	return true, bal, nil
}

func txBalance(tx *sql.Tx, userID id.UserID) (float64, error) {
	var bal float64
	if err := tx.QueryRow(
		`SELECT balance FROM euro_balances WHERE user_id = ?`, string(userID),
	).Scan(&bal); err != nil {
		return 0, fmt.Errorf("euro: read balance: %w", err)
	}
	return bal, nil
}

func (p *EuroPlugin) logTransaction(userID id.UserID, amount float64, reason string) {
	db.Exec("euro: log transaction",
		"INSERT INTO euro_transactions (user_id, amount, reason) VALUES (?, ?, ?)",
		string(userID), amount, reason,
	)
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

func (p *EuroPlugin) handleBalance(ctx MessageContext) error {
	p.ensureBalance(ctx.Sender)
	d := db.Get()

	var balance float64
	if err := d.QueryRow("SELECT balance FROM euro_balances WHERE user_id = ?",
		string(ctx.Sender)).Scan(&balance); err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch balance.")
	}

	debtLimit := envFloat("BLACKJACK_DEBT_LIMIT", 1000)

	var sb strings.Builder
	sb.WriteString(fmt.Sprintf("💰 **Your Balance:** €%d\n", int(balance)))

	if balance < 0 {
		sb.WriteString(fmt.Sprintf("⚠️ You are in debt! (limit: €%d)\n", int(debtLimit)))
		if balance <= -debtLimit {
			sb.WriteString("🚫 Betting disabled until you earn your way out of debt.\n")
		}
	}

	// Recent transactions
	rows, err := d.Query(
		`SELECT amount, reason, created_at FROM euro_transactions
		 WHERE user_id = ? ORDER BY created_at DESC LIMIT 5`,
		string(ctx.Sender),
	)
	if err == nil {
		defer rows.Close()
		sb.WriteString("\n**Recent transactions:**\n")
		for rows.Next() {
			var amount float64
			var reason, createdAt string
			rows.Scan(&amount, &reason, &createdAt)
			sign := "+"
			if amount < 0 {
				sign = ""
			}
			sb.WriteString(fmt.Sprintf("  %s€%.0f — %s\n", sign, amount, reason))
		}
	}

	return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}

func (p *EuroPlugin) handleBaltop(ctx MessageContext) error {
	d := db.Get()
	rows, err := d.Query(
		`SELECT user_id, balance FROM euro_balances ORDER BY balance DESC LIMIT 10`,
	)
	if err != nil {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch leaderboard.")
	}
	defer rows.Close()

	var sb strings.Builder
	sb.WriteString("💰 **Euro Leaderboard**\n\n")
	rank := 0
	for rows.Next() {
		var userID string
		var balance float64
		rows.Scan(&userID, &balance)
		rank++
		name := p.DisplayName(id.UserID(userID))
		medal := ""
		switch rank {
		case 1:
			medal = "🥇"
		case 2:
			medal = "🥈"
		case 3:
			medal = "🥉"
		}
		sb.WriteString(fmt.Sprintf("%s %d. **%s** — €%d\n", medal, rank, name, int(balance)))
	}

	if rank == 0 {
		sb.WriteString("No balances yet.")
	}

	return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
}

func (p *EuroPlugin) handleTransfer(ctx MessageContext) error {
	args := p.GetArgs(ctx.Body, "baltransfer")
	parts := strings.Fields(args)
	if len(parts) < 2 {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!baltransfer @user €amount`")
	}

	targetID, ok := p.ResolveUser(parts[0], ctx.RoomID)
	if !ok {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
	}
	if targetID == ctx.Sender {
		return p.SendReply(ctx.RoomID, ctx.EventID, "You can't transfer to yourself.")
	}

	amountStr := strings.TrimPrefix(parts[1], "€")
	amount := 0.0
	fmt.Sscanf(amountStr, "%f", &amount)
	if amount < 1 {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Minimum transfer is €1.")
	}

	balance := p.GetBalance(ctx.Sender)
	if balance < amount {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			fmt.Sprintf("Insufficient balance. You have €%d.", int(balance)))
	}

	if !p.Debit(ctx.Sender, amount, fmt.Sprintf("transfer to %s", targetID)) {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Transfer failed.")
	}
	p.Credit(targetID, amount, fmt.Sprintf("transfer from %s", ctx.Sender))

	senderName := p.DisplayName(ctx.Sender)
	targetName := p.DisplayName(targetID)
	return p.SendReply(ctx.RoomID, ctx.EventID,
		fmt.Sprintf("💸 **%s** sent €%d to **%s**.", senderName, int(amount), targetName))
}

// handleGrant lets an admin credit euros to any user. Logs the transaction
// with reason "admin grant by <admin>" plus any free-form note the admin
// supplied as the third argument.
func (p *EuroPlugin) handleGrant(ctx MessageContext) error {
	if !p.IsAdmin(ctx.Sender) {
		return nil
	}
	args := p.GetArgs(ctx.Body, "eurogrant")
	parts := strings.Fields(args)
	if len(parts) < 2 {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: `!eurogrant @user amount [reason]`")
	}

	targetID, ok := p.ResolveUser(parts[0], ctx.RoomID)
	if !ok {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Could not resolve user.")
	}

	amountStr := strings.TrimPrefix(parts[1], "€")
	amount := 0.0
	fmt.Sscanf(amountStr, "%f", &amount)
	if amount < 1 {
		return p.SendReply(ctx.RoomID, ctx.EventID, "Amount must be at least €1.")
	}

	reason := fmt.Sprintf("admin grant by %s", ctx.Sender)
	if len(parts) > 2 {
		reason = fmt.Sprintf("%s — %s", reason, strings.Join(parts[2:], " "))
	}

	p.Credit(targetID, amount, reason)
	newBalance := p.GetBalance(targetID)
	targetName := p.DisplayName(targetID)
	return p.SendReply(ctx.RoomID, ctx.EventID,
		fmt.Sprintf("✅ Granted €%d to **%s**. New balance: €%d.", int(amount), targetName, int(newBalance)))
}
