/*
 * 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 bootstrap

import (
	"context"
	"crypto"
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"fmt"
	"io/ioutil"
	"net"
	"net/url"
	"os"
	"path/filepath"
	"strings"
	"time"

	"github.com/golang-jwt/jwt/v4"
	"github.com/longsleep/rndm"
	"github.com/sirupsen/logrus"

	"github.com/libregraph/lico/config"
	"github.com/libregraph/lico/encryption"
	"github.com/libregraph/lico/identity"
	"github.com/libregraph/lico/managers"
	oidcProvider "github.com/libregraph/lico/oidc/provider"
	"github.com/libregraph/lico/utils"
)

// API types.
type APIType string

const (
	APITypeKonnect APIType = "konnect"
	APITypeSignin  APIType = "signin"
)

// Defaults.
const (
	DefaultSigningKeyID   = "default"
	DefaultSigningKeyBits = 2048

	DefaultGuestIdentityManagerName = "guest"
)

// Bootstrap is a data structure to hold configuration required to start
// konnectd.
type Bootstrap interface {
	Config() *Config
	Managers() *managers.Managers

	MakeURIPath(api APIType, subpath string) string
}

// Implementation of the bootstrap interface.
type bootstrap struct {
	config *Config

	uriBasePath string

	managers *managers.Managers
}

// Config returns the bootstap configuration.
func (bs *bootstrap) Config() *Config {
	return bs.config
}

// Managers returns bootstrapped identity-managers.
func (bs *bootstrap) Managers() *managers.Managers {
	return bs.managers
}

// Boot is the main entry point to bootstrap the service after validating the
// given configuration. The resulting Bootstrap struct can be used to retrieve
// configured identity-managers and their respective http-handlers and config.
//
// This function should be used by consumers which want to embed this project
// as a library.
func Boot(ctx context.Context, settings *Settings, cfg *config.Config) (Bootstrap, error) {
	// NOTE(longsleep): Ensure to use same salt length as the hash size.
	// See https://www.ietf.org/mail-archive/web/jose/current/msg02901.html for
	// reference and https://github.com/golang-jwt/jwt/v4/issues/285 for
	// the issue in upstream jwt-go.
	for _, alg := range []string{jwt.SigningMethodPS256.Name, jwt.SigningMethodPS384.Name, jwt.SigningMethodPS512.Name} {
		sm := jwt.GetSigningMethod(alg)
		if signingMethodRSAPSS, ok := sm.(*jwt.SigningMethodRSAPSS); ok {
			signingMethodRSAPSS.Options.SaltLength = rsa.PSSSaltLengthEqualsHash
		}
	}

	bs := &bootstrap{
		config: &Config{
			Config:   cfg,
			Settings: settings,
		},
	}

	err := bs.initialize(settings)
	if err != nil {
		return nil, err
	}

	err = bs.setup(ctx, settings)
	if err != nil {
		return nil, err
	}

	return bs, nil
}

// initialize, parsed parameters from commandline with validation and adds them
// to the associated Bootstrap data.
func (bs *bootstrap) initialize(settings *Settings) error {
	logger := bs.config.Config.Logger
	var err error

	if settings.IdentityManager == "" {
		return fmt.Errorf("identity-manager argument missing, use one of kc, ldap, cookie, dummy")
	}

	bs.config.IssuerIdentifierURI, err = url.Parse(settings.Iss)
	if err != nil {
		return fmt.Errorf("invalid iss value, iss is not a valid URL), %v", err)
	} else if settings.Iss == "" {
		return fmt.Errorf("missing iss value, did you provide the --iss parameter?")
	} else if bs.config.IssuerIdentifierURI.Scheme != "https" {
		return fmt.Errorf("invalid iss value, URL must start with https://")
	} else if bs.config.IssuerIdentifierURI.Host == "" {
		return fmt.Errorf("invalid iss value, URL must have a host")
	}

	bs.uriBasePath = settings.URIBasePath

	bs.config.SignInFormURI, err = url.Parse(settings.SignInURI)
	if err != nil {
		return fmt.Errorf("invalid sign-in URI, %v", err)
	}

	bs.config.SignedOutURI, err = url.Parse(settings.SignedOutURI)
	if err != nil {
		return fmt.Errorf("invalid signed-out URI, %v", err)
	}

	bs.config.AuthorizationEndpointURI, err = url.Parse(settings.AuthorizationEndpointURI)
	if err != nil {
		return fmt.Errorf("invalid authorization-endpoint-uri, %v", err)
	}

	bs.config.EndSessionEndpointURI, err = url.Parse(settings.EndsessionEndpointURI)
	if err != nil {
		return fmt.Errorf("invalid endsession-endpoint-uri, %v", err)
	}

	if settings.Insecure {
		// NOTE(longsleep): This disable http2 client support. See https://github.com/golang/go/issues/14275 for reasons.
		bs.config.TLSClientConfig = utils.InsecureSkipVerifyTLSConfig()
		logger.Warnln("insecure mode, TLS client connections are susceptible to man-in-the-middle attacks")
	} else {
		bs.config.TLSClientConfig = utils.DefaultTLSConfig()
	}

	for _, trustedProxy := range settings.TrustedProxy {
		if ip := net.ParseIP(trustedProxy); ip != nil {
			bs.config.Config.TrustedProxyIPs = append(bs.config.Config.TrustedProxyIPs, &ip)
			continue
		}
		if _, ipNet, errParseCIDR := net.ParseCIDR(trustedProxy); errParseCIDR == nil {
			bs.config.Config.TrustedProxyNets = append(bs.config.Config.TrustedProxyNets, ipNet)
			continue
		}
	}
	if len(bs.config.Config.TrustedProxyIPs) > 0 {
		logger.Infoln("trusted proxy IPs", bs.config.Config.TrustedProxyIPs)
	}
	if len(bs.config.Config.TrustedProxyNets) > 0 {
		logger.Infoln("trusted proxy networks", bs.config.Config.TrustedProxyNets)
	}

	if len(settings.AllowScope) > 0 {
		bs.config.Config.AllowedScopes = settings.AllowScope
		logger.Infoln("using custom allowed OAuth 2 scopes", bs.config.Config.AllowedScopes)
	}

	bs.config.Config.AllowClientGuests = settings.AllowClientGuests
	if bs.config.Config.AllowClientGuests {
		logger.Infoln("client controlled guests are enabled")
	}

	bs.config.Config.AllowDynamicClientRegistration = settings.AllowDynamicClientRegistration
	if bs.config.Config.AllowDynamicClientRegistration {
		logger.Infoln("dynamic client registration is enabled")
	}

	encryptionSecretFn := settings.EncryptionSecretFile

	if encryptionSecretFn != "" {
		logger.WithField("file", encryptionSecretFn).Infoln("loading encryption secret from file")
		bs.config.EncryptionSecret, err = ioutil.ReadFile(encryptionSecretFn)
		if err != nil {
			return fmt.Errorf("failed to load encryption secret from file: %v", err)
		}
		if len(bs.config.EncryptionSecret) != encryption.KeySize {
			return fmt.Errorf("invalid encryption secret size - must be %d bytes", encryption.KeySize)
		}
	} else {
		logger.Warnf("missing --encryption-secret parameter, using random encyption secret with %d bytes", encryption.KeySize)
		bs.config.EncryptionSecret = rndm.GenerateRandomBytes(encryption.KeySize)
	}

	bs.config.Config.ListenAddr = settings.Listen

	bs.config.IdentifierClientDisabled = settings.IdentifierClientDisabled
	bs.config.IdentifierClientPath = settings.IdentifierClientPath

	bs.config.IdentifierRegistrationConf = settings.IdentifierRegistrationConf
	if bs.config.IdentifierRegistrationConf != "" {
		bs.config.IdentifierRegistrationConf, _ = filepath.Abs(bs.config.IdentifierRegistrationConf)
		if _, errStat := os.Stat(bs.config.IdentifierRegistrationConf); errStat != nil {
			return fmt.Errorf("identifier-registration-conf file not found or unable to access: %v", errStat)
		}
		bs.config.IdentifierAuthoritiesConf = bs.config.IdentifierRegistrationConf
	}

	bs.config.IdentifierScopesConf = settings.IdentifierScopesConf
	if bs.config.IdentifierScopesConf != "" {
		bs.config.IdentifierScopesConf, _ = filepath.Abs(bs.config.IdentifierScopesConf)
		if _, errStat := os.Stat(bs.config.IdentifierScopesConf); errStat != nil {
			return fmt.Errorf("identifier-scopes-conf file not found or unable to access: %v", errStat)
		}
	}

	if settings.IdentifierDefaultBannerLogo != "" {
		// Load from file.
		b, errRead := ioutil.ReadFile(settings.IdentifierDefaultBannerLogo)
		if errRead != nil {
			return fmt.Errorf("identifier-default-banner-logo failed to open: %w", errRead)
		}
		bs.config.IdentifierDefaultBannerLogo = b
	}
	if settings.IdentifierDefaultSignInPageText != "" {
		bs.config.IdentifierDefaultSignInPageText = &settings.IdentifierDefaultSignInPageText
	}
	if settings.IdentifierDefaultUsernameHintText != "" {
		bs.config.IdentifierDefaultUsernameHintText = &settings.IdentifierDefaultUsernameHintText
	}
	bs.config.IdentifierUILocales = settings.IdentifierUILocales

	bs.config.SigningKeyID = settings.SigningKid
	bs.config.Signers = make(map[string]crypto.Signer)
	bs.config.Validators = make(map[string]crypto.PublicKey)
	bs.config.Certificates = make(map[string][]*x509.Certificate)

	signingMethodString := settings.SigningMethod
	bs.config.SigningMethod = jwt.GetSigningMethod(signingMethodString)
	if bs.config.SigningMethod == nil {
		return fmt.Errorf("unknown signing method: %s", signingMethodString)
	}

	signingKeyFns := settings.SigningPrivateKeyFiles
	if len(signingKeyFns) > 0 {
		first := true
		for _, signingKeyFn := range signingKeyFns {
			logger.WithField("path", signingKeyFn).Infoln("loading signing key")
			err = addSignerWithIDFromFile(signingKeyFn, "", bs)
			if err != nil {
				return err
			}
			if first {
				// Also add key under the provided id.
				first = false
				err = addSignerWithIDFromFile(signingKeyFn, bs.config.SigningKeyID, bs)
				if err != nil {
					return err
				}
			}
		}
	} else {
		//NOTE(longsleep): remove me - create keypair a random key pair.
		sm := jwt.SigningMethodPS256
		bs.config.SigningMethod = sm
		logger.WithField("alg", sm.Name).Warnf("missing --signing-private-key parameter, using random %d bit signing key", DefaultSigningKeyBits)
		signer, _ := rsa.GenerateKey(rand.Reader, DefaultSigningKeyBits)
		bs.config.Signers[bs.config.SigningKeyID] = signer
	}

	// Ensure we have a signer for the things we need.
	err = validateSigners(bs)
	if err != nil {
		return err
	}

	validationKeysPath := settings.ValidationKeysPath
	if validationKeysPath != "" {
		logger.WithField("path", validationKeysPath).Infoln("loading validation keys")
		err = addValidatorsFromPath(validationKeysPath, bs)
		if err != nil {
			return err
		}
	}

	bs.config.Config.HTTPTransport = utils.HTTPTransportWithTLSClientConfig(bs.config.TLSClientConfig)

	bs.config.AccessTokenDurationSeconds = settings.AccessTokenDurationSeconds
	if bs.config.AccessTokenDurationSeconds == 0 {
		bs.config.AccessTokenDurationSeconds = 60 * 10 // 10 Minutes
	}
	bs.config.IDTokenDurationSeconds = settings.IDTokenDurationSeconds
	if bs.config.IDTokenDurationSeconds == 0 {
		bs.config.IDTokenDurationSeconds = 60 * 60 // 1 Hour
	}
	bs.config.RefreshTokenDurationSeconds = settings.RefreshTokenDurationSeconds
	if bs.config.RefreshTokenDurationSeconds == 0 {
		bs.config.RefreshTokenDurationSeconds = 60 * 60 * 24 * 365 * 3 // 3 Years
	}
	bs.config.DyamicClientSecretDurationSeconds = settings.DyamicClientSecretDurationSeconds

	return nil
}

// setup takes care of setting up the managers based on the associated
// Bootstrap's data.
func (bs *bootstrap) setup(ctx context.Context, settings *Settings) error {
	managers, err := newManagers(ctx, bs)
	if err != nil {
		return err
	}
	bs.managers = managers

	identityManager, err := bs.setupIdentity(ctx, settings)
	if err != nil {
		return err
	}
	managers.Set("identity", identityManager)

	guestManager, err := bs.setupGuest(ctx, identityManager)
	if err != nil {
		return err
	}
	managers.Set("guest", guestManager)

	oidcProvider, err := bs.setupOIDCProvider(ctx)
	if err != nil {
		return err
	}
	managers.Set("oidc", oidcProvider)
	managers.Set("handler", oidcProvider) // Use OIDC provider as default HTTP handler.

	err = managers.Apply()
	if err != nil {
		return fmt.Errorf("failed to apply managers: %v", err)
	}

	// Final steps
	err = oidcProvider.InitializeMetadata()
	if err != nil {
		return fmt.Errorf("failed to initialize provider metadata: %v", err)
	}

	return nil
}

func (bs *bootstrap) MakeURIPath(api APIType, subpath string) string {
	subpath = strings.TrimPrefix(subpath, "/")
	uriPath := ""

	switch api {
	case APITypeKonnect:
		uriPath = fmt.Sprintf("%s/konnect/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
	case APITypeSignin:
		uriPath = fmt.Sprintf("%s/signin/v1/%s", strings.TrimSuffix(bs.uriBasePath, "/"), subpath)
	default:
		panic("unknown api type")
	}

	if subpath == "" {
		uriPath = strings.TrimSuffix(uriPath, "/")
	}
	return uriPath
}

func (bs *bootstrap) MakeURI(api APIType, subpath string) *url.URL {
	uriPath := bs.MakeURIPath(api, subpath)
	uri, _ := url.Parse(bs.config.IssuerIdentifierURI.String())
	uri.Path = uriPath

	return uri
}

func (bs *bootstrap) setupIdentity(ctx context.Context, settings *Settings) (identity.Manager, error) {
	logger := bs.config.Config.Logger

	if settings.IdentityManager == "" {
		return nil, fmt.Errorf("identity-manager argument missing")
	}

	// Identity manager.
	identityManagerName := settings.IdentityManager
	identityManager, err := getIdentityManagerByName(identityManagerName, bs)
	if err != nil {
		return nil, err
	}
	logger.WithFields(logrus.Fields{
		"name":   identityManagerName,
		"scopes": identityManager.ScopesSupported(nil),
		"claims": identityManager.ClaimsSupported(nil),
	}).Infoln("identity manager set up")

	return identityManager, nil
}

func (bs *bootstrap) setupGuest(ctx context.Context, identityManager identity.Manager) (identity.Manager, error) {
	if !bs.config.Config.AllowClientGuests {
		return nil, nil
	}

	var err error
	logger := bs.config.Config.Logger

	guestManager, err := getIdentityManagerByName(DefaultGuestIdentityManagerName, bs)
	if err != nil {
		return nil, err
	}

	if guestManager != nil {
		logger.Infoln("identity guest manager set up")
	}
	return guestManager, nil
}

func (bs *bootstrap) setupOIDCProvider(ctx context.Context) (*oidcProvider.Provider, error) {
	var err error
	logger := bs.config.Config.Logger

	sessionCookiePath, err := getCommonURLPathPrefix(bs.config.AuthorizationEndpointURI.EscapedPath(), bs.config.EndSessionEndpointURI.EscapedPath())
	if err != nil {
		return nil, fmt.Errorf("failed to find common URL prefix for authorize and endsession: %v", err)
	}

	var registrationPath = ""
	if bs.config.Config.AllowDynamicClientRegistration {
		registrationPath = bs.MakeURIPath(APITypeKonnect, "/register")
	}

	provider, err := oidcProvider.NewProvider(&oidcProvider.Config{
		Config: bs.config.Config,

		IssuerIdentifier:       bs.config.IssuerIdentifierURI.String(),
		WellKnownPath:          "/.well-known/openid-configuration",
		JwksPath:               bs.MakeURIPath(APITypeKonnect, "/jwks.json"),
		AuthorizationPath:      bs.config.AuthorizationEndpointURI.EscapedPath(),
		TokenPath:              bs.MakeURIPath(APITypeKonnect, "/token"),
		UserInfoPath:           bs.MakeURIPath(APITypeKonnect, "/userinfo"),
		EndSessionPath:         bs.config.EndSessionEndpointURI.EscapedPath(),
		CheckSessionIframePath: bs.MakeURIPath(APITypeKonnect, "/session/check-session.html"),
		RegistrationPath:       registrationPath,

		BrowserStateCookiePath: bs.MakeURIPath(APITypeKonnect, "/session/"),
		BrowserStateCookieName: "__Secure-KKBS", // Kopano-Konnect-Browser-State

		SessionCookiePath: sessionCookiePath,
		SessionCookieName: "__Secure-KKCS", // Kopano-Konnect-Client-Session

		AccessTokenDuration:  time.Duration(bs.config.AccessTokenDurationSeconds) * time.Second,
		IDTokenDuration:      time.Duration(bs.config.IDTokenDurationSeconds) * time.Second,
		RefreshTokenDuration: time.Duration(bs.config.RefreshTokenDurationSeconds) * time.Second,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to create provider: %v", err)
	}
	if bs.config.SigningMethod != nil {
		err = provider.SetSigningMethod(bs.config.SigningMethod)
		if err != nil {
			return nil, fmt.Errorf("failed to set provider signing method: %v", err)
		}
	}

	// All add signers.
	for id, signer := range bs.config.Signers {
		if id == bs.config.SigningKeyID {
			err = provider.SetSigningKey(id, signer)
			// Always set default key.
			if id != DefaultSigningKeyID {
				provider.SetValidationKey(DefaultSigningKeyID, signer.Public())
			}
		} else {
			// Set non default signers as well.
			err = provider.SetSigningKey(id, signer)
		}
		if err != nil {
			return nil, err
		}
	}
	// Add all validators.
	for id, publicKey := range bs.config.Validators {
		err = provider.SetValidationKey(id, publicKey)
		if err != nil {
			return nil, err
		}
	}
	// Add all certificates.
	for id, certificate := range bs.config.Certificates {
		err = provider.SetCertificate(id, certificate)
		if err != nil {
			return nil, err
		}
	}

	sk, ok := provider.GetSigningKey(bs.config.SigningMethod)
	if !ok {
		return nil, fmt.Errorf("no signing key for selected signing method")
	}
	if bs.config.SigningKeyID == "" {
		// Ensure that there is a default signing Key ID even if none was set.
		provider.SetValidationKey(DefaultSigningKeyID, sk.PrivateKey.Public())
	}
	logger.WithFields(logrus.Fields{
		"id":     sk.ID,
		"method": fmt.Sprintf("%T", sk.SigningMethod),
		"alg":    sk.SigningMethod.Alg(),
	}).Infoln("oidc token signing default set up")

	return provider, nil
}
