package main

// Steam OpenID 2.0 sign-in. Separate from the Steam credential store
// we use for SteamCMD workshop downloads (see steamauth.go) — this
// feature is about panel login, not game-install auth.
//
// Flow:
//
//   1. User clicks "Sign in with Steam" on /login.
//   2. Frontend navigates to GET /api/auth/steam/start.
//   3. Handler builds the OpenID redirect URL and 302s the browser to
//      https://steamcommunity.com/openid/login with:
//          openid.ns         = http://specs.openid.net/auth/2.0
//          openid.mode       = checkid_setup
//          openid.return_to  = <public_url>/api/auth/steam/callback
//          openid.realm      = <public_url>
//          openid.identity   = http://specs.openid.net/auth/2.0/identifier_select
//          openid.claimed_id = http://specs.openid.net/auth/2.0/identifier_select
//   4. User logs in with Steam, Steam 302s back to our callback with
//      several openid.* query params (including openid.claimed_id
//      containing the 17-digit SteamID64).
//   5. Handler re-POSTs every received openid.* param back to Steam
//      with openid.mode=check_authentication. Steam replies with
//      "is_valid:true" if the signature is genuine.
//   6. On success, extract the SteamID from claimed_id, look up a user
//      with that steam_id, create a session cookie.
//
// Steam's OpenID dialect has no secrets — the verify-with-Steam round
// trip is what authenticates the claim. We only accept claimed_id
// URLs under https://steamcommunity.com/openid/id/<17 digits>.

import (
	"context"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"regexp"
	"strings"
	"time"

	"github.com/dbledeez/panel/controller/internal/db"
)

const (
	steamOpenIDEndpoint   = "https://steamcommunity.com/openid/login"
	steamClaimedIDPrefix  = "https://steamcommunity.com/openid/id/"
	steamLoginCookieTTL   = 30 * 24 * time.Hour // same as password-based sessions
)

// steamIDRE matches the 17-digit SteamID64 at the end of the claimed_id
// URL. Steam's claimed_id is always https://steamcommunity.com/openid/id/<N>.
var steamIDRE = regexp.MustCompile(`^https://steamcommunity\.com/openid/id/(\d{17})$`)

// ---- /api/auth/steam/start ----

// handleSteamLoginStart builds the OpenID redirect and 302s the caller.
// The `next` query param (where to send the user after a successful
// login) is embedded in return_to so the callback can honour it.
func (a *auth) handleSteamLoginStart(w http.ResponseWriter, r *http.Request) {
	realm := a.publicURL(r)
	next := strings.TrimSpace(r.URL.Query().Get("next"))
	if next == "" || !strings.HasPrefix(next, "/") {
		next = "/"
	}

	returnTo, _ := url.Parse(realm + "/api/auth/steam/callback")
	q := returnTo.Query()
	q.Set("next", next)
	returnTo.RawQuery = q.Encode()

	params := url.Values{}
	params.Set("openid.ns", "http://specs.openid.net/auth/2.0")
	params.Set("openid.mode", "checkid_setup")
	params.Set("openid.return_to", returnTo.String())
	params.Set("openid.realm", realm)
	params.Set("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select")
	params.Set("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select")

	http.Redirect(w, r, steamOpenIDEndpoint+"?"+params.Encode(), http.StatusFound)
}

// ---- /api/auth/steam/callback ----

// handleSteamLoginCallback verifies the OpenID assertion with Steam,
// extracts the SteamID, and either logs the matched user in (by setting
// a session cookie) or bounces them back to /login with an error.
func (a *auth) handleSteamLoginCallback(w http.ResponseWriter, r *http.Request) {
	// The incoming request has lots of openid.* query params. We verify
	// by re-POSTing them to Steam's OpenID endpoint with mode flipped.
	claimedID := r.URL.Query().Get("openid.claimed_id")
	if claimedID == "" {
		redirectToLoginWithErr(w, r, "Steam login didn't return a claimed ID.")
		return
	}
	m := steamIDRE.FindStringSubmatch(claimedID)
	if len(m) != 2 {
		redirectToLoginWithErr(w, r, "Steam login returned an unexpected claimed ID format.")
		return
	}
	steamID := m[1]

	ok, err := verifySteamOpenIDAssertion(r.Context(), r.URL.Query())
	if err != nil {
		a.log.Warn("steam openid verify failed", "err", err, "steam_id", steamID)
		redirectToLoginWithErr(w, r, "Couldn't verify your Steam login with Steam's servers. Try again.")
		return
	}
	if !ok {
		a.log.Warn("steam openid verify returned is_valid=false", "steam_id", steamID)
		redirectToLoginWithErr(w, r, "Steam reported the login assertion as invalid. Try again.")
		return
	}

	user, err := a.db.GetUserBySteamID(r.Context(), steamID)
	if err != nil {
		if errors.Is(err, db.ErrUserNotFound) {
			a.log.Info("steam login refused — no user linked", "steam_id", steamID)
			redirectToLoginWithErr(w, r, "That Steam account isn't linked to any panel user. Ask an admin to link it.")
			return
		}
		a.log.Error("steam login db lookup", "err", err, "steam_id", steamID)
		redirectToLoginWithErr(w, r, "Login database error — see controller logs.")
		return
	}
	if user.Disabled {
		a.log.Warn("steam login refused — user disabled", "steam_id", steamID, "user_id", user.ID)
		redirectToLoginWithErr(w, r, "This account is disabled.")
		return
	}

	// Success — mint a session cookie and redirect to the original `next` target.
	token, err := newSessionToken()
	if err != nil {
		a.log.Error("generate session token", "err", err)
		redirectToLoginWithErr(w, r, "Session creation failed.")
		return
	}
	expiresAt := time.Now().Add(steamLoginCookieTTL)
	if err := a.db.CreateSession(r.Context(), token, user.ID, expiresAt, clientIP(r)); err != nil {
		a.log.Error("create session", "err", err)
		redirectToLoginWithErr(w, r, "Session save failed.")
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    token,
		Path:     "/",
		Expires:  expiresAt,
		HttpOnly: true,
		Secure:   r.TLS != nil,
		SameSite: http.SameSiteLaxMode,
	})
	a.log.Info("steam login success", "user_id", user.ID, "email", user.Email, "steam_id", steamID)

	next := r.URL.Query().Get("next")
	if next == "" || !strings.HasPrefix(next, "/") {
		next = "/"
	}
	http.Redirect(w, r, next, http.StatusFound)
}

// ---- /api/auth/steam/link-start + link-callback ----
//
// The login flow above maps a verified SteamID → existing user row.
// Here we do the inverse: an already-signed-in operator redirects to
// Steam to PROVE they own a SteamID, then the callback links it to
// the current session user. Lets users add Steam without an admin
// pasting their 17-digit ID by hand. Admin can still paste IDs for
// teammates via the existing /api/users/{id}/steam endpoint.

// handleSteamLinkStart kicks the OpenID redirect, but with a return_to
// of /link-callback so the verified ID gets stamped onto the current
// session user. requireSession is enforced at the router level.
func (a *auth) handleSteamLinkStart(w http.ResponseWriter, r *http.Request) {
	realm := a.publicURL(r)
	next := strings.TrimSpace(r.URL.Query().Get("next"))
	if next == "" || !strings.HasPrefix(next, "/") {
		next = "/?steam_linked=ok"
	}
	returnTo, _ := url.Parse(realm + "/api/auth/steam/link-callback")
	q := returnTo.Query()
	q.Set("next", next)
	returnTo.RawQuery = q.Encode()

	params := url.Values{}
	params.Set("openid.ns", "http://specs.openid.net/auth/2.0")
	params.Set("openid.mode", "checkid_setup")
	params.Set("openid.return_to", returnTo.String())
	params.Set("openid.realm", realm)
	params.Set("openid.identity", "http://specs.openid.net/auth/2.0/identifier_select")
	params.Set("openid.claimed_id", "http://specs.openid.net/auth/2.0/identifier_select")

	http.Redirect(w, r, steamOpenIDEndpoint+"?"+params.Encode(), http.StatusFound)
}

// handleSteamLinkCallback verifies with Steam, then writes the SteamID
// onto the current session user's row. Browser still has the session
// cookie because Steam redirected back to our origin.
func (a *auth) handleSteamLinkCallback(w http.ResponseWriter, r *http.Request) {
	// Resolve current session user — required to know whose row to update.
	user, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok {
		http.Redirect(w, r, "/login?err=link-needs-session", http.StatusFound)
		return
	}
	claimedID := r.URL.Query().Get("openid.claimed_id")
	if claimedID == "" {
		http.Redirect(w, r, "/?steam_linked=err&msg=no-claim", http.StatusFound)
		return
	}
	m := steamIDRE.FindStringSubmatch(claimedID)
	if len(m) != 2 {
		http.Redirect(w, r, "/?steam_linked=err&msg=bad-claim", http.StatusFound)
		return
	}
	steamID := m[1]

	ok2, err := verifySteamOpenIDAssertion(r.Context(), r.URL.Query())
	if err != nil {
		a.log.Warn("steam link verify failed", "err", err, "steam_id", steamID)
		http.Redirect(w, r, "/?steam_linked=err&msg=verify-failed", http.StatusFound)
		return
	}
	if !ok2 {
		a.log.Warn("steam link verify returned is_valid=false", "steam_id", steamID)
		http.Redirect(w, r, "/?steam_linked=err&msg=invalid", http.StatusFound)
		return
	}

	// Refuse to silently steal a SteamID that's already linked to a
	// different panel user — operator should explicitly unlink there
	// first. Same SteamID re-link to the same user is a no-op success.
	if other, lerr := a.db.GetUserBySteamID(r.Context(), steamID); lerr == nil && other.ID != user.ID {
		a.log.Warn("steam link refused — id already linked to another user", "steam_id", steamID, "other_user_id", other.ID, "current_user_id", user.ID)
		http.Redirect(w, r, "/?steam_linked=err&msg=already-linked", http.StatusFound)
		return
	}

	if err := a.db.LinkSteamIDToUser(r.Context(), user.ID, steamID); err != nil {
		a.log.Error("steam link db update", "err", err, "user_id", user.ID, "steam_id", steamID)
		http.Redirect(w, r, "/?steam_linked=err&msg=db", http.StatusFound)
		return
	}
	a.log.Info("steam linked", "user_id", user.ID, "email", user.Email, "steam_id", steamID)

	next := r.URL.Query().Get("next")
	if next == "" || !strings.HasPrefix(next, "/") {
		next = "/?steam_linked=ok"
	}
	http.Redirect(w, r, next, http.StatusFound)
}

// verifySteamOpenIDAssertion POSTs every received openid.* parameter
// back to Steam with `openid.mode=check_authentication`, then parses the
// key-value response. Returns (is_valid, err).
func verifySteamOpenIDAssertion(ctx context.Context, in url.Values) (bool, error) {
	out := url.Values{}
	for k, vs := range in {
		if !strings.HasPrefix(k, "openid.") {
			continue
		}
		for _, v := range vs {
			out.Add(k, v)
		}
	}
	out.Set("openid.mode", "check_authentication")

	cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
	defer cancel()
	req, err := http.NewRequestWithContext(cctx, "POST", steamOpenIDEndpoint,
		strings.NewReader(out.Encode()))
	if err != nil {
		return false, err
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		return false, fmt.Errorf("post to steam: %w", err)
	}
	defer resp.Body.Close()
	body, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
	if err != nil {
		return false, fmt.Errorf("read steam reply: %w", err)
	}
	// Response is a key-value text document like:
	//   ns:http://specs.openid.net/auth/2.0
	//   is_valid:true
	kv := parseKVResponse(string(body))
	return kv["is_valid"] == "true", nil
}

func parseKVResponse(s string) map[string]string {
	out := map[string]string{}
	for _, line := range strings.Split(s, "\n") {
		i := strings.IndexByte(line, ':')
		if i <= 0 {
			continue
		}
		out[line[:i]] = strings.TrimSpace(line[i+1:])
	}
	return out
}

// publicURL is the base origin the OpenID realm + return_to URLs are built
// against. Falls back to the request's scheme+host if the operator didn't
// set --public-url on the CLI.
func (a *auth) publicURL(r *http.Request) string {
	if a.publicURLOverride != "" {
		return strings.TrimRight(a.publicURLOverride, "/")
	}
	scheme := "http"
	if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
		scheme = "https"
	}
	return scheme + "://" + r.Host
}

func redirectToLoginWithErr(w http.ResponseWriter, r *http.Request, msg string) {
	u := url.URL{Path: "/login"}
	q := u.Query()
	q.Set("steam_err", msg)
	if next := r.URL.Query().Get("next"); next != "" {
		q.Set("next", next)
	}
	u.RawQuery = q.Encode()
	http.Redirect(w, r, u.String(), http.StatusFound)
}
