package rbac

import (
	"cmp"
	"errors"
	"fmt"
	"net/http"
	"regexp"
	"slices"
	"strconv"
	"strings"

	"braces.dev/errtrace"
	"github.com/gin-gonic/gin"
	accountsdk "github.com/tidbcloud/account/idl/pbgen/proto/account"
	"github.com/tidbcloud/pkgv2/auth/kong"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	authsdk "github.com/tidbcloud/tiinf/sdk/auth"
	"go.uber.org/zap"

	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-portal/domain/adapter/account"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-portal/domain/entity"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-portal/pkg/apimiddleware/grpcerror"
	"github.com/tidbcloud/tidb-management-service/tidb-mgmt-portal/pkg/consts"
	"github.com/tidbcloud/tidb-management-service/utilities/allowlist"
)

type rbacServiceImpl struct {
	account   account.Client
	allowList *allowlist.AllowLockList
}

func newRBACService(acc account.Client, allowList *allowlist.AllowLockList) Service {
	return &rbacServiceImpl{account: acc, allowList: allowList}
}

func (r *rbacServiceImpl) CheckAllowList(ctx *gin.Context) {
	if r.allowList == nil {
		tilog.Info(ctx.Request.Context(), "allow list is nil, skip check allow list")
		return
	}

	path := consts.RelativePath(ctx.Request.URL.Path)
	if !strings.HasPrefix(path, "/dedicated") {
		tilog.Info(ctx.Request.Context(), "not dedicated api, skip check allow list")
		return
	}

	user, err := getUserFromContext(ctx)
	if err != nil {
		grpcerror.NewOpenAPIError(ctx, http.StatusUnauthorized, consts.ErrUnauthenticated)
		return
	}

	// check allow list
	var allowProjectIds []*accountsdk.Project
	for _, project := range user.Projects {
		allow, err := r.allowList.DedicatedAllowList().IsAllow(ctx.Request.Context(), user.Org.Id, project.Id)
		if err != nil {
			tilog.Error(ctx.Request.Context(), "failed to check allow list",
				zap.Uint64(consts.OrgIdKey, user.Org.Id),
				zap.Uint64(consts.ProjectIdKey, project.Id),
				zap.Error(err))
			grpcerror.NewOpenAPIError(ctx, http.StatusInternalServerError, consts.ErrInternal)
			return
		}
		if allow {
			allowProjectIds = append(allowProjectIds, &accountsdk.Project{Id: project.Id})
		} else {
			tilog.Warn(ctx.Request.Context(), "project not in allow list", zap.Uint64(consts.ProjectIdKey, project.Id))
		}
	}

	if len(allowProjectIds) == 0 {
		tilog.Error(ctx.Request.Context(), "no permission project to access the resource",
			zap.Uint64(consts.OrgIdKey, user.Org.Id),
			zap.Any(consts.ProjectIdKey, getProjectIDList(user)))
		grpcerror.NewOpenAPIError(ctx, http.StatusForbidden, consts.ErrPermissionDenied)
		return
	}
	user.Projects = allowProjectIds
	// set user to context for rbac
	ctx.Set(consts.XAuthUser, user)
}

func (r *rbacServiceImpl) CheckRBAC(ctx *gin.Context) {
	// add whitelist
	if !isIgnoreRBACPath(ctx) {
		user, err := getUserFromContext(ctx)
		if err != nil {
			grpcerror.NewOpenAPIError(ctx, http.StatusUnauthorized, consts.ErrUnauthenticated)
			return
		}

		if !r.checkPermission(ctx, user) {
			return
		}

		// set grpc metadata
		if err := setGrpcGatewayMetaData(ctx, user); err != nil {
			tilog.Error(ctx.Request.Context(),
				"set grpc metadata failed",
				zap.Error(err))
			grpcerror.NewOpenAPIError(ctx, http.StatusInternalServerError, consts.ErrInternal)
			return
		}
		tilog.Info(ctx.Request.Context(), "rbac successful", zap.String("path", ctx.Request.URL.Path))
	}
}

// checkIdentityPermission by account ipml
func (r *rbacServiceImpl) checkPermission(ctx *gin.Context, user *entity.User) bool {
	resource := getResource(ctx)
	if resource == "" {
		tilog.Error(ctx.Request.Context(), "rbac check error: get resource uri failed",
			zap.String("path", ctx.Request.URL.Path),
			zap.String("method", ctx.Request.Method),
		)
		grpcerror.NewOpenAPIError(ctx, http.StatusNotFound, consts.ErrNoRoute)
		return false
	}

	checkPermissionParam := &authsdk.CheckPermissionParam{
		IdentityTye: convIdentityType(user.IdentityType),
		IdentityVal: convIdentityVal(user),
		ResourceUri: resource,
	}

	if checkPermissionParam.IdentityTye == "" || checkPermissionParam.IdentityVal == "" {
		tilog.Error(ctx.Request.Context(), "rbac check error: get identity type or identity value failed",
			zap.String("path", ctx.Request.URL.Path),
			zap.String("method", ctx.Request.Method),
			zap.Any("param", checkPermissionParam))
		grpcerror.NewOpenAPIError(ctx, http.StatusForbidden, consts.ErrPermissionDenied)
		return false
	}

	permission, msg, err := r.checkIdentityPermission(ctx, user, checkPermissionParam)

	if err != nil {
		tilog.Error(ctx.Request.Context(), "failed to check identity permission",
			zap.String("message", msg),
			zap.Uint64(consts.OrgIdKey, user.Org.Id),
			zap.Any(consts.ProjectIdKey, getProjectIDList(user)),
			zap.Any("user", user.User),
			zap.String("resource_uri", resource),
			zap.Error(err))
		grpcerror.NewOpenAPIError(ctx, http.StatusForbidden, consts.ErrPermissionDenied)
		return false
	}

	if !permission {
		tilog.Error(ctx.Request.Context(), "no permission",
			zap.String("message", msg),
			zap.Uint64(consts.OrgIdKey, user.Org.Id),
			zap.Any(consts.ProjectIdKey, getProjectIDList(user)),
			zap.Any("user", user.User),
			zap.String("resource_uri", resource),
			zap.Error(err))
		grpcerror.NewOpenAPIError(ctx, http.StatusForbidden, consts.ErrPermissionDenied)
		return false
	}

	return true
}

func isIgnoreRBACPath(ctx *gin.Context) bool {
	return slices.Contains(ignoreRBACPaths, ctx.Request.URL.Path)
}

func getResource(ctx *gin.Context) string {
	requestPath := fmt.Sprintf("%s:%s", strings.ToLower(ctx.Request.Method), consts.RelativePath(ctx.Request.URL.Path))
	if resource, ok := resourceCache.Get(requestPath); ok {
		return resource
	}

	for path, uri := range PathPattern2ResourceMap {
		if path.MatchString(requestPath) {
			resourceCache.Add(requestPath, uri)
			return uri
		}
	}
	tilog.Warn(
		ctx.Request.Context(),
		"rbac check error: no matched resource",
		zap.String(
			"path",
			requestPath,
		),
	)
	return ""
}

func convIdentityType(identityType authsdk.AuthnIdentityType) authsdk.AccessIdentityType {
	switch identityType {
	case authsdk.AuthnIdentityTypeUserToken:
		return authsdk.AccessIdentityTypeUserID
	case authsdk.AuthnIdentityTypeApiKeyAK:
		return authsdk.AccessIdentityTypeApiKeyAK
	case authsdk.AuthnIdentityTypeAccessToken:
		return authsdk.AccessIdentityTypeAccessToken
	}
	return ""
}

func convIdentityVal(user *entity.User) string {
	switch user.IdentityType {
	case authsdk.AuthnIdentityTypeUserToken:
		return fmt.Sprintf("%d", user.User.Id)
	case authsdk.AuthnIdentityTypeApiKeyAK:
		return user.PublicKey
	case authsdk.AuthnIdentityTypeAccessToken:
		return user.AccessToken
	}
	return ""
}

func setGrpcGatewayMetaData(ctx *gin.Context, user *entity.User) error {
	if user.Org == nil {
		return errtrace.Wrap(fmt.Errorf("failed to get org"))
	}

	// org id
	ctx.Request.Header.Set(grpcGatewayOrgIDKey, strconv.FormatUint(user.Org.Id, 10))
	if user.User == nil {
		return errtrace.Wrap(fmt.Errorf("failed to get user"))
	}

	// user info
	ctx.Request.Header.Set(grpcGatewayUserIDKey, strconv.FormatUint(user.User.Id, 10))
	ctx.Request.Header.Set(grpcGatewayUserEmailKey, user.User.Email)

	// auth type
	if user.PublicKey != "" {
		ctx.Request.Header.Set(grpcGatewayXAuthType, string(kong.AuthTypeOpenAPI))
		ctx.Request.Header.Set(grpcGatewayXAuthContent, ctx.GetHeader(authsdk.XAuthContentHeader))
		ctx.Request.Header.Set(grpcGatewayPublicKeyKey, user.PublicKey)
		ctx.Request.Header.Set(grpcGatewayPublicKeyKeyId, strconv.FormatUint(user.PublicKeyId, 10))
	}

	// access token
	if user.AccessToken != "" {
		ctx.Request.Header.Set(grpcGatewayAccessTokenKey, user.AccessToken)
		ctx.Request.Header.Set(grpcGatewayXAuthMethod, authsdk.XAuthMethodValBear)
		ctx.Request.Header.Set(grpcGatewayXAuthRaw, ctx.GetHeader(authsdk.XAuthRawHeader))
	}

	// project id
	projectIDList := getProjectIDList(user)
	ctx.Request.Header.Set(grpcGatewayProjectIDListKey, projectIDList)
	projectId, err := getDefaultProjectId(user)
	if err != nil {
		return errtrace.Wrap(fmt.Errorf("failed to get default projectId: %w", err))
	}
	ctx.Request.Header.Set(grpcGatewayDefaultProjectIDKey, strconv.FormatUint(projectId, 10))

	// cluster id
	clusterId, _ := parseClusterIdFromPath(consts.RelativePath(ctx.Request.URL.Path))
	if clusterId != "" {
		ctx.Request.Header.Set(grpcGatewayClusterIDKey, clusterId)
	}

	return nil
}

func getProjectIDList(u *entity.User) string {
	var projectIds []string
	for _, p := range u.Projects {
		projectIds = append(projectIds, strconv.FormatUint(p.Id, 10))
	}
	return strings.Join(projectIds, ",")
}

func getDefaultProjectId(u *entity.User) (uint64, error) {
	// sort by project id
	slices.SortFunc(u.Projects, func(a, b *accountsdk.Project) int {
		return cmp.Compare(a.Id, b.Id)
	})
	// return the oldest project id
	for _, p := range u.Projects {
		return p.Id, nil
	}
	return 0, errtrace.Wrap(errors.New("no default project found"))
}

var clusterIdRegexp = regexp.MustCompile(`/clusters/(?P<clusterId>-|\d+)`)
var shadowPoolIdRegexp = regexp.MustCompile(`/shadowPools/(?P<shadowPoolId>\d+)`)

// cluster id could be "-"
func parseClusterIdFromPath(path string) (string, error) {
	if index := clusterIdRegexp.SubexpIndex(consts.ClusterIdKey); index != -1 {
		res := clusterIdRegexp.FindStringSubmatch(path)
		if len(res) != 0 {
			return res[index], nil
		}
	}
	return "", errors.New("clusterId not found")
}

func parseShadowPoolIdFromPath(path string) (string, error) {
	if index := shadowPoolIdRegexp.SubexpIndex("shadowPoolId"); index != -1 {
		res := shadowPoolIdRegexp.FindStringSubmatch(path)
		if len(res) != 0 {
			return res[index], nil
		}
	}
	return "", errors.New("shadowPoolId not found")
}

func getUserFromContext(ctx *gin.Context) (*entity.User, error) {
	v, ok := ctx.Get(consts.XAuthUser)
	if !ok {
		tilog.Error(ctx.Request.Context(), "rbac check error: failed to get auth user",
			zap.String("path", ctx.Request.URL.Path),
			zap.String("method", ctx.Request.Method),
		)

		return nil, fmt.Errorf("failed to get auth user")
	}
	user, ok := v.(*entity.User)
	if !ok {
		tilog.Error(ctx.Request.Context(), "rbac check error: failed to get user from context",
			zap.String("path", ctx.Request.URL.Path),
			zap.String("method", ctx.Request.Method),
		)
		return nil, fmt.Errorf("failed to get user from context")
	}
	return user, nil
}
