package plugin

import (
	"database/sql"
	"fmt"
	"log/slog"
	"math"
	"math/rand/v2"
	"strings"
	"time"

	"gogobee/internal/db"

	"github.com/google/uuid"
	"maunium.net/go/mautrix/id"
)

// ── Constants ────────────────────────────────────────────────────────────────

const (
	// Adv 2.0 Phase L4b — gate switched off CombatLevel onto DnDCharacter.Level.
	// Legacy gate was CombatLevel >= 5; new gate is Level >= 3
	// (gogobee_legacy_migration.md §2.3).
	rivalMinLevel         = 3
	rivalChallengeWindow  = 24 * time.Hour
	rivalSamePairCooldown = 7 * 24 * time.Hour
	rivalMinIntervalHours = 3 * 24 // 3 days in hours
	rivalMaxIntervalHours = 4 * 24 // 4 days in hours
)

// rivalLevelForUser returns the player's D&D level for rival gating + stake
// math. Post-L5g every legacy player has a DnDCharacter row, so the
// CombatLevel-derived fallback has been retired. Returns 1 as a safe floor
// (filtered out by rivalMinLevel anyway).
func rivalLevelForUser(char *AdventureCharacter) int {
	if dnd, err := LoadDnDCharacter(char.UserID); err == nil && dnd != nil && dnd.Level > 0 {
		return dnd.Level
	}
	return 1
}

// ── Types ────────────────────────────────────────────────────────────────────

type advRivalChallenge struct {
	ChallengeID  string
	ChallengerID id.UserID
	ChallengedID id.UserID
	Stake        int
	Round        int
	PlayerScore  int
	RivalScore   int
	ExpiresAt    time.Time
	CreatedAt    time.Time
}

type advRivalRecord struct {
	RivalID    id.UserID
	Wins       int
	Losses     int
	LastDuelAt *time.Time
}

type advPendingRivalRPS struct {
	ChallengeID string
}

// ── Stake Calculation ────────────────────────────────────────────────────────

// rivalStake returns the duel stake in € for a given D&D Level.
// Adv 2.0 Phase L4b — previously `(combatLevel / 5) * 1000`. New formula
// `(level / 3) * 1000` keeps the same magnitude at the unlock threshold
// (Level 3 = €1000, matching legacy CombatLevel 5 = €1000) and tops out
// around €6000 at Level 20. Players below `rivalMinLevel` produce stake 0,
// which selectRivalPair already filters out.
func rivalStake(level int) int {
	return (level / 3) * 1000
}

// ── Rival Pool Unlock ────────────────────────────────────────────────────────

func (p *AdventurePlugin) checkRivalPoolUnlock(char *AdventureCharacter) {
	level := rivalLevelForUser(char)
	if level < rivalMinLevel {
		return
	}
	pool, notified, _ := loadRivalState(char.UserID)
	if pool > 0 && notified {
		// Already unlocked + notified; nothing to do.
		return
	}
	// Promote to pool + send unlock DM if not yet announced.
	// Persistence rides the caller's saveAdvCharacter → fan-out.
	char.RivalPool = 1
	if !notified {
		char.RivalUnlockedNotified = true
		p.SendDM(char.UserID, rivalUnlockDM)
	}
}

// ── DB CRUD ──────────────────────────────────────────────────────────────────

func loadRivalChallengeByID(challengeID string) (*advRivalChallenge, error) {
	d := db.Get()
	c := &advRivalChallenge{}
	err := d.QueryRow(`
		SELECT challenge_id, challenger_id, challenged_id, stake,
		       round, player_score, rival_score, expires_at, created_at
		FROM adventure_rival_challenges WHERE challenge_id = ?`, challengeID).Scan(
		&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
		&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
	)
	if err == sql.ErrNoRows {
		return nil, nil
	}
	if err != nil {
		return nil, err
	}
	return c, nil
}

func insertRivalChallenge(c *advRivalChallenge) error {
	db.Exec("rival: insert challenge",
		`INSERT INTO adventure_rival_challenges
		 (challenge_id, challenger_id, challenged_id, stake, round, player_score, rival_score, expires_at)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
		c.ChallengeID, string(c.ChallengerID), string(c.ChallengedID),
		c.Stake, c.Round, c.PlayerScore, c.RivalScore, c.ExpiresAt,
	)
	return nil
}

func saveRivalChallengeRound(c *advRivalChallenge) {
	db.Exec("rival: update round",
		`UPDATE adventure_rival_challenges
		 SET round = ?, player_score = ?, rival_score = ?
		 WHERE challenge_id = ?`,
		c.Round, c.PlayerScore, c.RivalScore, c.ChallengeID,
	)
}

func deleteRivalChallenge(challengeID string) {
	db.Exec("rival: delete challenge",
		`DELETE FROM adventure_rival_challenges WHERE challenge_id = ?`,
		challengeID,
	)
}

func upsertRivalRecord(userID, rivalID id.UserID, won bool) {
	if won {
		db.Exec("rival: upsert record win",
			`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
			 VALUES (?, ?, 1, 0, CURRENT_TIMESTAMP)
			 ON CONFLICT(user_id, rival_id) DO UPDATE SET wins = wins + 1, last_duel_at = CURRENT_TIMESTAMP`,
			string(userID), string(rivalID),
		)
	} else {
		db.Exec("rival: upsert record loss",
			`INSERT INTO adventure_rival_records (user_id, rival_id, wins, losses, last_duel_at)
			 VALUES (?, ?, 0, 1, CURRENT_TIMESTAMP)
			 ON CONFLICT(user_id, rival_id) DO UPDATE SET losses = losses + 1, last_duel_at = CURRENT_TIMESTAMP`,
			string(userID), string(rivalID),
		)
	}
}

func loadAllRivalRecords(userID id.UserID) ([]advRivalRecord, error) {
	d := db.Get()
	rows, err := d.Query(`
		SELECT rival_id, wins, losses, last_duel_at
		FROM adventure_rival_records
		WHERE user_id = ?
		ORDER BY last_duel_at DESC`, string(userID))
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var records []advRivalRecord
	for rows.Next() {
		r := advRivalRecord{}
		var lastDuel sql.NullTime
		if err := rows.Scan(&r.RivalID, &r.Wins, &r.Losses, &lastDuel); err != nil {
			return nil, err
		}
		if lastDuel.Valid {
			r.LastDuelAt = &lastDuel.Time
		}
		records = append(records, r)
	}
	return records, rows.Err()
}

func communityPotAdd(amount int) {
	db.Exec("rival: community pot add",
		`INSERT INTO community_pot (id, balance, updated_at)
		 VALUES (1, ?, CURRENT_TIMESTAMP)
		 ON CONFLICT(id) DO UPDATE SET balance = balance + ?, updated_at = CURRENT_TIMESTAMP`,
		amount, amount,
	)
}

func communityTax(userID id.UserID, gross float64, rate float64) (net float64, tax int) {
	t := math.Round(gross * rate)
	tax = int(t)
	net = gross - t
	if tax > 0 {
		communityPotAdd(tax)
		trackTaxPaid(userID, tax)
	}
	return net, tax
}

func trackTaxPaid(userID id.UserID, amount int) {
	if amount <= 0 {
		return
	}
	db.Exec("tax: track paid",
		`INSERT INTO tax_ledger (user_id, total_paid, updated_at)
		 VALUES (?, ?, CURRENT_TIMESTAMP)
		 ON CONFLICT(user_id) DO UPDATE SET total_paid = total_paid + ?, updated_at = CURRENT_TIMESTAMP`,
		string(userID), amount, amount,
	)
}

func communityPotBalance() int {
	d := db.Get()
	var balance int
	_ = d.QueryRow(`SELECT COALESCE(balance, 0) FROM community_pot WHERE id = 1`).Scan(&balance)
	return balance
}

func communityPotDebit(amount int) bool {
	d := db.Get()
	res, err := d.Exec(`UPDATE community_pot SET balance = balance - ?, updated_at = CURRENT_TIMESTAMP
		WHERE id = 1 AND balance >= ?`, amount, amount)
	if err != nil {
		return false
	}
	n, _ := res.RowsAffected()
	return n == 1
}

func loadExpiredRivalChallenges() ([]advRivalChallenge, error) {
	d := db.Get()
	rows, err := d.Query(`
		SELECT challenge_id, challenger_id, challenged_id, stake,
		       round, player_score, rival_score, expires_at, created_at
		FROM adventure_rival_challenges
		WHERE expires_at <= CURRENT_TIMESTAMP`)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var challenges []advRivalChallenge
	for rows.Next() {
		c := advRivalChallenge{}
		if err := rows.Scan(
			&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
			&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
		); err != nil {
			return nil, err
		}
		challenges = append(challenges, c)
	}
	return challenges, rows.Err()
}

func lastRivalChallengeTime() time.Time {
	d := db.Get()
	var t sql.NullTime
	_ = d.QueryRow(`SELECT MAX(created_at) FROM adventure_rival_challenges`).Scan(&t)
	if t.Valid {
		return t.Time
	}
	return time.Time{}
}

// pendingRivalChallengeForChallenged returns the active challenge where the
// given user is the challenged party (i.e. the side that needs to act).
// Returns nil if no challenge is pending or the user is only on the
// auto-resolved challenger side.
func pendingRivalChallengeForChallenged(userID id.UserID) *advRivalChallenge {
	d := db.Get()
	c := &advRivalChallenge{}
	err := d.QueryRow(`
		SELECT challenge_id, challenger_id, challenged_id, stake,
		       round, player_score, rival_score, expires_at, created_at
		FROM adventure_rival_challenges
		WHERE challenged_id = ? AND expires_at > CURRENT_TIMESTAMP
		ORDER BY created_at DESC LIMIT 1`, string(userID)).Scan(
		&c.ChallengeID, &c.ChallengerID, &c.ChallengedID, &c.Stake,
		&c.Round, &c.PlayerScore, &c.RivalScore, &c.ExpiresAt, &c.CreatedAt,
	)
	if err != nil {
		return nil
	}
	return c
}

func hasActiveChallenge(userID id.UserID) bool {
	d := db.Get()
	var count int
	_ = d.QueryRow(`
		SELECT COUNT(*) FROM adventure_rival_challenges
		WHERE (challenger_id = ? OR challenged_id = ?) AND expires_at > CURRENT_TIMESTAMP`,
		string(userID), string(userID)).Scan(&count)
	return count > 0
}

func lastDuelBetween(a, b id.UserID) time.Time {
	d := db.Get()
	var t sql.NullTime
	_ = d.QueryRow(`
		SELECT last_duel_at FROM adventure_rival_records
		WHERE user_id = ? AND rival_id = ?`, string(a), string(b)).Scan(&t)
	if t.Valid {
		return t.Time
	}
	return time.Time{}
}

// ── Pair Selection ───────────────────────────────────────────────────────────

func (p *AdventurePlugin) selectRivalPair() (*AdventureCharacter, *AdventureCharacter) {
	chars, err := loadAllAdvCharacters()
	if err != nil {
		slog.Error("rival: load characters", "err", err)
		return nil, nil
	}

	// Filter to eligible pool members.
	var pool []AdventureCharacter
	for _, c := range chars {
		if !c.Alive || c.BabysitActive {
			continue
		}
		poolFlag, _, _ := loadRivalState(c.UserID)
		if poolFlag == 0 {
			continue
		}
		if hasActiveChallenge(c.UserID) {
			continue
		}
		stake := rivalStake(rivalLevelForUser(&c))
		if stake <= 0 {
			continue
		}
		bal := p.euro.GetBalance(c.UserID)
		if bal < float64(stake) {
			continue
		}
		pool = append(pool, c)
	}

	if len(pool) < 2 {
		return nil, nil
	}

	// Shuffle and find first valid pair.
	rand.Shuffle(len(pool), func(i, j int) { pool[i], pool[j] = pool[j], pool[i] })

	now := time.Now().UTC()
	for i := 0; i < len(pool); i++ {
		for j := i + 1; j < len(pool); j++ {
			a, b := &pool[i], &pool[j]
			// Check same-pair cooldown (7 days).
			last := lastDuelBetween(a.UserID, b.UserID)
			if !last.IsZero() && now.Sub(last) < rivalSamePairCooldown {
				continue
			}
			// Randomly assign challenger/challenged.
			if rand.IntN(2) == 0 {
				return a, b
			}
			return b, a
		}
	}
	return nil, nil
}

// ── Challenge Issuance ───────────────────────────────────────────────────────

func (p *AdventurePlugin) issueRivalChallenge(challenger, challenged *AdventureCharacter) {
	stake := rivalStake(rivalLevelForUser(challenged))
	challenge := &advRivalChallenge{
		ChallengeID:  uuid.New().String()[:12],
		ChallengerID: challenger.UserID,
		ChallengedID: challenged.UserID,
		Stake:        stake,
		Round:        1,
		PlayerScore:  0,
		RivalScore:   0,
		ExpiresAt:    time.Now().UTC().Add(rivalChallengeWindow),
	}
	insertRivalChallenge(challenge)

	// Notify the rival (challenger) that a challenge was issued in their name.
	p.SendDM(challenger.UserID, fmt.Sprintf(
		"⚔️ You've been matched as a rival against **%s**. A challenge has been issued in your name. "+
			"Your throws will be generated automatically. Sit back and wait for the result.",
		challenged.DisplayName))

	// Send the dramatic challenge DM to the challenged player.
	openingTaunt := pickRivalFlavor(rivalOpeningTaunts)
	dm := fmt.Sprintf(`⚔️ RIVAL CHALLENGE

You're walking along enjoying a nice sunny day when you look across the road and notice someone staring at you intensely -- the look of someone who caught a faint but unmistakable smell at a restaurant and has traced it back to its source.

The person crosses the street. The sun disappears behind dark, angry clouds as if on cue.

*"You're looking at me like you got a problem."*

They look you over slowly. The scowl becomes a smug smile.

*"Your face looks like someone who's fought countless battles and lost just as many. Ah well. I already crossed the street so I guess I'll make this quick."*

They reach to their side. You ready yourself.

The stakes are €%d. Best of 3. Rock Paper Scissors.

*%s*

Reply with **rock**, **paper**, or **scissors** for Round 1.
You have 24 hours.`, stake, openingTaunt)

	p.SendDM(challenged.UserID, dm)

	// Store pending interaction for the challenged player.
	p.pending.Store(string(challenged.UserID), &advPendingInteraction{
		Type:      "rival_rps",
		Data:      &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
		ExpiresAt: challenge.ExpiresAt,
	})

	slog.Info("rival: challenge issued",
		"challenger", challenger.DisplayName,
		"challenged", challenged.DisplayName,
		"stake", stake)
}

// ── RPS Resolution ───────────────────────────────────────────────────────────

type rpsThrow int

const (
	rpsRock rpsThrow = iota
	rpsPaper
	rpsScissors
)

var rpsNames = map[rpsThrow]string{
	rpsRock:     "rock",
	rpsPaper:    "paper",
	rpsScissors: "scissors",
}

func parseRPS(s string) (rpsThrow, bool) {
	switch strings.ToLower(strings.TrimSpace(s)) {
	case "rock", "r":
		return rpsRock, true
	case "paper", "p":
		return rpsPaper, true
	case "scissors", "s", "scissor":
		return rpsScissors, true
	}
	return 0, false
}

func randomRPS() rpsThrow {
	return rpsThrow(rand.IntN(3))
}

// rpsResult: 1 = player wins, -1 = rival wins, 0 = tie.
func rpsResult(player, rival rpsThrow) int {
	if player == rival {
		return 0
	}
	if (player == rpsRock && rival == rpsScissors) ||
		(player == rpsPaper && rival == rpsRock) ||
		(player == rpsScissors && rival == rpsPaper) {
		return 1
	}
	return -1
}

func (p *AdventurePlugin) resolveRivalRPSRound(ctx MessageContext, interaction *advPendingInteraction) error {
	// Acquire per-user lock to prevent concurrent resolution from rapid messages.
	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	data := interaction.Data.(*advPendingRivalRPS)

	playerThrow, ok := parseRPS(ctx.Body)
	if !ok {
		// Re-store the pending interaction so the player can try again.
		p.pending.Store(string(ctx.Sender), interaction)
		return p.SendDM(ctx.Sender, "Rock, paper, or scissors. That's it. Those are the options.")
	}

	challenge, err := loadRivalChallengeByID(data.ChallengeID)
	if err != nil || challenge == nil {
		// Challenge was expired/deleted by the ticker — don't re-store pending.
		return p.SendDM(ctx.Sender, "That challenge is no longer active.")
	}

	// Resolve throw.
	rivalThrow := randomRPS()
	result := rpsResult(playerThrow, rivalThrow)

	// Tie — both re-throw. Re-prompt the player.
	if result == 0 {
		tieLine := pickRivalFlavor(rivalTied)
		text := fmt.Sprintf("You threw %s. They threw %s. Tie.\n\n*%s*\n\n"+
			"Reply with **rock**, **paper**, or **scissors** to re-throw.",
			rpsNames[playerThrow], rpsNames[rivalThrow], tieLine)

		// Re-store the pending interaction for the same round.
		p.pending.Store(string(ctx.Sender), &advPendingInteraction{
			Type:      "rival_rps",
			Data:      &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
			ExpiresAt: challenge.ExpiresAt,
		})

		return p.SendDM(ctx.Sender, text)
	}

	// Build the round DM.
	var sb strings.Builder

	sb.WriteString(fmt.Sprintf("⚔️ ROUND %d RESULT\n\n", challenge.Round))
	sb.WriteString(fmt.Sprintf("You threw %s. They threw %s.\n\n", rpsNames[playerThrow], rpsNames[rivalThrow]))

	if result == 1 {
		// Player won the round.
		challenge.PlayerScore++
		// Outcome line.
		outcomePool := rivalRoundOutcomeWin
		outcome := pickRivalFlavor(outcomePool)
		switch strings.Count(outcome, "%s") {
		case 2:
			outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
		case 1:
			outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
		}
		sb.WriteString(outcome + "\n\n")
		sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
		// Rival reaction (they lost the round).
		sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundLost)))
	} else {
		// Rival won the round.
		challenge.RivalScore++
		outcomePool := rivalRoundOutcomeLoss
		outcome := pickRivalFlavor(outcomePool)
		switch strings.Count(outcome, "%s") {
		case 2:
			outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow], rpsNames[playerThrow])
		case 1:
			outcome = fmt.Sprintf(outcome, rpsNames[rivalThrow])
		}
		sb.WriteString(outcome + "\n\n")
		sb.WriteString(fmt.Sprintf("Score: You %d -- Rival %d\n\n", challenge.PlayerScore, challenge.RivalScore))
		// Rival reaction (they won the round).
		sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalRoundWon)))
	}

	// Check for match end (best of 3 = first to 2).
	if challenge.PlayerScore >= 2 || challenge.RivalScore >= 2 {
		playerWon := challenge.PlayerScore >= 2
		saveRivalChallengeRound(challenge)
		p.pending.Delete(string(ctx.Sender))
		p.finalizeRivalMatch(challenge, playerWon, &sb)
		return p.SendDM(ctx.Sender, sb.String())
	}

	// Match continues — next round.
	challenge.Round++
	saveRivalChallengeRound(challenge)

	sb.WriteString(fmt.Sprintf("\nReply with **rock**, **paper**, or **scissors** for Round %d.", challenge.Round))

	// Update pending interaction for next round.
	p.pending.Store(string(ctx.Sender), &advPendingInteraction{
		Type:      "rival_rps",
		Data:      &advPendingRivalRPS{ChallengeID: challenge.ChallengeID},
		ExpiresAt: challenge.ExpiresAt,
	})

	return p.SendDM(ctx.Sender, sb.String())
}

// ── Match Finalization ───────────────────────────────────────────────────────

func (p *AdventurePlugin) finalizeRivalMatch(challenge *advRivalChallenge, playerWon bool, sb *strings.Builder) {
	challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
	challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)

	var winnerID, loserID id.UserID
	var winnerName, loserName string

	if playerWon {
		winnerID = challenge.ChallengedID
		loserID = challenge.ChallengerID
		winnerName = ""
		loserName = ""
		if challengedChar != nil {
			winnerName = challengedChar.DisplayName
		}
		if challengerChar != nil {
			loserName = challengerChar.DisplayName
		}
	} else {
		winnerID = challenge.ChallengerID
		loserID = challenge.ChallengedID
		if challengerChar != nil {
			winnerName = challengerChar.DisplayName
		}
		if challengedChar != nil {
			loserName = challengedChar.DisplayName
		}
	}

	// Gold transfer — try full stake, fall back to available balance.
	stake := challenge.Stake
	if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss") {
		// Loser can't cover full stake — debit whatever they have.
		bal := p.euro.GetBalance(loserID)
		stake = int(bal)
		if stake > 0 {
			// Best-effort debit of remaining balance. If it fails (concurrent drain), stake becomes 0.
			if !p.euro.Debit(loserID, float64(stake), "rival_duel_loss_partial") {
				stake = 0
			}
		}
	}

	winnerShare := int(math.Round(float64(stake) * 0.3))
	potShare := stake - winnerShare
	if winnerShare > 0 {
		p.euro.Credit(winnerID, float64(winnerShare), "rival_duel_win")
	}
	if potShare > 0 {
		communityPotAdd(potShare)
		trackTaxPaid(loserID, potShare)
	}

	// Update rival records (both directions).
	upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, playerWon)
	upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, !playerWon)

	// Append closing line to the challenged player's DM.
	sb.WriteString("\n⚔️ DUEL RESOLVED\n\n")
	sb.WriteString(fmt.Sprintf("Final score: You %d -- Rival %d\n", challenge.PlayerScore, challenge.RivalScore))

	if playerWon {
		sb.WriteString(fmt.Sprintf("€%d added to your balance. €%d added to the community pot.\n\n", winnerShare, potShare))
		sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingLoss)))
	} else {
		sb.WriteString(fmt.Sprintf("€%d removed from your balance. €%d added to the community pot.\n\n", stake, potShare))
		sb.WriteString(fmt.Sprintf("*%s*\n", pickRivalFlavor(rivalClosingWin)))
	}

	// Load W/L record for display.
	records, _ := loadAllRivalRecords(challenge.ChallengedID)
	for _, r := range records {
		if r.RivalID == challenge.ChallengerID {
			rivalName := string(challenge.ChallengerID)
			if challengerChar != nil {
				rivalName = challengerChar.DisplayName
			}
			sb.WriteString(fmt.Sprintf("Record vs %s: %dW-%dL\n", rivalName, r.Wins, r.Losses))
			break
		}
	}

	// Send closing DM to the rival (challenger).
	var rivalDM strings.Builder
	rivalDM.WriteString("⚔️ DUEL RESOLVED\n\n")
	rivalDM.WriteString(fmt.Sprintf("Your challenge against **%s** has concluded.\n",
		func() string {
			if challengedChar != nil {
				return challengedChar.DisplayName
			}
			return string(challenge.ChallengedID)
		}()))
	rivalDM.WriteString(fmt.Sprintf("Final score: %s %d -- %s %d\n",
		func() string {
			if challengedChar != nil {
				return challengedChar.DisplayName
			}
			return "Them"
		}(), challenge.PlayerScore,
		func() string {
			if challengerChar != nil {
				return challengerChar.DisplayName
			}
			return "You"
		}(), challenge.RivalScore))

	if !playerWon {
		rivalDM.WriteString(fmt.Sprintf("You won! +€%d to your balance. €%d to the community pot.\n", winnerShare, potShare))
	} else {
		rivalDM.WriteString(fmt.Sprintf("You lost. €%d removed from your balance.\n", stake))
	}
	p.SendDM(challenge.ChallengerID, rivalDM.String())

	// Room announcement.
	gr := gamesRoom()
	if gr != "" {
		var announce string
		if challenge.Round >= 3 {
			announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s** in a nail-biting best of 3!",
				winnerName, stake, loserName)
		} else {
			announce = fmt.Sprintf("⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**!",
				winnerName, stake, loserName)
		}
		p.SendMessage(gr, announce)
	}

	// Delete the challenge row.
	deleteRivalChallenge(challenge.ChallengeID)

	slog.Info("rival: match resolved",
		"winner", winnerName, "loser", loserName,
		"stake", stake, "rounds", challenge.Round)
}

// ── Expiry Handling ──────────────────────────────────────────────────────────

func (p *AdventurePlugin) expireRivalChallenges() {
	expired, err := loadExpiredRivalChallenges()
	if err != nil {
		slog.Error("rival: load expired challenges", "err", err)
		return
	}

	for _, challenge := range expired {
		// Challenged player forfeits — rival wins.
		challengerChar, _ := loadAdvCharacter(challenge.ChallengerID)
		challengedChar, _ := loadAdvCharacter(challenge.ChallengedID)

		stake := challenge.Stake
		if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit") {
			bal := p.euro.GetBalance(challenge.ChallengedID)
			stake = int(bal)
			if stake > 0 {
				if !p.euro.Debit(challenge.ChallengedID, float64(stake), "rival_forfeit_partial") {
					stake = 0
				}
			}
		}

		winnerShare := int(math.Round(float64(stake) * 0.3))
		potShare := stake - winnerShare
		if winnerShare > 0 {
			p.euro.Credit(challenge.ChallengerID, float64(winnerShare), "rival_forfeit_win")
		}
		if potShare > 0 {
			communityPotAdd(potShare)
			trackTaxPaid(challenge.ChallengedID, potShare)
		}

		upsertRivalRecord(challenge.ChallengedID, challenge.ChallengerID, false)
		upsertRivalRecord(challenge.ChallengerID, challenge.ChallengedID, true)

		// DM the challenged player (forfeit notice).
		forfeitLine := pickRivalFlavor(rivalForfeitLines)
		p.SendDM(challenge.ChallengedID, fmt.Sprintf("⚔️ DUEL FORFEITED\n\n%s\n\n€%d removed from your balance.",
			forfeitLine, stake))

		// DM the challenger.
		challengedName := string(challenge.ChallengedID)
		if challengedChar != nil {
			challengedName = challengedChar.DisplayName
		}
		p.SendDM(challenge.ChallengerID, fmt.Sprintf(
			"⚔️ Your rival **%s** didn't respond in time. You win by default. +€%d to your balance.",
			challengedName, winnerShare))

		// Room announcement.
		gr := gamesRoom()
		if gr != "" {
			winnerName := string(challenge.ChallengerID)
			if challengerChar != nil {
				winnerName = challengerChar.DisplayName
			}
			loserName := challengedName
			p.SendMessage(gr, fmt.Sprintf(
				"⚔️ DUEL OUTCOME DECIDED! **%s** has snatched €%d from **%s**, who couldn't even be bothered to show up!",
				winnerName, stake, loserName))
		}

		deleteRivalChallenge(challenge.ChallengeID)
		p.pending.Delete(string(challenge.ChallengedID))

		slog.Info("rival: challenge expired",
			"challenger", challenge.ChallengerID,
			"challenged", challenge.ChallengedID,
			"stake", stake)
	}
}

// ── Challenge Scheduler ──────────────────────────────────────────────────────

func (p *AdventurePlugin) rivalChallengeTicker() {
	ticker := time.NewTicker(1 * time.Minute)
	defer ticker.Stop()

	// Roll the next challenge interval once. Re-roll after each issued challenge.
	nextIntervalHours := rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)

	for {
		select {
		case <-p.stopCh:
			return
		case <-ticker.C:
			now := time.Now().UTC()

			// Only fire on the hour.
			if now.Minute() != 0 {
				continue
			}

			// Only issue challenges between 08:00 and 22:00 UTC.
			if now.Hour() < 8 || now.Hour() >= 22 {
				// Still check for expiry outside challenge hours.
				p.expireRivalChallenges()
				continue
			}

			// Expire old challenges first.
			p.expireRivalChallenges()

			// Check if enough time has passed since last challenge.
			last := lastRivalChallengeTime()
			if !last.IsZero() && now.Sub(last) < time.Duration(nextIntervalHours)*time.Hour {
				continue
			}

			// Try to issue a challenge.
			challenger, challenged := p.selectRivalPair()
			if challenger == nil || challenged == nil {
				continue
			}

			p.issueRivalChallenge(challenger, challenged)

			// Roll a fresh interval for the next challenge.
			nextIntervalHours = rivalMinIntervalHours + rand.IntN(rivalMaxIntervalHours-rivalMinIntervalHours+1)
		}
	}
}

// ── Rivals Command ───────────────────────────────────────────────────────────

func (p *AdventurePlugin) handleRivalsCmd(ctx MessageContext) error {
	char, _, err := p.ensureCharacter(ctx.Sender)
	if err != nil {
		return err
	}

	pool, _, _ := loadRivalState(char.UserID)
	if pool == 0 {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			fmt.Sprintf("You need Level %d to enter the rival pool. Currently: %d.",
				rivalMinLevel, rivalLevelForUser(char)))
	}

	records, err := loadAllRivalRecords(char.UserID)
	if err != nil || len(records) == 0 {
		return p.SendReply(ctx.RoomID, ctx.EventID,
			"⚔️ You're in the rival pool but haven't been challenged yet. Your time will come.")
	}

	var sb strings.Builder
	sb.WriteString("⚔️ **Rival Record**\n\n")
	for _, r := range records {
		rivalChar, _ := loadAdvCharacter(r.RivalID)
		name := string(r.RivalID)
		if rivalChar != nil {
			name = rivalChar.DisplayName
		}
		daysAgo := ""
		if r.LastDuelAt != nil {
			days := int(time.Since(*r.LastDuelAt).Hours() / 24)
			if days == 0 {
				daysAgo = "today"
			} else if days == 1 {
				daysAgo = "1 day ago"
			} else {
				daysAgo = fmt.Sprintf("%d days ago", days)
			}
		}
		sb.WriteString(fmt.Sprintf("  %-16s %dW - %dL   last duel: %s\n", name, r.Wins, r.Losses, daysAgo))
	}

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