package httpapi

import (
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"log/slog"
	"net/http"
	"strings"
	"time"

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

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

// shortcutScope is the single scope a shortcut token is granted; the route layer
// only lets a token carrying it reach the clipboard endpoints (see server.go).
const shortcutScope = "clipboard"

// requireScope gates a route group that scoped shortcut tokens may also reach: a
// scoped token must carry the named scope, while a full login session always
// passes (it has no scope restriction).
func requireScope(scope string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			claims, ok := jwtauth.ClaimsFromContext(r.Context())
			if !ok {
				writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
				return
			}
			if claims.Scoped() && !claims.HasScope(scope) {
				writeJSON(w, http.StatusForbidden, map[string]string{"error": "insufficient scope"})
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

// rejectScoped blocks scoped shortcut tokens outright — for every route only a
// full login session may touch. A leaked clipboard token thus reaches nothing
// here: not the device list, transfers, signalling, nor token self-management.
func rejectScoped(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, ok := jwtauth.ClaimsFromContext(r.Context())
		if !ok {
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
			return
		}
		if claims.Scoped() {
			writeJSON(w, http.StatusForbidden, map[string]string{"error": "session token required"})
			return
		}
		next.ServeHTTP(w, r)
	})
}

// requireFullSession rejects restricted guest sessions (scan-login borrow): they
// may transfer files but not reach account-management surfaces — removing devices,
// approving more devices, or minting / listing / revoking long-lived tokens. A
// borrowed device thus can't escalate. Full / OIDC / dev sessions pass; scoped
// shortcut tokens are already blocked upstream by rejectScoped (AUTH.md §3.2).
func requireFullSession(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		claims, ok := jwtauth.ClaimsFromContext(r.Context())
		if !ok {
			writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "claims missing"})
			return
		}
		if claims.Guest() {
			writeJSON(w, http.StatusForbidden, map[string]string{"error": "full session required"})
			return
		}
		next.ServeHTTP(w, r)
	})
}

// 这些端点只由登录会话调用（路由层 rejectScoped 守门）——用 token 不能自管 token，
// 防止泄漏的快捷指令 token 自我续期或越权。

type shortcutIssueReq struct {
	Label string `json:"label"`
}

type shortcutIssueResp struct {
	// Token 仅在签发时返回这一次，服务端不留可还原副本（只存 jti 等元数据）。
	Token     string   `json:"token"`
	Jti       string   `json:"jti"`
	Label     string   `json:"label"`
	Scopes    []string `json:"scopes"`
	ExpiresAt int64    `json:"expires_at"`
}

type shortcutTokenView struct {
	Jti        string   `json:"jti"`
	Label      string   `json:"label"`
	Scopes     []string `json:"scopes"`
	CreatedAt  int64    `json:"created_at"`
	ExpiresAt  int64    `json:"expires_at"`
	LastUsedAt *int64   `json:"last_used_at"`
	Revoked    bool     `json:"revoked"`
}

// handleShortcutIssue mints a long-lived, clipboard-scoped HS256 token, stores
// its metadata, and returns the token string once. Disabled (503) when no HS256
// secret is configured.
func (s *Server) handleShortcutIssue(w http.ResponseWriter, r *http.Request) {
	if len(s.cfg.HS256Secret) == 0 {
		writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "shortcut tokens not enabled"})
		return
	}
	claims, _ := jwtauth.ClaimsFromContext(r.Context())

	var req shortcutIssueReq
	if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&req); err != nil {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
		return
	}
	label := strings.TrimSpace(req.Label)
	if label == "" {
		label = "iOS Shortcut"
	}
	if len(label) > 64 {
		label = label[:64]
	}

	now := time.Now()
	active, err := s.queries.CountActiveShortcutTokensByUser(r.Context(), db.CountActiveShortcutTokensByUserParams{
		UserID:    claims.UserID,
		ExpiresAt: now.Unix(),
	})
	if err != nil {
		slog.Error("count shortcut tokens failed", "err", err, "user", claims.UserID)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
		return
	}
	if active >= int64(s.cfg.ShortcutMaxPerUser) {
		writeJSON(w, http.StatusConflict, map[string]string{"error": "too many active tokens"})
		return
	}

	jti, err := randomTokenID()
	if err != nil {
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "id gen"})
		return
	}
	expires := now.Add(time.Duration(s.cfg.ShortcutTokenTTLDays) * 24 * time.Hour)

	token, err := signShortcutToken(s.cfg.HS256Secret, claims.UserID, jti, shortcutScope, now, expires)
	if err != nil {
		slog.Error("sign shortcut token failed", "err", err)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "sign"})
		return
	}

	if err := s.queries.InsertShortcutToken(r.Context(), db.InsertShortcutTokenParams{
		Jti:       jti,
		UserID:    claims.UserID,
		Label:     label,
		Scopes:    shortcutScope,
		CreatedAt: now.Unix(),
		ExpiresAt: expires.Unix(),
	}); err != nil {
		slog.Error("insert shortcut token failed", "err", err, "user", claims.UserID)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
		return
	}

	writeJSON(w, http.StatusCreated, shortcutIssueResp{
		Token:     token,
		Jti:       jti,
		Label:     label,
		Scopes:    []string{shortcutScope},
		ExpiresAt: expires.Unix(),
	})
}

// handleShortcutList returns the caller's tokens (metadata only, never the token
// string).
func (s *Server) handleShortcutList(w http.ResponseWriter, r *http.Request) {
	claims, _ := jwtauth.ClaimsFromContext(r.Context())
	rows, err := s.queries.ListShortcutTokensByUser(r.Context(), claims.UserID)
	if err != nil {
		slog.Error("list shortcut tokens failed", "err", err, "user", claims.UserID)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
		return
	}
	out := make([]shortcutTokenView, 0, len(rows))
	for _, t := range rows {
		out = append(out, shortcutTokenView{
			Jti:        t.Jti,
			Label:      t.Label,
			Scopes:     strings.Fields(t.Scopes),
			CreatedAt:  t.CreatedAt,
			ExpiresAt:  t.ExpiresAt,
			LastUsedAt: t.LastUsedAt,
			Revoked:    t.Revoked != 0,
		})
	}
	writeJSON(w, http.StatusOK, map[string]any{"tokens": out})
}

// handleShortcutRevoke flips the revoked flag (scoped to the caller's user_id so
// one user can't revoke another's). verifyHS256 reads the flag on every request,
// so revocation takes effect on the token's next use.
func (s *Server) handleShortcutRevoke(w http.ResponseWriter, r *http.Request) {
	claims, _ := jwtauth.ClaimsFromContext(r.Context())
	jti := chi.URLParam(r, "jti")
	if jti == "" {
		writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing jti"})
		return
	}
	n, err := s.queries.RevokeShortcutToken(r.Context(), db.RevokeShortcutTokenParams{
		Jti:    jti,
		UserID: claims.UserID,
	})
	if err != nil {
		slog.Error("revoke shortcut token failed", "err", err, "user", claims.UserID)
		writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "db"})
		return
	}
	if n == 0 {
		writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// signShortcutToken mints a compact HS256 JWT: sub=userID, jti, scope (space-
// delimited OAuth-style, informational — the store row is authoritative), iat,
// exp. The shared HS256 secret is the same one verifyHS256 checks against.
func signShortcutToken(secret, userID, jti, scope string, iat, exp time.Time) (string, error) {
	sig, err := jose.NewSigner(
		jose.SigningKey{Algorithm: jose.HS256, Key: jwtauth.DeriveHS256Key(secret)},
		(&jose.SignerOptions{}).WithType("JWT"),
	)
	if err != nil {
		return "", err
	}
	std := jwt.Claims{
		Subject:  userID,
		ID:       jti,
		IssuedAt: jwt.NewNumericDate(iat),
		Expiry:   jwt.NewNumericDate(exp),
	}
	return jwt.Signed(sig).Claims(std).Claims(map[string]any{"scope": scope}).Serialize()
}

// randomTokenID returns a 128-bit URL-safe random id for the jti.
func randomTokenID() (string, error) {
	var b [16]byte
	if _, err := rand.Read(b[:]); err != nil {
		return "", err
	}
	return base64.RawURLEncoding.EncodeToString(b[:]), nil
}
