package sso

import (
	"context"
	"fmt"
	"strings"
	"time"

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

	"github.com/phishingclub/phishingclub/errs"
	"github.com/phishingclub/phishingclub/model"
)

// oidcNetworkTimeout bounds the outbound calls to the identity provider
// (discovery, token exchange and JWKS) so a slow or unreachable provider cannot
// hang a request goroutine.
const oidcNetworkTimeout = 15 * time.Second

// OIDCClient is a configured generic OpenID Connect relying party. It holds the
// discovered provider, the oauth2 config and the ID token verifier so logins and
// callbacks can be served without re running discovery each request.
type OIDCClient struct {
	Provider  *oidc.Provider
	OAuth2    *oauth2.Config
	Verifier  *oidc.IDTokenVerifier
	ACRValues string
}

// OIDCUserInfo holds the identity claims taken from a verified ID token.
type OIDCUserInfo struct {
	Subject       string
	Email         string
	EmailVerified bool
	Name          string
}

// NewOIDCClient discovers the provider and builds the relying party from the SSO
// configuration. Discovery performs a network request to the issuer.
func NewOIDCClient(ctx context.Context, sso *model.SSOOption) (*OIDCClient, error) {
	if !sso.Enabled {
		return nil, errs.Wrap(errs.ErrSSODisabled)
	}
	issuer := strings.TrimSpace(sso.IssuerURL.String())
	if issuer == "" {
		return nil, errs.Wrap(errors.New("missing OIDC issuer URL"))
	}
	clientID := sso.ClientID.String()
	ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout)
	defer cancel()
	provider, err := oidc.NewProvider(ctx, issuer)
	if err != nil {
		return nil, errs.Wrap(errors.Errorf("failed to discover OIDC provider: %s", err))
	}
	oauth2Config := &oauth2.Config{
		ClientID:     clientID,
		ClientSecret: sso.ClientSecret.String(),
		Endpoint:     provider.Endpoint(),
		RedirectURL:  sso.RedirectURL.String(),
		Scopes:       strings.Fields(sso.ScopesOrDefault()),
	}
	verifier := provider.Verifier(&oidc.Config{
		ClientID: clientID,
	})
	return &OIDCClient{
		Provider:  provider,
		OAuth2:    oauth2Config,
		Verifier:  verifier,
		ACRValues: strings.TrimSpace(sso.ACRValues.String()),
	}, nil
}

// AuthCodeURL builds the authorization request URL. The nonce binds the ID token
// to this login, PKCE protects the code exchange and acr_values requests a given
// authentication context such as multi factor authentication.
func (c *OIDCClient) AuthCodeURL(state string, nonce string, verifier string) string {
	opts := []oauth2.AuthCodeOption{
		oidc.Nonce(nonce),
		oauth2.S256ChallengeOption(verifier),
	}
	if c.ACRValues != "" {
		opts = append(opts, oauth2.SetAuthURLParam("acr_values", c.ACRValues))
	}
	return c.OAuth2.AuthCodeURL(state, opts...)
}

// Exchange swaps the authorization code for tokens, verifies the ID token and
// returns the identity claims. It enforces the nonce, that the email is verified
// and, when configured, that the returned authentication context matches.
func (c *OIDCClient) Exchange(
	ctx context.Context,
	code string,
	nonce string,
	verifier string,
) (*OIDCUserInfo, error) {
	ctx, cancel := context.WithTimeout(ctx, oidcNetworkTimeout)
	defer cancel()
	token, err := c.OAuth2.Exchange(ctx, code, oauth2.VerifierOption(verifier))
	if err != nil {
		return nil, errs.Wrap(err)
	}
	rawIDToken, ok := token.Extra("id_token").(string)
	if !ok || rawIDToken == "" {
		return nil, errs.Wrap(errors.New("no id_token in token response"))
	}
	idToken, err := c.Verifier.Verify(ctx, rawIDToken)
	if err != nil {
		return nil, errs.Wrap(err)
	}
	if idToken.Nonce != nonce {
		return nil, errs.Wrap(errors.New("OIDC nonce mismatch"))
	}
	var claims struct {
		Subject       string   `json:"sub"`
		Email         string   `json:"email"`
		EmailVerified flexBool `json:"email_verified"`
		Name          string   `json:"name"`
		ACR           string   `json:"acr"`
	}
	if err := idToken.Claims(&claims); err != nil {
		return nil, errs.Wrap(err)
	}
	if claims.Email == "" {
		return nil, errs.Wrap(errs.ErrSSONoEmail)
	}
	// a generic provider can hand out self set, unverified emails so the email
	// is only trusted for account matching when the provider has verified it
	if !bool(claims.EmailVerified) {
		return nil, errs.Wrap(errs.ErrSSOEmailNotVerified)
	}
	if c.ACRValues != "" && !acrSatisfied(claims.ACR, c.ACRValues) {
		return nil, errs.Wrap(fmt.Errorf(
			"%w (provider returned acr %q, required one of %q)",
			errs.ErrSSOMFARequired, claims.ACR, c.ACRValues,
		))
	}
	return &OIDCUserInfo{
		Subject:       claims.Subject,
		Email:         claims.Email,
		EmailVerified: bool(claims.EmailVerified),
		Name:          claims.Name,
	}, nil
}

// NewPKCEVerifier returns a fresh high entropy PKCE code verifier (RFC 7636).
func NewPKCEVerifier() string {
	return oauth2.GenerateVerifier()
}

// acrSatisfied reports whether the acr returned by the provider is one of the
// space separated values that were requested.
func acrSatisfied(returned string, requested string) bool {
	returned = strings.TrimSpace(returned)
	if returned == "" {
		return false
	}
	for _, want := range strings.Fields(requested) {
		if returned == want {
			return true
		}
	}
	return false
}

// flexBool accepts the email_verified claim as either a JSON boolean or a
// quoted string, since providers differ, and fails closed on anything else.
type flexBool bool

func (b *flexBool) UnmarshalJSON(data []byte) error {
	switch strings.Trim(string(data), `"`) {
	case "true":
		*b = true
	case "false", "", "null":
		*b = false
	default:
		return fmt.Errorf("invalid boolean value for claim: %s", string(data))
	}
	return nil
}
