package main

import (
	"context"
	"crypto/rand"
	"crypto/subtle"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"regexp"
	"strings"
	"time"

	"golang.org/x/crypto/argon2"

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

// argon2id parameters — chosen to keep login latency ≈ ~50-100 ms on
// typical hardware while resisting offline cracking. Stored inline with
// each hash (PHC string format) so we can bump them later.
const (
	argonTime    = 3
	argonMemory  = 64 * 1024 // 64 MiB
	argonThreads = 2
	argonKeyLen  = 32
	argonSaltLen = 16
)

const (
	sessionCookieName = "panel_session"
	sessionTTL        = 24 * time.Hour
)

// sessionUserKey is the context key the auth middleware uses to stash the
// authenticated user on an incoming request.
type sessionUserKey struct{}

// auth holds the bits the auth handlers need. Kept in the main package so
// it can share types with httpServer without any dependency rewiring.
type auth struct {
	log               *slog.Logger
	db                *db.DB
	publicURLOverride string // set from --public-url flag; used by Steam OpenID realm/return_to
}

// ---- Password hashing (argon2id, PHC format) ----

// hashPassword produces a PHC-format string safe for DB storage:
//
//	$argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>
func hashPassword(password string) (string, error) {
	salt := make([]byte, argonSaltLen)
	if _, err := rand.Read(salt); err != nil {
		return "", fmt.Errorf("rand salt: %w", err)
	}
	hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
	return fmt.Sprintf(
		"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
		argonMemory, argonTime, argonThreads,
		base64.RawStdEncoding.EncodeToString(salt),
		base64.RawStdEncoding.EncodeToString(hash),
	), nil
}

// verifyPassword is constant-time. Returns (true, nil) on match,
// (false, nil) on mismatch, (false, err) on bad hash format.
func verifyPassword(password, encoded string) (bool, error) {
	parts := strings.Split(encoded, "$")
	// ["", "argon2id", "v=19", "m=65536,t=3,p=2", salt, hash]
	if len(parts) != 6 || parts[1] != "argon2id" {
		return false, fmt.Errorf("not an argon2id hash")
	}
	var memory, timeCost uint32
	var threads uint8
	if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
		return false, fmt.Errorf("params: %w", err)
	}
	salt, err := base64.RawStdEncoding.DecodeString(parts[4])
	if err != nil {
		return false, fmt.Errorf("salt: %w", err)
	}
	expected, err := base64.RawStdEncoding.DecodeString(parts[5])
	if err != nil {
		return false, fmt.Errorf("hash: %w", err)
	}
	computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expected)))
	return subtle.ConstantTimeCompare(expected, computed) == 1, nil
}

// ---- Session tokens ----

func newSessionToken() (string, error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return hex.EncodeToString(b), nil
}

func clientIP(r *http.Request) string {
	if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
		// Take the first IP in the list.
		if i := strings.IndexByte(fwd, ','); i > 0 {
			return strings.TrimSpace(fwd[:i])
		}
		return strings.TrimSpace(fwd)
	}
	return r.RemoteAddr
}

// ---- Admin bootstrap ----

// bootstrapAdmin creates a default admin account if the users table is
// empty. The password is generated and printed to the controller's log
// (operator is expected to log in and change it via the UI on first run).
//
// Pass a non-empty envPassword ($PANEL_ADMIN_PASSWORD) to use a stable
// password instead of a random one — useful for dev / CI.
//
// initialSteamID (17-digit SteamID64, optional):
//
//   - If the users table is empty and this is non-empty, the bootstrap
//     admin gets this Steam ID linked immediately, enabling
//     "Sign in with Steam" on first boot.
//   - If an admin already exists AND doesn't have a steam_id linked yet,
//     we retro-link the first admin to this Steam ID. Useful when the
//     operator is adding Steam login to an already-running panel.
//   - Ignored if the first admin already has a steam_id (don't clobber).
// bootstrapAdmin creates the first admin. envEmail (from PANEL_ADMIN_EMAIL)
// lets the operator choose their login; blank falls back to the default
// admin@panel.local so existing installs are unaffected.
func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envEmail, envPassword, initialSteamID string) error {
	adminEmail := strings.TrimSpace(strings.ToLower(envEmail))
	if adminEmail == "" {
		adminEmail = "admin@panel.local"
	}
	initialSteamID = strings.TrimSpace(initialSteamID)
	if initialSteamID != "" && !steamID64RE.MatchString(initialSteamID) {
		log.Warn("--initial-admin-steam-id isn't a 17-digit SteamID64, ignoring", "value", initialSteamID)
		initialSteamID = ""
	}
	n, err := database.CountUsers(ctx)
	if err != nil {
		return err
	}
	if n > 0 {
		// Not our first boot — if the operator added --initial-admin-steam-id
		// after the fact, retro-link it to the first admin when that admin
		// doesn't yet have a steam_id. Otherwise do nothing.
		if initialSteamID == "" {
			return nil
		}
		first, err := database.GetFirstAdmin(ctx)
		if err != nil {
			log.Warn("could not find first admin to retro-link steam id", "err", err)
			return nil
		}
		if first.SteamID != "" {
			if first.SteamID != initialSteamID {
				log.Warn("first admin already has a different steam_id; leaving it alone",
					"existing", first.SteamID, "requested", initialSteamID)
			}
			return nil
		}
		if err := database.LinkSteamIDToUser(ctx, first.ID, initialSteamID); err != nil {
			return fmt.Errorf("link initial steam id: %w", err)
		}
		log.Warn("═══════════════════════════════════════════════════════════════════════════")
		log.Warn("STEAM LINK — linked existing admin account to your Steam ID",
			"email", first.Email, "steam_id", initialSteamID)
		log.Warn("  Sign in with Steam is now active on /login")
		log.Warn("═══════════════════════════════════════════════════════════════════════════")
		return nil
	}
	password := envPassword
	generated := password == ""
	if generated {
		pw, err := newSessionToken()
		if err != nil {
			return err
		}
		password = pw[:16] // 16 hex chars is enough entropy for dev
	}
	hash, err := hashPassword(password)
	if err != nil {
		return err
	}
	userID, err := database.CreateUser(ctx, adminEmail, hash, "admin")
	if err != nil {
		return err
	}
	if initialSteamID != "" {
		if err := database.LinkSteamIDToUser(ctx, userID, initialSteamID); err != nil {
			log.Warn("create-user succeeded but steam link failed", "err", err)
		}
	}
	const banner = "═══════════════════════════════════════════════════════════════════════════"
	log.Warn(banner)
	log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account")
	log.Warn("  email:    " + adminEmail)
	if generated {
		log.Warn("  password: " + password)
		log.Warn("  ^^^ log in once and change it via the UI ^^^")
	} else {
		log.Warn("  password: (supplied via PANEL_ADMIN_PASSWORD env)")
	}
	if initialSteamID != "" {
		log.Warn("  steam_id: " + initialSteamID + "  (Sign in with Steam is active)")
	}
	log.Warn(banner)
	return nil
}

// steamID64RE matches a 17-digit Steam community ID.
var steamID64RE = regexp.MustCompile(`^\d{17}$`)

// ---- Middleware ----

// requireSession enforces a valid session on every wrapped request.
// Unauthenticated API hits get 401; unauthenticated page hits get 302 /login.
func (a *auth) requireSession(isAPI bool, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		u, err := a.userFromRequest(r)
		if err != nil {
			if isAPI {
				writeError(w, http.StatusUnauthorized, "unauthenticated", "log in to continue")
			} else {
				http.Redirect(w, r, "/login", http.StatusFound)
			}
			return
		}
		ctx := context.WithValue(r.Context(), sessionUserKey{}, u)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// userFromRequest reads the session cookie and returns the user, or an
// error if the cookie is missing / expired / invalid.
func (a *auth) userFromRequest(r *http.Request) (*db.UserRow, error) {
	c, err := r.Cookie(sessionCookieName)
	if err != nil {
		return nil, fmt.Errorf("no session cookie")
	}
	u, err := a.db.GetSessionUser(r.Context(), c.Value, clientIP(r))
	if err != nil {
		return nil, err
	}
	if u.Disabled {
		return nil, fmt.Errorf("account disabled")
	}
	return u, nil
}

// ---- Handlers ----

type loginReq struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
	var req loginReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	req.Email = strings.ToLower(strings.TrimSpace(req.Email))
	if req.Email == "" || req.Password == "" {
		writeError(w, http.StatusBadRequest, "missing", "email and password required")
		return
	}

	u, err := a.db.GetUserByEmail(r.Context(), req.Email)
	if err != nil {
		if errors.Is(err, db.ErrUserNotFound) {
			// Still do a bogus verify so login timing doesn't leak user enumeration.
			_, _ = verifyPassword(req.Password, "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
			writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
			return
		}
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	if u.Disabled {
		writeError(w, http.StatusUnauthorized, "disabled", "account is disabled")
		return
	}
	ok, err := verifyPassword(req.Password, u.PasswordHash)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "verify", err.Error())
		return
	}
	if !ok {
		writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
		return
	}
	token, err := newSessionToken()
	if err != nil {
		writeError(w, http.StatusInternalServerError, "token", err.Error())
		return
	}
	exp := time.Now().Add(sessionTTL)
	if err := a.db.CreateSession(r.Context(), token, u.ID, exp, clientIP(r)); err != nil {
		writeError(w, http.StatusInternalServerError, "session", err.Error())
		return
	}
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    token,
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		Expires:  exp,
	})
	writeJSON(w, http.StatusOK, map[string]any{
		"email": u.Email,
		"role":  u.Role,
	})
}

func (a *auth) handleLogout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie(sessionCookieName); err == nil {
		_ = a.db.DeleteSession(r.Context(), c.Value)
	}
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   -1,
	})
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

func (a *auth) handleMe(w http.ResponseWriter, r *http.Request) {
	u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok {
		writeError(w, http.StatusUnauthorized, "unauthenticated", "not logged in")
		return
	}
	writeJSON(w, http.StatusOK, map[string]any{
		"id":       u.ID,
		"email":    u.Email,
		"role":     u.Role,
		"steam_id": u.SteamID,
	})
}

type changePasswordReq struct {
	Current string `json:"current_password"`
	New     string `json:"new_password"`
}

// handleChangePassword is behind requireSession — user context is guaranteed.
func (a *auth) handleChangePassword(w http.ResponseWriter, r *http.Request) {
	u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
	if !ok {
		writeError(w, http.StatusUnauthorized, "unauthenticated", "not logged in")
		return
	}
	var req changePasswordReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeError(w, http.StatusBadRequest, "bad_json", err.Error())
		return
	}
	if len(req.New) < 8 {
		writeError(w, http.StatusBadRequest, "weak_password", "new password must be at least 8 characters")
		return
	}
	ok, err := verifyPassword(req.Current, u.PasswordHash)
	if err != nil || !ok {
		writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password incorrect")
		return
	}
	newHash, err := hashPassword(req.New)
	if err != nil {
		writeError(w, http.StatusInternalServerError, "hash", err.Error())
		return
	}
	if err := a.db.UpdateUserPassword(r.Context(), u.ID, newHash); err != nil {
		writeError(w, http.StatusInternalServerError, "db", err.Error())
		return
	}
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
