package main

// Steam credential storage + test-login flow.
// Handlers are hung off httpServer (the REST surface) via h.handleSteam*
// after registration in httpapi.go's handler() routing table.
//
// Some SteamCMD apps (DayZ app 223350, Arma 3, a handful of paid servers)
// refuse `+login anonymous` with "No subscription". The panel needs to
// supply real Steam credentials from an account that owns the target app.
// We do this with a one-time login ceremony:
//
//  1. Operator triggers Create/Update on a module whose provider declares
//     `requires_steam_login: true`.
//  2. Controller checks for a cached credential. None → returns 200 with
//     {"steam_login_required": true, "reason": "..."} — frontend pops a modal.
//  3. Operator enters Steam username + password (+ optional Steam Guard code).
//  4. POST /api/steam/login runs a one-shot SteamCMD sidecar with those
//     creds. Outcomes:
//       - success → persist password ciphertext + sentry/SSFN blob, return 200
//       - Steam Guard required → return {"need_guard": true}; modal re-prompts
//       - invalid password → 401
//  5. Controller re-enqueues the original update request with the now-known
//     credentials.
//
// Secrets handling:
//   - Password is encrypted at rest with AES-GCM, key HKDF-derived from the
//     CA private key. Never logged, never sent back to the UI.
//   - Sentry file (SSFN) is stored separately and mounted into every
//     SteamCMD sidecar so 2FA isn't re-prompted every run.
//   - Database column is BYTEA; transport is over HTTPS (dashboard) → the
//     password crosses the wire once per login ceremony.

import (
	"bytes"
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/rsa"
	"crypto/sha256"
	"crypto/x509"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"os"
	"os/exec"
	"regexp"
	"strings"
	"time"

	"golang.org/x/crypto/hkdf"
)

// steamKEK derives a 32-byte key from the controller's CA private key.
// The CA key is unique per panel install, never leaves disk, and is
// already backed up/restored as a unit with the rest of data/ca/ — so
// tying our encryption key to it means "restore data/ca + postgres dump
// → everything still decrypts" without a separate key file to manage.
//
// We pass the PKCS1-marshalled private key bytes into HKDF. The marshal
// output is deterministic for a given key so the KEK is stable across
// restarts.
func steamKEK(privKey *rsa.PrivateKey) ([]byte, error) {
	if privKey == nil {
		return nil, errors.New("ca key is nil")
	}
	seed := x509.MarshalPKCS1PrivateKey(privKey)
	h := hkdf.New(sha256.New, seed, []byte("panel.steam.credentials.v1"), []byte("aes-256-gcm"))
	out := make([]byte, 32)
	if _, err := io.ReadFull(h, out); err != nil {
		return nil, fmt.Errorf("hkdf: %w", err)
	}
	return out, nil
}

// encryptSteamPassword returns (ciphertext, nonce). AES-GCM provides both
// confidentiality and integrity.
func encryptSteamPassword(kek []byte, password string) ([]byte, []byte, error) {
	block, err := aes.NewCipher(kek)
	if err != nil {
		return nil, nil, err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return nil, nil, err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return nil, nil, err
	}
	ct := gcm.Seal(nil, nonce, []byte(password), nil)
	return ct, nonce, nil
}

// decryptSteamPassword is the inverse of encryptSteamPassword.
func decryptSteamPassword(kek, ciphertext, nonce []byte) (string, error) {
	block, err := aes.NewCipher(kek)
	if err != nil {
		return "", err
	}
	gcm, err := cipher.NewGCM(block)
	if err != nil {
		return "", err
	}
	pt, err := gcm.Open(nil, nonce, ciphertext, nil)
	if err != nil {
		return "", err
	}
	return string(pt), nil
}

// ---- HTTP handlers ----

type steamLoginRequest struct {
	Username   string `json:"username"`
	Password   string `json:"password,omitempty"`    // required on first login, absent on guard-retry
	GuardCode  string `json:"guard_code,omitempty"`  // Steam Guard code — empty unless server asked for it
}

type steamLoginResponse struct {
	OK            bool   `json:"ok"`
	Username      string `json:"username,omitempty"`
	NeedGuard     bool   `json:"need_guard,omitempty"`
	Error         string `json:"error,omitempty"`
}

func (h *httpServer) handleSteamLogin(w http.ResponseWriter, r *http.Request) {
	var req steamLoginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_request", err.Error())
		return
	}
	req.Username = strings.TrimSpace(req.Username)
	if req.Username == "" {
		writeError(w, http.StatusBadRequest, "bad_request", "username required")
		return
	}

	// If no password was supplied AND we don't have one cached, reject.
	// (UI sends password on the initial call but not on guard-retries;
	// on guard-retry we reuse the cached-plaintext path via the new creds
	// we just stored a moment ago.)
	var password string
	if req.Password != "" {
		password = req.Password
	} else {
		kek, err := steamKEK(h.ca.RootKey)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "internal", "kek derive: "+err.Error())
			return
		}
		existing, err := h.db.GetSteamCredentialByUsername(r.Context(), req.Username)
		if err != nil {
			writeError(w, http.StatusUnauthorized, "no_cached_password", "provide a password for first login")
			return
		}
		pw, err := decryptSteamPassword(kek, existing.PasswordCiphertext, existing.PasswordNonce)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "decrypt", err.Error())
			return
		}
		password = pw
	}

	// Run SteamCMD test-login. Mounts the panel-wide SteamCMD auth volume
	// so a successful login's sentry/SSFN blob persists for subsequent
	// update sidecars.
	result, err := runSteamCMDTestLogin(r.Context(), req.Username, password, req.GuardCode)
	if err != nil {
		h.log.Error("steam test-login infra error", "username", req.Username, "err", err)
		writeError(w, http.StatusInternalServerError, "internal", err.Error())
		return
	}
	h.log.Info("steam test-login",
		"username", req.Username,
		"outcome", result.outcome,
		"guard_supplied", req.GuardCode != "",
		"message_head", firstLine(result.message),
	)
	// On unclassified outcomes, dump the tail of SteamCMD's output so we
	// can grow the classifier. Password is never in this message (we
	// redact via redactSteamOutput before it reaches here, and SteamCMD
	// doesn't echo +login args back on stdout).
	if result.outcome == steamLoginUnknown {
		h.log.Warn("steam test-login: unclassified output (please copy to a dev issue)",
			"tail", result.message,
		)
	}

	switch result.outcome {
	case steamLoginSuccess:
		kek, err := steamKEK(h.ca.RootKey)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "internal", "kek derive: "+err.Error())
			return
		}
		ct, nonce, err := encryptSteamPassword(kek, password)
		if err != nil {
			writeError(w, http.StatusInternalServerError, "internal", "encrypt: "+err.Error())
			return
		}
		if err := h.db.UpsertSteamCredential(r.Context(), req.Username, ct, nonce); err != nil {
			writeError(w, http.StatusInternalServerError, "internal", "persist: "+err.Error())
			return
		}
		if err := h.db.TouchSteamCredential(r.Context(), req.Username); err != nil {
			h.log.Warn("touch steam credential", "err", err)
		}
		writeJSON(w, http.StatusOK, steamLoginResponse{OK: true, Username: req.Username})
	case steamLoginGuardRequired:
		writeJSON(w, http.StatusOK, steamLoginResponse{OK: false, NeedGuard: true, Username: req.Username})
	case steamLoginInvalidPassword:
		writeJSON(w, http.StatusUnauthorized, steamLoginResponse{OK: false, Error: "invalid Steam password"})
	case steamLoginRateLimited:
		writeJSON(w, http.StatusTooManyRequests, steamLoginResponse{OK: false, Error: "Steam rate-limited the login. Wait a few minutes."})
	default:
		writeJSON(w, http.StatusInternalServerError, steamLoginResponse{OK: false, Error: result.message})
	}
}

type steamLoginOutcome int

const (
	steamLoginUnknown steamLoginOutcome = iota
	steamLoginSuccess
	steamLoginGuardRequired
	steamLoginInvalidPassword
	steamLoginRateLimited
)

type steamLoginResult struct {
	outcome steamLoginOutcome
	message string
}

// runSteamCMDTestLogin runs `docker run --rm steamcmd/steamcmd +set_steam_guard_code <g> +login <u> <p> +quit`
// and classifies the result. The panel-wide auth volume is mounted so the
// sentry blob persists across sidecars.
func runSteamCMDTestLogin(ctx context.Context, username, password, guardCode string) (steamLoginResult, error) {
	const authVolume = "panel-steamcmd-auth"
	const image = "steamcmd/steamcmd:latest"

	// The steamcmd:latest image runs as root with HOME=/root; its data dir
	// is /root/.local/share/Steam (config.vdf login cache + ssfn sentry +
	// workshop cache all live under here). Mounting the auth volume at
	// /root/Steam (as earlier versions did) catches nothing — fresh 2FA
	// prompt on EVERY login. Fixed: mount at the real data dir.
	args := []string{
		"run", "--rm",
		"-v", authVolume + ":/root/.local/share/Steam",
		image,
	}
	if guardCode != "" {
		args = append(args, "+set_steam_guard_code", guardCode)
	}
	args = append(args, "+login", username, password, "+quit")

	// Allow 90 s for login (Steam Guard email delivery + user typing takes
	// real time — but here we're handed the code already). Rate-limiting
	// is the slow case.
	cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
	defer cancel()

	// Locate docker. Try the standard Windows install path first (that's
	// where Claude Code sessions + the panel's dev loop always run), then
	// fall back to $PATH + $DOCKER_BIN. Using os.Stat with a native-style
	// path — Unix-style `/c/Program Files/...` fails on Windows.
	dockerCandidates := []string{
		os.Getenv("DOCKER_BIN"),
		`C:\Program Files\Docker\Docker\resources\bin\docker.exe`,
		"/usr/bin/docker",
		"/usr/local/bin/docker",
	}
	dockerBin := "docker"
	for _, c := range dockerCandidates {
		if c == "" {
			continue
		}
		if fi, err := os.Stat(c); err == nil && !fi.IsDir() {
			dockerBin = c
			break
		}
	}

	cmd := exec.CommandContext(cctx, dockerBin, args...)
	var out bytes.Buffer
	cmd.Stdout = &out
	cmd.Stderr = &out
	runErr := cmd.Run() // classify via stdout regardless of exit code
	rawOut := out.String()
	// SteamCMD's stdout is laced with ANSI color escapes. They routinely
	// split the literal strings we classify on ("Waiting for user info..."
	// then ESC[0m then "OK"), so normalize before matching. We also case-
	// fold to tolerate minor casing drift between SteamCMD versions.
	outStr := strings.ToLower(ansiEscapeRE.ReplaceAllString(rawOut, ""))
	if runErr != nil && outStr == "" {
		// exec itself failed (docker not found, bad args, etc.). Return a
		// real error so the caller can show "Failed to run SteamCMD: ...".
		return steamLoginResult{outcome: steamLoginUnknown, message: runErr.Error()}, nil
	}

	// Classify by the tell-tale SteamCMD phrases. Order matters — guard-
	// required and invalid-password need to win over a bare "OK" earlier
	// in the stream that's from the Steam-API probe, not the real login.
	hasAny := func(needles ...string) bool {
		for _, n := range needles {
			if strings.Contains(outStr, n) {
				return true
			}
		}
		return false
	}

	switch {
	// Hard-stop failure markers win first.
	case hasAny("invalid password", "invalidpassword", "failed (invalid password)",
		"password is incorrect", "failed to login"):
		return steamLoginResult{outcome: steamLoginInvalidPassword, message: redactSteamOutput(rawOut)}, nil
	case hasAny("ratelimitexceeded", "rate limit", "too many login failures"):
		return steamLoginResult{outcome: steamLoginRateLimited, message: redactSteamOutput(rawOut)}, nil
	case hasAny("two-factor code", "steam guard code",
		"accountlogondenied", "accountlogondeniedneedtwofactorcode",
		"requires a steam guard", "please check your email"):
		return steamLoginResult{outcome: steamLoginGuardRequired, message: redactSteamOutput(rawOut)}, nil

	// Success markers — any of these in the stream means we got past auth.
	// The most reliable is "logging in user '<name>' ... to steam public" + "ok"
	// later in the stream. We also accept the older "logged in ok" phrasing.
	case hasAny("logged in ok",
		"logging in user",           // immediately followed by success in practice
		"waiting for user info",     // only reached after a clean login
		"waiting for client config"): // same — follows successful auth
		return steamLoginResult{outcome: steamLoginSuccess}, nil
	}

	// Unknown outcome — dump a redacted snippet.
	return steamLoginResult{outcome: steamLoginUnknown, message: redactSteamOutput(rawOut)}, nil
}

// ansiEscapeRE matches the common ANSI CSI color/style escapes SteamCMD
// emits (e.g. \x1b[0m, \x1b[1m, \x1b[33;1m). We strip these before
// classifying stdout so color codes don't fragment our search strings.
var ansiEscapeRE = regexp.MustCompile(`\x1b\[[0-9;]*[A-Za-z]`)

// firstLine returns the first non-empty line of s, for log-friendly
// one-liners derived from multi-line SteamCMD output.
func firstLine(s string) string {
	for _, ln := range strings.Split(s, "\n") {
		ln = strings.TrimSpace(ln)
		if ln != "" {
			if len(ln) > 120 {
				ln = ln[:120] + "…"
			}
			return ln
		}
	}
	return ""
}

// redactSteamOutput strips anything that looks like a credential before
// we return it as an error message.
func redactSteamOutput(s string) string {
	// Trim to the last ~20 lines and mask obvious credentials.
	lines := strings.Split(s, "\n")
	if len(lines) > 25 {
		lines = lines[len(lines)-25:]
	}
	for i, ln := range lines {
		if strings.Contains(ln, "+login") {
			lines[i] = "[+login redacted]"
		}
	}
	return strings.Join(lines, "\n")
}

// ---- helpers used from handlers ----

func (h *httpServer) handleSteamList(w http.ResponseWriter, r *http.Request) {
	names, err := h.db.ListSteamCredentialUsernames(r.Context())
	if err != nil {
		writeError(w, http.StatusInternalServerError, "internal", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"usernames": names})
}

func (h *httpServer) handleSteamDelete(w http.ResponseWriter, r *http.Request) {
	username := strings.TrimSpace(r.URL.Query().Get("username"))
	if username == "" {
		writeError(w, http.StatusBadRequest, "bad_request", "username query param required")
		return
	}
	if err := h.db.DeleteSteamCredential(r.Context(), username); err != nil {
		writeError(w, http.StatusInternalServerError, "internal", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}

// providerNeedsSteamLogin inspects the module-summary advertisement the
// agent sent at handshake and returns (true, providerID) if the update
// provider the caller picked (or the module's first provider) declares
// requires_steam_login. Returns (false, "") when credentials aren't
// needed — the update path then proceeds with the normal +login anonymous.
func (h *httpServer) providerNeedsSteamLogin(ctx context.Context, agentID, instanceID, providerID string) (bool, string) {
	rows, err := h.db.ListInstances(ctx, "")
	if err != nil {
		return false, ""
	}
	var moduleID string
	for _, r := range rows {
		if r.InstanceID == instanceID {
			moduleID = r.ModuleID
			break
		}
	}
	if moduleID == "" {
		return false, ""
	}
	mod := h.registry.moduleByID(agentID, moduleID)
	if mod == nil {
		return false, ""
	}
	for _, p := range mod.UpdateProviders {
		if providerID == "" || p.Id == providerID {
			return p.RequiresSteamLogin, p.Id
		}
	}
	return false, ""
}

// getSteamLoginForUpdate returns the cached Steam username + plaintext
// password to use for the next SteamCMD sidecar. The agent receives these
// over the normal gRPC control envelope (mTLS-encrypted channel), used as
// `+login <user> <pass>` args, and they never appear in the Console log
// stream (the dispatch-side log pump redacts them; see steamcmd.go).
// Returns (_, _, false) if no credential is cached.
func (h *httpServer) getSteamLoginForUpdate(ctx context.Context) (username, password string, ok bool) {
	row, err := h.db.GetAnySteamCredential(ctx)
	if err != nil {
		return "", "", false
	}
	kek, err := steamKEK(h.ca.RootKey)
	if err != nil {
		return "", "", false
	}
	pw, err := decryptSteamPassword(kek, row.PasswordCiphertext, row.PasswordNonce)
	if err != nil {
		return "", "", false
	}
	return row.Username, pw, true
}

