package httpapi

import (
	"context"
	"crypto/aes"
	"crypto/cipher"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"time"

	"commilitia.net/cdrop/internal/db"
	"commilitia.net/cdrop/internal/jwtauth"
)

// Browser "passwordless re-login" (web only — desktop persists its own
// refresh_token in the OS keyring and never touches these endpoints).
//
// The durable credential (Casdoor's ~15 KB refresh_token) stays server-side in
// web_sessions, encrypted at rest. The browser only ever holds an opaque cookie
// token whose SHA-256 is the row's primary key — so a leaked DB yields neither a
// usable cookie nor a decryptable token. The cookie is HttpOnly (XSS can't read
// it), Secure (HTTPS only), SameSite=Lax + Origin-checked (CSRF), and Path-scoped
// to /api/auth so it rides only these four endpoints, not every API call.

const (
	sessionCookieName = "cdrop_session"
	sessionCookiePath = "/api/auth"

	// webSessionTTL is the sliding inactivity window: each refresh pushes
	// expires_at this far forward. The effective cap is min(this, the IdP's own
	// refresh_token validity) — once Casdoor retires the refresh_token, refresh
	// 401s and the user re-logs in regardless.
	webSessionTTL = 7 * 24 * time.Hour

	// refreshLockStripes bounds the per-session refresh lock set (see Server).
	refreshLockStripes = 256
)

// lockRefresh serialises refreshes of one session id, returning the unlock fn.
// Callers must re-read the session row after acquiring it: a concurrent refresh
// may have already rotated the refresh_token.
func (s *Server) lockRefresh(id string) func() {
	idx := stripeIndex(id)
	s.refreshLocks[idx].Lock()
	return func() { s.refreshLocks[idx].Unlock() }
}

// stripeIndex folds a session id into a stripe with FNV-1a — bounded, no cleanup.
func stripeIndex(id string) int {
	var h uint32 = 2166136261
	for i := 0; i < len(id); i++ {
		h = (h ^ uint32(id[i])) * 16777619
	}
	return int(h % refreshLockStripes)
}

// deriveSessionKey turns a config secret of any length into a 32-byte AES key
// (mirrors jwtauth.DeriveHS256Key). Empty secret → nil (feature disabled).
func deriveSessionKey(secret string) []byte {
	if secret == "" {
		return nil
	}
	sum := sha256.Sum256([]byte(secret))
	return sum[:]
}

// deriveSiteOrigin extracts scheme://host from the configured redirect_uri to
// give the CSRF Origin check a fixed expected value. Empty (dev) disables it.
func deriveSiteOrigin(redirectURI string) string {
	if redirectURI == "" {
		return ""
	}
	u, err := url.Parse(redirectURI)
	if err != nil || u.Scheme == "" || u.Host == "" {
		return ""
	}
	return u.Scheme + "://" + u.Host
}

// newSessionToken mints a fresh opaque cookie token plus its storage id. raw
// goes in Set-Cookie; id (hex SHA-256 of raw) is the DB key, so the stored row
// never contains a usable cookie value.
func newSessionToken() (raw, id string, err error) {
	b := make([]byte, 32)
	if _, err := rand.Read(b); err != nil {
		return "", "", err
	}
	raw = base64.RawURLEncoding.EncodeToString(b)
	return raw, sessionID(raw), nil
}

func sessionID(raw string) string {
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

// encryptRefresh seals a refresh_token with AES-256-GCM. Output is
// base64(nonce || ciphertext+tag); the key is s.sessionKey (env-derived).
func (s *Server) encryptRefresh(plain string) (string, error) {
	if len(s.sessionKey) == 0 {
		return "", errors.New("session key not configured")
	}
	gcm, err := newGCM(s.sessionKey)
	if err != nil {
		return "", err
	}
	nonce := make([]byte, gcm.NonceSize())
	if _, err := rand.Read(nonce); err != nil {
		return "", err
	}
	ct := gcm.Seal(nonce, nonce, []byte(plain), nil)
	return base64.StdEncoding.EncodeToString(ct), nil
}

func (s *Server) decryptRefresh(enc string) (string, error) {
	if len(s.sessionKey) == 0 {
		return "", errors.New("session key not configured")
	}
	raw, err := base64.StdEncoding.DecodeString(enc)
	if err != nil {
		return "", err
	}
	gcm, err := newGCM(s.sessionKey)
	if err != nil {
		return "", err
	}
	if len(raw) < gcm.NonceSize() {
		return "", errors.New("ciphertext too short")
	}
	nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
	plain, err := gcm.Open(nil, nonce, ct, nil)
	if err != nil {
		return "", err
	}
	return string(plain), nil
}

func newGCM(key []byte) (cipher.AEAD, error) {
	block, err := aes.NewCipher(key)
	if err != nil {
		return nil, err
	}
	return cipher.NewGCM(block)
}

func setSessionCookie(w http.ResponseWriter, raw string) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    raw,
		Path:     sessionCookiePath,
		MaxAge:   int(webSessionTTL / time.Second),
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
}

func clearSessionCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookieName,
		Value:    "",
		Path:     sessionCookiePath,
		MaxAge:   -1,
		HttpOnly: true,
		Secure:   true,
		SameSite: http.SameSiteLaxMode,
	})
}

// sameOrigin is belt-and-suspenders CSRF defence atop SameSite=Lax: when the
// browser sends an Origin header (always, on fetch POST) it must match the
// deployment's own origin. Absent Origin (non-browser clients) is allowed, as is
// an unconfigured site origin (dev).
func (s *Server) sameOrigin(r *http.Request) bool {
	origin := r.Header.Get("Origin")
	if origin == "" || s.siteOrigin == "" {
		return true
	}
	return origin == s.siteOrigin
}

// handleAuthLogout destroys the server-side session and clears the cookie.
// Cookie-authenticated (no bearer): the cookie is the only thing that proves
// which session to drop.
func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) {
	if c, err := r.Cookie(sessionCookieName); err == nil && c.Value != "" {
		if err := s.queries.DeleteWebSession(r.Context(), sessionID(c.Value)); err != nil {
			slog.Warn("web session delete failed", "err", err)
		}
	}
	clearSessionCookie(w)
	w.WriteHeader(http.StatusNoContent)
}

type sessionDeviceReq struct {
	DeviceName string `json:"device_name"`
}

// handleAuthDevice persists this browser's device name into its session row so
// it survives PWA storage eviction: on the next boot, /auth/refresh hands the
// name back and the client re-hydrates selfDeviceName without a trip to /setup.
func (s *Server) handleAuthDevice(w http.ResponseWriter, r *http.Request) {
	if !s.sameOrigin(r) {
		writeJSON(w, http.StatusForbidden, map[string]string{"error": "bad origin"})
		return
	}
	c, err := r.Cookie(sessionCookieName)
	if err != nil || c.Value == "" {
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
		return
	}
	var req sessionDeviceReq
	if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	name := jwtauth.SanitizeDeviceName(req.DeviceName)
	if name == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "empty device name"})
		return
	}
	if err := s.queries.SetWebSessionDevice(r.Context(), db.SetWebSessionDeviceParams{
		DeviceName: name,
		ID:         sessionID(c.Value),
	}); err != nil {
		slog.Error("set web session device failed", "err", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "persist failed"})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// RunWebSessionReaper periodically reclaims expired web_sessions rows. Lazy
// deletion on access covers the hot path; this sweeps sessions that simply go
// idle and are never touched again.
func RunWebSessionReaper(ctx context.Context, q *db.Queries) {
	ticker := time.NewTicker(1 * time.Hour)
	defer ticker.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-ticker.C:
			now := time.Now().Unix()
			n, err := q.DeleteExpiredWebSessions(ctx, now)
			if err != nil {
				slog.Warn("web session reaper failed", "err", err)
			} else if n > 0 {
				slog.Info("web sessions reaped", "count", n)
			}
			// Scan-login requests are short-lived (default 120s); sweep the
			// stragglers here too. Expired rows are already rejected at use time.
			if n, err := q.DeleteExpiredLoginRequests(ctx, now); err != nil {
				slog.Warn("login request reaper failed", "err", err)
			} else if n > 0 {
				slog.Info("login requests reaped", "count", n)
			}
		}
	}
}
