package backend

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"

	gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
	cs3 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
	rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
	libregraph "github.com/opencloud-eu/libre-graph-api-go"
	"github.com/opencloud-eu/opencloud/pkg/log"
	"github.com/opencloud-eu/opencloud/pkg/oidc"
	"github.com/opencloud-eu/opencloud/services/graph/pkg/errorcode"
	"github.com/opencloud-eu/opencloud/services/proxy/pkg/config"
	revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
	"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
	utils "github.com/opencloud-eu/reva/v2/pkg/utils"
	"go-micro.dev/v4/selector"
)

type cs3backend struct {
	graphSelector selector.Selector
	Options
}

// Option defines a single option function.
type Option func(o *Options)

// Options defines the available options for this package.
type Options struct {
	logger              log.Logger
	gatewaySelector     pool.Selectable[gateway.GatewayAPIClient]
	selector            selector.Selector
	machineAuthAPIKey   string
	oidcISS             string
	serviceAccount      config.ServiceAccount
	autoProvisionClaims config.AutoProvisionClaims
}

var (
	errGroupNotFound = errors.New("group not found")
)

// WithLogger sets the logger option
func WithLogger(l log.Logger) Option {
	return func(o *Options) {
		o.logger = l
	}
}

// WithRevaGatewaySelector set the gatewaySelector option
func WithRevaGatewaySelector(selectable pool.Selectable[gateway.GatewayAPIClient]) Option {
	return func(o *Options) {
		o.gatewaySelector = selectable
	}
}

// WithSelector set the Selector option
func WithSelector(selector selector.Selector) Option {
	return func(o *Options) {
		o.selector = selector
	}
}

// WithMachineAuthAPIKey configures the machine auth API key
func WithMachineAuthAPIKey(ma string) Option {
	return func(o *Options) {
		o.machineAuthAPIKey = ma
	}
}

// WithOIDCissuer set the OIDC issuer URL
func WithOIDCissuer(oidcISS string) Option {
	return func(o *Options) {
		o.oidcISS = oidcISS
	}
}

// WithServiceAccount configures the service account creator to use
func WithServiceAccount(c config.ServiceAccount) Option {
	return func(o *Options) {
		o.serviceAccount = c
	}
}

func WithAutoProvisionClaims(claims config.AutoProvisionClaims) Option {
	return func(o *Options) {
		o.autoProvisionClaims = claims
	}
}

// NewCS3UserBackend creates a user-provider which fetches users from a CS3 UserBackend
func NewCS3UserBackend(opts ...Option) UserBackend {
	opt := Options{}
	for _, o := range opts {
		o(&opt)
	}

	b := cs3backend{
		Options:       opt,
		graphSelector: opt.selector,
	}

	return &b
}

func (c *cs3backend) GetUserByClaims(ctx context.Context, claim, value string) (*cs3.User, string, error) {
	gatewayClient, err := c.gatewaySelector.Next()
	if err != nil {
		return nil, "", fmt.Errorf("could not obtain gatewayClient: %s", err)
	}

	res, err := gatewayClient.Authenticate(ctx, &gateway.AuthenticateRequest{
		Type:         "machine",
		ClientId:     claim + ":" + value,
		ClientSecret: c.machineAuthAPIKey,
	})

	switch {
	case err != nil:
		return nil, "", fmt.Errorf("could not get user by claim %v with value %v: %w", claim, value, err)
	case res.Status.Code != rpcv1beta1.Code_CODE_OK:
		if res.Status.Code == rpcv1beta1.Code_CODE_NOT_FOUND {
			return nil, "", ErrAccountNotFound
		}
		return nil, "", fmt.Errorf("could not get user by claim %v with value %v : %s ", claim, value, res.GetStatus().GetMessage())
	}

	user := res.User

	return user, res.GetToken(), nil
}

func (c *cs3backend) Authenticate(ctx context.Context, username string, password string) (*cs3.User, string, error) {
	gatewayClient, err := c.gatewaySelector.Next()
	if err != nil {
		return nil, "", fmt.Errorf("could not obtain gatewayClient: %s", err)
	}

	res, err := gatewayClient.Authenticate(ctx, &gateway.AuthenticateRequest{
		Type:         "basic",
		ClientId:     username,
		ClientSecret: password,
	})

	switch {
	case err != nil:
		return nil, "", fmt.Errorf("could not authenticate with username and password user: %s, %w", username, err)
	case res.Status.Code != rpcv1beta1.Code_CODE_OK:
		return nil, "", fmt.Errorf("could not authenticate with username and password user: %s, got code: %d", username, res.GetStatus().GetCode())
	}

	return res.User, res.Token, nil
}

// CreateUserFromClaims creates a new user via libregraph users API, taking the
// attributes from the provided `claims` map. On success it returns the new
// user. If the user already exist this is not considered an error and the
// function will just return the existing user.
func (c *cs3backend) CreateUserFromClaims(ctx context.Context, claims map[string]any) (*cs3.User, error) {
	gatewayClient, err := c.gatewaySelector.Next()
	if err != nil {
		c.logger.Error().Err(err).Msg("could not select next gateway client")
		return nil, err
	}
	newctx := context.Background()
	authRes, err := gatewayClient.Authenticate(newctx, &gateway.AuthenticateRequest{
		Type:         "serviceaccounts",
		ClientId:     c.serviceAccount.ServiceAccountID,
		ClientSecret: c.serviceAccount.ServiceAccountSecret,
	})
	if err != nil {
		return nil, err
	}
	if authRes.GetStatus().GetCode() != rpcv1beta1.Code_CODE_OK {
		return nil, fmt.Errorf("error authenticating service user: %s", authRes.GetStatus().GetMessage())
	}

	lgClient, err := c.setupLibregraphClient(newctx, authRes.GetToken())
	if err != nil {
		c.logger.Error().Err(err).Msg("Error setting up libregraph client.")
		return nil, err
	}

	newUser, err := c.libregraphUserFromClaims(claims)
	if err != nil {
		c.logger.Error().Err(err).Interface("claims", claims).Msg("Error creating user from claims")
		return nil, fmt.Errorf("error creating user from claims: %w", err)
	}

	req := lgClient.UsersApi.CreateUser(newctx).User(newUser)

	created, resp, err := req.Execute()
	defer resp.Body.Close()

	var reread bool
	if err != nil {
		if resp == nil {
			return nil, err
		}

		// If the user already exists here, some other request did already create it in parallel.
		// So just issue a Debug message and ignore the libregraph error otherwise
		var lerr error
		if reread, lerr = c.isAlreadyExists(resp); lerr != nil {
			c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.")
			return nil, err
		}
		if !reread {
			c.logger.Error().Err(err).Msg("Error creating user")
			return nil, err
		}
	}

	// User has been created meanwhile, re-read it to get the user id
	if reread {
		c.logger.Debug().Msg("User already exist, re-reading via libregraph")
		gureq := lgClient.UserApi.GetUser(newctx, newUser.GetOnPremisesSamAccountName())
		created, resp, err = gureq.Execute()
		defer resp.Body.Close()
		if err != nil {
			c.logger.Error().Err(err).Msg("Error trying to re-read user from graphAPI")
			return nil, err
		}
	}

	cs3UserCreated := c.cs3UserFromLibregraph(newctx, created)

	return &cs3UserCreated, nil
}

func (c cs3backend) UpdateUserIfNeeded(ctx context.Context, user *cs3.User, claims map[string]any) error {
	newUser, err := c.libregraphUserFromClaims(claims)
	if err != nil {
		c.logger.Error().Err(err).Interface("claims", claims).Msg("Error converting claims to user")
		return fmt.Errorf("error converting claims to updated user: %w", err)
	}

	// Check if the user needs to be updated, only updates of "displayName" and "mail" are supported
	// currently.
	userupdate := libregraph.NewUserUpdate()
	switch {
	case newUser.GetDisplayName() != user.GetDisplayName():
		userupdate.SetDisplayName(newUser.GetDisplayName())
	case newUser.GetMail() != user.GetMail():
		userupdate.SetMail(newUser.GetMail())
	}

	if userupdate.HasDisplayName() || userupdate.HasMail() {
		return c.updateLibregraphUser(user.GetId().GetOpaqueId(), *userupdate)
	}

	return nil
}

// SyncGroupMemberships maintains a users group memberships based on an OIDC claim
func (c cs3backend) SyncGroupMemberships(ctx context.Context, user *cs3.User, claims map[string]any) error {
	gatewayClient, err := c.gatewaySelector.Next()
	if err != nil {
		c.logger.Error().Err(err).Msg("could not select next gateway client")
		return err
	}
	newctx := context.Background()
	token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret)
	if err != nil {
		c.logger.Error().Err(err).Msg("Error getting token for service user")
		return err
	}

	lgClient, err := c.setupLibregraphClient(newctx, token)
	if err != nil {
		c.logger.Error().Err(err).Msg("Error setting up libregraph client")
		return err
	}

	lgUser, resp, err := lgClient.UserApi.GetUser(newctx, user.GetId().GetOpaqueId()).Expand([]string{"memberOf"}).Execute()
	if resp != nil {
		defer resp.Body.Close()
	}
	if err != nil {
		c.logger.Error().Err(err).Msg("Failed to lookup user via libregraph")
		return err
	}

	currentGroups := lgUser.GetMemberOf()
	currentGroupSet := make(map[string]struct{})
	for _, group := range currentGroups {
		currentGroupSet[group.GetDisplayName()] = struct{}{}
	}

	newGroupSet := make(map[string]struct{})
	if groups, ok := claims[c.autoProvisionClaims.Groups].([]any); ok {
		for _, g := range groups {
			if group, ok := g.(string); ok {
				newGroupSet[group] = struct{}{}
			}
		}
	}

	for group := range newGroupSet {
		if _, exists := currentGroupSet[group]; !exists {
			c.logger.Debug().Str("group", group).Msg("adding user to group")
			// Check if group exists
			lgGroup, err := c.getLibregraphGroup(newctx, lgClient, group)
			switch {
			case errors.Is(err, errGroupNotFound):
				newGroup := libregraph.Group{}
				newGroup.SetDisplayName(group)
				req := lgClient.GroupsApi.CreateGroup(newctx).Group(newGroup)
				var resp *http.Response
				lgGroup, resp, err = req.Execute()
				if resp != nil {
					defer resp.Body.Close()
				}
				switch {
				case err == nil:
				// all good
				case resp == nil:
					return err
				default:
					// Ignore error if group already exists
					exists, lerr := c.isAlreadyExists(resp)
					switch {
					case lerr != nil:
						c.logger.Error().Err(lerr).Msg("extracting error from ibregraph response body failed.")
						return err
					case !exists:
						c.logger.Error().Err(err).Msg("Failed to create group via libregraph")
						return err
					default:
						// group has been created meanwhile, re-read it to get the group id
						lgGroup, err = c.getLibregraphGroup(newctx, lgClient, group)
						if err != nil {
							return err
						}
					}
				}
			case err != nil:
				return err
			}

			memberref := "https://localhost/graph/v1.0/users/" + user.GetId().GetOpaqueId()
			resp, err := lgClient.GroupApi.AddMember(newctx, lgGroup.GetId()).MemberReference(
				libregraph.MemberReference{
					OdataId: &memberref,
				},
			).Execute()
			if resp != nil {
				defer resp.Body.Close()
			}
			if err != nil {
				c.logger.Error().Err(err).Msg("Failed to add user to group via libregraph")
			}
		}
	}
	for current := range currentGroupSet {
		if _, exists := newGroupSet[current]; !exists {
			c.logger.Debug().Str("group", current).Msg("deleting user from group")
			lgGroup, err := c.getLibregraphGroup(newctx, lgClient, current)
			if err != nil {
				return err
			}
			resp, err := lgClient.GroupApi.DeleteMember(newctx, lgGroup.GetId(), user.GetId().GetOpaqueId()).Execute()
			if resp != nil {
				defer resp.Body.Close()
			}
			if err != nil {
				return err
			}
		}
	}
	return nil
}

func (c cs3backend) getLibregraphGroup(ctx context.Context, client *libregraph.APIClient, group string) (*libregraph.Group, error) {
	lgGroup, resp, err := client.GroupApi.GetGroup(ctx, group).Execute()
	if resp != nil {
		defer resp.Body.Close()
	}
	if err != nil {
		switch {
		case resp == nil:
			return nil, err
		case resp.StatusCode == http.StatusNotFound:
			return nil, errGroupNotFound
		case resp.StatusCode != http.StatusOK:
			return nil, err
		}
	}
	return lgGroup, nil
}

func (c cs3backend) updateLibregraphUser(userid string, user libregraph.UserUpdate) error {
	gatewayClient, err := c.gatewaySelector.Next()
	if err != nil {
		c.logger.Error().Err(err).Msg("could not select next gateway client")
		return err
	}
	newctx := context.Background()
	token, err := utils.GetServiceUserToken(newctx, gatewayClient, c.serviceAccount.ServiceAccountID, c.serviceAccount.ServiceAccountSecret)
	if err != nil {
		c.logger.Error().Err(err).Msg("Error getting token for service user")
		return err
	}

	lgClient, err := c.setupLibregraphClient(newctx, token)
	if err != nil {
		c.logger.Error().Err(err).Msg("Error setting up libregraph client")
		return err
	}

	req := lgClient.UserApi.UpdateUser(newctx, userid).UserUpdate(user)

	_, resp, err := req.Execute()
	defer resp.Body.Close()
	if err != nil {
		c.logger.Error().Err(err).Msg("Failed to update user via libregraph")
		return err
	}

	return nil
}

func (c cs3backend) setupLibregraphClient(_ context.Context, cs3token string) (*libregraph.APIClient, error) {
	// Use micro registry to resolve next graph service endpoint
	next, err := c.graphSelector.Select("eu.opencloud.web.graph")
	if err != nil {
		c.logger.Debug().Err(err).Msg("setupLibregraphClient: error during Select")
		return nil, err
	}
	node, err := next()
	if err != nil {
		c.logger.Debug().Err(err).Msg("setupLibregraphClient: error getting next Node")
		return nil, err
	}
	lgconf := libregraph.NewConfiguration()
	lgconf.Servers = libregraph.ServerConfigurations{
		{
			URL: fmt.Sprintf("%s://%s/graph", node.Metadata["protocol"], node.Address),
		},
	}

	lgconf.DefaultHeader = map[string]string{revactx.TokenHeader: cs3token}
	return libregraph.NewAPIClient(lgconf), nil
}

func (c cs3backend) isAlreadyExists(resp *http.Response) (bool, error) {
	oDataErr := libregraph.NewOdataErrorWithDefaults()
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		c.logger.Debug().Err(err).Msg("Error trying to read libregraph response")
		return false, err
	}
	err = json.Unmarshal(body, oDataErr)
	if err != nil {
		c.logger.Debug().Err(err).Msg("Error unmarshalling libregraph response")
		return false, err
	}

	c.logger.Warn().Str("OData Error", oDataErr.Error.Message).Msg("Error Response")

	if oDataErr.Error.Code == errorcode.NameAlreadyExists.String() {
		return true, nil
	}
	return false, nil
}

func (c cs3backend) libregraphUserFromClaims(claims map[string]any) (libregraph.User, error) {
	user := libregraph.User{}
	if dn, ok := claims[c.autoProvisionClaims.DisplayName].(string); ok {
		user.SetDisplayName(dn)
	} else {
		return user, fmt.Errorf("missing claim '%s' (displayName)", c.autoProvisionClaims.DisplayName)
	}
	if username, ok := claims[c.autoProvisionClaims.Username].(string); ok {
		user.SetOnPremisesSamAccountName(username)
	} else {
		return user, fmt.Errorf("missing claim '%s' (username)", c.autoProvisionClaims.Username)
	}
	// Email is optional so we don't need an 'else' here
	if mail, ok := claims[c.autoProvisionClaims.Email].(string); ok {
		user.SetMail(mail)
	}

	sub, subExists := claims[oidc.Sub].(string)
	iss, issExists := claims[oidc.Iss].(string)

	if subExists && issExists {
		var objectIdentity libregraph.ObjectIdentity
		objectIdentity.SetIssuer(iss)
		objectIdentity.SetIssuerAssignedId(sub)
		user.Identities = append(user.Identities, objectIdentity)
	}

	return user, nil
}

func (c cs3backend) cs3UserFromLibregraph(_ context.Context, lu *libregraph.User) cs3.User {
	cs3id := cs3.UserId{
		Type: cs3.UserType_USER_TYPE_PRIMARY,
		Idp:  c.oidcISS,
	}

	cs3id.OpaqueId = lu.GetId()

	cs3user := cs3.User{
		Id: &cs3id,
	}
	cs3user.Username = lu.GetOnPremisesSamAccountName()
	cs3user.DisplayName = lu.GetDisplayName()
	cs3user.Mail = lu.GetMail()
	return cs3user
}
