package main

// Empyrion scenario downloader. Spawns a SteamCMD sidecar to download a
// Steam Workshop scenario (Reforged Eden 2 etc.) into a shared panel
// volume, then copies the unpacked tree into the target instance's
// `/game/Content/Scenarios/<scenario_name>/` via the agent's docker exec.
//
// Steam Workshop IDs we ship presets for (operators can supply any):
//   3041847672 — Reforged Eden 2  (folder name: "Reforged Eden 2")
//   2710373316 — Reforged Eden 1  (folder name: "Reforged Eden")
//
// App ID for Empyrion is 383120 (the *client* — workshop content for the
// dedicated server is published against the client app id, same as DayZ).

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"strings"
	"sync"
	"time"

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

	"github.com/dbledeez/panel/controller/internal/events"
	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

const empyrionAppID = "383120"

// empyrionScenarioPresets is what the panel UI surfaces in its dropdown;
// operators can also supply a workshop ID directly.
//
// Workshop IDs verified against Steam Workshop search 2026-04-30:
//   3143225812 — Reforged Eden 2 (Beta) — current active community scenario
//   2550354956 — Reforged Eden 1 — author marked OBSOLETE; kept for old saves
//
// Earlier presets used 3041847672 / 2710373316 which were wrong (one was
// likely a private/non-existent item — SteamCMD reports "Access Denied"
// indistinguishably for restricted items and missing items, which sent
// us chasing a nonexistent permissions issue).
var empyrionScenarioPresets = []map[string]string{
	{"id": "3143225812", "name": "Reforged Eden 2 (Beta)", "blurb": "Active community scenario; deep tech tree, balanced PvE."},
	{"id": "2550354956", "name": "Reforged Eden (legacy)", "blurb": "Author marked OBSOLETE; kept for older saves."},
}

// In-memory job store. Shared volume + log buffer + state.
type empyrionScenarioJob struct {
	JobID         string    `json:"jobId"`
	WorkshopID    string    `json:"workshopId"`
	ScenarioName  string    `json:"scenarioName"`
	InstanceID    string    `json:"instanceId"`
	State         string    `json:"state"` // queued / downloading / installing / done / error
	StartedAt     time.Time `json:"startedAt"`
	FinishedAt    time.Time `json:"finishedAt,omitempty"`
	Error         string    `json:"error,omitempty"`
	Log           string    `json:"log,omitempty"`
	BytesDownload int64     `json:"bytes,omitempty"`
}

var (
	empyrionJobsMu sync.Mutex
	empyrionJobs   = map[string]*empyrionScenarioJob{}
)

// handleEmpyrionScenarioPresets returns the curated workshop preset list.
// GET /api/empyrion/scenarios/presets
func (h *httpServer) handleEmpyrionScenarioPresets(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]any{"presets": empyrionScenarioPresets})
}

// handleEmpyrionScenarioInstall kicks off a SteamCMD download + install for
// an instance. Body: {workshop_id, scenario_name}. Returns a job ID; poll
// /api/instances/{id}/empyrion/scenario/jobs/{jobId} for status + log.
//
// If Steam credentials aren't configured, returns 200 with
// {"steam_login_required": true, "reason": "..."} instead of starting a
// job — UI catches that, pops the credential modal, retries the install.
// Mirrors the DayZ workshop flow.
//
// POST /api/instances/{id}/empyrion/scenario/install
func (h *httpServer) handleEmpyrionScenarioInstall(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	var req struct {
		WorkshopID   string `json:"workshop_id"`
		ScenarioName string `json:"scenario_name"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	req.WorkshopID = strings.TrimSpace(req.WorkshopID)
	req.ScenarioName = strings.TrimSpace(req.ScenarioName)
	if req.WorkshopID == "" || req.ScenarioName == "" {
		writeError(w, http.StatusBadRequest, "missing", "workshop_id and scenario_name are required")
		return
	}
	// Disallow path-traversal in scenario_name; SteamCMD writes into a path
	// derived from this string and the empyrion server reads it as a folder
	// under Content/Scenarios.
	if strings.ContainsAny(req.ScenarioName, "/\\.") {
		writeError(w, http.StatusBadRequest, "bad_name", "scenario_name must be a simple folder name (no slashes/dots)")
		return
	}

	// Pre-flight: workshop_download_item is no longer anonymously
	// accessible for most app workshops (Steam policy ~2023+). If no
	// Steam credentials are stored, return a sentinel that the UI maps
	// to its "Steam login required" modal — same pattern DayZ uses.
	if _, _, ok := h.getSteamLoginForUpdate(r.Context()); !ok {
		writeJSON(w, http.StatusOK, map[string]any{
			"steam_login_required": true,
			"reason":               "Empyrion workshop downloads need a Steam account that owns Empyrion. Steam disabled anonymous workshop downloads for most apps in 2023.",
		})
		return
	}

	job := &empyrionScenarioJob{
		JobID:        fmt.Sprintf("emp-scen-%d", time.Now().UnixNano()),
		WorkshopID:   req.WorkshopID,
		ScenarioName: req.ScenarioName,
		InstanceID:   id,
		State:        "queued",
		StartedAt:    time.Now(),
	}
	empyrionJobsMu.Lock()
	empyrionJobs[job.JobID] = job
	empyrionJobsMu.Unlock()

	// Run in background. The user polls for status/log.
	go h.runEmpyrionScenarioJob(context.Background(), job, agentID)

	writeJSON(w, http.StatusAccepted, job)
}

// handleEmpyrionScenariosInstalled lists every scenario folder present
// under the instance's Content/Scenarios/ directory. Used to populate
// the "Active scenario" dropdown so operators don't have to type folder
// names by hand. Listing happens via the agent's FsList RPC against
// the instance's running container's filesystem.
//
// GET /api/instances/{id}/empyrion/scenarios/installed
func (h *httpServer) handleEmpyrionScenariosInstalled(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	agentID, err := h.lookupInstance(r, id)
	if err != nil {
		writeError(w, http.StatusNotFound, "not_found", err.Error())
		return
	}
	conn := h.registry.get(agentID)
	if conn == nil {
		writeError(w, http.StatusServiceUnavailable, "agent_offline", "agent not connected")
		return
	}
	corrID := newCorrelationID("fslist-scen")
	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_FsList{
			FsList: &panelv1.FsListRequest{InstanceId: id, Path: "/game/Content/Scenarios"},
		},
	}); err != nil {
		writeError(w, http.StatusBadGateway, "send_failed", err.Error())
		return
	}
	env, err := h.pending.Await(r.Context(), corrID, 8*time.Second)
	if err != nil {
		writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
		return
	}
	res := env.GetFsListResult()
	scenarios := []map[string]any{}
	if res != nil && res.Error == "" {
		for _, e := range res.Entries {
			if !e.IsDir {
				continue
			}
			scenarios = append(scenarios, map[string]any{
				"name":            e.Name,
				"panel_installed": false, // best-effort; agents could check for the marker file later
			})
		}
	}
	writeJSON(w, http.StatusOK, map[string]any{"scenarios": scenarios})
}

// handleEmpyrionScenarioActivate sets `SCENARIO` in the instance's
// config_values to the chosen folder name. The empyrion entrypoint
// reads SCENARIO each boot and patches dedicated.yaml.GameConfig
// .CustomScenario from it. The change does NOT take effect until the
// server restarts — the panel UI surfaces a "restart needed" prompt.
//
// POST /api/instances/{id}/empyrion/scenario/activate {"scenario":"Reforged Eden 2 (Beta)"}
func (h *httpServer) handleEmpyrionScenarioActivate(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	var req struct {
		Scenario string `json:"scenario"`
	}
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	req.Scenario = strings.TrimSpace(req.Scenario)
	if strings.ContainsAny(req.Scenario, "/\\") {
		writeError(w, http.StatusBadRequest, "bad_name", "scenario must be a simple folder name")
		return
	}
	// Update only the SCENARIO key on the config_values JSONB column.
	// jsonb_set creates the key if missing; coalesce handles a NULL
	// config_values column. For an empty scenario name we delete the
	// key instead of writing "" — the entrypoint's sed pattern would
	// otherwise embed an empty SCENARIO line that disables the feature
	// less cleanly than just having SCENARIO unset.
	var query string
	var args []any
	if req.Scenario == "" {
		query = `UPDATE instances SET config_values = COALESCE(config_values, '{}'::jsonb) - 'SCENARIO', updated_at = NOW() WHERE instance_id = $1`
		args = []any{id}
	} else {
		query = `UPDATE instances SET config_values = jsonb_set(COALESCE(config_values, '{}'::jsonb), '{SCENARIO}', to_jsonb($2::text), true), updated_at = NOW() WHERE instance_id = $1`
		args = []any{id, req.Scenario}
	}
	tag, err := h.db.Pool().Exec(r.Context(), query, args...)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	if tag.RowsAffected() == 0 {
		writeError(w, http.StatusNotFound, "not_found", "instance not found")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"scenario": req.Scenario, "restart_required": true})
}

// handleEmpyrionScenarioJob returns current state + log of a job.
// GET /api/instances/{id}/empyrion/scenario/jobs/{jobId}
func (h *httpServer) handleEmpyrionScenarioJob(w http.ResponseWriter, r *http.Request) {
	jobID := r.PathValue("jobId")
	empyrionJobsMu.Lock()
	j := empyrionJobs[jobID]
	empyrionJobsMu.Unlock()
	if j == nil {
		writeError(w, http.StatusNotFound, "no_job", "job not found")
		return
	}
	writeJSON(w, http.StatusOK, j)
}

// runEmpyrionScenarioJob forwards the install request to the agent that
// owns the instance. The actual SteamCMD download + scenario copy run on
// the agent's local Docker daemon — only that daemon can mount the
// instance's per-agent game volume. Progress flows back as LogLine
// envelopes (stream="scenario") which we capture from the events bus
// into the job's log buffer for the panel UI's job-status poll.
func (h *httpServer) runEmpyrionScenarioJob(ctx context.Context, j *empyrionScenarioJob, agentID string) {
	defer func() {
		j.FinishedAt = time.Now()
		empyrionJobsMu.Lock()
		empyrionJobs[j.JobID] = j
		empyrionJobsMu.Unlock()
	}()

	j.State = "downloading"
	var logBuf bytes.Buffer
	appendLog := func(s string) {
		if !strings.HasSuffix(s, "\n") {
			s += "\n"
		}
		logBuf.WriteString(s)
		j.Log = logBuf.String()
	}
	appendLog(fmt.Sprintf("[%s] starting workshop install on agent %s: app=%s item=%s scenario=%q",
		time.Now().Format("15:04:05"), agentID, empyrionAppID, j.WorkshopID, j.ScenarioName))

	conn := h.registry.get(agentID)
	if conn == nil {
		j.State = "error"
		j.Error = fmt.Sprintf("agent %q not connected", agentID)
		return
	}

	// Subscribe to bus events for this instance's "scenario" log stream
	// before we send the request so we don't miss early lines.
	sub := h.bus.Subscribe(events.Filter{InstanceID: j.InstanceID})
	defer sub.Close()

	corrID := newCorrelationID("emp-scen")
	pendCh, cancelPending := h.pending.Register(corrID)
	defer cancelPending()

	// Pull Steam credentials from the encrypted store. Anonymous SteamCMD
	// can't download Empyrion workshop items as of Steam's 2023 policy
	// tightening — workshop_download_item returns "Access Denied" for
	// most apps without a logged-in account. If the operator hasn't set
	// up creds, surface a clear error instead of letting the agent burn
	// 30s and report Access Denied.
	steamUser, steamPass, hasCreds := h.getSteamLoginForUpdate(ctx)
	if !hasCreds {
		j.State = "error"
		j.Error = "Steam credentials not configured. Empyrion workshop downloads require a logged-in Steam account (anonymous gets Access Denied from Steam's API). Configure under Settings → Steam, then retry."
		appendLog("[error] " + j.Error)
		return
	}

	if err := conn.Send(&panelv1.ControllerEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.ControllerEnvelope_EmpyrionScenarioInstall{
			EmpyrionScenarioInstall: &panelv1.EmpyrionScenarioInstallRequest{
				JobId:         j.JobID,
				InstanceId:    j.InstanceID,
				WorkshopId:    j.WorkshopID,
				ScenarioName:  j.ScenarioName,
				AppId:         empyrionAppID,
				SteamUsername: steamUser,
				SteamPassword: steamPass,
			},
		},
	}); err != nil {
		j.State = "error"
		j.Error = "send to agent: " + err.Error()
		return
	}
	appendLog("[" + time.Now().Format("15:04:05") + "] request forwarded to agent — streaming progress…")

	jobCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
	defer cancel()
	for {
		select {
		case env := <-pendCh:
			res := env.GetEmpyrionScenarioInstallResult()
			if res == nil {
				j.State = "error"
				j.Error = "unexpected envelope from agent"
				return
			}
			if res.Ok {
				appendLog(fmt.Sprintf("[%s] agent confirms scenario %q installed", time.Now().Format("15:04:05"), res.ScenarioName))
				j.State = "done"
			} else {
				appendLog(fmt.Sprintf("[%s] agent reports error: %s", time.Now().Format("15:04:05"), res.Error))
				j.State = "error"
				j.Error = res.Error
			}
			return
		case ev := <-sub.Events():
			if ev == nil || ev.GetLog() == nil {
				continue
			}
			if ev.GetLog().Stream != "scenario" {
				continue
			}
			appendLog(ev.GetLog().Line)
		case <-jobCtx.Done():
			j.State = "error"
			j.Error = "scenario install timed out (30m)"
			return
		}
	}
}
