package main

import (
	"context"
	"crypto"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"io"
	"regexp"
	"strings"
	"time"

	dcontext "github.com/distribution/distribution/v3/context"
	"github.com/distribution/distribution/v3/registry/auth"
	"github.com/distribution/distribution/v3/registry/auth/token"
	"github.com/docker/libtrust"
)

// ResolveScopeSpecifiers converts a list of scope specifiers from a token
// request's `scope` query parameters into a list of standard access objects.
func ResolveScopeSpecifiers(ctx context.Context, scopeSpecs []string) []auth.Access {
	requestedAccessSet := make(map[auth.Access]struct{}, 2*len(scopeSpecs))

	for _, scopeSpecifier := range scopeSpecs {
		// There should be 3 parts, separated by a `:` character.
		parts := strings.SplitN(scopeSpecifier, ":", 3)

		if len(parts) != 3 {
			dcontext.GetLogger(ctx).Infof("ignoring unsupported scope format %s", scopeSpecifier)
			continue
		}

		resourceType, resourceName, actions := parts[0], parts[1], parts[2]

		resourceType, resourceClass := splitResourceClass(resourceType)
		if resourceType == "" {
			continue
		}

		// Actions should be a comma-separated list of actions.
		for _, action := range strings.Split(actions, ",") {
			requestedAccess := auth.Access{
				Resource: auth.Resource{
					Type:  resourceType,
					Class: resourceClass,
					Name:  resourceName,
				},
				Action: action,
			}

			// Add this access to the requested access set.
			requestedAccessSet[requestedAccess] = struct{}{}
		}
	}

	requestedAccessList := make([]auth.Access, 0, len(requestedAccessSet))
	for requestedAccess := range requestedAccessSet {
		requestedAccessList = append(requestedAccessList, requestedAccess)
	}

	return requestedAccessList
}

var typeRegexp = regexp.MustCompile(`^([a-z0-9]+)(\([a-z0-9]+\))?$`)

func splitResourceClass(t string) (string, string) {
	matches := typeRegexp.FindStringSubmatch(t)
	if len(matches) < 2 {
		return "", ""
	}
	if len(matches) == 2 || len(matches[2]) < 2 {
		return matches[1], ""
	}
	return matches[1], matches[2][1 : len(matches[2])-1]
}

// ResolveScopeList converts a scope list from a token request's
// `scope` parameter into a list of standard access objects.
func ResolveScopeList(ctx context.Context, scopeList string) []auth.Access {
	scopes := strings.Split(scopeList, " ")
	return ResolveScopeSpecifiers(ctx, scopes)
}

func scopeString(a auth.Access) string {
	if a.Class != "" {
		return fmt.Sprintf("%s(%s):%s:%s", a.Type, a.Class, a.Name, a.Action)
	}
	return fmt.Sprintf("%s:%s:%s", a.Type, a.Name, a.Action)
}

// ToScopeList converts a list of access to a
// scope list string
func ToScopeList(access []auth.Access) string {
	var s []string
	for _, a := range access {
		s = append(s, scopeString(a))
	}
	return strings.Join(s, ",")
}

// TokenIssuer represents an issuer capable of generating JWT tokens
type TokenIssuer struct {
	Issuer     string
	SigningKey libtrust.PrivateKey
	Expiration time.Duration
}

// CreateJWT creates and signs a JSON Web Token for the given subject and
// audience with the granted access.
func (issuer *TokenIssuer) CreateJWT(subject string, audience string, grantedAccessList []auth.Access) (string, error) {
	// Make a set of access entries to put in the token's claimset.
	resourceActionSets := make(map[auth.Resource]map[string]struct{}, len(grantedAccessList))
	for _, access := range grantedAccessList {
		actionSet, exists := resourceActionSets[access.Resource]
		if !exists {
			actionSet = map[string]struct{}{}
			resourceActionSets[access.Resource] = actionSet
		}
		actionSet[access.Action] = struct{}{}
	}

	accessEntries := make([]*token.ResourceActions, 0, len(resourceActionSets))
	for resource, actionSet := range resourceActionSets {
		actions := make([]string, 0, len(actionSet))
		for action := range actionSet {
			actions = append(actions, action)
		}

		accessEntries = append(accessEntries, &token.ResourceActions{
			Type:    resource.Type,
			Class:   resource.Class,
			Name:    resource.Name,
			Actions: actions,
		})
	}

	randomBytes := make([]byte, 15)
	_, err := io.ReadFull(rand.Reader, randomBytes)
	if err != nil {
		return "", err
	}
	randomID := base64.URLEncoding.EncodeToString(randomBytes)

	now := time.Now()

	signingHash := crypto.SHA256
	var alg string
	switch issuer.SigningKey.KeyType() {
	case "RSA":
		alg = "RS256"
	case "EC":
		alg = "ES256"
	default:
		panic(fmt.Errorf("unsupported signing key type %q", issuer.SigningKey.KeyType()))
	}

	joseHeader := token.Header{
		Type:       "JWT",
		SigningAlg: alg,
	}

	if x5c := issuer.SigningKey.GetExtendedField("x5c"); x5c != nil {
		joseHeader.X5c = x5c.([]string)
	} else {
		var jwkMessage json.RawMessage
		jwkMessage, err = issuer.SigningKey.PublicKey().MarshalJSON()
		if err != nil {
			return "", err
		}
		joseHeader.RawJWK = &jwkMessage
	}

	exp := issuer.Expiration
	if exp == 0 {
		exp = 5 * time.Minute
	}

	claimSet := token.ClaimSet{
		Issuer:     issuer.Issuer,
		Subject:    subject,
		Audience:   []string{audience},
		Expiration: now.Add(exp).Unix(),
		NotBefore:  now.Unix(),
		IssuedAt:   now.Unix(),
		JWTID:      randomID,

		Access: accessEntries,
	}

	var (
		joseHeaderBytes []byte
		claimSetBytes   []byte
	)

	if joseHeaderBytes, err = json.Marshal(joseHeader); err != nil {
		return "", fmt.Errorf("unable to encode jose header: %s", err)
	}
	if claimSetBytes, err = json.Marshal(claimSet); err != nil {
		return "", fmt.Errorf("unable to encode claim set: %s", err)
	}

	encodedJoseHeader := joseBase64Encode(joseHeaderBytes)
	encodedClaimSet := joseBase64Encode(claimSetBytes)
	encodingToSign := fmt.Sprintf("%s.%s", encodedJoseHeader, encodedClaimSet)

	var signatureBytes []byte
	if signatureBytes, _, err = issuer.SigningKey.Sign(strings.NewReader(encodingToSign), signingHash); err != nil {
		return "", fmt.Errorf("unable to sign jwt payload: %s", err)
	}

	signature := joseBase64Encode(signatureBytes)

	return fmt.Sprintf("%s.%s", encodingToSign, signature), nil
}

func joseBase64Encode(data []byte) string {
	return strings.TrimRight(base64.URLEncoding.EncodeToString(data), "=")
}
