package plugin

import (
	"fmt"
	"log/slog"
	"math/rand/v2"
	"strconv"
	"strings"
	"sync"
	"time"

	"gogobee/internal/db"

	"github.com/chehsunliu/poker"
	"maunium.net/go/mautrix"
	"maunium.net/go/mautrix/id"
)

// holdemConfig holds configurable parameters.
type holdemConfig struct {
	SmallBlind      int64
	BigBlind        int64
	MinBuyin        int64
	MaxBuyin        int64
	TimeoutSeconds  int
	NPCName         string
	NPCHouseBalance int64
}

func loadHoldemConfig() holdemConfig {
	return holdemConfig{
		SmallBlind:      int64(envInt("HOLDEM_SMALL_BLIND", 10)),
		BigBlind:        int64(envInt("HOLDEM_BIG_BLIND", 20)),
		MinBuyin:        int64(envInt("HOLDEM_MIN_BUYIN", 200)),
		MaxBuyin:        int64(envInt("HOLDEM_MAX_BUYIN", 2000)),
		TimeoutSeconds:  envInt("HOLDEM_TIMEOUT_SECONDS", 90),
		NPCName:         envOrDefault("HOLDEM_NPC_NAME", "TwinBee"),
		NPCHouseBalance: int64(envInt("HOLDEM_NPC_HOUSE_BALANCE", 10000)),
	}
}

// HoldemPlugin is the Texas Hold'em game plugin.
type HoldemPlugin struct {
	Base
	euro   *EuroPlugin
	cfg    holdemConfig
	mu     sync.Mutex
	games  map[id.RoomID]*HoldemGame
	policy PolicyTable
}

// NewHoldemPlugin creates a new Texas Hold'em plugin.
func NewHoldemPlugin(client *mautrix.Client, euro *EuroPlugin) *HoldemPlugin {
	return &HoldemPlugin{
		Base:  NewBase(client),
		euro:  euro,
		cfg:   loadHoldemConfig(),
		games: make(map[id.RoomID]*HoldemGame),
	}
}

func (p *HoldemPlugin) Name() string    { return "holdem" }
func (p *HoldemPlugin) Version() string { return "2.1.0" }

func (p *HoldemPlugin) Commands() []CommandDef {
	return []CommandDef{
		{Name: "holdem", Description: "Texas Hold'em poker", Usage: "!holdem join/start/fold/check/call/raise/allin/leave/help", Category: "Games"},
	}
}

func (p *HoldemPlugin) Init() error {
	// Try to load pre-trained CFR policy.
	policyPath := envOrDefault("HOLDEM_CFR_POLICY", "data/policy.gob")
	policy, err := LoadPolicy(policyPath)
	if err != nil {
		slog.Warn("holdem: no CFR policy loaded, NPC will use fallback strategy", "err", err)
		p.policy = make(PolicyTable)
	} else {
		p.policy = policy
	}
	return nil
}

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

func (p *HoldemPlugin) OnMessage(ctx MessageContext) error {
	if !p.IsCommand(ctx.Body, "holdem") {
		return nil
	}

	args := strings.TrimSpace(p.GetArgs(ctx.Body, "holdem"))

	// Check if this is a DM command.
	isDM := p.isDMRoom(ctx.RoomID)

	// DM-only commands.
	if strings.HasPrefix(args, "tips") {
		return p.handleTipsToggle(ctx, args, isDM)
	}

	// If this is a DM, resolve the player's game room so actions route correctly.
	// Save the original DM room into OriginRoomID so sender-private replies (errors,
	// help, status) route back via DM rather than broadcasting to the games room.
	if isDM {
		p.mu.Lock()
		gameRoom := p.findGameRoom(ctx.Sender)
		p.mu.Unlock()
		if gameRoom != "" {
			ctx.OriginRoomID = ctx.RoomID
			ctx.RoomID = gameRoom
			ctx.EventID = ""
		}
	}

	// Room-only commands need games room check.
	if !isDM && !isGamesRoom(ctx.RoomID) {
		gr := gamesRoom()
		if gr != "" {
			return p.reply(ctx, "Games are only available in the games channel!")
		}
	}

	switch {
	case args == "play" || strings.HasPrefix(args, "play "):
		return p.handlePlay(ctx, strings.TrimSpace(strings.TrimPrefix(args, "play")))
	case args == "join" || strings.HasPrefix(args, "join "):
		return p.handleJoin(ctx, strings.TrimSpace(strings.TrimPrefix(args, "join")))
	case args == "leave":
		return p.handleLeave(ctx)
	case args == "start":
		return p.handleStart(ctx)
	case args == "fold":
		return p.handleAction(ctx, "fold", 0)
	case args == "check":
		return p.handleAction(ctx, "check", 0)
	case args == "call":
		return p.handleAction(ctx, "call", 0)
	case strings.HasPrefix(args, "raise"):
		return p.handleRaise(ctx, args)
	case args == "allin":
		return p.handleAction(ctx, "allin", 0)
	case args == "status":
		return p.handleStatus(ctx)
	case args == "help":
		return p.reply(ctx, renderHelpMessage())
	case args == "addbot":
		return p.handleAddBot(ctx)
	default:
		return p.reply(ctx, "Unknown command. Try `!holdem help`.")
	}
}

// reply routes sender-private responses to the originating DM when the command
// came from a DM (ctx.OriginRoomID set), otherwise replies in-thread.
func (p *HoldemPlugin) reply(ctx MessageContext, msg string) error {
	if ctx.OriginRoomID != "" {
		return p.SendMessage(ctx.OriginRoomID, msg)
	}
	return p.SendReply(ctx.RoomID, ctx.EventID, msg)
}

func (p *HoldemPlugin) isDMRoom(roomID id.RoomID) bool {
	// DM rooms typically have exactly 2 members.
	members := p.RoomMembers(roomID)
	return len(members) <= 2
}

// findGameRoom returns the game room ID for a player, or empty if not found.
// Must be called with p.mu held.
func (p *HoldemPlugin) findGameRoom(userID id.UserID) id.RoomID {
	for _, game := range p.games {
		if game.playerByUserID(userID) != nil {
			return game.RoomID
		}
	}
	return ""
}

func (p *HoldemPlugin) handleTipsToggle(ctx MessageContext, args string, isDM bool) error {
	if !isDM {
		return p.reply(ctx, "Tips can only be toggled in DM. Send `!holdem tips on/off` directly to me.")
	}

	if p.IsAdmin(ctx.Sender) {
		return p.reply(ctx, "Tips are always on for admins. 🐝")
	}

	parts := strings.Fields(args)
	if len(parts) < 2 {
		pref := loadTipsPref(ctx.Sender)
		status := "on"
		if !pref {
			status = "off"
		}
		return p.reply(ctx, fmt.Sprintf("Tips are currently **%s**. Use `!holdem tips on/off` to change.", status))
	}

	switch parts[1] {
	case "on":
		saveTipsPref(ctx.Sender, true)
		return p.reply(ctx, "Tips enabled. You'll receive coaching on your turn.")
	case "off":
		saveTipsPref(ctx.Sender, false)
		return p.reply(ctx, "Tips disabled.")
	default:
		return p.reply(ctx, "Use `!holdem tips on` or `!holdem tips off`.")
	}
}

// handlePlay is a shortcut that joins the player, adds the bot, and starts dealing.
func (p *HoldemPlugin) handlePlay(ctx MessageContext, amountStr string) error {
	p.mu.Lock()

	// If a game already exists with a hand in progress, treat as a regular join.
	if game := p.games[ctx.RoomID]; game != nil && game.HandInProgress {
		p.mu.Unlock()
		return p.handleJoin(ctx, amountStr)
	}

	// If player is already seated, just try to add bot + start.
	if game := p.games[ctx.RoomID]; game != nil && game.playerByUserID(ctx.Sender) != nil {
		p.mu.Unlock()
		if err := p.handleAddBot(ctx); err != nil {
			slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err)
		}
		return p.handleStart(ctx)
	}
	p.mu.Unlock()

	// Join the table.
	if err := p.handleJoin(ctx, amountStr); err != nil {
		return err
	}

	if err := p.handleAddBot(ctx); err != nil {
		slog.Warn("holdem: addbot failed", "room", ctx.RoomID, "err", err)
	}

	// Start dealing.
	return p.handleStart(ctx)
}

func (p *HoldemPlugin) handleJoin(ctx MessageContext, amountStr string) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	game := p.games[ctx.RoomID]

	if game == nil {
		// Create a new game lobby.
		game = &HoldemGame{
			RoomID:            ctx.RoomID,
			SmallBlind:        p.cfg.SmallBlind,
			BigBlind:          p.cfg.BigBlind,
			DealerIdx:         -1, // so first nextActiveIdx lands on seat 0
			WaitingForPlayers: true,
		}
		p.games[ctx.RoomID] = game
		p.startIdleTimer(game)
	}

	// Check if already seated.
	if game.playerByUserID(ctx.Sender) != nil {
		return p.reply(ctx, "You're already at the table.")
	}

	// Max 9 players.
	if len(game.Players) >= 9 {
		return p.reply(ctx, "Table is full (9 players max).")
	}

	// Check balance.
	balance := p.euro.GetBalance(ctx.Sender)
	if balance < float64(p.cfg.MinBuyin) {
		return p.reply(ctx,
			fmt.Sprintf("Minimum buy-in is €%d. Your balance: €%.0f.", p.cfg.MinBuyin, balance))
	}

	// Resolve DM room.
	dmRoom, err := p.GetDMRoom(ctx.Sender)
	if err != nil {
		return p.reply(ctx, "Couldn't open a DM with you. Check your privacy settings.")
	}

	// Get display name.
	name := p.DisplayName(ctx.Sender)

	// Determine buy-in: player-specified, or max buy-in, capped by balance.
	var buyin int64
	if amountStr != "" {
		// Strip € prefix if present.
		amountStr = strings.TrimPrefix(amountStr, "€")
		requested, err := strconv.ParseInt(amountStr, 10, 64)
		if err != nil || requested <= 0 {
			return p.reply(ctx,
				fmt.Sprintf("Usage: `!holdem join [amount]` — amount between €%d and €%d.", p.cfg.MinBuyin, p.cfg.MaxBuyin))
		}
		if requested < p.cfg.MinBuyin {
			return p.reply(ctx,
				fmt.Sprintf("Minimum buy-in is €%d.", p.cfg.MinBuyin))
		}
		if requested > p.cfg.MaxBuyin {
			return p.reply(ctx,
				fmt.Sprintf("Maximum buy-in is €%d.", p.cfg.MaxBuyin))
		}
		if requested > int64(balance) {
			return p.reply(ctx,
				fmt.Sprintf("You only have €%.0f.", balance))
		}
		buyin = requested
	} else {
		buyin = p.cfg.MaxBuyin
		if int64(balance) < buyin {
			buyin = int64(balance)
		}
	}

	// Debit buy-in immediately to prevent double-spending across tables.
	if !p.euro.Debit(ctx.Sender, float64(buyin), "holdem_buyin") {
		return p.reply(ctx, "Failed to reserve buy-in.")
	}

	state := PlayerActive
	sittingOut := false
	if game.HandInProgress {
		state = PlayerSatOut
		sittingOut = true
	}

	player := &HoldemPlayer{
		UserID:       ctx.Sender,
		DisplayName:  name,
		Stack:        buyin,
		OpeningStack: buyin,
		State:        state,
		TipsEnabled:  loadTipsPref(ctx.Sender) || p.IsAdmin(ctx.Sender),
		SittingOut:   sittingOut,
		DMRoomID:     dmRoom,
	}

	game.Players = append(game.Players, player)

	if sittingOut {
		return p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** will join the next hand. (€%d buy-in)", name, buyin))
	}

	return p.SendMessage(ctx.RoomID,
		fmt.Sprintf("**%s** joined the table. (€%d buy-in) — %d players seated. Use `!holdem start` when ready.",
			name, buyin, len(game.Players)))
}

func (p *HoldemPlugin) handleLeave(ctx MessageContext) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	game := p.games[ctx.RoomID]
	if game == nil {
		return p.reply(ctx, "No active game here.")
	}

	player := game.playerByUserID(ctx.Sender)
	if player == nil {
		return p.reply(ctx, "You're not at the table.")
	}

	if !game.HandInProgress || player.SittingOut {
		// Credit remaining stack back (buy-in was debited at join).
		cashout := player.Stack
		if !player.IsNPC && cashout > 0 {
			net, _ := communityTax(player.UserID, float64(cashout), 0.05)
			p.euro.Credit(player.UserID, net, "holdem_cashout")
		}
		// Remove immediately.
		p.removePlayer(game, ctx.Sender)
		p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** has left the table. (€%d returned)", player.DisplayName, cashout))

		if len(game.Players) == 0 {
			delete(p.games, ctx.RoomID)
		}
		return nil
	}

	idx := game.playerIdx(ctx.Sender)
	if idx == game.ActionIdx {
		// It's their turn — fold and remove.
		p.stopActionTimer(game)
		game.doFold(idx)
		p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** folds and leaves the table.", player.DisplayName))

		// Credit remaining stack back (buy-in was debited at join).
		if player.Stack > 0 {
			net, _ := communityTax(player.UserID, float64(player.Stack), 0.05)
			p.euro.Credit(player.UserID, net, "holdem_cashout")
		}
		p.removePlayer(game, ctx.Sender)

		if game.activeCount() <= 1 {
			p.finishHand(game)
		} else {
			p.advanceAction(game)
		}
		return nil
	}

	// Not their turn — flag for removal.
	player.WantsLeave = true
	return p.SendMessage(ctx.RoomID, fmt.Sprintf("**%s** will leave after this hand.", player.DisplayName))
}

func (p *HoldemPlugin) handleStart(ctx MessageContext) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	game := p.games[ctx.RoomID]
	if game == nil {
		return p.reply(ctx, "No players seated. Use `!holdem join` first.")
	}

	if game.HandInProgress {
		return p.reply(ctx, "A hand is already in progress.")
	}

	// Count non-sitting-out players.
	ready := 0
	for _, pl := range game.Players {
		if !pl.SittingOut {
			ready++
		}
	}

	if ready < 2 {
		hint := " Use `!holdem join`."
		if ready == 1 {
			hint = " Use `!holdem addbot` to add an AI opponent."
		}
		return p.reply(ctx, "Need at least 2 players to start."+hint)
	}

	p.startHand(game)
	return nil
}

func (p *HoldemPlugin) handleRaise(ctx MessageContext, args string) error {
	parts := strings.Fields(args)
	if len(parts) < 2 {
		return p.reply(ctx, "Usage: `!holdem raise <amount>`")
	}

	amtStr := strings.TrimPrefix(parts[1], "€")
	amount, err := strconv.ParseInt(amtStr, 10, 64)
	if err != nil || amount <= 0 {
		return p.reply(ctx, "Invalid amount. Usage: `!holdem raise <amount>`")
	}

	return p.handleAction(ctx, "raise", amount)
}

func (p *HoldemPlugin) handleAction(ctx MessageContext, action string, amount int64) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	game := p.games[ctx.RoomID]
	if game == nil || !game.HandInProgress {
		return p.reply(ctx, "No hand in progress.")
	}

	seatIdx := game.playerIdx(ctx.Sender)
	if seatIdx < 0 {
		return p.reply(ctx, "You're not at this table.")
	}

	if seatIdx != game.ActionIdx {
		return p.reply(ctx, "It's not your turn.")
	}

	player := game.Players[seatIdx]
	if player.State != PlayerActive {
		return p.reply(ctx, "You can't act right now.")
	}

	var result ActionResult
	var errMsg string

	switch action {
	case "fold":
		result = game.doFold(seatIdx)
	case "check":
		result, errMsg = game.doCheck(seatIdx)
	case "call":
		result, errMsg = game.doCall(seatIdx)
	case "raise":
		result, errMsg = game.doRaise(seatIdx, amount)
	case "allin":
		result = game.doAllIn(seatIdx)
	}

	if errMsg != "" {
		return p.reply(ctx, errMsg)
	}

	// Reset action timer.
	p.stopActionTimer(game)

	// Broadcast action to all players via DM.
	p.broadcastDM(game, result.Announcement)

	if result.HandOver {
		p.finishHand(game)
		return nil
	}

	if result.AllAllIn {
		p.runOutBoard(game)
		return nil
	}

	if result.StreetOver {
		p.advanceStreet(game)
		return nil
	}

	// Advance to next player.
	game.ActionIdx = game.nextCanActIdx(seatIdx)
	p.sendTurnNotifications(game)
	p.startActionTimer(game)
	return nil
}

func (p *HoldemPlugin) handleStatus(ctx MessageContext) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	game := p.games[ctx.RoomID]
	if game == nil {
		return p.reply(ctx, "No active game here.")
	}

	seatIdx := game.playerIdx(ctx.Sender)
	if seatIdx < 0 {
		return p.reply(ctx, "You're not at this table.")
	}

	player := game.Players[seatIdx]
	hand := ""
	if game.HandInProgress && player.State != PlayerSatOut {
		hand = renderPrivateHand(game, seatIdx) + "\n"
	}
	view := renderTableView(game, seatIdx)
	return p.SendMessage(player.DMRoomID, hand+view)
}

func (p *HoldemPlugin) handleAddBot(ctx MessageContext) error {
	p.mu.Lock()
	defer p.mu.Unlock()

	game := p.games[ctx.RoomID]
	if game == nil {
		game = &HoldemGame{
			RoomID:            ctx.RoomID,
			SmallBlind:        p.cfg.SmallBlind,
			BigBlind:          p.cfg.BigBlind,
			DealerIdx:         -1,
			WaitingForPlayers: true,
		}
		p.games[ctx.RoomID] = game
	}

	// Check if NPC already exists.
	for _, pl := range game.Players {
		if pl.IsNPC {
			return p.reply(ctx, "An AI opponent is already at the table.")
		}
	}

	if len(game.Players) >= 9 {
		return p.reply(ctx, "Table is full.")
	}

	// Get NPC house balance.
	npcBalance := p.getNPCBalance()
	buyin := npcBalance
	if buyin > p.cfg.MaxBuyin {
		buyin = p.cfg.MaxBuyin
	}
	if buyin < p.cfg.MinBuyin {
		buyin = p.cfg.MinBuyin
	}

	npc := &HoldemPlayer{
		UserID:       id.UserID(fmt.Sprintf("@npc_%s:local", strings.ToLower(p.cfg.NPCName))),
		DisplayName:  p.cfg.NPCName,
		Stack:        buyin,
		OpeningStack: buyin,
		State:        PlayerActive,
		IsNPC:        true,
	}

	if game.HandInProgress {
		npc.State = PlayerSatOut
		npc.SittingOut = true
	}

	game.Players = append(game.Players, npc)

	msg := fmt.Sprintf("🤖 **%s** is an AI opponent using a trained poker solver. (€%d buy-in)", p.cfg.NPCName, buyin)
	return p.SendMessage(ctx.RoomID, msg)
}

// --- Game lifecycle ---

func (p *HoldemPlugin) startHand(game *HoldemGame) {
	p.stopIdleTimer(game)

	// Unsit players who were waiting.
	for _, pl := range game.Players {
		if pl.SittingOut {
			pl.SittingOut = false
			pl.State = PlayerActive
		}
	}

	// Remove players who want to leave.
	// Balance was already settled by settleNetDeltas at end of previous hand,
	// and OpeningStack was reset to Stack in endHand, so no settlement needed here.
	for i := len(game.Players) - 1; i >= 0; i-- {
		if game.Players[i].WantsLeave {
			game.Players = append(game.Players[:i], game.Players[i+1:]...)
		}
	}

	if len(game.Players) < 2 {
		p.SendMessage(game.RoomID, "Not enough players to start a hand.")
		return
	}

	// Reset game state.
	game.HandInProgress = true
	game.Street = StreetPreFlop
	game.Community = nil
	game.SidePots = nil
	game.Pot = 0
	game.Deck = newShuffledDeck()
	game.DeckPos = 0

	// Reset player state.
	for _, pl := range game.Players {
		pl.State = PlayerActive
		pl.Bet = 0
		pl.TotalBet = 0
		pl.HasActed = false
		pl.Hole = [2]poker.Card{}
	}

	// Advance dealer.
	game.DealerIdx = game.nextActiveIdx(game.DealerIdx)

	// Post blinds.
	_, bbIdx := game.postBlinds()

	// Deal hole cards (no burn before initial deal — burns are before flop/turn/river only).
	for _, pl := range game.Players {
		if pl.State != PlayerSatOut {
			pl.Hole[0] = game.drawCard()
			pl.Hole[1] = game.drawCard()
		}
	}

	// Set first to act.
	game.ActionIdx = game.firstToActPreflop(bbIdx)

	// Announce to room.
	p.SendMessage(game.RoomID, renderStartAnnouncement(game))

	// Send hole cards to all players via DM.
	// Skip the first-to-act player — they'll get hand + table from sendTurnNotifications.
	for i, pl := range game.Players {
		if pl.IsNPC || pl.State == PlayerSatOut || i == game.ActionIdx {
			continue
		}
		view := renderPrivateHand(game, i)
		p.SendMessage(pl.DMRoomID, view)
	}

	// Send turn notification.
	p.sendTurnNotifications(game)
	p.startActionTimer(game)
}

func (p *HoldemPlugin) advanceStreet(game *HoldemGame) {
	game.collectPot()
	game.resetStreetBets()

	switch game.Street {
	case StreetPreFlop:
		game.Street = StreetFlop
		game.burnCard()
		game.Community = append(game.Community, game.drawCard(), game.drawCard(), game.drawCard())
	case StreetFlop:
		game.Street = StreetTurn
		game.burnCard()
		game.Community = append(game.Community, game.drawCard())
	case StreetTurn:
		game.Street = StreetRiver
		game.burnCard()
		game.Community = append(game.Community, game.drawCard())
	case StreetRiver:
		game.Street = StreetShowdown
		p.doShowdown(game)
		return
	}

	// Set action to first active player after dealer.
	game.ActionIdx = game.firstToActPostflop()
	game.LastAggressorIdx = game.ActionIdx

	// Notify all players of new board.
	for i, pl := range game.Players {
		if pl.IsNPC || pl.State == PlayerSatOut || pl.State == PlayerFolded {
			continue
		}
		view := renderTableView(game, i)
		p.SendMessage(pl.DMRoomID, view)
	}

	// Check if all players are all-in.
	if game.canActCount() <= 1 {
		if game.canActCount() == 0 {
			p.runOutBoard(game)
			return
		}
		// Only 1 player can act — if they're the only non-allin, auto-advance.
		if game.activeCount()-game.canActCount() > 0 {
			// There are all-in players, run out.
			p.runOutBoard(game)
			return
		}
	}

	p.sendTurnNotifications(game)
	p.startActionTimer(game)
}

func (p *HoldemPlugin) advanceAction(game *HoldemGame) {
	if game.activeCount() <= 1 {
		p.finishHand(game)
		return
	}

	if game.canActCount() == 0 {
		p.runOutBoard(game)
		return
	}

	// Find next active player.
	game.ActionIdx = game.nextCanActIdx(game.ActionIdx)

	if game.isStreetComplete(game.ActionIdx) {
		p.advanceStreet(game)
		return
	}

	p.sendTurnNotifications(game)
	p.startActionTimer(game)
}

func (p *HoldemPlugin) runOutBoard(game *HoldemGame) {
	game.collectPot()

	// Build side pots if needed.
	hasAllIn := false
	for _, pl := range game.Players {
		if pl.State == PlayerAllIn {
			hasAllIn = true
			break
		}
	}
	if hasAllIn {
		game.buildSidePots()
	}

	// Deal remaining community cards.
	for game.Street < StreetShowdown {
		switch game.Street {
		case StreetPreFlop:
			game.Street = StreetFlop
			game.burnCard()
			game.Community = append(game.Community, game.drawCard(), game.drawCard(), game.drawCard())
		case StreetFlop:
			game.Street = StreetTurn
			game.burnCard()
			game.Community = append(game.Community, game.drawCard())
		case StreetTurn:
			game.Street = StreetRiver
			game.burnCard()
			game.Community = append(game.Community, game.drawCard())
		case StreetRiver:
			game.Street = StreetShowdown
		}

		// Brief notification with new board.
		boardMsg := fmt.Sprintf("**%s** — Board: %s", game.Street.String(), renderCards(game.Community))
		p.broadcastDM(game, boardMsg)
	}

	p.doShowdown(game)
}

func (p *HoldemPlugin) doShowdown(game *HoldemGame) {
	game.collectPot()

	// Build side pots if needed.
	hasAllIn := false
	for _, pl := range game.Players {
		if pl.State == PlayerAllIn {
			hasAllIn = true
			break
		}
	}
	if hasAllIn && len(game.SidePots) == 0 {
		game.buildSidePots()
	}

	results, winnings := runShowdown(game)

	// Track bot defeats.
	for _, pl := range game.Players {
		if pl.IsNPC {
			continue
		}
		won := winnings[pl.UserID]
		if won == 0 && pl.State != PlayerFolded {
			// Lost at showdown — check if any NPC won.
			for _, npl := range game.Players {
				if npl.IsNPC && winnings[npl.UserID] > 0 {
					recordBotDefeat(pl.UserID, "holdem")
					break
				}
			}
		}
	}

	// Post end announcement to room and DM each player.
	endAnn := renderEndAnnouncement(results, game)
	if gameHasPublicRoom(game) {
		p.SendMessage(game.RoomID, endAnn)
	}
	p.broadcastDM(game, endAnn)

	// Settle balances.
	settleNetDeltas(game, p.euro)

	// Update NPC balance.
	for _, pl := range game.Players {
		if pl.IsNPC {
			delta := pl.Stack - pl.OpeningStack
			p.updateNPCBalance(delta)
		}
	}

	// Record scores.
	p.recordScores(game, winnings)

	p.endHand(game)
}

func (p *HoldemPlugin) finishHand(game *HoldemGame) {
	p.stopActionTimer(game)

	// Return uncalled bet.
	name, amount := game.returnUncalledBet()
	if amount > 0 {
		msg := renderUncalledBetReturn(name, amount)
		p.broadcastDM(game, msg)
	}

	// Award pot to last remaining player.
	ann, winnerID := awardPotToLastPlayer(game)
	if ann != "" {
		if gameHasPublicRoom(game) {
			p.SendMessage(game.RoomID, ann)
		}
		p.broadcastDM(game, ann)
	}

	// Track bot defeats (if NPC won by everyone folding).
	for _, pl := range game.Players {
		if pl.IsNPC {
			continue
		}
		if pl.UserID != winnerID && pl.State == PlayerFolded {
			for _, npl := range game.Players {
				if npl.IsNPC && npl.UserID == winnerID {
					recordBotDefeat(pl.UserID, "holdem")
					break
				}
			}
		}
	}

	// Settle balances.
	settleNetDeltas(game, p.euro)

	// Update NPC balance.
	for _, pl := range game.Players {
		if pl.IsNPC {
			delta := pl.Stack - pl.OpeningStack
			p.updateNPCBalance(delta)
		}
	}

	p.endHand(game)
}

func (p *HoldemPlugin) endHand(game *HoldemGame) {
	p.stopActionTimer(game)
	game.HandInProgress = false
	game.Street = StreetPreFlop

	// Remove players who want to leave or busted out.
	// Credit their remaining stack (buy-in was debited at join).
	for i := len(game.Players) - 1; i >= 0; i-- {
		pl := game.Players[i]
		if pl.WantsLeave || pl.Stack <= 0 {
			if !pl.IsNPC {
				if pl.Stack > 0 {
					net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
					p.euro.Credit(pl.UserID, net, "holdem_cashout")
				}
				p.SendMessage(game.RoomID, fmt.Sprintf("**%s** has left the table.", pl.DisplayName))
			}
			game.Players = append(game.Players[:i], game.Players[i+1:]...)
		}
	}

	// Reset opening stacks for next hand.
	for _, pl := range game.Players {
		pl.OpeningStack = pl.Stack
	}

	if len(game.Players) < 2 {
		// Cash out remaining players.
		for _, pl := range game.Players {
			if !pl.IsNPC && pl.Stack > 0 {
				net, _ := communityTax(pl.UserID, float64(pl.Stack), 0.05)
				p.euro.Credit(pl.UserID, net, "holdem_cashout")
			}
		}
		p.SendMessage(game.RoomID, "Not enough players for another hand. Game over.")
		delete(p.games, game.RoomID)
		return
	}

	p.SendMessage(game.RoomID, fmt.Sprintf("Hand complete. %d players at the table. Type `!holdem start` for the next hand.", len(game.Players)))
	p.startIdleTimer(game)
}

// --- Notifications ---

func (p *HoldemPlugin) sendTurnNotifications(game *HoldemGame) {
	actionPlayer := game.Players[game.ActionIdx]

	if actionPlayer.IsNPC {
		// NPC acts automatically.
		go p.npcAct(game)
		return
	}

	// Send table view + private hand to action player.
	view := renderTableView(game, game.ActionIdx)
	hand := renderPrivateHand(game, game.ActionIdx)
	p.SendMessage(actionPlayer.DMRoomID, hand+"\n"+view)

	// Generate tip asynchronously — snapshot under lock, compute equity + generate outside.
	if actionPlayer.TipsEnabled {
		snap := snapshotForTip(game, game.ActionIdx)
		dmRoom := actionPlayer.DMRoomID
		playerID := string(actionPlayer.UserID)
		safeGo("holdem-tip", func() {
			tipCtx := buildTipContext(snap)
			tip := generateTip(tipCtx)
			p.SendMessage(dmRoom, tip)
			logTipAudit(playerID, tipCtx, tip)
		})
	}
}

// broadcastDM delivers msg to each player's channel — their DM room in
// multiplayer, or game.RoomID directly in solo-vs-bot (where the room IS
// the player's DM). Deduplicated by room so callers that also post to
// game.RoomID for spectators (see gameHasPublicRoom) don't cause the solo
// player to see the message twice.
func (p *HoldemPlugin) broadcastDM(game *HoldemGame, msg string) {
	sent := map[id.RoomID]bool{}
	first := true
	for _, pl := range game.Players {
		if pl.IsNPC || pl.State == PlayerSatOut {
			continue
		}
		if sent[pl.DMRoomID] {
			continue
		}
		sent[pl.DMRoomID] = true
		if !first {
			// Jitter 150–400ms between sends to avoid bursts on the Matrix server.
			time.Sleep(150*time.Millisecond + time.Duration(rand.IntN(250))*time.Millisecond)
		}
		first = false
		p.SendMessage(pl.DMRoomID, msg)
	}
}

// gameHasPublicRoom reports whether game.RoomID is a separate public room
// (multiplayer) rather than a player's own DM (solo-vs-bot).
func gameHasPublicRoom(g *HoldemGame) bool {
	for _, pl := range g.Players {
		if !pl.IsNPC && pl.DMRoomID == g.RoomID {
			return false
		}
	}
	return true
}

// --- NPC ---

func (p *HoldemPlugin) npcAct(game *HoldemGame) {
	// Compute action under lock (reads game state safely).
	p.mu.Lock()
	if !game.HandInProgress {
		p.mu.Unlock()
		return
	}
	npcIdx := game.ActionIdx
	action, delay := NPCChooseAction(p.policy, game, npcIdx)
	p.mu.Unlock()

	// Human-like delay outside the lock.
	time.Sleep(delay)

	p.mu.Lock()
	defer p.mu.Unlock()

	// Verify game state hasn't changed.
	if !game.HandInProgress || game.ActionIdx != npcIdx {
		return
	}

	// Stop the action timer so it can't fire while we process.
	p.stopActionTimer(game)

	actionStr, amount := cfrActionToGameAction(action, game, npcIdx)

	var result ActionResult
	switch actionStr {
	case "fold":
		result = game.doFold(npcIdx)
	case "check":
		result, _ = game.doCheck(npcIdx)
	case "call":
		result, _ = game.doCall(npcIdx)
	case "raise":
		result, _ = game.doRaise(npcIdx, amount)
	case "allin":
		result = game.doAllIn(npcIdx)
	}

	p.broadcastDM(game, result.Announcement)

	if result.HandOver {
		p.finishHand(game)
		return
	}

	if result.AllAllIn {
		p.runOutBoard(game)
		return
	}

	if result.StreetOver {
		p.advanceStreet(game)
		return
	}

	game.ActionIdx = game.nextCanActIdx(npcIdx)
	p.sendTurnNotifications(game)
	p.startActionTimer(game)
}

// --- Timers ---

func (p *HoldemPlugin) startActionTimer(game *HoldemGame) {
	p.stopActionTimer(game)

	timeout := time.Duration(p.cfg.TimeoutSeconds) * time.Second
	actionIdx := game.ActionIdx
	roomID := game.RoomID

	// Warning at 30s before timeout (if timeout > 35s).
	if p.cfg.TimeoutSeconds > 35 {
		warningDelay := timeout - 30*time.Second
		player := game.Players[actionIdx]
		dmRoom := player.DMRoomID
		game.warningTimer = time.AfterFunc(warningDelay, func() {
			p.mu.Lock()
			defer p.mu.Unlock()
			g := p.games[roomID]
			if g == nil || !g.HandInProgress || g.ActionIdx != actionIdx {
				return
			}
			pl := g.Players[actionIdx]
			if pl.IsNPC {
				return
			}
			p.SendMessage(dmRoom, "⏰ **30 seconds** remaining to act!")
		})
	}

	game.actionTimer = time.AfterFunc(timeout, func() {
		p.mu.Lock()
		defer p.mu.Unlock()

		g := p.games[roomID]
		if g == nil || !g.HandInProgress || g.ActionIdx != actionIdx {
			return
		}

		player := g.Players[actionIdx]
		if player.IsNPC {
			return
		}

		// Auto-action: check if possible, otherwise fold.
		toCall := g.CurrentBet - player.Bet
		if toCall <= 0 {
			result, _ := g.doCheck(actionIdx)
			p.broadcastDM(g, "⏰ "+result.Announcement+" (timeout)")
		} else {
			result := g.doFold(actionIdx)
			p.broadcastDM(g, "⏰ "+result.Announcement+" (timeout)")
		}

		if g.activeCount() <= 1 {
			p.finishHand(g)
			return
		}

		if g.canActCount() == 0 {
			p.runOutBoard(g)
			return
		}

		g.ActionIdx = g.nextCanActIdx(actionIdx)

		if g.isStreetComplete(g.ActionIdx) {
			p.advanceStreet(g)
			return
		}

		p.sendTurnNotifications(g)
		p.startActionTimer(g)
	})
}

func (p *HoldemPlugin) stopActionTimer(game *HoldemGame) {
	if game.warningTimer != nil {
		game.warningTimer.Stop()
		game.warningTimer = nil
	}
	if game.actionTimer != nil {
		game.actionTimer.Stop()
		game.actionTimer = nil
	}
}

// startIdleTimer starts a 1-hour timer that closes the table if no hand is started.
// A warning is sent at the 45-minute mark.
// Call when a game lobby is created or when a hand ends.
func (p *HoldemPlugin) startIdleTimer(game *HoldemGame) {
	p.stopIdleTimer(game)
	roomID := game.RoomID

	// Warning at 45 minutes.
	game.idleWarningTimer = time.AfterFunc(45*time.Minute, func() {
		p.mu.Lock()
		defer p.mu.Unlock()
		g := p.games[roomID]
		if g == nil || g.HandInProgress {
			return
		}
		p.SendMessage(roomID, "⏰ Table will close in **15 minutes** due to inactivity. Type `!holdem start` to play or `!holdem leave` to cash out.")
	})

	game.idleTimer = time.AfterFunc(1*time.Hour, func() {
		p.mu.Lock()
		defer p.mu.Unlock()

		g := p.games[roomID]
		if g == nil || g.HandInProgress {
			return
		}

		// Refund all players and close the table.
		for _, pl := range g.Players {
			if !pl.IsNPC && pl.Stack > 0 {
				p.euro.Credit(pl.UserID, float64(pl.Stack), "holdem_idle_refund")
			}
		}
		p.SendMessage(roomID, "Table closed — 1 hour of inactivity. All buy-ins have been refunded.")
		delete(p.games, roomID)
	})
}

func (p *HoldemPlugin) stopIdleTimer(game *HoldemGame) {
	if game.idleWarningTimer != nil {
		game.idleWarningTimer.Stop()
		game.idleWarningTimer = nil
	}
	if game.idleTimer != nil {
		game.idleTimer.Stop()
		game.idleTimer = nil
	}
}

// --- Helpers ---

func (p *HoldemPlugin) removePlayer(game *HoldemGame, uid id.UserID) {
	for i, pl := range game.Players {
		if pl.UserID == uid {
			game.Players = append(game.Players[:i], game.Players[i+1:]...)
			// Adjust seat indices that reference positions after the removed player.
			// Only decrement when strictly greater — if equal, the next player
			// slides into the same index so no adjustment is needed.
			if game.DealerIdx > i {
				game.DealerIdx--
			}
			if game.ActionIdx > i {
				game.ActionIdx--
			}
			if game.LastAggressorIdx > i {
				game.LastAggressorIdx--
			}
			// Wrap indices if they exceed the new length.
			if n := len(game.Players); n > 0 {
				game.DealerIdx %= n
				game.ActionIdx %= n
				game.LastAggressorIdx %= n
			}
			return
		}
	}
}

func (p *HoldemPlugin) getNPCBalance() int64 {
	d := db.Get()
	var balance int64
	err := d.QueryRow(`SELECT balance FROM holdem_npc_balance WHERE npc_name = ?`, p.cfg.NPCName).Scan(&balance)
	if err != nil {
		// Initialize.
		db.Exec("holdem: init npc balance",
			`INSERT OR IGNORE INTO holdem_npc_balance (npc_name, balance) VALUES (?, ?)`,
			p.cfg.NPCName, p.cfg.NPCHouseBalance)
		return p.cfg.NPCHouseBalance
	}
	return balance
}

func (p *HoldemPlugin) updateNPCBalance(delta int64) {
	db.Exec("holdem: update npc balance",
		`UPDATE holdem_npc_balance SET balance = balance + ?, hands_played = hands_played + 1 WHERE npc_name = ?`,
		delta, p.cfg.NPCName)
}

// resetNPCHouseBalance resets all NPC balances to the configured house balance.
// Called daily at midnight.
func resetNPCHouseBalance() {
	balance := int64(envInt("HOLDEM_NPC_HOUSE_BALANCE", 10000))
	db.Exec("holdem: reset npc balance",
		`UPDATE holdem_npc_balance SET balance = ?`, balance)
}

func (p *HoldemPlugin) recordScores(game *HoldemGame, winnings map[id.UserID]int64) {
	for _, pl := range game.Players {
		if pl.IsNPC {
			continue
		}
		won := winnings[pl.UserID]
		lost := int64(0)
		delta := pl.Stack - pl.OpeningStack
		if delta < 0 {
			lost = -delta
		}
		biggestPot := game.Pot
		for _, sp := range game.SidePots {
			if sp.Amount > biggestPot {
				biggestPot = sp.Amount
			}
		}

		db.Exec("holdem: record player score",
			`INSERT INTO holdem_scores (user_id, hands_played, total_won, total_lost, biggest_pot)
			 VALUES (?, 1, ?, ?, ?)
			 ON CONFLICT(user_id) DO UPDATE SET
			   hands_played = hands_played + 1,
			   total_won = total_won + ?,
			   total_lost = total_lost + ?,
			   biggest_pot = MAX(biggest_pot, ?)`,
			string(pl.UserID), won, lost, biggestPot, won, lost, biggestPot,
		)
	}
}
