package azure

import (
	"context"
	"net/url"
	"sort"

	"github.com/pomerium/pomerium/pkg/grpc/directory"
)

const (
	groupsDeltaPath = "/v1.0/groups/delta"
	usersDeltaPath  = "/v1.0/users/delta"
)

type (
	deltaCollection struct {
		provider       *Provider
		groups         map[string]deltaGroup
		groupDeltaLink string
		users          map[string]deltaUser
		userDeltaLink  string
	}
	deltaGroup struct {
		id          string
		displayName string
		members     map[string]deltaGroupMember
	}
	deltaGroupMember struct {
		memberType string
		id         string
	}
	deltaUser struct {
		id          string
		displayName string
		email       string
	}
)

func newDeltaCollection(p *Provider) *deltaCollection {
	return &deltaCollection{
		provider: p,
		groups:   make(map[string]deltaGroup),
		users:    make(map[string]deltaUser),
	}
}

// Sync syncs the latest changes from the microsoft graph API.
//
// Synchronization is based on https://docs.microsoft.com/en-us/graph/delta-query-groups
//
// It involves 4 steps:
//
//   1. an initial request to /v1.0/groups/delta
//   2. one or more requests to /v1.0/groups/delta?$skiptoken=..., which comes from the @odata.nextLink
//   3. a final response with @odata.deltaLink
//   4. on the next call to sync, starting at @odata.deltaLink
//
// Only the changed groups/members are returned. Removed groups/members have an @removed property.
func (dc *deltaCollection) Sync(ctx context.Context) error {
	err := dc.syncGroups(ctx)
	if err != nil {
		return err
	}

	return dc.syncUsers(ctx)
}

func (dc *deltaCollection) syncGroups(ctx context.Context) error {
	apiURL := dc.groupDeltaLink

	// if no delta link is set yet, start the initial fill
	if apiURL == "" {
		apiURL = dc.provider.cfg.graphURL.ResolveReference(&url.URL{
			Path: groupsDeltaPath,
			RawQuery: url.Values{
				"$select": {"displayName,members"},
			}.Encode(),
		}).String()
	}

	for {
		var res groupsDeltaResponse
		err := dc.provider.api(ctx, apiURL, &res)
		if err != nil {
			return err
		}

		for _, g := range res.Value {
			// if removed exists, the group was deleted
			if g.Removed != nil {
				delete(dc.groups, g.ID)
				continue
			}

			gdg := dc.groups[g.ID]
			gdg.id = g.ID
			gdg.displayName = g.DisplayName
			if gdg.members == nil {
				gdg.members = make(map[string]deltaGroupMember)
			}
			for _, m := range g.Members {
				// if removed exists, the member was deleted
				if m.Removed != nil {
					delete(gdg.members, m.ID)
					continue
				}

				gdg.members[m.ID] = deltaGroupMember{
					memberType: m.Type,
					id:         m.ID,
				}
			}
			dc.groups[g.ID] = gdg
		}

		switch {
		case res.NextLink != "":
			// when there's a next link we will query again
			apiURL = res.NextLink
		default:
			// once no next link is set anymore, we save the delta link and return
			dc.groupDeltaLink = res.DeltaLink
			return nil
		}
	}
}

func (dc *deltaCollection) syncUsers(ctx context.Context) error {
	apiURL := dc.userDeltaLink

	// if no delta link is set yet, start the initial fill
	if apiURL == "" {
		apiURL = dc.provider.cfg.graphURL.ResolveReference(&url.URL{
			Path: usersDeltaPath,
			RawQuery: url.Values{
				"$select": {"displayName,mail,userPrincipalName"},
			}.Encode(),
		}).String()
	}

	for {
		var res usersDeltaResponse
		err := dc.provider.api(ctx, apiURL, &res)
		if err != nil {
			return err
		}

		for _, u := range res.Value {
			// if removed exists, the user was deleted
			if u.Removed != nil {
				delete(dc.users, u.ID)
				continue
			}
			dc.users[u.ID] = deltaUser{
				id:          u.ID,
				displayName: u.DisplayName,
				email:       u.getEmail(),
			}
		}

		switch {
		case res.NextLink != "":
			// when there's a next link we will query again
			apiURL = res.NextLink
		default:
			// once no next link is set anymore, we save the delta link and return
			dc.userDeltaLink = res.DeltaLink
			return nil
		}
	}
}

// CurrentUserGroups returns the directory groups and users based on the current state.
func (dc *deltaCollection) CurrentUserGroups() ([]*directory.Group, []*directory.User) {
	var groups []*directory.Group

	groupLookup := newGroupLookup()
	for _, g := range dc.groups {
		groups = append(groups, &directory.Group{
			Id:   g.id,
			Name: g.displayName,
		})
		var groupIDs, userIDs []string
		for _, m := range g.members {
			switch m.memberType {
			case "#microsoft.graph.group":
				groupIDs = append(groupIDs, m.id)
			case "#microsoft.graph.user":
				userIDs = append(userIDs, m.id)
			}
		}
		groupLookup.addGroup(g.id, groupIDs, userIDs)
	}
	sort.Slice(groups, func(i, j int) bool {
		return groups[i].GetId() < groups[j].GetId()
	})

	var users []*directory.User
	for _, u := range dc.users {
		users = append(users, &directory.User{
			Id:          u.id,
			GroupIds:    groupLookup.getGroupIDsForUser(u.id),
			DisplayName: u.displayName,
			Email:       u.email,
		})
	}
	sort.Slice(users, func(i, j int) bool {
		return users[i].GetId() < users[j].GetId()
	})

	return groups, users
}

// API types for the microsoft graph API.
type (
	deltaResponseRemoved struct {
		Reason string `json:"reason"`
	}

	groupsDeltaResponse struct {
		Context   string                     `json:"@odata.context"`
		NextLink  string                     `json:"@odata.nextLink,omitempty"`
		DeltaLink string                     `json:"@odata.deltaLink,omitempty"`
		Value     []groupsDeltaResponseGroup `json:"value"`
	}
	groupsDeltaResponseGroup struct {
		apiGroup
		Members []groupsDeltaResponseGroupMember `json:"members@delta"`
		Removed *deltaResponseRemoved            `json:"@removed,omitempty"`
	}
	groupsDeltaResponseGroupMember struct {
		Type    string                `json:"@odata.type"`
		ID      string                `json:"id"`
		Removed *deltaResponseRemoved `json:"@removed,omitempty"`
	}

	usersDeltaResponse struct {
		Context   string                   `json:"@odata.context"`
		NextLink  string                   `json:"@odata.nextLink,omitempty"`
		DeltaLink string                   `json:"@odata.deltaLink,omitempty"`
		Value     []usersDeltaResponseUser `json:"value"`
	}
	usersDeltaResponseUser struct {
		apiUser
		Removed *deltaResponseRemoved `json:"@removed,omitempty"`
	}
)