package sign

import (
	"crypto"
	"fmt"
	"net/http"
	"strings"
	"time"
)

const (
	// CookiePolicyName name of the policy cookie
	CookiePolicyName = "CloudFront-Policy"
	// CookieSignatureName name of the signature cookie
	CookieSignatureName = "CloudFront-Signature"
	// CookieKeyIDName name of the signing Key ID cookie
	CookieKeyIDName = "CloudFront-Key-Pair-Id"
	// CookieHashAlgorithmName name of the hash algorithm cookie
	CookieHashAlgorithmName = "CloudFront-Hash-Algorithm"
)

// A CookieOptions optional additional options that can be applied to the signed
// cookies.
type CookieOptions struct {
	Path     string
	Domain   string
	Secure   bool
	SameSite http.SameSite
	Expires  time.Time
}

// apply will integration the options provided into the base cookie options
// a new copy will be returned. The base CookieOption will not be modified.
func (o CookieOptions) apply(opts ...func(*CookieOptions)) CookieOptions {
	if len(opts) == 0 {
		return o
	}

	for _, opt := range opts {
		opt(&o)
	}

	return o
}

// A CookieSigner provides signing utilities to sign Cookies for Amazon CloudFront
// resources. Using a private key and Credential Key Pair key ID the CookieSigner
// only needs to be created once per Credential Key Pair key ID and private key.
//
// More information about signed Cookies and their structure can be found at:
// http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-custom-policy.html
//
// To sign a Cookie, create a CookieSigner with your private key and credential
// pair key ID. Once you have a CookieSigner instance you can call Sign or
// SignWithPolicy to sign the URLs.
//
// The signer is safe to use concurrently, but the optional cookies options
// are not safe to modify concurrently.
type CookieSigner struct {
	keyID  string
	signer crypto.Signer

	// HashAlg specifies the hash algorithm for signing. Defaults to
	// HashSHA1. Set to HashSHA256 to produce SHA-256 signed cookies
	// with a CloudFront-Hash-Algorithm cookie.
	HashAlg HashAlgorithm
	Opts    CookieOptions
}

// NewCookieSigner constructs and returns a new CookieSigner to be used to for
// signing Amazon CloudFront URL resources with.
func NewCookieSigner(keyID string, signer crypto.Signer, opts ...func(*CookieOptions)) *CookieSigner {
	return &CookieSigner{
		keyID:  keyID,
		signer: signer,
		Opts:   CookieOptions{}.apply(opts...),
	}
}

// Sign returns the cookies needed to allow user agents to make arbetrary
// requests to cloudfront for the resource(s) defined by the policy.
//
// Sign will create a CloudFront policy with only a resource and condition of
// DateLessThan equal to the expires time provided.
//
// The returned slice cookies should all be added to the Client's cookies or
// server's response.
//
// Example:
//
//	s := sign.NewCookieSigner(keyID, signer)
//
//	// Get Signed cookies for a resource that will expire in 1 hour
//	cookies, err := s.Sign("*", time.Now().Add(1 * time.Hour))
//	if err != nil {
//	    fmt.Println("failed to create signed cookies", err)
//	    return
//	}
//
//	// Or get Signed cookies for a resource that will expire in 1 hour
//	// and set path and domain of cookies
//	cookies, err := s.Sign("*", time.Now().Add(1 * time.Hour), func(o *sign.CookieOptions) {
//	    o.Path = "/"
//	    o.Domain = ".example.com"
//	})
//	if err != nil {
//	    fmt.Println("failed to create signed cookies", err)
//	    return
//	}
//
//	// Server Response via http.ResponseWriter
//	for _, c := range cookies {
//	    http.SetCookie(w, c)
//	}
//
//	// Client request via the cookie jar
//	if client.CookieJar != nil {
//	    for _, c := range cookies {
//	       client.Cookie(w, c)
//	    }
//	}
func (s CookieSigner) Sign(u string, expires time.Time, opts ...func(*CookieOptions)) ([]*http.Cookie, error) {
	scheme, err := cookieURLScheme(u)
	if err != nil {
		return nil, err
	}

	resource, err := CreateResource(scheme, u)
	if err != nil {
		return nil, err
	}

	p := NewCannedPolicy(resource, expires)
	return createCookies(p, s.keyID, s.signer, s.HashAlg, s.Opts.apply(opts...))
}

// Returns and validates the URL's scheme.
// http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-setting-signed-cookie-custom-policy.html#private-content-custom-policy-statement-cookies
func cookieURLScheme(u string) (string, error) {
	parts := strings.SplitN(u, "://", 2)
	if len(parts) != 2 {
		return "", fmt.Errorf("invalid cookie URL, missing scheme")
	}

	scheme := strings.ToLower(parts[0])
	if scheme != "http" && scheme != "https" && scheme != "http*" {
		return "", fmt.Errorf("invalid cookie URL scheme. Expect http, https, or http*. Go, %s", scheme)
	}

	return scheme, nil
}

// SignWithPolicy returns the cookies needed to allow user agents to make
// arbetrairy requets to cloudfront for the resource(s) defined by the policy.
//
// The returned slice cookies should all be added to the Client's cookies or
// server's response.
//
// Example:
//
//	s := sign.NewCookieSigner(keyID, signer)
//
//	policy := &sign.Policy{
//	    Statements: []sign.Statement{
//	        {
//	            // Read the provided documentation on how to set this
//	            // correctly, you'll probably want to use wildcards.
//	            Resource: rawCloudFrontURL,
//	            Condition: sign.Condition{
//	                // Optional IP source address range
//	                IPAddress: &sign.IPAddress{SourceIP: "192.0.2.0/24"},
//	                // Optional date URL is not valid until
//	                DateGreaterThan: &sign.AWSEpochTime{time.Now().Add(30 * time.Minute)},
//	                // Required date the URL will expire after
//	                DateLessThan: &sign.AWSEpochTime{time.Now().Add(1 * time.Hour)},
//	            },
//	        },
//	    },
//	}
//
//	// Get Signed cookies for a resource that will expire in 1 hour
//	cookies, err := s.SignWithPolicy(policy)
//	if err != nil {
//	    fmt.Println("failed to create signed cookies", err)
//	    return
//	}
//
//	// Or get Signed cookies for a resource that will expire in 1 hour
//	// and set path and domain of cookies
//	cookies, err := s.SignWithPolicy(policy, func(o *sign.CookieOptions) {
//	    o.Path = "/"
//	    o.Domain = ".example.com"
//	})
//	if err != nil {
//	    fmt.Println("failed to create signed cookies", err)
//	    return
//	}
//
//	// Server Response via http.ResponseWriter
//	for _, c := range cookies {
//	    http.SetCookie(w, c)
//	}
//
//	// Client request via the cookie jar
//	if client.CookieJar != nil {
//	    for _, c := range cookies {
//	       client.Cookie(w, c)
//	    }
//	}
func (s CookieSigner) SignWithPolicy(p *Policy, opts ...func(*CookieOptions)) ([]*http.Cookie, error) {
	return createCookies(p, s.keyID, s.signer, s.HashAlg, s.Opts.apply(opts...))
}

// Prepares the cookies to be attached to the header. An (optional) options
// struct is provided in case people don't want to manually edit their cookies.
func createCookies(p *Policy, keyID string, signer crypto.Signer, hashAlg HashAlgorithm, opt CookieOptions) ([]*http.Cookie, error) {
	b64Sig, b64Policy, err := p.SignWithAlgorithm(signer, hashAlg)
	if err != nil {
		return nil, err
	}

	// Creates proper cookies
	cPolicy := &http.Cookie{
		Name:     CookiePolicyName,
		Value:    string(b64Policy),
		HttpOnly: true,
		Expires:  opt.Expires,
	}
	cSignature := &http.Cookie{
		Name:     CookieSignatureName,
		Value:    string(b64Sig),
		HttpOnly: true,
		Expires:  opt.Expires,
	}
	cKey := &http.Cookie{
		Name:     CookieKeyIDName,
		Value:    keyID,
		HttpOnly: true,
		Expires:  opt.Expires,
	}

	cookies := []*http.Cookie{cPolicy, cSignature, cKey}

	// Hash algorithm cookie is always appended when explicitly set. When
	// HashAlg is empty (zero value), it defaults to SHA-1 without the
	// cookie for backward compatibility.
	if hashAlg != "" {
		cookies = append(cookies, &http.Cookie{
			Name:     CookieHashAlgorithmName,
			Value:    string(hashAlg),
			HttpOnly: true,
			Expires:  opt.Expires,
		})
	}

	// Applie the cookie options
	for _, c := range cookies {
		c.Path = opt.Path
		c.Domain = opt.Domain
		c.Secure = opt.Secure
		c.SameSite = opt.SameSite
	}

	return cookies, nil
}
