// Package dispatch ties together the Target-side components: it receives
// ControllerEnvelope messages off the gRPC stream, routes them to the
// module registry and the active Runtime, and emits AgentEnvelope state
// updates back to the Controller.
//
// Survivability:
//
//   - On successful Create, dispatch writes a sidecar JSON in metaDir so
//     the agent can re-attach to the running container after a restart.
//   - On Stop, the sidecar is deleted.
//   - On agent startup, Rehydrate() scans sidecars, verifies each
//     container still exists, and rebuilds the in-memory instance map.
//   - On every (re)connect, SetSender + Announce re-plumb the upward
//     pipe and re-send current state so the controller's DB stays honest.
package dispatch

import (
	"context"
	"fmt"
	"log/slog"
	"net"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"sync/atomic"
	"time"

	"google.golang.org/protobuf/types/known/timestamppb"

	agentmodule "github.com/dbledeez/panel/agent/internal/module"
	"github.com/dbledeez/panel/agent/internal/runtime"
	"github.com/dbledeez/panel/agent/internal/rcon"
	"github.com/dbledeez/panel/agent/internal/state"
	modulepkg "github.com/dbledeez/panel/pkg/module"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// Sender is the subset of the gRPC stream we use to send events upstream.
type Sender interface {
	Send(*panelv1.AgentEnvelope) error
}

// Dispatcher wires the gRPC stream to the runtime and module registry.
type Dispatcher struct {
	log       *slog.Logger
	modules   *modulepkg.Registry
	runtime   runtime.Runtime
	dataRoot  string
	metaDir   string
	backupDir string

	sendMu sync.Mutex
	send   Sender
	// sendCh decouples envelope producers (handler goroutines, trackers,
	// log streams) from the actual gRPC Send. A single drainer goroutine
	// owns the stream's Send so concurrent producers never race on it
	// (grpc ClientStream.Send is NOT safe for concurrent use) and never
	// block the dispatch path when the controller is slow to drain. High-
	// frequency telemetry (logs/stats/app-state/player/heartbeat) is
	// DROPPED when the buffer is full rather than blocking — losing a log
	// line is fine (the controller's durable state comes from Announce +
	// InstanceState, not replayed history); freezing the agent is not.
	// This is what stopped a chatty game (VEIN) from wedging the whole
	// agent fleet-wide via a backpressured synchronous Send.
	sendCh     chan *panelv1.AgentEnvelope
	sendClosed chan struct{}

	mu        sync.Mutex
	instances map[string]*instanceRecord
	// fsHelpers holds one long-lived alpine sidecar per stopped instance
	// whose Files tab has been opened. Lets operators browse/edit files
	// on a stopped container. Reaped after ~5 min idle; torn down when
	// the main instance starts (frees up volumes for the game).
	fsHelpers map[string]*fsHelper
	// updaters tracks the active updater goroutine per instance so a
	// stop / delete can cancel an in-flight steamcmd download. Without
	// this the sidecar runs to completion (or its 30-min timeout)
	// holding the volume mount, blocking the main delete from purging
	// — which the operator sees as "stuck downloading" with the parent
	// already gone. Each entry holds a unique pointer so the
	// unregister path can compare against the slot it installed.
	updaters map[string]*updaterSlot

	// uploads holds in-progress chunked uploads keyed by upload_id.
	// Each entry owns a temp file the chunk handler appends to; on the
	// final chunk the file is shipped to the container, closed and
	// unlinked. See files_chunked.go.
	uploads map[string]*uploadSession
}

// updaterSlot pairs the cancel func with a unique pointer identity so
// concurrent unregisters from racing updater goroutines don't clobber
// each other. Comparing pointers is cheap and correct.
type updaterSlot struct {
	cancel context.CancelFunc
}

// fsHelper tracks one file-ops sidecar: alpine mounting the instance's
// volumes read/write, running `sleep infinity`.
type fsHelper struct {
	containerID string
	lastAccess  time.Time
}

type instanceRecord struct {
	InstanceID     string
	ContainerID    string
	ModuleID       string
	// Branch is the normalized Steam branch this instance was installed from
	// (e.g. "v2.6", "latest_experimental", "public"), derived at Create from
	// the chosen update_provider. Used by warm-seed so a new instance only
	// reuses an install from a sibling on the SAME branch — copying a 3.0
	// (experimental) install into a server meant to run 2.6 would corrupt it.
	// Empty on instances created before this feature (treated as the module
	// default branch by branchOrDefault).
	Branch         string
	DataPath       string
	// BrowseableRoot is the default (first) file-manager root —
	// container-absolute. BrowseableRoots is the full list; when the
	// module exposes more than one view (7DTD: saves + game files),
	// safeJoinContainer accepts any of them. Always contains at least
	// BrowseableRoot.
	BrowseableRoot  string
	BrowseableRoots []modulepkg.BrowseableRoot
	LogCancel       context.CancelFunc

	// ExitWatchCancel single-flights the watchExit goroutine per container
	// lifetime. Before this guard, every activate() call — handleStart,
	// Announce on EVERY controller reconnect, the post-warm-seed autostart —
	// spawned a fresh watchExit, each parked in a ctx-unbounded
	// ContainerWait: leaked goroutines + leaked docker connections (the same
	// failure shape as the ExecCapture leak) + duplicate crashed/exited
	// emissions when the container finally died. Guarded by d.mu like the
	// other cancel fields.
	ExitWatchCancel context.CancelFunc

	// RCON wire-up, resolved at Create time, consumed at Start time.
	RCONAddr     string
	RCONPassword string

	// State tracker and its cancel, populated on Start.
	Tracker       *state.Tracker
	TrackerCancel context.CancelFunc
	TrackerDone   chan struct{}

	// stopping suppresses watchExit's CRASHED emission during graceful stop.
	stopping atomic.Bool

	// memGuardWarned latches true the first time the 45 GB warn path
	// fires on this container. Both paths (warn + hard) check this to
	// avoid stacking SIGKILL on top of an in-flight graceful save.
	memGuardWarned atomic.Bool

	// lastStatus tracks the most recent InstanceStatus the agent sent
	// upstream for this instance. Read by EmitAppState to promote a stale
	// CRASHED status back to RUNNING when state-source polls succeed —
	// the docker-restart-policy auto-recovery flip in watchExit doesn't
	// fire when a server segfaults during world-gen but stabilizes on
	// retry, leaving the panel stuck on CRASHED until manual stop+start.
	lastStatus atomic.Int32

	// hangGuard is the per-instance state for the ark-sa hang-detection
	// guardrail. Embedded by value so its zero value is usable. Methods
	// on Dispatcher (arkHangGuardOn{LogLine,RconResult}, arkHangGuardRestart)
	// own the field; nothing outside the guard touches it. See hangguard.go.
	hangGuard hangGuardState

	// provisionGuard single-flights the 7dtd cluster auto-provision watcher
	// (one per instance at a time). See decoremap_provision.go.
	provisionGuard atomic.Bool
}

// New constructs a dispatcher. The caller sets the sender via SetSender
// after the initial handshake (and on each reconnect).
func New(log *slog.Logger, modules *modulepkg.Registry, rt runtime.Runtime, dataRoot, metaDir, backupDir string) *Dispatcher {
	d := &Dispatcher{
		log:       log,
		modules:   modules,
		runtime:   rt,
		dataRoot:  dataRoot,
		metaDir:   metaDir,
		backupDir: backupDir,
		instances: map[string]*instanceRecord{},
		fsHelpers: map[string]*fsHelper{},
		updaters:  map[string]*updaterSlot{},
		uploads:   map[string]*uploadSession{},
		// Buffer sized to absorb a burst of telemetry from a chatty game
		// while the controller catches up. Beyond this, droppable envelopes
		// are discarded (see sendEnv). 4096 is generous for ~dozens of
		// instances; a full buffer means the controller is badly behind and
		// dropping stale logs/stats is the right call.
		sendCh: make(chan *panelv1.AgentEnvelope, 4096),
	}
	go d.fsHelperReaper()
	go d.uploadReaper()
	go d.sendLoop()
	return d
}

// sendLoop is the SOLE owner of stream.Send. It drains sendCh and writes each
// envelope to the current sender. Running on one goroutine guarantees Sends
// are serialized (grpc ClientStream.Send is not safe for concurrent use) and
// that a slow/backpressured controller can never block a producer past the
// buffer. Envelopes enqueued while disconnected (send == nil) are dropped —
// the controller rebuilds durable state from Announce on reconnect.
func (d *Dispatcher) sendLoop() {
	for env := range d.sendCh {
		d.sendMu.Lock()
		s := d.send
		d.sendMu.Unlock()
		if s == nil {
			continue
		}
		_ = s.Send(env)
	}
}

// Modules exposes the registry so main() can summarize it into the
// AgentHello envelope. Read-only from the caller's perspective.
func (d *Dispatcher) Modules() *modulepkg.Registry { return d.modules }

// SetSender installs (or replaces) the upstream sender after a
// (re)connect. Safe to call from any goroutine.
func (d *Dispatcher) SetSender(s Sender) {
	d.sendMu.Lock()
	d.send = s
	d.sendMu.Unlock()
}

// sendEnv enqueues an envelope for the sendLoop drainer instead of calling
// stream.Send inline. This keeps producers (handler goroutines, trackers, log
// streams) off the gRPC send path entirely: they never race on Send and never
// block when the controller is slow to drain.
//
// Backpressure policy: high-frequency telemetry (logs / stats / app-state /
// player / heartbeat) is DROPPED if the buffer is full — losing a log line is
// harmless, wedging the agent is not. Low-frequency CRITICAL envelopes
// (command replies, instance-state, FS/backup/etc. results) are never dropped:
// if the buffer is somehow full they block briefly until space frees. Because
// critical envelopes are rare, this can't cause the flood-driven wedge that a
// chatty game's telemetry would.
//
// Messages emitted while disconnected (send == nil) are still effectively
// dropped by sendLoop — intentional, the controller rebuilds durable state
// from Announce() on reconnect, not replayed history.
func (d *Dispatcher) sendEnv(env *panelv1.AgentEnvelope) {
	if isDroppableEnvelope(env) {
		select {
		case d.sendCh <- env:
		default:
			// Buffer full — drop this telemetry frame rather than block.
		}
		return
	}
	// Critical envelope: enqueue, blocking only if the buffer is full (rare).
	d.sendCh <- env
}

// SendHeartbeat enqueues a heartbeat envelope on the droppable send queue.
// Routing heartbeats through sendLoop keeps it the SOLE owner of stream.Send:
// previously main.go's session ticker called stream.Send directly, racing the
// drainer goroutine (grpc ClientStream.Send is NOT safe for concurrent use).
// Heartbeats are droppable telemetry — a skipped beat under backpressure is
// harmless. Note the tradeoff: a broken stream is now detected by the recv
// loop (stream.Recv errors when the transport dies), not by a failed
// heartbeat send.
func (d *Dispatcher) SendHeartbeat(t time.Time) {
	d.sendEnv(&panelv1.AgentEnvelope{
		SentAt: timestamppb.New(t),
		Payload: &panelv1.AgentEnvelope_Heartbeat{
			Heartbeat: &panelv1.Heartbeat{At: timestamppb.New(t)},
		},
	})
}

// isDroppableEnvelope reports whether an envelope is high-frequency telemetry
// that's safe to discard under backpressure. Everything not listed here is
// treated as critical (command replies, instance state, FS/backup results,
// hello/announce) and is never dropped.
func isDroppableEnvelope(env *panelv1.AgentEnvelope) bool {
	switch env.GetPayload().(type) {
	case *panelv1.AgentEnvelope_Log,
		*panelv1.AgentEnvelope_InstanceStats,
		*panelv1.AgentEnvelope_AppState,
		*panelv1.AgentEnvelope_Player,
		*panelv1.AgentEnvelope_Heartbeat:
		return true
	default:
		return false
	}
}

// Handle routes a single inbound envelope.
func (d *Dispatcher) Handle(ctx context.Context, env *panelv1.ControllerEnvelope) {
	switch p := env.Payload.(type) {
	case *panelv1.ControllerEnvelope_Ping:
		d.sendEnv(&panelv1.AgentEnvelope{
			CorrelationId: env.CorrelationId,
			SentAt:        timestamppb.Now(),
			Payload:       &panelv1.AgentEnvelope_Pong{Pong: &panelv1.Pong{Nonce: p.Ping.Nonce}},
		})
	case *panelv1.ControllerEnvelope_InstanceCreate:
		d.handleCreate(ctx, env.CorrelationId, p.InstanceCreate)
	case *panelv1.ControllerEnvelope_InstanceStart:
		d.handleStart(ctx, env.CorrelationId, p.InstanceStart)
	case *panelv1.ControllerEnvelope_InstanceStop:
		d.handleStop(ctx, env.CorrelationId, p.InstanceStop)
	case *panelv1.ControllerEnvelope_InstanceDelete:
		d.handleDelete(ctx, env.CorrelationId, p.InstanceDelete)
	case *panelv1.ControllerEnvelope_Backup:
		d.handleBackup(ctx, env.CorrelationId, p.Backup)
	case *panelv1.ControllerEnvelope_Restore:
		d.handleRestore(ctx, env.CorrelationId, p.Restore)
	case *panelv1.ControllerEnvelope_BackupListEntries:
		d.handleBackupListEntries(env.CorrelationId, p.BackupListEntries)
	case *panelv1.ControllerEnvelope_BackupReadFile:
		d.handleBackupReadFile(env.CorrelationId, p.BackupReadFile)
	case *panelv1.ControllerEnvelope_Exec:
		// No exec implementation yet — tell the caller instead of logging
		// and silently succeeding (the controller's Await would otherwise
		// hang until timeout with no signal why).
		d.log.Warn("exec requested but not implemented", "instance_id", p.Exec.InstanceId)
		d.replyError(env.CorrelationId, p.Exec.InstanceId, "exec_not_implemented", "exec is not implemented on this agent")
	case *panelv1.ControllerEnvelope_Rcon:
		d.handleRCON(ctx, env.CorrelationId, p.Rcon)
	case *panelv1.ControllerEnvelope_FsList:
		d.handleFsList(env.CorrelationId, p.FsList)
	case *panelv1.ControllerEnvelope_FsRead:
		d.handleFsRead(env.CorrelationId, p.FsRead)
	case *panelv1.ControllerEnvelope_FsWrite:
		d.handleFsWrite(env.CorrelationId, p.FsWrite)
	case *panelv1.ControllerEnvelope_FsDelete:
		d.handleFsDelete(env.CorrelationId, p.FsDelete)
	case *panelv1.ControllerEnvelope_FsSymlink:
		d.handleFsSymlink(env.CorrelationId, p.FsSymlink)
	case *panelv1.ControllerEnvelope_FsExtract:
		d.handleFsExtract(env.CorrelationId, p.FsExtract)
	case *panelv1.ControllerEnvelope_FsCompress:
		d.handleFsCompress(env.CorrelationId, p.FsCompress)
	case *panelv1.ControllerEnvelope_FsRename:
		d.handleFsRename(env.CorrelationId, p.FsRename)
	case *panelv1.ControllerEnvelope_FsWriteChunk:
		d.handleFsWriteChunk(env.CorrelationId, p.FsWriteChunk)
	case *panelv1.ControllerEnvelope_ArkSaveRestore:
		d.handleArkSaveRestore(env.CorrelationId, p.ArkSaveRestore)
	case *panelv1.ControllerEnvelope_DayzModInstall:
		d.handleDayzModInstall(env.CorrelationId, p.DayzModInstall)
	case *panelv1.ControllerEnvelope_DayzModUninstall:
		d.handleDayzModUninstall(env.CorrelationId, p.DayzModUninstall)
	case *panelv1.ControllerEnvelope_Update:
		d.handleUpdate(env.CorrelationId, p.Update)
	case *panelv1.ControllerEnvelope_EmpyrionScenarioInstall:
		d.handleEmpyrionScenarioInstall(env.CorrelationId, p.EmpyrionScenarioInstall)
	case *panelv1.ControllerEnvelope_EmpyrionDiscoveries:
		d.handleEmpyrionDiscoveries(env.CorrelationId, p.EmpyrionDiscoveries)
	case *panelv1.ControllerEnvelope_EmpyrionPlayerSummary:
		d.handleEmpyrionPlayerSummary(env.CorrelationId, p.EmpyrionPlayerSummary)
	case *panelv1.ControllerEnvelope_RegionScan:
		d.handleRegionScan(ctx, env.CorrelationId, p.RegionScan)
	case *panelv1.ControllerEnvelope_RegionHeal:
		d.handleRegionHeal(ctx, env.CorrelationId, p.RegionHeal)
	case *panelv1.ControllerEnvelope_InstanceRenderConfig:
		d.handleRenderConfig(ctx, env.CorrelationId, p.InstanceRenderConfig)
	case *panelv1.ControllerEnvelope_SeedMods:
		d.handleSeedMods(ctx, env.CorrelationId, p.SeedMods)
	case *panelv1.ControllerEnvelope_UpdateCheck:
		d.handleUpdateCheck(env.CorrelationId, p.UpdateCheck)
	default:
		d.log.Debug("unhandled controller message", "type", fmt.Sprintf("%T", env.Payload))
	}
}

// Shutdown winds down agent-internal goroutines on SIGINT/SIGTERM but
// LEAVES the game containers running. A `systemctl restart panel-agent`
// shouldn't stop every ARK / 7DTD / V Rising the operator has up — the
// agent is a control-plane process; the game containers are workloads
// that should survive control-plane restarts the same way they survive
// gRPC blips. Earlier behaviour (Stop every instance) caused half a
// dozen game containers to drop out from under players any time the
// agent binary was redeployed.
//
// What we still tear down: state trackers + log-stream pumps, since
// those are agent-side goroutines holding open RCON dials and Docker
// log subscriptions that the next agent process will re-establish on
// rehydrate. The Docker container itself stays untouched.
func (d *Dispatcher) Shutdown(_ context.Context) {
	// Snapshot the cancel funcs under d.mu (they're mutated under the same
	// mutex by activate/handleStop), then invoke them outside the lock.
	d.mu.Lock()
	cancels := make([]context.CancelFunc, 0, len(d.instances)*3)
	n := 0
	for _, r := range d.instances {
		n++
		for _, c := range []context.CancelFunc{r.TrackerCancel, r.LogCancel, r.ExitWatchCancel} {
			if c != nil {
				cancels = append(cancels, c)
			}
		}
		r.TrackerCancel = nil
		r.LogCancel = nil
		r.ExitWatchCancel = nil
	}
	d.mu.Unlock()
	for _, c := range cancels {
		c()
	}
	d.log.Info("agent shutdown — game containers left running for next agent rehydrate", "instance_count", n)
}

// Rehydrate loads each sidecar in metaDir, verifies the underlying
// container still exists, and rebuilds the in-memory instance record.
// Stale sidecars (container gone) are deleted. Intended for agent cold
// start. Activation (log stream + tracker) is deferred to Announce so
// the sender is in place before we start emitting events.
func (d *Dispatcher) Rehydrate(ctx context.Context) error {
	metas, err := d.loadAllMeta()
	if err != nil {
		return fmt.Errorf("load meta: %w", err)
	}
	haveMeta := make(map[string]bool, len(metas))
	for _, m := range metas {
		haveMeta[m.InstanceID] = true
	}
	for _, m := range metas {
		state, err := d.runtime.InspectByName(ctx, "panel-"+m.InstanceID)
		if err != nil {
			d.log.Warn("rehydrate: container gone, removing sidecar", "instance_id", m.InstanceID, "err", err)
			d.deleteMeta(m.InstanceID)
			continue
		}
		// If the sidecar predates this feature, BrowseableRoot is empty.
		// Fill it from the current manifest so rehydrated instances gain
		// container-path file ops without needing recreate.
		browseable := m.BrowseableRoot
		var roots []modulepkg.BrowseableRoot
		if mf, ok := d.modules.Get(m.ModuleID); ok {
			// Always re-read roots from current manifest — lets operators
			// add new browseable_roots entries and have existing instances
			// pick them up on next agent restart without a container rebuild.
			roots = resolveBrowseableRoots(mf)
			if browseable == "" {
				browseable = resolveBrowseableRoot(mf)
			}
		}
		d.mu.Lock()
		d.instances[m.InstanceID] = &instanceRecord{
			InstanceID:      m.InstanceID,
			ContainerID:     state.ContainerID,
			ModuleID:        m.ModuleID,
			Branch:          m.Branch,
			DataPath:        m.DataPath,
			BrowseableRoot:  browseable,
			BrowseableRoots: roots,
			RCONAddr:        m.RCONAddr,
			RCONPassword:    m.RCONPassword,
		}
		d.mu.Unlock()
		d.log.Info("rehydrated instance",
			"instance_id", m.InstanceID,
			"module_id", m.ModuleID,
			"container_status", state.Status,
		)
		// Reap orphan sidecar containers from a previous agent crash.
		// steamcmd/backup/wipe sidecars are usually torn down by their
		// goroutine's defer — but if the agent dies mid-run, the defer
		// never fires and the sidecar lingers. It then holds the
		// instance's volumes "in use," blocking future delete-with-purge.
		//
		// We only sweep EXITED containers here; running ones might be
		// legitimately in use (e.g. an fs-helper sidecar an operator
		// kept open). Force-removed exited orphans are safe — they
		// already finished whatever they were doing.
		if dr, ok := d.runtime.(*runtime.DockerRuntime); ok {
			holders, err := dr.ListContainersHoldingInstanceVolumes(ctx, m.InstanceID)
			if err == nil {
				for _, hid := range holders {
					if hid == state.ContainerID {
						continue // the main instance container itself
					}
					// InspectByName accepts an ID too — Docker's ContainerInspect
					// resolves either form. Status tells us if this is an
					// exited orphan vs a still-running fs-helper.
					hstate, ierr := dr.InspectByName(ctx, hid)
					if ierr != nil {
						continue
					}
					if hstate.Status == "exited" || hstate.Status == "dead" {
						if err := dr.ForceRemoveContainer(ctx, hid); err == nil {
							d.log.Info("rehydrate: reaped orphan sidecar", "instance_id", m.InstanceID, "container_id", short(hid))
						}
					}
				}
			}
		}
	}

	// Orphan detection: a main game-server container (panel.role=instance)
	// that exists on the runtime but has NO sidecar metadata. This is the
	// fingerprint of an interrupted delete — e.g. a delete racing with
	// agent shutdown, where the sidecar got removed but the container
	// remove failed because the Docker connection died mid-call. Such an
	// instance is fully invisible to the agent (every file/RCON/stop op
	// returns "not on this target") even though the controller still
	// routes to it and the world keeps running. Surface it loudly so it
	// can be recovered instead of silently lost.
	if dr, ok := d.runtime.(*runtime.DockerRuntime); ok {
		names, lerr := dr.ListMainInstanceNames(ctx)
		if lerr != nil {
			d.log.Warn("rehydrate: orphan scan failed", "err", lerr)
		} else {
			for _, id := range names {
				if haveMeta[id] {
					continue
				}
				d.mu.Lock()
				_, tracked := d.instances[id]
				d.mu.Unlock()
				if tracked {
					continue
				}
				d.log.Error("rehydrate: ORPHAN container has no sidecar metadata — instance is invisible to the agent (likely an interrupted delete). Recover by recreating its sidecar or re-issuing create/delete from the controller.",
					"instance_id", id, "container", "panel-"+id)
			}
		}
	}

	return nil
}

// Announce re-sends the current state of every tracked instance to the
// controller. For instances whose containers are currently running, it
// also (re)activates log streaming + the state tracker. Called after
// each (re)connect.
func (d *Dispatcher) Announce(ctx context.Context) {
	d.mu.Lock()
	recs := make([]*instanceRecord, 0, len(d.instances))
	for _, r := range d.instances {
		recs = append(recs, r)
	}
	d.mu.Unlock()

	for _, rec := range recs {
		rtState, err := d.runtime.InspectByName(ctx, "panel-"+rec.InstanceID)
		if err != nil {
			d.log.Warn("announce: inspect failed", "instance_id", rec.InstanceID, "err", err)
			continue
		}
		status := rtStatusToProto(rtState.Status)
		d.sendInstanceState(rec.InstanceID, status, rtState.ExitCode, "announced:"+rtState.Status)
		if rtState.Status == "running" {
			// On every reconnect, tear down and rebuild the log + stats
			// streams. The reason: the controller's in-memory log ring buffer
			// is wiped on its restart, so when the user opens the Console tab
			// after a controller restart, history is blank. Docker's
			// ContainerLogs API replays Tail=400 lines on every fresh attach,
			// so rebuilding the streams here repopulates the controller's
			// ring buffer with recent history (including the per-module
			// "ready" log line, which lets the dashboard's auto-scan clear
			// the "starting" overlay without needing the operator to open
			// the console). Old streams keep flowing in the background until
			// their goroutines notice the cancelled context and exit.
			d.mu.Lock()
			prevLogCancel := rec.LogCancel
			rec.LogCancel = nil
			d.mu.Unlock()
			if prevLogCancel != nil {
				prevLogCancel()
			}
			d.activate(rec)
		}
	}
}

// ---- Emitter (used by state.Tracker) ----

// EmitAppState forwards an AppStateUpdate upstream.
//
// Side effect: a successful state-source poll (the only thing that
// produces AppStateUpdate) implies the server is responsive. If the
// last status we sent was CRASHED, promote to RUNNING — handles the
// "segfault on first world-gen, healthy on retry" pattern where docker
// restart-policy auto-recovery in watchExit doesn't fire because the
// container's restart was driven by a different cause.
func (d *Dispatcher) EmitAppState(a *panelv1.AppStateUpdate) {
	if a != nil && a.InstanceId != "" {
		d.mu.Lock()
		rec := d.instances[a.InstanceId]
		d.mu.Unlock()
		if rec != nil {
			last := panelv1.InstanceStatus(rec.lastStatus.Load())
			if last == panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED {
				d.sendInstanceState(a.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0,
					"running (recovered — state source healthy)")
			}
		}
	}
	d.sendEnv(&panelv1.AgentEnvelope{
		SentAt:  timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_AppState{AppState: a},
	})
}

// EmitPlayerEvent forwards a PlayerEvent upstream.
func (d *Dispatcher) EmitPlayerEvent(p *panelv1.PlayerEvent) {
	d.sendEnv(&panelv1.AgentEnvelope{
		SentAt:  timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_Player{Player: p},
	})
}

// ---- Lifecycle handlers ----

// handleCreate resolves the manifest, creates the container, and records it.
// The container is NOT started here — that's InstanceStart's job.
func (d *Dispatcher) handleCreate(ctx context.Context, corrID string, req *panelv1.InstanceCreate) {
	log := d.log.With("instance_id", req.InstanceId, "module_id", req.ModuleId, "correlation_id", corrID)

	manifest, ok := d.modules.Get(req.ModuleId)
	if !ok {
		d.replyError(corrID, req.InstanceId, "module_not_found", fmt.Sprintf("module %q not in registry", req.ModuleId))
		log.Warn("module not found")
		return
	}

	if !manifest.HasMode("docker") {
		d.replyError(corrID, req.InstanceId, "mode_unsupported", "module does not support docker mode")
		return
	}

	if req.DataPath == "" {
		req.DataPath = filepath.Join(d.dataRoot, req.InstanceId)
	}
	if err := os.MkdirAll(req.DataPath, 0o755); err != nil {
		d.replyError(corrID, req.InstanceId, "data_path_mkdir", err.Error())
		log.Error("mkdir data_path", "err", err)
		return
	}

	// Expand templated paths in any mount_overrides the controller sent,
	// then mkdir the resolved host path so Docker's bind-mount won't
	// silently create an empty root-owned dir elsewhere.
	//
	// Templates understood (both are absolute after expansion):
	//   $AGENT_DATA_ROOT  → agent's --data-root flag (where per-instance
	//                       data lives; default ./data/instances)
	//   $PANEL_DATA_ROOT  → parent of AGENT_DATA_ROOT — the panel's data
	//                       root (./data on the default setup). This is
	//                       where cross-instance shared dirs live (like
	//                       ARK cluster transfer dirs, alongside
	//                       instances/ and backups/).
	if len(req.MountOverrides) > 0 {
		panelRoot := filepath.Dir(d.dataRoot)
		for k, v := range req.MountOverrides {
			exp := v
			exp = strings.ReplaceAll(exp, "$PANEL_DATA_ROOT", panelRoot)
			exp = strings.ReplaceAll(exp, "$AGENT_DATA_ROOT", d.dataRoot)
			req.MountOverrides[k] = exp
			if filepath.IsAbs(exp) {
				if err := os.MkdirAll(exp, 0o755); err != nil {
					d.replyError(corrID, req.InstanceId, "mount_mkdir", err.Error())
					log.Error("mkdir mount override", "path", exp, "err", err)
					return
				}
			}
		}
	}

	values, err := mergeValuesAndSecrets(manifest, req.ConfigValues)
	if err != nil {
		d.replyError(corrID, req.InstanceId, "secrets", err.Error())
		log.Error("generate secrets", "err", err)
		return
	}
	if err := modulepkg.RenderForBranch(manifest, req.DataPath, values, resolveCreateBranch(manifest, values)); err != nil {
		d.replyError(corrID, req.InstanceId, "render", err.Error())
		log.Error("render config files", "err", err)
		return
	}
	req.ConfigValues = values

	// _PANEL_SUPPRESS_AUTOSTART sentinel — controller-set marker for
	// recreate dances (env-config / change-ports / cluster / mods) that
	// preserve the prior running state. When true, skip the agent's
	// post-create auto-start path so a stopped instance stays stopped
	// after the recreate. The controller will issue an explicit Start
	// itself when the prior state was "running".
	suppressAutoStart := false
	if v := req.ConfigValues["_PANEL_SUPPRESS_AUTOSTART"]; v == "true" {
		suppressAutoStart = true
		delete(req.ConfigValues, "_PANEL_SUPPRESS_AUTOSTART")
	}

	// _PANEL_SKIP_REINSTALL sentinel — controller-set marker for recreate
	// dances that PRESERVE the install volume (env-config / change-ports /
	// cluster / mods). On a recreate the game files + the operator's custom
	// Mods are already on the volume, so the create-time warm-seed +
	// first-update must NOT run: warm-seed does `find /game -delete` and
	// re-copies from an ARBITRARY sibling, which silently drops a 7DTD
	// server's custom Mods (the whole point of preserving the volume is to
	// keep them). A first-ever create never carries this flag, so brand-new
	// installs still warm-seed / download normally. Invariant for callers:
	// any path that sets this must also set _PANEL_SUPPRESS_AUTOSTART when the
	// prior state was stopped AND issue its own Start when it was running —
	// the agent does neither for a skip-reinstall recreate.
	skipReinstall := false
	if v := req.ConfigValues["_PANEL_SKIP_REINSTALL"]; v == "true" {
		skipReinstall = true
		delete(req.ConfigValues, "_PANEL_SKIP_REINSTALL")
	}

	// _PANEL_SEED_MODS_FROM sentinel — controller-set marker for a cluster
	// join that should clone the cluster master's Mods set onto this server.
	// Value is the master's instance id. Handled in the skip-reinstall branch
	// below (a cluster join is always a volume-preserving recreate): we run a
	// Mods-only sidecar copy before the controller starts the server.
	seedModsFrom := ""
	if v := req.ConfigValues["_PANEL_SEED_MODS_FROM"]; v != "" {
		seedModsFrom = v
		delete(req.ConfigValues, "_PANEL_SEED_MODS_FROM")
	}

	// _PANEL_WIPE_PATHS sentinel — comma-separated list of paths
	// (relative to BrowseableRoot) the controller wants nuked before
	// the new container starts. Used by the ARK map-switch flow when
	// the operator checks "wipe existing world data" — clean slate
	// for the new map without keeping the old map's saves on disk.
	//
	// Strip the key so it doesn't pollute the container env. Wipe runs
	// AFTER the previous container was deleted (volumes preserved) and
	// BEFORE we create the new one, in an alpine sidecar that mounts
	// the same volumes we're about to mount.
	if wipeCSV := req.ConfigValues["_PANEL_WIPE_PATHS"]; wipeCSV != "" {
		delete(req.ConfigValues, "_PANEL_WIPE_PATHS")
		paths := []string{}
		for _, p := range strings.Split(wipeCSV, ",") {
			p = strings.TrimSpace(p)
			if p != "" {
				paths = append(paths, p)
			}
		}
		if len(paths) > 0 {
			if err := d.runWipeSidecar(ctx, manifest, req.InstanceId, req.DataPath, paths); err != nil {
				d.replyError(corrID, req.InstanceId, "wipe", err.Error())
				log.Error("wipe sidecar", "err", err)
				return
			}
			log.Info("wiped paths before create", "instance_id", req.InstanceId, "paths", paths)
		}
	}

	// Auto-bump host ports that collide with already-bound ports on the
	// host. Without this, two ARK SA instances both try to bind 7777/UDP
	// + 27020/TCP — the second container's port mapping silently drops
	// (Docker creates the container without published ports), the agent's
	// RCON tracker still dials the manifest default (27020), and ends up
	// talking to whichever container won the binding. Result: both
	// "running" servers share state from one container's RCON.
	//
	// We mutate req.Ports in place so both ResolveDocker (below) and
	// resolveRCONAddress (after Create) see the bumped ports — keeping
	// the RCON dial address aligned with the actual host binding.
	if err := assignNonCollidingHostPorts(manifest, req); err != nil {
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "port_alloc_failed: "+err.Error())
		d.replyError(corrID, req.InstanceId, "port_alloc", err.Error())
		log.Error("port allocation", "err", err)
		return
	}

	// Mirror the assigned ports into ConfigValues for any port that
	// declares an `env:` mapping. This is what makes a non-default
	// host port reach the game binary — without it, two ARK instances
	// pick distinct host ports but both ARKs internally bind 7777/27015
	// because their ASA_PORT/QUERY_PORT envs are still the manifest
	// defaults. Steam server browser would then list only one of them.
	//
	// Also force ContainerPort = HostPort for ALL ports. The game's
	// env var tells it which port to bind INSIDE the container; that
	// port has to match what Docker forwards from the outside, otherwise
	// the agent's RCON dial (or a player's join attempt) hits an empty
	// container port. Was previously a 1:1 mapping for non-internal
	// ports only — but RCON's env var (e.g. RCON_PORT) also had to
	// follow the host port to avoid the same mismatch internally.
	if req.ConfigValues == nil {
		req.ConfigValues = map[string]string{}
	}
	for i, p := range req.Ports {
		if p == nil {
			continue
		}
		decl := manifest.Port(p.Name)
		if decl == nil {
			continue
		}
		if decl.Env != "" {
			req.ConfigValues[decl.Env] = fmt.Sprintf("%d", p.HostPort)
		}
		req.Ports[i].ContainerPort = p.HostPort
	}

	spec, err := agentmodule.ResolveDocker(manifest, req)
	if err != nil {
		d.replyError(corrID, req.InstanceId, "resolve", err.Error())
		log.Error("resolve docker spec", "err", err)
		return
	}

	// Forward image-acquisition progress (auto-build of `panel-*` images
	// on first use, or registry pull progress) to the controller as log
	// lines on the regular stdout stream — operator sees them in the
	// Console tab while waiting on the create. Without this, first-time
	// builds run silently for 30s–3min and the UI just sits on
	// "installing".
	spec.LogSink = func(line string) {
		d.sendEnv(&panelv1.AgentEnvelope{
			SentAt: timestamppb.Now(),
			Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
				InstanceId: req.InstanceId,
				Stream:     "stdout",
				At:         timestamppb.Now(),
				Line:       line,
			}},
		})
	}

	log.Info("pulling image and creating container", "image", spec.Image)
	containerID, err := d.runtime.Create(ctx, spec)
	if err != nil {
		// Emit a terminal state so the controller's DB row moves out of
		// "creating" and the operator can see what went wrong.
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "create_failed: "+err.Error())
		d.replyError(corrID, req.InstanceId, "create", err.Error())
		log.Error("runtime create", "err", err)
		return
	}

	rconAddr, rconPassword := resolveRCONAddress(manifest, req)
	browseable := resolveBrowseableRoot(manifest)
	roots := resolveBrowseableRoots(manifest)

	rec := &instanceRecord{
		InstanceID:      req.InstanceId,
		ContainerID:     containerID,
		ModuleID:        req.ModuleId,
		Branch:          resolveCreateBranch(manifest, req.ConfigValues),
		DataPath:        req.DataPath,
		BrowseableRoot:  browseable,
		BrowseableRoots: roots,
		RCONAddr:        rconAddr,
		RCONPassword:    rconPassword,
	}

	d.mu.Lock()
	d.instances[req.InstanceId] = rec
	d.mu.Unlock()

	if err := d.writeMeta(rec); err != nil {
		log.Warn("write sidecar meta failed (instance will not survive agent restart)", "err", err)
	}

	log.Info("instance created", "container_id", short(containerID), "rcon_addr", rconAddr)
	// Detail string is parsed by the controller's UI to drive banners; for
	// modules whose first update_provider needs Steam creds we tag the
	// state so the dashboard can show a "Sign in + Update to install"
	// banner directly on the card. Otherwise stay with the generic
	// "created" detail.
	createdDetail := "created"
	if len(manifest.UpdateProviders) > 0 && manifest.UpdateProviders[0].RequiresSteamLogin {
		createdDetail = "needs_steam_login"
	}
	d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, createdDetail)
	d.replyOK(corrID)

	// Shared auto-start helper — used from both the update-success path and
	// the "skip update but still auto-start" path so the Create flow lands
	// on a running server regardless of which install model the module uses.
	autoStart := func() {
		startCorr := fmt.Sprintf("autostart-%d", time.Now().UnixNano())
		d.log.Info("auto-starting after create", "instance_id", req.InstanceId, "correlation_id", startCorr)
		startCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
		defer cancel()
		d.handleStart(startCtx, startCorr, &panelv1.InstanceStart{InstanceId: req.InstanceId})
	}

	// Auto-fire the primary update provider so the operator doesn't have to
	// click Update as a separate step. Opt-out via `auto_update_on_create:
	// false` in module.yaml for modules whose image handles its own
	// downloads (e.g. ark-sa's community image).
	//
	// Recreate-dance creates carry _PANEL_SUPPRESS_AUTOSTART=true; for those
	// we skip BOTH the update kick AND the auto-start so a previously
	// stopped instance stays stopped after an env-config edit. Volumes
	// already hold the game files (recreate preserves them), so re-running
	// the updater would just waste minutes and then auto-start at the end
	// via onUpdateComplete. Controller will issue an explicit Start when
	// the prior state was running.
	if suppressAutoStart {
		log.Info("create: auto-start + auto-update suppressed (recreate dance, prior state was stopped)", "instance_id", req.InstanceId)
	} else if skipReinstall {
		// Recreate that PRESERVES a populated install volume (env-config /
		// cluster / ports / mods). Skip the create-time warm-seed +
		// first-update — they wipe /game and re-copy from a sibling, which
		// silently drops a 7DTD server's custom Mods. The controller drives
		// the post-recreate start itself (explicit Start when the prior state
		// was running; stays stopped otherwise via _PANEL_SUPPRESS_AUTOSTART),
		// so the agent does nothing further here — EXCEPT an optional mod-seed.
		if seedModsFrom != "" {
			// Cluster join with "seed mods from master": clone the master's
			// Mods set onto this server before the controller starts it.
			// Synchronous so the seeded mods are in place by the time the
			// create reply returns and the controller issues Start (~5-10s for
			// a typical ~170 MB 7DTD mod set — well inside the 90s create
			// budget). Best-effort: a failure leaves the existing mods intact.
			seedCtx, cancel := context.WithTimeout(context.Background(), 75*time.Second)
			if _, serr := d.seedModsFromInstance(seedCtx, seedModsFrom, req.InstanceId, req.DataPath, manifest); serr != nil {
				log.Warn("mod-seed failed — server keeps its existing mods", "instance_id", req.InstanceId, "src", seedModsFrom, "err", serr)
			}
			cancel()
		}
		log.Info("create: install preserved (recreate dance) — skipping warm-seed + update", "instance_id", req.InstanceId)
	} else if shouldAutoUpdate(manifest) {
		// Default to the first declared provider, but allow a config_value
		// to override it at create time. This is what lets Conan Exiles
		// pick `enhanced` (UE5) vs `legacy` (UE4) from the create wizard's
		// EDITION dropdown — the matching update_provider id fires on
		// first install. Generic by design: any module can expose a
		// `provider_id` config_value and have it auto-honored.
		providerID := manifest.UpdateProviders[0].ID
		if pref := pickProviderFromConfigValues(req.ConfigValues, manifest); pref != "" {
			providerID = pref
		}
		log.Info("auto-triggering first update", "provider_id", providerID,
			"chosen_via_config_values", providerID != manifest.UpdateProviders[0].ID)
		autoCorr := fmt.Sprintf("autoupdate-%d", time.Now().UnixNano())
		instanceID := req.InstanceId
		// Surface the install as a server-authoritative state so any operator
		// viewing the dashboard (not just the one who clicked Create) sees
		// the "updating" pulse. The client was previously only flipping
		// is-installing locally on its own Create button press — refreshing
		// or joining a session mid-install showed "stopped" incorrectly.
		// handleUpdateWithHook emits UPDATING again immediately below, but
		// firing it here too closes the UI race between create-completion
		// and the goroutine starting.
		d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, "installing")

		// When the first-time download finishes, auto-start the server. That
		// way Create → "installing" → "starting" → "running" happens as one
		// operator gesture instead of requiring a separate Start click. Mirrors
		// AMP-style "create and run" behaviour. Guarded by the same flag as
		// auto-update itself — a module that opts out of auto-update stays
		// stopped after create as before.
		onUpdateComplete := func(err error) {
			if err != nil {
				// Clear the "installing" pulse so the UI stops pretending the
				// download is in flight. Detail carries the error for anyone
				// inspecting the DB / logs.
				d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "install_failed: "+err.Error())
				return
			}
			// Cluster join "seed mods from master" requested at CREATE time: on a
			// FRESH create the install ran through this warm-seed/update path (not
			// the skip-reinstall branch that also honors seedModsFrom), so clone
			// the master's Mods set HERE — after the base install, before the
			// server is startable. Best-effort: a failure keeps the base mods.
			if seedModsFrom != "" {
				d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, fmt.Sprintf("seeding mods from cluster master (%s)", seedModsFrom))
				seedCtx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
				if _, serr := d.seedModsFromInstance(seedCtx, seedModsFrom, instanceID, req.DataPath, manifest); serr != nil {
					log.Warn("create mod-seed failed — server keeps its base mods", "instance_id", instanceID, "src", seedModsFrom, "err", serr)
				}
				cancel()
			}
			// Drop the installing marker before kicking Start — handleStart
			// sends its own STARTING event which will supersede, but emitting
			// the transition explicitly avoids an awkward "installing" flash
			// if Start races the next poll tick.
			d.sendInstanceState(instanceID, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "installed")
			// Auto-start ONLY if the module opts in. 7DTD sets
			// auto_start_on_create=false so the operator finishes world/RWG +
			// cluster setup and starts manually (and to avoid the warm-seed →
			// immediate-start memory race that locked up the agent host).
			if shouldAutoStart(manifest) {
				autoStart()
			} else {
				log.Info("create: install complete — auto-start suppressed (auto_start_on_create=false); operator starts manually", "instance_id", instanceID)
			}
		}
		// Warm-seed shortcut: if a sibling instance of the same module
		// already has the install volume populated, copy it instead of
		// running the updater. Saves the operator a 30-90 min SteamCMD
		// round-trip on the second/third/Nth instance of a game. If no
		// sibling or the seed fails, fall through to the normal updater.
		// The seed itself runs in a debian sidecar and takes ~2-5 min
		// for ~16 GB on local SSD.
		dataPath := req.DataPath
		go func() {
			seedCtx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
			defer cancel()
			seeded, err := d.tryWarmSeedFromSibling(seedCtx, instanceID, manifest, dataPath)
			if err != nil {
				log.Warn("warm-seed failed, falling through to updater", "instance_id", instanceID, "err", err)
			} else if seeded {
				log.Info("warm-seed succeeded — skipping initial updater run", "instance_id", instanceID, "module_id", manifest.ID)
				onUpdateComplete(nil)
				return
			}
			d.handleUpdateWithHook(autoCorr, &panelv1.UpdateRequest{
				InstanceId: instanceID,
				ProviderId: providerID,
			}, onUpdateComplete)
		}()
	} else if shouldAutoStart(manifest) {
		// Image-managed install path (ark-sa et al): no external updater but
		// we still want Create → running in one gesture. The container's
		// entrypoint handles first-boot SteamCMD itself, so all we do here
		// is kick Start. UI sees STARTING (yellow pulse) until the game's
		// ready-regex fires. Slight delay gives the agent's replyOK envelope
		// time to reach the controller before the next state update.
		log.Info("auto-starting (module handles install via container entrypoint)")
		go func() {
			time.Sleep(250 * time.Millisecond)
			autoStart()
		}()
	}
}

// shouldAutoStart decides whether a just-created instance should boot
// automatically after the create (and first-time update, if any) lands.
// Default: yes. Modules that legitimately want "create then wait for the
// operator to configure before starting" opt out with
// `auto_start_on_create: false`. Independent of shouldAutoUpdate so that
// image-managed modules (ark-sa: no external updater, in-container
// SteamCMD) still end the Create flow on a running server.
//
// EXCEPTION — Steam-login-required modules: when the first update_provider
// has requires_steam_login, the create-time auto-update is skipped (no
// creds to use), so the game files were never downloaded. Auto-starting
// in that state would just bounce the container with an "executable not
// found" error and leave the instance crashed. We hold off until the
// operator has signed in to Steam + clicked Update; the update path's
// onUpdateComplete hook fires the start at the right moment.
func shouldAutoStart(m *modulepkg.Manifest) bool {
	if m == nil {
		return false
	}
	if m.AutoStartOnCreate != nil {
		return *m.AutoStartOnCreate
	}
	// Don't auto-start before the Steam-gated install has run.
	if len(m.UpdateProviders) > 0 && m.UpdateProviders[0].RequiresSteamLogin {
		return false
	}
	return true
}

// shouldAutoUpdate decides whether a just-created instance should kick off
// its first update provider automatically. Default: yes, if there's any
// provider declared. Modules whose container image handles its own
// downloads (ark-sa via UPDATE_SERVER=TRUE) should set
// `auto_update_on_create: false` in their manifest.
func shouldAutoUpdate(m *modulepkg.Manifest) bool {
	if m == nil || len(m.UpdateProviders) == 0 {
		return false
	}
	if m.AutoUpdateOnCreate != nil {
		return *m.AutoUpdateOnCreate
	}
	// Modules whose first provider requires a Steam login can't auto-update:
	// the agent doesn't have credentials on the auto-create path (the
	// controller-side Steam-login gate doesn't intercept create events).
	// The operator clicks Update manually once creds are in place; that
	// path flows through the controller and picks up the cached login.
	if m.UpdateProviders[0].RequiresSteamLogin {
		return false
	}
	return true
}

// pickProviderFromConfigValues lets a module expose a config_value that
// overrides which update_provider auto-fires at create time. Two keys are
// honored, in order of preference:
//
//   1. config_values["provider_id"] — explicit, generic. Matches an
//      update_provider's `id` directly.
//   2. config_values["EDITION"]     — module-specific convention used by
//      Conan Exiles (enhanced/legacy → matching provider id).
//
// If neither is set, or the named provider doesn't exist, returns "" so
// the caller falls back to UpdateProviders[0]. Case-insensitive match
// against the provider id; trim whitespace defensively because
// `runtime.docker.env` defaults can sneak through with stray spaces.
func pickProviderFromConfigValues(cv map[string]string, m *modulepkg.Manifest) string {
	if cv == nil || m == nil || len(m.UpdateProviders) == 0 {
		return ""
	}
	candidates := []string{cv["provider_id"], cv["EDITION"]}
	for _, want := range candidates {
		want = strings.TrimSpace(strings.ToLower(want))
		if want == "" {
			continue
		}
		for _, p := range m.UpdateProviders {
			if strings.ToLower(p.ID) == want {
				return p.ID
			}
		}
	}
	return ""
}

// resolveCreateBranch returns the normalized Steam branch an instance is being
// installed from, derived from the update_provider it will use (the same
// selection logic as the create-time auto-update: config_values.provider_id
// via pickProviderFromConfigValues, else UpdateProviders[0]). The value tags
// the instance record so warm-seed only reuses an install from a sibling on
// the SAME branch. See normalizeBranch for the public-equivalent collapsing.
func resolveCreateBranch(m *modulepkg.Manifest, cv map[string]string) string {
	if m == nil || len(m.UpdateProviders) == 0 {
		return ""
	}
	id := m.UpdateProviders[0].ID
	if pref := pickProviderFromConfigValues(cv, m); pref != "" {
		id = pref
	}
	for i := range m.UpdateProviders {
		if m.UpdateProviders[i].ID == id {
			return normalizeBranch(m.UpdateProviders[i].Beta)
		}
	}
	return ""
}

// handleStart starts a previously-created instance and begins log streaming.
func (d *Dispatcher) handleStart(ctx context.Context, corrID string, req *panelv1.InstanceStart) {
	log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)

	d.mu.Lock()
	rec, ok := d.instances[req.InstanceId]
	d.mu.Unlock()
	if !ok {
		d.replyError(corrID, req.InstanceId, "not_found", "instance not created on this target")
		return
	}

	d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0, "starting")

	// A Start racing an in-flight graceful stop is a trap: docker start on a
	// container that's still shutting down is a no-op "success", and when the
	// stop's SIGTERM grace finally lands it kills the instance the operator
	// just asked to start (observed live: a UI restart left the server
	// stranded stopped). Wait for the stop to settle before proceeding —
	// bounded so a wedged stop can't park Start forever.
	if rec.stopping.Load() {
		log.Info("start racing an in-flight stop — waiting for it to settle")
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0, "waiting for in-flight stop to finish…")
		deadline := time.Now().Add(90 * time.Second)
		for rec.stopping.Load() && time.Now().Before(deadline) {
			select {
			case <-ctx.Done():
				d.replyError(corrID, req.InstanceId, "cancelled", ctx.Err().Error())
				return
			case <-time.After(time.Second):
			}
		}
	}

	// Region Medic — for 7DTD, validate + heal the active world's regions while
	// the container is still STOPPED, snapshot a fresh rolling backup, and ping
	// Discord on heal. The CLI reads its per-server config (enable/keep/channel)
	// from region-medic.json in the saves volume. Best-effort: never blocks start.
	if rec.ModuleID == "7dtd" {
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0, "region medic: validating world…")
		d.runRegionMedicOnStart(ctx, rec)
		// Snapshot the shared cluster Player/ dir (character .ttp saves) to a
		// rolling slot before boot. No-op for standalone servers. The panel
		// tar backup can't capture this — the active Player dir is a symlink
		// out to the /cluster bind — so this is the only restore path for
		// per-character data on a clustered server.
		d.snapshotClusterPlayerOnStart(ctx, rec)
	}

	// Retry start on transient port-bind races. When a container exits the
	// kernel can hold its UDP bindings for a few seconds; the next Start
	// sees "ports are not available" until the OS releases them. A short
	// backoff is much friendlier than making the user click Start twice.
	const maxAttempts = 4
	var err error
	for attempt := 1; attempt <= maxAttempts; attempt++ {
		err = d.runtime.Start(ctx, rec.ContainerID)
		if err == nil {
			break
		}
		if !isTransientStartError(err) || attempt == maxAttempts {
			break
		}
		wait := time.Duration(attempt*2) * time.Second
		log.Warn("transient start error, retrying", "err", err, "attempt", attempt, "wait", wait)
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STARTING, 0,
			fmt.Sprintf("retrying (port still held): attempt %d/%d", attempt+1, maxAttempts))
		select {
		case <-ctx.Done():
			d.replyError(corrID, req.InstanceId, "cancelled", ctx.Err().Error())
			return
		case <-time.After(wait):
		}
	}
	if err != nil {
		d.replyError(corrID, req.InstanceId, "start", err.Error())
		d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, err.Error())
		log.Error("runtime start", "err", err)
		return
	}
	d.activate(rec)
	d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0, "running")
	d.replyOK(corrID)
	log.Info("instance started")

	// Auto-provision a freshly-generated clustered 7DTD world: watch (best-effort,
	// off-thread) for the gen boot to bake native-banded decorations and bounce the
	// container ONCE so the entrypoint aligns them to the cluster canonical. Strict
	// no-op for already-canon worlds (season10/insane/pvp/creative) and non-cluster
	// servers; single-flight. See decoremap_provision.go.
	if rec.ModuleID == "7dtd" {
		d.autoProvisionClusterWorldOnStart(rec)
	}

	// post_start lifecycle hooks — best-effort shell commands that run
	// INSIDE the container after it's up. Used to patch image-generated
	// config files that need a panel-side fixup (e.g. ark-sa's RCON port
	// needs to be duplicated into [SessionSettings] of GameUserSettings.ini
	// because ASA ignores AUTH responses when it's only under
	// [ServerSettings]). Failures are logged but don't fail the Start —
	// a broken hook shouldn't block an otherwise-healthy server.
	if manifest, ok := d.modules.Get(rec.ModuleID); ok && manifest.Lifecycle != nil && len(manifest.Lifecycle.PostStart) > 0 {
		go d.runPostStartHooks(rec, manifest.Lifecycle.PostStart)
	}
}

// runPostStartHooks executes each post_start command inside the running
// container, via docker exec. Runs async so a slow or blocking hook (e.g.
// one that waits for a config file to be written by the entrypoint) never
// holds up the Start reply or the dashboard state transition to RUNNING.
func (d *Dispatcher) runPostStartHooks(rec *instanceRecord, commands []string) {
	log := d.log.With("instance_id", rec.InstanceID, "phase", "post_start")
	// Bound the whole hook suite at 10 minutes — ASA's ini fixup waits
	// for the image's entrypoint to write the file on first boot, which
	// on a cold SteamCMD install can take several minutes.
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
	defer cancel()
	for i, cmd := range commands {
		if cmd == "" {
			continue
		}
		log.Info("running hook", "index", i, "cmd_preview", previewCmd(cmd))
		stdout, stderr, code, err := d.runtime.ExecCapture(ctx, rec.ContainerID, []string{"sh", "-c", cmd})
		if err != nil {
			log.Warn("hook exec failed", "index", i, "err", err)
			continue
		}
		if code != 0 {
			log.Warn("hook non-zero exit", "index", i, "exit_code", code,
				"stdout", trimForLog(stdout), "stderr", trimForLog(stderr))
			continue
		}
		log.Info("hook ok", "index", i, "exit_code", code, "stdout", trimForLog(stdout))
	}
}

func previewCmd(s string) string {
	s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
	if len(s) > 80 {
		return s[:80] + "…"
	}
	return s
}

func trimForLog(b []byte) string {
	s := strings.TrimSpace(string(b))
	if len(s) > 400 {
		s = s[:400] + "…"
	}
	return s
}

// extractINIValue pulls a single key from a specific section of an INI
// file. If section is empty the first matching key anywhere in the file
// wins. Lines with `;` or `#` as the first non-space character are
// comments. Whitespace around the value is trimmed.
//
// Used to read live RCON passwords out of game config files (ARK's
// [ServerSettings]ServerAdminPassword) so operator edits through the
// Config tab take effect without panel manual intervention.
func extractINIValue(content, section, key string) (string, error) {
	inSection := section == ""
	scanner := strings.Split(content, "\n")
	for _, raw := range scanner {
		line := strings.TrimSpace(raw)
		if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
			continue
		}
		if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
			sec := line[1 : len(line)-1]
			if section == "" {
				// No section filter — keep scanning across sections.
				continue
			}
			inSection = sec == section
			continue
		}
		if !inSection {
			continue
		}
		eq := strings.IndexByte(line, '=')
		if eq <= 0 {
			continue
		}
		k := strings.TrimSpace(line[:eq])
		if k == key {
			return strings.TrimSpace(line[eq+1:]), nil
		}
	}
	return "", fmt.Errorf("ini key [%s]%s not found", section, key)
}

// isTransientStartError detects Docker errors that typically resolve within
// a few seconds (port bind still held by kernel after previous container
// exit, daemon momentarily busy). We treat these as retryable so the user
// doesn't see a stale "ports unavailable" the first time they click Start.
func isTransientStartError(err error) bool {
	if err == nil {
		return false
	}
	s := err.Error()
	return strings.Contains(s, "ports are not available") ||
		strings.Contains(s, "port is already allocated") ||
		strings.Contains(s, "address already in use") ||
		strings.Contains(s, "bind: Only one usage")
}

// activate starts log streaming + state tracker for a running instance.
// Idempotent: if LogCancel is already set, this is a no-op.
func (d *Dispatcher) activate(rec *instanceRecord) {
	d.mu.Lock()
	alreadyActive := rec.LogCancel != nil
	d.mu.Unlock()
	if alreadyActive {
		return
	}
	// Free the shared volumes for the game by tearing down the fs helper.
	// Both containers on the same volume is technically allowed, but we
	// prefer the game own it exclusively while it's running.
	d.teardownFsHelper(rec.InstanceID)
	manifest, ok := d.modules.Get(rec.ModuleID)
	if !ok {
		d.log.Warn("activate: module vanished", "instance_id", rec.InstanceID, "module_id", rec.ModuleID)
		return
	}

	// Tear down any tracker still attached to this rec from a previous
	// activation BEFORE building a new one. activate() is called from
	// handleStart (which doesn't pre-cancel), Announce on every controller
	// reconnect (definitely doesn't pre-cancel), and the post-warm-seed
	// autostart in handleCreate. Without this guard each call leaves the
	// previous goroutine alive with its frozen cfg.RCONAddr — visible as
	// "rcon dial failed ... 127.0.0.1:<old_port>" forever after a recreate
	// (the old tracker still references the old container's port allocation;
	// only an agent restart cleared it). Wait briefly for the goroutine to
	// exit so the fresh dial doesn't race the stale one.
	d.mu.Lock()
	prevCancel := rec.TrackerCancel
	prevDone := rec.TrackerDone
	rec.TrackerCancel = nil
	rec.Tracker = nil
	rec.TrackerDone = nil
	d.mu.Unlock()
	if prevCancel != nil {
		prevCancel()
		if prevDone != nil {
			select {
			case <-prevDone:
			case <-time.After(5 * time.Second):
				d.log.Warn("activate: prior tracker did not exit within 5s",
					"instance_id", rec.InstanceID)
			}
		}
	}

	// On rehydrate, sidecar may have an empty RCONPassword (older agent
	// versions didn't persist secrets, or the password lookup hadn't
	// been wired for this module yet). Re-resolve from the live manifest
	// so a manifest update (e.g. adding password_secret to ark-sa) takes
	// effect on the next start without forcing a delete + recreate.
	if rec.RCONPassword == "" && manifest.RCON != nil {
		_, pw := resolveRCONAddress(manifest, &panelv1.InstanceCreate{InstanceId: rec.InstanceID})
		if pw != "" {
			rec.RCONPassword = pw
			_ = d.writeMeta(rec) // best-effort persist
		}
	}

	// If the module declares a way to source the RCON password at runtime
	// (from a file or from a specific INI key), wire a PasswordFunc that
	// re-reads on each dial attempt. Lets modules whose entrypoint
	// generates secrets at first boot (7DTD's TelnetPassword via
	// /game-saves/.panel-telnet-password) or whose config file is the
	// source of truth (ARK: SA's [ServerSettings]ServerAdminPassword)
	// work without manual wiring.
	var passwordFunc func(context.Context) (string, error)
	if manifest.RCON != nil {
		if iniSpec := manifest.RCON.PasswordFromINI; iniSpec != nil && iniSpec.File != "" && iniSpec.Key != "" {
			path := iniSpec.File
			section := iniSpec.Section
			key := iniSpec.Key
			containerID := rec.ContainerID
			rt := d.runtime
			passwordFunc = func(ctx context.Context) (string, error) {
				data, err := rt.CopyFileFromContainer(ctx, containerID, path)
				if err != nil {
					return "", err
				}
				return extractINIValue(string(data), section, key)
			}
		} else if manifest.RCON.PasswordFromFile != "" {
			path := manifest.RCON.PasswordFromFile
			containerID := rec.ContainerID
			rt := d.runtime
			passwordFunc = func(ctx context.Context) (string, error) {
				data, err := rt.CopyFileFromContainer(ctx, containerID, path)
				if err != nil {
					return "", err
				}
				return strings.TrimSpace(string(data)), nil
			}
		}
	}

	tracker, err := state.New(d.log, state.Config{
		InstanceID:   rec.InstanceID,
		Manifest:     manifest,
		RCONAddr:     rec.RCONAddr,
		RCONPassword: rec.RCONPassword,
		PasswordFunc: passwordFunc,
		Emitter:      d,
		// stdio adapter needs a handle to the runtime so it can attach to
		// the container's stdin. Irrelevant for tcp adapters — tracker
		// ignores these fields unless manifest.RCON.Adapter == "stdio".
		ContainerID: "panel-" + rec.InstanceID,
		Stdio:       d.runtime.(rcon.StdioBackend),
		// arkHangGuard hook — counts consecutive RCON failures per
		// instance and triggers an auto-restart at threshold. No-op for
		// non-ark-sa modules (the guard short-circuits inside).
		OnRconResult: func(success bool) { d.arkHangGuardOnRconResult(rec, success) },
	})
	if err != nil {
		d.log.Warn("state tracker init failed (continuing without it)", "instance_id", rec.InstanceID, "err", err)
	} else {
		trackerCtx, trackerCancel := context.WithCancel(context.Background())
		done := make(chan struct{})
		d.mu.Lock()
		rec.Tracker = tracker
		rec.TrackerCancel = trackerCancel
		rec.TrackerDone = done
		d.mu.Unlock()
		go func() {
			defer close(done)
			tracker.Run(trackerCtx)
		}()
	}

	// Same teardown discipline for the log + stats streams: every caller of
	// activate (Announce, handleStart, post-warm-seed autostart) reaches
	// here, and most pre-cancel LogCancel — but not all. Cancel anything
	// lingering before installing fresh streams so we don't end up with
	// two streamLogs goroutines duplicating every log line into the
	// emitter.
	d.mu.Lock()
	prevLogCancel := rec.LogCancel
	rec.LogCancel = nil
	d.mu.Unlock()
	if prevLogCancel != nil {
		prevLogCancel()
	}

	logCtx, logCancel := context.WithCancel(context.Background())
	d.mu.Lock()
	rec.LogCancel = logCancel
	d.mu.Unlock()
	go d.streamLogs(logCtx, rec)
	d.startExitWatch(rec)
	// Stats poller shares the log-lifetime context: cancelled on stop,
	// auto-closed if the container exits.
	go d.streamStats(logCtx, rec)
}

// startExitWatch (re)arms the exit watcher for a record, single-flight:
// any watcher from a previous activation is cancelled first, so re-Announce
// on controller reconnects can't stack N watchExit goroutines each holding
// its own ContainerWait docker connection.
func (d *Dispatcher) startExitWatch(rec *instanceRecord) {
	ctx, cancel := context.WithCancel(context.Background())
	d.mu.Lock()
	prev := rec.ExitWatchCancel
	rec.ExitWatchCancel = cancel
	d.mu.Unlock()
	if prev != nil {
		prev()
	}
	go d.watchExit(ctx, rec)
}

// handleStop stops the container gracefully and leaves everything else
// intact — the in-memory record, the sidecar JSON, the Docker container
// (in Exited state), the volumes, and the DB row all persist so the
// instance can be re-started, backed up, or restored. Full teardown is
// InstanceDelete's job.
func (d *Dispatcher) handleStop(ctx context.Context, corrID string, req *panelv1.InstanceStop) {
	log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)

	d.mu.Lock()
	rec, ok := d.instances[req.InstanceId]
	d.mu.Unlock()
	if !ok {
		d.replyError(corrID, req.InstanceId, "not_found", "instance not on this target")
		return
	}

	rec.stopping.Store(true)
	d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING, 0, "stopping")
	grace := time.Duration(req.GraceSeconds) * time.Second
	if grace == 0 {
		grace = 30 * time.Second
	}
	// Cancel any in-flight updater (steamcmd download, github fetch).
	// Otherwise Stop kills the main container but the sidecar keeps
	// running, which the operator perceives as "stop didn't work — it's
	// still downloading."
	d.cancelUpdater(req.InstanceId)

	// Graceful in-game save BEFORE signalling the container. A bare docker
	// stop (SIGTERM→SIGKILL after grace) can tear a region file mid-write —
	// the root cause of 7DTD .7rg "Wrong chunk header!" corruption that
	// Region Medic exists to repair. If the module declares an RCON `save`
	// command and the tracker is live, flush the world first. Best-effort:
	// short timeout, and fall through to the normal stop on any error.
	d.mu.Lock()
	tracker := rec.Tracker
	d.mu.Unlock()
	if manifest, ok := d.modules.Get(rec.ModuleID); !req.Force && tracker != nil && ok && manifest.RCON != nil {
		if saveCmd := manifest.RCON.Commands["save"]; saveCmd != "" {
			saveCtx, cancelSave := context.WithTimeout(ctx, 15*time.Second)
			if out, err := tracker.Exec(saveCtx, saveCmd); err != nil {
				log.Warn("graceful save before stop failed (continuing to stop)", "cmd", saveCmd, "err", err)
			} else {
				log.Info("graceful save before stop", "cmd", saveCmd, "out", strings.TrimSpace(out))
				time.Sleep(2 * time.Second) // let the engine flush region files to disk
			}
			cancelSave()
		}
	}

	// Tear down the tracker, log/stats streams, and exit watcher. Mutations
	// go under d.mu — activate/Announce/Shutdown touch the same fields from
	// other goroutines; the cancel calls themselves happen outside the lock.
	d.mu.Lock()
	trackerCancel := rec.TrackerCancel
	logCancel := rec.LogCancel
	exitWatchCancel := rec.ExitWatchCancel
	rec.TrackerCancel = nil
	rec.Tracker = nil
	rec.LogCancel = nil
	rec.ExitWatchCancel = nil
	d.mu.Unlock()
	for _, c := range []context.CancelFunc{trackerCancel, logCancel, exitWatchCancel} {
		if c != nil {
			c()
		}
	}
	if err := d.runtime.Stop(ctx, rec.ContainerID, grace); err != nil {
		// Clear the flag on failure too — the container is still running, so
		// a future exit is NOT part of a graceful stop, and handleStart's
		// race guard must not wait on a stop that already gave up.
		rec.stopping.Store(false)
		d.replyError(corrID, req.InstanceId, "stop", err.Error())
		log.Error("runtime stop", "err", err)
		return
	}

	// Reset stopping so the exit watcher (if it fires again on a future
	// restart) treats future exits normally.
	rec.stopping.Store(false)

	d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "stopped")
	d.replyOK(corrID)
	log.Info("instance stopped (container retained for restart/backup)")
}

// handleRCON runs one operator-supplied RCON command against the instance's
// tracker client and returns the result via an RCONResult envelope carrying
// the same correlation_id so the controller can match it to the original
// ExecRCON request.
func (d *Dispatcher) handleRCON(ctx context.Context, corrID string, req *panelv1.RCONCommand) {
	d.mu.Lock()
	rec, ok := d.instances[req.InstanceId]
	var tracker *state.Tracker
	if ok {
		tracker = rec.Tracker
	}
	d.mu.Unlock()
	if !ok {
		d.sendRCONResult(corrID, "", "not_found", "instance not on this target")
		return
	}
	if tracker == nil {
		d.sendRCONResult(corrID, "", "no_tracker", "instance has no RCON tracker (module may not declare rcon)")
		return
	}
	// Use tracker.Exec so we transparently redial on dead connections —
	// Palworld's RCON closes sockets after ~60s idle, so the first operator
	// command after a quiet period would otherwise always fail with EOF.
	out, err := tracker.Exec(ctx, req.Command)
	if err != nil {
		d.sendRCONResult(corrID, out, "exec", err.Error())
		return
	}
	d.sendRCONResult(corrID, out, "", "")
}

func (d *Dispatcher) sendRCONResult(corrID, output, _code, errMsg string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_RconResult{
			RconResult: &panelv1.RCONResult{Output: output, Error: errMsg},
		},
	})
}

func (d *Dispatcher) streamLogs(ctx context.Context, rec *instanceRecord) {
	// Coalesce progress-style bursts (SteamCMD download ticks, preallocation,
	// LGSM step banners) into at most one update per 200ms per "key". Non-
	// progress lines go through immediately so errors and unique content are
	// never delayed. The Tracker still sees every line for event matching.
	//
	// The whole thing runs in a restart loop. Reason: docker's
	// ContainerLogs(--follow) can return cleanly (EOF / pipe closed) for
	// reasons that have nothing to do with the container exiting — daemon
	// hiccups, log driver buffer flushes, occasional plain weirdness. Before
	// this loop, when StreamLogs returned without ctx.Err(), the goroutine
	// just exited and the instance went silent until the next agent restart.
	// Symptom in the wild: a server's pill stuck on "starting" because no
	// log lines were reaching the controller's ring buffer, even though the
	// container itself was fine. Live-debugged on V Rising 2026-04-29.
	for {
		if ctx.Err() != nil {
			return
		}
		// Skip if the container is no longer running — watchExit owns the
		// terminal "stopped" / "crashed" transition, no point chasing the
		// stream of a dead container.
		if rec.ContainerID == "" {
			return
		}

		co := newLogCoalescer(200*time.Millisecond, func(stream runtime.LogStream, line string, at time.Time) {
			d.sendEnv(&panelv1.AgentEnvelope{
				SentAt: timestamppb.Now(),
				Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
					InstanceId: rec.InstanceID,
					Stream:     string(stream),
					At:         timestamppb.New(at),
					Line:       line,
				}},
			})
		})
		err := d.runtime.StreamLogs(ctx, rec.ContainerID, func(stream runtime.LogStream, line string, at time.Time) {
			co.offer(stream, line, at)
			// Snapshot the tracker under d.mu — handleStop/activate swap it
			// concurrently. Uncontended mutex cost is noise next to the
			// regex matching OnLogLine does.
			d.mu.Lock()
			tracker := rec.Tracker
			d.mu.Unlock()
			if tracker != nil {
				tracker.OnLogLine(line)
			}
			// ark-sa hang-detection guardrail: fires on the engine's
			// "!!!HANG DETECTED!!!" self-detection token. No-op for
			// other modules. See hangguard.go.
			d.arkHangGuardOnLogLine(rec, line)
		})
		co.stop()

		if ctx.Err() != nil {
			return
		}
		if err != nil {
			d.log.Warn("log stream ended with error, will restart",
				"instance_id", rec.InstanceID, "err", err)
		} else {
			d.log.Info("log stream returned cleanly, restarting",
				"instance_id", rec.InstanceID)
		}
		// Brief backoff so a wedged docker daemon doesn't peg CPU on tight
		// retry. 2s is plenty quick for "the operator is watching the
		// pill," far short of human-perceptible silence.
		select {
		case <-ctx.Done():
			return
		case <-time.After(2 * time.Second):
		}
	}
}

// watchExit blocks on ContainerWait and reports the container's exit (or
// crash/auto-restart cycle) upstream. ctx-bound: cancelled by startExitWatch
// on re-activation, by handleStop, and by Shutdown, so a stale watcher can't
// hold a docker connection past its container lifetime.
func (d *Dispatcher) watchExit(ctx context.Context, rec *instanceRecord) {
	for {
		code, err := d.runtime.Wait(ctx, rec.ContainerID)
		if ctx.Err() != nil {
			return
		}
		if err != nil {
			if strings.Contains(err.Error(), "context") {
				return
			}
			d.log.Warn("wait error", "instance_id", rec.InstanceID, "err", err)
		}
		if rec.stopping.Load() {
			return
		}

		// Docker's restart policy (unless-stopped / on-failure / always) may
		// relaunch the container process after the previous one dies. If the
		// container is back in "running" state by the time Wait returns, this
		// is a crash-then-auto-restart cycle, not a terminal exit — report the
		// crash + recovery to the UI and re-arm our streams against the new
		// process. Otherwise the Console goes dead and the status pill gets
		// stuck on "stopped" even though the server is live again.
		state, ierr := d.runtime.InspectByName(ctx, "panel-"+rec.InstanceID)
		if ierr == nil && state.Status == "running" {
			d.log.Warn("container auto-restarted by docker restart policy",
				"instance_id", rec.InstanceID, "prior_exit_code", code)
			d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, int32(code),
				fmt.Sprintf("crashed (exit %d) — docker auto-restarted", code))
			d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0,
				"running (recovered after crash)")
			d.rearmStreamsAfterAutoRestart(rec)
			continue
		}

		// Terminal exit — no auto-restart.
		status := panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
		if code != 0 {
			status = panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED
		}
		d.sendInstanceState(rec.InstanceID, status, int32(code), "exited")
		return
	}
}

// rearmStreamsAfterAutoRestart cancels the log + stats streams left pointing
// at the dead process and spawns fresh ones against the newly-restarted
// container. Called from watchExit on a crash-recovery cycle so the Console
// tab and stats graph don't go dark between the docker `die` and the next
// `start`. The RCON tracker (if any) is left to reconnect on its next poll —
// no explicit rearm needed.
func (d *Dispatcher) rearmStreamsAfterAutoRestart(rec *instanceRecord) {
	d.mu.Lock()
	if rec.LogCancel != nil {
		rec.LogCancel()
	}
	logCtx, logCancel := context.WithCancel(context.Background())
	rec.LogCancel = logCancel
	d.mu.Unlock()
	go d.streamLogs(logCtx, rec)
	go d.streamStats(logCtx, rec)
}

func (d *Dispatcher) sendInstanceState(instanceID string, status panelv1.InstanceStatus, exitCode int32, detail string) {
	// Record the last-sent status on the instance record (when one exists)
	// so EmitAppState can detect a stale CRASHED that needs promotion.
	d.mu.Lock()
	rec := d.instances[instanceID]
	d.mu.Unlock()
	if rec != nil {
		rec.lastStatus.Store(int32(status))
	}
	d.sendEnv(&panelv1.AgentEnvelope{
		SentAt: timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_InstanceState{
			InstanceState: &panelv1.InstanceStateUpdate{
				InstanceId: instanceID,
				Status:     status,
				ExitCode:   exitCode,
				At:         timestamppb.Now(),
				Detail:     detail,
			},
		},
	})
}

func (d *Dispatcher) replyOK(corrID string) {
	if corrID == "" {
		return
	}
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_Result{
			Result: &panelv1.CommandResult{ExitCode: 0},
		},
	})
}

func (d *Dispatcher) replyError(corrID, instanceID, code, msg string) {
	if corrID == "" && instanceID == "" {
		return
	}
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_Result{
			Result: &panelv1.CommandResult{
				ExitCode: 1,
				Error: &panelv1.Error{
					Code:    code,
					Message: msg,
					At:      timestamppb.Now(),
				},
			},
		},
	})
}

// assignNonCollidingHostPorts walks the manifest's ports list and, for
// each declared port, picks an actually-available host port — auto-
// bumping if the manifest default (or operator-supplied override) is
// already in use by another process or container. Mutates req.Ports
// in place: every declared manifest port ends up with an explicit
// HostPort entry, so downstream code (ResolveDocker, resolveRCONAddress)
// sees the same chosen port.
//
// Probe strategy: bind a real socket on 0.0.0.0:port (or 127.0.0.1:port
// for internal ports) for ~50ms; if the bind succeeds, the port is
// available — Docker's published-port machinery will succeed there too.
// Avoids guessing from /proc/net or shelling to ss/lsof.
//
// Bump direction: increment by 1 starting from the requested port up
// to +200. We intentionally don't pick from a far-flung high range —
// keeping ports clustered makes operator firewall configs easier and
// the typical "default + N siblings" workload fits in <50.
func assignNonCollidingHostPorts(manifest *modulepkg.Manifest, req *panelv1.InstanceCreate) error {
	if manifest == nil || manifest.Runtime.Docker == nil {
		return nil
	}
	// Build by-name lookup of any existing operator overrides so we can
	// preserve them and only mutate when they collide.
	overrideByName := map[string]*panelv1.PortMap{}
	for i, ov := range req.Ports {
		if ov != nil && ov.Name != "" {
			overrideByName[ov.Name] = req.Ports[i]
		}
	}
	// Track ports we've claimed during THIS create — sibling ports in the
	// same module (e.g. ark-sa game/raw/rcon) all bind on this same call,
	// so the probe needs to know about ports we're about to take.
	claimed := map[string]bool{} // key: "tcp:7777" / "udp:7777"
	// Reserve list for predictable rollback if something goes wrong mid-call.
	for _, p := range manifest.Ports {
		if p.Name == "" || p.Default == 0 {
			continue
		}
		proto := strings.ToLower(p.Proto)
		if proto != "tcp" && proto != "udp" {
			proto = "tcp"
		}
		// Operator-supplied override wins as the starting point.
		startHost := uint16(p.Default)
		if ov, ok := overrideByName[p.Name]; ok && ov.HostPort != 0 {
			startHost = uint16(ov.HostPort)
		}
		chosen, err := findFreePort(startHost, proto, p.Internal, claimed)
		if err != nil {
			return fmt.Errorf("port %q: %w", p.Name, err)
		}
		claimed[fmt.Sprintf("%s:%d", proto, chosen)] = true
		// Update or insert the PortMap entry. ContainerPort stays at the
		// manifest default (or operator override) — only HostPort is
		// adjusted to dodge the collision.
		containerPort := uint32(p.Default)
		if ov, ok := overrideByName[p.Name]; ok && ov.ContainerPort != 0 {
			containerPort = ov.ContainerPort
		}
		entry := &panelv1.PortMap{
			Name:          p.Name,
			Proto:         proto,
			ContainerPort: containerPort,
			HostPort:      uint32(chosen),
			Internal:      p.Internal,
		}
		if existing, ok := overrideByName[p.Name]; ok {
			*existing = *entry
		} else {
			req.Ports = append(req.Ports, entry)
			overrideByName[p.Name] = req.Ports[len(req.Ports)-1]
		}
	}
	return nil
}

// findFreePort scans upward from start, returning the first port that
// can be bound on the appropriate interface. claimed tracks ports we've
// already chosen earlier in the same call. Caps at +200 — keeps panel-
// assigned ports in a tight cluster for operator firewall sanity.
func findFreePort(start uint16, proto string, internal bool, claimed map[string]bool) (uint16, error) {
	host := "0.0.0.0"
	if internal {
		host = "127.0.0.1"
	}
	for offset := 0; offset < 200; offset++ {
		port := uint32(start) + uint32(offset)
		if port > 65535 {
			break
		}
		key := fmt.Sprintf("%s:%d", proto, port)
		if claimed[key] {
			continue
		}
		addr := fmt.Sprintf("%s:%d", host, port)
		if proto == "udp" {
			l, err := net.ListenPacket("udp", addr)
			if err != nil {
				continue
			}
			_ = l.Close()
			return uint16(port), nil
		}
		l, err := net.Listen("tcp", addr)
		if err != nil {
			continue
		}
		_ = l.Close()
		return uint16(port), nil
	}
	return 0, fmt.Errorf("no free %s port in range %d-%d", proto, start, uint32(start)+200)
}

// resolveRCONAddress determines the 127.0.0.1:port the Agent will use to
// reach the instance's RCON and pulls the password from config_values.
// Returns ("", "") if the manifest declares no RCON.
//
// Password source precedence:
//  1. config_values["rcon_password"] — explicit override
//  2. config_values[manifest.RCON.PasswordSecret] — module-declared mapping
//     (operator-set value via the Config tab)
//  3. manifest.Runtime.Docker.Env[manifest.RCON.PasswordSecret] —
//     module manifest's own default for the secret-named env var (this
//     is the line that fixes ARK SA: SERVER_ADMIN_PASSWORD lives in
//     env defaults, never gets seeded into ConfigValues, and the
//     acekorneya image doesn't write it back to GameUserSettings.ini)
//  4. manifest.RCON.PasswordLiteral — explicit literal in manifest
//  5. "" (tracker will still attempt connect; auth may fail)
func resolveRCONAddress(manifest *modulepkg.Manifest, req *panelv1.InstanceCreate) (addr, password string) {
	if manifest.RCON == nil || manifest.RCON.HostPort == "" {
		return "", ""
	}
	portDecl := manifest.Port(manifest.RCON.HostPort)
	if portDecl == nil {
		return "", ""
	}
	hostPort := uint16(portDecl.Default)
	for _, ov := range req.Ports {
		if ov != nil && ov.Name == manifest.RCON.HostPort && ov.HostPort != 0 {
			hostPort = uint16(ov.HostPort)
			break
		}
	}
	if req.ConfigValues != nil {
		if p := req.ConfigValues["rcon_password"]; p != "" {
			password = p
		} else if key := manifest.RCON.PasswordSecret; key != "" {
			password = req.ConfigValues[key]
		}
	}
	// Fall back to the manifest's env-defaults map when the secret
	// hasn't been overridden via ConfigValues. The agent merges env
	// defaults into the container's env at create time, but it does
	// NOT seed ConfigValues with them — so without this, RCON sees
	// an empty password and dials get RST'd at auth.
	if password == "" && manifest.RCON.PasswordSecret != "" && manifest.Runtime.Docker != nil {
		if v, ok := manifest.Runtime.Docker.Env[manifest.RCON.PasswordSecret]; ok {
			password = v
		}
	}
	// Last resort: explicit literal in the manifest.
	if password == "" {
		password = manifest.RCON.PasswordLiteral
	}
	return fmt.Sprintf("127.0.0.1:%d", hostPort), password
}

// resolveBrowseableRoot returns the container-absolute path the file
// manager should treat as the DEFAULT root (first view). Precedence:
// browseable_roots[0], explicit browseable_root, first declared volume's
// container path, or "" to fall back to bind mount.
func resolveBrowseableRoot(manifest *modulepkg.Manifest) string {
	if manifest.Runtime.Docker == nil {
		return ""
	}
	d := manifest.Runtime.Docker
	if len(d.BrowseableRoots) > 0 && d.BrowseableRoots[0].Path != "" {
		return d.BrowseableRoots[0].Path
	}
	if d.BrowseableRoot != "" {
		return d.BrowseableRoot
	}
	if len(d.Volumes) > 0 {
		return d.Volumes[0].Container
	}
	return ""
}

// resolveBrowseableRoots returns every root the operator is allowed to
// browse. Guarantees at least one entry (synthesized from browseable_root
// if the module only declares the singular form).
func resolveBrowseableRoots(manifest *modulepkg.Manifest) []modulepkg.BrowseableRoot {
	if manifest.Runtime.Docker == nil {
		return nil
	}
	d := manifest.Runtime.Docker
	if len(d.BrowseableRoots) > 0 {
		return d.BrowseableRoots
	}
	if d.BrowseableRoot != "" {
		return []modulepkg.BrowseableRoot{{Name: "default", Path: d.BrowseableRoot}}
	}
	if len(d.Volumes) > 0 {
		return []modulepkg.BrowseableRoot{{Name: "default", Path: d.Volumes[0].Container}}
	}
	return nil
}

// mergeValuesAndSecrets generates any module-declared secrets that are
// flagged Generated, then layers user-supplied config_values on top
// (user wins). Returns a fresh map suitable for template rendering.
func mergeValuesAndSecrets(manifest *modulepkg.Manifest, userValues map[string]string) (map[string]string, error) {
	secrets, err := modulepkg.GenerateSecrets(manifest)
	if err != nil {
		return nil, err
	}
	out := make(map[string]string, len(secrets)+len(userValues))
	for k, v := range secrets {
		out[k] = v
	}
	for k, v := range userValues {
		if v != "" {
			out[k] = v
		}
	}
	return out, nil
}

// rtStatusToProto converts a Docker container State.Status to our
// InstanceStatus enum. Unknown states map to UNSPECIFIED.
func rtStatusToProto(s string) panelv1.InstanceStatus {
	switch s {
	case "running":
		return panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING
	case "restarting":
		return panelv1.InstanceStatus_INSTANCE_STATUS_STARTING
	case "created":
		return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
	case "paused":
		return panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING
	case "exited":
		return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
	case "dead":
		return panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED
	default:
		return panelv1.InstanceStatus_INSTANCE_STATUS_UNSPECIFIED
	}
}

func short(id string) string {
	if len(id) > 12 {
		return id[:12]
	}
	return id
}
