package service

import (
	"context"
	"dataservices/commons/apikeygenerator"
	"dataservices/commons/config"
	"dataservices/commons/constants"
	ghutil "dataservices/commons/github"
	"dataservices/commons/utils"
	dto "dataservices/internal/dto/dataapi"
	"dataservices/internal/entity"
	"dataservices/internal/repository"
	"encoding/json"
	"errors"
	"fmt"
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/google/go-github/v50/github"
	"github.com/tidbcloud/tiinf/errs"
	"github.com/tidbcloud/tiinf/monitor/tilog"
	"go.uber.org/zap"
	"gorm.io/gorm"
)

type CallbackStateType string

const (
	UpdateDataappStateType    CallbackStateType = "D"
	PreflightRequestStateType CallbackStateType = "R"

	GithubOperator = "GitHub"
)

func EncryptDataappState(dataappId uint64) string {
	return apikeygenerator.Encrypt(fmt.Sprintf("%s:%d:%d", UpdateDataappStateType, time.Now().Unix(), dataappId))
}

func EncryptPreflightRequestState(preflightRequestId uint64) string {
	return apikeygenerator.Encrypt(fmt.Sprintf("%s:%d:%d", PreflightRequestStateType, time.Now().Unix(), preflightRequestId))
}

func DecryptState(state string) (CallbackStateType, uint64, error) {
	idStr, err := apikeygenerator.Decrypt(state)
	if err != nil {
		return "", 0, err
	}
	ids := strings.Split(idStr, ":")
	if len(ids) != 3 {
		return "", 0, errs.NewBadRequest("invalid state length", nil)
	}
	ts, err := strconv.ParseInt(ids[1], 10, 64)
	if err != nil {
		return "", 0, errs.NewBadRequest("invalid state ts", nil)
	}
	if time.Now().Unix()-ts > 3*60 {
		return "", 0, errs.NewBadRequest("state expired", nil)
	}
	stateType := CallbackStateType(ids[0])
	if stateType != UpdateDataappStateType && stateType != PreflightRequestStateType {
		return "", 0, errs.NewBadRequest("invalid state type", nil)
	}
	id, err := strconv.ParseUint(ids[2], 10, 64)
	if err != nil {
		return "", 0, errs.NewBadRequest("invalid state", err)
	}
	return stateType, id, nil
}

// SaveGithubEvent saves github webhook payload to database
func SaveGithubEvent(ctx context.Context, headerDelivery, headerEvent, payload string) uint64 {
	event := &entity.GhappEvent{
		GithubDelivery: headerDelivery,
		GithubEvent:    headerEvent,
		Payload:        payload,
	}
	ghappEvent, err := repository.CreateGhappEvent(ctx, event)
	if err != nil {
		tilog.Error(ctx, "create ghapp event failed", zap.Error(err))
		return 0
	}
	return ghappEvent.ID
}

// ProcessInstallationEvent handles installation event of github webhook
func ProcessInstallationEvent(ctx context.Context, event *github.InstallationEvent) error {
	switch *event.Action {
	case "created":
		tilog.Info(ctx, "installation created", zap.Int64("installation_id", *event.Installation.ID))
	case "suspend", "deleted":
		err := repository.Transaction(ctx, func(ctx context.Context) error {
			err := repository.UpdateGhappAuthorizedInstallationState(ctx, *event.Installation.ID, entity.GhappAuthorizedInstallationStateUninstalled)
			if err != nil {
				return errs.NewInternalDB("update state failed", err)
			}
			settings, err := repository.ListDataAppDeploymentSettingsByInstallationId(ctx, *event.Installation.ID)
			if err != nil {
				return errs.NewInternalDB("list settings failed", err)
			}
			for _, setting := range settings {
				tilog.Info(ctx, "clear datapp github likage", zap.Uint64("dataapp_id", setting.DataappID))
				if err = repository.ClearDataAppDeploymentSettingsGithubLikage(ctx, setting.ID); err != nil {
					return errs.NewInternalDB("clear github linkage failed", err)
				}
			}
			return nil
		})
		return err
	case "unsuspend":
		err := repository.UpdateGhappAuthorizedInstallationState(ctx, *event.Installation.ID, entity.GhappAuthorizedInstallationStateInstalled)
		if err != nil {
			return errs.NewInternalDB("update state failed", err)
		}
	case "new_permissions_accepted":
		tilog.Info(ctx, "new permissions accepted", zap.Int64("installation_id", *event.Installation.ID))
	default:
		tilog.Info(ctx, "unknown action", zap.String("action", *event.Action))
	}
	return nil
}

func ProcessInstallationRepositoriesEvent(ctx context.Context, event *github.InstallationRepositoriesEvent) error {
	switch *event.Action {
	case "added":
		for _, repo := range event.RepositoriesAdded {
			tilog.Info(ctx, "installation repo added", zap.Int64("installation_id", *event.Installation.ID), zap.String("repo", *repo.FullName))
		}
	case "removed":
		err := repository.Transaction(ctx, func(ctx context.Context) error {
			for _, repo := range event.RepositoriesRemoved {
				tilog.Info(ctx, "installation repo removed", zap.Int64("installation_id", *event.Installation.ID), zap.String("repo", *repo.FullName))
				settings, err := repository.ListDataAppDeploymentSettingsByRepoId(ctx, *repo.ID)
				if err != nil {
					return errs.NewInternalDB("list settings failed", err)
				}
				for _, setting := range settings {
					tilog.Info(ctx, "clear datapp github likage", zap.Uint64("dataapp_id", setting.DataappID))
					if err = repository.ClearDataAppDeploymentSettingsGithubLikage(ctx, setting.ID); err != nil {
						return errs.NewInternalDB("clear github linkage failed", err)
					}
				}
			}
			return nil
		})
		return err
	default:
		tilog.Info(ctx, "unknown action", zap.String("action", *event.Action))
	}
	return nil
}

func ProcessRepositoriesEvent(ctx context.Context, event *github.RepositoryEvent) error {
	switch *event.Action {
	case "archived", "deleted", "transferred":
		tilog.Info(ctx, "received repository event", zap.String("action", *event.Action), zap.Int64("repo_id", *event.Repo.ID))
		err := repository.Transaction(ctx, func(ctx context.Context) error {
			settings, err := repository.ListDataAppDeploymentSettingsByRepoId(ctx, *event.Repo.ID)
			if err != nil {
				return errs.NewInternalDB("list settings failed", err)
			}
			for _, setting := range settings {
				tilog.Info(ctx, "clear datapp github likage", zap.Uint64("dataapp_id", setting.DataappID))
				if err = repository.ClearDataAppDeploymentSettingsGithubLikage(ctx, setting.ID); err != nil {
					return errs.NewInternalDB("clear github linkage failed", err)
				}
			}
			return nil
		})
		return err
	case "created", "unarchived", "edited", "renamed", "publicized", "privatized":
		tilog.Info(ctx, "received repository event", zap.String("action", *event.Action), zap.Int64("repo_id", *event.Repo.ID))
	default:
		tilog.Info(ctx, "unknown action", zap.String("action", *event.Action))
	}
	return nil
}

func ProcessGithubAuthorizationEvent(ctx context.Context, event *github.GitHubAppAuthorizationEvent) error {
	switch *event.Action {
	case "revoked":
		tilog.Info(ctx, "received authorization revoked event", zap.Int64("sender_id", *event.Sender.ID))
		err := repository.Transaction(ctx, func(ctx context.Context) error {
			auths, err := repository.ListConfirmedGhappAuthorizationByAccountId(ctx, *event.Sender.ID)
			if err != nil {
				return errs.NewInternalDB("list auth failed", err)
			}
			for _, auth := range auths {
				if auth.DataappId == 0 {
					continue
				}
				tilog.Info(ctx, "unlink dataapp with github", zap.Uint64("dataapp_id", auth.DataappId))
				if err := UnlinkDataappWithGithub(ctx, auth.DataappId); err != nil {
					return err
				}
			}
			return nil
		})
		return err
	}
	return nil
}

func ProcessDeleteEvent(ctx context.Context, event *github.DeleteEvent) error {
	switch *event.RefType {
	case "branch":
		tilog.Info(ctx, "branch deleted", zap.String("ref_type", *event.RefType), zap.Int64("repo_id", *event.Repo.ID), zap.String("ref", *event.Ref))
		err := repository.Transaction(ctx, func(ctx context.Context) error {
			settings, err := repository.ListDataAppDeploymentSettingsByBranch(ctx, *event.Repo.ID, *event.Ref)
			if err != nil {
				return errs.NewInternalDB("list settings failed", err)
			}
			for _, setting := range settings {
				tilog.Info(ctx, "clear datapp github likage", zap.Uint64("dataapp_id", setting.DataappID))
				if err = repository.ClearDataAppDeploymentSettingsGithubLikage(ctx, setting.ID); err != nil {
					return errs.NewInternalDB("clear github linkage failed", err)
				}
			}
			return nil
		})
		return err
	case "tag":
		tilog.Info(ctx, "tag deleted", zap.String("ref_type", *event.RefType), zap.Int64("repo_id", *event.Repo.ID), zap.String("ref", *event.Ref))
	default:
		tilog.Info(ctx, "unknown action", zap.String("ref_type", *event.RefType))
	}
	return nil
}

func ProcessPushEvent(ctx context.Context, event *github.PushEvent) error {
	// filter pushed by bot
	if strings.Contains(*event.Sender.Login, "bot") {
		return nil
	}
	// delete branch should be handled individually
	if *event.After == "0000000000000000000000000000000000000000" {
		return nil
	}
	refParts := strings.Split(*event.Ref, "/")
	if len(refParts) != 3 {
		tilog.Error(ctx, "invalid ref", zap.String("ref", *event.Ref))
		return errs.NewBadRequest("invalid ref", nil)
	}
	branch := refParts[2]
	settings, err := repository.ListDataAppDeploymentSettingsByBranch(ctx, *event.Repo.ID, branch)
	if err != nil {
		return errs.NewInternalDB("list settings failed", err)
	}
	for _, setting := range settings {
		// the behavior is same as atlas
		// if the branch has multiple directory associated with dataapp
		// then the push event with trigger deployment of each directory
		if !setting.EnableAutoDeploy {
			continue
		}
		err := processPushEventSingleDir(ctx, setting, event)
		if err != nil {
			deploymentLog := &entity.DataAppDeploymentLog{
				DataappID:          setting.DataappID,
				Name:               *event.HeadCommit.Message,
				Origin:             entity.DataAppDeploymentLogOriginGithub,
				CommitID:           *event.After,
				DiffUrl:            *event.Compare,
				CreatedBy:          *event.Sender.Login,
				Status:             entity.DataAppDeploymentStatusFailed,
				StatusErrorMessage: fmt.Sprintf("Failed: %s", err),
				Snapshot:           "",
				DeployedAt:         utils.Now(),
				FinishedAt:         utils.Now(),
			}
			repository.CreateDataAppDeploymentLog(ctx, deploymentLog)
			tilog.Error(ctx, "processPushEventSingleDir failed", zap.Uint64("dataapp_id", setting.DataappID), zap.String("branch", setting.GithubBranch), zap.String("directory", setting.GithubDirectory), zap.Error(err))
			// return err
		}
	}
	return nil
}

func processPushEventSingleDir(ctx context.Context, setting *entity.DataAppDeploymentSetting, event *github.PushEvent) error {
	_, err := repository.GetConfirmedGhappAuthorizationByDataappId(ctx, setting.DataappID)
	if err != nil {
		return errs.NewInternalDB("get confirmed authorization failed", err)
	}
	entries, err := ghutil.ListCommitEntries(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		*event.Repo.Owner.Login,
		*event.Repo.Name,
		*event.After,
	)
	if err != nil {
		return errs.NewInternalDB("list commit entries failed", err)
	}
	tilog.Info(ctx, "list entries result", zap.Int("entries_length", len(entries)), zap.String("sha", *event.After))

	fileTree, err := ghutil.Entries2FileTreeNode(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		*event.Repo.Owner.Login,
		*event.Repo.Name,
		setting.GithubDirectory,
		entries,
	)
	if err != nil {
		return errs.NewInternalGeneralBusiness(fmt.Sprintf("parse github files failed: %s", err), err)
	}
	tilog.Info(ctx, "entries2FileTreeNode result", zap.Any("tree", fileTree))

	// check if there is diff to avoid unnessasary deployment
	lastSnapshot, _, err := GetLatestSuccDeploySnapshot(ctx, setting.DataappID)
	tilog.Info(ctx, "lastSnapshot result", zap.Any("lastSnapshot", lastSnapshot))
	if err != nil {
		tilog.Error(ctx, "continue deployment since get latest success deployment failed", zap.Uint64("dataapp_id", setting.DataappID), zap.String("branch", setting.GithubBranch), zap.String("directory", setting.GithubDirectory), zap.Error(err))
	} else if lastSnapshot != nil {
		lastFileTree, err := ghutil.Snapshot2FileTreeNode(lastSnapshot)
		if err != nil {
			tilog.Error(ctx, "continue deployment since convert snapshot to file tree failed", zap.Uint64("dataapp_id", setting.DataappID), zap.String("branch", setting.GithubBranch), zap.String("directory", setting.GithubDirectory), zap.Error(err))
		} else if lastFileTree != nil {
			diff := lastFileTree.Diff(fileTree)
			diffStr, err := diff.ToString()
			if err == nil && diffStr == "" {
				tilog.Info(ctx, "skip deployment since no diff", zap.Uint64("dataapp_id", setting.DataappID), zap.String("branch", setting.GithubBranch), zap.String("directory", setting.GithubDirectory))
				return nil
			} else {
				tilog.Info(ctx, "continue deployment since diff", zap.Uint64("dataapp_id", setting.DataappID), zap.String("branch", setting.GithubBranch), zap.String("directory", setting.GithubDirectory), zap.Error(err), zap.String("diff", diffStr))
			}
			tilog.Info(ctx, "diffStr", zap.Any("diffStr", diffStr))
		}
	}

	snapshot, err := ghutil.FileTreeNode2Snapshot(fileTree)
	if err != nil {
		return errs.NewInternalGeneralBusiness(fmt.Sprintf("validate github files failed: %s", err), err)
	}

	dataApp, err := repository.GetDataApp(setting.DataappID)
	if err != nil {
		return errs.NewBadRequest("GetDataApp Info failed", err)
	}
	if err = ValidateSnapshot(ctx, dataApp, snapshot); err != nil {
		return err
	}

	err = repository.Transaction(ctx, func(ctx context.Context) error {
		fullSnapshot, err := PatchInfoToSnapshot(ctx, dataApp, snapshot)
		if err != nil {
			return err
		}
		snapshotJson, err := json.Marshal(fullSnapshot)
		if err != nil {
			return errs.NewInternalGeneralBusiness("failed to dump json", err)
		}

		deploymentLog := &entity.DataAppDeploymentLog{
			DataappID:  setting.DataappID,
			Name:       *event.HeadCommit.Message,
			Origin:     entity.DataAppDeploymentLogOriginGithub,
			CommitID:   *event.After,
			DiffUrl:    *event.Compare,
			CreatedBy:  *event.Sender.Login,
			Status:     entity.DataAppDeploymentStatusPending,
			Snapshot:   string(snapshotJson),
			DeployedAt: utils.Now(),
		}
		_, err = repository.CreateDataAppDeploymentLog(ctx, deploymentLog)
		if err != nil {
			return errs.NewInternalDB("failed to create deployment log", err)
		}
		return nil
	})
	return err
}

func CheckDataAppObjValid(ctx context.Context, dataApp *entity.DataApp, dataAppFromGit *entity.DataApp) error {
	if dataApp.ExternalAppID != dataAppFromGit.ExternalAppID {
		return errs.NewBadRequest("ExternalAppID check failed.", nil)
	}
	if err := validateAppName(dataAppFromGit.AppName); err != nil {
		return err
	}
	if err := validateAppDescription(dataAppFromGit.Description); err != nil {
		return err
	}
	if dataAppFromGit.AppVersion == "" {
		dataAppFromGit.AppVersion = "1.0.0"
	}
	if err := validateAppVersion(dataAppFromGit.AppVersion); err != nil {
		return err
	}
	if string(dataAppFromGit.AppType) != "dataapi" && string(dataAppFromGit.AppType) != "dataapp" {
		return errs.NewBadRequest("AppType check failed.", nil)
	}
	return nil
}

func PatchInfoToSnapshotDataAppObj(ctx context.Context, dataApp *entity.DataApp, dataAppFromGit *entity.DataApp) (*entity.DataApp, error) {
	dataApp.ExternalAppID = dataAppFromGit.ExternalAppID
	dataApp.AppType = entity.DataAppTypeDataapi
	dataApp.AppName = dataAppFromGit.AppName
	dataApp.Description = dataAppFromGit.Description
	dataApp.AppVersion = dataAppFromGit.AppVersion
	return dataApp, nil
}

func CheckEndpointObjValid(ctx context.Context, dataApp *entity.DataApp, ep *entity.Endpoint) error {
	if ep.Type != entity.EndpointTypeChat2Sql && ep.Type != entity.EndpointTypeSystemData {
		return errs.NewBadRequest("invalid type", nil)
	}
	var setting entity.EndpointSettings
	json.Unmarshal([]byte(ep.Settings), &setting)
	if ep.IsBatch() {
		err := VerifyBatchOn(ctx, entity.VerifyBatch{
			Sql:    ep.SqlTemplate,
			Method: ep.Method,
		})
		if err != nil {
			return errs.NewBadRequest("batch should not turn on", err)
		}
		// if setting.RowsLimit > constants.ExeSQLBatchMaxRowlimit {
		// 	return errs.NewBadRequest(fmt.Sprintf("batch endpoint's row limit should not exceed %d", constants.ExeSQLBatchMaxRowlimit), nil)
		// }
	}
	var args []entity.EndpointArgs
	json.Unmarshal([]byte(ep.Args), &args)
	endpointParam := entity.CreateEndpointParam{
		Name:        ep.Name,
		Description: ep.Description,
		Endpoint:    ep.Endpoint,
		Method:      ep.Method,
		ClusterId:   ep.ClusterId,
		Database:    ep.Database,
		SqlTemplate: ep.SqlTemplate,
		Settings:    setting,
		Args:        args,
		Batch:       ep.IsBatch(),
		Tag:         ep.Tag,
	}
	var selectedKeys map[string]any
	if ep.IsSystemDataEndpoint() {
		selectedKeys = map[string]interface{}{"settings": struct{}{}}
	} else {
		selectedKeys = map[string]interface{}{"name": struct{}{}, "settings": struct{}{}, "description": "", "args": []any{}}
	}
	err := isEndpointParamValid(ctx, dataApp, ep, &endpointParam, selectedKeys)
	if err != nil {
		tilog.Error(ctx, "isEndpointParamValid failed", zap.String("endpoint", endpointParam.Method+":"+endpointParam.Endpoint), zap.Error(err))
		return err
	}
	return checkEndpointCanRun(ctx, ep)
}

func PatchInfoToSnapshotEndpointObjs(ctx context.Context, dataAppId uint64, endpointsFromGit []*entity.Endpoint) ([]*entity.Endpoint, error) {
	var endpoints []*entity.Endpoint
	for _, endpointFromGit := range endpointsFromGit {
		endpointFromGit.DataappId = dataAppId
		endpointFromGit.RWMode = getSqlRwMode(ctx, endpointFromGit.SqlTemplate)
		endpoint, err := repository.GetDraftEndpointByNameAndMethod(endpointFromGit.DataappId, endpointFromGit.Endpoint, endpointFromGit.Method)
		if err != nil {
			if !errors.Is(err, errs.ErrNotFound) {
				return endpointsFromGit, err
			}
			if endpointFromGit.IsSystemDataEndpoint() {
				if !isGoodFormatSystemDataEndpoint(ctx, endpointFromGit) {
					return endpointsFromGit, errs.NewBadRequest(fmt.Sprintf("Invalid endpoint format for System/Data type: %s:%s", endpointFromGit.Method, endpointFromGit.Endpoint), nil)
				}
				endpointFromGit.RWMode = constants.SqlModeINVALID
			}
			if endpointFromGit.IsBatch() {
				settings := endpointFromGit.GetSettings()
				settings.RowsLimit = 0
				settingsStr, _ := json.Marshal(settings)
				endpointFromGit.Settings = string(settingsStr)
			}
			endpointFromGit.CreatedBy = GithubOperator
			endpointFromGit.UpdatedBy = GithubOperator
			var newErr error
			endpoint, newErr = repository.CreateEndpoint(ctx, endpointFromGit, nil)
			if newErr != nil {
				return endpointsFromGit, newErr
			}
			endpoints = append(endpoints, endpoint)
		} else {
			if endpoint.Type != endpointFromGit.Type {
				return nil, errs.NewBadRequest(fmt.Sprintf("Modifying endpoint type is not allowed:%s:%s", endpointFromGit.Method, endpointFromGit.Endpoint), nil)
			}
			if endpointFromGit.IsBatch() {
				settings := endpointFromGit.GetSettings()
				settings.RowsLimit = endpoint.GetSettings().RowsLimit
				settingsStr, _ := json.Marshal(settings)
				endpoint.Settings = string(settingsStr)
			} else {
				endpoint.Settings = endpointFromGit.Settings
			}
			if !endpoint.IsSystemDataEndpoint() {
				endpoint.Name = endpointFromGit.Name
				endpoint.Description = endpointFromGit.Description
				endpoint.Endpoint = endpointFromGit.Endpoint
				endpoint.Method = endpointFromGit.Method
				endpoint.ClusterId = endpointFromGit.ClusterId
				endpoint.Database = endpointFromGit.Database
				endpoint.SqlTemplate = endpointFromGit.SqlTemplate
				endpoint.Args = endpointFromGit.Args
				endpoint.RWMode = endpointFromGit.RWMode
				endpoint.Tag = endpointFromGit.Tag
				endpoint.Batch = endpointFromGit.IsBatch()
			} else {
				if err := checkSystemDataEndpointSettingsValid(endpoint.Settings); err != nil {
					return nil, err
				}
			}
			endpoints = append(endpoints, endpoint)
		}
	}
	return endpoints, nil
}

func isGoodFormatSystemDataEndpoint(ctx context.Context, endpoint *entity.Endpoint) bool {
	if err := checkSystemDataEndpointSettingsValid(endpoint.Settings); err != nil {
		tilog.Error(ctx, "isGoodFormatSystemDataEndpoint failed with check settings params", zap.Error(err), zap.Uint64("endpoint_id", endpoint.ID))
		return false
	}
	if err := checkSystemDataEndpointArgsValid(endpoint.Args); err != nil {
		tilog.Error(ctx, "isGoodFormatSystemDataEndpoint failed with check args params", zap.Error(err), zap.Uint64("endpoint_id", endpoint.ID))
		return false
	}
	systemEndpoint := defaultExecuteQueryEndpoint
	systemEndpoint.DataappId = endpoint.DataappId
	systemEndpoint.Settings = endpoint.Settings
	systemEndpoint.Args = endpoint.Args
	systemEndpoint.Description = url.PathEscape(systemEndpoint.Description)
	return endpoint.Equal(&systemEndpoint)
}

func checkSystemDataEndpointSettingsValid(settings any) error {
	userSettings, ok := settings.(entity.EndpointSettings)
	if !ok {
		settingsStr, ok := settings.(string)
		if !ok {
			return errs.NewBadRequest("invalid endpoint settings", nil)
		}
		if err := json.Unmarshal([]byte(settingsStr), &userSettings); err != nil {
			return errs.NewBadRequest("falied to parse user endpoint settings", err)
		}
	}

	if userSettings.CacheEnabled != 0 || userSettings.CacheTTL != 0 || userSettings.EnablePagination != 0 {
		return errs.NewBadRequest("invalid parameters in endpoint settings", nil)
	}
	return nil
}

func checkSystemDataEndpointArgsValid(args string) error {
	var argList entity.HttpEndpointArgsList
	if err := utils.FromJson(args, &argList); err != nil {
		return err
	}
	for _, arg := range argList {
		if arg.Name == "cluster_id" || arg.Name == "database" {
			arg.Default = "%s"
		}
	}

	var systemArgList entity.HttpEndpointArgsList
	if err := utils.FromJson(defaultExecuteQueryEndpoint.Args, &systemArgList); err != nil {
		return err
	}
	if !systemArgList.Equal(argList) {
		return errs.NewBadRequest("invalid endpoint arguments", nil)
	}
	return nil
}

func PatchInfoToSnapshotClusterObjs(ctx context.Context, dataApp *entity.DataApp, clusterFromGit []*entity.AppCluster) ([]*entity.AppCluster, error) {
	appAllClusters, err := repository.ListAppClusterByAppId(ctx, dataApp.ID)
	if err != nil {
		return nil, err
	}
	appClusterMap := make(map[uint64]*entity.AppCluster)
	for _, appCluster := range appAllClusters {
		appClusterMap[appCluster.ClusterID] = appCluster
	}

	result := make([]*entity.AppCluster, 0, len(clusterFromGit))
	for _, cluster := range clusterFromGit {
		appCluster := appClusterMap[cluster.ClusterID]
		if appCluster == nil {
			result = append(result, cluster)
		} else {
			result = append(result, appCluster)
		}
	}
	return result, nil
}

func PatchInfoToSnapshot(ctx context.Context, dataApp *entity.DataApp, snapshot *entity.DataAppSnapshot) (*entity.DataAppSnapshot, error) {
	var err error
	// 1. validate dataapp info
	// 2. fill endpoint info to snapshot
	snapshot.DataApp, err = PatchInfoToSnapshotDataAppObj(ctx, dataApp, snapshot.DataApp)
	if err != nil {
		return snapshot, err
	}
	snapshot.Endpoints, err = PatchInfoToSnapshotEndpointObjs(ctx, dataApp.ID, snapshot.Endpoints)
	if err != nil {
		return snapshot, err
	}
	snapshot.Clusters, err = PatchInfoToSnapshotClusterObjs(ctx, dataApp, snapshot.Clusters)
	if err != nil {
		return snapshot, err
	}
	return snapshot, nil
}

func ValidateSnapshot(ctx context.Context, dataApp *entity.DataApp, snapshot *entity.DataAppSnapshot) error {
	if err := CheckDataAppObjValid(ctx, dataApp, snapshot.DataApp); err != nil {
		return err
	}

	clusterIds := make([]uint64, 0)
	seenClusterIds := make(map[uint64]struct{})
	for _, cluster := range snapshot.Clusters {
		clusterIds = append(clusterIds, cluster.ClusterID)
		if _, ok := seenClusterIds[cluster.ClusterID]; ok {
			return errs.NewBadRequest(fmt.Sprintf("validate data_sources/cluster.json failed: duplicate cluster_id `%d`", cluster.ClusterID), nil)
		}
		seenClusterIds[cluster.ClusterID] = struct{}{}
	}
	if len(clusterIds) == 0 {
		return errs.NewBadRequest("Sorry, dataapp MUST have at least one datasource", nil)
	}
	err := checkClusterStatus(ctx, dataApp.OrgId, dataApp.ProjectId, clusterIds, "")
	if err != nil {
		return errs.NewBadRequest(fmt.Sprintf("validate data_sources/cluster.json failed: %s", err), err)
	}

	pathTrie := utils.NewPathTrie()
	for _, endpoint := range snapshot.Endpoints {
		endpointUniq := fmt.Sprintf("%s:%s", endpoint.Method, endpoint.Endpoint)
		if err := pathTrie.Insert(endpoint.Method, endpoint.Endpoint, nil); err != nil {
			return err
		}
		endpoint.DataappId = dataApp.ID
		if err := CheckEndpointObjValid(ctx, dataApp, endpoint); err != nil {
			return errs.NewBadRequest(fmt.Sprintf("validate endpoint `%s` failed: %s", endpointUniq, err), err)
		}
		if !endpoint.IsSystemDataEndpoint() {
			if _, ok := seenClusterIds[endpoint.ClusterId]; !ok {
				return errs.NewBadRequest(fmt.Sprintf("validate endpoint `%s` failed: cluster_id doesn't belong to data source", endpointUniq), nil)
			}
		}
	}
	return nil
}

// ProcessAuthorizationCallback handles the github callback when user finish their authorization
func ProcessAuthorizationCallback(ctx context.Context, code, state string) (string, error) {
	if code == "" || state == "" {
		return "", errs.NewBadRequest("invalid callback", nil)
	}
	stateType, id, err := DecryptState(state)
	if err != nil {
		return "", err
	}
	user, installations, err := ghutil.Code2UserAndInstallations(ctx, code)
	if err != nil {
		return "", errs.NewBadRequest("invalid code", err)
	}

	switch stateType {
	case UpdateDataappStateType:
		return processUpdateDataappCallback(ctx, user, installations, id)
	case PreflightRequestStateType:
		return processPreflightRequestCallback(ctx, user, installations, id)
	default:
		return "", errs.NewBadRequest("invalid state type", nil)
	}
}

func processPreflightRequestCallback(ctx context.Context, user *github.User, installations []*github.Installation, preflightRequestId uint64) (string, error) {
	err := repository.Transaction(ctx, func(ctx context.Context) error {
		auth, err := repository.GetGhappAuthorizationByPreflightRequestId(ctx, preflightRequestId)
		if err != nil {
			return errs.NewInternalDB("get auth failed", err)
		}

		if err := repository.UpdateGhappAuthorizationAccount(ctx, auth.ID, *user.ID, *user.Login); err != nil {
			return errs.NewInternalDB("update account failed", err)
		}

		if err := confirmGhappAuthorization(ctx, auth, installations); err != nil {
			return errs.NewInternalGeneralBusiness("confirm auth failed", err)
		}
		return nil
	})
	if err != nil {
		return "", err
	}

	return config.Global().GhappConf.PreflightRequestRedirectUrl, nil
}

func processUpdateDataappCallback(ctx context.Context, user *github.User, installations []*github.Installation, dataappId uint64) (string, error) {
	err := repository.Transaction(ctx, func(ctx context.Context) error {
		dataapp, err := repository.GetDataAppWithCtx(ctx, dataappId)
		if err != nil {
			return errs.NewInternalDB("get datapp failed", err)
		}

		confirmedAuth, err := repository.GetConfirmedGhappAuthorizationByDataappId(ctx, dataapp.ID)
		if err == nil && confirmedAuth.AuthorizeAccountId != *user.ID {
			return errs.NewBadRequest("a dataapp can only be authorized once", nil)
		}

		auth, err := repository.GetGhappAuthorizationByAuthorizeAccountId(ctx, dataapp.ID, *user.ID)
		if err != nil && err != gorm.ErrRecordNotFound {
			return errs.NewInternalDB("get auth failed", err)
		}
		if err == gorm.ErrRecordNotFound {
			auth = &entity.GhappAuthorization{
				DataappId:             dataapp.ID,
				AuthorizeAccountId:    *user.ID,
				AuthorizeAccountLogin: *user.Login,
				State:                 entity.GhappAuthorizationStateUnconfirmed,
			}
			if _, err := repository.CreateGhappAuthorization(ctx, auth); err != nil {
				return errs.NewInternalDB("create auth failed", err)
			}
		} else {
			if err := repository.UpdateGhappAuthorizationState(ctx, auth.ID, entity.GhappAuthorizationStateUnconfirmed); err != nil {
				return errs.NewInternalDB("update state failed", err)
			}
		}

		if err := confirmGhappAuthorization(ctx, auth, installations); err != nil {
			return errs.NewInternalGeneralBusiness("confirm auth failed", err)
		}
		return nil
	})
	if err != nil {
		return "", err
	}

	return config.Global().GhappConf.UpdateDataappRedirectUrl, nil
}

// If the github user has installed the githubapp before authorization
// update authorization state to `confirmed`
// then the user can bind repository to the dataapp
func confirmGhappAuthorization(ctx context.Context, auth *entity.GhappAuthorization, installations []*github.Installation) error {
	if err := repository.UpdateGhappAuthorizedInstallationStateToUninstalled(ctx, auth.ID); err != nil {
		return errs.NewInternalDB("update state failed", err)
	}
	for _, installation := range installations {
		authorizedInstallation := &entity.GhappAuthorizedInstallation{
			GhappAuthorizationId: auth.ID,
			InstallationId:       *installation.ID,
			State:                entity.GhappAuthorizedInstallationStateInstalled,
		}
		if err := repository.UpsertGhappAuthorizedInstallation(ctx, authorizedInstallation); err != nil {
			return errs.NewInternalDB("upsert auth failed", err)
		}
	}
	if len(installations) > 0 {
		if err := repository.UpdateGhappAuthorizationState(ctx, auth.ID, entity.GhappAuthorizationStateConfirmed); err != nil {
			return errs.NewInternalDB("update state failed", err)
		}
	}
	return nil
}

func GetAuthorizeUrl(ctx context.Context, dataappId uint64) (*dto.GetAuthorizeUrl, error) {
	state := EncryptDataappState(dataappId)
	url := fmt.Sprintf("https://github.com/login/oauth/authorize?client_id=%s&state=%s", config.Global().GhappConf.ClientId, state)
	return &dto.GetAuthorizeUrl{Url: url}, nil
}

func GeneratePreflightRequest(ctx context.Context, orgId, projectId uint64, createdBy string) (*dto.GetAuthorizeUrl, error) {
	preflightRequest, err := repository.CreateGhappPreflightRequest(ctx, &entity.GhappPreflightRequest{
		OrgId:     orgId,
		ProjectId: projectId,
		CreatedBy: createdBy,
	})
	if err != nil {
		return nil, errs.NewInternalDB("create preflight request failed", err)
	}
	preflightRequestId := preflightRequest.ID

	err = repository.CreatePreflightGhappAuthorization(ctx, preflightRequestId)
	if err != nil {
		return nil, errs.NewInternalDB("create auth failed", err)
	}
	state := EncryptPreflightRequestState(preflightRequestId)
	url := fmt.Sprintf("https://github.com/login/oauth/authorize?client_id=%s&state=%s", config.Global().GhappConf.ClientId, state)
	return &dto.GetAuthorizeUrl{Url: url, PreflightRequestId: strconv.FormatUint(preflightRequestId, 10)}, nil
}

func ListAuthorizedRepositories(ctx context.Context, dataappId, preflightRequestId uint64) ([]*dto.GithubRepository, error) {
	if dataappId == 0 && preflightRequestId == 0 {
		return nil, errs.NewBadRequest("invalid param", nil)
	}
	result := make([]*dto.GithubRepository, 0, 10)
	var auth *entity.GhappAuthorization
	var err error
	if dataappId > 0 {
		auth, err = repository.GetConfirmedGhappAuthorizationByDataappId(ctx, dataappId)
	} else {
		auth, err = repository.GetConfirmedGhappAuthorizationByPreflightRequestId(ctx, preflightRequestId)
	}
	if err != nil && err != gorm.ErrRecordNotFound {
		return nil, errs.NewBadRequest("failed to get authorization", err)
	} else if err == gorm.ErrRecordNotFound {
		return result, nil
	}

	installations, err := repository.ListGhappAuthorizedInstallationByAuthorizationId(ctx, auth.ID)
	if err != nil && err != gorm.ErrRecordNotFound {
		return nil, errs.NewBadRequest("failed to get installation", err)
	} else if err == gorm.ErrRecordNotFound {
		return result, nil
	}
	for _, installation := range installations {
		repos, err := ghutil.ListGithubRepositoriesByInstallationId(
			ctx,
			config.Global().GhappConf.AppId,
			installation.InstallationId,
		)
		if err != nil {
			return nil, errs.NewInternalGeneralBusiness("list repos failed", err)
		}
		for _, repo := range repos {
			result = append(result, &dto.GithubRepository{
				FullName:       repo.FullName,
				Id:             repo.ID,
				Private:        *repo.Private,
				InstallationId: installation.InstallationId,
				PushedAt:       repo.PushedAt,
			})
		}
	}
	sort.SliceStable(result, func(i, j int) bool {
		return result[i].PushedAt.String() > result[j].PushedAt.String()
	})
	return result, nil
}

func ListBranches(ctx context.Context, dataappId, preflightRequestId uint64, repoId int64) ([]*dto.GithubRepositoryBranch, error) {
	repos, err := ListAuthorizedRepositories(ctx, dataappId, preflightRequestId)
	if err != nil {
		return nil, errs.NewInternalDB("list auth failed", err)
	}
	var r *dto.GithubRepository
	for _, repo := range repos {
		if *repo.Id == repoId {
			r = repo
		}
	}
	if r == nil {
		return nil, errs.NewInternalGeneralBusiness("repository not found", nil)
	}
	fullName := strings.Split(*r.FullName, "/")
	branches, err := ghutil.ListGithubRepositoryBranches(ctx, config.Global().GhappConf.AppId, r.InstallationId, fullName[0], fullName[1])
	if err != nil {
		return nil, errs.NewInternalGeneralBusiness("list github branches failed", err)
	}
	result := make([]*dto.GithubRepositoryBranch, 0, 10)
	for _, b := range branches {
		result = append(result, &dto.GithubRepositoryBranch{
			Name:      b.Name,
			Protected: utils.Bool2Ptr(false),
		})
	}
	return result, nil
}

func ValidateRepoBranchDir(ctx context.Context, dataappId, preflightRequestId uint64, repoId int64, branch, directory string) (*dto.RepoBranchDirValidateResult, error) {
	branches, err := ListBranches(ctx, dataappId, preflightRequestId, repoId)
	if err != nil {
		return nil, err
	}
	found := false
	for _, b := range branches {
		if branch == *b.Name {
			found = true
		}
	}
	if !found {
		return nil, errs.NewBadRequest("branch not found", nil)
	}
	settings, err := repository.ListDataAppDeploymentSettingsByGithubRepo(ctx, repoId, branch, directory)
	if err != nil {
		return nil, errs.NewInternalDB("list settings failed", err)
	}
	if len(settings) == 0 {
		return &dto.RepoBranchDirValidateResult{Conflict: false}, nil
	}
	confictSetting := settings[0]
	dataapp, err := repository.GetDataAppWithCtx(ctx, confictSetting.DataappID)
	if err != nil {
		return nil, errs.NewInternalDB("get dataapp failed", err)
	}
	// should not confilict with itself
	if dataappId != 0 && dataappId == dataapp.ID {
		return &dto.RepoBranchDirValidateResult{Conflict: false}, nil
	}
	return &dto.RepoBranchDirValidateResult{Conflict: true, ExternalAppID: dataapp.ExternalAppID}, nil
}

func SyncSnapshotToGithub(ctx context.Context, dataAppID uint64, commitMessage string, snapshot *entity.DataAppSnapshot) error {
	setting, err := repository.GetDataAppDeploySetting(ctx, dataAppID)
	if err != nil {
		tilog.Error(ctx, "failed to get deployment setting", zap.Uint64("dataapp_id", dataAppID), zap.Error(err))
		return errs.NewInternalDB("get settings failed", err)
	}
	if !setting.EnableAutoDeploy || setting.GithubInstallationId == 0 ||
		setting.GithubRepoID == 0 || setting.GithubBranch == "" || setting.GithubDirectory == "" {
		tilog.Info(ctx, "dataapp deployment setting can not sync snapshot to github", zap.Uint64("dataapp_id", dataAppID))
		return nil
	}

	fileTree, err := ghutil.Snapshot2FileTreeNode(snapshot)
	if err != nil {
		tilog.Error(ctx, "convert snapshot to file tree failed", zap.Uint64("dataapp_id", dataAppID), zap.Error(err))
		return errs.NewInternalGeneralBusiness("convert snapshot to file tree failed", err)
	}

	repo, err := ghutil.GetRepoById(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		setting.GithubRepoID,
	)
	if err != nil {
		tilog.Error(ctx, "get github repo failed", zap.Uint64("dataapp_id", dataAppID), zap.Error(err))
		return errs.NewInternalGeneralBusiness("get github repo failed", err)
	}
	branch, err := ghutil.GetBranch(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		*repo.Owner.Login,
		*repo.Name,
		setting.GithubBranch,
	)
	if err != nil {
		tilog.Error(ctx, "get github branch failed", zap.Uint64("dataapp_id", dataAppID), zap.Error(err))
		return errs.NewInternalGeneralBusiness("get github branch failed", err)
	}
	entries, err := ghutil.ListCommitEntries(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		*repo.Owner.Login,
		*repo.Name,
		*branch.Commit.SHA,
	)
	if err != nil {
		return errs.NewInternalDB("list commit entries failed", err)
	}
	tilog.Info(ctx, "list entries result", zap.Int("entries_length", len(entries)), zap.String("sha", *branch.Commit.SHA))

	lastFileTree, err := ghutil.Entries2FileTreeNode(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		*repo.Owner.Login,
		*repo.Name,
		setting.GithubDirectory,
		entries,
	)
	if err != nil {
		return errs.NewInternalGeneralBusiness(fmt.Sprintf("parse github files failed: %s", err), err)
	}
	tilog.Info(ctx, "entries2FileTreeNode result", zap.Any("tree", lastFileTree))

	err = ghutil.MakeCommit(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		*repo.Owner.Login,
		*repo.Name,
		setting.GithubBranch,
		setting.GithubDirectory,
		commitMessage,
		fileTree,
		lastFileTree,
	)
	if err != nil {
		tilog.Error(ctx, "failed to commit to github", zap.Uint64("dataapp_id", dataAppID), zap.Error(err))
		return errs.NewInternalGeneralBusiness("commit to github failed", err)
	}
	tilog.Info(ctx, "sync to github succeed", zap.Uint64("dataapp_id", dataAppID))
	return nil
}

func GetDataappGithubLinkageStatus(ctx context.Context, dataappId uint64) (*dto.GithubLinkageStatusResult, error) {
	_, err := repository.GetConfirmedGhappAuthorizationByDataappId(ctx, dataappId)
	if err != nil && err != gorm.ErrRecordNotFound {
		return nil, errs.NewInternalDB("get auth failed", err)
	} else if err == gorm.ErrRecordNotFound {
		return &dto.GithubLinkageStatusResult{
			Authorized: false,
		}, nil
	}

	setting, err := repository.GetDataAppDeploySetting(ctx, dataappId)
	if err != nil {
		return nil, errs.NewInternalDB("get settings failed", err)
	}
	if setting.GithubRepoID == 0 {
		return &dto.GithubLinkageStatusResult{
			Authorized: true,
		}, nil
	}

	repo, err := ghutil.GetRepoById(
		ctx,
		config.Global().GhappConf.AppId,
		setting.GithubInstallationId,
		setting.GithubRepoID,
	)
	if err != nil {
		return nil, errs.NewInternalGeneralBusiness("get repo failed", err)
	}

	return &dto.GithubLinkageStatusResult{
		Authorized:       true,
		InstallationId:   setting.GithubInstallationId,
		RepoId:           setting.GithubRepoID,
		RepoFullName:     *repo.FullName,
		Branch:           setting.GithubBranch,
		Directory:        setting.GithubDirectory,
		EnableAutoDeploy: setting.EnableAutoDeploy,
	}, nil
}

func UnlinkDataappWithGithub(ctx context.Context, dataappId uint64) error {
	tilog.Info(ctx, "clear dataapp github linkage", zap.Uint64("dataapp_id", dataappId))
	err := repository.ClearDataAppDeploymentSettingsGithubLikageByDataappId(ctx, dataappId)
	if err != nil {
		return errs.NewInternalDB("clear github linkage failed", err)
	}
	auth, err := repository.GetConfirmedGhappAuthorizationByDataappId(ctx, dataappId)
	if err != nil && err != gorm.ErrRecordNotFound {
		return errs.NewInternalDB("get auth failed", err)
	} else if err == gorm.ErrRecordNotFound {
		return nil
	}
	tilog.Info(ctx, "update ghapp_authorization to unconfirmed", zap.Uint64("dataapp_id", dataappId))
	return repository.UpdateGhappAuthorizationState(ctx, auth.ID, entity.GhappAuthorizationStateUnconfirmed)
}

func ValidatePreflightRequestId(ctx context.Context, orgId, projectId, preflightRequestId uint64) error {
	_, err := repository.GetOrgProjectPreflightRequest(ctx, orgId, projectId, preflightRequestId)
	if err != nil && err != gorm.ErrRecordNotFound {
		return errs.NewInternalDB("get preflight request failed", err)
	} else if err == gorm.ErrRecordNotFound {
		return errs.NewForbidden("invalid preflight_request_id", nil)
	}
	return nil
}

func IsSnapshotObjDiffByGithub(diff *entity.SnapshotDiff) bool {
	if !diff.HasDiff ||
		diff.DataAppDiff.HasDiff() ||
		diff.AppClustersDiff.HasDiff() ||
		!diff.EndpointsDiff.HasDiff() ||
		len(diff.EndpointsDiff.Modified) > 0 ||
		len(diff.EndpointsDiff.Deleted) > 0 ||
		len(diff.EndpointsDiff.ModifiedPublish) > 0 ||
		len(diff.EndpointsDiff.DeletedPublish) > 0 ||
		len(diff.EndpointsDiff.Added) == 0 {
		return false
	}
	for _, endpoint := range diff.EndpointsDiff.Added {
		if endpoint.CreatedBy != GithubOperator {
			return false
		}
	}
	return true
}
