package plugin

import (
	cryptorand "crypto/rand"
	"database/sql"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"time"

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

// Phase 12 E1a — Expedition data model and persistence.
// Implements `gogobee_expedition_system.md` §4.4 (Supplies), §5.3 (Camp),
// §8.4 (Threat Clock), and §9 (Expedition State). One persisted Expedition
// per active campaign; players may have at most one row with status='active'
// (enforced in code).
//
// E1a ships the data layer only: structs, schema (in db.go), persistence
// helpers, and a round-trip test. Day cycle (E1d), supply procurement (E1b),
// commands (E1c), and camp interactions (E1e) wire on top in subsequent
// commits. Threat clock events / siege mode are scaffolded but not driven
// until E2.

// Expedition status values.
const (
	ExpeditionStatusActive     = "active"
	ExpeditionStatusExtracting = "extracting"
	ExpeditionStatusComplete   = "complete"
	ExpeditionStatusFailed     = "failed"
	ExpeditionStatusAbandoned  = "abandoned"
)

// ExpeditionSupplies — §4.4.
type ExpeditionSupplies struct {
	Current       float32 `json:"current"`        // current SU
	Max           float32 `json:"max"`            // purchased at outset
	DailyBurn     float32 `json:"daily_burn"`     // base rate for this zone tier
	HarshMod      float32 `json:"harsh_mod"`      // multiplier if harsh conditions active
	ForagedToday  bool    `json:"foraged_today"`  // Ranger forage attempt used today
	PacksStandard int     `json:"packs_standard"` // count purchased (max 3)
	PacksDeluxe   int     `json:"packs_deluxe"`   // count purchased (max 1)
}

// CampState — §5.3.
type CampState struct {
	Active        bool      `json:"active"`
	Type          string    `json:"camp_type"` // rough|standard|fortified|base
	RoomIndex     int       `json:"room_index"`
	EstablishedAt time.Time `json:"established_at"`
	NightEvents   []string  `json:"night_events"`
	// RestApplied is set when the long-rest effects (HP refill, spell slots,
	// threat -5 etc.) have already been applied at pitch time. processOvernightCamp
	// uses it to skip re-applying so the night cycle just breaks the camp.
	RestApplied bool `json:"rest_applied,omitempty"`
	// AutoPitched is set when the long-expedition autopilot pitched this
	// camp. The autorun ticker breaks an auto-pitched camp itself after a
	// minimum dwell so the walk can keep moving; player-pitched camps stay
	// up until the player breaks them (or moves on).
	AutoPitched bool `json:"auto_pitched,omitempty"`
}

// ThreatEvent — §8.4.
type ThreatEvent struct {
	Timestamp time.Time `json:"timestamp"`
	Delta     int       `json:"delta"`
	Reason    string    `json:"reason"`
}

// Expedition — §9. The in-memory shape of a dnd_expedition row.
type Expedition struct {
	ID             string
	UserID         string
	ZoneID         ZoneID
	RunID          string // optional pointer to dnd_zone_run.run_id
	Status         string
	StartDate      time.Time
	CurrentDay     int
	CurrentRegion  string
	BossDefeated   bool
	Supplies       ExpeditionSupplies
	Camp           *CampState
	ThreatLevel    int
	SiegeMode      bool
	ThreatEvents   []ThreatEvent
	TemporalStack  int
	RegionState    map[string]any
	XPEarned       int
	CoinsEarned    int
	DMMood         int
	LastBriefingAt *time.Time
	LastRecapAt    *time.Time
	// LastAmbientKind — the Kind of the most recent ambient event the
	// ticker fired (e.g. "pack_rat", "monologue"). Empty when no ambient
	// has fired yet. Used by pickAmbientEvent for back-to-back anti-repeat.
	LastAmbientKind string
	LastActivity    time.Time
	CompletedAt     *time.Time
}

// IsActive reports whether the expedition is in flight.
func (e *Expedition) IsActive() bool {
	return e.Status == ExpeditionStatusActive || e.Status == ExpeditionStatusExtracting
}

// ExpeditionEntry — one row in dnd_expedition_log; mirrors §9.
type ExpeditionEntry struct {
	EntryID      int64
	ExpeditionID string
	Day          int
	Timestamp    time.Time
	Type         string
	Summary      string
	Flavor       string
}

// Errors returned by the expedition layer.
var (
	ErrExpeditionAlreadyActive = errors.New("expedition already active for player")
	ErrNoActiveExpedition      = errors.New("no active expedition for player")
)

// newExpeditionID — 16-char hex token. Same scheme as zone runs.
func newExpeditionID() string {
	var b [8]byte
	if _, err := cryptorand.Read(b[:]); err != nil {
		// Vanishingly unlikely; fall through with a zeroed prefix.
	}
	return hex.EncodeToString(b[:])
}

// startExpedition creates a new expedition for the player. The caller must
// have already validated zone access and supply purchase totals.
// runID may be empty — the zone run is created lazily by E1c command flow.
func startExpedition(userID id.UserID, zoneID ZoneID, runID string, supplies ExpeditionSupplies) (*Expedition, error) {
	existing, err := getActiveExpedition(userID)
	if err != nil {
		return nil, err
	}
	if existing != nil {
		return nil, ErrExpeditionAlreadyActive
	}

	now := time.Now().UTC()
	// Multi-region zones (§11) start the player in the first region;
	// single-region zones leave CurrentRegion empty.
	currentRegion := ""
	if first, ok := firstRegion(zoneID); ok {
		currentRegion = first.ID
	}
	regionState := map[string]any{}
	if currentRegion != "" {
		regionState[regionStateVisitedKey] = []string{currentRegion}
	}
	startMood := 50
	if isHol, _ := isHolidayToday(); isHol {
		startMood = 55
	}
	if b := activeOmen().StartMoodBonus; b > 0 { // N7/B3 the Omen
		startMood += b
	}
	exp := &Expedition{
		ID:            newExpeditionID(),
		UserID:        string(userID),
		ZoneID:        zoneID,
		RunID:         runID,
		Status:        ExpeditionStatusActive,
		StartDate:     now,
		CurrentDay:    1,
		CurrentRegion: currentRegion,
		Supplies:      supplies,
		ThreatEvents:  []ThreatEvent{},
		RegionState:   regionState,
		DMMood:        startMood,
		LastActivity:  now,
	}
	supJSON, _ := json.Marshal(supplies)
	regJSON, _ := json.Marshal(exp.RegionState)
	threatJSON, _ := json.Marshal(exp.ThreatEvents)

	if _, err := db.Get().Exec(`
		INSERT INTO dnd_expedition
			(expedition_id, user_id, zone_id, run_id, status,
			 start_date, current_day, current_region, supplies_json,
			 threat_events, region_state, gm_mood, last_activity)
		VALUES (?, ?, ?, ?, 'active', ?, 1, ?, ?, ?, ?, ?, ?)`,
		exp.ID, exp.UserID, string(zoneID), nullableString(runID),
		now, currentRegion, string(supJSON), string(threatJSON), string(regJSON), startMood, now,
	); err != nil {
		return nil, fmt.Errorf("insert expedition: %w", err)
	}
	return exp, nil
}

// expeditionSelectCols is the column list scanExpedition reads, in the order it
// reads them. scanExpedition's Scan is positional, so every query that feeds it
// must project exactly this — hence one constant rather than a copy per query.
// The `e.` alias means a query can JOIN expedition_party without ambiguity; a
// query with no JOIN just aliases dnd_expedition to e.
const expeditionSelectCols = `
		e.expedition_id, e.user_id, e.zone_id, e.run_id, e.status,
		e.start_date, e.current_day, e.current_region, e.boss_defeated,
		e.supplies_json, e.camp_json, e.threat_level, e.threat_siege,
		e.threat_events, e.temporal_stack, e.region_state,
		e.xp_earned, e.coins_earned, e.gm_mood,
		e.last_briefing_at, e.last_recap_at, e.last_ambient_kind,
		e.last_activity, e.completed_at`

// getActiveExpedition returns the player's in-flight expedition, or (nil, nil).
// 'extracting' rows are post-extraction (resumable) — see getResumableExpedition.
func getActiveExpedition(userID id.UserID) (*Expedition, error) {
	row := db.Get().QueryRow(`
		SELECT`+expeditionSelectCols+`
		  FROM dnd_expedition e
		 WHERE e.user_id = ?
		   AND e.status = 'active'
		 ORDER BY e.start_date DESC
		 LIMIT 1`, string(userID))
	e, err := scanExpedition(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	return e, err
}

// ownedLiveExpedition returns the expedition this player owns and has not yet
// finished with — 'active' or 'extracting'. An extracted expedition is not
// over: it holds its roster for the whole resume window (releaseParty is not
// called on it), so the owner is still the only person who can close it.
//
// Active rows sort first, so a leader who owns both — extracted from one, then
// started another — resolves to the one they are standing in.
func ownedLiveExpedition(userID id.UserID) (*Expedition, error) {
	row := db.Get().QueryRow(`
		SELECT`+expeditionSelectCols+`
		  FROM dnd_expedition e
		 WHERE e.user_id = ?
		   AND e.status IN ('active', 'extracting')
		 ORDER BY (e.status = 'active') DESC, e.start_date DESC
		 LIMIT 1`, string(userID))
	e, err := scanExpedition(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	return e, err
}

// getExpedition fetches by ID regardless of status. Test/admin use.
func getExpedition(id string) (*Expedition, error) {
	row := db.Get().QueryRow(`
		SELECT`+expeditionSelectCols+`
		  FROM dnd_expedition e WHERE e.expedition_id = ?`, id)
	e, err := scanExpedition(row)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, nil
	}
	return e, err
}

func scanExpedition(row scanner) (*Expedition, error) {
	var (
		e               Expedition
		zoneID          string
		runID           sql.NullString
		suppliesJSON    string
		campJSON        sql.NullString
		threatJSON      string
		regionJSON      string
		bossI           int
		siegeI          int
		lastBriefingRaw sql.NullTime
		lastRecapRaw    sql.NullTime
		completedRaw    sql.NullTime
	)
	if err := row.Scan(
		&e.ID, &e.UserID, &zoneID, &runID, &e.Status,
		&e.StartDate, &e.CurrentDay, &e.CurrentRegion, &bossI,
		&suppliesJSON, &campJSON, &e.ThreatLevel, &siegeI,
		&threatJSON, &e.TemporalStack, &regionJSON,
		&e.XPEarned, &e.CoinsEarned, &e.DMMood,
		&lastBriefingRaw, &lastRecapRaw, &e.LastAmbientKind,
		&e.LastActivity, &completedRaw,
	); err != nil {
		return nil, err
	}
	e.ZoneID = ZoneID(zoneID)
	if runID.Valid {
		e.RunID = runID.String
	}
	e.BossDefeated = bossI != 0
	e.SiegeMode = siegeI != 0
	if err := json.Unmarshal([]byte(suppliesJSON), &e.Supplies); err != nil {
		return nil, fmt.Errorf("decode supplies_json: %w", err)
	}
	if campJSON.Valid && campJSON.String != "" {
		var c CampState
		if err := json.Unmarshal([]byte(campJSON.String), &c); err != nil {
			return nil, fmt.Errorf("decode camp_json: %w", err)
		}
		e.Camp = &c
	}
	if threatJSON != "" {
		if err := json.Unmarshal([]byte(threatJSON), &e.ThreatEvents); err != nil {
			return nil, fmt.Errorf("decode threat_events: %w", err)
		}
	}
	if e.ThreatEvents == nil {
		e.ThreatEvents = []ThreatEvent{}
	}
	if regionJSON != "" {
		if err := json.Unmarshal([]byte(regionJSON), &e.RegionState); err != nil {
			slog.Warn("expedition: region_state decode failed; falling back to empty",
				"expedition", e.ID, "err", err)
		}
	}
	if e.RegionState == nil {
		e.RegionState = map[string]any{}
	}
	if lastBriefingRaw.Valid {
		t := lastBriefingRaw.Time
		e.LastBriefingAt = &t
	}
	if lastRecapRaw.Valid {
		t := lastRecapRaw.Time
		e.LastRecapAt = &t
	}
	if completedRaw.Valid {
		t := completedRaw.Time
		e.CompletedAt = &t
	}
	return &e, nil
}

// updateSupplies persists a new supplies snapshot.
func updateSupplies(expID string, s ExpeditionSupplies) error {
	b, _ := json.Marshal(s)
	_, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET supplies_json = ?,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, string(b), expID)
	return err
}

// withExpeditionSupplies serializes one read-modify-write of the shared supply
// pool. updateSupplies rewrites supplies_json wholesale, so a caller that folds
// its delta onto an *Expedition it read earlier silently discards anything that
// landed in between — a member's pooled packs, another writer's spend. Handlers
// run one goroutine per event, so those writers genuinely interleave.
//
// fn is handed a freshly-read expedition under the pool's own lock and returns
// the supplies to persist. Callers that keep using their own *Expedition
// afterwards must copy the returned pool back onto it.
//
// advUserLock cannot stand in here: it is keyed by sender, so two members
// racing the same expedition row take two different mutexes and exclude nobody.
func (p *AdventurePlugin) withExpeditionSupplies(
	expID string, fn func(fresh *Expedition) (ExpeditionSupplies, error),
) (ExpeditionSupplies, error) {
	mu := p.advExpeditionLock(expID)
	mu.Lock()
	defer mu.Unlock()

	fresh, err := getExpedition(expID)
	if err != nil {
		return ExpeditionSupplies{}, err
	}
	if fresh == nil {
		return ExpeditionSupplies{}, fmt.Errorf("expedition %s not found", expID)
	}
	next, err := fn(fresh)
	if err != nil {
		return ExpeditionSupplies{}, err
	}
	if err := updateSupplies(expID, next); err != nil {
		return ExpeditionSupplies{}, err
	}
	return next, nil
}

// updateCamp persists camp state. Pass nil to break camp.
func updateCamp(expID string, c *CampState) error {
	var arg any
	if c == nil {
		arg = nil
	} else {
		b, _ := json.Marshal(c)
		arg = string(b)
	}
	_, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET camp_json = ?,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, arg, expID)
	return err
}

// setExpeditionRunID writes the expedition's linked zone-run pointer.
// Used during region transitions and on initial spawn (R2).
func setExpeditionRunID(expID, runID string) error {
	_, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET run_id = ?,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, nullableString(runID), expID)
	return err
}

// abandonExpedition flags the player's live expedition as abandoned. Idempotent.
//
// It spans 'extracting' as well as 'active': an extracted expedition still owns
// its roster, so abandoning is the leader's only way to free their party
// without paying to `!resume` first. Character reset (dnd_setup) leans on this
// too — a rerolled leader who left an extracted party behind would otherwise
// strand every member until the sweeper reaped the row.
//
// One row per call, active first, so the run-spawn rollback in expeditionCmdStart
// tears down the expedition it just created rather than an older extracted one.
func abandonExpedition(userID id.UserID) error {
	e, err := ownedLiveExpedition(userID)
	if err != nil {
		return err
	}
	if e == nil {
		return ErrNoActiveExpedition
	}
	if _, err = db.Get().Exec(`
		UPDATE dnd_expedition
		   SET status = 'abandoned',
		       completed_at = CURRENT_TIMESTAMP,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, e.ID); err != nil {
		return err
	}
	releaseParty(e.ID)
	return nil
}

// completeExpedition marks the expedition complete (boss defeated or extracted).
func completeExpedition(expID string, status string) error {
	if _, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET status = ?,
		       completed_at = CURRENT_TIMESTAMP,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, status, expID); err != nil {
		return err
	}
	releaseParty(expID)
	return nil
}

// releaseParty clears the roster of an expedition that has reached a terminal
// status, freeing every member to start a run of their own. A member is barred
// from adventuring anywhere else while seated (assertNotAdventuring), so a
// roster that outlives its expedition strands the whole party.
//
// It is deliberately *not* called on the 'extracting' status: that is a
// seven-day resumable limbo, and `!resume` must bring the party back with it.
// The roster is cleared when the resume window lapses and the row flips to
// 'failed' — which routes through completeExpedition like everything else.
//
// A failure here is logged, not returned: the expedition is already terminal by
// the time we get here, and refusing to finish it over a stale roster row would
// leave the leader stuck instead of the members.
func releaseParty(expID string) {
	if err := disbandParty(expID); err != nil {
		slog.Warn("expedition: disband party", "expedition", expID, "err", err)
	}
	// An unanswered invite to a finished expedition would otherwise sit there
	// until its TTL, letting someone accept their way onto a corpse.
	if err := clearExpeditionInvites(expID); err != nil {
		slog.Warn("expedition: clear invites", "expedition", expID, "err", err)
	}
}

// applyThreatDelta clamps the threat level to [0,100], records the event,
// and flips siege_mode when the level reaches 100.
func applyThreatDelta(expID string, delta int, reason string) error {
	e, err := getExpedition(expID)
	if err != nil {
		return err
	}
	if e == nil {
		return ErrNoActiveExpedition
	}
	level := e.ThreatLevel + delta
	if level < 0 {
		level = 0
	}
	if level > 100 {
		level = 100
	}
	// Spec §8.3: Siege Mode is one-way — the OR keeps siege sticky
	// even if a subsequent negative delta drops the underlying level.
	siege := e.SiegeMode || level >= 100
	events := append(e.ThreatEvents, ThreatEvent{
		Timestamp: time.Now().UTC(),
		Delta:     delta,
		Reason:    reason,
	})
	eventsJSON, _ := json.Marshal(events)
	_, err = db.Get().Exec(`
		UPDATE dnd_expedition
		   SET threat_level = ?,
		       threat_siege = ?,
		       threat_events = ?,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`,
		level, boolToInt(siege), string(eventsJSON), expID)
	return err
}

// updateTemporalStack persists the zone-temporal stack (heat / instability).
func updateTemporalStack(expID string, stack int) error {
	_, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET temporal_stack = ?,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, stack, expID)
	return err
}

// advanceExpeditionDay bumps current_day by one. Real-time day cycle (E1d)
// calls this from the 06:00 cron.
func advanceExpeditionDay(expID string) error {
	_, err := db.Get().Exec(`
		UPDATE dnd_expedition
		   SET current_day = current_day + 1,
		       last_activity = CURRENT_TIMESTAMP
		 WHERE expedition_id = ?`, expID)
	return err
}

// appendExpeditionLog inserts one log entry.
func appendExpeditionLog(expID string, day int, entryType, summary, flavor string) error {
	_, err := db.Get().Exec(`
		INSERT INTO dnd_expedition_log (expedition_id, day, entry_type, summary, flavor)
		VALUES (?, ?, ?, ?, ?)`, expID, day, entryType, summary, flavor)
	return err
}

// dayExpeditionLog returns every log entry recorded against the given
// (expedition, day) pair, oldest first. Used by the D4-a end-of-day
// digest to bundle a single rollup DM at night-camp pitch.
func dayExpeditionLog(expID string, day int) ([]ExpeditionEntry, error) {
	rows, err := db.Get().Query(`
		SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor
		  FROM dnd_expedition_log
		 WHERE expedition_id = ?
		   AND day = ?
		 ORDER BY entry_id`, expID, day)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []ExpeditionEntry
	for rows.Next() {
		var e ExpeditionEntry
		if err := rows.Scan(
			&e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp,
			&e.Type, &e.Summary, &e.Flavor,
		); err != nil {
			return nil, err
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

// recentExpeditionLog returns the last `limit` entries, newest first.
func recentExpeditionLog(expID string, limit int) ([]ExpeditionEntry, error) {
	if limit <= 0 {
		limit = 5
	}
	rows, err := db.Get().Query(`
		SELECT entry_id, expedition_id, day, timestamp, entry_type, summary, flavor
		  FROM dnd_expedition_log
		 WHERE expedition_id = ?
		 ORDER BY timestamp DESC, entry_id DESC
		 LIMIT ?`, expID, limit)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []ExpeditionEntry
	for rows.Next() {
		var e ExpeditionEntry
		if err := rows.Scan(
			&e.EntryID, &e.ExpeditionID, &e.Day, &e.Timestamp,
			&e.Type, &e.Summary, &e.Flavor,
		); err != nil {
			return nil, err
		}
		out = append(out, e)
	}
	return out, rows.Err()
}

func nullableString(s string) any {
	if s == "" {
		return nil
	}
	return s
}
