package main

// Admin HTTP surface for out-of-band operator repair.
//
// These endpoints duplicate (and intentionally bypass) the regular
// session-protected /api/* surface so an operator with shell access to
// the prod box can drive recreate-dance fixes without first having a
// dashboard session — which historically required deploying a temporary
// "localhost-bypass" build on every incident (see changelog 2026-04-28).
//
// Auth model: a request is admitted if EITHER
//   1. RemoteAddr resolves to loopback (127.0.0.1 / ::1), OR
//   2. the X-Panel-Admin-Token header (constant-time compared) matches
//      the on-disk token at $data_dir/admin-token.
//
// The token is auto-generated on first start. It lives at mode 0600 so
// only the panel user (refuge in prod) can read it. There is no
// rotation API yet — to rotate, delete the file and restart the
// controller. The new token is logged once at boot.
//
// Endpoints (all under /admin/v1/, not /api/, so they sit OUTSIDE the
// session middleware):
//   GET  /admin/v1/health
//   GET  /admin/v1/instances
//   POST /admin/v1/instances/{id}/ark-cluster-rebind
//   POST /admin/v1/instances/{id}/config       body: {"set":{...},"delete":[...]}
//   POST /admin/v1/instances/{id}/recreate     body: {"mount_overrides":{...}}

import (
	"context"
	"crypto/rand"
	"crypto/subtle"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net"
	"net/http"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
	"google.golang.org/protobuf/types/known/timestamppb"
)

const (
	adminTokenFile     = "admin-token"
	adminTokenHeader   = "X-Panel-Admin-Token"
	adminRecreateBudget = 3 * time.Minute
)

// ensureAdminToken loads the existing token from $dataDir/admin-token, or
// generates and persists a new one. Returns the token bytes (raw hex).
// Logs a "first run" message when generating; subsequent reads are silent.
func ensureAdminToken(dataDir string) (string, bool, error) {
	path := filepath.Join(dataDir, adminTokenFile)
	if b, err := os.ReadFile(path); err == nil {
		tok := strings.TrimSpace(string(b))
		if len(tok) >= 32 {
			return tok, false, nil
		}
		// File exists but is empty/short — treat as missing and regenerate.
	} else if !errors.Is(err, os.ErrNotExist) {
		return "", false, fmt.Errorf("read admin-token: %w", err)
	}
	if err := os.MkdirAll(dataDir, 0o755); err != nil {
		return "", false, fmt.Errorf("mkdir data: %w", err)
	}
	buf := make([]byte, 32)
	if _, err := rand.Read(buf); err != nil {
		return "", false, fmt.Errorf("rand: %w", err)
	}
	tok := hex.EncodeToString(buf)
	if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil {
		return "", false, fmt.Errorf("write admin-token: %w", err)
	}
	return tok, true, nil
}

// adminAuth gates the admin mux. Loopback (127.0.0.1 / ::1) is always
// allowed without a token — this matches the on-host operator pattern.
// Otherwise the request must carry X-Panel-Admin-Token = the on-disk
// token. Constant-time compare so a timing oracle can't probe the token.
func (h *httpServer) adminAuth(token string, next http.Handler) http.Handler {
	want := []byte(token)
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if isLoopback(r.RemoteAddr) {
			next.ServeHTTP(w, r)
			return
		}
		got := r.Header.Get(adminTokenHeader)
		if got == "" || subtle.ConstantTimeCompare([]byte(got), want) != 1 {
			h.log.Warn("admin: token rejected", "remote", r.RemoteAddr, "path", r.URL.Path)
			writeError(w, http.StatusUnauthorized, "unauthorized", "valid "+adminTokenHeader+" required for non-loopback requests")
			return
		}
		next.ServeHTTP(w, r)
	})
}

func isLoopback(remoteAddr string) bool {
	host, _, err := net.SplitHostPort(remoteAddr)
	if err != nil {
		host = remoteAddr
	}
	ip := net.ParseIP(host)
	return ip != nil && ip.IsLoopback()
}

// requireSessionOrAdminToken is the gate for /api/*. A request is admitted if:
//
//   1. caller is loopback (on-host operator drives), OR
//   2. caller carries a valid X-Panel-Admin-Token header (service callers
//      from a different LAN host — AMP-Monitor on toulouse, etc.), OR
//   3. caller has a valid panel_session cookie (browser session).
//
// Falls back through the cases in that order. Routes that need an
// authenticated user (handleMe, handleChangePassword) read sessionUserKey
// from the request context and 401 themselves if absent — service callers
// don't have a user identity, just admit-or-deny.
//
// adminToken empty (shouldn't happen at runtime; only in tests) → behaves
// exactly like requireSession.
func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) http.Handler {
	sessionGate := h.auth.requireSession(isAPI, next)
	if h.adminToken == "" {
		return sessionGate
	}
	want := []byte(h.adminToken)
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if isLoopback(r.RemoteAddr) {
			next.ServeHTTP(w, r)
			return
		}
		if got := r.Header.Get(adminTokenHeader); got != "" &&
			subtle.ConstantTimeCompare([]byte(got), want) == 1 {
			next.ServeHTTP(w, r)
			return
		}
		sessionGate.ServeHTTP(w, r)
	})
}

// registerAdminRoutes wires the admin mux onto the outer mux. Called
// from httpServer.handler() during construction.
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
	if token == "" {
		// Defensive: refuse to register if no token is available. This
		// would only fire if ensureAdminToken returned empty, which
		// shouldn't happen on a healthy boot.
		h.log.Error("admin routes NOT registered (empty token)")
		return
	}
	adm := http.NewServeMux()
	adm.HandleFunc("GET /admin/v1/health", h.adminHealth)
	adm.HandleFunc("GET /admin/v1/instances", h.adminListInstances)
	adm.HandleFunc("POST /admin/v1/instances/{id}/ark-cluster-rebind", h.adminArkClusterRebind)
	adm.HandleFunc("POST /admin/v1/instances/{id}/config", h.adminSetConfig)
	adm.HandleFunc("POST /admin/v1/instances/{id}/recreate", h.adminRecreate)
	adm.HandleFunc("POST /admin/v1/instances/{id}/install-base-mods", h.adminInstallBaseMods)
	adm.HandleFunc("GET /admin/v1/empyrion/discoveries", h.adminEmpyrionDiscoveries)
	adm.HandleFunc("GET /admin/v1/empyrion/player-summary", h.adminEmpyrionPlayerSummary)
	mux.Handle("/admin/v1/", h.adminAuth(token, adm))
}

// ---- handlers ----

func (h *httpServer) adminHealth(w http.ResponseWriter, r *http.Request) {
	host, _ := os.Hostname()
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":       true,
		"version":  controllerVersion,
		"hostname": host,
		"loopback": isLoopback(r.RemoteAddr),
		"time":     time.Now().UTC().Format(time.RFC3339),
	})
}

type adminInstanceDTO struct {
	InstanceID    string            `json:"instance_id"`
	AgentID       string            `json:"agent_id"`
	ModuleID      string            `json:"module_id"`
	Status        string            `json:"status"`
	ConfigValues  map[string]string `json:"config_values"`
	AssignedPorts map[string]uint32 `json:"assigned_ports"`
	ARKClusterID  string            `json:"ark_cluster_id,omitempty"`
}

func (h *httpServer) adminListInstances(w http.ResponseWriter, r *http.Request) {
	rows, err := h.db.ListInstances(r.Context(), "")
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	clusterMembers := map[string]string{}
	if h.arkClusters != nil {
		h.arkClusters.mu.Lock()
		reg, lerr := h.arkClusters.load()
		h.arkClusters.mu.Unlock()
		if lerr == nil {
			clusterMembers = reg.Members
		}
	}
	out := make([]adminInstanceDTO, 0, len(rows))
	for _, row := range rows {
		out = append(out, adminInstanceDTO{
			InstanceID:    row.InstanceID,
			AgentID:       row.AgentID,
			ModuleID:      row.ModuleID,
			Status:        row.Status,
			ConfigValues:  row.ConfigValues,
			AssignedPorts: row.AssignedPorts,
			ARKClusterID:  clusterMembers[row.InstanceID],
		})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
	writeJSON(w, http.StatusOK, map[string]any{"instances": out})
}

// adminArkClusterRebind re-runs recreateArkWithClusterMount against the
// instance's currently-registered cluster. Use case: container drifted
// off the bind mount (recreate path missed MountOverrides), and we want
// to re-attach without changing any other state.
func (h *httpServer) adminArkClusterRebind(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	row, err := h.loadInstanceForAdmin(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	if row.ModuleID != "ark-sa" {
		writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("instance %q is module %q, not ark-sa", id, row.ModuleID))
		return
	}
	h.arkClusters.mu.Lock()
	reg, lerr := h.arkClusters.load()
	h.arkClusters.mu.Unlock()
	if lerr != nil {
		writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
		return
	}
	cid := reg.Members[id]
	if cid == "" {
		writeError(w, http.StatusBadRequest, "not_clustered", "instance is not registered as a cluster member; use the dashboard to set up/join a cluster first")
		return
	}
	ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
	defer cancel()
	if err := h.recreateArkWithClusterMount(ctx, row, row.AgentID, cid); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	h.log.Info("admin: ark-cluster-rebind", "instance", id, "cluster", cid, "remote", r.RemoteAddr)
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":          true,
		"instance_id": id,
		"cluster_id":  cid,
	})
}

type adminSetConfigReq struct {
	Set    map[string]string `json:"set"`
	Delete []string          `json:"delete"`
}

// adminSetConfig merges Set into config_values and removes Delete keys,
// then runs the env-config recreate dance preserving ports + cluster
// mount overrides.
func (h *httpServer) adminSetConfig(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req adminSetConfigReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if len(req.Set) == 0 && len(req.Delete) == 0 {
		writeError(w, http.StatusBadRequest, "no_changes", "set and delete are both empty")
		return
	}
	row, err := h.loadInstanceForAdmin(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	merged := map[string]string{}
	for k, v := range row.ConfigValues {
		merged[k] = v
	}
	for k, v := range req.Set {
		merged[k] = v
	}
	for _, k := range req.Delete {
		delete(merged, k)
	}
	ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
	defer cancel()
	if err := h.adminRecreateInstance(ctx, row, merged, h.mountOverridesFor(id)); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	h.log.Info("admin: set-config", "instance", id, "set_keys", mapKeys(req.Set), "delete_keys", req.Delete, "remote", r.RemoteAddr)
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":            true,
		"instance_id":   id,
		"config_values": merged,
	})
}

type adminRecreateReq struct {
	MountOverrides map[string]string `json:"mount_overrides"`
}

// adminRecreate re-runs the stop→delete-preserve→create→start dance with
// no config changes, optionally with custom MountOverrides. If the body
// is empty or omits mount_overrides, the cluster-derived overrides
// (mountOverridesFor) are used — same as every regular recreate path.
func (h *httpServer) adminRecreate(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req adminRecreateReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	row, err := h.loadInstanceForAdmin(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	mounts := req.MountOverrides
	if mounts == nil {
		mounts = h.mountOverridesFor(id)
	}
	ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
	defer cancel()
	if err := h.adminRecreateInstance(ctx, row, row.ConfigValues, mounts); err != nil {
		writeError(w, http.StatusBadGateway, "recreate", err.Error())
		return
	}
	h.log.Info("admin: recreate", "instance", id, "mount_overrides", mounts, "remote", r.RemoteAddr)
	writeJSON(w, http.StatusOK, map[string]any{
		"ok":              true,
		"instance_id":     id,
		"mount_overrides": mounts,
	})
}

// adminInstallBaseMods triggers the agent's base-mod installer for the
// given instance — Allocs Server Fixes + PrismaCore, downloaded fresh
// from authoritative sources (illy.bz + GitHub). TFP_* and xMarkers are
// shipped by the SteamCMD install and don't need re-download. Custom
// mods (RefugeBot, AGF-VP, etc.) are not touched. Useful when an
// operator wants to ensure the standard base-mod set is present on a
// freshly-installed instance that didn't get warm-seeded from a sibling.
//
// Implementation note: routes through the existing Update RPC machinery
// using the special provider id "_basemods" — the agent dispatcher
// short-circuits that to installBaseMods() instead of running the
// module's normal updater. Saves us a new proto round-trip.
func (h *httpServer) adminInstallBaseMods(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	row, err := h.loadInstanceForAdmin(r.Context(), id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	if row.ModuleID != "7dtd" {
		writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("base-mods installer is 7DTD-only; instance %q is module %q", id, row.ModuleID))
		return
	}
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		writeError(w, http.StatusBadGateway, "agent_offline", fmt.Sprintf("agent %q is not connected", row.AgentID))
		return
	}
	// Fire-and-forget: Update RPC produces an UpdateResult routed via
	// AgentEvent (not CommandResult via pending registry), so awaitAgentCall
	// would block forever. The agent's _basemods short-circuit runs async
	// and emits the same UPDATING / install-finished pulses as a real
	// update — operators see progress on the instance card or via
	// `journalctl -u panel-agent.service | grep install-base-mods`.
	corrID := newCorrelationID("update-basemods")
	env := &panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_Update{
			Update: &panelv1.UpdateRequest{InstanceId: id, ProviderId: "_basemods"},
		},
	}
	if err := conn.Send(env); err != nil {
		writeError(w, http.StatusBadGateway, "agent_call", err.Error())
		return
	}
	h.log.Info("admin: install-base-mods kicked", "instance", id, "remote", r.RemoteAddr, "corr_id", corrID)
	writeJSON(w, http.StatusAccepted, map[string]any{
		"ok":             true,
		"instance_id":    id,
		"correlation_id": corrID,
		"note":           "base-mods install kicked off async; watch agent log for 'install-base-mods' lines (~30-60s)",
	})
}

// ---- shared recreate helper ----

// adminRecreateInstance is the admin-side equivalent of
// recreateWithEnvConfig + changePorts: stop (if running), delete-preserve,
// upsert with the new config, recreate with the given mount overrides
// and original ports, restart if it was running before.
func (h *httpServer) adminRecreateInstance(ctx context.Context, row *db.InstanceRow, mergedCV map[string]string, mountOverrides map[string]string) error {
	conn := h.registry.get(row.AgentID)
	if conn == nil {
		return fmt.Errorf("agent %q is not connected", row.AgentID)
	}
	wasRunning := row.Status == "running"

	if wasRunning {
		if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
			InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
		}); err != nil {
			return fmt.Errorf("stop: %w", err)
		}
	}
	if err := h.awaitAgentCall(ctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
		InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
	}); err != nil {
		return fmt.Errorf("delete: %w", err)
	}
	if err := h.db.UpsertInstance(ctx, db.InstanceRow{
		InstanceID:    row.InstanceID,
		AgentID:       row.AgentID,
		ModuleID:      row.ModuleID,
		ModuleVersion: row.ModuleVersion,
		RunMode:       row.RunMode,
		Status:        "creating",
		ConfigValues:  mergedCV,
		AssignedPorts: row.AssignedPorts,
	}); err != nil {
		return fmt.Errorf("db upsert: %w", err)
	}
	var ports []*panelv1.PortMap
	for name, hp := range row.AssignedPorts {
		ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
	}
	// Copy so we can inject control sentinels without mutating mergedCV.
	// This recreate PRESERVES the install volume — tell the agent to skip the
	// create-time warm-seed + first-update, which would otherwise wipe /game
	// and re-seed from a sibling, silently dropping a 7DTD server's custom Mods.
	createCV := make(map[string]string, len(mergedCV)+2)
	for k, v := range mergedCV {
		createCV[k] = v
	}
	createCV["_PANEL_SKIP_REINSTALL"] = "true"
	if !wasRunning {
		createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
	}
	if err := h.awaitAgentCall(ctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
		InstanceCreate: &panelv1.InstanceCreate{
			InstanceId:     row.InstanceID,
			ModuleId:       row.ModuleID,
			Version:        row.ModuleVersion,
			RunMode:        panelv1.RunMode_RUN_MODE_DOCKER,
			Ports:          ports,
			ConfigValues:   createCV,
			MountOverrides: mountOverrides,
		},
	}); err != nil {
		return fmt.Errorf("create: %w", err)
	}
	if wasRunning {
		if err := h.awaitAgentCall(ctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
			InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
		}); err != nil {
			return fmt.Errorf("start: %w", err)
		}
	}
	return nil
}

func (h *httpServer) loadInstanceForAdmin(ctx context.Context, id string) (*db.InstanceRow, error) {
	if id == "" {
		return nil, fmt.Errorf("instance id required")
	}
	rows, err := h.db.ListInstances(ctx, "")
	if err != nil {
		return nil, err
	}
	for i := range rows {
		if rows[i].InstanceID == id {
			return &rows[i], nil
		}
	}
	return nil, fmt.Errorf("instance %q not found", id)
}

func mapKeys(m map[string]string) []string {
	out := make([]string, 0, len(m))
	for k := range m {
		out = append(out, k)
	}
	sort.Strings(out)
	return out
}
