package services

import (
	"crypto/rsa"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"
	"regexp"
	"strconv"
	"strings"
	"sync"
	"time"

	"astrology-api/repositories"

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

// Google's x509 certificates for Firebase ID tokens. Rotated every few hours;
// the Cache-Control max-age on the response tells us for how long.
const firebaseCertsURL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"

// SocialIdentity is a verified person from any of the three providers this API
// accepts: a Firebase ID token, a Google Sign-In token, or a Facebook access
// token. The fields are whatever that provider vouched for.
//
// FirebaseUser is kept as an alias because it is the name the rest of the
// package already uses, and renaming call sites would be churn for its own sake.
type SocialIdentity = FirebaseUser

// FirebaseUser is the subset of the ID token claims this API cares about.
// UID is the Firebase `sub` and is stable per provider account.
type FirebaseUser struct {
	UID string

	Email string

	EmailVerified bool

	Name string

	PhotoURL string

	// Provider is Firebase's sign_in_provider: google.com, apple.com,
	// facebook.com, phone, password...
	Provider string

	PhoneNumber string
}

// certCache holds one of Google's x509 certificate sets, keyed by kid.
//
// Two sets are in play. Firebase ID tokens are signed with the securetoken set;
// the OAuth ID tokens the native Google Sign-In SDKs produce are signed with a
// different one. A token presented against the wrong set fails as "signed with
// an unknown key", which is exactly what an app sending a Google Sign-In token
// to a Firebase-only verifier sees.
//
// Both are served in the same {kid: PEM} shape and rotate every few hours, with
// the Cache-Control max-age saying for how long.
type certCache struct {
	url string

	mutex sync.RWMutex

	keys map[string]*rsa.PublicKey

	expiresAt time.Time
}

var (
	firebaseCerts = &certCache{url: firebaseCertsURL}

	googleOAuthCerts = &certCache{url: googleOAuthCertsURL}
)

var maxAgePattern = regexp.MustCompile(`max-age\s*=\s*(\d+)`)

//////////////////////////////////////////////////////////////
// Project ID
//////////////////////////////////////////////////////////////

// One setting covers every app.
//
// A Firebase ID token carries no app identity: the web app, the Android app and
// the iOS app of one project all mint tokens whose aud is the *project id* and
// whose iss is securetoken.google.com/<project id>. So there is nothing to
// configure per platform, and the app ids from the Firebase console have no
// server-side role at all — they are client configuration. Restricting which app
// may call the API is what Firebase App Check is for, and that arrives as a
// separate token.
//
// The id is read from the environment first and from the systemflag table
// second, following the vedicAstroAPI key which is already configured that way.
// The database fallback is what makes this fixable on a running server: .env is
// outside version control, so a deployment whose file predates social login
// cannot supply the value without a redeploy.
//
// Both flag spellings are tried because the existing row is lower-case p. MySQL
// compares these case-insensitively anyway, but a case-sensitive collation would
// not.
var firebaseProjectIDFlags = []string{
	"firebaseProjectId",
	"firebaseprojectId",
}

// firebaseProjectIDFlag names the flag in messages.
const firebaseProjectIDFlag = "firebaseProjectId"

// Environment names accepted. The first is the documented one; the others are
// the spellings that turn up in hand-edited .env files.
var firebaseProjectIDEnvNames = []string{
	"FIREBASE_PROJECT_ID",
	"FIREBASE_PROJECTID",
	"FIREBASE_PROJECT",
}

// The resolution is memoised: without it every social login repeats the
// systemflag lookup. The TTL is short so that changing the flag on a live server
// takes effect without a restart.
const firebaseProjectTTL = 5 * time.Minute

var (
	firebaseProjectMutex sync.RWMutex

	firebaseProjectCached string

	firebaseProjectSource string

	firebaseProjectExpiry time.Time
)

// cleanConfigValue strips what hand-edited configuration leaves behind:
// surrounding whitespace, the carriage return a file saved on Windows carries
// onto a Linux server, and matching quotes. A project id read back with its
// quotes still attached fails verification as an audience mismatch, which is far
// harder to trace than a missing value.
func cleanConfigValue(value string) string {

	value = strings.TrimSpace(value)

	for _, quote := range []string{"\"", "'"} {

		if len(value) >= 2 && strings.HasPrefix(value, quote) && strings.HasSuffix(value, quote) {
			value = value[1 : len(value)-1]
		}
	}

	return strings.TrimSpace(value)
}

func firebaseProjectID() string {

	projectID, _ := FirebaseProjectIDSource()

	return projectID
}

// FirebaseProjectIDSource returns the project id and where it came from, so the
// startup log and the failure messages can say. Without it, the only symptom of
// a .env the running process never read is social login answering "not
// configured", which reads as a code fault rather than a deployment one.
func FirebaseProjectIDSource() (string, string) {

	firebaseProjectMutex.RLock()

	cachedID := firebaseProjectCached

	cachedSource := firebaseProjectSource

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

	firebaseProjectMutex.RUnlock()

	if fresh && cachedID != "" {
		return cachedID, cachedSource
	}

	projectID, source := resolveFirebaseProjectID()

	if projectID != "" {

		firebaseProjectMutex.Lock()

		firebaseProjectCached = projectID
		firebaseProjectSource = source
		firebaseProjectExpiry = time.Now().Add(firebaseProjectTTL)

		firebaseProjectMutex.Unlock()
	}

	return projectID, source
}

func resolveFirebaseProjectID() (string, string) {

	for _, name := range firebaseProjectIDEnvNames {

		if value := cleanConfigValue(os.Getenv(name)); value != "" {
			return value, "environment (" + name + ")"
		}
	}

	for _, flag := range firebaseProjectIDFlags {

		value, err := repositories.GetSystemFlagValue(flag)

		if err != nil {
			continue
		}

		if cleaned := cleanConfigValue(value); cleaned != "" {
			return cleaned, "systemflag (" + flag + ")"
		}
	}

	return "", ""
}

// peekTokenClaims reads a token's issuer and audience without verifying
// anything. Two callers need it: the dispatcher, to tell a Firebase ID token
// from a Google Sign-In one, and the error paths, to say which project or client
// a rejected token was actually minted for.
func peekTokenClaims(idToken string) (issuer string, audience string) {

	parts := strings.Split(idToken, ".")

	if len(parts) != 3 {
		return "", ""
	}

	payload, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "="))

	if err != nil {
		return "", ""
	}

	var claims struct {
		Issuer string `json:"iss"`

		Audience interface{} `json:"aud"`
	}

	if err := json.Unmarshal(payload, &claims); err != nil {
		return "", ""
	}

	switch value := claims.Audience.(type) {

	case string:
		audience = value

	case []interface{}:

		if len(value) > 0 {

			if first, ok := value[0].(string); ok {
				audience = first
			}
		}
	}

	return claims.Issuer, audience
}

// firebaseTokenAudience is the audience alone, which for a Firebase ID token
// *is* the project id — so naming it turns "invalid token" into the answer.
func firebaseTokenAudience(idToken string) string {

	_, audience := peekTokenClaims(idToken)

	return audience
}

// firebaseProjectExists asks Google whether a project id is real. The well-known
// document is public and 404s for an id that does not exist, which catches a
// typo at boot instead of at the first sign-in.
func firebaseProjectExists(projectID string) bool {

	client := &http.Client{Timeout: 8 * time.Second}

	resp, err := client.Get(
		"https://securetoken.google.com/" + projectID + "/.well-known/openid-configuration",
	)

	if err != nil {

		// No network at boot is not evidence of a bad project id.
		return true
	}

	defer resp.Body.Close()

	return resp.StatusCode == http.StatusOK
}

// LogFirebaseConfig reports at boot which social providers are configured.
//
// Each provider is reported independently. An earlier version returned early
// when one was unconfigured, which silently hid the providers after it — the
// exact failure this logging exists to prevent.
//
// Call it after the database is connected, so the systemflag fallbacks are
// visible.
func LogFirebaseConfig() {

	logFirebaseProject()

	logGoogleSignInConfig()

	logFacebookConfig()
}

func logFirebaseProject() {

	projectID, source := FirebaseProjectIDSource()

	if projectID == "" {

		log.Printf(
			"firebase: project id is NOT configured - Firebase sign-in will fail. "+
				"Set FIREBASE_PROJECT_ID in the .env this service actually reads, "+
				"or add a systemflag row named %q.",
			firebaseProjectIDFlag,
		)

		return
	}

	if !firebaseProjectExists(projectID) {

		log.Printf(
			"firebase: project id %q (from %s) is not a known Firebase project - "+
				"every sign-in will fail on audience. Check Project settings > Project ID "+
				"in the console where the apps are registered.",
			projectID,
			source,
		)

		return
	}

	log.Printf(
		"firebase: verifying tokens for project %q (from %s) - one project covers "+
			"its web, android and ios apps",
		projectID,
		source,
	)
}

func logGoogleSignInConfig() {

	clientIDs, source := GoogleOAuthClientIDs()

	if len(clientIDs) == 0 {

		log.Printf(
			"google sign-in: no OAuth client ids configured - a client sending a raw "+
				"Google Sign-In token (iss accounts.google.com) will be refused. Set "+
				"GOOGLE_OAUTH_CLIENT_IDS or add a systemflag row named %q if that is "+
				"what the apps send.",
			googleOAuthClientIDFlag,
		)

		return
	}

	log.Printf(
		"google sign-in: accepting OAuth tokens for %d client id(s) (from %s)",
		len(clientIDs),
		source,
	)
}

// logFacebookConfig reports whether Facebook login can work. Its credentials are
// a pair, and a half-configured pair fails at the first sign-in rather than at
// boot, so it is worth saying so here.
func logFacebookConfig() {

	appID, source := FacebookAppConfig()

	if appID == "" {

		log.Printf(
			"facebook login: not configured - set FACEBOOK_APP_ID and FACEBOOK_APP_SECRET, "+
				"or add systemflag rows named %q and %q.",
			facebookAppIDFlag,
			facebookAppSecretFlag,
		)

		return
	}

	log.Printf("facebook login: verifying tokens for app %s (from %s)", appID, source)
}

// fetch downloads and parses the current certificates for this set.
func (c *certCache) fetch() (map[string]*rsa.PublicKey, time.Time, error) {

	client := &http.Client{
		Timeout: 15 * time.Second,
	}

	resp, err := client.Get(c.url)

	if err != nil {
		return nil, time.Time{}, err
	}

	defer resp.Body.Close()

	body, err := io.ReadAll(resp.Body)

	if err != nil {
		return nil, time.Time{}, err
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {

		return nil, time.Time{}, fmt.Errorf(
			"could not fetch signing certificates from %s: HTTP %d",
			c.url,
			resp.StatusCode,
		)
	}

	var certs map[string]string

	if err := json.Unmarshal(body, &certs); err != nil {
		return nil, time.Time{}, err
	}

	keys := make(map[string]*rsa.PublicKey, len(certs))

	for kid, pemCert := range certs {

		key, err := jwt.ParseRSAPublicKeyFromPEM([]byte(pemCert))

		if err != nil {
			continue
		}

		keys[kid] = key
	}

	if len(keys) == 0 {
		return nil, time.Time{}, fmt.Errorf("no usable certificates returned by %s", c.url)
	}

	// Default to an hour when Google does not tell us otherwise.
	expiresAt := time.Now().Add(1 * time.Hour)

	if match := maxAgePattern.FindStringSubmatch(resp.Header.Get("Cache-Control")); len(match) == 2 {

		if seconds, err := strconv.Atoi(match[1]); err == nil && seconds > 0 {
			expiresAt = time.Now().Add(time.Duration(seconds) * time.Second)
		}
	}

	return keys, expiresAt, nil
}

// publicKey returns the cached key for kid, refreshing when the cache has
// expired or when the kid is unknown (a rotation not seen yet).
func (c *certCache) publicKey(kid string) (*rsa.PublicKey, error) {

	c.mutex.RLock()

	key, found := c.keys[kid]

	fresh := time.Now().Before(c.expiresAt)

	c.mutex.RUnlock()

	if found && fresh {
		return key, nil
	}

	keys, expiresAt, err := c.fetch()

	if err != nil {
		return nil, err
	}

	c.mutex.Lock()

	c.keys = keys

	c.expiresAt = expiresAt

	c.mutex.Unlock()

	key, found = keys[kid]

	if !found {

		return nil, fmt.Errorf(
			"token signed with a key that is not in %s (kid %s)",
			c.url,
			kid,
		)
	}

	return key, nil
}

// VerifyFirebaseIDToken validates a Firebase ID token exactly the way the Admin
// SDK does: RS256 only, signed by Google's current certificate, `aud` equal to
// the project ID and `iss` equal to securetoken.google.com/<projectID>.
//
// Verification is done in-process against Google's public certificates, so no
// service-account key is needed — only FIREBASE_PROJECT_ID.
func VerifyFirebaseIDToken(idToken string) (*FirebaseUser, error) {

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

	projectID := firebaseProjectID()

	if projectID == "" {

		return nil, fmt.Errorf(
			"firebase project id is not configured: set FIREBASE_PROJECT_ID in the .env "+
				"this service reads and restart it, or add a systemflag row named %q",
			firebaseProjectIDFlag,
		)
	}

	parser := jwt.NewParser(
		jwt.WithValidMethods([]string{"RS256"}),
		jwt.WithAudience(projectID),
		jwt.WithIssuer("https://securetoken.google.com/"+projectID),
		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("firebase token is missing its kid header")
		}

		return firebaseCerts.publicKey(kid)
	})

	if err != nil {

		// An audience or issuer mismatch means the apps signed in against a
		// different Firebase project than this server verifies against. Naming
		// the configured project turns that from a puzzle into a one-line fix.
		if errors.Is(err, jwt.ErrTokenInvalidAudience) || errors.Is(err, jwt.ErrTokenInvalidIssuer) {

			return nil, fmt.Errorf(
				"firebase token was issued for project %q but this server verifies "+
					"project %q - the app and the API must use the same Firebase project: %w",
				firebaseTokenAudience(idToken),
				projectID,
				err,
			)
		}

		return nil, fmt.Errorf("invalid firebase token: %w", err)
	}

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

	if uid == "" {
		return nil, errors.New("firebase token has no subject")
	}

	user := &FirebaseUser{
		UID: uid,
	}

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

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

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

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

	user.PhoneNumber, _ = claims["phone_number"].(string)

	if firebaseClaim, ok := claims["firebase"].(map[string]interface{}); ok {

		user.Provider, _ = firebaseClaim["sign_in_provider"].(string)
	}

	if user.Provider == "" {
		user.Provider = "firebase"
	}

	return user, nil
}
