// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-2021 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 Licensee 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 Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package openid

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"math/rand"
	"net/http"
	"time"

	"code.vikunja.io/web/handler"

	"code.vikunja.io/api/pkg/db"
	"xorm.io/xorm"

	"code.vikunja.io/api/pkg/log"
	"code.vikunja.io/api/pkg/models"
	"code.vikunja.io/api/pkg/modules/auth"
	"code.vikunja.io/api/pkg/user"
	"github.com/coreos/go-oidc/v3/oidc"
	petname "github.com/dustinkirkland/golang-petname"
	"github.com/labstack/echo/v4"
	"golang.org/x/oauth2"
)

// Callback contains the callback after an auth request was made and redirected
type Callback struct {
	Code  string `query:"code" json:"code"`
	Scope string `query:"scop" json:"scope"`
}

// Provider is the structure of an OpenID Connect provider
type Provider struct {
	Name            string `json:"name"`
	Key             string `json:"key"`
	OriginalAuthURL string `json:"-"`
	AuthURL         string `json:"auth_url"`
	ClientID        string `json:"client_id"`
	Scope           string `json:"scope"`
	ClientSecret    string `json:"-"`
	openIDProvider  *oidc.Provider
	Oauth2Config    *oauth2.Config `json:"-"`
}
type TeamData struct {
	TeamName    string
	OidcID      string
	Description string
}
type claims struct {
	Email             string      `json:"email"`
	Name              string      `json:"name"`
	PreferredUsername string      `json:"preferred_username"`
	Nickname          string      `json:"nickname"`
	Teams             []string    `json:"groups"`
	VikunjaGroups     interface{} `json:"vikunja_groups"`
}

func init() {
	rand.Seed(time.Now().UTC().UnixNano())
}

func (p *Provider) setOicdProvider() (err error) {
	p.openIDProvider, err = oidc.NewProvider(context.Background(), p.OriginalAuthURL)
	return err
}

// HandleCallback handles the auth request callback after redirecting from the provider with an auth code
// @Summary Authenticate a user with OpenID Connect
// @Description After a redirect from the OpenID Connect provider to the frontend has been made with the authentication `code`, this endpoint can be used to obtain a jwt token for that user and thus log them in.
// @tags auth
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param callback body openid.Callback true "The openid callback"
// @Param provider path int true "The OpenID Connect provider key as returned by the /info endpoint"
// @Success 200 {object} auth.Token
// @Failure 500 {object} models.Message "Internal error"
// @Router /auth/openid/{provider}/callback [post]
func HandleCallback(c echo.Context) error {
	cb := &Callback{}
	if err := c.Bind(cb); err != nil {
		return c.JSON(http.StatusBadRequest, models.Message{Message: "Bad data"})
	}

	// Check if the provider exists
	providerKey := c.Param("provider")
	provider, err := GetProvider(providerKey)
	if err != nil {
		log.Error(err)
		return handler.HandleHTTPError(err, c)
	}
	if provider == nil {
		return c.JSON(http.StatusBadRequest, models.Message{Message: "Provider does not exist"})
	}

	// Parse the access & ID token
	oauth2Token, err := provider.Oauth2Config.Exchange(context.Background(), cb.Code)
	if err != nil {
		var rerr *oauth2.RetrieveError
		if errors.As(err, &rerr) {
			log.Error(err)

			details := make(map[string]interface{})
			if err := json.Unmarshal(rerr.Body, &details); err != nil {
				log.Errorf("Error unmarshalling token for provider %s: %v", provider.Name, err)
				return handler.HandleHTTPError(err, c)
			}

			return c.JSON(http.StatusBadRequest, map[string]interface{}{
				"message": "Could not authenticate against third party.",
				"details": details,
			})
		}

		return handler.HandleHTTPError(err, c)
	}

	// Extract the ID Token from OAuth2 token.
	rawIDToken, ok := oauth2Token.Extra("id_token").(string)
	if !ok {
		return c.JSON(http.StatusBadRequest, models.Message{Message: "Missing token"})
	}

	verifier := provider.openIDProvider.Verifier(&oidc.Config{ClientID: provider.ClientID})

	// Parse and verify ID Token payload.
	idToken, err := verifier.Verify(context.Background(), rawIDToken)
	if err != nil {
		log.Errorf("Error verifying token for provider %s: %v", provider.Name, err)
		return handler.HandleHTTPError(err, c)
	}

	// Extract custom claims
	cl := &claims{}

	err = idToken.Claims(cl)
	if err != nil {
		log.Errorf("Error getting token claims for provider %s: %v", provider.Name, err)
		return handler.HandleHTTPError(err, c)
	}

	if cl.Email == "" || cl.Name == "" || cl.PreferredUsername == "" {
		info, err := provider.openIDProvider.UserInfo(context.Background(), provider.Oauth2Config.TokenSource(context.Background(), oauth2Token))
		if err != nil {
			log.Errorf("Error getting userinfo for provider %s: %v", provider.Name, err)
			return handler.HandleHTTPError(err, c)
		}

		cl2 := &claims{}
		err = info.Claims(cl2)
		if err != nil {
			log.Errorf("Error parsing userinfo claims for provider %s: %v", provider.Name, err)
			return handler.HandleHTTPError(err, c)
		}

		if cl.Email == "" {
			cl.Email = cl2.Email
		}

		if cl.Name == "" {
			cl.Name = cl2.Name
		}

		if cl.PreferredUsername == "" {
			cl.PreferredUsername = cl2.PreferredUsername
		}

		if cl.PreferredUsername == "" && cl2.Nickname != "" {
			cl.PreferredUsername = cl2.Nickname
		}

		if cl.Email == "" {
			log.Errorf("Claim does not contain an email address for provider %s", provider.Name)
			return handler.HandleHTTPError(&user.ErrNoOpenIDEmailProvided{}, c)
		}
	}

	s := db.NewSession()
	defer s.Close()

	// Check if we have seen this user before
	u, err := getOrCreateUser(s, cl, idToken.Issuer, idToken.Subject)

	if err != nil {
		_ = s.Rollback()
		log.Errorf("Error creating new user for provider %s: %v", provider.Name, err)
		return handler.HandleHTTPError(err, c)
	}

	// does the oidc token contain well formed "vikunja_groups" through vikunja_scope
	teamData, err := getTeamDataFromToken(cl.VikunjaGroups, provider)
	if err != nil {
		log.Errorf("Error creating teams for user and vikunja groups %s: %v", cl.VikunjaGroups, err)
		return handler.HandleHTTPError(err, c)
	}

	//find old teams for user through oidc
	oldOidcTeams, _ := models.FindAllOidcTeamIDsForUser(s, u.ID)
	var oidcTeams []int64
	if len(teamData) > 0 {
		// check if we have seen these teams before.
		// find or create Teams and assign user as teammember.
		log.Debugf("TeamData is set %v", teamData)
		teams, err := GetOrCreateTeamsByOIDCAndNames(s, teamData, u)
		if err != nil {
			log.Errorf("Error verifying team for name %v, got %v", cl.Name, teams, err)
			return err
		}
		for _, team := range teams {
			tm := models.TeamMember{TeamID: team.ID, Username: u.Username}
			exists, err := tm.CheckMembership(s)
			if !exists {
				err = tm.Create(s, u)
				if err != nil {
					log.Debugf("Could not assign %v to %v. %v", u.Username, team.Name, err)
				}
			} else {
				log.Debugf("Team exists? %v or error: %v", exists, err)
			}
			oidcTeams = append(oidcTeams, team.ID)
		}
	}
	SignOutFromOrDeleteTeamsByID(s, u, notIn(oldOidcTeams, oidcTeams))
	err = s.Commit()
	if err != nil {
		_ = s.Rollback()
		log.Errorf("Error creating new Team for provider %s: %v", provider.Name, err)
		return handler.HandleHTTPError(err, c)
	}
	// Create token
	return auth.NewUserAuthTokenResponse(u, c, false)
}

func SignOutFromOrDeleteTeamsByID(s *xorm.Session, u *user.User, teamIDs []int64) {
	for _, teamID := range teamIDs {
		tm := models.TeamMember{TeamID: teamID, Username: u.Username}
		err := tm.Delete(s, u)
		if err != nil {
			team, err := models.GetTeamByID(s, teamID)
			if err != nil {
				log.Errorf("Cannot find team with id: %v, err: %v", teamID, err)
			} else {
				err = team.Delete(s, u)
				if err != nil {
					log.Errorf("Cannot delete team %v", err)
				}
			}
		}
	}
}

func getTeamDataFromToken(groups interface{}, provider *Provider) (teamData []TeamData, err error) {
	teamData = []TeamData{}
	if groups != nil {
		el := groups.([]interface{})
		for _, data := range el {
			team := data.(map[string]interface{})
			var name string
			var description string
			var oidcID string
			if team["name"] != nil {
				name = team["name"].(string)
			}
			if team["description"] != nil {
				description = team["description"].(string)
			}
			if team["oidcID"] != nil {
				switch t := team["oidcID"].(type) {
				case int64:
					oidcID = fmt.Sprintf("%v", team["oidcID"])
				case string:
					oidcID = string(team["oidcID"].(string))
				case float64:
					fl := float64(team["oidcID"].(float64))
					oidcID = fmt.Sprintf("%v", fl)
				default:
					log.Errorf("No oidcID assigned for %v or type %v not supported", team, t)
				}
			}
			if name == "" || oidcID == "" {
				log.Errorf("Claim of your custom scope does not hold name or oidcID for automatic group assignment through oidc provider. Please check %s", provider.Name)
				return teamData, &user.ErrOpenIDCustomScopeMalformed{}
			}
			teamData = append(teamData, TeamData{TeamName: name, OidcID: oidcID, Description: description})
		}
	}
	return teamData, nil
}

func CreateTeamWithData(s *xorm.Session, teamData TeamData, u *user.User) (team *models.Team, err error) {
	tea := &models.Team{
		Name:        teamData.TeamName,
		Description: teamData.Description,
		OidcID:      teamData.OidcID,
	}
	log.Debugf("Teams: %v", tea.OidcID)

	err = tea.Create(s, u)
	return tea, err
}

// this functions creates an array of existing teams that was generated from the oidc data.
func GetOrCreateTeamsByOIDCAndNames(s *xorm.Session, teamData []TeamData, u *user.User) (te []models.Team, err error) {
	te = []models.Team{}
	// Procedure can only be successful if oidcID is set and converted to string
	for _, oidcTeam := range teamData {
		team, err := models.GetTeamByOidcIDAndName(s, oidcTeam.OidcID, oidcTeam.TeamName)
		if err != nil {
			log.Debugf("Team with oidc_id %v and name %v does not exist. Create Team.. ", oidcTeam.OidcID, oidcTeam.TeamName)
			newTeam, err := CreateTeamWithData(s, oidcTeam, u)
			if err != nil {
				return te, err
			}
			te = append(te, *newTeam)
		} else {
			log.Debugf("Team with oidc_id %v and name %v already exists.", team.OidcID, team.Name)
			te = append(te, team)
		}

	}
	log.Debugf("Array: %v", te)
	return te, err
}

func getOrCreateUser(s *xorm.Session, cl *claims, issuer, subject string) (u *user.User, err error) {

	// Check if the user exists for that issuer and subject
	u, err = user.GetUserWithEmail(s, &user.User{
		Issuer:  issuer,
		Subject: subject,
	})
	if err != nil && !user.IsErrUserDoesNotExist(err) {
		return nil, err
	}

	// If no user exists, create one with the preferred username if it is not already taken
	if user.IsErrUserDoesNotExist(err) {
		uu := &user.User{
			Username: cl.PreferredUsername,
			Email:    cl.Email,
			Name:     cl.Name,
			Status:   user.StatusActive,
			Issuer:   issuer,
			Subject:  subject,
		}

		// Check if we actually have a preferred username and generate a random one right away if we don't
		if uu.Username == "" {
			uu.Username = petname.Generate(3, "-")
		}

		u, err = user.CreateUser(s, uu)
		if err != nil && !user.IsErrUsernameExists(err) {
			return nil, err
		}

		// If their preferred username is already taken, create some random one from the email and subject
		if user.IsErrUsernameExists(err) {
			uu.Username = petname.Generate(3, "-")
			u, err = user.CreateUser(s, uu)
			if err != nil {
				return nil, err
			}
		}

		// And create its namespace
		err = models.CreateNewNamespaceForUser(s, u)
		if err != nil {
			return nil, err
		}

		return
	}

	// If it exists, check if the email address changed and change it if not
	if cl.Email != u.Email || cl.Name != u.Name {
		if cl.Email != u.Email {
			u.Email = cl.Email
		}
		if cl.Name != u.Name {
			u.Name = cl.Name
		}
		u, err = user.UpdateUser(s, &user.User{
			ID:      u.ID,
			Email:   u.Email,
			Name:    u.Name,
			Issuer:  issuer,
			Subject: subject,
		})
		if err != nil {
			return nil, err
		}
	}

	return
}

// find the elements which appear in slice1,but not in slice2
func notIn(slice1 []int64, slice2 []int64) []int64 {
	var diff []int64

	for _, s1 := range slice1 {
		found := false
		for _, s2 := range slice2 {
			if s1 == s2 {
				found = true
				break
			}
		}
		// String not found. We add it to return slice
		if !found {
			diff = append(diff, s1)
		}
	}
	return diff
}
