package notifications

import (
	"bytes"
	"crypto/rsa"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"os"
	"strings"
	"sync"
	"time"

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

// Firebase Cloud Messaging, over the HTTP v1 API.
//
// No Firebase Admin SDK: the SDK would pull a large dependency tree in for one
// POST, and everything it does here is already available — the service account
// key is an RSA key, `golang-jwt` is already a dependency for this project's
// own tokens, and the rest is one HTTP call.
//
// The flow Google requires:
//
//	service account JSON  ->  self-signed JWT (RS256, scope firebase.messaging)
//	                      ->  exchanged at oauth2 token endpoint for a bearer
//	                      ->  POST /v1/projects/<id>/messages:send
//
// The bearer lasts an hour and is cached, so a burst of notifications costs one
// token exchange rather than one per message.

//------------------------------------------------
// Configuration
//------------------------------------------------
//
// Read at point of use through os.Getenv, like the rest of this project's
// configuration:
//
//	FIREBASE_PROJECT_ID        astroeye-1
//	FIREBASE_CREDENTIALS_FILE  path to the service account JSON
//	FIREBASE_CREDENTIALS_JSON  the JSON itself, for hosts that only offer env
//	                           vars. Takes precedence over the file.
//
// With none of them set, notifications are disabled and every send is a
// logged no-op — the API still runs, which matters because a push failure must
// never break a wallet or settlement operation.

const (
	envProjectID       = "FIREBASE_PROJECT_ID"
	envCredentialsFile = "FIREBASE_CREDENTIALS_FILE"
	envCredentialsJSON = "FIREBASE_CREDENTIALS_JSON"

	// The only scope FCM needs. Narrower than cloud-platform on purpose.
	scopeFirebaseMessaging = "https://www.googleapis.com/auth/firebase.messaging"

	fcmSendURLFormat = "https://fcm.googleapis.com/v1/projects/%s/messages:send"

	// Refresh this long before the bearer actually expires, so a message is
	// never sent with a token that dies in flight.
	tokenRefreshMargin = 2 * time.Minute
)

// serviceAccount is the subset of the service account JSON that matters.
type serviceAccount struct {
	Type        string `json:"type"`
	ProjectID   string `json:"project_id"`
	PrivateKey  string `json:"private_key"`
	ClientEmail string `json:"client_email"`
	TokenURI    string `json:"token_uri"`
}

// credentials is a loaded, parsed service account plus its cached bearer.
type credentials struct {
	account *serviceAccount

	key *rsa.PrivateKey

	projectID string

	mu          sync.Mutex
	accessToken string
	expiresAt   time.Time
}

var (
	credentialsOnce sync.Once
	loadedCreds     *credentials
	loadError       error
)

// loadCredentials parses the service account once per process. A failure is
// remembered rather than retried on every message, so a misconfigured
// deployment logs once instead of on every notification.
func loadCredentials() (*credentials, error) {

	credentialsOnce.Do(func() {

		raw, source, err := readServiceAccount()

		if err != nil {
			loadError = err
			return
		}

		account := &serviceAccount{}

		if err := json.Unmarshal(raw, account); err != nil {
			loadError = fmt.Errorf("firebase credentials in %s are not valid JSON: %w", source, err)
			return
		}

		if strings.TrimSpace(account.ClientEmail) == "" ||
			strings.TrimSpace(account.PrivateKey) == "" {

			loadError = fmt.Errorf(
				"firebase credentials in %s are missing client_email or private_key — "+
					"download the file from Firebase Console > Project settings > Service accounts",
				source,
			)
			return
		}

		// The JSON stores the PEM with literal \n when it has been pasted
		// through an env var, so both spellings have to work.
		pem := strings.ReplaceAll(account.PrivateKey, "\\n", "\n")

		key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(pem))

		if err != nil {
			loadError = fmt.Errorf("firebase private key in %s could not be parsed: %w", source, err)
			return
		}

		projectID := strings.TrimSpace(os.Getenv(envProjectID))

		if projectID == "" {
			projectID = strings.TrimSpace(account.ProjectID)
		}

		if projectID == "" {
			loadError = errors.New("firebase project id is not set and the credentials carry none")
			return
		}

		if strings.TrimSpace(account.TokenURI) == "" {
			account.TokenURI = "https://oauth2.googleapis.com/token"
		}

		loadedCreds = &credentials{
			account:   account,
			key:       key,
			projectID: projectID,
		}
	})

	if loadError != nil {
		return nil, loadError
	}

	if loadedCreds == nil {
		return nil, errors.New("firebase credentials are not configured")
	}

	return loadedCreds, nil
}

func readServiceAccount() ([]byte, string, error) {

	if inline := strings.TrimSpace(os.Getenv(envCredentialsJSON)); inline != "" {
		return []byte(inline), envCredentialsJSON, nil
	}

	path := strings.TrimSpace(os.Getenv(envCredentialsFile))

	if path == "" {
		return nil, "", fmt.Errorf(
			"neither %s nor %s is set, so push notifications are disabled",
			envCredentialsFile,
			envCredentialsJSON,
		)
	}

	raw, err := os.ReadFile(path)

	if err != nil {
		return nil, path, fmt.Errorf("firebase credentials file %s could not be read: %w", path, err)
	}

	return raw, path, nil
}

// Configured reports whether push notifications can be sent at all. Used to
// keep the logs quiet on a deployment that has not set Firebase up yet.
func Configured() bool {

	_, err := loadCredentials()

	return err == nil
}

// ConfigError is why push is unavailable, for a startup log or the
// notifytest command. nil when Firebase is configured.
func ConfigError() error {

	_, err := loadCredentials()

	return err
}

// SendTest pushes one message straight to a device token, with no database
// and no in-app row. It is the deployment check: if this fails, no product
// notification will work either, and the error says which half is broken —
// the credentials or the token.
func SendTest(token string, title string, body string) error {

	result := send(strings.TrimSpace(token), Payload{
		Title:          title,
		Body:           body,
		AndroidChannel: channelGeneral,
		Data: map[string]string{
			"type": "TEST",
		},
	})

	return result.Err
}

//------------------------------------------------
// Bearer token
//------------------------------------------------

// bearer returns a valid OAuth2 access token, exchanging a freshly signed JWT
// for one when the cached token is missing or close to expiry.
func (c *credentials) bearer() (string, error) {

	c.mu.Lock()
	defer c.mu.Unlock()

	if c.accessToken != "" && time.Now().Add(tokenRefreshMargin).Before(c.expiresAt) {
		return c.accessToken, nil
	}

	now := time.Now()

	claims := jwt.MapClaims{
		"iss":   c.account.ClientEmail,
		"scope": scopeFirebaseMessaging,
		"aud":   c.account.TokenURI,
		"iat":   now.Unix(),
		"exp":   now.Add(time.Hour).Unix(),
	}

	assertion, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(c.key)

	if err != nil {
		return "", fmt.Errorf("signing the firebase assertion failed: %w", err)
	}

	form := url.Values{}
	form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
	form.Set("assertion", assertion)

	request, err := http.NewRequest(
		http.MethodPost,
		c.account.TokenURI,
		strings.NewReader(form.Encode()),
	)

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

	request.Header.Set("Content-Type", "application/x-www-form-urlencoded")

	response, err := httpClient.Do(request)

	if err != nil {
		return "", fmt.Errorf("firebase token exchange failed: %w", err)
	}

	defer response.Body.Close()

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

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

	if response.StatusCode != http.StatusOK {
		return "", fmt.Errorf(
			"firebase token exchange returned %d: %s",
			response.StatusCode,
			strings.TrimSpace(string(body)),
		)
	}

	var token struct {
		AccessToken string `json:"access_token"`
		ExpiresIn   int    `json:"expires_in"`
		TokenType   string `json:"token_type"`
	}

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

	if token.AccessToken == "" {
		return "", errors.New("firebase token exchange returned no access token")
	}

	lifetime := token.ExpiresIn

	if lifetime <= 0 {
		lifetime = 3600
	}

	c.accessToken = token.AccessToken
	c.expiresAt = now.Add(time.Duration(lifetime) * time.Second)

	return c.accessToken, nil
}

//------------------------------------------------
// Send
//------------------------------------------------

// httpClient is shared so connections are reused across a fan-out. The
// timeout is deliberately short: a notification is never worth holding a
// request, or a goroutine, open for long.
var httpClient = &http.Client{
	Timeout: 15 * time.Second,
}

// fcmMessage is the HTTP v1 envelope.
//
// `data` values must all be strings — FCM rejects numbers — so every payload
// is stringified before it gets here.
type fcmMessage struct {
	Message struct {
		Token string `json:"token"`

		Notification struct {
			Title string `json:"title"`
			Body  string `json:"body"`
		} `json:"notification"`

		Data map[string]string `json:"data,omitempty"`

		Android *androidConfig `json:"android,omitempty"`
		APNS    *apnsConfig    `json:"apns,omitempty"`
	} `json:"message"`
}

type androidConfig struct {
	Priority string `json:"priority"`

	Notification struct {
		Sound       string `json:"sound,omitempty"`
		ChannelID   string `json:"channel_id,omitempty"`
		ClickAction string `json:"click_action,omitempty"`
	} `json:"notification"`
}

type apnsConfig struct {
	Headers map[string]string `json:"headers,omitempty"`

	Payload struct {
		Aps struct {
			Sound string `json:"sound,omitempty"`
			Badge int    `json:"badge,omitempty"`
		} `json:"aps"`
	} `json:"payload"`
}

// sendResult says what happened to one token, so the caller can prune the
// dead ones.
type sendResult struct {
	Token string

	// Invalid means FCM said this token will never work again (UNREGISTERED
	// or INVALID_ARGUMENT) — the app was uninstalled or the token was
	// replaced. The registry row is deleted rather than retried.
	Invalid bool

	Err error
}

// send delivers one message to one device token.
func send(token string, payload Payload) sendResult {

	creds, err := loadCredentials()

	if err != nil {
		return sendResult{Token: token, Err: err}
	}

	bearer, err := creds.bearer()

	if err != nil {
		return sendResult{Token: token, Err: err}
	}

	message := fcmMessage{}
	message.Message.Token = token
	message.Message.Notification.Title = payload.Title
	message.Message.Notification.Body = payload.Body
	message.Message.Data = payload.Data

	// High priority so a call or chat request wakes a dozing handset. The
	// data-only notifications this API sends are all time-sensitive.
	android := &androidConfig{Priority: "high"}
	android.Notification.Sound = "default"
	android.Notification.ChannelID = payload.AndroidChannel
	android.Notification.ClickAction = "FLUTTER_NOTIFICATION_CLICK"
	message.Message.Android = android

	apns := &apnsConfig{Headers: map[string]string{"apns-priority": "10"}}
	apns.Payload.Aps.Sound = "default"
	message.Message.APNS = apns

	body, err := json.Marshal(message)

	if err != nil {
		return sendResult{Token: token, Err: err}
	}

	request, err := http.NewRequest(
		http.MethodPost,
		fmt.Sprintf(fcmSendURLFormat, creds.projectID),
		bytes.NewReader(body),
	)

	if err != nil {
		return sendResult{Token: token, Err: err}
	}

	request.Header.Set("Authorization", "Bearer "+bearer)
	request.Header.Set("Content-Type", "application/json")

	response, err := httpClient.Do(request)

	if err != nil {
		return sendResult{Token: token, Err: err}
	}

	defer response.Body.Close()

	responseBody, _ := io.ReadAll(response.Body)

	if response.StatusCode == http.StatusOK {
		return sendResult{Token: token}
	}

	//------------------------------------------------
	// Dead Tokens
	//------------------------------------------------
	//
	// 404 UNREGISTERED is the app having been uninstalled; 400
	// INVALID_ARGUMENT on a token field is a malformed registration. Neither
	// will ever succeed, so the row is pruned instead of retried forever.

	invalid := response.StatusCode == http.StatusNotFound ||
		(response.StatusCode == http.StatusBadRequest &&
			strings.Contains(string(responseBody), "registration token"))

	return sendResult{
		Token:   token,
		Invalid: invalid,
		Err: fmt.Errorf(
			"fcm returned %d: %s",
			response.StatusCode,
			strings.TrimSpace(string(responseBody)),
		),
	}
}
