package plugin

// Tier-6 postgame "Layer-2" boss mechanics (Phase P8).
//
// Layer 1 (shipped) is everything the stock engine expresses: a single
// MonsterAbility rider plus HP/AC/Attack/PhaseTwoAt on the stat block. Layer 2
// is the bespoke, per-boss stuff the plan promised — mechanics that read the
// *run* the player took to reach the boss, not just the fight in front of them.
//
// It holds BOTH halves of that seam:
//
//   - PRE-COMBAT (applyBossRunModifiers / seedBossRunStatuses): adjustments
//     derived once from run state and folded into the boss's live Combatant (or
//     the fresh session) before the fight resolves. applyBossRunModifiers must be
//     a PURE, IDEMPOTENT function of run state, because the turn engine rebuilds
//     the enemy from the bestiary every single round (partyCombatantsForSession)
//     — the boss's numbers are re-derived on every !attack. That is fine as long
//     as the inputs are frozen for the fight, which they are: the boss room is
//     terminal, so no more nodes are walked once the fight begins.
//
//   - IN-COMBAT (applyBossInCombatRoundEnd): round-boundary mechanics that read
//     and mutate the live combatState — HP snapshots, once-only rewinds, escalating
//     timers. Called from the turn engine's stepRoundEnd after the round's damage
//     has settled. Any state it spends round-trips through CombatStatuses so a
//     suspend/resume can't replay it.
//
// The remaining planned in-combat mechanics (Inversion Stitch, Two Hearts) are
// still a separate seam — a new MonsterAbility.Effect case in applyAbility — and
// are not in this file yet.

// applyBossRunModifiers folds any pre-combat Layer-2 mechanic for bossID into
// the freshly-built enemy Combatant, reading the run the player took to get
// here. It is dispatched by bestiary ID and is a no-op for every enemy that
// isn't a hooked T6 boss, so the two build seams can call it unconditionally on
// whatever they just built. A nil enemy or nil run is a no-op.
func applyBossRunModifiers(bossID string, enemy *Combatant, run *DungeonRun) {
	if enemy == nil || run == nil {
		return
	}
	switch bossID {
	case "boss_aurvandryx":
		applyGreedTax(enemy, run)
	}
}

// ── Greed Tax — Aurvandryx, the Ember Before Fire (First Hoard) ─────────────
//
// The First Hoard carries the game's richest LootBias nodes on purpose (the
// gilded veins at 2.5–3.0; nowhere else in the game exceeds 1.0). Aurvandryx —
// the hoard's true owner, not its guard dog — takes the interest out of your
// hide: her Attack rises with how much of the gilded route you walked to reach
// her cradle. Strip the veins and meet a furious wyrm; take the vow-of-poverty
// line through the zone and fight her lean.
//
// Signal: the summed excess LootBias (above the 1.0 baseline) of every node the
// run walked — NOT run.LootCollected, which only tracks the boss-only signature
// manifest and is empty at the boss. A full-explore route through the First
// Hoard accrues ~3.5 richness (+7 Attack); the cap covers a maximal line.
//
// Calibration (P8 sim sweep, L20 party+Pete+pets): the full-explore route lands
// first_hoard at ~47% clear (mid-band 40–55), down from the taxless 56%. A lean
// prod route pays no tax and fights her at the taxless rate; a maximal route
// hits the cap. No base-stat retune was needed — the tax corrects the prior
// slight overshoot into the band. NOTE: the sim's autopilot explores the whole
// graph, so the sweep only exercises the full-route point; the lean/greedy
// spread is a prod-only player-agency lever the headless sim cannot walk.
const (
	// greedTaxRichnessMult converts summed route richness into Attack points.
	greedTaxRichnessMult = 2.0

	// greedTaxMaxAttack caps the tax so a maximal hoard-run meets a very hard
	// wyrm, never a literally-unbeatable one.
	greedTaxMaxAttack = 12
)

// greedRouteRichness sums the excess LootBias (above the 1.0 baseline) of every
// node the run has walked. Extracted for a deterministic unit test.
func greedRouteRichness(run *DungeonRun) float64 {
	g, ok := loadZoneGraph(run.ZoneID)
	if !ok {
		return 0
	}
	var rich float64
	for _, id := range run.VisitedNodes {
		if n, exists := g.Nodes[id]; exists && n.Content.LootBias > 1.0 {
			rich += n.Content.LootBias - 1.0
		}
	}
	return rich
}

// greedTaxAttack is the Attack surcharge for a route of the given richness.
func greedTaxAttack(richness float64) int {
	tax := int(richness * greedTaxRichnessMult)
	if tax > greedTaxMaxAttack {
		tax = greedTaxMaxAttack
	}
	if tax < 0 {
		tax = 0
	}
	return tax
}

func applyGreedTax(enemy *Combatant, run *DungeonRun) {
	enemy.Stats.Attack += greedTaxAttack(greedRouteRichness(run))
}

// ── Phylactery Verses — Valdris, At Last (The Ossuary Ascendant) ────────────
//
// The plan Valdris has been running since he "died" in the T1 Crypt: the
// phylactery shard players looted for years was bait, and he has rebuilt as a
// true lich. His rebirths are bound into three Verses hidden in the cathedral,
// each a NodeKindSecret behind a Perception gate. Every Verse a player finds and
// walks before the fight UNBINDS one rebirth; every Verse they skip leaves it
// armed. Full-clear explorers strip all three and fight a mortal lich;
// speedrunners who blow past the secrets fight a god who will not stay down.
//
// This is the mirror-image of the Greed Tax's design axis: the Tax punishes
// greedy exploration, the Verses reward thorough exploration. Both are prod
// player-agency levers the headless sim only samples at whatever route its
// autopilot happens to walk.
//
// Unlike the Greed Tax (a pure per-round Attack recompute), a rebirth is spent
// mid-fight, so it is stateful: seedBossRunStatuses freezes the charge count
// onto the session ONCE at fight start, and the turn engine round-trips the
// live count through CombatStatuses. It must NOT be re-derived on the per-round
// enemy rebuild, or spent rebirths would come back every round.
const (
	// phylacteryReviveFrac is the fraction of the boss's (party-scaled) max HP
	// each unbound rebirth restores him to — a real second wind, not the 1-HP
	// stay of survive_at_1.
	phylacteryReviveFrac = 4 // 1/4 == 25%
)

// phylacteryReviveCharges is the number of rebirths still armed on Valdris: one
// per zone Verse (NodeKindSecret) the run has NOT visited. The Ossuary's only
// secret nodes are the three Verses (the shared builder stamps none by default),
// so counting unvisited secrets is exactly "unbound rebirths". Zero for any
// non-Valdris boss or a nil run. A pure function of frozen run state.
func phylacteryReviveCharges(bossID string, run *DungeonRun) int {
	if bossID != "boss_valdris_ascendant" || run == nil {
		return 0
	}
	g, ok := loadZoneGraph(run.ZoneID)
	if !ok {
		return 0
	}
	visited := make(map[string]bool, len(run.VisitedNodes))
	for _, id := range run.VisitedNodes {
		visited[id] = true
	}
	charges := 0
	for id, n := range g.Nodes {
		if n.Kind == NodeKindSecret && !visited[id] {
			charges++
		}
	}
	return charges
}

// seedBossRunStatuses folds any ONCE-AT-FIGHT-START Layer-2 boss state into the
// freshly-created session, reading the run the player took to get here. It is
// the stateful counterpart to applyBossRunModifiers (which is a per-round pure
// recompute): whatever it seeds here is mutated by the fight and round-tripped,
// never re-derived. enemyMaxHP is the party-scaled pool already persisted onto
// the session. Returns whether it changed anything (so the caller can skip a
// redundant save). No-op — false — for every non-hooked boss.
func seedBossRunStatuses(sess *CombatSession, bossID string, enemyMaxHP int, run *DungeonRun) bool {
	if sess == nil {
		return false
	}
	if charges := phylacteryReviveCharges(bossID, run); charges > 0 {
		sess.Statuses.EnemyReviveCharges = charges
		sess.Statuses.EnemyReviveHP = max(1, enemyMaxHP/phylacteryReviveFrac)
		return true
	}
	return false
}

// ── Amendment — The Custodian of the Last Hour (The Last Meridian) ───────────
//
// The Custodian has concluded its contract is complete and is dismantling the
// hours on its way out — including, once, the last few minutes of its own fight.
// Its true durability is HIDDEN in the opening rounds: it snapshots its HP at the
// end of round 3, and the first time the party knocks it into phase 2 it *rewinds
// itself* to that snapshot — once. Front-loaded burst is partially refunded (the
// damage past the snapshot is undone), so sustained builds that can grind through
// the extra pool shine over glass-cannon alpha strikes. A soft "midnight timer"
// then leans on stalls: every round past round 20 the Custodian's Attack climbs,
// so a fight that can't close still resolves before the clock runs out.
//
// This is the first IN-COMBAT Layer-2 mechanic. Unlike the pre-combat hooks it
// reads/mutates the live combatState at round end, and its once-only state
// (EnemyRewindHP snapshot + EnemyRewindUsed) round-trips through CombatStatuses so
// a suspend/resume can't hand the boss a second rewind. Dispatched by bestiary ID,
// so it is a no-op for every other enemy.
const (
	// custodianSnapshotRound is the round whose end HP the Custodian rewinds to.
	// Snapshotting late enough that a normal party has committed real damage, but
	// early enough that the refund still matters, is the whole point — the fight's
	// true length is concealed until the rewind fires.
	custodianSnapshotRound = 3

	// custodianPhaseTwoFrac must track The Custodian's PhaseTwoAt in
	// postgame_zone_defs.go (0.45): the rewind is meant to fire exactly as the
	// party crosses the boss into phase 2. Kept as a local constant because the
	// turn engine resolves off the bestiary template + session, not the
	// ZoneDefinition, so the threshold isn't otherwise in reach at round end.
	custodianPhaseTwoFrac = 0.45

	// midnightAtkStep / midnightAtkCap: the soft closing-time timer. Every round
	// past midnightTimerAfter adds midnightAtkStep to the boss's Attack (via the
	// shared EnemyAtkBuff, which enemyAttackStat already folds in), capped so a
	// truly stalled fight still ends without the number becoming meaningless.
	midnightTimerAfter = 20
	midnightAtkStep    = 2
	midnightAtkCap     = 40
)

// applyBossInCombatRoundEnd resolves the round-boundary in-combat Layer-2
// mechanics for the enemy the turn engine is fighting. It runs after the round's
// damage has settled and only while the enemy still stands. Dispatched by
// bestiary ID; a no-op for every enemy that isn't a hooked T6 boss.
func applyBossInCombatRoundEnd(st *combatState, bossID string, enemyMaxHP int) {
	if st == nil {
		return
	}
	switch bossID {
	case "boss_custodian":
		applyAmendment(st, enemyMaxHP)
	case "boss_seamstress":
		applyInversionStitch(st, enemyMaxHP)
	}
}

// applyAmendment resolves the Custodian's Amendment (round-3 snapshot + once-only
// phase-2 rewind) and its soft midnight timer, given the round that just finished
// (st.round, pre-increment) and the boss's party-scaled MaxHP.
func applyAmendment(st *combatState, enemyMaxHP int) {
	// Snapshot the boss's HP at the end of round 3 — the concealed "true length".
	if st.round == custodianSnapshotRound && st.enemyRewindHP == 0 {
		st.enemyRewindHP = st.enemyHP
	}
	// The first time the party crosses the boss into phase 2, rewind to the
	// snapshot — once. Guarded on snapshot > current so a party that never got
	// below the round-3 HP (and a fight bursted into phase 2 before the snapshot
	// was even taken, EnemyRewindHP == 0) can't be handed a free heal.
	phaseTwo := int(custodianPhaseTwoFrac * float64(enemyMaxHP))
	if !st.enemyRewindUsed && st.enemyRewindHP > st.enemyHP && st.enemyHP <= phaseTwo {
		st.enemyHP = min(enemyMaxHP, st.enemyRewindHP)
		st.enemyRewindUsed = true
		st.events = append(st.events, CombatEvent{
			Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "amendment_rewind",
			PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
		})
	}
	// Soft midnight timer: closing time leans on a stalled fight.
	if st.round >= midnightTimerAfter && st.enemyAtkBuff < midnightAtkCap {
		st.enemyAtkBuff = min(midnightAtkCap, st.enemyAtkBuff+midnightAtkStep)
		st.events = append(st.events, CombatEvent{
			Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "midnight_toll",
			Damage: midnightAtkStep, PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
		})
	}
}

// ── Inversion Stitch — The Seamstress (The Unplace) ──────────────────────────
//
// The Seamstress is sewing the tear shut from the inside, and once she's far
// enough into the work (phase 2) she starts turning the room inside-out around
// the party: for a two-round pulse, healing runs the wrong direction — every
// cure lands as a wound (gated in stepPlayerActionEffect, floored at 1 HP so a
// player is never killed by their own healer, only softened for the Seamstress's
// own blows to finish). The catch is that she can't invert the room without a
// visible tell: each pulse is telegraphed one full round ahead, so a player who
// reads it holds their heals and waits it out. The autopilot that drives the sim
// does not read tells — so the headless sweep sees the mechanic at its harshest,
// the way an undisciplined party would, and a real player who respects the
// warning fares better. That gap IS the difficulty lever, the same way the Greed
// Tax and the Phylactery Verses are prod-only player-agency levers.
//
// It is the second in-combat Layer-2 mechanic, so it rides the same round-end
// seam Amendment opened. Its state (inversionActive countdown + inversionTelegraph
// warning) round-trips through CombatStatuses so a suspend/resume can't drop or
// double a pulse. A no-op outside the Seamstress's phase 2.
const (
	// seamstressPhaseTwoFrac must track The Seamstress's PhaseTwoAt in
	// postgame_zone_defs.go (0.35): the inversion only starts once she is sewn
	// deep enough into the tear. Kept local for the same reason as
	// custodianPhaseTwoFrac — the round-end hook resolves off the session, not the
	// ZoneDefinition, so the threshold isn't otherwise in reach here.
	seamstressPhaseTwoFrac = 0.35

	// inversionPulseRounds is how many rounds each inside-out pulse lasts once it
	// activates (heals sting for this many player-rounds).
	inversionPulseRounds = 2
)

// applyInversionStitch drives the Seamstress's phase-2 inversion cadence at round
// end: telegraph a pulse one round ahead, activate it for inversionPulseRounds,
// then re-telegraph the next — giving a repeating warn(1) → sting(2) rhythm the
// player can play around. No-op until the boss is in phase 2.
func applyInversionStitch(st *combatState, enemyMaxHP int) {
	// Layer-1 fight until the Seamstress is sewn into her own phase 2.
	phaseTwo := int(seamstressPhaseTwoFrac * float64(enemyMaxHP))
	if st.enemyHP > phaseTwo {
		return
	}
	// An active pulse counts down one round per round end. While it stays above
	// zero the next round's heals still sting; when it lapses this round, fall
	// through so a fresh telegraph is scheduled immediately.
	if st.inversionActive > 0 {
		st.inversionActive--
		if st.inversionActive > 0 {
			return
		}
	}
	// A telegraphed pulse activates: the room turns inside-out for the pulse.
	if st.inversionTelegraph {
		st.inversionActive = inversionPulseRounds
		st.inversionTelegraph = false
		st.events = append(st.events, CombatEvent{
			Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_stitch",
			PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
		})
		return
	}
	// Otherwise warn: the next round's pulse is one round out.
	st.inversionTelegraph = true
	st.events = append(st.events, CombatEvent{
		Round: st.round, Phase: CombatPhaseRoundEnd, Actor: "enemy", Action: "inversion_telegraph",
		PlayerHP: st.playerHP, EnemyHP: st.enemyHP,
	})
}
