package services

import (
	"errors"
	"fmt"
	"os"
	"strings"
	"sync"
	"time"

	"astrology-api/repositories"

	"github.com/golang-jwt/jwt/v5"
)

//////////////////////////////////////////////////////////////
// Google Sign-In (OAuth) ID Tokens
//////////////////////////////////////////////////////////////

// Two different tokens can arrive at the social login endpoints, and only one of
// them is a Firebase ID token.
//
//	Firebase ID token       iss = https://securetoken.google.com/<project id>
//	                        aud = <project id>
//	                        has a "firebase" claim naming the sign-in provider
//
//	Google Sign-In token    iss = https://accounts.google.com
//	                        aud = an OAuth client id (…apps.googleusercontent.com)
//	                        no "firebase" claim
//
// The native Google Sign-In SDKs hand the app the second kind. The Firebase flow
// expects the app to exchange it — signInWithCredential, then getIdToken — and
// send the first kind. An app that skips the exchange used to fail here with
// "token signed with an unknown key", because Google signs the two kinds with
// different certificate sets.
//
// Accepting both keeps such an app working. What it must not do is accept the
// second kind loosely: Google signs the OAuth tokens of every client on earth
// with these keys, so a signature check alone proves nothing about who the token
// was minted for. Without an audience allowlist, a token issued for somebody
// else's app would log its holder into this one. The client ids therefore have to
// be configured, and a token whose audience is not among them is refused.

const googleOAuthCertsURL = "https://www.googleapis.com/oauth2/v1/certs"

// Google issues these with and without the scheme, and both are legitimate.
var googleOAuthIssuers = []string{
	"https://accounts.google.com",
	"accounts.google.com",
}

// googleOAuthClientIDFlag names the systemflag row in messages.
const googleOAuthClientIDFlag = "googleOAuthClientIds"

// Configuration, in the same shape as the Firebase project id: environment
// first, systemflag second, so a live server can be fixed without a redeploy.
// Comma separated — Android, iOS and web each have their own client id, and a
// token's audience is whichever one the app was built against.
var googleOAuthClientIDEnvNames = []string{
	"GOOGLE_OAUTH_CLIENT_IDS",
	"GOOGLE_CLIENT_IDS",
	"GOOGLE_CLIENT_ID",
}

var googleOAuthClientIDFlags = []string{
	"googleOAuthClientIds",
	"googleoauthclientids",
	"googleClientIds",
}

const googleOAuthClientTTL = 5 * time.Minute

var (
	googleOAuthClientMutex sync.RWMutex

	googleOAuthClientCached []string

	googleOAuthClientSource string

	googleOAuthClientExpiry time.Time
)

// GoogleOAuthClientIDs returns the configured client ids and where they came
// from, memoised so that a sign-in does not repeat the systemflag lookup.
func GoogleOAuthClientIDs() ([]string, string) {

	googleOAuthClientMutex.RLock()

	cached := googleOAuthClientCached

	cachedSource := googleOAuthClientSource

	fresh := time.Now().Before(googleOAuthClientExpiry)

	googleOAuthClientMutex.RUnlock()

	if fresh && len(cached) > 0 {
		return cached, cachedSource
	}

	clientIDs, source := resolveGoogleOAuthClientIDs()

	if len(clientIDs) > 0 {

		googleOAuthClientMutex.Lock()

		googleOAuthClientCached = clientIDs
		googleOAuthClientSource = source
		googleOAuthClientExpiry = time.Now().Add(googleOAuthClientTTL)

		googleOAuthClientMutex.Unlock()
	}

	return clientIDs, source
}

func resolveGoogleOAuthClientIDs() ([]string, string) {

	for _, name := range googleOAuthClientIDEnvNames {

		if list := splitClientIDs(os.Getenv(name)); len(list) > 0 {
			return list, "environment (" + name + ")"
		}
	}

	for _, flag := range googleOAuthClientIDFlags {

		value, err := repositories.GetSystemFlagValue(flag)

		if err != nil {
			continue
		}

		if list := splitClientIDs(value); len(list) > 0 {
			return list, "systemflag (" + flag + ")"
		}
	}

	return nil, ""
}

// splitClientIDs accepts a comma, semicolon or whitespace separated list, since
// hand-edited configuration uses all three.
func splitClientIDs(value string) []string {

	fields := strings.FieldsFunc(value, func(char rune) bool {
		return char == ',' || char == ';' || char == ' ' || char == '\t' || char == '\n' || char == '\r'
	})

	list := make([]string, 0, len(fields))

	for _, field := range fields {

		if cleaned := cleanConfigValue(field); cleaned != "" {
			list = append(list, cleaned)
		}
	}

	return list
}

//////////////////////////////////////////////////////////////
// Dispatch
//////////////////////////////////////////////////////////////

// VerifySocialIDToken accepts any of the three token kinds and returns the
// identity it proves. What decides the verifier:
//
//	a JWT issued by securetoken.google.com   -> Firebase ID token
//	a JWT issued by accounts.google.com      -> Google Sign-In token
//	not a JWT at all                         -> Facebook access token
//
// The last case is not a guess: a Facebook access token is an opaque string with
// none of a JWT's structure, so there is nothing else it could be. Dispatching
// means an app sending the "wrong" kind to /social/mobile-login still gets a
// real answer rather than a certificate error.
func VerifySocialIDToken(idToken string) (*SocialIdentity, error) {

	idToken = strings.TrimSpace(idToken)

	if idToken == "" {
		return nil, errors.New("id_token is required")
	}

	// A JWT is three base64 segments. Anything else is a bearer token, which
	// among the providers here means Facebook.
	if len(strings.Split(idToken, ".")) != 3 {
		return VerifyFacebookAccessToken(idToken)
	}

	issuer, _ := peekTokenClaims(idToken)

	for _, googleIssuer := range googleOAuthIssuers {

		if issuer == googleIssuer {
			return VerifyGoogleOAuthIDToken(idToken)
		}
	}

	return VerifyFirebaseIDToken(idToken)
}

//////////////////////////////////////////////////////////////
// Verification
//////////////////////////////////////////////////////////////

// VerifyGoogleOAuthIDToken validates a Google Sign-In ID token: RS256, signed by
// Google's current OAuth certificate, issued by accounts.google.com, and with an
// audience among the configured client ids.
func VerifyGoogleOAuthIDToken(idToken string) (*FirebaseUser, error) {

	clientIDs, _ := GoogleOAuthClientIDs()

	if len(clientIDs) == 0 {

		_, audience := peekTokenClaims(idToken)

		return nil, fmt.Errorf(
			"this is a Google Sign-In token (issued for client %q), not a Firebase ID "+
				"token. Either exchange it in the app for a Firebase credential and send "+
				"the Firebase user's token, or allow the client id here: set "+
				"GOOGLE_OAUTH_CLIENT_IDS, or add a systemflag row named %q, listing it",
			audience,
			googleOAuthClientIDFlag,
		)
	}

	parser := jwt.NewParser(
		jwt.WithValidMethods([]string{"RS256"}),
		jwt.WithExpirationRequired(),
	)

	claims := jwt.MapClaims{}

	_, err := parser.ParseWithClaims(idToken, claims, func(token *jwt.Token) (interface{}, error) {

		kid, _ := token.Header["kid"].(string)

		if kid == "" {
			return nil, errors.New("google token is missing its kid header")
		}

		return googleOAuthCerts.publicKey(kid)
	})

	if err != nil {
		return nil, fmt.Errorf("invalid google sign-in token: %w", err)
	}

	//------------------------------------------------
	// Issuer
	//------------------------------------------------

	issuer, _ := claims["iss"].(string)

	validIssuer := false

	for _, googleIssuer := range googleOAuthIssuers {

		if issuer == googleIssuer {
			validIssuer = true
			break
		}
	}

	if !validIssuer {

		return nil, fmt.Errorf(
			"google sign-in token has an unexpected issuer %q",
			issuer,
		)
	}

	//------------------------------------------------
	// Audience — the check that makes the rest safe
	//------------------------------------------------

	audience, _ := claims["aud"].(string)

	authorizedParty, _ := claims["azp"].(string)

	if !containsString(clientIDs, audience) {

		return nil, fmt.Errorf(
			"google sign-in token was issued for client %q (authorized party %q), which "+
				"is not one of this server's configured OAuth client ids - add it to "+
				"GOOGLE_OAUTH_CLIENT_IDS or the systemflag row %q if it is one of ours",
			audience,
			authorizedParty,
			googleOAuthClientIDFlag,
		)
	}

	//------------------------------------------------
	// Identity
	//------------------------------------------------

	subject, _ := claims["sub"].(string)

	if subject == "" {
		return nil, errors.New("google sign-in token has no subject")
	}

	user := &FirebaseUser{
		UID: subject,

		// Google is the only provider that issues these, and the subject is a
		// Google account id — a different identifier space from a Firebase uid
		// for the same person. Both link to one local user through the verified
		// email address, which is why the email check in SocialLogin matters.
		Provider: "google.com",
	}

	user.Email, _ = claims["email"].(string)

	user.EmailVerified, _ = claims["email_verified"].(bool)

	user.Name, _ = claims["name"].(string)

	user.PhotoURL, _ = claims["picture"].(string)

	return user, nil
}

func containsString(list []string, value string) bool {

	for _, item := range list {

		if item == value {
			return true
		}
	}

	return false
}
