package plugin

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

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

// postCombatBookkeeping is the close-out every fight owes its character,
// whatever surface it was fought on: achievements, and the subclass state that
// outlives the fight (Berserker exhaustion, Grim Harvest's kill-heal).
//
// It exists because there are four close-outs — two auto-resolve
// (runDungeonCombat, runZoneCombatRoster) and two turn-based
// (finishCombatSession, finishPartyCombatSession) — and for a long time only
// the auto-resolve pair ran any of this. The same Elite kill therefore paid out
// differently depending on whether the player let it auto-resolve or fought it
// a round at a time. Route all four through here and the divergence cannot
// silently reopen.
//
// raged is whether the character's Berserker rage was active for this fight;
// mods carries the fight-start modifiers Grim Harvest reads. HP persistence is
// NOT done here — the turn-based paths already own their own HP writes.
func (p *AdventurePlugin) postCombatBookkeeping(
	userID id.UserID, dndChar *DnDCharacter, raged bool, result CombatResult, mods CombatModifiers,
) {
	p.grantCombatAchievements(userID, result)
	if err := persistDnDPostCombatSubclass(dndChar, raged, result, mods); err != nil {
		slog.Error("dnd: post-combat subclass persist", "user", userID, "err", err)
	}
}

// grantCombatAchievements checks combat results for achievement-worthy moments.
func (p *AdventurePlugin) grantCombatAchievements(userID id.UserID, result CombatResult) {
	if p.achievements == nil {
		return
	}
	if result.PlayerWon && result.NearDeath {
		p.achievements.GrantAchievement(userID, "combat_near_death")
	}
	if result.SniperKilled {
		p.achievements.GrantAchievement(userID, "combat_sniper_kill")
	}
	if result.MistyHealed {
		p.achievements.GrantAchievement(userID, "combat_misty_clutch")
	}
	for _, ev := range result.Events {
		if ev.Action == "pet_whiff" {
			p.achievements.GrantAchievement(userID, "combat_pet_save")
			break
		}
	}
	if checkDeathSaveEvent(result.Events) {
		p.achievements.GrantAchievement(userID, "combat_death_save")
	}
	for _, ev := range result.Events {
		if ev.Action == "use_consumable" {
			p.achievements.GrantAchievement(userID, "combat_consumable_used")
			break
		}
	}
}

// runDungeonCombat executes dungeon combat using the new simulation engine.
func (p *AdventurePlugin) runDungeonCombat(
	userID id.UserID,
	char *AdventureCharacter,
	equip map[EquipmentSlot]*AdvEquipment,
	loc *AdvLocation,
	bonuses *AdvBonusSummary,
	inPenaltyZone bool,
) CombatResult {
	chatLvl := p.chatLevel(userID)
	hasGrudge := char.GrudgeLocation == loc.Name

	playerStats, playerMods := DerivePlayerStats(char, equip, bonuses, chatLvl, char.CurrentStreak, hasGrudge)

	// Penalty zone Attack/Defense reduction (mirrors +5% death, -15% success
	// from old system). The MaxHP penalty is applied below, after
	// applyDnDPlayerLayer reseats MaxHP onto the dnd HP scale.
	if inPenaltyZone {
		playerStats.Attack = int(float64(playerStats.Attack) * 0.85)
		playerStats.Defense = int(float64(playerStats.Defense) * 0.85)
	}

	// Load consumables from inventory and auto-select
	consumables := p.loadConsumableInventory(userID)
	enemyStats, enemyMods := DeriveDungeonMonsterStats(loc)

	// All combat is D&D. Auto-migrate if needed.
	dndChar, _, err := ensureDnDCharacterForCombat(userID, char)
	if err != nil {
		slog.Error("dnd: ensureDnDCharacterForCombat (dungeon) failed", "user", userID, "err", err)
		return CombatResult{}
	}
	applyDnDPlayerLayer(&playerStats, dndChar)
	applyDnDEquipmentLayer(&playerStats, dndChar, equip)
	applyDnDHPScaling(&playerStats, dndChar)
	if inPenaltyZone {
		playerStats.MaxHP = int(float64(playerStats.MaxHP) * 0.90)
		if playerStats.StartHP > 0 {
			playerStats.StartHP = int(float64(playerStats.StartHP) * 0.90)
		}
	}
	applyClassPassives(&playerStats, &playerMods, dndChar)
	applyRacePassives(&playerStats, &playerMods, dndChar)
	applySubclassPassives(&playerStats, &playerMods, dndChar)
	applyMagicItemEffects(&playerStats, &playerMods, userID)
	trySimAutoArm(dndChar)
	if firedName, fired := armAbilityForFight(dndChar, &playerMods); fired {
		slog.Info("dnd: armed ability fired", "user", userID, "ability", firedName)
	}
	applyDnDDungeonMonsterLayer(&enemyStats, loc.Tier)
	applyPendingCast(userID, dndChar, &playerStats, &playerMods, &enemyStats)

	// Auto-consumable: panic heal only (see dnd_boss_consumables.go).
	setupAutoHealFromInventory(consumables, &playerMods)

	// Dungeon monsters T3+ can have abilities
	var ability *MonsterAbility
	switch {
	case loc.Tier >= 5:
		ability = &MonsterAbility{Name: "Abyssal Wrath", Phase: "any", ProcChance: 0.30, Effect: "enrage"}
	case loc.Tier == 4:
		ability = &MonsterAbility{Name: "Dark Curse", Phase: "clash", ProcChance: 0.25, Effect: "poison"}
	case loc.Tier == 3:
		ability = &MonsterAbility{Name: "Stone Skin", Phase: "opening", ProcChance: 0.20, Effect: "armor_break"}
	}

	displayName, _ := loadDisplayName(userID)
	player := Combatant{
		Name:     displayName,
		Stats:    playerStats,
		Mods:     playerMods,
		IsPlayer: true,
	}
	enemy := Combatant{
		Name:    loc.Denizens,
		Stats:   enemyStats,
		Mods:    enemyMods,
		Ability: ability,
	}

	result := SimulateCombat(player, enemy, dungeonCombatPhases)
	dumpCombatEventsIfDebug(fmt.Sprintf("dungeon:%s vs %s", loc.Name, displayName), result)

	// Remove the actual heal items consumed during combat. No more
	// burning Coal Bombs / Ink Vials on chump fights — those stay put
	// until a player-driven use command lands.
	consumeFiredHealingItems(userID, countHealEventsFired(result))

	persistDnDHPAfterCombat(userID, result.PlayerEndHP)
	p.postCombatBookkeeping(userID, dndChar, playerMods.BerserkerRage, result, playerMods)

	if xp := dungeonCombatXP(result, loc.Tier); xp > 0 {
		if _, err := p.grantDnDXP(userID, xp); err != nil {
			slog.Error("dnd: grantDnDXP dungeon", "user", userID, "err", err)
		}
	}
	_ = dndChar

	return result
}

// loadCombatBonuses computes the AdvBonusSummary for combat stat derivation.
func (p *AdventurePlugin) loadCombatBonuses(userID id.UserID, char *AdventureCharacter) *AdvBonusSummary {
	treasures, _ := loadAdvTreasureBonuses(userID)
	buffs, _ := loadAdvActiveBuffs(userID)
	hasGrudge := char.GrudgeLocation != ""
	b := computeAdvBonuses(treasures, buffs, char.CurrentStreak, hasGrudge)
	// N7/B2 — renown pays out on loot/XP here too. Safe despite this feeding
	// combat_stats: applyRenownBonuses touches only LootQuality/XPMultiplier,
	// which combat stat derivation never reads (see adventure_renown.go).
	applyRenownBonuses(b, char.RenownLevel())
	return b
}

// loadConsumableInventory scans inventory for items matching consumable definitions.
// If the player has sufficient foraging level, auto-crafts consumables from ingredients first.
func (p *AdventurePlugin) loadConsumableInventory(userID id.UserID) []ConsumableItem {
	items, err := loadAdvInventory(userID)
	if err != nil {
		return nil
	}

	// Auto-craft if player has foraging level 10+
	char, err := loadAdvCharacter(userID)
	if err == nil && char.ForagingSkill >= 10 {
		craftResults, updatedItems := autoCraftConsumables(userID, items, char.ForagingSkill, homeWorkshopCraftBonus(char.HouseTier))
		if len(craftResults) > 0 {
			items = updatedItems
			for _, cr := range craftResults {
				if cr.Success {
					slog.Info("crafting: auto-crafted", "user", userID, "item", cr.Recipe.Result)
					if p.achievements != nil {
						p.achievements.GrantAchievement(userID, "adv_first_craft")
						if cr.Recipe.Tier >= 5 {
							p.achievements.GrantAchievement(userID, "adv_craft_t5")
						}
					}
				} else {
					slog.Info("crafting: failed", "user", userID, "item", cr.Recipe.Result)
				}
			}
			// Store results for narrative rendering
			p.storeCraftResults(userID, craftResults)
		}
	}

	var consumables []ConsumableItem
	for _, item := range items {
		if item.Type != "consumable" {
			continue
		}
		def := consumableDefByName(item.Name)
		if def == nil {
			continue
		}
		consumables = append(consumables, ConsumableItem{
			InventoryID: item.ID,
			Def:         def,
		})
	}
	return consumables
}

func (p *AdventurePlugin) storeCraftResults(userID id.UserID, results []CraftResult) {
	p.craftResults.Store(string(userID), results)
}

func (p *AdventurePlugin) popCraftResults(userID id.UserID) []CraftResult {
	val, ok := p.craftResults.LoadAndDelete(string(userID))
	if !ok {
		return nil
	}
	return val.([]CraftResult)
}

// prependCraftNarrative adds crafting results to the first combat phase message.
func (p *AdventurePlugin) prependCraftNarrative(userID id.UserID, messages []string) []string {
	results := p.popCraftResults(userID)
	if len(results) == 0 || len(messages) == 0 {
		return messages
	}

	var lines []string
	for _, cr := range results {
		if cr.Success {
			lines = append(lines, fmt.Sprintf("🧪 _Crafted **%s** from foraged ingredients._", cr.Recipe.Result))
		} else {
			lines = append(lines, fmt.Sprintf("💨 _Failed to craft %s — ingredients lost._", cr.Recipe.Result))
		}
	}

	prefix := strings.Join(lines, "\n") + "\n\n"
	messages[0] = prefix + messages[0]
	return messages
}

// sendCombatMessages sends phase messages with delays, then a final message.
// Runs in a goroutine so it doesn't block the message handler.
// Returns a channel that is closed when all messages have been sent.
func (p *AdventurePlugin) sendCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
	return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 5, 4) // 5–8s
}

// sendZoneCombatMessages is the zone/expedition variant — same staging as
// arena, but tighter pacing (2–3s) so dungeon advances feel snappier.
//
// Contract: returns a done channel that closes after the final DM has
// been sent. Callers that need to chain post-flush work (death follow-ups,
// hospital ads, tier countdowns) MUST consume it. Callers in the middle
// of a command-handler flow that have no post-flush work should explicitly
// discard with `_ = ...` — blocking on flush would stall the handler
// behind the 2–3s/message pacing, which is the whole point of streaming.
func (p *AdventurePlugin) sendZoneCombatMessages(userID id.UserID, phaseMessages []string, finalMessage string) <-chan struct{} {
	return p.sendCombatMessagesWithDelay(userID, phaseMessages, finalMessage, 2, 2) // 2–3s
}

// sendCombatMessagesWithDelay is the shared streamer. base/jitter define
// the per-message delay window in seconds: delay = base + rand.IntN(jitter).
func (p *AdventurePlugin) sendCombatMessagesWithDelay(userID id.UserID, phaseMessages []string, finalMessage string, base, jitter int) <-chan struct{} {
	done := make(chan struct{})
	go func() {
		defer close(done)
		for _, msg := range phaseMessages {
			_ = p.SendDM(userID, msg)
			delay := base
			if jitter > 0 {
				delay += rand.IntN(jitter)
			}
			time.Sleep(time.Duration(delay) * time.Second)
		}
		_ = p.SendDM(userID, finalMessage)
	}()
	return done
}

// XPBonusParams holds inputs for the shared XP bonus pipeline.
type XPBonusParams struct {
	BaseXP        int
	NearDeath     bool
	BonusMult     float64 // from AdvBonusSummary.XPMultiplier (percentage, e.g. 15 = +15%)
	Ironclad      bool
	OverlevelMult float64 // 1.0 = no penalty
}

// XPResult holds the final XP and a human-readable breakdown of bonuses applied.
type XPResult struct {
	Total     int
	Breakdown string // e.g. "+15% near-death, +5% ironclad" — empty if no bonuses
}

// applyXPBonuses applies near-death, bonus multiplier, ironclad, and overlevel
// penalties to a base XP value. Used by both dungeon and adventure paths.
func applyXPBonuses(p XPBonusParams) XPResult {
	xp := p.BaseXP
	var parts []string
	if p.NearDeath {
		xp = int(float64(xp) * 1.15)
		parts = append(parts, "+15% near-death")
	}
	if p.BonusMult != 0 {
		xp = int(float64(xp) * (1 + p.BonusMult/100))
		parts = append(parts, fmt.Sprintf("+%.0f%% bonus", p.BonusMult))
	}
	if p.Ironclad {
		xp = int(float64(xp) * 1.05)
		parts = append(parts, "+5% ironclad")
	}
	if p.OverlevelMult < 1.0 {
		xp = max(1, int(float64(xp)*p.OverlevelMult))
		penalty := int((1.0 - p.OverlevelMult) * 100)
		parts = append(parts, fmt.Sprintf("-%d%% overlevel", penalty))
	}
	breakdown := ""
	if len(parts) > 0 {
		breakdown = strings.Join(parts, ", ")
	}
	return XPResult{Total: xp, Breakdown: breakdown}
}

// DeathTransitionParams holds inputs for the shared death state machine.
type DeathTransitionParams struct {
	Char           *AdventureCharacter
	Equip          map[EquipmentSlot]*AdvEquipment
	Pet            PetState // pet state for ditch-recovery roll; zero value disables
	ChatLevel      int
	Location       string // set as GrudgeLocation; empty = don't set
	Source         string // death source: "adventure" | "arena" — recorded on Kill()
	DeathLocation  string // human-readable death location for the daily report; falls back to Location
	AllowPardon    bool   // chat level pardon (adventure only)
	AllowSovereign bool   // probability-band Sovereign reprieve (non-engine path)
	EngineSaved    bool   // combat engine used Sovereign death save
}

// DeathTransitionResult describes what happened during the death transition.
type DeathTransitionResult struct {
	Pardoned     bool
	Reprieved    bool // Sovereign reprieve (non-engine)
	PetRecovered bool
	Died         bool
}

// transitionDeath runs the shared death state machine: pardon → engine cooldown →
// Sovereign reprieve → kill + pet ditch recovery.
func transitionDeath(p DeathTransitionParams) DeathTransitionResult {
	var r DeathTransitionResult

	if p.AllowPardon && p.ChatLevel >= 20 && p.Char.PardonAvailable() && simFloat64() < 0.33 {
		r.Pardoned = true
		now := time.Now().UTC()
		p.Char.LastPardonUsed = &now
		if p.Location != "" {
			p.Char.GrudgeLocation = p.Location
		}
		return r
	}

	if p.EngineSaved {
		now := time.Now().UTC()
		p.Char.DeathReprieveLast = &now
	}

	if p.AllowSovereign && advEquippedArenaSets(p.Equip)["sovereign"] && p.Char.DeathReprieveAvailable() {
		r.Reprieved = true
		now := time.Now().UTC()
		p.Char.DeathReprieveLast = &now
		if p.Location != "" {
			p.Char.GrudgeLocation = p.Location
		}
		for _, slot := range allSlots {
			if eq, ok := p.Equip[slot]; ok {
				eq.Condition = 1
			}
		}
		return r
	}

	deathLoc := p.DeathLocation
	if deathLoc == "" {
		deathLoc = p.Location
	}
	p.Char.Kill(p.Source, deathLoc)
	if p.Location != "" {
		p.Char.GrudgeLocation = p.Location
	}
	r.Died = true

	if petRollDitchRecovery(p.Pet) && p.Char.DeadUntil != nil {
		reduced := time.Now().UTC().Add(petDitchRecoveryTime(p.Pet.Level))
		p.Char.DeadUntil = &reduced
		r.PetRecovered = true
	}

	return r
}

// checkDeathSaveEvent scans combat events for a Sovereign death save.
func checkDeathSaveEvent(events []CombatEvent) bool {
	for _, ev := range events {
		if ev.Action == "death_save" {
			return true
		}
	}
	return false
}

// injectConsumableEvents prepends use_consumable events so the narrative shows which items were consumed.
// If items were available but skipped (trivial threat), a skip event is injected instead.
//
// Uses PlayerEntryHP / EnemyEntryHP — the actual HP each side entered combat
// with — so the pre-combat HP line reflects wounded carry-over honestly. Was
// using PlayerStartHP (= MaxHP), which made wounded entries display "47/47"
// and made any HP loss in opening look like the player took silent damage.
func injectConsumableEvents(result CombatResult, selected []ConsumableItem, inventorySize int) CombatResult {
	entryHP := result.PlayerEntryHP
	if entryHP == 0 {
		entryHP = result.PlayerStartHP
	}
	enemyEntryHP := result.EnemyEntryHP
	if enemyEntryHP == 0 {
		enemyEntryHP = result.EnemyStartHP
	}
	if len(selected) == 0 {
		if inventorySize > 0 {
			result.Events = append([]CombatEvent{{
				Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "consumable_skip",
				PlayerHP: entryHP, EnemyHP: enemyEntryHP,
			}}, result.Events...)
		}
		return result
	}
	var events []CombatEvent
	for _, c := range selected {
		events = append(events, CombatEvent{
			Round: 0, Phase: "pre_combat", Actor: "consumable", Action: "use_consumable",
			PlayerHP: entryHP, EnemyHP: enemyEntryHP,
			Desc: c.Def.Name,
		})
	}
	result.Events = append(events, result.Events...)
	return result
}

// combatResultToOutcome maps a CombatResult to an AdvOutcomeType for dungeon integration.
func combatResultToOutcome(result CombatResult) AdvOutcomeType {
	if !result.PlayerWon {
		return AdvOutcomeDeath
	}
	// Won with >85% HP remaining → exceptional
	remainingPct := float64(result.PlayerEndHP) / float64(max(1, result.PlayerStartHP))
	if remainingPct > 0.85 {
		return AdvOutcomeExceptional
	}
	return AdvOutcomeSuccess
}

// combatDegradation derives equipment damage from actual combat events.
func combatDegradation(result CombatResult, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
	damage := make(map[EquipmentSlot]int)

	if !result.PlayerWon {
		// Death: heavy degradation
		for _, slot := range allSlots {
			damage[slot] = 20
		}
		damage[SlotWeapon] = 30
		damage[SlotArmor] = 30
		return applyDegradationModifiers(damage, equip)
	}

	// Derive from events: each enemy hit on player → armor/helmet wear
	// Each player hit → weapon wear, environmental → boots
	for _, ev := range result.Events {
		switch {
		case ev.Actor == "enemy" && (ev.Action == "hit" || ev.Action == "crit"):
			damage[SlotArmor] += 1 + rand.IntN(3)
			damage[SlotHelmet] += 1 + rand.IntN(2)
		case ev.Actor == "enemy" && ev.Action == "cleave":
			damage[SlotArmor] += 2 + rand.IntN(3)
		case ev.Actor == "enemy" && (ev.Action == "bonus_damage" || ev.Action == "aoe" || ev.Action == "execute" || ev.Action == "retaliate" || ev.Action == "max_hp_drain"):
			damage[SlotArmor] += 1 + rand.IntN(2)
		case ev.Actor == "enemy" && ev.Action == "lifesteal":
			damage[SlotArmor] += 1 + rand.IntN(2)
		case ev.Actor == "player" && (ev.Action == "hit" || ev.Action == "crit"):
			damage[SlotWeapon] += 1
		case ev.Actor == "environment":
			damage[SlotBoots] += 1 + rand.IntN(2)
		case ev.Action == "armor_break":
			damage[SlotArmor] += 3
		}
	}

	return applyDegradationModifiers(damage, equip)
}

func applyDegradationModifiers(damage map[EquipmentSlot]int, equip map[EquipmentSlot]*AdvEquipment) map[EquipmentSlot]int {
	tempered := advEquippedArenaSets(equip)["tempered"]
	for slot, dmg := range damage {
		eq, ok := equip[slot]
		if !ok {
			continue
		}
		if tempered {
			dmg = int(float64(dmg) * 0.75)
		}
		if eq.ActionsUsed >= 20 {
			dmg = int(float64(dmg) * 0.8)
		}
		if dmg < 0 {
			dmg = 0
		}
		damage[slot] = dmg
		eq.Condition -= dmg
		if eq.Condition < 0 {
			eq.Condition = 0
		}
	}
	return damage
}

// resolveDungeonAction runs a dungeon encounter through the combat engine
// and returns an AdvActionResult compatible with the existing adventure flow.
func (p *AdventurePlugin) resolveDungeonAction(
	char *AdventureCharacter,
	equip map[EquipmentSlot]*AdvEquipment,
	loc *AdvLocation,
	bonuses *AdvBonusSummary,
	inPenaltyZone bool,
) *AdvActionResult {
	result := &AdvActionResult{
		Location: loc,
		XPSkill:  "combat",
	}

	combat := p.runDungeonCombat(char.UserID, char, equip, loc, bonuses, inPenaltyZone)
	result.CombatLog = &combat
	result.Outcome = combatResultToOutcome(combat)

	// Misty condition repair (post-combat, same as arena)
	now := time.Now().UTC()
	if char.MistyBuffExpires != nil && now.Before(*char.MistyBuffExpires) {
		if rand.Float64() < 0.20 {
			npcRepairMostDamaged(char.UserID, equip, 5)
		}
	}
	result.NearDeath = combat.NearDeath

	// Overlevel penalty
	skillLevel := advEffectiveSkill(char, loc.Activity, bonuses)
	overlevelMult := advOverlevelMultiplier(skillLevel, loc)

	// Loot on success/exceptional
	if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
		result.LootItems = generateAdvLoot(loc, result.Outcome == AdvOutcomeExceptional, bonuses.LootQuality)
		if overlevelMult < 1.0 {
			for i := range result.LootItems {
				result.LootItems[i].Value = max(1, int64(float64(result.LootItems[i].Value)*overlevelMult))
			}
		}
		for _, item := range result.LootItems {
			result.TotalLootValue += item.Value
		}
	}

	// XP calculation
	xp := advXPForOutcome(loc.Activity, loc.Tier, result.Outcome)
	if result.Outcome == AdvOutcomeSuccess || result.Outcome == AdvOutcomeExceptional {
		xp = advXPTable[loc.Activity][loc.Tier].Success
		if result.Outcome == AdvOutcomeExceptional {
			xp = advXPTable[loc.Activity][loc.Tier].Exceptional
		}
	}
	xpResult := applyXPBonuses(XPBonusParams{
		BaseXP:        xp,
		NearDeath:     result.NearDeath,
		BonusMult:     bonuses.XPMultiplier,
		Ironclad:      advEquippedArenaSets(equip)["ironclad"],
		OverlevelMult: overlevelMult,
	})
	result.XPGained = xpResult.Total
	result.XPBreakdown = xpResult.Breakdown

	// Equipment degradation from combat events
	result.EquipDamage = combatDegradation(combat, equip)
	result.EquipBroken = advCheckBrokenEquipment(equip)

	// Increment actions_used for equipment mastery
	for _, eq := range equip {
		eq.ActionsUsed++
	}

	return result
}
