package rolestore

import (
	"context"
	"net/http"

	"github.com/google/uuid"
	"golang.org/x/xerrors"

	"github.com/coder/coder/v2/coderd/database"
	"github.com/coder/coder/v2/coderd/rbac"
	"github.com/coder/coder/v2/coderd/util/syncmap"
)

type customRoleCtxKey struct{}

// CustomRoleMW adds a custom role cache on the ctx to prevent duplicate
// db fetches.
func CustomRoleMW(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		r = r.WithContext(CustomRoleCacheContext(r.Context()))
		next.ServeHTTP(w, r)
	})
}

// CustomRoleCacheContext prevents needing to lookup custom roles within the
// same request lifecycle. Optimizing this to span requests should be done
// in the future.
func CustomRoleCacheContext(ctx context.Context) context.Context {
	return context.WithValue(ctx, customRoleCtxKey{}, syncmap.New[string, rbac.Role]())
}

func roleCache(ctx context.Context) *syncmap.Map[string, rbac.Role] {
	c, ok := ctx.Value(customRoleCtxKey{}).(*syncmap.Map[string, rbac.Role])
	if !ok {
		return syncmap.New[string, rbac.Role]()
	}
	return c
}

// Expand will expand built in roles, and fetch custom roles from the database.
// If a custom role is defined, but does not exist, the role will be omitted on
// the response. This means deleted roles are silently dropped.
func Expand(ctx context.Context, db database.Store, names []rbac.RoleIdentifier) (rbac.Roles, error) {
	if len(names) == 0 {
		// That was easy
		return []rbac.Role{}, nil
	}

	cache := roleCache(ctx)
	lookup := make([]rbac.RoleIdentifier, 0)
	roles := make([]rbac.Role, 0, len(names))

	for _, name := range names {
		// Remove any built in roles
		expanded, err := rbac.RoleByName(name)
		if err == nil {
			roles = append(roles, expanded)
			continue
		}

		// Check custom role cache
		customRole, ok := cache.Load(name.String())
		if ok {
			roles = append(roles, customRole)
			continue
		}

		// Defer custom role lookup
		lookup = append(lookup, name)
	}

	if len(lookup) > 0 {
		lookupArgs := make([]database.NameOrganizationPair, 0, len(lookup))
		for _, name := range lookup {
			lookupArgs = append(lookupArgs, database.NameOrganizationPair{
				Name:           name.Name,
				OrganizationID: name.OrganizationID,
			})
		}

		// If some roles are missing from the database, they are omitted from
		// the expansion. These roles are no-ops. Should we raise some kind of
		// warning when this happens?
		dbroles, err := db.CustomRoles(ctx, database.CustomRolesParams{
			LookupRoles:     lookupArgs,
			ExcludeOrgRoles: false,
			OrganizationID:  uuid.Nil,
		})
		if err != nil {
			return nil, xerrors.Errorf("fetch custom roles: %w", err)
		}

		// convert dbroles -> roles
		for _, dbrole := range dbroles {
			converted, err := ConvertDBRole(dbrole)
			if err != nil {
				return nil, xerrors.Errorf("convert db role %q: %w", dbrole.Name, err)
			}
			roles = append(roles, converted)
			cache.Store(dbrole.RoleIdentifier().String(), converted)
		}
	}

	return roles, nil
}

func convertPermissions(dbPerms []database.CustomRolePermission) []rbac.Permission {
	n := make([]rbac.Permission, 0, len(dbPerms))
	for _, dbPerm := range dbPerms {
		n = append(n, rbac.Permission{
			Negate:       dbPerm.Negate,
			ResourceType: dbPerm.ResourceType,
			Action:       dbPerm.Action,
		})
	}
	return n
}

// ConvertDBRole should not be used by any human facing apis. It is used
// for authz purposes.
func ConvertDBRole(dbRole database.CustomRole) (rbac.Role, error) {
	role := rbac.Role{
		Identifier:  dbRole.RoleIdentifier(),
		DisplayName: dbRole.DisplayName,
		Site:        convertPermissions(dbRole.SitePermissions),
		Org:         nil,
		User:        convertPermissions(dbRole.UserPermissions),
	}

	// Org permissions only make sense if an org id is specified.
	if len(dbRole.OrgPermissions) > 0 && dbRole.OrganizationID.UUID == uuid.Nil {
		return rbac.Role{}, xerrors.Errorf("role has organization perms without an org id specified")
	}

	if dbRole.OrganizationID.UUID != uuid.Nil {
		role.Org = map[string][]rbac.Permission{
			dbRole.OrganizationID.UUID.String(): convertPermissions(dbRole.OrgPermissions),
		}
	}

	return role, nil
}
