package httpapi

import (
	"context"
	"encoding/json"
	"fmt"
	"io"
	"log/slog"
	"net/http"
	"net/url"
	"strings"
	"time"

	"github.com/go-jose/go-jose/v4"
	"github.com/go-jose/go-jose/v4/jwt"

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

// PROJECT_BRIEF.md §2 keeps the browser path on standard OIDC PKCE. Backend
// proxies the token endpoint so the browser doesn't need a CORS-friendly OIDC
// provider. The browser is then handed a cdrop self-signed access token (sid-bound
// to its web_sessions row), not the IdP's RS256 token, so OIDC web sessions verify
// statefully and revoke immediately — unified with scan-login (AUTH.md §5).

type authConfigResp struct {
	AuthMode     string `json:"auth_mode"`
	AuthorizeURL string `json:"authorize_url"`
	// TokenURL is published so native clients (the desktop app) can run their
	// own loopback PKCE flow directly against the provider, reusing this same
	// client_id. The browser doesn't need it — it proxies through /api/auth/exchange.
	TokenURL    string `json:"token_url"`
	ClientID    string `json:"client_id"`
	RedirectURI string `json:"redirect_uri"`
	Scopes      string `json:"scopes"`
}

// handleAuthConfig publishes everything the frontend needs to start the PKCE
// authorize redirect. No auth required (it's all public OIDC metadata).
func (s *Server) handleAuthConfig(w http.ResponseWriter, _ *http.Request) {
	writeJSON(w, http.StatusOK, authConfigResp{
		AuthMode:     s.cfg.AuthMode,
		AuthorizeURL: s.cfg.OIDCAuthorizeURL,
		TokenURL:     s.cfg.OIDCTokenURL,
		ClientID:     s.cfg.OIDCClientID,
		RedirectURI:  s.cfg.OIDCRedirectURI,
		Scopes:       s.cfg.OIDCScopes,
	})
}

type exchangeReq struct {
	Code         string `json:"code"`
	CodeVerifier string `json:"code_verifier"`
}

type tokenResp struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	IDToken      string `json:"id_token"`
	ExpiresIn    int    `json:"expires_in"`
	TokenType    string `json:"token_type"`
}

type userResp struct {
	ID     string `json:"id"`
	Name   string `json:"name"`
	Avatar string `json:"avatar,omitempty"`
}

// exchangeResp deliberately omits refresh_token: the durable credential never
// reaches the browser. It's encrypted into the server-side web_sessions row and
// represented to the client only by the HttpOnly session cookie set alongside.
type exchangeResp struct {
	AccessToken string   `json:"access_token"`
	ExpiresIn   int      `json:"expires_in"`
	User        userResp `json:"user"`
}

func (s *Server) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
	var req exchangeReq
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	if req.Code == "" || req.CodeVerifier == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing code or code_verifier"})
		return
	}
	if s.cfg.OIDCTokenURL == "" {
		writeJSON(w, http.StatusInternalServerError,
			map[string]string{"error": "OIDC token URL not configured"})
		return
	}

	form := url.Values{}
	form.Set("grant_type", "authorization_code")
	form.Set("code", req.Code)
	form.Set("code_verifier", req.CodeVerifier)
	form.Set("client_id", s.cfg.OIDCClientID)
	form.Set("redirect_uri", s.cfg.OIDCRedirectURI)
	// Casdoor's "cdrop" application is a confidential client; PKCE is layered
	// on top of the standard secret. Skip the secret if not configured to keep
	// public-client OIDC providers happy.
	if s.cfg.OIDCClientSecret != "" {
		form.Set("client_secret", s.cfg.OIDCClientSecret)
	}

	tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
	if err != nil {
		slog.Warn("oidc exchange failed", "err", err)
		writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
		return
	}

	identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim)
	if err != nil {
		writeJSON(w, http.StatusBadGateway,
			map[string]string{"error": "extract user: " + err.Error()})
		return
	}
	user := identity.User
	// Refresh the thin accounts row (display name / avatar / roles / match_key).
	s.upsertAccount(r.Context(), identity)

	// By default the browser holds a cdrop self-signed access token (sid-bound),
	// not the IdP's RS256 token — so the OIDC web session is verified statefully on
	// every request (verifySelfToken → web_sessions lookup) and revoking the row
	// logs this device out on its very next call, exactly like scan-login. We mint
	// it only after stashing the refresh_token server-side (encrypted, behind an
	// HttpOnly cookie) so the session is durable. The IdP RS256 token stays the
	// fallback for degraded configs (no encryption key / no refresh_token / an
	// id_token that fails verification) — it is still independently verified per
	// request via JWKS (verifyRS256), and the next refresh upgrades the device to
	// the unified self-token model. (AUTH.md §5; desktop keeps its RS256 token.)
	accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
	if len(s.sessionKey) > 0 && tr.RefreshToken != "" {
		id, cerr := s.createWebSession(r, w, user.ID, tr.RefreshToken)
		if cerr != nil {
			slog.Error("create web session failed", "err", cerr)
		} else {
			// The id_token is the SOLE trust anchor for the minted self token (the
			// IdP RS256 token no longer reaches the browser to be re-verified), so
			// its signature MUST validate here and its subject must match. On
			// failure we keep the durable session but hand back the per-request-
			// verified RS256 token; the next refresh upgrades this device to the
			// self-token model. (Should not happen with a healthy IdP — warn ops.)
			sub, _, verr := s.auth.VerifyIDToken(r.Context(), tr.IDToken)
			if verr != nil || sub != user.ID {
				slog.Warn("oidc id_token verification failed; using RS256 fallback",
					"err", verr, "sub_match", sub == user.ID)
			} else if tok, ein, merr := s.mintSessionToken(user.ID, id, "full"); merr != nil {
				slog.Error("mint session token failed", "err", merr)
			} else {
				accessToken, expiresIn = tok, ein
			}
		}
	}

	writeJSON(w, http.StatusOK, exchangeResp{
		AccessToken: accessToken,
		ExpiresIn:   expiresIn,
		User:        user,
	})
}

// createWebSession encrypts the refresh_token, persists a new session row, and
// sets the session cookie on the response. Returns the session row id so the
// caller can bind a cdrop self-signed access token to it (sid).
func (s *Server) createWebSession(r *http.Request, w http.ResponseWriter, userID, refreshToken string) (string, error) {
	raw, id, err := newSessionToken()
	if err != nil {
		return "", err
	}
	enc, err := s.encryptRefresh(refreshToken)
	if err != nil {
		return "", err
	}
	now := time.Now()
	if err := s.queries.CreateWebSession(r.Context(), db.CreateWebSessionParams{
		ID:           id,
		UserID:       userID,
		RefreshToken: enc,
		DeviceName:   "",
		UserAgent:    truncate(r.UserAgent(), 256),
		CreatedAt:    now.Unix(),
		LastUsedAt:   now.Unix(),
		ExpiresAt:    now.Add(webSessionTTL).Unix(),
	}); err != nil {
		return "", err
	}
	setSessionCookie(w, raw)
	return id, nil
}

// refreshResp is what the browser sees: a fresh access_token plus the identity
// and device name recovered from the session. No refresh_token — it stays
// server-side. This same endpoint, called with the cookie and no body on app
// boot, IS the passwordless re-login path.
type refreshResp struct {
	AccessToken string   `json:"access_token"`
	ExpiresIn   int      `json:"expires_in"`
	User        userResp `json:"user"`
	DeviceName  string   `json:"device_name"`
}

func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
	if s.cfg.OIDCTokenURL == "" {
		writeJSON(w, http.StatusInternalServerError,
			map[string]string{"error": "OIDC token URL not configured"})
		return
	}
	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 == "" {
		clearSessionCookie(w)
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no session"})
		return
	}
	id := sessionID(c.Value)

	// Serialise concurrent refreshes of this session, then read the row INSIDE the
	// lock: a racing tab may already have rotated the refresh_token, and we must
	// spend the current one, not a stale copy the IdP would reject.
	unlock := s.lockRefresh(id)
	defer unlock()

	sess, err := s.queries.GetWebSession(r.Context(), id)
	if err != nil {
		// Unknown / deleted session → not authenticated. Clear the stale cookie.
		clearSessionCookie(w)
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
		return
	}
	now := time.Now()
	if sess.ExpiresAt < now.Unix() {
		_ = s.queries.DeleteWebSession(r.Context(), id)
		clearSessionCookie(w)
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "session expired"})
		return
	}

	// Self-signed sessions (scan-login: kind self/guest) carry no IdP refresh_token.
	// Mint a fresh cdrop access token straight from the row, slide the window for
	// persistent sessions, re-arm the cookie — never touching the IdP (AUTH.md §3.1).
	if sess.Kind != "oidc" {
		s.refreshSelfSession(w, r, sess, c.Value, now)
		return
	}

	refreshToken, err := s.decryptRefresh(sess.RefreshToken)
	if err != nil {
		// Corrupt ciphertext or rotated key — the session is unusable, drop it.
		_ = s.queries.DeleteWebSession(r.Context(), id)
		clearSessionCookie(w)
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid session"})
		return
	}

	form := url.Values{}
	form.Set("grant_type", "refresh_token")
	form.Set("refresh_token", refreshToken)
	form.Set("client_id", s.cfg.OIDCClientID)
	if s.cfg.OIDCClientSecret != "" {
		form.Set("client_secret", s.cfg.OIDCClientSecret)
	}

	tr, err := postOIDCToken(r.Context(), s.cfg.OIDCTokenURL, form)
	if err != nil {
		// The IdP rejected the refresh_token (expired / revoked) — the session is
		// dead end to end, so destroy it rather than leave a row that always 401s.
		slog.Warn("oidc refresh failed", "err", err)
		_ = s.queries.DeleteWebSession(r.Context(), id)
		clearSessionCookie(w)
		writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "refresh rejected"})
		return
	}

	identity, err := extractIdentity(tr.IDToken, tr.AccessToken, s.cfg.AccountMatchClaim)
	if err != nil {
		writeJSON(w, http.StatusBadGateway,
			map[string]string{"error": "extract user: " + err.Error()})
		return
	}
	user := identity.User
	// Keep the thin accounts row fresh on every successful refresh too.
	s.upsertAccount(r.Context(), identity)

	// Rotate: Casdoor issues a new refresh_token on each grant; fall back to the
	// existing one if it didn't. Slide the window forward and re-arm the cookie.
	newRefresh := tr.RefreshToken
	if newRefresh == "" {
		newRefresh = refreshToken
	}
	if enc, encErr := s.encryptRefresh(newRefresh); encErr == nil {
		if err := s.queries.RotateWebSession(r.Context(), db.RotateWebSessionParams{
			RefreshToken: enc,
			LastUsedAt:   now.Unix(),
			ExpiresAt:    now.Add(webSessionTTL).Unix(),
			ID:           id,
		}); err != nil {
			slog.Error("rotate web session failed", "err", err)
		}
	} else {
		slog.Error("encrypt rotated refresh token failed", "err", encErr)
	}
	setSessionCookie(w, c.Value)

	// Hand back a cdrop self-signed access token (sid-bound), not the IdP RS256
	// token: the browser holds one uniform token type and revoking the session row
	// logs this device out on its next request. The IdP round-trip above still ran
	// — it rotated the refresh_token and surfaced IdP-side revocation (a rejected
	// refresh deletes the session above) — its access_token simply no longer ships.
	// Mint from the row's canonical user_id; degrade to the IdP token only if the
	// HS256 key is unconfigured. (AUTH.md §5.)
	accessToken, expiresIn := tr.AccessToken, tr.ExpiresIn
	if tok, ein, mErr := s.mintSessionToken(sess.UserID, id, "full"); mErr != nil {
		slog.Error("mint session token failed", "err", mErr)
	} else {
		accessToken, expiresIn = tok, ein
	}

	writeJSON(w, http.StatusOK, refreshResp{
		AccessToken: accessToken,
		ExpiresIn:   expiresIn,
		User:        user,
		DeviceName:  sess.DeviceName,
	})
}

// refreshSelfSession renews a cdrop self-signed session without contacting the
// IdP: it mints a fresh access token from the row. Persistent "self" sessions
// slide their inactivity window; one-time "guest" borrows do not (their fixed
// expires_at caps the borrow at QR_GUEST_TTL). Identity is enriched from the
// accounts row when present, else falls back to the bare user_id.
func (s *Server) refreshSelfSession(w http.ResponseWriter, r *http.Request, sess db.WebSession, rawCookie string, now time.Time) {
	token, expiresIn, err := s.mintSessionToken(sess.UserID, sess.ID, sess.Scope)
	if err != nil {
		slog.Error("mint session token failed", "err", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "mint failed"})
		return
	}
	if sess.Kind == "self" {
		newExp := now.Add(time.Duration(s.cfg.QRPersistTTLHours) * time.Hour)
		if err := s.queries.SlideWebSession(r.Context(), db.SlideWebSessionParams{
			LastUsedAt: now.Unix(),
			ExpiresAt:  newExp.Unix(),
			ID:         sess.ID,
		}); err != nil {
			slog.Error("slide web session failed", "err", err)
		}
		setSessionCookie(w, rawCookie)
	}
	user := userResp{ID: sess.UserID, Name: sess.UserID}
	if acct, err := s.queries.GetAccount(r.Context(), sess.UserID); err == nil {
		if acct.DisplayName != "" {
			user.Name = acct.DisplayName
		}
		user.Avatar = acct.AvatarUrl
	}
	writeJSON(w, http.StatusOK, refreshResp{
		AccessToken: token,
		ExpiresIn:   expiresIn,
		User:        user,
		DeviceName:  sess.DeviceName,
	})
}

var oidcHTTPClient = &http.Client{Timeout: 10 * time.Second}

func postOIDCToken(ctx interface{ Done() <-chan struct{} }, tokenURL string, form url.Values) (*tokenResp, error) {
	req, err := http.NewRequest(http.MethodPost, tokenURL, strings.NewReader(form.Encode()))
	if err != nil {
		return nil, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
	req.Header.Set("Accept", "application/json")
	resp, err := oidcHTTPClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("oidc unreachable: %w", err)
	}
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	if resp.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("oidc status %d: %s", resp.StatusCode, truncate(string(body), 200))
	}

	var tr tokenResp
	if err := json.Unmarshal(body, &tr); err != nil {
		return nil, fmt.Errorf("oidc malformed: %w", err)
	}
	if tr.AccessToken == "" {
		return nil, fmt.Errorf("oidc returned no access_token")
	}
	return &tr, nil
}

// tokenIdentity is what cdrop reads out of an OIDC id_token at login: the display
// identity plus the thin-account fields — match_key (for the admin-gated migration
// relink) and roles (the groups claim cache). AUTH.md §2.1.
type tokenIdentity struct {
	User     userResp
	MatchKey string
	Roles    []string
}

// extractIdentity parses {sub, preferred_username | name | email, avatar | picture,
// matchClaim, groups} from the id_token if available, else the access_token. The
// signature is NOT verified here — callers verify separately around it: at login
// (handleAuthExchange) the id_token signature is checked via VerifyIDToken before
// the extracted subject is trusted to mint a self token; at refresh the IdP just
// authenticated the refresh_token over a TLS back-channel, so the token it returns
// is trusted by transport. Either way the extracted fields are sound. (AUTH.md §5.)
func extractIdentity(idToken, accessToken, matchClaim string) (tokenIdentity, error) {
	pick := idToken
	if pick == "" {
		pick = accessToken
	}
	parsed, err := jwt.ParseSigned(pick, []jose.SignatureAlgorithm{
		jose.RS256, jose.RS384, jose.RS512,
		jose.ES256, jose.ES384, jose.ES512,
		jose.HS256,
	})
	if err != nil {
		return tokenIdentity{}, err
	}
	var std jwt.Claims
	custom := map[string]any{}
	if err := parsed.UnsafeClaimsWithoutVerification(&std, &custom); err != nil {
		return tokenIdentity{}, err
	}
	id := tokenIdentity{User: userResp{ID: std.Subject}}
	if v, ok := custom["preferred_username"].(string); ok && v != "" {
		id.User.Name = v
	} else if v, ok := custom["name"].(string); ok && v != "" {
		id.User.Name = v
	} else if v, ok := custom["email"].(string); ok && v != "" {
		id.User.Name = v
	} else {
		id.User.Name = id.User.ID
	}
	// Casdoor 的 OIDC id_token 暴露 avatar(自定义) 与 picture(标准 claim) 两种字段；
	// 不同 IdP 实现各异，所以都查一遍。值为空字符串视为未提供，前端落到字母方块回退。
	if v, ok := custom["avatar"].(string); ok && v != "" {
		id.User.Avatar = v
	} else if v, ok := custom["picture"].(string); ok && v != "" {
		id.User.Avatar = v
	}
	// match_key feeds the admin-gated cross-provider relink; pick the configured
	// claim (default email). Absent / non-string → empty, which simply never matches.
	if matchClaim != "" {
		if v, ok := custom[matchClaim].(string); ok {
			id.MatchKey = v
		}
	}
	if g, ok := custom["groups"].([]any); ok {
		for _, item := range g {
			if r, ok := item.(string); ok && r != "" {
				id.Roles = append(id.Roles, r)
			}
		}
	}
	return id, nil
}

// upsertAccount refreshes the caller's thin accounts row from their OIDC identity
// (AUTH.md §2.1). Best-effort — a write failure must never fail the login itself.
func (s *Server) upsertAccount(ctx context.Context, id tokenIdentity) {
	if id.User.ID == "" {
		return
	}
	now := time.Now().Unix()
	if err := s.queries.UpsertAccount(ctx, db.UpsertAccountParams{
		UserID:      id.User.ID,
		MatchKey:    id.MatchKey,
		DisplayName: id.User.Name,
		AvatarUrl:   id.User.Avatar,
		Roles:       strings.Join(id.Roles, ","),
		Provider:    "oidc",
		CreatedAt:   now,
		LastLoginAt: now,
	}); err != nil {
		slog.Error("upsert account failed", "err", err, "user", id.User.ID)
	}
}

func truncate(s string, n int) string {
	if len(s) <= n {
		return s
	}
	return s[:n] + "…"
}
