package auth

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"log/slog"
	"net/http"
	"strings"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"
	"golang.org/x/crypto/bcrypt"

	"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)

// sessionTokenBytes is the raw entropy per session token. 32 bytes of
// crypto/rand gives ~256 bits; after base64 url-safe encoding the cookie
// value is 43 chars with no padding.
const sessionTokenBytes = 32

// MintSessionToken returns a freshly-generated, url-safe opaque token.
// The token is what the client carries; the DB only ever sees its sha256.
func MintSessionToken() (string, error) {
	b := make([]byte, sessionTokenBytes)
	if _, err := rand.Read(b); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

// HashSessionToken is the single source of truth for mapping a raw token to
// the `sessions.token_hash` column. sha256 is fine here — we're not guarding
// against offline brute force (the token has 256 bits of entropy); we only
// want "leaked DB row can't be replayed without also having the raw token."
func HashSessionToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

// VerifyPassword is the canonical bcrypt comparison. Returns false on a
// malformed hash so callers don't need to distinguish "hash invalid" from
// "password wrong" — both are auth failures from the client's perspective.
func VerifyPassword(hash, plaintext string) bool {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plaintext)) == nil
}

// SessionCookieName is the cookie the web SPA rides. Exported because handlers
// that issue/clear the cookie (handleLogin / handleLogout) need to match it.
const SessionCookieName = "minstrel_session"

// RequireUser resolves the caller from a session cookie OR Authorization
// bearer header and puts the dbq.User in request context via userCtxKey.
// Requests without a valid session return 401 with no body so callers don't
// leak whether the username existed (matches the /rest/* auth posture).
//
// trustedHops supplies the reverse-proxy depth used to record the session's
// current address (#2453). It's a func rather than an int because the value
// is operator-editable at runtime and this middleware is constructed once at
// boot — reading it per request is what makes an admin change take effect
// without a restart. Passing nil means "trust nothing", i.e. the socket peer.
func RequireUser(pool *pgxpool.Pool, trustedHops func() int) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token := sessionTokenFromRequest(r)
			if token == "" {
				http.Error(w, "unauthenticated", http.StatusUnauthorized)
				return
			}
			if pool == nil {
				// Test-only path: the test at the top of this file constructs
				// the middleware with nil pool to prove the no-token case
				// short-circuits without a DB call. Any real token here is
				// programmer error.
				http.Error(w, "unauthenticated", http.StatusUnauthorized)
				return
			}
			q := dbq.New(pool)
			sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
			if err != nil {
				if errors.Is(err, pgx.ErrNoRows) {
					http.Error(w, "unauthenticated", http.StatusUnauthorized)
					return
				}
				slog.Error("api: session lookup failed", "err", err)
				http.Error(w, "auth lookup failed", http.StatusInternalServerError)
				return
			}
			user, err := q.GetUserByID(r.Context(), sess.UserID)
			if err != nil {
				if errors.Is(err, pgx.ErrNoRows) {
					// Session points at a deleted user — treat as unauth,
					// best-effort cleanup.
					_ = q.DeleteSession(r.Context(), sess.ID)
					http.Error(w, "unauthenticated", http.StatusUnauthorized)
					return
				}
				slog.Error("api: user lookup failed", "err", err)
				http.Error(w, "auth lookup failed", http.StatusInternalServerError)
				return
			}
			// Best-effort last-seen update. A failure here shouldn't fail the
			// request; the session is still valid and this is observability.
			// last_ip rides the same UPDATE — a session whose address has
			// moved since it was issued is the signal the active-sessions
			// surface exists to show, and it costs nothing extra here.
			if err := q.TouchSessionLastSeen(r.Context(), dbq.TouchSessionLastSeenParams{
				ID:     sess.ID,
				LastIp: ClientIP(r, hopsOf(trustedHops)),
			}); err != nil {
				slog.Warn("api: touch session last_seen failed", "err", err)
			}
			ctx := context.WithValue(r.Context(), userCtxKey, user)
			ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// UserCtxKeyForTest is exported ONLY for tests in sibling packages that need
// to inject a dbq.User into request context without going through the
// middleware. Do not use this outside _test.go files.
func UserCtxKeyForTest() any { return userCtxKey }

// SessionIDCtxKeyForTest is the sibling of UserCtxKeyForTest for the session
// id, so handler tests can exercise the current-session logic (which row is
// "this device", which one logout-others must spare) without standing up the
// middleware. Do not use this outside _test.go files.
func SessionIDCtxKeyForTest() any { return sessionIDCtxKey }

// OptionalUser is RequireUser's permissive sibling: it resolves the caller
// from the session cookie or bearer header and attaches the user to context
// when present + valid, but does NOT 401 on absence. The downstream handler
// runs unconditionally and is responsible for its own auth check via
// UserFromContext (or its own bespoke path — see /api/tracks/{id}/stream's
// streamAuthOk, which accepts EITHER a user-in-context OR a signed query
// token for UPnP / Sonos speakers that don't carry the user's cookie).
//
// Invalid tokens (stale session row, deleted user) silently drop through
// without attaching the user. The handler treats "no user in context" as
// "not authenticated" the same way it treats a missing cookie.
//
// Database lookup failures fall through too — a transient DB blip should not
// 5xx a stream request that may have a perfectly valid signed token. The
// error is logged so the operator can correlate.
func OptionalUser(pool *pgxpool.Pool, logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			token := sessionTokenFromRequest(r)
			if token == "" || pool == nil {
				next.ServeHTTP(w, r)
				return
			}
			q := dbq.New(pool)
			sess, err := q.GetSessionByTokenHash(r.Context(), HashSessionToken(token))
			if err != nil {
				if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
					logger.Warn("api: optional session lookup failed", "err", err)
				}
				next.ServeHTTP(w, r)
				return
			}
			user, err := q.GetUserByID(r.Context(), sess.UserID)
			if err != nil {
				if !errors.Is(err, pgx.ErrNoRows) && logger != nil {
					logger.Warn("api: optional user lookup failed", "err", err)
				}
				next.ServeHTTP(w, r)
				return
			}
			ctx := context.WithValue(r.Context(), userCtxKey, user)
			ctx = context.WithValue(ctx, sessionIDCtxKey, sess.ID)
			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

func sessionTokenFromRequest(r *http.Request) string {
	if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
		return c.Value
	}
	return extractBearerToken(r.Header.Get("Authorization"))
}

// extractBearerToken pulls the token out of an Authorization header in
// either "Bearer xyz" or "bearer xyz" form. Returns "" when the header is
// missing, malformed, or uses a different scheme.
func extractBearerToken(header string) string {
	if header == "" {
		return ""
	}
	const prefix = "bearer "
	lower := strings.ToLower(header)
	if !strings.HasPrefix(lower, prefix) {
		return ""
	}
	return strings.TrimSpace(header[len(prefix):])
}
