package plugin

import (
	"fmt"
	"log/slog"
	"strings"

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

// N3/P6c — seating the party at the doorway.
//
// P5 built startPartyCombatSession and left it with no production caller: there
// was no roster to seat it with. P6b filled the roster. This is the join.
//
// The rule the whole file turns on is that **seat 0 is the expedition leader**.
// Every seat-0 invariant P4 and P5 laid down rests on it: combat_session.user_id
// is the fight's lock key, the run- and expedition-scoped close-out effects fire
// once through it, and `!flee` is refused to any seat but zero. A party whose
// seat 0 were merely "whoever typed !fight" would flee the leader's run on a
// member's say-so.

// fightRoster is the seating order for a fight opened on this run: the
// expedition's leader first, then their party in join order. A bare zone run —
// and a solo expedition, whose roster table is empty — resolves to the one
// player who owns it.
//
// It doubles as the answer to "under whose lock is this fight taken", since
// roster[0] is the session's owner. It is resolved *before* the lock, so it
// must not touch getActiveZoneRun — that lookup carries the §4.3 idle reap.
func fightRoster(sender id.UserID) []id.UserID {
	e, _, err := activeExpeditionFor(sender)
	if err != nil || e == nil {
		return []id.UserID{sender}
	}
	// Seats, not audience: the hired companion fights even though he never
	// receives a DM about it.
	return expeditionSeats(e)
}

// buildFightSeats turns a roster into the seats that will actually sit down, and
// the enemy they face. A member who is down, or somehow already fighting, is left
// out: they sit this one out rather than blocking the party. The leader is not
// optional — if seat 0 cannot fight, nobody does, and `refusal` says why in terms
// of whoever typed `!fight`.
//
// senderSkip is the sender's own reason for being left out, empty when they are
// seated. Without it a downed member's `!fight` opens the party's fight and then
// answers them with silence.
//
// The enemy is built once. Every seat's build derives the identical stat block
// from (monster, tier, dmMood); only the player half varies.
func (p *AdventurePlugin) buildFightSeats(
	sender id.UserID, roster []id.UserID, monster DnDMonsterTemplate, tier, dmMood int, run *DungeonRun,
) (seats []CombatSeatSetup, enemy *Combatant, senderSkip, refusal string) {
	skip := func(uid id.UserID, why string) {
		if uid == sender {
			senderSkip = why
		}
	}
	// Parallel to `seats`, so a skipped member leaves no gap: what a seat costs the
	// enemy is priced from these once the roster is final.
	var levels []int
	var companions []bool
	for i, uid := range roster {
		leader := i == 0

		// The hired companion. He must be handled before everything below:
		// dndHPSnapshot returns (0,0) for a user with no sheet, so the very next
		// check would quietly sit him out of every fight he was paid for, and
		// buildZoneCombatants would then fail on him anyway.
		//
		// He is latched onto autopilot at seat time rather than after the away-player
		// deadline — nobody is going to type for him, and waiting three minutes to
		// discover that would stall the fight and then announce him to the party as
		// an absent player.
		if isCompanionSeat(uid) {
			expID := companionExpeditionFor(roster[0])
			class, level := companionLoadout(expID)
			player, _, _ := p.companionCombatant(class, level, monster, tier, dmMood)
			// He carries his wounds between fights, like everyone else. Seating him at
			// full max HP — which is what this did — hands the party an infinite body:
			// he soaks a share of every fight's incoming and then resets, while the
			// humans beside him bleed all the way to camp.
			seats = append(seats, CombatSeatSetup{
				UserID: uid,
				HP:     companionSeatHP(expID, player.Stats.MaxHP),
				HPMax:  player.Stats.MaxHP,
				Mods:   player.Mods, C: &player, EngineDriven: true,
			})
			levels, companions = append(levels, level), append(companions, true)
			continue
		}

		// Both refusals below are cheap and neither needs the build, so they run
		// before it: consuming a seat's armed ability and *then* sitting them out
		// would spend their rage on a fight they never joined.
		hp, hpMax := dndHPSnapshot(uid)
		if hp <= 0 {
			if leader {
				return nil, nil, "", seatZeroRefusal(sender, uid,
					"You're in no shape to fight. `!rest` first.",
					"Your party leader is in no shape to fight. The fight waits for them.")
			}
			skip(uid, "You're in no shape to fight — the party goes in without you. `!rest` when you can.")
			continue
		}
		// A member's live fight lives on a combat_participant row, so
		// hasActiveCombatSession — which keys on combat_session.user_id — would
		// answer "no" for them mid-fight. The caller has already ruled out this
		// room's own encounter, so anything found here is a different fight.
		if s, serr := activeCombatSessionFor(uid); serr == nil && s != nil {
			if leader {
				return nil, nil, "", seatZeroRefusal(sender, uid,
					"You're already in a fight. Finish it with `!attack` / `!flee`.",
					"Your party leader is already in a fight somewhere else.")
			}
			slog.Info("combat: party member busy in another fight", "user", uid, "session", s.SessionID)
			skip(uid, "You're already in a fight elsewhere — the party goes in without you.")
			continue
		}

		// Consumed exactly once for the fight, here. Every later rebuild
		// re-applies this id off the seat's statuses rather than re-arming.
		armed := ""
		if c, cerr := LoadDnDCharacter(uid); cerr == nil && c != nil {
			trySimAutoArm(c)
			armed = consumeArmedAbility(c)
		}
		player, e, dc, err := p.buildZoneCombatants(uid, monster, tier, dmMood, armed)
		if err != nil {
			if leader {
				return nil, nil, "", "Couldn't set up the fight: " + err.Error()
			}
			slog.Warn("combat: party member left out of fight", "user", uid, "err", err)
			skip(uid, "Couldn't bring you into the fight: "+err.Error())
			continue
		}
		if leader {
			enemy = &e
		}
		if armed != "" {
			slog.Info("combat: armed ability fired (turn-based start)", "user", uid, "ability", armed)
		}

		seats = append(seats, CombatSeatSetup{
			UserID: uid, HP: hp, HPMax: hpMax, Mods: player.Mods, C: &player, ArmedAbility: armed,
		})
		lvl := 0
		if dc != nil {
			lvl = dc.Level
		}
		levels, companions = append(levels, lvl), append(companions, false)
	}

	// Price each seat against the leader. It runs here, over the seats that were
	// actually seated — a member who was skipped (downed, busy elsewhere) never
	// joined the fight and must not be charged to the enemy.
	applySeatWeights(seatCombatants(seats), levels, companions)

	// Fold in any Layer-2 pre-combat boss mechanic before this enemy is used to
	// persist the initial HP pool. partyCombatantsForSession re-applies the same
	// modifier on every round's rebuild; doing it here keeps the persisted stat
	// block consistent with the fight the engine will actually run. No-op for
	// every non-hooked enemy.
	applyBossRunModifiers(monster.ID, enemy, run)
	return seats, enemy, senderSkip, ""
}

// seatCombatants is the roster's freshly-built characters in seating order — the
// same slice partyCombatantsForSession would rebuild from the persisted session,
// threaded through from the build that seated them instead.
func seatCombatants(seats []CombatSeatSetup) []*Combatant {
	players := make([]*Combatant, len(seats))
	for i, s := range seats {
		players[i] = s.C
	}
	return players
}

// seatZeroRefusal picks the copy for a blocked seat 0. The leader hears about
// themselves in the second person; a member hears who the party is waiting on.
func seatZeroRefusal(sender, leader id.UserID, own, aboutLeader string) string {
	if sender == leader {
		return own
	}
	return aboutLeader
}

// announcePartyFightStart DMs every seated member the opening block: the shared
// header, their own HP and curios, the roster's initiative order, whatever the
// enemy did before anyone could stop it, and either "your move" or who the round
// is waiting on.
//
// opening carries the events of a round-1 enemy turn — the monster can win
// initiative, and a party that reads "your move" over an unnarrated 12-point hit
// has been lied to. outcomes, when set, is the per-seat close-out of a fight that
// ended before it started.
//
// Solo does not come through here — handleFightCmd answers the one player who
// typed, with the bytes it always sent.
func (p *AdventurePlugin) announcePartyFightStart(
	sess *CombatSession, players []*Combatant, enemy *Combatant, header string,
	opening []CombatEvent, outcomes []string,
) {
	acting, waiting := actingSeat(sess, players, enemy)
	names := make([]string, len(players))
	for i, c := range players {
		names[i] = c.Name
	}
	for seat, uid := range sess.SeatUserIDs() {
		// Seat-keyed fan-out, so it bypasses expeditionAudience's filter — the
		// companion sits down but is never written to. (He also has no magic items
		// to line up, and activeMagicItemsLine would go looking for them.)
		if isCompanionUser(uid) {
			continue
		}
		var b strings.Builder
		b.WriteString(header)
		b.WriteString(fmt.Sprintf("You: **%d/%d HP**.\n", sess.seatHP(seat), sess.seatHPMax(seat)))
		if curios := activeMagicItemsLine(id.UserID(uid)); curios != "" {
			b.WriteString(curios + "\n")
		}
		b.WriteString("\n**Marching order:** ")
		b.WriteString(strings.Join(initiativeNames(players, sess, enemy), " → "))
		b.WriteString("\n\n")
		if len(opening) > 0 {
			b.WriteString(RenderPartyTurnRound(opening, names, enemy.Name, seat))
			b.WriteString("\n\n")
		}
		switch {
		case seat < len(outcomes):
			b.WriteString(outcomes[seat])
		case !waiting:
			b.WriteString(fmt.Sprintf("**Round %d.** The round is resolving.", sess.Round))
		case seat == acting:
			b.WriteString(partyMovePrompt(sess.Round))
		default:
			b.WriteString(fmt.Sprintf("**Round %d.** Waiting on **%s**.", sess.Round, players[acting].Name))
		}
		if err := p.SendDM(id.UserID(uid), b.String()); err != nil {
			slog.Error("combat: party fight-start DM failed", "user", uid, "err", err)
		}
	}
}

// partyMovePrompt is the line a seat sees when the round is its own. `!flee` is
// missing on purpose: in a party it is the leader's call, and P5 refuses it to
// every other seat.
func partyMovePrompt(round int) string {
	return fmt.Sprintf("**Round %d.** Your move — `!attack`, `!cast <spell>`, `!consume <item>`.", round)
}

// initiativeNames renders this round's turn order for the opening block. It is
// the party's one look at the initiative P3 rolls for them — the number itself
// stays hidden (accessibility over crunch); the order is the useful part.
func initiativeNames(players []*Combatant, sess *CombatSession, enemy *Combatant) []string {
	order := turnOrder(sess, sess.Round, players, enemy)
	names := make([]string, 0, len(order))
	for _, seat := range order {
		if seat == enemySeat {
			names = append(names, enemy.Name)
			continue
		}
		names = append(names, players[seat].Name)
	}
	return names
}
