package plugin

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

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

// Phase 12 E7 — Ambient between-day events. The expedition autopilot's
// "background ticking" pass: while an active expedition is sitting
// between briefings and recaps, the dungeon does its own small things
// every few hours. Most fires are pure flavor (TwinBee narrating a
// dripping sound, a draft, a polite possum); a few apply tiny
// mechanical nudges (±SU, +threat, small coin find, +1 HP if camped).
//
// Design constraints:
//   • At most one event per expedition per ambientCooldown window.
//   • Idempotency via compare-and-set on dnd_expedition.last_ambient_at
//     — survives bot restarts, double-fires, clock skew.
//   • Skip if a turn-based combat session is open: the per-round combat
//     DMs are the player's feed right now; ambient noise would talk
//     over them.
//   • Skip if we're inside the politeness window around the scheduled
//     06:00 briefing or 21:00 recap so we don't pile DMs on top of the
//     big daily message.
//   • Effects are deliberately small — ambient is texture, not balance.

const (
	// ambientCooldown — minimum time between ambient events for one
	// expedition. Tuned so a player offline for a workday sees ~1–2 hits,
	// not a flood, but a multi-day expedition still feels alive.
	ambientCooldown = 6 * time.Hour

	// ambientNearScheduleWindow — skip if we're within this many minutes
	// of a scheduled briefing (06:00 UTC) or recap (21:00 UTC).
	ambientNearScheduleWindow = 60 * time.Minute

	// ambientTickInterval — how often the ticker wakes up to check.
	// The cooldown gate is what actually paces events; the tick just
	// has to be frequent enough that the cooldown is enforced cleanly.
	ambientTickInterval = 5 * time.Minute
)

// expeditionAmbientTicker — fires ambient events every ambientTickInterval.
func (p *AdventurePlugin) expeditionAmbientTicker() {
	ticker := time.NewTicker(ambientTickInterval)
	defer ticker.Stop()
	for {
		select {
		case <-p.stopCh:
			return
		case <-ticker.C:
			p.fireExpeditionAmbients(time.Now().UTC())
		}
	}
}

// fireExpeditionAmbients walks active expeditions whose last_ambient_at
// is older than ambientCooldown and fires one ambient event per.
func (p *AdventurePlugin) fireExpeditionAmbients(now time.Time) {
	if inAmbientQuietWindow(now) {
		return
	}
	due, err := loadExpeditionsForAmbient(now.Add(-ambientCooldown))
	if err != nil {
		slog.Error("expedition: load ambient candidates", "err", err)
		return
	}
	for _, expID := range due {
		// Re-load each row fresh — the candidate list was a cheap
		// id-only scan, and per-expedition state may have moved.
		e, err := getExpedition(expID)
		if err != nil || e == nil {
			continue
		}
		if e.Status != ExpeditionStatusActive {
			continue
		}
		// Safety net: if the wrapping zone run is abandoned or completed,
		// the player has functionally left the dungeon even though the
		// expedition row hasn't been flipped. Skip silently rather than
		// DM about a place they're no longer in.
		if e.RunID != "" {
			if run, _ := getZoneRun(e.RunID); run != nil && !run.IsActive() {
				continue
			}
		}
		uid := id.UserID(e.UserID)
		if hasActiveCombatSession(uid) {
			continue
		}
		if err := p.deliverAmbient(e, now); err != nil {
			slog.Warn("expedition: ambient deliver", "expedition", e.ID, "err", err)
		}
	}
}

// inAmbientQuietWindow reports whether `now` is inside the politeness
// window around 06:00 UTC (briefing) or 21:00 UTC (recap). Centered on
// the scheduled minute: ±ambientNearScheduleWindow.
func inAmbientQuietWindow(now time.Time) bool {
	briefing := time.Date(now.Year(), now.Month(), now.Day(),
		expeditionBriefingHour, 0, 0, 0, time.UTC)
	recap := time.Date(now.Year(), now.Month(), now.Day(),
		expeditionRecapHour, 0, 0, 0, time.UTC)
	for _, t := range []time.Time{briefing, recap} {
		d := now.Sub(t)
		if d < 0 {
			d = -d
		}
		if d < ambientNearScheduleWindow {
			return true
		}
	}
	return false
}

// loadExpeditionsForAmbient — active expedition IDs whose last_ambient_at
// is NULL or older than the cutoff. Also requires start_date older than
// the cutoff so a freshly-started expedition gets a real beat of silence
// before the dungeon starts chiming in.
func loadExpeditionsForAmbient(cutoff time.Time) ([]string, error) {
	rows, err := db.Get().Query(`
		SELECT expedition_id
		  FROM dnd_expedition
		 WHERE status = 'active'
		   AND start_date < ?
		   AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
		cutoff, cutoff)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var ids []string
	for rows.Next() {
		var s string
		if err := rows.Scan(&s); err != nil {
			return nil, err
		}
		ids = append(ids, s)
	}
	return ids, rows.Err()
}

// deliverAmbient claims the slot, picks an event, applies its effect,
// DMs the player, and appends a log entry. Idempotent: the CAS update
// short-circuits on rowsAffected == 0 so a double-fire is a no-op.
func (p *AdventurePlugin) deliverAmbient(e *Expedition, now time.Time) error {
	// Pick before claiming the slot so the CAS can persist the new Kind
	// alongside last_ambient_at — that way back-to-back fires for the
	// same expedition (different ticker tick, different rng) read the
	// updated LastAmbientKind on the next load. Picking is pure and
	// cheap; if the CAS loses, we simply discard the picked event.
	ev := pickAmbientEvent(e, defaultAmbientRNG(), e.LastAmbientKind)
	line := flavor.Pick(ev.Pool)
	if line == "" {
		// Pool was empty — degrade to a generic monologue rather than
		// firing an event with no narration.
		ev = ambientEventMonologue()
		line = flavor.Pick(ev.Pool)
	}

	cutoff := now.Add(-ambientCooldown)
	res, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET last_ambient_at = ?,
		       last_ambient_kind = ?,
		       last_activity = ?
		 WHERE expedition_id = ?
		   AND status = 'active'
		   AND (last_ambient_at IS NULL OR last_ambient_at < ?)`,
		now, ev.Kind, now, e.ID, cutoff)
	if err != nil {
		return err
	}
	if n, _ := res.RowsAffected(); n == 0 {
		return nil // someone else got there first
	}

	footer := p.applyAmbientEffect(e, ev)
	body := renderAmbientDM(e, ev, line, footer)

	if uid := id.UserID(e.UserID); uid != "" {
		if err := p.SendDM(uid, body); err != nil {
			slog.Warn("expedition: ambient DM", "user", uid, "err", err)
		}
	}
	summary := fmt.Sprintf("ambient: %s", ev.Kind)
	if footer != "" {
		summary += " — " + footer
	}
	if err := appendExpeditionLog(e.ID, e.CurrentDay, "ambient",
		summary, line); err != nil {
		return err
	}
	return nil
}

// ── Event table ──────────────────────────────────────────────────────────────

// ambientEvent — one entry in the ambient pool. Weight is relative; the
// resolver normalizes over eligible events at pick time.
type ambientEvent struct {
	Kind     string
	Pool     []string
	Weight   int
	Eligible func(*Expedition) bool
}

// ambientEventMonologue — the always-eligible flavor fallback. Pulled
// out so deliverAmbient can degrade to it if a chosen pool is empty.
func ambientEventMonologue() ambientEvent {
	return ambientEvent{
		Kind:     "monologue",
		Pool:     flavor.AmbientMonologue,
		Weight:   35,
		Eligible: func(*Expedition) bool { return true },
	}
}

// ambientEvents returns the full event table. Constructed per-call so
// it's cheap to test in isolation and free of package-level state.
func ambientEvents() []ambientEvent {
	always := func(*Expedition) bool { return true }
	events := []ambientEvent{
		ambientEventMonologue(),
		{
			Kind:     "small_find",
			Pool:     flavor.AmbientSmallFind,
			Weight:   10,
			Eligible: always,
		},
		{
			Kind:     "noise",
			Pool:     flavor.AmbientNoise,
			Weight:   15,
			Eligible: always,
		},
		{
			Kind:   "pack_rat",
			Pool:   flavor.AmbientPackRat,
			Weight: 12,
			// Pack rats need something to nibble. If supplies are
			// already at the floor, skip — no point in the player
			// getting a "you lost 0.0 SU" DM.
			Eligible: func(e *Expedition) bool {
				return e.Supplies.Current >= 1
			},
		},
		{
			Kind:     "lucky_find",
			Pool:     flavor.AmbientLuckyFind,
			Weight:   10,
			Eligible: always,
		},
		{
			Kind:   "camp_visitor",
			Pool:   flavor.AmbientCampVisitor,
			Weight: 8,
			Eligible: func(e *Expedition) bool {
				return e.Camp != nil && e.Camp.Active
			},
		},
		{
			Kind:   "faction_whisper",
			Pool:   flavor.AmbientFactionWhisper,
			Weight: 10,
			// Whispers from a faction that doesn't know you exist
			// land flat. Gate behind a real threat level.
			Eligible: func(e *Expedition) bool {
				return e.ThreatLevel >= 30 && !e.SiegeMode
			},
		},
	}
	// N7/E4 — a live season adds a themed road visitor. Non-combat: it leaves a
	// keepsake and a coin gift, resolved in applyAmbientEffect. The pool and
	// reward come from activeSeason(); the ambient seam is production-only, so
	// this never reaches the balance sim.
	if s, ok := activeSeason(); ok && len(s.Visitor.FlavorPool) > 0 {
		events = append(events, ambientEvent{
			Kind:     "season_visitor",
			Pool:     s.Visitor.FlavorPool,
			Weight:   s.Visitor.Weight,
			Eligible: func(*Expedition) bool { return true },
		})
	}
	return events
}

// pickAmbientEvent runs a weighted pick over eligible events. avoidKind
// is the Kind of the previous fire (or "" if none); when a pick lands on
// that same Kind, we re-roll once. We only re-roll once so the bias is a
// soft nudge — if the user is in a state where most weight sits on one
// Kind (e.g. starving cuts pack_rat → camped + low threat narrows the
// table further) we still let it repeat rather than loop forever.
// Exposed for tests; the rng parameter lets them seed deterministically.
func pickAmbientEvent(e *Expedition, rng *rand.Rand, avoidKind string) ambientEvent {
	first := weightedPickAmbient(e, rng)
	if avoidKind == "" || first.Kind != avoidKind {
		return first
	}
	return weightedPickAmbient(e, rng)
}

// weightedPickAmbient is the underlying weighted-pick over eligible events.
func weightedPickAmbient(e *Expedition, rng *rand.Rand) ambientEvent {
	events := ambientEvents()
	total := 0
	for _, ev := range events {
		if ev.Eligible(e) {
			total += ev.Weight
		}
	}
	if total == 0 {
		return ambientEventMonologue()
	}
	r := rng.IntN(total)
	for _, ev := range events {
		if !ev.Eligible(e) {
			continue
		}
		if r < ev.Weight {
			return ev
		}
		r -= ev.Weight
	}
	return ambientEventMonologue() // unreachable barring float drift
}

func defaultAmbientRNG() *rand.Rand {
	return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))
}

// ── Effects ──────────────────────────────────────────────────────────────────

// applyAmbientEffect mutates expedition state per event Kind and returns a
// terse footer string for the DM ("Supplies −0.3", "+3 coins", "Threat +1").
// Pure-flavor kinds return "".
func (p *AdventurePlugin) applyAmbientEffect(e *Expedition, ev ambientEvent) string {
	rng := defaultAmbientRNG()
	switch ev.Kind {
	case "monologue", "small_find":
		return ""
	case "noise":
		if err := applyThreatDelta(e.ID, 1, "ambient: distant noise"); err != nil {
			slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
		}
		// Verb-form footer — "Threat +N" exposed the hidden meter.
		return "The dungeon listens a little harder."
	case "faction_whisper":
		if err := applyThreatDelta(e.ID, 2, "ambient: faction whisper"); err != nil {
			slog.Warn("expedition: ambient threat delta", "expedition", e.ID, "err", err)
		}
		return "Something out there is paying attention now."
	case "pack_rat":
		drain := float32(0.2) + float32(rng.IntN(2))*float32(0.1) // 0.2 or 0.3
		if _, err := p.withExpeditionSupplies(e.ID, func(fresh *Expedition) (ExpeditionSupplies, error) {
			ns := fresh.Supplies
			ns.Current -= drain
			if ns.Current < 0 {
				ns.Current = 0
			}
			return ns, nil
		}); err != nil {
			slog.Warn("expedition: ambient supply drain", "expedition", e.ID, "err", err)
			return ""
		}
		// Don't surface the raw SU number — it's a hidden meter and
		// "SU" doesn't appear in player vocab anywhere else.
		_ = drain
		return "A little less in the pack than there was."
	case "lucky_find":
		coins := 1 + rng.IntN(4) // 1d4
		if p.euro != nil {
			p.euro.Credit(id.UserID(e.UserID), float64(coins), "expedition ambient: lucky find")
		}
		// Mirror into coins_earned so the post-expedition tally is honest.
		if _, err := db.Get().Exec(`
			UPDATE dnd_expedition
			   SET coins_earned = coins_earned + ?,
			       last_activity = CURRENT_TIMESTAMP
			 WHERE expedition_id = ?`, coins, e.ID); err != nil {
			slog.Warn("expedition: ambient coin tally", "expedition", e.ID, "err", err)
		}
		return fmt.Sprintf("+%d coins", coins)
	case "camp_visitor":
		// Tiny morale-style HP +1, clipped at max. Only fires when
		// camped (gated in Eligible). No-op if already full.
		uid := id.UserID(e.UserID)
		c, err := LoadDnDCharacter(uid)
		if err != nil || c == nil {
			return ""
		}
		if c.HPCurrent >= c.HPMax {
			return ""
		}
		c.HPCurrent++
		if err := SaveDnDCharacter(c); err != nil {
			slog.Warn("expedition: ambient HP nudge", "user", uid, "err", err)
			return ""
		}
		return "+1 HP"
	case "season_visitor":
		// N7/E4 — the visitor leaves a sellable keepsake and a coin gift. Re-read
		// the season so the reward matches the current window even if it turned
		// over between pick and apply; bail quietly if it just ended.
		s, ok := activeSeason()
		if !ok {
			return ""
		}
		v := s.Visitor
		uid := id.UserID(e.UserID)
		if err := addAdvInventoryItem(uid, AdvItem{
			Name:  v.Keepsake,
			Type:  "trophy",
			Tier:  1,
			Value: v.KeepsakeValue,
		}); err != nil {
			slog.Warn("expedition: ambient season keepsake", "user", uid, "err", err)
			return ""
		}
		if v.Coins > 0 {
			if p.euro != nil {
				p.euro.Credit(uid, float64(v.Coins), "expedition ambient: "+s.Key+" visitor")
			}
			if _, err := db.Get().Exec(`
				UPDATE dnd_expedition
				   SET coins_earned = coins_earned + ?,
				       last_activity = CURRENT_TIMESTAMP
				 WHERE expedition_id = ?`, v.Coins, e.ID); err != nil {
				slog.Warn("expedition: ambient season coin tally", "expedition", e.ID, "err", err)
			}
			return fmt.Sprintf("You keep the %s. +%d coins", v.Keepsake, v.Coins)
		}
		return fmt.Sprintf("You keep the %s.", v.Keepsake)
	}
	return ""
}

// renderAmbientDM builds the DM body. Short header so it doesn't look
// like a briefing, then the flavor line, then a one-line footer when
// there was a mechanical effect.
func renderAmbientDM(e *Expedition, ev ambientEvent, line, footer string) string {
	var b strings.Builder
	b.WriteString(fmt.Sprintf("🌫 *Day %d — between briefings*\n\n", e.CurrentDay))
	b.WriteString(line)
	if footer != "" {
		b.WriteString("\n\n_")
		b.WriteString(footer)
		b.WriteString("_")
	}
	return b.String()
}

// ── Test seam — sql.NullTime helper kept here so tests can compose ──────────

// expeditionAmbientStamp returns the last_ambient_at value for a row, or
// (zero, false) if NULL. Used in tests to assert idempotency.
func expeditionAmbientStamp(expID string) (time.Time, bool, error) {
	var raw sql.NullTime
	err := db.Get().QueryRow(`
		SELECT last_ambient_at FROM dnd_expedition WHERE expedition_id = ?`,
		expID).Scan(&raw)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return time.Time{}, false, nil
		}
		return time.Time{}, false, err
	}
	if !raw.Valid {
		return time.Time{}, false, nil
	}
	return raw.Time, true, nil
}
