// Package oauth provides an OAuth 2.0 token client for AuthGate.
//
// It encapsulates all HTTP request/response logic for Device Code,
// Authorization Code + PKCE, Client Credentials, Refresh, Revoke,
// Introspect, and UserInfo flows. This is a pure HTTP client layer
// that does not handle storage, polling, or UI interactions.
package oauth

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

	retry "github.com/appleboy/go-httpretry"
)

// maxResponseBytes caps the amount of data read from any single HTTP response
// to prevent denial-of-service via unbounded response bodies.
const maxResponseBytes = 1 << 20 // 1 MB

// errResponseTooLarge is returned when a server response exceeds maxResponseBytes.
var errResponseTooLarge = fmt.Errorf("oauth: response body exceeds %d bytes", maxResponseBytes)

// limitedBody returns an io.Reader that reads up to maxResponseBytes+1 from r.
// Reading one extra byte lets us distinguish a body of exactly maxResponseBytes
// (valid, leaving N == 1) from one that exceeds the cap (reading at least
// maxResponseBytes+1 bytes, leaving N == 0).
func limitedBody(r io.Reader) *io.LimitedReader {
	return &io.LimitedReader{R: r, N: maxResponseBytes + 1}
}

// checkLimitExceeded returns errResponseTooLarge when the LimitedReader was
// fully exhausted (N == 0), meaning the response body exceeded maxResponseBytes.
// This must be called even on a successful decode: a body that is exactly
// maxResponseBytes+1 of valid JSON decodes cleanly while still violating the
// cap, so relying solely on decode errors would let oversized payloads pass.
// op identifies the calling operation (e.g., "userinfo") so the resulting
// error carries enough context for debugging. When decodeErr is non-nil it is
// wrapped via %w alongside errResponseTooLarge so callers can use errors.Is/As
// against either sentinel.
func checkLimitExceeded(lr *io.LimitedReader, op string, decodeErr error) error {
	if lr.N == 0 {
		if decodeErr != nil {
			return fmt.Errorf("%w (%s): %w", errResponseTooLarge, op, decodeErr)
		}
		return fmt.Errorf("%w (%s)", errResponseTooLarge, op)
	}
	return decodeErr
}

// OAuth 2.0 grant types (RFC 6749 / RFC 8628).
const (
	// GrantTypeAuthorizationCode is the Authorization Code grant (RFC 6749 §4.1).
	GrantTypeAuthorizationCode = "authorization_code"
	// GrantTypeClientCredentials is the Client Credentials grant (RFC 6749 §4.4).
	GrantTypeClientCredentials = "client_credentials"
	// GrantTypeRefreshToken exchanges a refresh token for a new access token (RFC 6749 §6).
	GrantTypeRefreshToken = "refresh_token"
	// GrantTypeDeviceCode is the Device Authorization grant (RFC 8628 §3.4).
	GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
)

// PKCEMethodS256 is the SHA-256 PKCE code-challenge method (RFC 7636 §4.3).
const PKCEMethodS256 = "S256"

// OAuth 2.0 error codes used in error responses
// (RFC 6749 §5.2, RFC 6750 §3.1, RFC 8628 §3.5).
const (
	// ErrCodeAuthorizationPending signals the user has not yet completed device authorization (RFC 8628 §3.5).
	ErrCodeAuthorizationPending = "authorization_pending"
	// ErrCodeSlowDown asks the client to increase its device-code polling interval (RFC 8628 §3.5).
	ErrCodeSlowDown = "slow_down"
	// ErrCodeExpiredToken indicates the device_code has expired before authorization completed (RFC 8628 §3.5).
	ErrCodeExpiredToken = "expired_token"
	// ErrCodeAccessDenied signals the user denied the authorization request (RFC 6749 §4.1.2.1).
	ErrCodeAccessDenied = "access_denied"
	// ErrCodeInvalidGrant indicates the grant (auth code, refresh token, etc.) is invalid, expired, or revoked (RFC 6749 §5.2).
	ErrCodeInvalidGrant = "invalid_grant"
	// ErrCodeInvalidToken indicates the access token is invalid, expired, or revoked (RFC 6750 §3.1).
	ErrCodeInvalidToken = "invalid_token"
)

// Token represents an OAuth 2.0 token response (RFC 6749 §5.1).
type Token struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token,omitempty"`
	TokenType    string `json:"token_type"`
	ExpiresIn    int    `json:"expires_in"`
	Scope        string `json:"scope,omitempty"`
	IDToken      string `json:"id_token,omitempty"`

	// ExpiresAt is computed from ExpiresIn at response parse time.
	ExpiresAt time.Time `json:"-"`
}

// IsExpired reports whether the token has expired.
func (t *Token) IsExpired() bool {
	return !t.ExpiresAt.IsZero() && time.Now().After(t.ExpiresAt)
}

// IsValid reports whether the token has a non-empty access token and is not expired.
func (t *Token) IsValid() bool {
	return t.AccessToken != "" && !t.IsExpired()
}

// DeviceAuth represents a device authorization response (RFC 8628 §3.2).
type DeviceAuth struct {
	DeviceCode              string `json:"device_code"`
	UserCode                string `json:"user_code"`
	VerificationURI         string `json:"verification_uri"`
	VerificationURIComplete string `json:"verification_uri_complete,omitempty"`
	ExpiresIn               int    `json:"expires_in"`
	Interval                int    `json:"interval"`
}

// IntrospectionResult represents a token introspection response (RFC 7662 §2.2).
type IntrospectionResult struct {
	Active    bool   `json:"active"`
	Scope     string `json:"scope,omitempty"`
	ClientID  string `json:"client_id,omitempty"`
	Username  string `json:"username,omitempty"`
	TokenType string `json:"token_type,omitempty"`
	Exp       int64  `json:"exp,omitempty"`
	Iat       int64  `json:"iat,omitempty"`
	Sub       string `json:"sub,omitempty"`
	Iss       string `json:"iss,omitempty"`
	Jti       string `json:"jti,omitempty"`
}

// UserInfo represents the OIDC UserInfo response (OIDC Core 1.0 §5.3).
type UserInfo struct {
	Sub               string `json:"sub"`
	Iss               string `json:"iss,omitempty"`
	Name              string `json:"name,omitempty"`
	PreferredUsername string `json:"preferred_username,omitempty"`
	Email             string `json:"email,omitempty"`
	EmailVerified     bool   `json:"email_verified,omitempty"`
	Picture           string `json:"picture,omitempty"`
	UpdatedAt         int64  `json:"updated_at,omitempty"`
	SubjectType       string `json:"subject_type,omitempty"`
}

// TokenInfo represents the tokeninfo response from AuthGate.
type TokenInfo struct {
	Active      bool   `json:"active"`
	UserID      string `json:"user_id"`
	ClientID    string `json:"client_id"`
	Scope       string `json:"scope"`
	Exp         int64  `json:"exp"`
	Iss         string `json:"iss"`
	SubjectType string `json:"subject_type"`
}

// Error represents an OAuth 2.0 error response (RFC 6749 §5.2).
type Error struct {
	Code        string `json:"error"`
	Description string `json:"error_description,omitempty"`
	StatusCode  int    `json:"-"`
}

// Error implements the error interface.
func (e *Error) Error() string {
	if e.Description != "" {
		return "oauth: " + e.Code + ": " + e.Description
	}
	return "oauth: " + e.Code
}

// Endpoints holds all OAuth 2.0 endpoint URLs.
type Endpoints struct {
	TokenURL               string
	AuthorizeURL           string
	DeviceAuthorizationURL string
	RevocationURL          string
	IntrospectionURL       string
	UserinfoURL            string
	TokenInfoURL           string
}

// Client is an OAuth 2.0 HTTP client.
type Client struct {
	clientID     string
	clientSecret string
	endpoints    Endpoints
	httpClient   *retry.Client
}

// Option configures a Client.
type Option func(*Client)

// WithClientSecret sets the client secret for confidential clients.
func WithClientSecret(secret string) Option {
	return func(c *Client) {
		c.clientSecret = secret
	}
}

// WithHTTPClient sets a custom retry HTTP client.
// If nil is provided, the default client is kept.
func WithHTTPClient(httpClient *retry.Client) Option {
	return func(c *Client) {
		if httpClient != nil {
			c.httpClient = httpClient
		}
	}
}

// NewClient creates a new OAuth 2.0 client.
// A default retry HTTP client is created only when no client is provided via WithHTTPClient.
func NewClient(clientID string, endpoints Endpoints, opts ...Option) (*Client, error) {
	c := &Client{
		clientID:  clientID,
		endpoints: endpoints,
	}
	for _, opt := range opts {
		if opt != nil {
			opt(c)
		}
	}

	if c.httpClient == nil {
		httpClient, err := retry.NewRealtimeClient(retry.WithNoLogging())
		if err != nil {
			return nil, fmt.Errorf("oauth: create http client: %w", err)
		}
		c.httpClient = httpClient
	}

	return c, nil
}

// ClientID returns the client ID.
func (c *Client) ClientID() string {
	return c.clientID
}

// Endpoints returns the endpoint URLs.
func (c *Client) Endpoints() Endpoints {
	return c.endpoints
}

// RequestDeviceCode initiates a device authorization request (RFC 8628 §3.1).
func (c *Client) RequestDeviceCode(ctx context.Context, scopes []string) (*DeviceAuth, error) {
	if c.endpoints.DeviceAuthorizationURL == "" {
		return nil, &Error{
			Code:        "invalid_request",
			Description: "device authorization endpoint not configured",
		}
	}

	data := url.Values{
		"client_id": {c.clientID},
	}
	if len(scopes) > 0 {
		data.Set("scope", strings.Join(scopes, " "))
	}

	var auth DeviceAuth
	if err := c.postForm(ctx, c.endpoints.DeviceAuthorizationURL, data, &auth); err != nil {
		return nil, err
	}
	return &auth, nil
}

// ExchangeDeviceCode exchanges a device code for tokens (RFC 8628 §3.4).
func (c *Client) ExchangeDeviceCode(ctx context.Context, deviceCode string) (*Token, error) {
	data := url.Values{
		"grant_type":  {GrantTypeDeviceCode},
		"device_code": {deviceCode},
		"client_id":   {c.clientID},
	}

	return c.tokenRequest(ctx, data)
}

// ExchangeAuthCode exchanges an authorization code for tokens (RFC 6749 §4.1.3).
func (c *Client) ExchangeAuthCode(
	ctx context.Context,
	code, redirectURI, codeVerifier string,
) (*Token, error) {
	data := url.Values{
		"grant_type":   {GrantTypeAuthorizationCode},
		"code":         {code},
		"redirect_uri": {redirectURI},
		"client_id":    {c.clientID},
	}

	if codeVerifier != "" {
		data.Set("code_verifier", codeVerifier)
	}
	if c.clientSecret != "" {
		data.Set("client_secret", c.clientSecret)
	}

	return c.tokenRequest(ctx, data)
}

// ClientCredentials requests a token using client credentials (RFC 6749 §4.4).
func (c *Client) ClientCredentials(ctx context.Context, scopes []string) (*Token, error) {
	data := url.Values{
		"grant_type": {GrantTypeClientCredentials},
		"client_id":  {c.clientID},
	}
	if c.clientSecret != "" {
		data.Set("client_secret", c.clientSecret)
	}
	if len(scopes) > 0 {
		data.Set("scope", strings.Join(scopes, " "))
	}

	return c.tokenRequest(ctx, data)
}

// RefreshToken exchanges a refresh token for new tokens (RFC 6749 §6).
func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*Token, error) {
	data := url.Values{
		"grant_type":    {GrantTypeRefreshToken},
		"refresh_token": {refreshToken},
		"client_id":     {c.clientID},
	}

	return c.tokenRequest(ctx, data)
}

// Revoke revokes a token (RFC 7009).
func (c *Client) Revoke(ctx context.Context, token string) error {
	if c.endpoints.RevocationURL == "" {
		return &Error{Code: "invalid_request", Description: "revocation endpoint not configured"}
	}

	data := url.Values{
		"token": {token},
	}

	resp, err := c.httpClient.Post(ctx, c.endpoints.RevocationURL,
		retry.WithBody("application/x-www-form-urlencoded", strings.NewReader(data.Encode())),
	)
	if err != nil {
		return fmt.Errorf("oauth: revoke request: %w", err)
	}
	defer resp.Body.Close()

	// RFC 7009 §2.2: The server responds with 200 for both success and invalid tokens.
	// However, non-200 responses (e.g., 500) indicate a server error.
	if resp.StatusCode != http.StatusOK {
		return parseErrorResponse(resp)
	}

	return nil
}

// Introspect introspects a token (RFC 7662).
func (c *Client) Introspect(ctx context.Context, token string) (*IntrospectionResult, error) {
	if c.endpoints.IntrospectionURL == "" {
		return nil, &Error{
			Code:        "invalid_request",
			Description: "introspection endpoint not configured",
		}
	}

	data := url.Values{
		"token":     {token},
		"client_id": {c.clientID},
	}
	if c.clientSecret != "" {
		data.Set("client_secret", c.clientSecret)
	}

	var result IntrospectionResult
	if err := c.postForm(ctx, c.endpoints.IntrospectionURL, data, &result); err != nil {
		return nil, err
	}
	return &result, nil
}

// UserInfo fetches user information from the UserInfo endpoint (OIDC Core 1.0 §5.3).
func (c *Client) UserInfo(ctx context.Context, accessToken string) (*UserInfo, error) {
	if c.endpoints.UserinfoURL == "" {
		return nil, &Error{Code: "invalid_request", Description: "userinfo endpoint not configured"}
	}

	var info UserInfo
	if err := c.getJSON(ctx, c.endpoints.UserinfoURL, accessToken, "userinfo", &info); err != nil {
		return nil, err
	}
	return &info, nil
}

// TokenInfoRequest fetches token information from the tokeninfo endpoint.
func (c *Client) TokenInfoRequest(ctx context.Context, accessToken string) (*TokenInfo, error) {
	if c.endpoints.TokenInfoURL == "" {
		return nil, &Error{
			Code:        "invalid_request",
			Description: "tokeninfo endpoint not configured",
		}
	}

	var info TokenInfo
	if err := c.getJSON(
		ctx,
		c.endpoints.TokenInfoURL,
		accessToken,
		"tokeninfo",
		&info,
	); err != nil {
		return nil, err
	}
	return &info, nil
}

// getJSON sends an authenticated GET request and decodes a JSON response,
// applying the same response-size cap as postForm. op identifies the operation
// (e.g., "userinfo", "tokeninfo") for error messages and oversize reporting.
func (c *Client) getJSON(ctx context.Context, endpoint, accessToken, op string, result any) error {
	resp, err := c.httpClient.Get(ctx, endpoint,
		retry.WithHeader("Authorization", "Bearer "+accessToken),
	)
	if err != nil {
		return fmt.Errorf("oauth: %s request: %w", op, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return parseErrorResponse(resp)
	}

	lr := limitedBody(resp.Body)
	decodeErr := json.NewDecoder(lr).Decode(result)
	if decodeErr != nil {
		decodeErr = fmt.Errorf("oauth: decode %s response: %w", op, decodeErr)
	}
	return checkLimitExceeded(lr, op, decodeErr)
}

// tokenRequest sends a token request and parses the response.
func (c *Client) tokenRequest(ctx context.Context, data url.Values) (*Token, error) {
	if c.endpoints.TokenURL == "" {
		return nil, &Error{Code: "invalid_request", Description: "token endpoint not configured"}
	}

	var tok Token
	if err := c.postForm(ctx, c.endpoints.TokenURL, data, &tok); err != nil {
		return nil, err
	}

	// Compute ExpiresAt from ExpiresIn
	if tok.ExpiresIn > 0 {
		tok.ExpiresAt = time.Now().Add(time.Duration(tok.ExpiresIn) * time.Second)
	}

	return &tok, nil
}

// postForm sends a POST request with form-encoded body and decodes the JSON response.
func (c *Client) postForm(ctx context.Context, endpoint string, data url.Values, result any) error {
	resp, err := c.httpClient.Post(ctx, endpoint,
		retry.WithBody("application/x-www-form-urlencoded", strings.NewReader(data.Encode())),
	)
	if err != nil {
		return fmt.Errorf("oauth: request to %s: %w", endpoint, err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return parseErrorResponse(resp)
	}

	lr := limitedBody(resp.Body)
	decodeErr := json.NewDecoder(lr).Decode(result)
	if decodeErr != nil {
		decodeErr = fmt.Errorf("oauth: decode response from %s: %w", endpoint, decodeErr)
	}
	return checkLimitExceeded(lr, endpoint, decodeErr)
}

// parseErrorResponse reads an OAuth error response body.
func parseErrorResponse(resp *http.Response) error {
	lr := limitedBody(resp.Body)
	body, err := io.ReadAll(lr)
	if err != nil {
		return &Error{
			Code:        "server_error",
			Description: "failed to read error response",
			StatusCode:  resp.StatusCode,
		}
	}

	// If the read exceeded the limit (N == 0 means the extra sentinel byte
	// was consumed), return a dedicated error instead of propagating a huge
	// truncated body in the error description.
	if lr.N == 0 {
		return &Error{
			Code:        "server_error",
			Description: "error response body exceeds size limit",
			StatusCode:  resp.StatusCode,
		}
	}

	var oauthErr Error
	if json.Unmarshal(body, &oauthErr) == nil && oauthErr.Code != "" {
		oauthErr.StatusCode = resp.StatusCode
		return &oauthErr
	}

	return &Error{
		Code:        http.StatusText(resp.StatusCode),
		Description: string(body),
		StatusCode:  resp.StatusCode,
	}
}
