package gcp

import (
	"context"
	"fmt"
	"slices"
	"strings"
	"sync"

	"cloud.google.com/go/iam/apiv1/iampb"
	resourcemanager "cloud.google.com/go/resourcemanager/apiv3"
	"cloud.google.com/go/resourcemanager/apiv3/resourcemanagerpb"
	iamadmin "google.golang.org/api/iam/v1"
)

type ResourceManagerClient interface {
	GetProjectIdentity(ctx context.Context, project string) (ProjectIdentity, error)
	EnsureProjectCustomRole(ctx context.Context, project, roleID string, role ProjectCustomRole) (string, error)
	AddProjectPolicyBinding(ctx context.Context, project, role, member string) error
	DelProjectPolicyBinding(ctx context.Context, project, role, member string) error
}

type ProjectIdentity struct {
	Input         string
	ProjectID     string
	ProjectNumber string
}

type ProjectCustomRole struct {
	Title               string
	Description         string
	IncludedPermissions []string
}

type MutatePolicyFn func(policy *iampb.Policy) bool

type resourceManagerClient struct {
	pc              *resourcemanager.ProjectsClient
	iamSvc          *iamadmin.Service
	projectPolicies map[string]*iampb.Policy

	mu sync.Mutex
}

func NewResourceManagerClient(ctx context.Context) (ResourceManagerClient, error) {
	pc, err := resourcemanager.NewProjectsClient(ctx)
	if err != nil {
		return nil, err
	}
	iamSvc, err := iamadmin.NewService(ctx)
	if err != nil {
		return nil, err
	}
	return &resourceManagerClient{
		pc:              pc,
		iamSvc:          iamSvc,
		projectPolicies: make(map[string]*iampb.Policy),
	}, nil
}

func (c *resourceManagerClient) GetProjectIdentity(ctx context.Context, project string) (ProjectIdentity, error) {
	input := strings.TrimSpace(strings.TrimPrefix(project, "projects/"))
	if input == "" {
		return ProjectIdentity{}, fmt.Errorf("project is empty")
	}
	if c.pc == nil {
		return ProjectIdentity{}, fmt.Errorf("resource manager projects client is not configured")
	}

	current, err := c.pc.GetProject(ctx, &resourcemanagerpb.GetProjectRequest{Name: "projects/" + input})
	if err != nil {
		return ProjectIdentity{}, fmt.Errorf("can't get project %s: %w", input, err)
	}
	return ProjectIdentity{
		Input:         input,
		ProjectID:     current.GetProjectId(),
		ProjectNumber: strings.TrimPrefix(current.GetName(), "projects/"),
	}, nil
}

func (c *resourceManagerClient) EnsureProjectCustomRole(ctx context.Context, project, roleID string, role ProjectCustomRole) (string, error) {
	if project == "" {
		return "", fmt.Errorf("project is empty")
	}
	if roleID == "" {
		return "", fmt.Errorf("role id is empty")
	}
	if len(role.IncludedPermissions) == 0 {
		return "", fmt.Errorf("included permissions are empty")
	}

	name := fmt.Sprintf("projects/%s/roles/%s", project, roleID)
	expected := &iamadmin.Role{
		Title:               role.Title,
		Description:         role.Description,
		IncludedPermissions: role.IncludedPermissions,
		Stage:               "GA",
	}

	current, err := c.iamSvc.Projects.Roles.Get(name).Context(ctx).Do()
	if err != nil {
		if !isNotFound(err) {
			return "", fmt.Errorf("can't get custom role %s: %w", name, err)
		}
		_, createErr := c.iamSvc.Projects.Roles.Create(
			"projects/"+project,
			&iamadmin.CreateRoleRequest{
				RoleId: roleID,
				Role:   expected,
			},
		).Context(ctx).Do()
		if createErr == nil {
			return name, nil
		}
		if !isAlreadyExists(createErr) {
			return "", fmt.Errorf("can't create custom role %s: %w", name, createErr)
		}
		current, err = c.iamSvc.Projects.Roles.Get(name).Context(ctx).Do()
		if err != nil {
			return "", fmt.Errorf("can't get custom role %s after create conflict: %w", name, err)
		}
	}

	if current != nil && current.Deleted {
		if _, err := c.iamSvc.Projects.Roles.Undelete(name, &iamadmin.UndeleteRoleRequest{}).Context(ctx).Do(); err != nil {
			return "", fmt.Errorf("can't undelete custom role %s: %w", name, err)
		}
		current, err = c.iamSvc.Projects.Roles.Get(name).Context(ctx).Do()
		if err != nil {
			return "", fmt.Errorf("can't get custom role %s after undelete: %w", name, err)
		}
	}

	expected.Name = name
	if current != nil {
		expected.Etag = current.Etag
		// Preserve already-granted permissions. This helper is used from live
		// request paths, so it should add missing permissions without pruning
		// emergency or rollout-specific grants from the custom role.
		expected.IncludedPermissions = mergedProjectCustomRolePermissions(current.IncludedPermissions, role.IncludedPermissions)
	}
	if projectCustomRoleEqual(current, expected) {
		return name, nil
	}
	if _, err := c.iamSvc.Projects.Roles.Patch(name, expected).Context(ctx).Do(); err != nil {
		return "", fmt.Errorf("can't patch custom role %s: %w", name, err)
	}
	return name, nil
}

func projectCustomRoleEqual(current, expected *iamadmin.Role) bool {
	if current == nil || expected == nil {
		return false
	}
	return current.Title == expected.Title &&
		current.Description == expected.Description &&
		current.Stage == expected.Stage &&
		permissionSlicesEqual(current.IncludedPermissions, expected.IncludedPermissions)
}

func permissionSlicesEqual(current, expected []string) bool {
	if len(current) != len(expected) {
		return false
	}
	current = slices.Clone(current)
	expected = slices.Clone(expected)
	slices.Sort(current)
	slices.Sort(expected)
	return slices.Equal(current, expected)
}

func mergedProjectCustomRolePermissions(current, required []string) []string {
	seen := map[string]struct{}{}
	out := make([]string, 0, len(current)+len(required))
	for _, permission := range append(slices.Clone(current), required...) {
		permission = strings.TrimSpace(permission)
		if permission == "" {
			continue
		}
		if _, ok := seen[permission]; ok {
			continue
		}
		seen[permission] = struct{}{}
		out = append(out, permission)
	}
	slices.Sort(out)
	return out
}

func (c *resourceManagerClient) AddProjectPolicyBinding(ctx context.Context, project, role, member string) error {
	return c.updateProjectRoleBinding(ctx, project, func(policy *iampb.Policy) bool {
		b := findBindingsWithoutCondition(policy.Bindings, role)
		if b != nil {
			for _, m := range b.Members {
				if m == member {
					return false
				}
			}
		}

		// directly append new bindings, gcp will auto combine bindings with same role(without condition)
		policy.Bindings = append(policy.Bindings, &iampb.Binding{
			Members: []string{member},
			Role:    role,
		})

		return true
	})
}

func (c *resourceManagerClient) DelProjectPolicyBinding(ctx context.Context, project, role, member string) error {
	return c.updateProjectRoleBinding(ctx, project, func(policy *iampb.Policy) bool {
		b := findBindingsWithoutCondition(policy.Bindings, role)
		if b == nil {
			return false
		}
		ms := []string{}
		for _, m := range b.Members {
			if m != member {
				ms = append(ms, m)
			}
		}

		if len(ms) == len(b.Members) {
			return false
		}

		b.Members = ms

		return true
	})
}

func findBindingsWithoutCondition(bindings []*iampb.Binding, role string) *iampb.Binding {
	for _, b := range bindings {
		if b.Role != role {
			continue
		}

		// skip role with condition
		if b.Condition != nil {
			continue
		}

		// gcp will auto combine bindings with same role(without condition)
		// so we can return the first binding we found
		return b
	}

	return nil
}

func (c *resourceManagerClient) updateProjectRoleBinding(ctx context.Context, project string, fn func(policy *iampb.Policy) bool) error {
	// Now the api call is not frequent
	// TODO(liubo02): optimize the lock
	c.mu.Lock()
	defer c.mu.Unlock()

	policy, ok := c.projectPolicies[project]
	if !ok || policy == nil {
		cur, err := c.pc.GetIamPolicy(ctx, &iampb.GetIamPolicyRequest{
			Resource: "projects/" + project,
			Options: &iampb.GetPolicyOptions{
				RequestedPolicyVersion: 3,
			},
		})
		// The project is not created by this application,
		// so we assume that the project policy has exists
		if err != nil {
			return fmt.Errorf("can't get iam policy: %w", err)
		}
		c.projectPolicies[project] = cur
		policy = cur
	}

	isChanged := fn(policy)

	if !isChanged {
		return nil
	}

	np, err := c.pc.SetIamPolicy(ctx, &iampb.SetIamPolicyRequest{
		Resource: "projects/" + project,
		Policy:   policy,
	})
	if err != nil {
		// reset cache to get project policy again
		c.projectPolicies[project] = nil
		return fmt.Errorf("can't set iam policy: %w", err)
	}

	c.projectPolicies[project] = np

	return nil
}
