/*
 * Copyright 2017-2019 Kopano and its licensors
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */

package clients

import (
	"context"
	"errors"
	"fmt"
	"io/ioutil"
	"net/url"
	"strings"
	"sync"
	"time"

	"github.com/golang-jwt/jwt/v4"
	"github.com/libregraph/oidc-go"
	"github.com/sirupsen/logrus"
	"gopkg.in/yaml.v2"
)

// Registry implements the registry for registered clients.
type Registry struct {
	mutex sync.RWMutex

	trustedURI *url.URL
	clients    map[string]*ClientRegistration

	allowDynamicClientRegistration bool
	dynamicClientSecretDuration    time.Duration

	StatelessCreator   func(ctx context.Context, signingMethod jwt.SigningMethod, claims jwt.Claims) (string, error)
	StatelessValidator func(token *jwt.Token) (interface{}, error)

	logger logrus.FieldLogger
}

// contextKey is an unexported type for keys defined in this package.
// This prevents collisions with keys defined in other packages.
type contextKey int

// claimsKey is the key for claims in contexts. It is
// unexported; clients use konnect.NewClaimsContext and
// connect.FromClaimsContext instead of using this key directly.
var registryKey contextKey

// NewRegistry created a new client Registry with the provided parameters.
func NewRegistry(ctx context.Context, trustedURI *url.URL, registrationConfFilepath string, allowDynamicClientRegistration bool, dynamicClientSecretDuration time.Duration, logger logrus.FieldLogger) (*Registry, error) {
	registryData := &RegistryData{}

	if registrationConfFilepath != "" {
		logger.Debugf("parsing identifier registration conf from %v", registrationConfFilepath)
		registryFile, err := ioutil.ReadFile(registrationConfFilepath)
		if err != nil {
			return nil, err
		}

		err = yaml.Unmarshal(registryFile, registryData)
		if err != nil {
			return nil, err
		}
	}

	r := &Registry{
		trustedURI: trustedURI,
		clients:    make(map[string]*ClientRegistration),

		allowDynamicClientRegistration: allowDynamicClientRegistration,
		dynamicClientSecretDuration:    dynamicClientSecretDuration,

		logger: logger,
	}

	for _, client := range registryData.Clients {
		validateErr := client.Validate()
		registerErr := r.Register(client)
		fields := logrus.Fields{
			"client_id":          client.ID,
			"with_client_secret": client.Secret != "",
			"trusted":            client.Trusted,
			"insecure":           client.Insecure,
			"application_type":   client.ApplicationType,
			"redirect_uris":      client.RedirectURIs,
			"origins":            client.Origins,
		}

		if validateErr != nil {
			logger.WithError(validateErr).WithFields(fields).Warnln("skipped registration of invalid client entry")
			continue
		}
		if registerErr != nil {
			logger.WithError(registerErr).WithFields(fields).Warnln("skipped registration of invalid client")
			continue
		}
		logger.WithFields(fields).Debugln("registered client")
	}

	return r, nil
}

// NewRegistryContext returns a new Context that carries value provided Registry.
func NewRegistryContext(ctx context.Context, r *Registry) context.Context {
	return context.WithValue(ctx, registryKey, r)
}

// FromRegistryContext returns the Registry value stored in ctx, if any.
func FromRegistryContext(ctx context.Context) (*Registry, bool) {
	r, ok := ctx.Value(registryKey).(*Registry)
	return r, ok
}

// Register validates the provided client registration and adds the client
// to the accociated registry if valid. Returns error otherwise.
func (r *Registry) Register(client *ClientRegistration) error {
	if client.ID == "" {
		return errors.New("invalid client_id")
	}

	if !client.Insecure && len(client.RedirectURIs) == 0 {
		return errors.New("no redirect_uris")
	}

	switch client.ApplicationType {
	case "":
		client.ApplicationType = oidc.ApplicationTypeWeb
		fallthrough
	case oidc.ApplicationTypeWeb:
		for _, urlString := range client.RedirectURIs {
			// http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
			parsed, _ := url.Parse(urlString)
			if parsed == nil || parsed.Host == "" {
				return fmt.Errorf("invalid redirect_uri %v - invalid or no hostname", urlString)
			} else if !client.Insecure && parsed.Scheme != "https" {
				return fmt.Errorf("invalid redirect_uri %v - make sure to use https when application_type is web", parsed)
			} else if IsLocalNativeHTTPURI(parsed) {
				return fmt.Errorf("invalid redirect_uri %v - host must not be localhost when application_type is web", parsed)
			}

			if len(client.Origins) == 0 {
				// Auto add first redirect scheme and host as Origin if no
				// origin is explicitly configured.
				client.Origins = append(client.Origins, parsed.Scheme+"://"+parsed.Host)
			}
		}
		if !client.Insecure && len(client.Origins) == 0 {
			return errors.New("no origins - origin is required when application_type is web")
		}
		// breaks

	case oidc.ApplicationTypeNative:
		// breaks
		for _, urlString := range client.RedirectURIs {
			// http://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata
			parsed, _ := url.Parse(urlString)
			if parsed == nil || parsed.Host == "" {
				return fmt.Errorf("invalid redirect_uri %v - invalid uri or no hostname", urlString)
			} else if parsed.Scheme == "https" {
				return fmt.Errorf("invalid redirect_uri %v - scheme must not be https when application_type is native", parsed)
			} else if parsed.Scheme == "http" && !IsLocalNativeHTTPURI(parsed) {
				return fmt.Errorf("invalid redirect_uri %v = http host must be localhost when application_type is native", parsed)
			}
		}
		// breaks

	default:
		return fmt.Errorf("unknown application_type: %v", client.ApplicationType)
	}

	r.mutex.Lock()
	defer r.mutex.Unlock()
	r.clients[client.ID] = client
	return nil
}

// Validate checks if the provided client registration data complies to the
// provided parameters and returns error when it does not.
func (r *Registry) Validate(client *ClientRegistration, clientSecret string, redirectURIString string, originURIString string, withoutSecret bool) error {
	if client.ApplicationType == oidc.ApplicationTypeWeb {
		if originURIString != "" && (!client.Insecure || len(client.Origins) > 0) {
			// Compare originURI if it was given.
			originOK := false
			for _, urlString := range client.Origins {
				if urlString == originURIString {
					originOK = true
					break
				}
			}
			if !originOK {
				return fmt.Errorf("invalid origin: %v", originURIString)
			}
		}
	}

	if redirectURIString != "" && (!client.Insecure || len(client.RedirectURIs) > 0) {
		// Make sure to validate the redirect URI unless client is marked insecure
		// and has no configured redirect URIs.
		redirectURIOK := false
		for _, registeredURIString := range client.RedirectURIs {
			if client.ApplicationType == oidc.ApplicationTypeNative {
				registeredURI, _ := url.Parse(registeredURIString)
				if IsLocalNativeHTTPURI(registeredURI) {
					redirectURI, err := url.Parse(redirectURIString)
					if err != nil {
						break
					}
					if IsLocalNativeHTTPURI(redirectURI) {
						if registeredURI.Path == "" || redirectURI.Path == registeredURI.Path {
							redirectURIOK = true
							break
						}
					}
					continue
				}
			}
			if registeredURIString == redirectURIString {
				redirectURIOK = true
				break
			}
		}
		if !redirectURIOK {
			return fmt.Errorf("invalid redirect_uri: %v", redirectURIString)
		}
	}

	if !withoutSecret {
		if valid, err := client.validateSecret(clientSecret); !valid {
			return fmt.Errorf("invalid client_secret: %v", err)
		}
	}

	return nil
}

// Lookup returns and validates the clients Detail information for the provided
// parameters from the accociated registry.
func (r *Registry) Lookup(ctx context.Context, clientID string, clientSecret string, redirectURI *url.URL, originURIString string, withoutSecret bool) (*Details, error) {
	var err error
	var trusted bool
	var dynamic bool
	var displayName string

	if clientID == "" {
		return nil, fmt.Errorf("invalid client_id")
	}

	originURI, _ := url.Parse(originURIString)

	// Implicit trust for web clients running and redirecting to the same origin
	// as the issuer (ourselves).
	if r.trustedURI != nil {
		for {
			if r.trustedURI.Scheme != redirectURI.Scheme || r.trustedURI.Host != redirectURI.Host {
				break
			}
			if originURI.Scheme != "" && (r.trustedURI.Scheme != originURI.Scheme || r.trustedURI.Host != originURI.Host) {
				break
			}
			trusted = true
			break
		}
	}

	// Lookup client registration.
	r.mutex.RLock()
	registration, _ := r.clients[clientID]
	r.mutex.RUnlock()

	if registration == nil && strings.HasPrefix(clientID, DynamicStatelessClientIDPrefix) {
		trusted = false
		dynamic = true
	}

	// Lookup dynamic clients when it makes sense.
	if dynamic && registration == nil {
		registration, _ = r.getDynamicClient(clientID)
	}

	if registration != nil {
		redirectURIBase := &url.URL{
			Scheme: redirectURI.Scheme,
			Host:   redirectURI.Host,
			Path:   redirectURI.Path,
		}
		err = r.Validate(registration, clientSecret, redirectURIBase.String(), originURIString, withoutSecret)
		displayName = registration.Name
		trusted = registration.Trusted
	} else {
		if trusted {
			// Always let in implicitly trusted clients.
			err = nil
		} else {
			err = fmt.Errorf("unknown client_id: %v", clientID)
		}
	}

	if err != nil {
		return nil, err
	}

	redirecURIString := redirectURI.String()

	r.logger.WithFields(logrus.Fields{
		"trusted":      trusted,
		"client_id":    clientID,
		"redirect_uri": redirecURIString,
		"known":        registration != nil,
	}).Debugln("identifier client lookup")

	return &Details{
		ID:          clientID,
		RedirectURI: redirecURIString,
		DisplayName: displayName,
		Trusted:     trusted,

		Registration: registration,
	}, nil
}

// Get returns the registered clients registration for the provided client ID.
func (r *Registry) Get(ctx context.Context, clientID string) (*ClientRegistration, bool) {
	// Lookup client registration.
	r.mutex.RLock()
	registration, ok := r.clients[clientID]
	r.mutex.RUnlock()
	if ok {
		return registration, true
	}

	return r.getDynamicClient(clientID)
}

func (r *Registry) getDynamicClient(clientID string) (*ClientRegistration, bool) {
	var registration *ClientRegistration

	if len(clientID) >= len(DynamicStatelessClientIDPrefix) {
		tokenString := clientID[len(DynamicStatelessClientIDPrefix):]
		if token, err := jwt.ParseWithClaims(tokenString, &RegistrationClaims{}, func(token *jwt.Token) (interface{}, error) {
			if r.StatelessValidator == nil {
				return nil, fmt.Errorf("no validator for dynamic client ids")
			}
			return r.StatelessValidator(token)
		}); err == nil {
			if claims, ok := token.Claims.(*RegistrationClaims); ok && token.Valid {
				// TODO(longsleep): Add secure client secret.
				registration = claims.ClientRegistration
				registration.ID = clientID
				registration.Secret = claims.StandardClaims.Subject
				registration.Dynamic = true
			}
		}
	}

	return registration, registration != nil
}
