package plugin

import (
	"fmt"
	"math/rand/v2"
	"sort"
	"strings"

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

// Phase 2 of expedition autopilot — auto-harvest on room entry.
// H2 extends this so `!zone advance` calls the same path for standalone
// (no-expedition) zone runs. When exp is nil the loop uses the per-run
// storage in dnd_zone_run.harvest_nodes_json and skips the
// expedition-only side effects (threat, log, region conditions,
// combat-interrupt roll).
//
// Design (Josie semantics, settled 2026-05-17):
//   - All applicable actions auto-run on every walked room. Class- and
//     kill-gated nodes filter themselves out via the existing per-resource
//     restrictions.
//   - Every node — Common through Legendary — is auto-attempted. Rarity
//     no longer pauses the walk; a whiff on a Rare node is a whiff.
//   - A node spawns with N charges. Auto-harvest swings exactly once per
//     remaining charge. Success or failure both consume a charge — no
//     retry-to-success. This is the entire point of the redesign.
//   - Noise interrupts apply threat++ and continue the per-charge loop
//     (parity with manual). Standard/Elite/Patrol combat interrupts
//     hard-stop the walk: stopEnded on death, stopHarvestCombat otherwise.
//     Standalone (exp==nil) mode skips the interrupt roll entirely —
//     zone runs already have a patrol mechanism baked into the advance
//     pipeline.
//   - No SU surcharge — manual !harvest is free; autopilot matches.
//   - Per-room footer ("+2 Iron, +1 Sage; 3 fails") + a walk-end tally
//     across all rooms.

// autoHarvestSummary holds the aggregated outcome for one room's
// auto-harvest pass. Per-room footer renders from this; expeditionCmdRun
// also folds it into a walk-wide tally for the closing block.
type autoHarvestSummary struct {
	Yields    map[string]int    // resourceID -> total qty granted
	Names     map[string]string // resourceID -> display name (capture once)
	Fails     int
	NoiseInts int
}

// autoHarvestResult is what autoHarvestRoom returns to the autopilot loop.
type autoHarvestResult struct {
	Summary     autoHarvestSummary
	CombatNarr  string // non-empty = a combat interrupt fired during the pass
	PlayerEnded bool   // true = combat killed the player
}

// allHarvestActions lists every action autopilot will try per room. Order
// matters only for footer readability — class- and gate-restricted nodes
// drop out via the existing filters.
var allHarvestActions = []HarvestAction{
	HarvestForage,
	HarvestScavenge,
	HarvestMine,
	HarvestFish,
	HarvestEssence,
	HarvestCommune,
}

// autoHarvestRoom runs a single auto-harvest pass on the player's current
// room. exp may be nil for standalone zone runs (no expedition); in that
// mode the function uses per-run storage and skips threat/interrupts/log.
// Does not advance the room; only resolves nodes already at the player's
// feet.
func (p *AdventurePlugin) autoHarvestRoom(
	userID id.UserID, run *DungeonRun, char *DnDCharacter, exp *Expedition,
) (autoHarvestResult, error) {
	res := autoHarvestResult{
		Summary: autoHarvestSummary{
			Yields: map[string]int{},
			Names:  map[string]string{},
		},
	}
	if run == nil {
		return res, nil
	}
	if !currentRoomCleared(run) {
		return res, nil
	}

	zoneID := run.ZoneID
	if exp != nil {
		zoneID = exp.ZoneID
	}

	nodeID := harvestNodeIDFor(run)
	var nodes []HarvestNode
	if exp != nil {
		nodes = loadHarvestNodes(exp, nodeID)
	} else {
		loaded, lerr := loadStandaloneHarvestNodes(run.RunID, zoneID, nodeID)
		if lerr != nil {
			return res, nil
		}
		nodes = loaded
	}
	if len(nodes) == 0 {
		return res, nil
	}

	zone, _ := getZone(zoneID)
	persistNodes := false

	for _, action := range allHarvestActions {
		if action == HarvestFish && !isFishingZone(zoneID) {
			continue
		}
		if exp != nil {
			if blockReason := zoneConditionHarvestBlock(exp, action); blockReason != "" {
				continue
			}
		}

		// One pass per action. Iterate every eligible node (not just the
		// lowest-DC one) so a room with two foragables gets two attempts.
		for i := range nodes {
			n := &nodes[i]
			if n.CurrentCharges <= 0 {
				continue
			}
			rdef, ok := FindZoneResource(zoneID, n.ResourceID)
			if !ok || rdef.Action != action {
				continue
			}
			if rdef.ClassRestrict != "" && rdef.ClassRestrict != char.Class {
				continue
			}
			if rdef.RequiresKill != "" {
				if exp == nil || !regionKillsContains(exp, rdef.RequiresKill) {
					continue
				}
			}
			if rdef.ConditionalEvent != "" {
				if exp == nil || !regionEventActive(exp, rdef.ConditionalEvent) {
					continue
				}
			}

			// Josie semantics: swing exactly once per remaining charge.
			// Each swing consumes a charge regardless of outcome.
			for n.CurrentCharges > 0 {
				// Interrupt roll, expedition-only. Standalone zone runs
				// already have a patrol mechanism in the advance pipeline.
				if exp != nil {
					interrupt, intTotal := resolveCombatInterrupt(
						exp.ThreatLevel, int(zone.Tier), char.Class, zoneID, nil)
					switch interrupt {
					case InterruptNoise:
						_ = applyThreatDelta(exp.ID, 2, "harvest noise (§4.2, autopilot)")
						res.Summary.NoiseInts++
						_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
							fmt.Sprintf("autopilot noise %s total=%d", string(action), intTotal), "")
						// fall through to the d20 roll, same as manual harvest.
					case InterruptStandard, InterruptElite, InterruptPatrol:
						body, ended := p.runHarvestInterrupt(userID, exp, run, zone, interrupt)
						res.CombatNarr = body
						res.PlayerEnded = ended
						_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "interrupt",
							fmt.Sprintf("autopilot %s kind=%d total=%d", string(action), int(interrupt), intTotal), "")
						if persistNodes {
							if exp != nil {
								_ = saveHarvestNodes(exp, nodeID, nodes)
							} else {
								_ = saveStandaloneHarvestNodes(run.RunID, zoneID, nodeID, nodes)
							}
						}
						return res, nil
					}
				}

				// Resolve the harvest attempt.
				roll := rand.IntN(20) + 1
				mod := harvestActionAbility(char, action) + classHarvestBonus(char.Class, action)
				if action == HarvestFish {
					if adv, _ := loadAdvCharacter(userID); adv != nil {
						mod += fishingSkillBonus(adv.FishingSkill)
					}
				}
				total := roll + mod
				dcDelta := 0
				if exp != nil {
					dcDelta, _ = zoneConditionHarvestDCMod(exp, action)
				}
				effectiveDC := rdef.DC + dcDelta
				outcome := resolveHarvestOutcome(total, effectiveDC)
				outcome = rangerRareCatchUpgrade(char.Class, action, outcome, nil)

				var grantedQty int
				switch outcome {
				case OutcomeNothing:
					res.Summary.Fails++
				case OutcomeCommon:
					grantedQty = 1
				case OutcomeStandard:
					grantedQty = rand.IntN(3) + 1
				case OutcomeRich:
					grantedQty = rand.IntN(3) + 1
					if bonus := pickRichBonus(zoneID, rdef.ID); bonus != nil {
						_ = grantHarvestYield(userID, *bonus, 1)
						res.Summary.Yields[bonus.ID] += 1
						res.Summary.Names[bonus.ID] = bonus.Name
					}
				}
				if grantedQty > 0 {
					_ = grantHarvestYield(userID, rdef, grantedQty)
					res.Summary.Yields[rdef.ID] += grantedQty
					res.Summary.Names[rdef.ID] = rdef.Name
				}
				// Charge is spent either way under Josie semantics.
				n.CurrentCharges--
				persistNodes = true

				if exp != nil {
					_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
						fmt.Sprintf("autopilot %s %s d20=%d total=%d dc=%d → %s",
							string(action), rdef.ID, roll, total, rdef.DC, string(outcome)), "")
				}
			}
		}
	}

	if persistNodes {
		var perr error
		if exp != nil {
			perr = saveHarvestNodes(exp, nodeID, nodes)
		} else {
			perr = saveStandaloneHarvestNodes(run.RunID, zoneID, nodeID, nodes)
		}
		if perr != nil && exp != nil {
			_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "harvest",
				fmt.Sprintf("autopilot persistence error: %v", perr), "")
		}
	}
	return res, nil
}

// renderAutoHarvestFooter formats one room's pass as a single compact
// line: "_+2 Scrap Iron, +1 Shadow Herb · 1 fail · 1 close call._".
// Empty string means there's nothing worth showing.
func renderAutoHarvestFooter(s autoHarvestSummary) string {
	if len(s.Yields) == 0 && s.Fails == 0 && s.NoiseInts == 0 {
		return ""
	}
	parts := []string{}
	// Stable ordering — sort yield keys so test output is deterministic
	// and the footer reads consistently across rooms.
	keys := make([]string, 0, len(s.Yields))
	for k := range s.Yields {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	for _, k := range keys {
		parts = append(parts, fmt.Sprintf("+%d %s", s.Yields[k], s.Names[k]))
	}
	if s.Fails > 0 {
		noun := "fail"
		if s.Fails > 1 {
			noun = "fails"
		}
		parts = append(parts, fmt.Sprintf("%d %s", s.Fails, noun))
	}
	if s.NoiseInts > 0 {
		noun := "close call"
		if s.NoiseInts > 1 {
			noun = "close calls"
		}
		parts = append(parts, fmt.Sprintf("%d %s", s.NoiseInts, noun))
	}
	return "_🎒 " + strings.Join(parts, " · ") + "._"
}

// renderWalkTally formats the cross-room cumulative haul for the walk's
// final block. Empty string when nothing was gathered.
func renderWalkTally(yields map[string]int, names map[string]string) string {
	if len(yields) == 0 {
		return ""
	}
	keys := make([]string, 0, len(yields))
	for k := range yields {
		keys = append(keys, k)
	}
	sort.Strings(keys)
	var b strings.Builder
	b.WriteString("**Walk haul:** ")
	for i, k := range keys {
		if i > 0 {
			b.WriteString(", ")
		}
		b.WriteString(fmt.Sprintf("%d %s", yields[k], names[k]))
	}
	return b.String()
}
