package dispatch

// Agent-side Empyrion workshop-scenario installer. Runs entirely on the
// local Docker daemon, so a multi-agent panel can install Reforged Eden 2
// (and any other workshop scenario) into an Empyrion instance regardless
// of which agent owns it.
//
// Flow:
//   1. SteamCMD sidecar mounts panel-empyrion-workshop (an agent-local
//      cache volume, lazily created on first install) and downloads the
//      workshop item.
//   2. Alpine helper sidecar mounts the workshop volume + the instance's
//      `panel-<INSTANCE>-game` volume and copies the scenario files into
//      Content/Scenarios/<scenario_name>/.
//   3. A marker file (.panel-installed) is touched so the empyrion
//      entrypoint can confirm the install.
//
// Progress is streamed back to the controller as LogLine envelopes with
// stream="scenario" so the panel UI's scenario job log shows what's
// happening live.

import (
	"context"
	"fmt"
	"os/exec"
	"strings"
	"time"

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

	panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)

func (d *Dispatcher) handleEmpyrionScenarioInstall(corrID string, req *panelv1.EmpyrionScenarioInstallRequest) {
	if req == nil || req.JobId == "" || req.InstanceId == "" || req.WorkshopId == "" || req.ScenarioName == "" {
		d.sendEmpyrionScenarioResult(corrID, req.GetJobId(), false, "missing required fields", req.GetScenarioName())
		return
	}
	appID := req.AppId
	if appID == "" {
		appID = "530870"
	}
	go d.runEmpyrionScenarioJob(corrID, req, appID)
}

func (d *Dispatcher) runEmpyrionScenarioJob(corrID string, req *panelv1.EmpyrionScenarioInstallRequest, appID string) {
	// Redact the password from any line we forward upstream, since
	// SteamCMD CAN echo `+login user PASS` back in error paths.
	redact := func(s string) string {
		if req.SteamPassword == "" || len(req.SteamPassword) < 4 {
			return s
		}
		return strings.ReplaceAll(s, req.SteamPassword, "[REDACTED]")
	}
	emit := func(line string) {
		d.sendEnv(&panelv1.AgentEnvelope{
			SentAt: timestamppb.Now(),
			Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
				InstanceId: req.InstanceId,
				Stream:     "scenario",
				At:         timestamppb.Now(),
				Line:       redact(line),
			}},
		})
	}
	finish := func(ok bool, msg string) {
		d.sendEmpyrionScenarioResult(corrID, req.JobId, ok, msg, req.ScenarioName)
	}

	emit(fmt.Sprintf("[scenario] starting workshop download: app=%s item=%s scenario=%q", appID, req.WorkshopId, req.ScenarioName))

	dockerBin, err := exec.LookPath("docker")
	if err != nil {
		finish(false, "docker binary not found on agent host")
		return
	}

	// Phase 1 — workshop download. The volume name is per-agent (Docker
	// daemons don't share volumes); it's created lazily by the first run.
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
	defer cancel()

	// Build the SteamCMD argv for an attempt — login portion is the only
	// variable. `+app_license_request` refreshes Steam's entitlement
	// cache before the workshop download (cheap fix for stale-license
	// "Access Denied" on owned games).
	buildArgs := func(loginArgs []string) []string {
		base := []string{
			"run", "--rm",
			"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
			"-v", "panel-empyrion-workshop:/empyrion-workshop/steamapps/workshop",
			"steamcmd/steamcmd:latest",
			"+@sSteamCmdForcePlatformType", "windows",
			"+force_install_dir", "/empyrion-workshop",
		}
		base = append(base, loginArgs...)
		base = append(base,
			"+app_license_request", appID,
			"+workshop_download_item", appID, req.WorkshopId, "validate",
			"+quit",
		)
		return base
	}

	var out string
	switch {
	case req.SteamUsername == "":
		emit("[scenario] no Steam credentials provided — trying anonymous (will fail for app workshops that require ownership)")
		out, err = runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", "anonymous"}), emit)
	default:
		// Try cached refresh-token login first (no password). If the
		// agent's panel-steamcmd-auth volume has a valid token from a
		// prior install, this skips Steam Guard entirely — no push
		// notification fires. Only when the cache is missing or expired
		// (Logon failure / no cached credentials in the SteamCMD output)
		// do we fall back to full password auth, which DOES require
		// approving a Steam Guard push but then re-primes the cache for
		// future runs.
		emit(fmt.Sprintf("[scenario] login attempt 1: %q via cached refresh token (no Steam Guard push if cache valid)", req.SteamUsername))
		cachedOut, cachedErr := runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", req.SteamUsername}), emit)
		needsPwd := cachedErr != nil ||
			strings.Contains(cachedOut, "Logon failure") ||
			strings.Contains(cachedOut, "No cached credentials") ||
			strings.Contains(cachedOut, "FAILED login") ||
			strings.Contains(cachedOut, "Login failure")
		if !needsPwd {
			out, err = cachedOut, cachedErr
			break
		}
		if req.SteamPassword == "" {
			finish(false, "Steam refresh-token cache is missing/expired on this agent and no password is available to re-prime it. Re-enter credentials via the Steam login modal.")
			return
		}
		emit(fmt.Sprintf("[scenario] login attempt 2: %q with password (Steam Guard push will fire — approve on phone)", req.SteamUsername))
		out, err = runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", req.SteamUsername, req.SteamPassword}), emit)
	}
	out = redact(out)
	if err != nil {
		finish(false, "steamcmd: "+err.Error())
		return
	}
	// Steam reports "Access Denied" when the logged-in account can't
	// pull this workshop item. Common causes (in order of likelihood):
	//   1. Account doesn't own the app — most common
	//   2. License cache stale — we already issued +app_license_request
	//      above, so if it still errors that's not it
	//   3. Workshop item is private / friends-only / restricted by author
	//   4. Workshop item requires the operator to "subscribe" to it via
	//      the desktop Steam client at least once before SteamCMD can
	//      pull it (some apps gate this)
	//   5. Family Library Sharing — the account borrows Empyrion via
	//      sharing instead of owning a license; SteamCMD requires real
	//      ownership, not borrowed licenses
	if strings.Contains(out, "ERROR! Download item") && strings.Contains(out, "Access Denied") {
		// Account confirmed-owner of the app but still denied — this is
		// almost always a publisher-side restriction on workshop_download_item
		// (Empyrion / Eleon is the canonical example: the desktop Steam
		// client uses a different API path that's allowed, but SteamCMD's
		// path is denied for this app's workshop). NOT a panel bug, NOT an
		// account bug. Manual upload is the only reliable path.
		ownsApp := strings.Contains(out, "already owned")
		var msg string
		if ownsApp {
			msg = fmt.Sprintf(
				"Workshop item %s denied even though account %q owns app %s (Steam confirmed: \"AppID %s already owned\"). "+
					"This is a publisher-side restriction on SteamCMD's workshop API — common for Empyrion (Eleon disables CLI workshop downloads; the desktop Steam client uses a different allowed path). "+
					"Workaround: subscribe to the item in the desktop Steam client (Workshop → Subscribe), let it download to %%STEAM%%/steamapps/workshop/content/%s/%s/, then upload that folder into the instance's panel-empyrion-game volume at Content/Scenarios/<scenario_name>/. The panel can't bypass this — Steam policy enforced server-side.",
				req.WorkshopId, req.SteamUsername, appID, appID, appID, req.WorkshopId,
			)
		} else {
			msg = fmt.Sprintf(
				"Steam returned Access Denied for workshop item %s after logging in as %q. "+
					"Likely causes: (a) account doesn't actually own app %s (Family Library Sharing borrows don't count — needs a purchase license); "+
					"(b) workshop item is private/friends-only/restricted by author; "+
					"(c) item requires desktop-Steam-client subscription first. "+
					"Workaround: download via desktop Steam client and drop Content/Scenarios/<folder> into the instance volume manually.",
				req.WorkshopId, req.SteamUsername, appID,
			)
		}
		finish(false, msg)
		return
	}
	if !strings.Contains(out, "Success. Downloaded item") {
		// Don't fail outright on a missing success marker — some
		// SteamCMD versions return without that exact string when the
		// item is already cached. Sanity-check the file actually
		// exists in the volume before deciding.
		probe := []string{"run", "--rm", "-v", "panel-empyrion-workshop:/ws", "alpine:3.20",
			"sh", "-c", fmt.Sprintf("test -d /ws/content/%s/%s && echo cached", appID, req.WorkshopId)}
		probeOut, _ := exec.CommandContext(ctx, dockerBin, probe...).CombinedOutput()
		if !strings.Contains(string(probeOut), "cached") {
			finish(false, "steamcmd did not download item — see log for details")
			return
		}
		emit("[scenario] workshop item already cached on this agent — skipping re-download")
	}

	// Phase 2 — copy into instance volume.
	emit("[scenario] copying scenario files into instance volume…")
	gameVol := "panel-" + req.InstanceId + "-game"
	dst := "/dst/Content/Scenarios/" + req.ScenarioName

	// Verify the instance volume exists; if not, the instance was
	// never created on this agent.
	probe := []string{"volume", "inspect", gameVol}
	if err := exec.CommandContext(ctx, dockerBin, probe...).Run(); err != nil {
		finish(false, fmt.Sprintf("instance volume %q not found on this agent — is the empyrion instance created here?", gameVol))
		return
	}

	helperArgs := []string{
		"run", "--rm",
		"-v", "panel-empyrion-workshop:/src",
		"-v", gameVol + ":/dst",
		"alpine:3.20", "sh", "-c",
		fmt.Sprintf(
			"mkdir -p %s && rm -rf %s/* %s/.* 2>/dev/null; "+
				"cp -a /src/content/%s/%s/. %s/ && date > %s/.panel-installed && echo OK",
			shQuote(dst), shQuote(dst), shQuote(dst),
			appID, req.WorkshopId, shQuote(dst), shQuote(dst),
		),
	}
	out, err = runWithLineEmit(ctx, dockerBin, helperArgs, emit)
	if err != nil {
		finish(false, "scenario copy failed: "+err.Error())
		return
	}
	if !strings.Contains(out, "OK") {
		finish(false, "scenario copy did not confirm OK — see log for details")
		return
	}

	emit(fmt.Sprintf("[scenario] %q installed into %s", req.ScenarioName, req.InstanceId))
	finish(true, "")
}

// shQuote single-quotes a path for safe inclusion in a `sh -c` script.
// Embedded single quotes are escaped via the standard '\'' trick. Used
// for scenario_name which is operator input — even though the controller
// already rejects path-traversal characters, defense-in-depth at the
// agent layer is cheap.
func shQuote(s string) string {
	return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

// runWithLineEmit spawns docker, captures combined stdout/stderr,
// emits each line as it arrives, and returns the full combined output
// for post-mortem checks (success markers etc.) when the process exits.
func runWithLineEmit(ctx context.Context, bin string, args []string, emit func(string)) (string, error) {
	cmd := exec.CommandContext(ctx, bin, args...)
	pipe, err := cmd.StdoutPipe()
	if err != nil {
		return "", err
	}
	cmd.Stderr = cmd.Stdout
	if err := cmd.Start(); err != nil {
		return "", err
	}
	var buf strings.Builder
	scanCh := make(chan struct{})
	go func() {
		defer close(scanCh)
		readBuf := make([]byte, 4096)
		var line strings.Builder
		for {
			n, err := pipe.Read(readBuf)
			if n > 0 {
				chunk := readBuf[:n]
				buf.Write(chunk)
				for _, b := range chunk {
					if b == '\n' {
						emit(strings.TrimRight(line.String(), "\r"))
						line.Reset()
					} else {
						line.WriteByte(b)
					}
				}
			}
			if err != nil {
				if line.Len() > 0 {
					emit(strings.TrimRight(line.String(), "\r"))
				}
				return
			}
		}
	}()
	waitErr := cmd.Wait()
	<-scanCh
	if waitErr != nil {
		return buf.String(), waitErr
	}
	return buf.String(), nil
}

func (d *Dispatcher) sendEmpyrionScenarioResult(corrID, jobID string, ok bool, errMsg, scenarioName string) {
	d.sendEnv(&panelv1.AgentEnvelope{
		CorrelationId: corrID,
		SentAt:        timestamppb.Now(),
		Payload: &panelv1.AgentEnvelope_EmpyrionScenarioInstallResult{
			EmpyrionScenarioInstallResult: &panelv1.EmpyrionScenarioInstallResult{
				JobId:        jobID,
				Ok:           ok,
				Error:        errMsg,
				ScenarioName: scenarioName,
			},
		},
	})
}
