package services

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"

	"astrology-api/repositories"
)

//////////////////////////////////////////////////////////////
// Facebook Login
//////////////////////////////////////////////////////////////

// Facebook does not hand the app a signed token the way Google does: a Facebook
// access token is an opaque string that means nothing until Facebook is asked
// about it. So verification here is two calls to the Graph API rather than a
// signature check.
//
//	debug_token   is this token real, unexpired, and issued for *our* app?
//	/me           who does it belong to?
//
// The app id check in the first call is the one that matters. An access token is
// bearer credential for whichever app minted it, so without confirming the app
// id, a token issued for somebody else's Facebook app would log its holder into
// this one. That is why the app secret is needed: debug_token will only answer
// for a caller that can prove it owns the app.
//
// The secret is read from configuration, never compiled in, and it belongs in
// the environment or the systemflag table exactly like the other provider
// credentials.

const facebookGraphVersion = "v21.0"

const facebookGraphBase = "https://graph.facebook.com"

// Environment names accepted for the credentials.
var facebookAppIDEnvNames = []string{
	"FACEBOOK_APP_ID",
	"FB_APP_ID",
}

var facebookAppSecretEnvNames = []string{
	"FACEBOOK_APP_SECRET",
	"FB_APP_SECRET",
}

// systemflag rows, so a live server can be configured without a redeploy.
var facebookAppIDFlags = []string{
	"facebookAppId",
	"facebookappId",
}

var facebookAppSecretFlags = []string{
	"facebookAppSecret",
	"facebookappSecret",
}

const (
	facebookAppIDFlag = "facebookAppId"

	facebookAppSecretFlag = "facebookAppSecret"
)

const facebookConfigTTL = 5 * time.Minute

var (
	facebookConfigMutex sync.RWMutex

	facebookAppIDCached string

	facebookAppSecretCached string

	facebookConfigSource string

	facebookConfigExpiry time.Time
)

// FacebookAppConfig returns the app id and where the pair came from. The secret
// is deliberately not returned: nothing outside this file needs it, and a value
// that is never handed out cannot be logged by accident.
func FacebookAppConfig() (string, string) {

	appID, _, source := facebookCredentials()

	return appID, source
}

func facebookCredentials() (string, string, string) {

	facebookConfigMutex.RLock()

	appID := facebookAppIDCached

	appSecret := facebookAppSecretCached

	source := facebookConfigSource

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

	facebookConfigMutex.RUnlock()

	if fresh && appID != "" && appSecret != "" {
		return appID, appSecret, source
	}

	appID, appSecret, source = resolveFacebookCredentials()

	if appID != "" && appSecret != "" {

		facebookConfigMutex.Lock()

		facebookAppIDCached = appID
		facebookAppSecretCached = appSecret
		facebookConfigSource = source
		facebookConfigExpiry = time.Now().Add(facebookConfigTTL)

		facebookConfigMutex.Unlock()
	}

	return appID, appSecret, source
}

func resolveFacebookCredentials() (string, string, string) {

	// The two halves may come from different places — an id committed to .env
	// and a secret held in the database, say — so they are resolved separately.
	appID, idSource := firstConfigValue(facebookAppIDEnvNames, facebookAppIDFlags)

	appSecret, secretSource := firstConfigValue(facebookAppSecretEnvNames, facebookAppSecretFlags)

	source := idSource

	if secretSource != idSource && secretSource != "" {
		source = idSource + " + " + secretSource
	}

	return appID, appSecret, source
}

// firstConfigValue looks through the environment then the systemflag table,
// which is the order every provider credential in this codebase uses.
func firstConfigValue(envNames []string, flagNames []string) (string, string) {

	for _, name := range envNames {

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

	for _, flag := range flagNames {

		value, err := repositories.GetSystemFlagValue(flag)

		if err != nil {
			continue
		}

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

	return "", ""
}

// hmacSHA256Hex builds the appsecret_proof Facebook expects: the access token
// signed with the app secret.
func hmacSHA256Hex(secret string, message string) string {

	mac := hmac.New(sha256.New, []byte(secret))

	mac.Write([]byte(message))

	return hex.EncodeToString(mac.Sum(nil))
}

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

// facebookDebugToken is the part of debug_token's answer worth reading.
type facebookDebugToken struct {
	Data struct {
		AppID string `json:"app_id"`

		Type string `json:"type"`

		Application string `json:"application"`

		IsValid bool `json:"is_valid"`

		ExpiresAt int64 `json:"expires_at"`

		UserID string `json:"user_id"`

		Scopes []string `json:"scopes"`

		Error *struct {
			Code int `json:"code"`

			Message string `json:"message"`
		} `json:"error"`
	} `json:"data"`
}

// facebookProfile is the /me response.
type facebookProfile struct {
	ID string `json:"id"`

	Name string `json:"name"`

	Email string `json:"email"`

	Picture struct {
		Data struct {
			URL string `json:"url"`

			IsSilhouette bool `json:"is_silhouette"`
		} `json:"data"`
	} `json:"picture"`
}

// graphError is how the Graph API reports a problem, whatever the endpoint.
type graphError struct {
	Error *struct {
		Message string `json:"message"`

		Type string `json:"type"`

		Code int `json:"code"`
	} `json:"error"`
}

func facebookGraphGet(path string, params url.Values, target interface{}) error {

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

	resp, err := client.Get(facebookGraphBase + "/" + path + "?" + params.Encode())

	if err != nil {
		return fmt.Errorf("facebook is unreachable: %w", err)
	}

	defer resp.Body.Close()

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

	if err != nil {
		return err
	}

	// Graph reports failures in the body, with an HTTP status to match; the
	// body carries the useful part.
	var failure graphError

	if err := json.Unmarshal(body, &failure); err == nil && failure.Error != nil {

		return fmt.Errorf("facebook rejected the request: %s", failure.Error.Message)
	}

	if resp.StatusCode < 200 || resp.StatusCode >= 300 {
		return fmt.Errorf("facebook returned HTTP %d", resp.StatusCode)
	}

	return json.Unmarshal(body, target)
}

// VerifyFacebookAccessToken confirms a Facebook access token belongs to this app
// and returns the identity behind it.
func VerifyFacebookAccessToken(accessToken string) (*SocialIdentity, error) {

	accessToken = strings.TrimSpace(accessToken)

	if accessToken == "" {
		return nil, errors.New("facebook access_token is required")
	}

	appID, appSecret, _ := facebookCredentials()

	if appID == "" || appSecret == "" {

		return nil, fmt.Errorf(
			"facebook login is not configured: set FACEBOOK_APP_ID and FACEBOOK_APP_SECRET, "+
				"or add systemflag rows named %q and %q",
			facebookAppIDFlag,
			facebookAppSecretFlag,
		)
	}

	//------------------------------------------------
	// 1. Is the token ours, and still valid?
	//------------------------------------------------

	debugParams := url.Values{}

	debugParams.Set("input_token", accessToken)

	// The app access token is literally "<id>|<secret>". It proves to Facebook
	// that the caller owns the app whose token it is inspecting.
	debugParams.Set("access_token", appID+"|"+appSecret)

	var debug facebookDebugToken

	if err := facebookGraphGet("debug_token", debugParams, &debug); err != nil {
		return nil, err
	}

	if debug.Data.Error != nil && debug.Data.Error.Message != "" {
		return nil, fmt.Errorf("facebook token is not usable: %s", debug.Data.Error.Message)
	}

	if !debug.Data.IsValid {
		return nil, errors.New("facebook access token is not valid or has been revoked")
	}

	// The check that makes the rest safe.
	if debug.Data.AppID != appID {

		return nil, fmt.Errorf(
			"facebook token was issued for app %s but this server is app %s - "+
				"the app and the API must use the same Facebook app",
			debug.Data.AppID,
			appID,
		)
	}

	// expires_at is 0 for a long-lived token, which is legitimate.
	if debug.Data.ExpiresAt > 0 && time.Now().Unix() > debug.Data.ExpiresAt {
		return nil, errors.New("facebook access token has expired, please sign in again")
	}

	if debug.Data.UserID == "" {
		return nil, errors.New("facebook token carries no user")
	}

	//------------------------------------------------
	// 2. Who is it?
	//------------------------------------------------

	profileParams := url.Values{}

	profileParams.Set("fields", "id,name,email,picture.width(400).height(400)")
	profileParams.Set("access_token", accessToken)

	// appsecret_proof signs the call with the app secret, so a stolen access
	// token alone cannot be used against the Graph API from elsewhere. Facebook
	// recommends it and can be configured to require it.
	profileParams.Set("appsecret_proof", hmacSHA256Hex(appSecret, accessToken))

	var profile facebookProfile

	if err := facebookGraphGet(facebookGraphVersion+"/me", profileParams, &profile); err != nil {
		return nil, err
	}

	if profile.ID == "" {
		return nil, errors.New("facebook returned no profile id")
	}

	if profile.ID != debug.Data.UserID {

		// The two calls disagreeing should be impossible; refusing is cheaper
		// than reasoning about what it would mean.
		return nil, errors.New("facebook profile does not match the inspected token")
	}

	identity := &SocialIdentity{
		UID:      profile.ID,
		Provider: "facebook.com",
		Name:     strings.TrimSpace(profile.Name),
		Email:    strings.ToLower(strings.TrimSpace(profile.Email)),

		// Facebook only releases a confirmed address, and only when the user
		// granted the email permission. An address that is absent is the normal
		// case for an account registered with a phone number — the mandatory
		// email step exists for exactly that.
		EmailVerified: strings.TrimSpace(profile.Email) != "",
	}

	if !profile.Picture.Data.IsSilhouette {
		identity.PhotoURL = profile.Picture.Data.URL
	}

	return identity, nil
}
