// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package openid

import (
	"errors"
	"fmt"
	"strconv"

	"code.vikunja.io/api/pkg/config"
	"code.vikunja.io/api/pkg/log"
	"code.vikunja.io/api/pkg/modules/keyvalue"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

// ErrDuplicateOIDCIssuer is returned when two configured providers resolve to the same issuer URL.
type ErrDuplicateOIDCIssuer struct {
	Issuer    string
	Provider1 string
	Provider2 string
}

func (e *ErrDuplicateOIDCIssuer) Error() string {
	return fmt.Sprintf(
		"duplicate OpenID Connect issuer %q: providers %q and %q resolve to the same issuer, which will cause team sync conflicts",
		e.Issuer, e.Provider1, e.Provider2,
	)
}

// IsErrDuplicateOIDCIssuer checks if an error is a duplicate issuer error.
func IsErrDuplicateOIDCIssuer(err error) bool {
	var target *ErrDuplicateOIDCIssuer
	return errors.As(err, &target)
}

// FindDuplicateIssuers checks a map of provider key → issuer URL for duplicates.
// It returns a list of all duplicate pairs found.
func FindDuplicateIssuers(providerIssuers map[string]string) []ErrDuplicateOIDCIssuer {
	issuerToKey := make(map[string]string)
	var duplicates []ErrDuplicateOIDCIssuer
	for key, issuer := range providerIssuers {
		if existingKey, exists := issuerToKey[issuer]; exists {
			duplicates = append(duplicates, ErrDuplicateOIDCIssuer{
				Issuer:    issuer,
				Provider1: existingKey,
				Provider2: key,
			})
		}
		issuerToKey[issuer] = key
	}
	return duplicates
}

// GetAllProviders returns all configured providers
func GetAllProviders() (providers []*Provider, err error) {
	if !config.AuthOpenIDEnabled.GetBool() {
		return nil, nil
	}

	providers = []*Provider{}
	exists, err := keyvalue.GetWithValue("openid_providers", &providers)
	if !exists {
		rawProviders := config.AuthOpenIDProviders.Get()
		if rawProviders == nil {
			return nil, nil
		}

		rawProvider, is := rawProviders.(map[string]interface{})
		if !is {
			// Try to convert from map[interface{}]interface{} (YAML format)
			if rawProviderInterface, ok := rawProviders.(map[interface{}]interface{}); ok {
				rawProvider = make(map[string]interface{}, len(rawProviderInterface))
				for k, v := range rawProviderInterface {
					if key, keyOK := k.(string); keyOK {
						rawProvider[key] = v
					}
				}
			} else {
				log.Criticalf("It looks like your openid configuration is in the wrong format. Please check the docs for the correct format.")
				return
			}
		}

		for key, p := range rawProvider {
			var pi map[string]interface{}
			var is bool
			pi, is = p.(map[string]interface{})
			// JSON config is a map[string]interface{}, other providers are not. Under the hood they are all strings so
			// it is safe to cast.
			if !is {
				if pis, pisOK := p.(map[interface{}]interface{}); pisOK {
					pi = make(map[string]interface{}, len(pis))
					for i, s := range pis {
						if key, keyOK := i.(string); keyOK {
							pi[key] = s
						}
					}
				} else {
					log.Errorf("Provider %s has invalid configuration format, skipping", key)
					continue
				}
			}

			provider, err := getProviderFromMap(pi, key)

			if err != nil {
				log.Errorf("Error while getting openid provider %s: %s", key, err)
				continue
			}

			if provider == nil {
				log.Errorf("Could not openid provider %s, please check your config", key)
				continue
			}

			providers = append(providers, provider)

			err = keyvalue.Put("openid_provider_"+key, provider)
			if err != nil {
				return nil, err
			}
		}

		// Check for duplicate issuers across providers
		providerIssuers := make(map[string]string)
		for _, p := range providers {
			issuer, issuerErr := p.Issuer()
			if issuerErr != nil {
				log.Errorf("Error getting issuer for openid provider %s: %s", p.Key, issuerErr)
				continue
			}
			providerIssuers[p.Key] = issuer
		}
		if duplicates := FindDuplicateIssuers(providerIssuers); len(duplicates) > 0 {
			return nil, &duplicates[0]
		}

		err = keyvalue.Put("openid_providers", providers)
	}

	return
}

// GetProvider retrieves a provider from keyvalue
func GetProvider(key string) (provider *Provider, err error) {
	provider = &Provider{}
	exists, err := keyvalue.GetWithValue("openid_provider_"+key, provider)
	if err != nil {
		return nil, err
	}
	if !exists {
		_, err = GetAllProviders() // This will put all providers in cache
		if err != nil {
			return nil, err
		}

		_, err = keyvalue.GetWithValue("openid_provider_"+key, provider)
		if err != nil {
			return nil, err
		}
	}

	err = provider.setOicdProvider()
	return
}

// parseBoolField reads a boolean-valued config field from a provider map,
// tolerating both native bools (from YAML/JSON) and strings (from env vars or
// the GetConfigValueFromFile path, which always return strings). Missing or
// empty values default to false with no error.
func parseBoolField(pi map[string]interface{}, key string) (val bool, err error) {
	raw, exists := pi[key]
	if !exists {
		return false, nil
	}
	switch v := raw.(type) {
	case bool:
		return v, nil
	case string:
		if v == "" {
			return false, nil
		}
		return strconv.ParseBool(v)
	default:
		return false, fmt.Errorf("expected bool, got %T", raw)
	}
}

func getProviderFromMap(pi map[string]interface{}, key string) (provider *Provider, err error) {

	requiredKeys := []string{
		"name",
		"authurl",
		"clientsecret",
		"clientid",
	}

	allKeys := append(
		[]string{
			"logouturl",
			"scope",
			"emailfallback",
			"usernamefallback",
			"forceuserinfo",
			"requireavailability",
		},
		requiredKeys...,
	)

	for _, configKey := range allKeys {
		valueFromFile := config.GetConfigValueFromFile("auth.openid.providers." + key + "." + configKey)
		if valueFromFile != "" {
			pi[configKey] = valueFromFile
		}
	}

	for _, key := range requiredKeys {
		if _, exists := pi[key]; !exists {
			return nil, fmt.Errorf("required key '%s' is missing in the provider configuration", key)
		}
	}

	name, is := pi["name"].(string)
	if !is {
		return nil, nil
	}

	var logoutURL string
	logoutValue, exists := pi["logouturl"]
	if exists {
		url, ok := logoutValue.(string)
		if ok {
			logoutURL = url
		}
	}

	var scope string
	if scopeValue, exists := pi["scope"]; exists {
		scope = scopeValue.(string)
	}
	if scope == "" {
		scope = "openid profile email"
	}

	emailFallback, err := parseBoolField(pi, "emailfallback")
	if err != nil {
		log.Errorf("emailfallback is not a boolean for provider %s: %s", key, err)
	}
	usernameFallback, err := parseBoolField(pi, "usernamefallback")
	if err != nil {
		log.Errorf("usernamefallback is not a boolean for provider %s: %s", key, err)
	}
	forceUserInfo, err := parseBoolField(pi, "forceuserinfo")
	if err != nil {
		log.Errorf("forceuserinfo is not a boolean for provider %s: %s", key, err)
	}
	requireAvailability, err := parseBoolField(pi, "requireavailability")
	if err != nil {
		log.Errorf("requireavailability is not a boolean for provider %s: %s", key, err)
	}

	provider = &Provider{
		Name:                name,
		Key:                 key,
		AuthURL:             pi["authurl"].(string),
		OriginalAuthURL:     pi["authurl"].(string),
		ClientSecret:        pi["clientsecret"].(string),
		LogoutURL:           logoutURL,
		Scope:               scope,
		EmailFallback:       emailFallback,
		UsernameFallback:    usernameFallback,
		ForceUserInfo:       forceUserInfo,
		RequireAvailability: requireAvailability,
	}

	cl, is := pi["clientid"].(int)
	if is {
		provider.ClientID = strconv.Itoa(cl)
	} else {
		provider.ClientID = pi["clientid"].(string)
	}

	err = provider.setOicdProvider()
	if err != nil {
		return
	}

	provider.Oauth2Config = &oauth2.Config{
		ClientID:     provider.ClientID,
		ClientSecret: provider.ClientSecret,
		// Discovery returns the OAuth2 endpoints.
		Endpoint: provider.openIDProvider.Endpoint(),

		// "openid" is a required scope for OpenID Connect flows.
		Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
	}

	provider.AuthURL = provider.Oauth2Config.Endpoint.AuthURL

	return
}

func CleanupSavedOpenIDProviders() {
	_ = keyvalue.Del("openid_providers")
}
