package plugin

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

	"gogobee/internal/flavor"
	"maunium.net/go/mautrix/id"
)

// Phase 12 E1e — basic camp system (rough + standard).
// Spec: gogobee_expedition_system.md §5. Fortified and Base camps are
// deliverables of E2 (Threat Clock & Night Phase) and E4 (Multi-Region)
// respectively — !camp explicitly rejects those types here so the
// surface is forward-compatible.

const (
	CampTypeRough     = "rough"
	CampTypeStandard  = "standard"
	CampTypeFortified = "fortified"
	CampTypeBase      = "base"
)

// campSupplyCost — extra SU consumed at pitch time (§5.1).
var campSupplyCost = map[string]float32{
	CampTypeRough:     0.5,
	CampTypeStandard:  1.0,
	CampTypeFortified: 2.0,
	CampTypeBase:      3.0,
}

// !camp <type> — establish a camp on the current expedition.
//
// Validation (§5.2):
//   - cannot camp in an active-enemy room
//   - cannot camp in a trap room
//   - cannot camp in a boss room
//   - non-cleared room ⇒ forced rough camp regardless of intent
//
// Until !advance is wired into the expedition layer (a later phase),
// the player is treated as standing at the entry room (cleared by
// definition), so all rooms read as cleared and camp validation only
// rejects the never-applicable boss/trap/active cases.

func (p *AdventurePlugin) handleCampCmd(ctx MessageContext, args string) error {
	userMu := p.advUserLock(ctx.Sender)
	userMu.Lock()
	defer userMu.Unlock()

	c, err := LoadDnDCharacter(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't load your character: "+err.Error())
	}
	if c == nil || c.PendingSetup {
		return p.SendDM(ctx.Sender,
			"No Adv 2.0 character yet — run `!setup` first.")
	}

	exp, isLeader, err := activeExpeditionFor(ctx.Sender)
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't read expedition state: "+err.Error())
	}
	if exp == nil {
		return p.SendDM(ctx.Sender,
			"No active expedition. `!camp` only works during an expedition. Use `!expedition start` first.")
	}

	args = strings.TrimSpace(strings.ToLower(args))
	requested, rest := splitFirstWord(args)
	if requested == "" {
		return p.SendDM(ctx.Sender, campHelpText(exp))
	}
	// Bare `!camp` reads the party's tent; pitching and striking it are the
	// leader's, since the camp is a property of the expedition everyone rides.
	if !isLeader {
		return p.SendDM(ctx.Sender,
			"Your party leader pitches the camp. `!camp` on its own shows what's already up.")
	}
	if requested == "break" || requested == "down" || requested == "leave" {
		return p.campBreak(ctx, exp)
	}
	switch requested {
	case "rough", "standard":
		// allowed in E1e
	case "fortified":
		if !exp.BossDefeated {
			return p.SendDM(ctx.Sender,
				"Fortified camps require a defeated zone boss. Clear the zone first.")
		}
	case "base":
		// E4d: §11.1 — base camps unlock per region after the region
		// boss is defeated, and only at base-camp-eligible sites.
		if !IsMultiRegionZone(exp.ZoneID) {
			return p.SendDM(ctx.Sender,
				"Base camps are a multi-region waypoint feature — only available in Tier 4–5 zones (Underdark / Dragon's Lair / Abyss Portal).")
		}
		region, ok := CurrentRegion(exp)
		if !ok {
			return p.SendDM(ctx.Sender,
				"Couldn't resolve your current region — try `!region` first.")
		}
		if !region.BaseCampSite {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"**%s** isn't a base-camp-eligible site. Look for a region whose boss has fallen and the geography is defensible.",
				region.Name))
		}
		if !IsRegionCleared(exp, region.ID) {
			return p.SendDM(ctx.Sender, fmt.Sprintf(
				"You can pitch a base camp in **%s** only after defeating its region boss (%s).",
				region.Name, region.RegionBoss))
		}
	default:
		return p.SendDM(ctx.Sender,
			"Unknown camp type. Try `rough` (any location) or `standard` (cleared rooms).")
	}
	_ = rest

	return p.campPitch(ctx, exp, requested)
}

func campHelpText(exp *Expedition) string {
	var b strings.Builder
	b.WriteString("**!camp <type>** — _override._ Autopilot pitches camp at night automatically; reach for this only to force a rest right now.\n\n")
	b.WriteString("`!camp rough` — partial rest (any location, +0.5 SU, high night risk)\n")
	b.WriteString("`!camp standard` — full long rest (cleared room, +1 SU, medium risk)\n")
	b.WriteString("`!camp fortified` — long rest + bonus (zone boss defeated, +2 SU, low risk)\n")
	b.WriteString("`!camp base` — persistent waypoint (region-boss-cleared base-camp site, +3 SU, very low risk)\n")
	b.WriteString("`!camp break` — break camp\n\n")
	if exp.Camp != nil && exp.Camp.Active {
		b.WriteString(fmt.Sprintf("_Currently camped: **%s** (room %d)._",
			exp.Camp.Type, exp.Camp.RoomIndex+1))
	}
	return b.String()
}

func (p *AdventurePlugin) campPitch(ctx MessageContext, exp *Expedition, kind string) error {
	if exp.Camp != nil && exp.Camp.Active {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"You're already camped (**%s**). `!camp break` first.", exp.Camp.Type))
	}

	// Room-context validation. Once !advance is wired into expeditions
	// this resolves to the actual current room from the linked DungeonRun.
	// For now, the entry room (always cleared) is the assumed location.
	cleared, problem := campLocationCheck(exp)
	if problem != "" {
		return p.SendDM(ctx.Sender, problem)
	}
	if !cleared && kind == CampTypeStandard {
		// §5.2: standard camp requires a cleared room. Reject explicitly
		// rather than silently downgrading — the player should make the call.
		return p.SendDM(ctx.Sender,
			"Standard camp needs a cleared room. This room isn't cleared yet — clear it first, or `!camp rough` for a partial rest here.")
	}

	cost, ok := campSupplyCost[kind]
	if !ok {
		cost = 0.5
	}
	if exp.Supplies.Current < cost {
		return p.SendDM(ctx.Sender, fmt.Sprintf(
			"Not enough supplies for that camp (need **%.1f SU**, have **%.1f SU**). Try `!camp rough` or break camp and conserve.",
			cost, exp.Supplies.Current))
	}

	// D2-b: event-anchored manual !camp counts as the night camp if it's
	// the first camp since the last rollover. Burn supplies *before* the
	// camp cost so the burn lands against pre-pitch supplies (matches the
	// legacy morning-burn ordering), then pitch, then drift.
	nightCamp := false
	var nightBurn float32
	var nightRoll nightRolloverResult
	if isEventAnchored(exp) {
		var since time.Duration
		if exp.LastBriefingAt != nil {
			since = time.Now().UTC().Sub(*exp.LastBriefingAt)
		} else {
			since = time.Now().UTC().Sub(exp.StartDate)
		}
		if since >= nightCampWindow {
			nightCamp = true
			burn, err := p.nightRolloverBurn(exp)
			if err != nil {
				return p.SendDM(ctx.Sender, "Couldn't burn night supplies: "+err.Error())
			}
			nightBurn = burn
		}
	}

	// The affordability check above ran against `exp` as it was read, and
	// nightRolloverBurn may have moved the pool since. Deduct against the pool
	// as it stands — the same arithmetic, just not onto a stale snapshot.
	pooled, err := p.withExpeditionSupplies(exp.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
		next := fresh.Supplies
		next.Current -= cost
		return next, nil
	})
	if err != nil {
		return p.SendDM(ctx.Sender, "Couldn't deduct supplies: "+err.Error())
	}
	exp.Supplies = pooled
	camp := &CampState{
		Active:        true,
		Type:          kind,
		RoomIndex:     campCurrentRoomIndex(exp),
		EstablishedAt: time.Now().UTC(),
		NightEvents:   []string{},
	}
	if err := updateCamp(exp.ID, camp); err != nil {
		return p.SendDM(ctx.Sender, "Couldn't pitch camp: "+err.Error())
	}
	exp.Camp = camp

	// Apply the long-rest effects immediately so the player isn't waiting
	// until the next 06:00 UTC briefing for HP and spell slots to come
	// back. The flag tells processOvernightCamp not to re-apply at briefing.
	restSummary := applyCampRest(exp, kind)
	if nightCamp {
		nightRoll = p.nightRolloverDrift(exp, time.Now().UTC())
		nightRoll.Burn = nightBurn
	}
	camp.RestApplied = true
	if err := updateCamp(exp.ID, camp); err != nil {
		slog.Warn("camp: mark rest applied", "expedition", exp.ID, "err", err)
	}

	// E4d: pick the BaseCampEstablished pool for base camps; otherwise
	// the generic camp pool. Both already handle [N] day interpolation
	// for the base-camp first-pitch message.
	var line string
	if kind == CampTypeBase {
		line = flavor.Pick(flavor.BaseCampEstablished)
		line = strings.ReplaceAll(line, "[N]", fmt.Sprintf("%d", exp.CurrentDay))
	} else {
		line = flavor.Pick(flavor.CampEstablished)
	}
	_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "rest",
		fmt.Sprintf("camp pitched (%s) — %.1f SU consumed", kind, cost), line)

	// Mark the region as a persistent base-camp waypoint on first pitch.
	if kind == CampTypeBase {
		if region, ok := CurrentRegion(exp); ok {
			if _, err := addRegionListEntry(exp, regionStateBaseCampKey, region.ID); err != nil {
				return p.SendDM(ctx.Sender, "Couldn't record base camp waypoint: "+err.Error())
			}
		}
	}

	var b strings.Builder
	b.WriteString(fmt.Sprintf("⛺ **Camp established — %s.**\n", kind))
	b.WriteString(fmt.Sprintf("Supplies: %.1f / %.1f SU (−%.1f).\n",
		exp.Supplies.Current, exp.Supplies.Max, cost))
	if line != "" {
		b.WriteString("\n" + line + "\n")
	}
	if restSummary != "" {
		b.WriteString("\n" + restSummary + "\n")
	}
	switch kind {
	case CampTypeBase:
		b.WriteString("\n_Base camp — **waypoint persisted**. Camp here again at no eligibility cost on later returns._")
	}
	if nightCamp {
		b.WriteString(fmt.Sprintf("\n\n🌙 **Day %d.** _Overnight burn: %.1f SU._\n", exp.CurrentDay, nightRoll.Burn))
		for _, tl := range nightRoll.TemporalLines {
			b.WriteString("\n🌀 " + tl + "\n")
		}
		for _, ml := range nightRoll.MilestoneLines {
			b.WriteString("\n" + ml)
		}
	}
	return p.SendDM(ctx.Sender, b.String())
}

// applyCampRest runs the long-rest effects (HP/spells/threat/heat) for the
// given camp kind and returns a player-facing summary. Shared by camp pitch
// (immediate rest) and overnight rollover (legacy deferred path). Does NOT
// break the camp — callers decide that.
func applyCampRest(e *Expedition, kind string) string {
	uid := id.UserID(e.UserID)
	c, _ := LoadDnDCharacter(uid)
	if c == nil {
		return ""
	}
	// Babysit safe-rest: an active subscription promotes a Standard camp
	// to Fortified for rest purposes (no need for boss-cleared arrangements).
	// Rough/Base are unchanged — Rough still implies no shelter, and Base
	// already exceeds Fortified.
	babysitUpgraded := false
	if kind == CampTypeStandard && BabysitSafeRest(uid) {
		kind = CampTypeFortified
		babysitUpgraded = true
	}
	prevHP := c.HPCurrent
	bonusHP := 0

	switch kind {
	case CampTypeRough:
		half := c.HPMax / 2
		if c.HPCurrent < half {
			c.HPCurrent = half
		}
	case CampTypeStandard:
		c.HPCurrent = c.HPMax
		c.TempHP = 0
		if c.Exhaustion > 0 {
			c.Exhaustion--
		}
	case CampTypeFortified, CampTypeBase:
		c.HPCurrent = c.HPMax
		c.TempHP = 0
		if c.Exhaustion > 0 {
			c.Exhaustion--
		}
		bonusHP = 1 + rand.IntN(6)
		c.HPCurrent += bonusHP
		if c.HPCurrent > c.HPMax {
			c.HPCurrent = c.HPMax
		}
	}
	_ = SaveDnDCharacter(c)
	if kind == CampTypeStandard || kind == CampTypeFortified || kind == CampTypeBase {
		_ = refreshAllResources(uid)
		_ = refreshSpellSlots(uid)
		// The companion sleeps at the same fire. His slots and his wounds are not
		// dnd_spell_slots / dnd_character rows — he has none — so nothing above
		// reaches him, and without these two his pool and his body would only ever
		// go down. Camp is where a caster gets their slots back and a body gets
		// patched up, and he is at the camp.
		_ = refreshCompanionSlots(e.ID)
		_ = refreshCompanionHP(e.ID)
		_ = ReplenishHarvestNodes(e)
	}

	// Threat reduction (§8.1: -5 for fortified long rest).
	if kind == CampTypeFortified || kind == CampTypeBase {
		_ = applyThreatDelta(e.ID, -5, "long rest in fortified camp")
	}

	// §7.3 Underforge: fortified rest drops heat stacks by 2.
	heatReduced := 0
	if (kind == CampTypeFortified || kind == CampTypeBase) && e.ZoneID == ZoneUnderforge {
		before := e.TemporalStack
		after := reduceUnderforgeHeat(e)
		heatReduced = before - after
	}

	switch kind {
	case CampTypeRough:
		if c.HPCurrent > prevHP {
			return fmt.Sprintf("Rough rest: HP %d → %d.", prevHP, c.HPCurrent)
		}
		return "Rough rest: no HP gain (already above the half-HP floor)."
	case CampTypeStandard:
		return fmt.Sprintf("Long rest: HP %d → %d, spell slots & resources refreshed.", prevHP, c.HPCurrent)
	case CampTypeFortified, CampTypeBase:
		label := "Fortified rest"
		if babysitUpgraded {
			label = "Fortified rest (babysitter watching the camp)"
		}
		summary := fmt.Sprintf("%s: HP %d → %d (+1d6 = %d bonus); threat clock −5; resources refreshed.",
			label, prevHP, c.HPCurrent, bonusHP)
		if heatReduced > 0 {
			summary += fmt.Sprintf(" Heat stacks −%d.", heatReduced)
		}
		return summary
	}
	return ""
}

// processOvernightCamp handles the briefing-time half of the camp lifecycle:
// auto-breaks the camp, and applies the long rest only if it wasn't already
// applied at pitch time. Returns a one-line summary for the briefing body, or
// "" if there was nothing to do.
func processOvernightCamp(e *Expedition) string {
	if e.Camp == nil || !e.Camp.Active {
		return ""
	}
	kind := e.Camp.Type
	alreadyApplied := e.Camp.RestApplied
	_ = updateCamp(e.ID, nil)
	e.Camp = nil
	if alreadyApplied {
		return ""
	}
	return applyCampRest(e, kind)
}

func (p *AdventurePlugin) campBreak(ctx MessageContext, exp *Expedition) error {
	if exp.Camp == nil || !exp.Camp.Active {
		return p.SendDM(ctx.Sender, "No camp to break.")
	}
	if err := updateCamp(exp.ID, nil); err != nil {
		return p.SendDM(ctx.Sender, "Couldn't break camp: "+err.Error())
	}
	_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp broken", "")
	return p.SendDM(ctx.Sender, "Camp struck. The dungeon awaits.")
}

// campLocationCheck enforces §5.2 placement rules. Returns (clearedRoom, problem).
// Until !advance wires into expeditions, the player is at the entry room
// (always cleared, never trap/boss/active-enemy) so this currently always
// returns (true, "").
func campLocationCheck(exp *Expedition) (cleared bool, problem string) {
	if exp.RunID == "" {
		// No room state — treat as the entry room. Cleared by definition.
		return true, ""
	}
	run, err := getZoneRun(exp.RunID)
	if err != nil || run == nil {
		// Run was wiped/abandoned out from under us; behave like entry.
		return true, ""
	}
	rt := run.CurrentRoomType()
	switch rt {
	case RoomBoss:
		return false, "You can't camp in a boss room. The room knows it's a boss room."
	case RoomTrap:
		return false, "You can't camp in a trap room — even a disarmed one."
	}
	for _, idx := range run.RoomsCleared {
		if idx == run.CurrentRoom {
			return true, ""
		}
	}
	// Not yet advanced-past, but the only thing that bars a rest is a live
	// fight. Forward-only navigation means players pause right after a kill
	// before advancing, and peaceful/exploration/loot rooms never spawn an
	// encounter at all — both are safe to rest in. The "cleared" flag would
	// otherwise reject standard camp here with a misleading "clear it first".
	encID := encounterIDForRoom(run.CurrentRoom)
	sess, err := getCombatSessionForEncounter(run.RunID, encID)
	if err != nil {
		// Can't read the encounter's combat state. Fail open like the
		// run-lookup path above rather than blocking standard camp with an
		// empty (misleading "not cleared") rejection — a DB hiccup shouldn't
		// strand a player who only wants to rest.
		slog.Warn("camp: combat session lookup failed; allowing camp",
			"run", run.RunID, "encounter", encID, "err", err)
		return true, ""
	}
	if sess != nil && sess.Status == CombatStatusActive {
		return false, "You can't camp mid-fight — finish the encounter first."
	}
	return true, ""
}

// autoBreakCampOnMove strikes an active camp when the player has moved
// to a different room than the one camp was pitched in. Camp is a
// stationary intent (long-rest at this spot until the next briefing);
// once the party walks on — whether by autopilot, manual !advance, or
// fork pick — the tent stops mattering. Overnight rest effects are not
// applied here (those only land at briefing time via
// processOvernightCamp). Returns the camp type that was struck, or ""
// if no break happened. Safe to call when there's no expedition.
func autoBreakCampOnMove(userID id.UserID) string {
	exp, err := getActiveExpedition(userID)
	if err != nil || exp == nil {
		return ""
	}
	if exp.Camp == nil || !exp.Camp.Active {
		return ""
	}
	if exp.RunID == "" {
		return ""
	}
	run, err := getZoneRun(exp.RunID)
	if err != nil || run == nil {
		return ""
	}
	if run.CurrentRoom == exp.Camp.RoomIndex {
		return ""
	}
	kind := exp.Camp.Type
	if err := updateCamp(exp.ID, nil); err != nil {
		slog.Warn("camp: auto-break on move failed", "expedition", exp.ID, "err", err)
		return ""
	}
	_ = appendExpeditionLog(exp.ID, exp.CurrentDay, "narrative", "camp struck (party moved on)", "")
	return kind
}

// campCurrentRoomIndex returns 0 (entry) when no room context exists.
func campCurrentRoomIndex(exp *Expedition) int {
	if exp.RunID == "" {
		return 0
	}
	run, err := getZoneRun(exp.RunID)
	if err != nil || run == nil {
		return 0
	}
	return run.CurrentRoom
}
