package authenticator

import (
	"crypto/rand"
	"errors"
	"fmt"
	"io"
	"net/http"
	"time"

	"github.com/transip/gotransip/v6/jwt"
	"github.com/transip/gotransip/v6/rest"
)

const (
	// this is the header key we will add the signature to
	signatureHeader = "Signature"
	// this prefix will be used to name tokens we requested
	// customers are able to see this in their control panel
	labelPrefix = "gotransip-client"
	// authenticationPath is the endpoint that the authenticator
	// will communicate with
	authenticationPath = "/auth"
	// a requested Token expires after a day by default
	// will be used if Authenticator.TokenExpiration is not set
	defaultTokenExpiration = "1 day"
	// DemoToken can be used to test with the api without using your own account
	DemoToken = `eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImp0aSI6ImN3MiFSbDU2eDNoUnkjelM4YmdOIn0.` +
		`eyJpc3MiOiJhcGkudHJhbnNpcC5ubCIsImF1ZCI6ImFwaS50cmFuc2lwLm5sIiwianRpIjoiY3cy` +
		`IVJsNTZ4M2hSeSN6UzhiZ04iLCJpYXQiOjE1ODIyMDE1NTAsIm5iZiI6MTU4MjIwMTU1MCwiZXhw` +
		`IjoyMTE4NzQ1NTUwLCJjaWQiOiI2MDQ0OSIsInJvIjpmYWxzZSwiZ2siOmZhbHNlLCJrdiI6dHJ1` +
		`ZX0.fYBWV4O5WPXxGuWG-vcrFWqmRHBm9yp0PHiYh_oAWxWxCaZX2Rf6WJfc13AxEeZ67-lY0TA2` +
		`kSaOCp0PggBb_MGj73t4cH8gdwDJzANVxkiPL1Saqiw2NgZ3IHASJnisUWNnZp8HnrhLLe5ficvb` +
		`1D9WOUOItmFC2ZgfGObNhlL2y-AMNLT4X7oNgrNTGm-mespo0jD_qH9dK5_evSzS3K8o03gu6p19` +
		`jxfsnIh8TIVRvNdluYC2wo4qDl5EW5BEZ8OSuJ121ncOT1oRpzXB0cVZ9e5_UVAEr9X3f26_Eomg` +
		`52-PjrgcRJ_jPIUYbrlo06KjjX2h0fzMr21ZE023Gw`
)

var (
	// ErrTokenExpired will be throwed when the static token that has been set by the client is expired
	// and we cannot request a new one
	ErrTokenExpired = errors.New("token expired and no private key is set")
)

// Authenticator is used to store,retrieve and request new tokens on every request.
// It checks the expiry date of a Token and if it is expired it will request a new one
type Authenticator struct {
	// this contains a []byte representation of the the private key of the customer
	// this key will be used to sign a new Token request
	PrivateKeyBody []byte
	// this is Token, that is filled with a static Token that a customer provides
	// or a Token that we got from a Token request
	Token jwt.Token
	// this is the http client to do auth requests with
	HTTPClient *http.Client
	// this would be the auth path, thus where we will get new tokens from
	BasePath string
	// this would be the account name of customer
	Login string
	// When this is set to true the requested tokens can only be used with the 'ip' we are requesting with
	Whitelisted bool
	// Whether or not we want to request read only Tokens, that can only only be used to retrieve information
	// not to create, modify or delete it
	ReadOnly bool
	// TokenCache is used to retrieve previously acquired tokens and saving new ones
	// If not set we do not use a cache to store the tokens
	TokenCache TokenCache
	// TokenExpiration defines lifetime of generated tokens.
	// If unspecified, the default is 1 day.
	// Has no effect for tokens provided via the Token field
	TokenExpiration time.Duration
}

// AuthRequest will be transformed and send in order to request a new Token
// for more information, see: https://api.transip.nl/rest/docs.html#header-authentication
type AuthRequest struct {
	// Account name
	Login string `json:"login"`
	// Unique number for this request
	Nonce string `json:"nonce"`
	// Custom name to give this Token, you can see your tokens in the transip control panel
	Label string `json:"label,omitempty"`
	// Enable read only mode
	ReadOnly bool `json:"read_only"`
	// Unix time stamp of when this Token should expire
	ExpirationTime string `json:"expiration_time"`
	// Whether this key can be used from everywhere, e.g should not be whitelisted to the current requesting ip
	GlobalKey bool `json:"global_key"`
}

// GetToken will return the current Token if it is not expired.
// If it is expired it will try to request a new Token, set and return that.
func (a *Authenticator) GetToken() (jwt.Token, error) {
	// If token is not set, and we have a token cache,
	// try to retrieve it from the token cache
	if a.Token.ExpiryDate == 0 && a.TokenCache != nil {
		if err := a.retrieveTokenFromCache(); err != nil {
			return jwt.Token{}, err
		}
	}

	if a.Token.Expired() && a.PrivateKeyBody == nil {
		return jwt.Token{}, ErrTokenExpired
	}
	if a.Token.Expired() {
		var err error
		a.Token, err = a.requestNewToken()

		if err != nil {
			return jwt.Token{}, err
		}

		// if a TokenCache is set we want to write acquired tokens to the cache
		if a.TokenCache != nil {
			if err = a.TokenCache.Set(a.getTokenCacheKey(), a.Token); err != nil {
				return jwt.Token{}, fmt.Errorf("error writing token to cache: %w", err)
			}
		}
	}

	return a.Token, nil
}

// retrieveTokenFromCache gets the token from the cache
func (a *Authenticator) retrieveTokenFromCache() error {
	var err error
	a.Token, err = a.TokenCache.Get(a.getTokenCacheKey())
	if err != nil {
		return fmt.Errorf("error getting token from cache: %w", err)
	}

	return nil
}

// requestNewToken will request a new Token using the http client
// creating a new AuthRequest, converting it to json and sending that to the api auth url
// on error it will pass this back
func (a *Authenticator) requestNewToken() (jwt.Token, error) {
	restRequest, err := a.getAuthRequest()
	if err != nil {
		return jwt.Token{}, fmt.Errorf("error during auth request creation: %w", err)
	}

	getMethod := rest.PostMethod

	httpRequest, err := restRequest.GetHTTPRequest(a.BasePath, getMethod.Method)
	if err != nil {
		return jwt.Token{}, fmt.Errorf("error constructing token http request: %w", err)
	}
	bodyToSign, err := restRequest.GetJSONBody()
	if err != nil {
		return jwt.Token{}, fmt.Errorf("error marshalling token request: %w", err)
	}
	signature, err := signWithKey(bodyToSign, a.PrivateKeyBody)
	if err != nil {
		return jwt.Token{}, err
	}
	httpRequest.Header.Add(signatureHeader, signature)

	httpResponse, err := a.HTTPClient.Do(httpRequest)
	if err != nil {
		return jwt.Token{}, fmt.Errorf("error requesting token: %w", err)
	}

	defer httpResponse.Body.Close()

	// read entire response body
	b, err := io.ReadAll(httpResponse.Body)
	if err != nil {
		return jwt.Token{}, fmt.Errorf("error requesting token: %w", err)
	}

	restResponse := rest.Response{
		Body:       b,
		StatusCode: httpResponse.StatusCode,
		Method:     getMethod,
	}

	var tokenToReturn tokenResponse
	err = restResponse.ParseResponse(&tokenToReturn)
	if err != nil {
		return jwt.Token{}, fmt.Errorf("error requesting token: %w", err)
	}

	return jwt.New(tokenToReturn.Token)
}

// tokenResponse is used to extract a Token from the api server response
type tokenResponse struct {
	Token string `json:"Token"`
}

// getNonce returns a random 16 character length string nonce
// each time it is called
func (a *Authenticator) getNonce() (string, error) {
	randomBytes := make([]byte, 8)

	if _, err := rand.Read(randomBytes); err != nil {
		return "", fmt.Errorf("error when getting random data for new nonce: %w", err)
	}

	// convert to hex
	return fmt.Sprintf("%02x", randomBytes), nil
}

// getAuthRequest returns a rest.Request filled with a new AuthRequest
func (a *Authenticator) getAuthRequest() (rest.Request, error) {
	labelPostFix := time.Now().UnixNano()

	nonce, err := a.getNonce()
	if err != nil {
		return rest.Request{}, err
	}

	authRequest := AuthRequest{
		Login:          a.Login,
		Nonce:          nonce,
		Label:          fmt.Sprintf("%s-%d", labelPrefix, labelPostFix),
		ReadOnly:       a.ReadOnly,
		ExpirationTime: a.getTokenExpirationString(),
		GlobalKey:      !a.Whitelisted,
	}

	return rest.Request{
		Endpoint: authenticationPath,
		Body:     authRequest,
	}, nil
}

// getTokenCacheKey returns a name for the given Login and our authenticator name
func (a *Authenticator) getTokenCacheKey() string {
	return fmt.Sprintf("%s-%s-token", labelPrefix, a.Login)
}

// getTokenExpirationString returns the requested or default expiration in string format for the API
func (a *Authenticator) getTokenExpirationString() string {
	if a.TokenExpiration != time.Duration(0) {
		return fmt.Sprintf("%0.0f seconds", a.TokenExpiration.Seconds())
	}
	return defaultTokenExpiration
}
