package main

import (
	"context"
	"crypto/tls"
	"crypto/x509"
	"encoding/json"
	"errors"
	"flag"
	"fmt"
	"io"
	"log/slog"
	"net"
	"net/http"
	"os"
	"os/signal"
	"sort"
	"strings"
	"sync"
	"syscall"
	"time"

	"google.golang.org/grpc"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/credentials"
	"google.golang.org/grpc/status"
	"google.golang.org/protobuf/types/known/timestamppb"

	"github.com/dbledeez/panel/controller/internal/ca"
	"github.com/dbledeez/panel/controller/internal/db"
	"github.com/dbledeez/panel/controller/internal/events"
	modulepkg "github.com/dbledeez/panel/pkg/module"
	"github.com/dbledeez/panel/pkg/version"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

// buildServerTLS constructs a TLS config for the controller's gRPC
// server: requires clients (agents) to present a cert signed by our CA.
func buildServerTLS(b *ca.CA) (*tls.Config, error) {
	serverPair := tls.Certificate{
		Certificate: [][]byte{b.Server.Raw},
		PrivateKey:  b.ServerKey,
	}
	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(b.RootPEM)
	return &tls.Config{
		Certificates: []tls.Certificate{serverPair},
		ClientAuth:   tls.RequireAndVerifyClientCert,
		ClientCAs:    pool,
		MinVersion:   tls.VersionTLS12,
	}, nil
}

const controllerVersion = "0.0.1-dev"

// grpcMaxMsgSize bounds a single gRPC message in either direction.
// Default is 4 MiB which silently rejects any FsWrite over that size,
// breaking large file uploads (mod zips, scenarios, save backups).
// 5 GiB matches the file-browser cap with headroom.
const grpcMaxMsgSize = 5 * 1024 * 1024 * 1024

// agentConn holds connection state for one Target agent, including the
// server-side gRPC stream so the Panel service can push commands to it.
type agentConn struct {
	id          string
	version     string
	os          string
	arch        string
	hostname    string
	connectedAt time.Time

	mu       sync.Mutex // guards lastSeen and modules — short critical sections only
	lastSeen time.Time
	stream   panelv1.Agent_ConnectServer
	modules  []*panelv1.ModuleSummary // advertised at handshake

	// streamMu serializes concurrent stream.Send calls. Held independently
	// of mu so a wedged Send (HTTP/2 flow-control exhaustion when the agent
	// stops draining) cannot block readers of mu-guarded fields. Was a
	// single shared mutex before; that caused /api/agents and every
	// controller→agent ad-hoc RPC to hang together as soon as one agent's
	// Send wedged. See feedback_panel_agent_mu_deadlock.md.
	streamMu sync.Mutex
}

// Send serializes envelope writes on the shared bidi stream. gRPC's
// ServerStream is not safe for concurrent Send.
func (c *agentConn) Send(env *panelv1.ControllerEnvelope) error {
	c.streamMu.Lock()
	defer c.streamMu.Unlock()
	return c.stream.Send(env)
}

type agentRegistry struct {
	mu     sync.RWMutex
	agents map[string]*agentConn
}

func newAgentRegistry() *agentRegistry {
	return &agentRegistry{agents: make(map[string]*agentConn)}
}

func (r *agentRegistry) register(a *agentConn) {
	r.mu.Lock()
	defer r.mu.Unlock()
	r.agents[a.id] = a
}

func (r *agentRegistry) touch(id string, t time.Time) {
	r.mu.RLock()
	a := r.agents[id]
	r.mu.RUnlock()
	if a == nil {
		return
	}
	a.mu.Lock()
	a.lastSeen = t
	a.mu.Unlock()
}

func (r *agentRegistry) remove(id string) {
	r.mu.Lock()
	defer r.mu.Unlock()
	delete(r.agents, id)
}

// removeConn deletes the agent entry ONLY if it still points at this exact
// conn. Reconnect-safe form of remove: a slow-dying previous stream's
// deferred cleanup must not evict a freshly-reconnected agent that already
// overwrote the map entry via register(). Using remove(id) blindly in the
// Connect defer caused controller→agent sends to silently target a dead
// half-open stream after an agent restart — agent→controller stats kept
// flowing so /api/agents still showed "connected", but every start/rcon/
// create to that agent timed out (the write buffered on the dead stream and
// never reached the agent). Hit on ivy 2026-06-10 after a module reload.
func (r *agentRegistry) removeConn(a *agentConn) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.agents[a.id] == a {
		delete(r.agents, a.id)
	}
}

func (r *agentRegistry) get(id string) *agentConn {
	r.mu.RLock()
	defer r.mu.RUnlock()
	return r.agents[id]
}

// moduleByID returns the agent's advertised ModuleSummary for a given id,
// or nil if the agent is offline or doesn't register that module.
func (r *agentRegistry) moduleByID(agentID, moduleID string) *panelv1.ModuleSummary {
	r.mu.RLock()
	a := r.agents[agentID]
	r.mu.RUnlock()
	if a == nil {
		return nil
	}
	a.mu.Lock()
	defer a.mu.Unlock()
	for _, m := range a.modules {
		if m.Id == moduleID {
			return m
		}
	}
	return nil
}

func (r *agentRegistry) snapshot() []*agentConn {
	r.mu.RLock()
	defer r.mu.RUnlock()
	out := make([]*agentConn, 0, len(r.agents))
	for _, a := range r.agents {
		out = append(out, a)
	}
	sort.Slice(out, func(i, j int) bool { return out[i].id < out[j].id })
	return out
}

// ---- Agent gRPC service (Target ↔ Controller) ----

type agentServer struct {
	panelv1.UnimplementedAgentServer
	log         *slog.Logger
	registry    *agentRegistry
	db          *db.DB
	bus         *events.Bus
	pending     *pendingRegistry
	arkClusters *arkClusterStore
	sevenDtdClusters *sevenDtdClusterStore
}

// pendingRegistry correlates Panel-originated requests (ExecRCON today,
// others later) with their eventual agent responses. The request handler
// generates a correlation_id, registers a one-shot channel, forwards the
// command, and awaits the channel with a timeout. The agent message loop
// delivers to the registered channel when an envelope with that
// correlation_id arrives upstream.
type pendingRegistry struct {
	mu      sync.Mutex
	pending map[string]chan *panelv1.AgentEnvelope
}

func newPendingRegistry() *pendingRegistry {
	return &pendingRegistry{pending: map[string]chan *panelv1.AgentEnvelope{}}
}

// Register reserves a correlation ID and returns the channel the eventual
// reply will arrive on, plus a cancel that frees the slot. Use this when
// you want to multiplex the reply with other selects (e.g. a streaming
// log subscription). Await is the simpler alternative for "send and wait".
func (r *pendingRegistry) Register(corrID string) (<-chan *panelv1.AgentEnvelope, func()) {
	ch := make(chan *panelv1.AgentEnvelope, 1)
	r.mu.Lock()
	r.pending[corrID] = ch
	r.mu.Unlock()
	return ch, func() {
		r.mu.Lock()
		delete(r.pending, corrID)
		r.mu.Unlock()
	}
}

func (r *pendingRegistry) Await(ctx context.Context, corrID string, timeout time.Duration) (*panelv1.AgentEnvelope, error) {
	ch := make(chan *panelv1.AgentEnvelope, 1)
	r.mu.Lock()
	r.pending[corrID] = ch
	r.mu.Unlock()
	defer func() {
		r.mu.Lock()
		delete(r.pending, corrID)
		r.mu.Unlock()
	}()
	t := time.NewTimer(timeout)
	defer t.Stop()
	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	case <-t.C:
		return nil, fmt.Errorf("timeout waiting for agent response")
	case env := <-ch:
		return env, nil
	}
}

func (r *pendingRegistry) Deliver(corrID string, env *panelv1.AgentEnvelope) bool {
	r.mu.Lock()
	ch, ok := r.pending[corrID]
	r.mu.Unlock()
	if !ok {
		return false
	}
	select {
	case ch <- env:
		return true
	default:
		return false
	}
}

func (s *agentServer) Connect(stream panelv1.Agent_ConnectServer) error {
	first, err := stream.Recv()
	if err != nil {
		return err
	}
	hello := first.GetHello()
	if hello == nil {
		return status.Error(codes.InvalidArgument, "first message must be AgentHello")
	}
	if hello.AgentId == "" {
		return status.Error(codes.InvalidArgument, "agent_id is required")
	}

	conn := &agentConn{
		id:          hello.AgentId,
		version:     hello.AgentVersion,
		os:          hello.HostOs,
		arch:        hello.HostArch,
		hostname:    hello.Hostname,
		connectedAt: time.Now(),
		lastSeen:    time.Now(),
		stream:      stream,
		modules:     hello.Modules,
	}
	s.registry.register(conn)
	defer s.registry.removeConn(conn)

	// Durable agent record.
	if err := s.db.UpsertAgent(stream.Context(), db.AgentRow{
		AgentID:  conn.id,
		Version:  conn.version,
		HostOS:   conn.os,
		HostArch: conn.arch,
		Hostname: conn.hostname,
	}); err != nil {
		s.log.Warn("db upsert agent failed", "agent_id", conn.id, "err", err)
	}

	s.log.Info("agent connected",
		"agent_id", conn.id,
		"version", conn.version,
		"os", conn.os,
		"arch", conn.arch,
		"hostname", conn.hostname,
	)

	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: first.CorrelationId,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Hello{
			Hello: &panelv1.ControllerHello{
				ControllerVersion: controllerVersion,
				ServerTime:        timestamppb.Now(),
			},
		},
	}); err != nil {
		return fmt.Errorf("send hello: %w", err)
	}

	for {
		env, err := stream.Recv()
		if err != nil {
			if errors.Is(err, io.EOF) {
				s.log.Info("agent disconnected cleanly", "agent_id", conn.id)
				return nil
			}
			s.log.Info("agent stream closed", "agent_id", conn.id, "err", err)
			return err
		}
		s.registry.touch(conn.id, time.Now())
		s.handleAgentMessage(stream.Context(), conn, env)
	}
}

func (s *agentServer) handleAgentMessage(ctx context.Context, conn *agentConn, env *panelv1.AgentEnvelope) {
	switch p := env.Payload.(type) {
	case *panelv1.AgentEnvelope_Heartbeat:
		s.log.Debug("heartbeat",
			"agent_id", conn.id,
			"cpu_pct", p.Heartbeat.CpuPercent,
			"mem_used", p.Heartbeat.MemUsedBytes,
			"instances", p.Heartbeat.InstanceCount,
		)
		if err := s.db.TouchAgent(ctx, conn.id); err != nil {
			s.log.Debug("db touch agent", "err", err)
		}
	case *panelv1.AgentEnvelope_InstanceState:
		is := p.InstanceState
		s.log.Info("instance state",
			"agent_id", conn.id,
			"instance_id", is.InstanceId,
			"status", is.Status.String(),
			"exit_code", is.ExitCode,
			"detail", is.Detail,
		)
		// When the agent reports the instance as deleted we remove the
		// DB row. Cluster-membership scrub is NOT done here — too coarse:
		// the cluster recreate dance (Setup Cluster, Join Cluster, change
		// ports) does an internal stop → delete → create cycle, which
		// fires this same "deleted" event mid-cycle. Scrubbing here would
		// wipe the membership we'd just written before the create leg
		// re-upserts the row with the new CLUSTER_ID. The eager scrub in
		// httpServer.deleteInstance covers user-initiated deletes; that
		// path doesn't trip the recreate-dance bug.
		if is.Detail == "deleted" {
			if err := s.db.DeleteInstance(ctx, is.InstanceId); err != nil {
				s.log.Warn("db delete instance", "instance_id", is.InstanceId, "err", err)
			}
			// Scrub the WI-14 update-check row too — harmless if absent.
			if err := s.db.DeleteUpdateCheck(ctx, is.InstanceId); err != nil {
				s.log.Debug("db delete update check", "instance_id", is.InstanceId, "err", err)
			}
		} else if err := s.db.UpdateInstanceStatus(ctx, is.InstanceId, statusToDB(is.Status), is.ExitCode, is.Detail); err != nil {
			s.log.Warn("db update instance status", "instance_id", is.InstanceId, "err", err)
		}
		s.bus.Publish(&panelv1.Event{
			AgentId: conn.id, At: env.SentAt,
			Payload: &panelv1.Event_InstanceState{InstanceState: is},
		})
	case *panelv1.AgentEnvelope_AppState:
		// Debug, not Info: app_state arrives on every RCON poll (a few per
		// second across all of a host's instances) and this handler runs
		// synchronously on the single per-agent gRPC read loop. At Info it
		// was a steady journald write in the hot path; the payload is on the
		// bus regardless.
		s.log.Debug("app state",
			"agent_id", conn.id,
			"instance_id", p.AppState.InstanceId,
			"players", p.AppState.PlayersOnline,
			"uptime", p.AppState.UptimeSeconds,
		)
		s.bus.Publish(&panelv1.Event{
			AgentId: conn.id, At: env.SentAt,
			Payload: &panelv1.Event_AppState{AppState: p.AppState},
		})
	case *panelv1.AgentEnvelope_InstanceStats:
		// Too frequent for INFO log; surface only on the bus.
		s.bus.Publish(&panelv1.Event{
			AgentId: conn.id, At: env.SentAt,
			Payload: &panelv1.Event_InstanceStats{InstanceStats: p.InstanceStats},
		})
	case *panelv1.AgentEnvelope_Player:
		s.log.Info("player event",
			"agent_id", conn.id,
			"instance_id", p.Player.InstanceId,
			"kind", p.Player.Kind.String(),
			"name", p.Player.PlayerName,
		)
		s.bus.Publish(&panelv1.Event{
			AgentId: conn.id, At: env.SentAt,
			Payload: &panelv1.Event_Player{Player: p.Player},
		})
	case *panelv1.AgentEnvelope_Log:
		// Debug, not Info. THIS is the firehose: every console line from
		// every instance on the host funnels through here, on the single
		// per-agent gRPC read loop. At Info, a 7DTD boot burst (thousands of
		// lines in seconds) turned each line into a synchronous journald
		// write; journald backpressure then stalled the read loop, which
		// also delivers command-results and app/instance state — so the
		// whole host's panel went unresponsive *while a server was starting*
		// (and the journal bloated to multi-GB). The line is still on the
		// event bus, the per-instance ring buffer, and `docker logs`.
		s.log.Debug("log",
			"agent_id", conn.id,
			"instance_id", p.Log.InstanceId,
			"stream", p.Log.Stream,
			"line", p.Log.Line,
		)
		s.bus.Publish(&panelv1.Event{
			AgentId: conn.id, At: env.SentAt,
			Payload: &panelv1.Event_Log{Log: p.Log},
		})
	case *panelv1.AgentEnvelope_Result:
		errStr := ""
		if p.Result.Error != nil {
			errStr = p.Result.Error.Code + ": " + p.Result.Error.Message
		}
		s.log.Info("command result",
			"agent_id", conn.id,
			"correlation_id", env.CorrelationId,
			"exit_code", p.Result.ExitCode,
			"error", errStr,
		)
		// Hand the result to any HTTP handler that's awaiting this
		// correlation id (awaitAgentCall in httpapi.go, the ARK cluster
		// recreate flow, changePorts, etc). Without this delivery every
		// caller of awaitAgentCall would time out after 90s even though
		// the agent replied almost immediately.
		s.pending.Deliver(env.CorrelationId, env)
	case *panelv1.AgentEnvelope_RconResult:
		s.log.Debug("rcon result",
			"agent_id", conn.id,
			"correlation_id", env.CorrelationId,
			"output_len", len(p.RconResult.Output),
			"error", p.RconResult.Error,
		)
		s.pending.Deliver(env.CorrelationId, env)
	case *panelv1.AgentEnvelope_FsListResult,
		*panelv1.AgentEnvelope_FsReadResult,
		*panelv1.AgentEnvelope_FsWriteResult,
		*panelv1.AgentEnvelope_FsDeleteResult,
		*panelv1.AgentEnvelope_FsSymlinkResult,
		*panelv1.AgentEnvelope_FsExtractResult,
		*panelv1.AgentEnvelope_FsCompressResult,
		*panelv1.AgentEnvelope_FsRenameResult,
		*panelv1.AgentEnvelope_FsWriteChunkResult,
		*panelv1.AgentEnvelope_ArkSaveRestoreResult,
		*panelv1.AgentEnvelope_DayzModInstallResult,
		*panelv1.AgentEnvelope_DayzModUninstallResult,
		*panelv1.AgentEnvelope_UpdateResult,
		*panelv1.AgentEnvelope_BackupResult,
		*panelv1.AgentEnvelope_RestoreResult,
		*panelv1.AgentEnvelope_BackupListEntriesResult,
		*panelv1.AgentEnvelope_BackupReadFileResult,
		*panelv1.AgentEnvelope_EmpyrionScenarioInstallResult,
		*panelv1.AgentEnvelope_EmpyrionDiscoveriesResult,
		*panelv1.AgentEnvelope_EmpyrionPlayerSummaryResult,
		*panelv1.AgentEnvelope_RegionScanResult,
		*panelv1.AgentEnvelope_RegionHealResult,
		*panelv1.AgentEnvelope_InstanceRenderConfigResult,
		*panelv1.AgentEnvelope_SeedModsResult,
		*panelv1.AgentEnvelope_UpdateCheckResult:
		// Correlation-routed results — just deliver to the waiting caller.
		_ = p
		s.pending.Deliver(env.CorrelationId, env)
	default:
		// Defensive: an unrouted result type is a bug — log it loudly so
		// the next "upload silently times out" mystery has a breadcrumb.
		s.log.Warn("agent envelope dropped — no controller switch case",
			"agent_id", conn.id, "correlation_id", env.CorrelationId,
			"payload_type", fmt.Sprintf("%T", env.Payload))
	}
}

// ---- Panel gRPC service (operator ↔ Controller) ----

type panelServer struct {
	panelv1.UnimplementedPanelServer
	log      *slog.Logger
	registry *agentRegistry
	db       *db.DB
	bus      *events.Bus
	pending  *pendingRegistry
	// modules is the in-memory module registry, used by CreateInstance to
	// run the same port allocator the HTTP postInstance does. nil falls
	// back to "send whatever the client gave" — the legacy behavior, only
	// safe when a single instance per module exists per agent.
	modules  *modulepkg.Registry
	// opnfwd is shared with httpServer — same orchestrator instance, so the
	// gRPC create/delete paths (panelctl) hit the same hooks the HTTP
	// dashboard does.
	opnfwd   *opnfwdOrchestrator
}

// ExecRCON forwards an ad-hoc RCON command to the agent owning the
// instance, then blocks until the matching RCONResult envelope arrives
// (or timeout). The correlation_id ties the two halves together.
func (s *panelServer) ExecRCON(ctx context.Context, req *panelv1.ExecRCONRequest) (*panelv1.ExecRCONResponse, error) {
	if req.AgentId == "" || req.InstanceId == "" || req.Command == "" {
		return nil, status.Error(codes.InvalidArgument, "agent_id, instance_id, and command are required")
	}
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}
	timeout := 10 * time.Second
	if req.TimeoutMs > 0 {
		timeout = time.Duration(req.TimeoutMs) * time.Millisecond
	}
	corrID := newCorrelationID("rcon")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Rcon{
			Rcon: &panelv1.RCONCommand{InstanceId: req.InstanceId, Command: req.Command},
		},
	}); err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	env, err := s.pending.Await(ctx, corrID, timeout)
	if err != nil {
		return nil, status.Errorf(codes.DeadlineExceeded, "await response: %v", err)
	}
	result := env.GetRconResult()
	if result == nil {
		return nil, status.Error(codes.Internal, "agent response did not contain rcon_result")
	}
	return &panelv1.ExecRCONResponse{
		Output:        result.Output,
		Error:         result.Error,
		CorrelationId: corrID,
	}, nil
}

// StreamEvents subscribes to the in-memory bus and forwards matching
// events to the operator until the stream closes or ctx is cancelled.
// Slow consumers are dropped (not back-pressured) — Subscription
// reports Dropped() count for diagnostics.
func (s *panelServer) StreamEvents(req *panelv1.StreamEventsRequest, stream panelv1.Panel_StreamEventsServer) error {
	sub := s.bus.Subscribe(events.Filter{
		InstanceID: req.InstanceId,
		AgentID:    req.AgentId,
	})
	defer sub.Close()

	s.log.Info("stream subscriber added",
		"instance_filter", req.InstanceId,
		"agent_filter", req.AgentId,
		"total_subs", s.bus.SubscriberCount(),
	)

	ctx := stream.Context()
	for {
		select {
		case <-ctx.Done():
			return nil
		case ev, ok := <-sub.Events():
			if !ok {
				return nil
			}
			if err := stream.Send(ev); err != nil {
				return err
			}
		}
	}
}

func (s *panelServer) ListAgents(_ context.Context, _ *panelv1.ListAgentsRequest) (*panelv1.ListAgentsResponse, error) {
	agents := s.registry.snapshot()
	out := &panelv1.ListAgentsResponse{Agents: make([]*panelv1.AgentInfo, 0, len(agents))}
	for _, a := range agents {
		a.mu.Lock()
		info := &panelv1.AgentInfo{
			AgentId:     a.id,
			Version:     a.version,
			HostOs:      a.os,
			HostArch:    a.arch,
			Hostname:    a.hostname,
			ConnectedAt: timestamppb.New(a.connectedAt),
			LastSeen:    timestamppb.New(a.lastSeen),
		}
		a.mu.Unlock()
		out.Agents = append(out.Agents, info)
	}
	return out, nil
}

func (s *panelServer) ListInstances(ctx context.Context, req *panelv1.ListInstancesRequest) (*panelv1.ListInstancesResponse, error) {
	rows, err := s.db.ListInstances(ctx, req.AgentId)
	if err != nil {
		return nil, status.Errorf(codes.Internal, "list instances: %v", err)
	}
	out := &panelv1.ListInstancesResponse{Instances: make([]*panelv1.InstanceInfo, 0, len(rows))}
	for _, r := range rows {
		out.Instances = append(out.Instances, &panelv1.InstanceInfo{
			InstanceId:    r.InstanceID,
			AgentId:       r.AgentID,
			ModuleId:      r.ModuleID,
			ModuleVersion: r.ModuleVersion,
			RunMode:       runModeFromDB(r.RunMode),
			Status:        statusFromDB(r.Status),
			LastExitCode:  r.LastExitCode,
			DataPath:      r.DataPath,
			Detail:        r.Detail,
			CreatedAt:     timestamppb.New(r.CreatedAt),
			UpdatedAt:     timestamppb.New(r.UpdatedAt),
		})
	}
	return out, nil
}

func (s *panelServer) CreateInstance(ctx context.Context, req *panelv1.CreateInstanceRequest) (*panelv1.CreateInstanceResponse, error) {
	if req.AgentId == "" || req.InstanceId == "" || req.ModuleId == "" {
		return nil, status.Error(codes.InvalidArgument, "agent_id, instance_id, and module_id are required")
	}
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}

	// Run the same port allocator the dashboard's POST /api/instances uses.
	// Without this, gRPC creates (panelctl, automation) would skip allocation
	// entirely and the agent would fall back to manifest-default ports —
	// guaranteed collisions on any second instance of a given module on the
	// same agent. Browser path was always correct; this brings gRPC in line.
	allocatedPorts := req.Ports
	assignedPorts := map[string]uint32{}
	if s.modules != nil {
		if manifest, ok := s.modules.Get(req.ModuleId); ok {
			ap, am, allocErr := allocatePorts(ctx, s.db, req.AgentId, req.InstanceId, manifest, req.Ports)
			if allocErr != nil {
				return nil, status.Errorf(codes.ResourceExhausted, "port alloc: %v", allocErr)
			}
			allocatedPorts = ap
			assignedPorts = am
		}
	}

	// Persist the instance first — the row survives agent or controller
	// restart. Status reflects user intent ("creating"); the agent's
	// InstanceStateUpdate will soon move it through stopped / running / etc.
	if err := s.db.UpsertInstance(ctx, db.InstanceRow{
		InstanceID:    req.InstanceId,
		AgentID:       req.AgentId,
		ModuleID:      req.ModuleId,
		ModuleVersion: req.Version,
		RunMode:       runModeToDB(req.RunMode),
		DataPath:      req.DataPath,
		ConfigValues:  req.ConfigValues,
		AssignedPorts: assignedPorts,
		Status:        "creating",
	}); err != nil {
		return nil, status.Errorf(codes.Internal, "persist instance: %v", err)
	}

	corrID := newCorrelationID("create")
	err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceCreate{
			InstanceCreate: &panelv1.InstanceCreate{
				InstanceId:   req.InstanceId,
				ModuleId:     req.ModuleId,
				Version:      req.Version,
				RunMode:      req.RunMode,
				ConfigValues: req.ConfigValues,
				Ports:        allocatedPorts,
				DataPath:     req.DataPath,
			},
		},
	})
	if err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	s.log.Info("forwarded InstanceCreate",
		"agent_id", req.AgentId,
		"instance_id", req.InstanceId,
		"module_id", req.ModuleId,
		"correlation_id", corrID,
	)
	// Best-effort opnfwd auto-forward, async so a slow opnfwd doesn't
	// stall the gRPC reply.
	if s.opnfwd != nil {
		go s.opnfwd.onInstanceCreated(context.Background(), req.InstanceId, req.AgentId, req.ModuleId, assignedPorts)
	}
	return &panelv1.CreateInstanceResponse{InstanceId: req.InstanceId, CorrelationId: corrID}, nil
}

func (s *panelServer) StartInstance(_ context.Context, req *panelv1.StartInstanceRequest) (*panelv1.StartInstanceResponse, error) {
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}
	corrID := newCorrelationID("start")
	err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: req.InstanceId},
		},
	})
	if err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	s.log.Info("forwarded InstanceStart", "agent_id", req.AgentId, "instance_id", req.InstanceId, "correlation_id", corrID)
	return &panelv1.StartInstanceResponse{CorrelationId: corrID}, nil
}

// UpdateInstance forwards an UpdateRequest to the instance's agent and
// returns the agent's synchronous accept/reject. Actual update progress
// streams back via LogLine events on the bus (consumed by panelctl watch
// and the dashboard's events pane).
func (s *panelServer) UpdateInstance(ctx context.Context, req *panelv1.UpdateInstanceRequest) (*panelv1.UpdateInstanceResponse, error) {
	if req.AgentId == "" || req.InstanceId == "" {
		return nil, status.Error(codes.InvalidArgument, "agent_id and instance_id are required")
	}
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}
	corrID := newCorrelationID("update")
	err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Update{
			Update: &panelv1.UpdateRequest{InstanceId: req.InstanceId, ProviderId: req.ProviderId},
		},
	})
	if err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	env, err := s.pending.Await(ctx, corrID, 15*time.Second)
	if err != nil {
		return nil, status.Errorf(codes.DeadlineExceeded, "await response: %v", err)
	}
	res := env.GetUpdateResult()
	if res == nil {
		return nil, status.Error(codes.Internal, "agent response missing update_result")
	}
	return &panelv1.UpdateInstanceResponse{
		Accepted:      res.Accepted,
		Error:         res.Error,
		ProviderId:    res.ProviderId,
		ProviderKind:  res.ProviderKind,
		CorrelationId: corrID,
	}, nil
}

// CreateBackup forwards a backup request to the instance's agent,
// awaits the sidecar's completion, and persists the resulting archive
// metadata in the DB. Operators can then restore from or list these.
func (s *panelServer) CreateBackup(ctx context.Context, req *panelv1.CreateBackupRequest) (*panelv1.CreateBackupResponse, error) {
	if req.AgentId == "" || req.InstanceId == "" {
		return nil, status.Error(codes.InvalidArgument, "agent_id and instance_id are required")
	}
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}
	corrID := newCorrelationID("backup")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Backup{
			Backup: &panelv1.BackupRequest{InstanceId: req.InstanceId, Description: req.Description},
		},
	}); err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	// Backups can take a while (multi-GB volumes). Allow generous timeout.
	env, err := s.pending.Await(ctx, corrID, 30*time.Minute)
	if err != nil {
		return nil, status.Errorf(codes.DeadlineExceeded, "await response: %v", err)
	}
	res := env.GetBackupResult()
	if res == nil {
		return nil, status.Error(codes.Internal, "agent reply missing backup_result")
	}
	if res.Error != "" {
		return nil, status.Errorf(codes.Internal, "backup failed: %s", res.Error)
	}
	if err := s.db.InsertBackup(ctx, db.BackupRow{
		ID:          res.BackupId,
		InstanceID:  req.InstanceId,
		AgentID:     req.AgentId,
		Path:        res.Path,
		SizeBytes:   res.SizeBytes,
		Description: req.Description,
	}); err != nil {
		s.log.Warn("backup succeeded but db insert failed", "err", err)
	}
	return &panelv1.CreateBackupResponse{
		BackupId:  res.BackupId,
		Path:      res.Path,
		SizeBytes: res.SizeBytes,
	}, nil
}

// RestoreBackup looks up the backup by id to find its agent + path,
// forwards a RestoreRequest, and awaits completion.
func (s *panelServer) RestoreBackup(ctx context.Context, req *panelv1.RestoreBackupRequest) (*panelv1.RestoreBackupResponse, error) {
	if req.InstanceId == "" || req.BackupId == "" {
		return nil, status.Error(codes.InvalidArgument, "instance_id and backup_id are required")
	}
	b, err := s.db.GetBackup(ctx, req.BackupId)
	if err != nil {
		return nil, status.Errorf(codes.NotFound, "backup %q: %v", req.BackupId, err)
	}
	if b.InstanceID != req.InstanceId {
		return nil, status.Error(codes.InvalidArgument, "backup belongs to a different instance")
	}
	agentID := b.AgentID
	if req.AgentId != "" {
		agentID = req.AgentId
	}
	conn := s.registry.get(agentID)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", agentID)
	}
	corrID := newCorrelationID("restore")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Restore{
			Restore: &panelv1.RestoreRequest{
				InstanceId: req.InstanceId,
				BackupId:   req.BackupId,
				BackupPath: b.Path,
			},
		},
	}); err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	env, err := s.pending.Await(ctx, corrID, 30*time.Minute)
	if err != nil {
		return nil, status.Errorf(codes.DeadlineExceeded, "await response: %v", err)
	}
	res := env.GetRestoreResult()
	if res == nil {
		return nil, status.Error(codes.Internal, "agent reply missing restore_result")
	}
	if res.Error != "" {
		return nil, status.Errorf(codes.Internal, "restore failed: %s", res.Error)
	}
	return &panelv1.RestoreBackupResponse{BytesRestored: res.BytesRestored}, nil
}

// DeleteInstance forwards an InstanceDelete to the agent, awaits the
// terminal CommandResult, and — on success — deletes the controller's
// DB row. Fire-and-forget for volume cleanup is avoided so partial
// failures surface to the operator.
func (s *panelServer) DeleteInstance(ctx context.Context, req *panelv1.DeleteInstanceRequest) (*panelv1.DeleteInstanceResponse, error) {
	if req.AgentId == "" || req.InstanceId == "" {
		return nil, status.Error(codes.InvalidArgument, "agent_id and instance_id are required")
	}
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}
	// Drop opnfwd forwards before sending the delete envelope. Best-effort.
	if s.opnfwd != nil {
		s.opnfwd.onInstanceDeleting(ctx, req.InstanceId)
	}
	corrID := newCorrelationID("delete")
	err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceDelete{
			InstanceDelete: &panelv1.InstanceDelete{
				InstanceId:    req.InstanceId,
				GraceSeconds:  req.GraceSeconds,
				PurgeVolumes:  req.PurgeVolumes,
			},
		},
	})
	if err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	// DB row cleanup happens async in agentServer.handleAgentMessage when
	// we see the terminal InstanceStateUpdate{detail=deleted}; see main
	// loop in this file.
	s.log.Info("forwarded InstanceDelete", "agent_id", req.AgentId, "instance_id", req.InstanceId, "purge", req.PurgeVolumes, "correlation_id", corrID)
	return &panelv1.DeleteInstanceResponse{CorrelationId: corrID}, nil
}

func (s *panelServer) StopInstance(_ context.Context, req *panelv1.StopInstanceRequest) (*panelv1.StopInstanceResponse, error) {
	conn := s.registry.get(req.AgentId)
	if conn == nil {
		return nil, status.Errorf(codes.NotFound, "agent %q is not connected", req.AgentId)
	}
	corrID := newCorrelationID("stop")
	err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: req.InstanceId, GraceSeconds: req.GraceSeconds},
		},
	})
	if err != nil {
		return nil, status.Errorf(codes.Unavailable, "forward to agent: %v", err)
	}
	s.log.Info("forwarded InstanceStop", "agent_id", req.AgentId, "instance_id", req.InstanceId, "correlation_id", corrID)
	return &panelv1.StopInstanceResponse{CorrelationId: corrID}, nil
}

func newCorrelationID(op string) string {
	return fmt.Sprintf("%s-%d", op, time.Now().UnixNano())
}

// ---- Shared forwarding helpers (used by both panel RPC and scheduler) ----

func forwardRCON(ctx context.Context, registry *agentRegistry, pending *pendingRegistry, agentID, instanceID, command string, timeout time.Duration) (string, error) {
	conn := registry.get(agentID)
	if conn == nil {
		return "", fmt.Errorf("agent %q is not connected", agentID)
	}
	corrID := newCorrelationID("rcon")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Rcon{
			Rcon: &panelv1.RCONCommand{InstanceId: instanceID, Command: command},
		},
	}); err != nil {
		return "", fmt.Errorf("forward: %w", err)
	}
	env, err := pending.Await(ctx, corrID, timeout)
	if err != nil {
		return "", err
	}
	r := env.GetRconResult()
	if r == nil {
		return "", fmt.Errorf("agent response missing rcon_result")
	}
	if r.Error != "" {
		return r.Output, fmt.Errorf("%s", r.Error)
	}
	return r.Output, nil
}

// forwardFsRead asks the agent to read a file from inside an instance's
// container (via the existing FsRead RPC the Files tab uses). Returns the
// bytes on success. Used by the scheduler's bloodmoon-aware path to read
// PanelBloodMoonAgent's status JSON without adding a new RPC.
func forwardFsRead(ctx context.Context, registry *agentRegistry, pending *pendingRegistry, agentID, instanceID, path string, timeout time.Duration) ([]byte, error) {
	conn := registry.get(agentID)
	if conn == nil {
		return nil, fmt.Errorf("agent %q is not connected", agentID)
	}
	corrID := newCorrelationID("fsread")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsRead{
			FsRead: &panelv1.FsReadRequest{InstanceId: instanceID, Path: path},
		},
	}); err != nil {
		return nil, fmt.Errorf("forward fs-read: %w", err)
	}
	env, err := pending.Await(ctx, corrID, timeout)
	if err != nil {
		return nil, err
	}
	r := env.GetFsReadResult()
	if r == nil {
		return nil, fmt.Errorf("agent response missing fs_read_result")
	}
	if r.Error != "" {
		return nil, fmt.Errorf("%s", r.Error)
	}
	return r.Content, nil
}

func forwardInstanceStart(registry *agentRegistry, agentID, instanceID string) error {
	conn := registry.get(agentID)
	if conn == nil {
		return fmt.Errorf("agent %q is not connected", agentID)
	}
	return conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: newCorrelationID("start"),
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: instanceID},
		},
	})
}

// postWebhook fires an HTTP request for a scheduler webhook action.
// Short timeout so a slow sink can't stall the scheduler goroutine.
func postWebhook(ctx context.Context, url, method, body string, headers map[string]string) (int, error) {
	if method == "" {
		method = "POST"
	}
	req, err := http.NewRequestWithContext(ctx, method, url, strings.NewReader(body))
	if err != nil {
		return 0, fmt.Errorf("build request: %w", err)
	}
	for k, v := range headers {
		req.Header.Set(k, v)
	}
	if req.Header.Get("Content-Type") == "" && body != "" {
		// Best-effort: if body looks like JSON, label it as such.
		trimmed := strings.TrimSpace(body)
		if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
			req.Header.Set("Content-Type", "application/json")
		}
	}
	req.Header.Set("User-Agent", version.UserAgent())
	client := &http.Client{Timeout: 10 * time.Second}
	resp, err := client.Do(req)
	if err != nil {
		return 0, err
	}
	defer resp.Body.Close()
	// Drain up to 8 KiB to release the connection back to the pool.
	_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 8*1024))
	return resp.StatusCode, nil
}

func forwardInstanceStop(registry *agentRegistry, agentID, instanceID string, grace uint32) error {
	conn := registry.get(agentID)
	if conn == nil {
		return fmt.Errorf("agent %q is not connected", agentID)
	}
	return conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: newCorrelationID("stop"),
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: instanceID, GraceSeconds: grace},
		},
	})
}

// ---- proto <-> DB conversions ----

func statusToDB(s panelv1.InstanceStatus) string {
	switch s {
	case panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED:
		return "stopped"
	case panelv1.InstanceStatus_INSTANCE_STATUS_STARTING:
		return "starting"
	case panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING:
		return "running"
	case panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING:
		return "stopping"
	case panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED:
		return "crashed"
	case panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING:
		return "updating"
	default:
		return "unspecified"
	}
}

func statusFromDB(s string) panelv1.InstanceStatus {
	switch s {
	case "stopped":
		return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
	case "starting":
		return panelv1.InstanceStatus_INSTANCE_STATUS_STARTING
	case "running":
		return panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING
	case "stopping":
		return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING
	case "crashed":
		return panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED
	case "updating":
		return panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING
	case "creating":
		// represents a pending intent; UI treats as stopped-pending
		return panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED
	default:
		return panelv1.InstanceStatus_INSTANCE_STATUS_UNSPECIFIED
	}
}

func runModeToDB(m panelv1.RunMode) string {
	switch m {
	case panelv1.RunMode_RUN_MODE_HOST:
		return "host"
	default:
		return "docker"
	}
}

func runModeFromDB(s string) panelv1.RunMode {
	switch s {
	case "host":
		return panelv1.RunMode_RUN_MODE_HOST
	default:
		return panelv1.RunMode_RUN_MODE_DOCKER
	}
}

// ---- main ----

const defaultDBURL = "postgres://panel:panel_dev@127.0.0.1:5432/panel?sslmode=disable"

func main() {
	grpcAddr := flag.String("grpc", ":8443", "gRPC listen address (both Agent + Panel services)")
	httpAddr := flag.String("http", ":8080", "HTTP dashboard + REST API listen address")
	caDir := flag.String("ca-dir", "./data/ca", "directory holding internal CA + server cert (auto-generated on first run)")
	tlsMode := flag.String("tls", "auto", "auto | on | off — 'auto' enables mTLS if CA + server cert present")
	extraHostnames := flag.String("tls-hostnames", "", "comma-separated SAN DNS names to add to the server cert (e.g. 'panel.example.com')")
	logLevel := flag.String("log-level", "info", "log level: debug, info, warn, error")
	dbURL := flag.String("db-url", defaultDBURL, "Postgres connection string (env PANEL_DB_URL also honored)")
	// Steam OpenID flags.
	publicURL := flag.String("public-url", "",
		"Public base URL used for Steam OpenID realm + return_to (e.g. https://panel.example.com). "+
			"If empty, the request's Host header is used — fine for LAN/dev, MUST be set behind a reverse proxy.")
	initialAdminSteamID := flag.String("initial-admin-steam-id", "",
		"17-digit SteamID64 of the initial owner. On first run the bootstrap admin gets this Steam ID linked, "+
			"enabling Sign-in-with-Steam. On subsequent runs, if the first admin has no Steam ID linked, this value links it. "+
			"Ignored once the first admin already has a steam_id set.")
	flag.Parse()

	if envURL := os.Getenv("PANEL_DB_URL"); envURL != "" && *dbURL == defaultDBURL {
		*dbURL = envURL
	}

	var lvl slog.Level
	if err := lvl.UnmarshalText([]byte(*logLevel)); err != nil {
		fmt.Fprintf(os.Stderr, "invalid log level %q: %v\n", *logLevel, err)
		os.Exit(2)
	}
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl}))

	ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer cancel()

	logger.Info("opening database", "url", redactDSN(*dbURL))
	database, err := db.Open(ctx, *dbURL)
	if err != nil {
		logger.Error("db open failed (is docker compose up?)", "err", err)
		os.Exit(1)
	}
	defer database.Close()
	logger.Info("database ready")

	// Internal CA — always bootstrap. Controls gRPC TLS eligibility.
	var caBundle *ca.CA
	{
		hosts := []string{}
		if *extraHostnames != "" {
			for _, h := range strings.Split(*extraHostnames, ",") {
				if h = strings.TrimSpace(h); h != "" {
					hosts = append(hosts, h)
				}
			}
		}
		b, err := ca.Ensure(*caDir, hosts)
		if err != nil {
			logger.Error("CA bootstrap", "err", err)
			os.Exit(1)
		}
		caBundle = b
		logger.Info("internal CA ready",
			"ca_dir", *caDir,
			"ca_subject", b.Root.Subject.CommonName,
			"ca_expires", b.Root.NotAfter.Format("2006-01-02"),
			"server_expires", b.Server.NotAfter.Format("2006-01-02"),
		)
	}

	lis, err := net.Listen("tcp", *grpcAddr)
	if err != nil {
		logger.Error("listen failed", "addr", *grpcAddr, "err", err)
		os.Exit(1)
	}

	registry := newAgentRegistry()
	bus := events.New(512)
	logBuf := events.NewLogBuffer(bus, 500)
	pending := newPendingRegistry()
	_ = logBuf // referenced later by httpServer

	// Decide TLS mode. "auto" = TLS on if CA bootstrap succeeded;
	// "on" = require TLS; "off" = plaintext always.
	tlsEnabled := false
	switch *tlsMode {
	case "off":
		tlsEnabled = false
	case "on":
		tlsEnabled = true
	default:
		tlsEnabled = caBundle != nil
	}

	var grpcServer *grpc.Server
	if tlsEnabled {
		tlsCfg, err := buildServerTLS(caBundle)
		if err != nil {
			logger.Error("build server TLS", "err", err)
			os.Exit(1)
		}
		grpcServer = grpc.NewServer(
			grpc.Creds(credentials.NewTLS(tlsCfg)),
			grpc.MaxRecvMsgSize(grpcMaxMsgSize),
			grpc.MaxSendMsgSize(grpcMaxMsgSize),
		)
		logger.Info("gRPC TLS enabled (mTLS: agents must present a cert signed by our CA)")
	} else {
		grpcServer = grpc.NewServer(
			grpc.MaxRecvMsgSize(grpcMaxMsgSize),
			grpc.MaxSendMsgSize(grpcMaxMsgSize),
		)
		logger.Warn("gRPC TLS DISABLED — agents connect with --insecure")
	}
	// One ark-cluster store shared across the agent state-event handler
	// and the HTTP API — both need to read/write ark-clusters.json so
	// they must mutex on the same instance.
	arkClusters := newArkClusterStore("./data")
	sevenDtdClusters := newSevenDtdClusterStore("./data")

	// Bootstrap the long-lived admin token used by panelctl admin and
	// other out-of-band repair tools. File mode 0600; auto-generated on
	// first start. See admin.go.
	adminToken, adminTokenNew, err := ensureAdminToken("./data")
	if err != nil {
		logger.Error("admin token bootstrap", "err", err)
		os.Exit(1)
	}
	if adminTokenNew {
		logger.Info("admin token generated", "path", "./data/"+adminTokenFile, "len", len(adminToken))
	}

	// Best-effort load the modules/ directory so the port allocator can
	// read manifest port declarations without round-tripping the agent.
	// Both controller and agent run from the same WorkingDirectory in
	// the standard prod layout; if modules/ isn't there (e.g. controller
	// running on a separate machine), the allocator gracefully falls
	// back to its no-op path and the agent's own port-bump kicks in.
	moduleRegistry := modulepkg.NewRegistry()
	if err := moduleRegistry.LoadDir("./modules"); err != nil {
		logger.Warn("module registry load failed (port allocator will be a no-op)", "err", err)
	} else {
		logger.Info("loaded module manifests", "count", moduleRegistry.Len())
	}

	// opnfwd integration — orchestrator wires DB + module registry + opnfwd
	// HTTP client. Shared between the HTTP server's create/delete paths and
	// the gRPC panelServer's create/delete paths so panelctl-driven actions
	// hit the same hooks the dashboard does. No-op until operator flips
	// panel_settings.opnfwd_enabled = true.
	opnfwdOrch := newOpnfwdOrchestrator(logger, database, moduleRegistry)

	srv := grpcServer
	panelv1.RegisterAgentServer(srv, &agentServer{log: logger, registry: registry, db: database, bus: bus, pending: pending, arkClusters: arkClusters, sevenDtdClusters: sevenDtdClusters})
	panelv1.RegisterPanelServer(srv, &panelServer{log: logger, registry: registry, db: database, bus: bus, pending: pending, modules: moduleRegistry, opnfwd: opnfwdOrch})

	// httpSrvPtr is a deferred-set pointer so the scheduler executor closures
	// can reference the httpServer after it's built. The scheduler fires after
	// httpSrv is constructed, so by the time closures are called, srv is set.
	type httpSrvHolder struct {
		mu  sync.Mutex
		srv *httpServer
	}
	httpSrvHolderVar := &httpSrvHolder{}

	// Scheduler — timed triggers + action dispatch. Executor closures
	// bridge into the existing agent forwarding machinery.
	executor := Executor{
		ExecRCON: func(ctx context.Context, agentID, instanceID, cmd string) (string, error) {
			return forwardRCON(ctx, registry, pending, agentID, instanceID, cmd, 10*time.Second)
		},
		StartInst: func(ctx context.Context, agentID, instanceID string) error {
			return forwardInstanceStart(registry, agentID, instanceID)
		},
		StopInst: func(ctx context.Context, agentID, instanceID string, grace uint32) error {
			return forwardInstanceStop(registry, agentID, instanceID, grace)
		},
		Webhook: postWebhook,
		Backup: func(ctx context.Context, agentID, instanceID, description string) (int64, error) {
			res, err := runBackupShared(ctx, registry, pending, database, agentID, instanceID, description)
			if err != nil {
				return 0, err
			}
			return res.SizeBytes, nil
		},
		Empyrion: func(ctx context.Context, instanceID, subop, body string) (string, error) {
			// Reuse the http server's resolver logic by constructing a temp httpServer.
			// In practice main wires both, so we look up here directly.
			h := &httpServer{db: database}
			tgt, err := h.resolveEmpyrionTarget(ctx, instanceID)
			if err != nil {
				return "", err
			}
			return fireEmpyrionScheduledAction(ctx, tgt, subop, body)
		},
		BloodMoonStatus: func(ctx context.Context, agentID, instanceID string) (BloodMoonStatus, error) {
			// Status file is written by PanelBloodMoonAgent.dll (a panel-owned
			// 7DTD mod baked into the runtime image). Lives next to the
			// telnet-password dotfile, same volume the entrypoint owns.
			const statusPath = "/game-saves/.panel-bloodmoon-status.json"
			data, err := forwardFsRead(ctx, registry, pending, agentID, instanceID, statusPath, 10*time.Second)
			if err != nil {
				return BloodMoonStatus{Source: "unavailable"}, err
			}
			var s BloodMoonStatus
			if err := json.Unmarshal(data, &s); err != nil {
				return BloodMoonStatus{Source: "unavailable"}, fmt.Errorf("bloodmoon json parse: %w", err)
			}
			return s, nil
		},
		EmpyrionFreshStart: func(ctx context.Context, instanceID, body string) (string, error) {
			// The httpServer is constructed later; we use a deferred-set pointer.
			// The scheduler fires after httpSrv is built, so by the time this
			// closure is called, httpSrvHolderVar.srv will be non-nil.
			httpSrvHolderVar.mu.Lock()
			h := httpSrvHolderVar.srv
			httpSrvHolderVar.mu.Unlock()
			if h == nil {
				return "", errors.New("empyrion-fresh-start not available yet")
			}
			var req freshStartReq
			if body != "" {
				_ = json.Unmarshal([]byte(body), &req)
			}
			if req.AnnouncementMinutes <= 0 {
				req.AnnouncementMinutes = 10
			}
			if req.WipeScope == "" {
				req.WipeScope = "poi"
			}
			return fireEmpyrionFreshStart(ctx, h, instanceID, req)
		},
	}

	// Empyrion bridge SSE → Postgres recorder. One subscription per running
	// empyrion instance; reconciles every 30s.
	{
		shim := &httpServer{db: database}
		recorder := newEmpyrionHistoryRecorder(database, func(ctx context.Context, iid string) (*empyrionTarget, error) {
			return shim.resolveEmpyrionTarget(ctx, iid)
		}, logger)
		go recorder.Run(ctx)
	}
	// Data retention: empyrion_events is append-only and unbounded; prune
	// rows older than 90 days once at boot then daily. known_players is a
	// per-player upsert cache (already compact) — deliberately untouched.
	go func() {
		const keep = 90 * 24 * time.Hour
		t := time.NewTicker(24 * time.Hour)
		defer t.Stop()
		for {
			if n, err := database.PruneEmpyrionEvents(ctx, keep); err != nil {
				logger.Warn("empyrion_events prune", "err", err)
			} else if n > 0 {
				logger.Info("empyrion_events prune", "deleted", n)
			}
			select {
			case <-ctx.Done():
				return
			case <-t.C:
			}
		}
	}()
	// Empyrion chat-bot. Opens a second SSE connection per running empyrion
	// instance and responds to "!command" chat messages.
	{
		shim := &httpServer{db: database}
		chatbot := newChatBotRunner(logger, database, func(ctx context.Context, iid string) (*empyrionTarget, error) {
			return shim.resolveEmpyrionTarget(ctx, iid)
		})
		go chatbot.Run(ctx)
	}
	engine := newScheduleEngine(logger, database, registry, executor)
	events2, err := newEventTriggers(engine, bus)
	if err != nil {
		logger.Error("event triggers init", "err", err)
		os.Exit(1)
	}
	engine.events = events2
	if err := engine.Start(ctx); err != nil {
		logger.Error("scheduler start", "err", err)
		os.Exit(1)
	}

	// Bootstrap the admin account on first run. The generated password is
	// printed to the log so the operator can grab it immediately.
	if err := bootstrapAdmin(ctx, logger, database, os.Getenv("PANEL_ADMIN_EMAIL"), os.Getenv("PANEL_ADMIN_PASSWORD"), *initialAdminSteamID); err != nil {
		logger.Error("admin bootstrap", "err", err)
		os.Exit(1)
	}

	// Seed default empyrion kits when the templates table is empty.
	if err := seedEmpyrionKits(ctx, logger, database); err != nil {
		logger.Warn("empyrion kit seed", "err", err)
	}

	authSvc := &auth{log: logger, db: database, publicURLOverride: *publicURL}
	pairSvc := &pairService{ca: caBundle, db: database}

	httpSrvHandler := &httpServer{
		log: logger, registry: registry, db: database, bus: bus, logBuf: logBuf,
		pending: pending, engine: engine, auth: authSvc, pair: pairSvc,
		ca: caBundle, dayzJobs: newDayzModJobManager(), arkClusters: arkClusters, sevenDtdClusters: sevenDtdClusters,
		modules: moduleRegistry, adminToken: adminToken, opnfwd: opnfwdOrch,
		freshStartJobs: newFreshStartManager(),
		httpAddr:       *httpAddr,
		metrics:        newMetricsSampler(bus),
	}
	// Prometheus /metrics (WI-11): dedicated registry reading bus/registry/
	// pool state at scrape time; scheduler fire outcomes flow in via onFire.
	httpSrvHandler.prom = newPromMetrics(httpSrvHandler)
	engine.onFire = httpSrvHandler.prom.recordScheduleFire
	// Cluster-metrics sampler: folds live events into a rolling time-series
	// that backs the dashboard's overview sparklines (survives page reloads).
	go httpSrvHandler.metrics.run(ctx)
	// Wire the httpServer into the scheduler executor for fresh-start actions.
	httpSrvHolderVar.mu.Lock()
	httpSrvHolderVar.srv = httpSrvHandler
	httpSrvHolderVar.mu.Unlock()

	httpSrv := &http.Server{
		Addr:    *httpAddr,
		Handler: httpSrvHandler.handler(),
	}

	logger.Info("controller starting",
		"version", controllerVersion,
		"grpc", *grpcAddr,
		"http", *httpAddr,
	)

	go func() {
		<-ctx.Done()
		logger.Info("shutdown signal received, stopping gracefully")
		<-engine.Stop().Done()
		shutCtx, shutCancel := context.WithTimeout(context.Background(), 5*time.Second)
		defer shutCancel()
		_ = httpSrv.Shutdown(shutCtx)
		// gRPC GracefulStop blocks until every open stream closes — but the
		// agent's Connect RPC is a long-lived bidi stream that doesn't end
		// on controller shutdown, so without a deadline this stalls for the
		// full systemd TimeoutStopSec (~90s) before SIGKILL. Bound it to a
		// few seconds, then forcibly terminate. The agent reconnects within
		// seconds against the new controller binary.
		stopped := make(chan struct{})
		go func() { srv.GracefulStop(); close(stopped) }()
		select {
		case <-stopped:
		case <-time.After(5 * time.Second):
			logger.Warn("gRPC graceful stop timed out, forcing close (agent stream)")
			srv.Stop()
		}
	}()

	go func() {
		logger.Info("http dashboard listening", "addr", *httpAddr, "url", "http://localhost"+*httpAddr+"/")
		if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			logger.Error("http serve failed", "err", err)
		}
	}()

	if err := srv.Serve(lis); err != nil && !errors.Is(err, grpc.ErrServerStopped) {
		logger.Error("serve failed", "err", err)
		os.Exit(1)
	}
	logger.Info("controller stopped")
}

// redactDSN masks the password in a Postgres URL for safe logging.
func redactDSN(dsn string) string {
	// naive: find ":" after "://" and "@" and replace the middle
	schemeEnd := -1
	atIdx := -1
	for i := 0; i < len(dsn)-2; i++ {
		if dsn[i] == ':' && dsn[i+1] == '/' && dsn[i+2] == '/' {
			schemeEnd = i + 3
			break
		}
	}
	if schemeEnd < 0 {
		return dsn
	}
	for i := schemeEnd; i < len(dsn); i++ {
		if dsn[i] == '@' {
			atIdx = i
			break
		}
	}
	if atIdx < 0 {
		return dsn
	}
	// find the ":" between schemeEnd and atIdx
	for i := schemeEnd; i < atIdx; i++ {
		if dsn[i] == ':' {
			return dsn[:i+1] + "***" + dsn[atIdx:]
		}
	}
	return dsn
}
