package main

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log/slog"
	"strings"
	"sync"
	"time"

	"github.com/robfig/cron/v3"

	"github.com/dbledeez/panel/controller/internal/db"
)

// Action is the polymorphic JSON payload stored in schedules.action.
// Validated at dispatch; unknown types are logged + ignored.
//
// Examples:
//
//	{"type":"rcon","command":"say \"back in 5\""}
//	{"type":"instance","op":"stop","grace_seconds":30}
//	{"type":"instance","op":"start"}
//	{"type":"instance","op":"restart","grace_seconds":30}
//	{"type":"backup","description":"Nightly auto-backup"}
//	{"type":"webhook","url":"https://hooks.slack.com/...","body":"{\"text\":\"server idle, stopping\"}"}
type Action struct {
	Type         string            `json:"type"`
	Command      string            `json:"command,omitempty"`
	Op           string            `json:"op,omitempty"`
	GraceSeconds uint32            `json:"grace_seconds,omitempty"`
	// BackupBeforeStart turns a "stop" or "restart" action into a
	// stop → wait → clean backup → (start, for restart) sequence.
	// "Clean" here means the main container has fully quiesced before
	// tar runs so we never archive a half-written save file. Backup
	// failure is logged but does not block the start step on restart.
	BackupBeforeStart bool `json:"backup_before_start,omitempty"`
	// PreSaveRCON, if non-empty, is sent over RCON BEFORE the stop step
	// runs. Used by ARK ("SaveWorld") and other games that need an
	// in-process flush before the container terminates so the world
	// state captured by the subsequent backup is internally consistent.
	// Failure to deliver the RCON command is logged but does not block
	// the stop — we still want the maintenance restart to land.
	PreSaveRCON string `json:"pre_save_rcon,omitempty"`
	// PreSaveDelaySec is how many seconds to wait between the save RCON
	// and the actual stop. Default is 10s when PreSaveRCON is set.
	PreSaveDelaySec uint32 `json:"pre_save_delay_sec,omitempty"`
	// Backup fields.
	Description string `json:"description,omitempty"` // free-form label saved with the resulting tarball
	// Webhook fields.
	URL     string            `json:"url,omitempty"`
	Method  string            `json:"method,omitempty"`  // defaults to POST
	Body    string            `json:"body,omitempty"`    // raw body; if content_type is application/json the UI will pretty-print
	Headers map[string]string `json:"headers,omitempty"`
	// Warning cascade for stop/restart. When WarnLeadMinutes > 0, fire
	// the schedule's cron tick that many minutes EARLY (the UI does this
	// shift transparently on save), then send WarnTemplate over RCON every
	// minute from WarnLeadMinutes down to 1 before running the pre-save +
	// stop sequence. {min} in the template is substituted with the remaining
	// whole minutes. Minutes listed in WarnExtraMinutes get a second
	// emphasized line right after the regular one (defaults to [2,1]
	// when the toggle is on and the field isn't set).
	WarnLeadMinutes   uint32   `json:"warn_lead_minutes,omitempty"`
	WarnTemplate      string   `json:"warn_template,omitempty"`
	WarnExtraTemplate string   `json:"warn_extra_template,omitempty"`
	WarnExtraMinutes  []uint32 `json:"warn_extra_minutes,omitempty"`
	// BloodMoonAware, when true on a stop/restart action against a 7DTD
	// instance, makes the scheduler check the PanelBloodMoonAgent status
	// file before firing. If a horde is active (or will start within
	// WarnLeadMinutes+15min), the fire is rescheduled to ~15 real-world
	// minutes after the horde ends and the warning cascade is replaced
	// with a single broadcast: "Scheduled restart postponed until 15
	// minutes after blood moon ends." Default off; the UI defaults it on
	// for 7dtd schedules. No-op on non-7dtd modules (executor returns
	// nil status → we proceed as normal).
	BloodMoonAware bool `json:"bloodmoon_aware,omitempty"`
	// BloodMoonPostBufferMinutes is how long after horde-end to wait
	// before firing the deferred restart. Default 15 minutes.
	BloodMoonPostBufferMinutes uint32 `json:"bloodmoon_post_buffer_minutes,omitempty"`
	// BloodMoonPostponeTemplate is the player-facing RCON say sent once
	// when the fire is postponed. {until_local} substituted with HH:MM
	// of the rescheduled fire in the server's local TZ. Default uses
	// "Scheduled restart postponed until {until_local} (15 min after blood moon)."
	BloodMoonPostponeTemplate string `json:"bloodmoon_postpone_template,omitempty"`
	// Empyrion EAH-style actions. Subop selects the bridge endpoint shape:
	//   "broadcast"     → POST /api/v1/chat/broadcast       Body=msg
	//   "save"          → POST /api/v1/save                 (no body)
	//   "console"       → POST /api/v1/console              Body=cmd
	// Resolves the instance's host:port via the same lookup the proxy uses.
	Subop string `json:"subop,omitempty"`
}

// Executor performs the actual work the scheduler dispatches. Injected at
// engine construction so the scheduler package stays unaware of grpc
// registries / pending-correlation machinery.
type Executor struct {
	ExecRCON  func(ctx context.Context, agentID, instanceID, cmd string) (string, error)
	StartInst func(ctx context.Context, agentID, instanceID string) error
	StopInst  func(ctx context.Context, agentID, instanceID string, grace uint32) error
	Webhook   func(ctx context.Context, url, method, body string, headers map[string]string) (status int, err error)
	// Backup runs the full agent-side backup pipeline (sidecar tar →
	// host tarball → DB row insert), returning size_bytes. The description
	// is persisted alongside the row so operator can tell auto-backups
	// apart from manual ones in the UI.
	Backup func(ctx context.Context, agentID, instanceID, description string) (sizeBytes int64, err error)
	// Empyrion fires an EAH-style action against the bridge. Subop selects
	// the verb (broadcast/save/console). Body is the payload (broadcast
	// message text, console command line, etc).
	Empyrion func(ctx context.Context, instanceID, subop, body string) (detail string, err error)
	// EmpyrionFreshStart runs a fresh-start sequence (announce → save → wipe → restart).
	// Body is the JSON-encoded freshStartReq. Returns the job ID.
	EmpyrionFreshStart func(ctx context.Context, instanceID, body string) (jobID string, err error)
	// BloodMoonStatus reads the PanelBloodMoonAgent status file from inside
	// a 7DTD instance's container and returns the parsed result. Nil for
	// non-7DTD modules; returns BloodMoonStatus{Source:"unavailable"} when
	// the mod hasn't written a file yet (agent disconnected, instance
	// stopped, or mod not loaded). Used by the scheduler's pre-fire defer
	// check to delay stop/restart actions through an active horde.
	BloodMoonStatus func(ctx context.Context, agentID, instanceID string) (BloodMoonStatus, error)
}

// BloodMoonStatus mirrors the JSON the PanelBloodMoonAgent mod writes to
// /game-saves/.panel-bloodmoon-status.json on every 5s tick. Fields may
// be zero/empty when the mod is loaded but the world hasn't initialized
// (early boot) or the agent ran the math fallback only.
type BloodMoonStatus struct {
	Schema         int    `json:"schema"`
	WrittenAtUnix  int64  `json:"written_at_unix"`
	InGameDay      int    `json:"in_game_day"`
	InGameHour     int    `json:"in_game_hour"`
	InGameMinute   int    `json:"in_game_minute"`
	BloodMoonFreq  int    `json:"bloodmoon_freq"`
	BloodMoonRange int    `json:"bloodmoon_range"`
	DayNightLength int    `json:"day_night_length"`
	NextHordeDay   int    `json:"next_horde_day"`
	Active         bool   `json:"active"`
	EndsAtUnix     int64  `json:"ends_at_unix"`
	Source         string `json:"source"`
}

// scheduleEngine runs each enabled cron schedule against a robfig cron
// scheduler, and records fire-results back into Postgres. Event-driven
// schedules are handled by eventTriggers (see scheduler_event.go) which
// hangs off this struct.
type scheduleEngine struct {
	log      *slog.Logger
	db       *db.DB
	registry *agentRegistry
	exec     Executor

	cron *cron.Cron
	mu   sync.Mutex
	// entries maps schedule_id → cron entry id so we can remove on delete.
	entries map[string]cron.EntryID
	// events optionally registers event-driven triggers. May be nil in tests.
	events *eventTriggers
	// onFire, when set, observes every recorded fire outcome (status string:
	// ok/error/cancelled/deferred). Wired to the Prometheus counter in main.
	onFire func(status string)
}

func newScheduleEngine(log *slog.Logger, database *db.DB, registry *agentRegistry, exec Executor) *scheduleEngine {
	c := cron.New(cron.WithSeconds(), cron.WithLogger(cron.DiscardLogger))
	return &scheduleEngine{
		log:      log,
		db:       database,
		registry: registry,
		exec:     exec,
		cron:     c,
		entries:  map[string]cron.EntryID{},
	}
}

// Start loads every enabled schedule from the DB and registers a cron entry.
// The cron scheduler runs in its own goroutine; Stop drains it.
func (e *scheduleEngine) Start(ctx context.Context) error {
	rows, err := e.db.ListSchedules(ctx, "")
	if err != nil {
		return fmt.Errorf("load schedules: %w", err)
	}
	for _, r := range rows {
		if !r.Enabled {
			continue
		}
		if err := e.register(r); err != nil {
			e.log.Warn("scheduler: register failed", "id", r.ID, "err", err)
		}
	}
	e.cron.Start()
	e.log.Info("scheduler started", "active_cron", len(e.entries))
	if e.events != nil {
		e.events.Start(ctx)
	}
	return nil
}

// isStillEnabled re-reads the schedule row from the DB and returns whether
// it's still enabled. Used by the warning cascade + the just-before-stop
// check so an operator un-checking "on" during a multi-minute warning
// cascade actually aborts the in-flight restart. DB errors fall back to
// "still enabled" so a transient lookup failure doesn't silently cancel
// a real maintenance window.
func (e *scheduleEngine) isStillEnabled(scheduleID string) bool {
	rows, err := e.db.ListSchedules(context.Background(), "")
	if err != nil {
		return true
	}
	for _, r := range rows {
		if r.ID == scheduleID {
			return r.Enabled
		}
	}
	// Row deleted out from under us → cancel.
	return false
}

// scheduleByID returns a schedule by id, or nil if not found / errored.
// Used by the event trigger dispatcher to check enabled + sustain config
// at fire time.
func (e *scheduleEngine) scheduleByID(id string) *db.ScheduleRow {
	rows, err := e.db.ListSchedules(context.Background(), "")
	if err != nil {
		return nil
	}
	for i := range rows {
		if rows[i].ID == id {
			return &rows[i]
		}
	}
	return nil
}

func (e *scheduleEngine) Stop() context.Context {
	return e.cron.Stop()
}

// Add registers a schedule and adds it to the cron scheduler (if enabled).
func (e *scheduleEngine) Add(r db.ScheduleRow) error {
	if err := e.db.InsertSchedule(context.Background(), r); err != nil {
		return err
	}
	if !r.Enabled {
		return nil
	}
	return e.register(r)
}

// Remove stops and deletes a schedule.
func (e *scheduleEngine) Remove(id string) error {
	e.unregister(id)
	return e.db.DeleteSchedule(context.Background(), id)
}

// Update changes a schedule in place. Re-registers in cron so new cron_spec
// takes effect without controller restart.
func (e *scheduleEngine) Update(r db.ScheduleRow) error {
	if err := e.db.UpdateSchedule(context.Background(), r); err != nil {
		return err
	}
	e.unregister(r.ID)
	if !r.Enabled {
		return nil
	}
	// Reload from DB to get the canonical row state before re-registering.
	rows, err := e.db.ListSchedules(context.Background(), "")
	if err != nil {
		return err
	}
	for _, row := range rows {
		if row.ID == r.ID {
			return e.register(row)
		}
	}
	return fmt.Errorf("schedule %q not found after update", r.ID)
}

// SetEnabled flips the enabled bit in DB AND in the cron scheduler.
func (e *scheduleEngine) SetEnabled(id string, enabled bool) error {
	if err := e.db.SetScheduleEnabled(context.Background(), id, enabled); err != nil {
		return err
	}
	if !enabled {
		e.unregister(id)
		return nil
	}
	// Re-load this schedule from DB and register it.
	rows, err := e.db.ListSchedules(context.Background(), "")
	if err != nil {
		return err
	}
	for _, r := range rows {
		if r.ID == id {
			return e.register(r)
		}
	}
	return fmt.Errorf("schedule %q not found after enable", id)
}

func (e *scheduleEngine) register(r db.ScheduleRow) error {
	switch r.TriggerKind {
	case "cron":
		id, err := e.cron.AddFunc(r.CronSpec, func() { e.fire(r) })
		if err != nil {
			return fmt.Errorf("cron spec %q: %w", r.CronSpec, err)
		}
		e.mu.Lock()
		e.entries[r.ID] = id
		e.mu.Unlock()
		e.log.Debug("scheduler: registered cron", "id", r.ID, "instance", r.InstanceID, "cron", r.CronSpec)
		return nil
	case "event":
		if e.events == nil {
			return fmt.Errorf("event triggers not configured")
		}
		if err := e.events.Register(r); err != nil {
			return err
		}
		e.log.Debug("scheduler: registered event", "id", r.ID, "instance", r.InstanceID, "spec", r.EventSpec, "sustained_s", r.EventSustainedSeconds)
		return nil
	default:
		return fmt.Errorf("trigger_kind %q not supported", r.TriggerKind)
	}
}

func (e *scheduleEngine) unregister(id string) {
	e.mu.Lock()
	entryID, ok := e.entries[id]
	delete(e.entries, id)
	e.mu.Unlock()
	if ok {
		e.cron.Remove(entryID)
	}
	if e.events != nil {
		e.events.Unregister(id)
	}
}

// fire executes a single schedule trigger. Action errors are recorded to
// the DB so operators can see them in the dashboard.
func (e *scheduleEngine) fire(r db.ScheduleRow) {
	log := e.log.With("schedule_id", r.ID, "instance_id", r.InstanceID)

	// Re-check enabled — the row may have been disabled between scheduler
	// lookup and this callback firing.
	rows, err := e.db.ListSchedules(context.Background(), "")
	if err != nil {
		log.Warn("fire: reload failed", "err", err)
		return
	}
	var current *db.ScheduleRow
	for i := range rows {
		if rows[i].ID == r.ID {
			current = &rows[i]
			break
		}
	}
	if current == nil || !current.Enabled {
		return
	}

	// Look up the agent that owns this instance.
	instances, err := e.db.ListInstances(context.Background(), "")
	if err != nil {
		e.record(r.ID, "error", "db: "+err.Error())
		return
	}
	var agentID string
	for _, in := range instances {
		if in.InstanceID == r.InstanceID {
			agentID = in.AgentID
			break
		}
	}
	if agentID == "" {
		e.record(r.ID, "error", "instance not in db")
		return
	}

	var act Action
	if err := json.Unmarshal(r.Action, &act); err != nil {
		e.record(r.ID, "error", "bad action json: "+err.Error())
		return
	}

	// Bloodmoon-aware deferral: for 7DTD stop/restart actions with the
	// flag set, ask the agent for the PanelBloodMoonAgent status file.
	// If a horde is active (or about to start within our warning window),
	// reschedule the entire fire for ~15 real minutes after the horde
	// ends. The deferred re-fire calls dispatch directly — it skips the
	// cron entry, so a second trigger won't double-fire.
	if act.BloodMoonAware && act.Type == "instance" && (act.Op == "stop" || act.Op == "restart") && e.exec.BloodMoonStatus != nil {
		bmCtx, bmCancel := context.WithTimeout(context.Background(), 15*time.Second)
		bm, bmErr := e.exec.BloodMoonStatus(bmCtx, agentID, r.InstanceID)
		bmCancel()
		if bmErr != nil {
			log.Warn("bloodmoon status check failed; proceeding with normal fire", "err", bmErr)
		} else {
			buffer := act.BloodMoonPostBufferMinutes
			if buffer == 0 {
				buffer = 15
			}
			// Treat "active right now" and "active within warning window" the same
			// way — we'd start broadcasting the wrong restart-warning right into
			// players' horde fight otherwise.
			windowEarlyMin := act.WarnLeadMinutes
			if windowEarlyMin == 0 {
				windowEarlyMin = 5 // safety margin if no warning cascade configured
			}
			shouldDefer := false
			var fireAt time.Time
			now := time.Now()
			if bm.Active && bm.EndsAtUnix > 0 {
				shouldDefer = true
				fireAt = time.Unix(bm.EndsAtUnix, 0).Add(time.Duration(buffer) * time.Minute)
			} else if !bm.Active && bm.NextHordeDay > 0 && bm.InGameDay == bm.NextHordeDay && bm.DayNightLength > 0 {
				// Horde day is today but we're pre-22:00. Compute real-seconds
				// until 22:00, defer if it's within (warnLead + 2) min.
				gameMinutesUntilHorde := ((22 - bm.InGameHour) * 60) - bm.InGameMinute
				if gameMinutesUntilHorde >= 0 {
					realSecsUntilHorde := float64(gameMinutesUntilHorde) * float64(bm.DayNightLength) / 24.0
					if realSecsUntilHorde < float64(windowEarlyMin+2)*60.0 {
						shouldDefer = true
						// Worst-case horde length = 6 game-hours = 360 game-min
						hordeLenRealSec := 360.0 * float64(bm.DayNightLength) / 24.0
						fireAt = now.Add(time.Duration(realSecsUntilHorde+hordeLenRealSec) * time.Second).Add(time.Duration(buffer) * time.Minute)
					}
				}
			}
			if shouldDefer {
				delay := time.Until(fireAt)
				if delay < time.Minute {
					delay = time.Minute // guard against past-time math
				}
				// One-shot broadcast so players know what happened. The default
				// template substitutes {buffer} with the actual configured
				// post-horde buffer, so the message stays truthful when an
				// operator picks a non-default value.
				if e.exec.ExecRCON != nil {
					tmpl := act.BloodMoonPostponeTemplate
					if tmpl == "" {
						tmpl = `say "[ff8800]Scheduled restart postponed until {until_local} ({buffer} min after blood moon).[-]"`
					}
					untilLocal := fireAt.Local().Format("15:04")
					bufferStr := fmt.Sprintf("%d", buffer)
					msg := strings.NewReplacer(
						"{until_local}", untilLocal,
						"{until}", untilLocal,
						"{buffer}", bufferStr,
						"{buffer_min}", bufferStr,
					).Replace(tmpl)
					broadcastCtx, broadcastCancel := context.WithTimeout(context.Background(), 10*time.Second)
					_, _ = e.exec.ExecRCON(broadcastCtx, agentID, r.InstanceID, msg)
					broadcastCancel()
				}
				e.record(r.ID, "deferred", fmt.Sprintf("bloodmoon active (day %d, hr %d:%02d, source=%s); refire at %s",
					bm.InGameDay, bm.InGameHour, bm.InGameMinute, bm.Source, fireAt.Local().Format("2006-01-02 15:04:05")))
				log.Info("scheduler: deferring fire for bloodmoon", "delay", delay, "fire_at", fireAt)
				// Schedule the deferred re-fire on a one-shot timer. We capture
				// the schedule row by value so subsequent UI edits don't
				// retroactively change what fires. We DO re-check enabled at
				// fire time (same path as cron).
				snapshot := r
				time.AfterFunc(delay, func() { e.fire(snapshot) })
				return
			}
		}
	}

	// Compute a fire timeout that covers the worst case: warning cascade
	// (WarnLeadMinutes) + pre-save delay + stop grace + clean backup +
	// start. 60s was the historical default; for restarts with warnings
	// we need room for the full sequence.
	timeout := 60 * time.Second
	if act.WarnLeadMinutes > 0 {
		timeout += time.Duration(act.WarnLeadMinutes+2) * time.Minute
	}
	if act.PreSaveDelaySec > 0 {
		timeout += time.Duration(act.PreSaveDelaySec) * time.Second
	}
	if act.GraceSeconds > 0 {
		timeout += time.Duration(act.GraceSeconds) * time.Second
	}
	if act.BackupBeforeStart {
		timeout += 10 * time.Minute
	}
	// A standalone backup action tars the whole world save (multi-GB for a
	// long-lived 7DTD season). The base 60s deadline is nowhere near enough
	// — it was silently failing every scheduled auto-backup with "context
	// deadline exceeded" while only the restart-coupled backup (which got
	// headroom from the warn-cascade math above) ever succeeded.
	if act.Type == "backup" {
		timeout += 10 * time.Minute
	}
	ctx, cancel := context.WithTimeout(context.Background(), timeout)
	defer cancel()

	status, detail := e.dispatch(ctx, r.ID, agentID, r.InstanceID, act)
	log.Info("scheduler fired", "action", act.Type, "op", act.Op, "status", status, "detail", detail)
	e.record(r.ID, status, detail)
}

func (e *scheduleEngine) dispatch(ctx context.Context, scheduleID, agentID, instanceID string, act Action) (status, detail string) {
	switch act.Type {
	case "rcon":
		if act.Command == "" {
			return "error", "rcon action missing command"
		}
		out, err := e.exec.ExecRCON(ctx, agentID, instanceID, act.Command)
		if err != nil {
			return "error", err.Error()
		}
		if len(out) > 240 {
			out = out[:240] + "…"
		}
		return "ok", out
	case "instance":
		switch act.Op {
		case "start":
			if err := e.exec.StartInst(ctx, agentID, instanceID); err != nil {
				return "error", err.Error()
			}
			return "ok", "started"
		case "stop":
			grace := act.GraceSeconds
			if grace == 0 {
				grace = 30
			}
			warnNote, cancelled := e.runWarningCascade(ctx, scheduleID, agentID, instanceID, act)
			if cancelled {
				return "cancelled", "schedule disabled mid-cascade" + warnNote
			}
			preSaveNote := e.runPreSave(ctx, agentID, instanceID, act)
			if scheduleID != "" && !e.isStillEnabled(scheduleID) {
				return "cancelled", "schedule disabled before stop" + warnNote + preSaveNote
			}
			if err := e.exec.StopInst(ctx, agentID, instanceID, grace); err != nil {
				return "error", err.Error()
			}
			detail := fmt.Sprintf("stopped (grace=%ds)%s%s", grace, warnNote, preSaveNote)
			if act.BackupBeforeStart && e.exec.Backup != nil {
				// Poll the controller DB for the stopped state instead of
				// sleeping a fixed window — the agent's InstanceStateUpdate
				// stream pushes the real status within ~1s of container exit,
				// so we can backup the moment it's safe rather than waiting
				// a worst-case grace period for fast-shutdown games.
				if werr := e.waitForStopped(ctx, instanceID, time.Duration(grace+30)*time.Second); werr != nil {
					return "error", detail + ", but wait_for_stop failed: " + werr.Error()
				}
				size, berr := e.exec.Backup(ctx, agentID, instanceID, "Pre-stop scheduled backup")
				if berr != nil {
					return "error", detail + ", but scheduled backup failed: " + berr.Error()
				}
				detail += fmt.Sprintf(" + clean backup (%d bytes)", size)
			}
			return "ok", detail
		case "restart":
			grace := act.GraceSeconds
			if grace == 0 {
				grace = 30
			}
			warnNote, cancelled := e.runWarningCascade(ctx, scheduleID, agentID, instanceID, act)
			if cancelled {
				return "cancelled", "schedule disabled mid-cascade" + warnNote
			}
			preSaveNote := e.runPreSave(ctx, agentID, instanceID, act)
			if scheduleID != "" && !e.isStillEnabled(scheduleID) {
				return "cancelled", "schedule disabled before stop" + warnNote + preSaveNote
			}
			if err := e.exec.StopInst(ctx, agentID, instanceID, grace); err != nil {
				return "error", "stop: " + err.Error()
			}
			// Poll for actual stopped state instead of sleeping. Saves
			// 5-25 seconds on most games and removes the race window where
			// a clean backup could begin while the container was still
			// flushing world state to disk.
			if werr := e.waitForStopped(ctx, instanceID, time.Duration(grace+30)*time.Second); werr != nil {
				return "error", "wait_for_stop: " + werr.Error()
			}
			backupNote := ""
			if act.BackupBeforeStart && e.exec.Backup != nil {
				size, berr := e.exec.Backup(ctx, agentID, instanceID, "Pre-restart scheduled backup")
				if berr != nil {
					// Non-fatal: the server should still come back up.
					// Surface in the fire-result detail so the operator
					// can see the schedule attempted a backup that failed.
					e.log.Warn("scheduled clean backup failed during restart", "instance_id", instanceID, "err", berr)
					backupNote = " (backup failed: " + berr.Error() + ")"
				} else {
					backupNote = fmt.Sprintf(" + clean backup (%d bytes)", size)
				}
			}
			if err := e.exec.StartInst(ctx, agentID, instanceID); err != nil {
				return "error", "start: " + err.Error() + backupNote + preSaveNote
			}
			return "ok", "restarted" + backupNote + warnNote + preSaveNote
		default:
			return "error", "unknown instance op: " + act.Op
		}
	case "backup":
		if e.exec.Backup == nil {
			return "error", "backup executor not configured"
		}
		desc := act.Description
		if desc == "" {
			desc = "Auto-backup"
		}
		size, err := e.exec.Backup(ctx, agentID, instanceID, desc)
		if err != nil {
			return "error", err.Error()
		}
		return "ok", fmt.Sprintf("%d bytes", size)
	case "empyrion":
		if e.exec.Empyrion == nil {
			return "error", "empyrion executor not configured"
		}
		out, err := e.exec.Empyrion(ctx, instanceID, act.Subop, act.Body)
		if err != nil {
			return "error", err.Error()
		}
		return "ok", out
	case "empyrion-fresh-start":
		if e.exec.EmpyrionFreshStart == nil {
			return "error", "empyrion-fresh-start executor not configured"
		}
		jobID, err := e.exec.EmpyrionFreshStart(ctx, instanceID, act.Body)
		if err != nil {
			return "error", err.Error()
		}
		return "ok", "fresh-start job " + jobID
	case "webhook":
		if act.URL == "" {
			return "error", "webhook action missing url"
		}
		method := act.Method
		if method == "" {
			method = "POST"
		}
		if e.exec.Webhook == nil {
			return "error", "webhook executor not configured"
		}
		code, err := e.exec.Webhook(ctx, act.URL, method, act.Body, act.Headers)
		if err != nil {
			return "error", err.Error()
		}
		return "ok", fmt.Sprintf("%s %s → %d", method, act.URL, code)
	default:
		return "error", "unknown action type: " + act.Type
	}
}

// runWarningCascade fires player-facing RCON say messages every minute from
// WarnLeadMinutes down to 1 before the stop step. {min} in the template is
// substituted with the remaining whole minutes. Marks in WarnExtraMinutes
// (default [2,1]) also get an extra emphasized line right after the regular
// one. RCON failures are logged but do not abort — the restart still lands.
//
// Returns (suffix, cancelled). cancelled=true means the schedule was
// disabled mid-cascade via the panel UI; the caller MUST skip the stop
// step in that case. A "cancelled" broadcast goes out to players before
// returning so they know the restart they were warned about isn't coming.
//
// scheduleID may be empty when this is called outside the scheduler path
// (currently never — but the guard keeps the new check inert if so).
func (e *scheduleEngine) runWarningCascade(ctx context.Context, scheduleID, agentID, instanceID string, act Action) (string, bool) {
	if act.WarnLeadMinutes == 0 {
		return "", false
	}
	tmpl := act.WarnTemplate
	if tmpl == "" {
		tmpl = `say "[ff0000]Server restart in {min} minute(s)![-]"`
	}
	extraTmpl := act.WarnExtraTemplate
	if extraTmpl == "" {
		extraTmpl = `say "[ff0000]⚠ FINAL WARNING — restart in {min} minute(s)! Get to safety![-]"`
	}
	extraSet := map[uint32]bool{}
	if len(act.WarnExtraMinutes) == 0 {
		extraSet[2] = true
		extraSet[1] = true
	} else {
		for _, m := range act.WarnExtraMinutes {
			extraSet[m] = true
		}
	}
	sub := func(s string, min uint32) string {
		r := strings.NewReplacer("{min}", fmt.Sprintf("%d", min), "{minutes}", fmt.Sprintf("%d", min), "{m}", fmt.Sprintf("%d", min))
		return r.Replace(s)
	}
	// notifyCancelled broadcasts a single line so players know the restart
	// they were just warned about isn't happening. RCON failure is logged
	// but doesn't stop the cancellation.
	notifyCancelled := func() {
		if e.exec.ExecRCON == nil {
			return
		}
		msg := `say "[00ff00]Scheduled restart cancelled by operator.[-]"`
		bctx, bcancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer bcancel()
		if _, err := e.exec.ExecRCON(bctx, agentID, instanceID, msg); err != nil {
			e.log.Warn("cancellation broadcast failed", "instance_id", instanceID, "err", err)
		}
	}
	sent := 0
	for min := act.WarnLeadMinutes; min >= 1; min-- {
		// Re-check enabled at every tick. Operator unchecking "on" during
		// a multi-minute cascade now actually aborts the in-flight restart.
		if scheduleID != "" && !e.isStillEnabled(scheduleID) {
			notifyCancelled()
			return fmt.Sprintf(" (warned %d msgs; cancelled by operator at %dmin)", sent, min), true
		}
		if _, err := e.exec.ExecRCON(ctx, agentID, instanceID, sub(tmpl, min)); err != nil {
			e.log.Warn("warning RCON failed; continuing cascade",
				"instance_id", instanceID, "min", min, "err", err)
		} else {
			sent++
		}
		if extraSet[min] {
			if _, err := e.exec.ExecRCON(ctx, agentID, instanceID, sub(extraTmpl, min)); err != nil {
				e.log.Warn("warning RCON (extra) failed; continuing cascade",
					"instance_id", instanceID, "min", min, "err", err)
			} else {
				sent++
			}
		}
		if min == 1 {
			break
		}
		select {
		case <-ctx.Done():
			return fmt.Sprintf(" (warned %d msgs; cancelled at %dmin)", sent, min), false
		case <-time.After(time.Minute):
		}
	}
	// Hold the final minute so the stop lands ~60s after the "1 minute" warning.
	// Operator can still cancel during this hold window.
	holdCheck := time.NewTicker(5 * time.Second)
	defer holdCheck.Stop()
	holdDone := time.After(time.Minute)
	for {
		select {
		case <-ctx.Done():
			return fmt.Sprintf(" (warned %d msgs; cancelled before stop)", sent), false
		case <-holdCheck.C:
			if scheduleID != "" && !e.isStillEnabled(scheduleID) {
				notifyCancelled()
				return fmt.Sprintf(" (warned %d msgs; cancelled by operator in final minute)", sent), true
			}
		case <-holdDone:
			return fmt.Sprintf(" (warned %d msgs over %dmin)", sent, act.WarnLeadMinutes), false
		}
	}
}

// runPreSave fires the optional save-RCON before a stop/restart and
// waits the configured delay so the in-process world flush has time to
// finish. Returns a short suffix to append to the fire-result detail
// so operators can see the pre-save fired (or didn't). Failures are
// logged but never block the surrounding stop/restart — the server
// will simply restore from its previous auto-save if the pre-save
// didn't land.
func (e *scheduleEngine) runPreSave(ctx context.Context, agentID, instanceID string, act Action) string {
	if act.PreSaveRCON == "" {
		return ""
	}
	delay := time.Duration(act.PreSaveDelaySec) * time.Second
	if delay == 0 {
		delay = 10 * time.Second
	}
	if _, err := e.exec.ExecRCON(ctx, agentID, instanceID, act.PreSaveRCON); err != nil {
		e.log.Warn("scheduled pre-save RCON failed; continuing with stop",
			"instance_id", instanceID, "cmd", act.PreSaveRCON, "err", err)
		return fmt.Sprintf(" (pre-save %q failed: %s)", act.PreSaveRCON, err.Error())
	}
	e.log.Info("scheduled pre-save fired, waiting before stop",
		"instance_id", instanceID, "cmd", act.PreSaveRCON, "delay", delay)
	select {
	case <-ctx.Done():
		return fmt.Sprintf(" (pre-save %q sent; cancelled before stop)", act.PreSaveRCON)
	case <-time.After(delay):
	}
	return fmt.Sprintf(" (pre-save %q sent +%s wait)", act.PreSaveRCON, delay)
}

// waitForStopped polls the controller's DB row for instanceID until its
// status is "stopped" or "crashed", or until the supplied timeout.
// The agent pushes InstanceStateUpdate envelopes whenever the container
// transitions, and the controller's gRPC handler writes them to the DB
// row, so this is normally <1s after the actual exit. Way more accurate
// than the prior naive sleep, especially for games whose graceful stop
// finishes fast (V Rising) or for ones whose grace exceeds the actual
// shutdown by a wide margin (ARK on a small map).
func (e *scheduleEngine) waitForStopped(ctx context.Context, instanceID string, timeout time.Duration) error {
	deadline := time.Now().Add(timeout)
	tick := 500 * time.Millisecond
	for {
		// Single-row lookup: this used to re-list EVERY instance (full table
		// scan + config/ports JSON decode per row) twice a second per waiter.
		row, err := e.db.GetInstanceRow(ctx, instanceID)
		if err == nil {
			switch row.Status {
			case "stopped", "crashed":
				return nil
			}
		}
		if time.Now().After(deadline) {
			return fmt.Errorf("timed out after %s waiting for instance to stop", timeout)
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(tick):
		}
	}
}

func (e *scheduleEngine) record(id, status, detail string) {
	if e.onFire != nil {
		e.onFire(status)
	}
	if err := e.db.UpdateScheduleFireResult(context.Background(), id, status, detail); err != nil {
		e.log.Warn("record fire result", "id", id, "err", err)
	}
}

// newScheduleID returns a random, URL-safe, panel-prefixed id.
func newScheduleID() string {
	b := make([]byte, 8)
	_, _ = rand.Read(b)
	return "sch_" + hex.EncodeToString(b)
}
