// Package oidc implements web.Auth against a Dex OIDC provider using the
// standard Authorization Code flow (docs/ui-spec.md §6, ADR-011). It is the
// production counterpart to web.StubAuth: handlers depend only on the web.Auth
// interface, so swapping the stub for Dex is a wiring choice in cmd/tapir, not
// a code change (ADR-003).
//
// Authentication is real (Dex OIDC) and is the only gate: any Dex-authenticated
// subject may sign in (ADR-012 dropped ADR-011's single-subject allowlist).
// Authorization/registration is layered on top in internal/web (an authenticated
// subject with no tapir user is routed to registration). Sessions are STATELESS
// (ADR-029): the identity + expiry live inside an HMAC-signed (HS256) HttpOnly
// Secure SameSite=Lax persistent cookie with a long sliding TTL — no server-side
// table, so a deploy/restart never logs anyone out and the cookie also survives
// browser-close. Tokens are never logged; logout clears the cookie client-side.
//
// This is mcp-chassis's cousin but NOT the same code: mcp-chassis validates
// inbound Bearer JWTs for MCP APIs; this is a browser session login.
package oidc

import (
	"context"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"

	"git.d-ma.be/mathias/tapir/internal/metrics"
	"git.d-ma.be/mathias/tapir/internal/web"
)

// Config is the OIDC + session configuration. cmd/tapir maps these from
// TAPIR_OIDC_*/TAPIR_DEX_*/TAPIR_SESSION_SECRET; this package takes the resolved
// struct.
type Config struct {
	// Issuer is the Dex issuer URL, e.g. https://auth.d-ma.be. Discovery
	// (.well-known/openid-configuration) runs against it in New.
	Issuer string
	// ClientID / ClientSecret are the registered Dex static client credentials.
	ClientID     string
	ClientSecret string
	// RedirectURL is the absolute callback URL, e.g.
	// https://tapir.d-ma.be/auth/callback. Must match the Dex client config.
	RedirectURL string
	// SessionSecret keys the HS256 session-cookie signature. Never logged.
	SessionSecret string
}

const (
	// defaultSessionTTL is generous and sliding: Tapir is a "check back tomorrow"
	// reader, so a short TTL meant a re-login (full IdP redirect dance) on almost
	// every visit. 30 days, slid forward on each request, keeps a regular user
	// logged in indefinitely while an abandoned session still lapses.
	defaultSessionTTL = 30 * 24 * time.Hour
	pendingTTL        = 10 * time.Minute
	sessionCookie     = "tapir_session"
	loginPath         = "/auth/login"
)

// DexAuth implements web.Auth against Dex.
type DexAuth struct {
	cfg      Config
	oauth    *oauth2.Config
	verifier *oidc.IDTokenVerifier

	pending    *pendingStore
	secret     []byte
	sessionTTL time.Duration
	now        func() time.Time
	insecure   bool // tests only: drop the Secure attribute so cookies flow over http
}

var _ web.Auth = (*DexAuth)(nil)

// Option tunes a DexAuth. Production wiring passes none; tests inject a clock,
// a shorter TTL, or insecure cookies.
type Option func(*DexAuth)

// WithClock overrides the time source (sliding-refresh and expiry tests).
func WithClock(now func() time.Time) Option {
	return func(d *DexAuth) {
		if now != nil {
			d.now = now
		}
	}
}

// WithSessionTTL overrides the session lifetime.
func WithSessionTTL(ttl time.Duration) Option {
	return func(d *DexAuth) {
		if ttl > 0 {
			d.sessionTTL = ttl
		}
	}
}

// WithInsecureCookies drops the Secure cookie attribute. TEST ONLY — it lets
// the session cookie travel over plain http; never use it in production.
func WithInsecureCookies() Option {
	return func(d *DexAuth) { d.insecure = true }
}

// New discovers the issuer and returns a configured DexAuth. ctx bounds the
// discovery request only.
func New(ctx context.Context, cfg Config, opts ...Option) (*DexAuth, error) {
	for name, val := range map[string]string{
		"issuer":         cfg.Issuer,
		"client id":      cfg.ClientID,
		"client secret":  cfg.ClientSecret,
		"redirect url":   cfg.RedirectURL,
		"session secret": cfg.SessionSecret,
	} {
		if strings.TrimSpace(val) == "" {
			return nil, fmt.Errorf("oidc: missing %s", name)
		}
	}

	provider, err := oidc.NewProvider(ctx, cfg.Issuer)
	if err != nil {
		return nil, fmt.Errorf("oidc: discover issuer: %w", err)
	}

	d := &DexAuth{
		cfg:      cfg,
		verifier: provider.Verifier(&oidc.Config{ClientID: cfg.ClientID}),
		oauth: &oauth2.Config{
			ClientID:     cfg.ClientID,
			ClientSecret: cfg.ClientSecret,
			Endpoint:     provider.Endpoint(),
			RedirectURL:  cfg.RedirectURL,
			Scopes:       []string{oidc.ScopeOpenID, "profile", "email"},
		},
		pending:    newPendingStore(),
		secret:     []byte(cfg.SessionSecret),
		sessionTTL: defaultSessionTTL,
		now:        time.Now,
	}
	for _, o := range opts {
		o(d)
	}
	return d, nil
}

// Routes mounts the auth endpoints. Patterns are absolute so the handler works
// whether the caller mounts it at the root or under the /auth/ subtree.
func (d *DexAuth) Routes() http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("/auth/login", d.handleLogin)
	mux.HandleFunc("/auth/callback", d.handleCallback)
	mux.HandleFunc("/auth/logout", d.handleLogout)
	return mux
}

// Middleware redirects unauthenticated requests to the login endpoint and lets
// authenticated ones through, sliding the session expiry forward. /healthz and
// /auth/* are always public (avoids a redirect loop when the whole mux is
// wrapped).
func (d *DexAuth) Middleware(h http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if isPublicPath(r.URL.Path) {
			h.ServeHTTP(w, r)
			return
		}
		c, err := r.Cookie(sessionCookie)
		if err != nil {
			d.redirectUnauthenticated(w, r)
			return
		}
		user, _, ok := d.decodeSession(c.Value, d.now())
		if !ok {
			d.redirectUnauthenticated(w, r)
			return
		}
		// Sliding refresh: re-issue the cookie with a fresh expiry so an active
		// user never lapses (the expiry lives in the cookie, so sliding = re-sign).
		d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
		h.ServeHTTP(w, r)
	})
}

// CurrentUser resolves the authenticated principal from the stateless cookie.
func (d *DexAuth) CurrentUser(r *http.Request) (web.User, bool) {
	c, err := r.Cookie(sessionCookie)
	if err != nil {
		return web.User{}, false
	}
	user, _, ok := d.decodeSession(c.Value, d.now())
	return user, ok
}

func (d *DexAuth) handleLogin(w http.ResponseWriter, r *http.Request) {
	state, err := randToken()
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	nonce, err := randToken()
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	d.pending.put(state, nonce, d.now().Add(pendingTTL))
	http.Redirect(w, r, d.oauth.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound)
}

func (d *DexAuth) handleCallback(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	if q.Get("error") != "" {
		http.Error(w, "authentication failed", http.StatusBadRequest)
		return
	}

	state := q.Get("state")
	nonce, ok := d.pending.take(state, d.now())
	if state == "" || !ok {
		http.Error(w, "invalid or expired state", http.StatusBadRequest)
		return
	}
	code := q.Get("code")
	if code == "" {
		http.Error(w, "missing authorization code", http.StatusBadRequest)
		return
	}

	tok, err := d.oauth.Exchange(r.Context(), code)
	if err != nil {
		http.Error(w, "token exchange failed", http.StatusBadGateway)
		return
	}
	rawID, ok := tok.Extra("id_token").(string)
	if !ok {
		http.Error(w, "no id token in response", http.StatusBadGateway)
		return
	}
	idToken, err := d.verifier.Verify(r.Context(), rawID)
	if err != nil {
		http.Error(w, "invalid id token", http.StatusUnauthorized)
		return
	}
	if idToken.Nonce != nonce {
		http.Error(w, "nonce mismatch", http.StatusBadRequest)
		return
	}

	// Authentication is the only gate (ADR-012): any Dex-authenticated subject may
	// establish a session. Whether that subject has a tapir user — and routing to
	// registration if not — is decided downstream in internal/web, not here.
	var claims struct {
		Email string `json:"email"`
	}
	_ = idToken.Claims(&claims) // email is best-effort; subject is the identity

	user := web.User{Subject: idToken.Subject, Email: claims.Email}
	d.setSessionCookie(w, d.encodeSession(user, d.now().Add(d.sessionTTL)))
	metrics.IncLogin()
	http.Redirect(w, r, "/", http.StatusFound)
}

func (d *DexAuth) handleLogout(w http.ResponseWriter, r *http.Request) {
	// Stateless sessions: clearing the cookie logs the browser out. There is no
	// server-side record to delete (ADR-029); a copy of the cookie stays valid
	// until its expiry — an accepted trade for the Stage-0 reader app.
	d.clearSessionCookie(w)
	// Land on the public landing page, not the login endpoint: a just-logged-out
	// visitor should see /welcome, not be bounced straight back into a Dex login.
	http.Redirect(w, r, "/welcome", http.StatusFound)
}

// redirectUnauthenticated sends an unauthenticated visitor somewhere useful: the
// bare root goes to the public landing page (/welcome), any deeper guarded path
// goes to login so the post-login round-trip can return them to it. isPublicPath
// has already let /welcome and /auth/* through, so this never loops.
func (d *DexAuth) redirectUnauthenticated(w http.ResponseWriter, r *http.Request) {
	if r.URL.Path == "/" {
		http.Redirect(w, r, "/welcome", http.StatusFound)
		return
	}
	d.redirectToLogin(w, r)
}

func (d *DexAuth) redirectToLogin(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, loginPath, http.StatusFound)
}

// setSessionCookie writes the signed session value as a PERSISTENT cookie
// (Max-Age set), so it survives the browser/app being closed — a session cookie
// (no Max-Age) was dropped on iPhone Safari close, forcing re-login. value is the
// already-signed payload from encodeSession.
func (d *DexAuth) setSessionCookie(w http.ResponseWriter, value string) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    value,
		Path:     "/",
		HttpOnly: true,
		Secure:   !d.insecure,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   int(d.sessionTTL.Seconds()),
	})
}

func (d *DexAuth) clearSessionCookie(w http.ResponseWriter) {
	http.SetCookie(w, &http.Cookie{
		Name:     sessionCookie,
		Value:    "",
		Path:     "/",
		HttpOnly: true,
		Secure:   !d.insecure,
		SameSite: http.SameSiteLaxMode,
		MaxAge:   -1,
	})
}

func isPublicPath(p string) bool {
	return p == "/healthz" || p == "/welcome" || strings.HasPrefix(p, "/auth/")
}
