package service

import (
	"context"
	"encoding/json"
	"fmt"
	"io/fs"
	"os"
	"path"
	"strings"
	"time"

	"github.com/1Panel-dev/1Panel/agent/app/repo"
	"github.com/subosito/gotenv"

	"github.com/1Panel-dev/1Panel/agent/app/task"
	"github.com/1Panel-dev/1Panel/agent/i18n"

	"github.com/1Panel-dev/1Panel/agent/buserr"

	"github.com/1Panel-dev/1Panel/agent/app/dto"
	"github.com/1Panel-dev/1Panel/agent/app/model"
	"github.com/1Panel-dev/1Panel/agent/constant"
	"github.com/1Panel-dev/1Panel/agent/global"
	"github.com/1Panel-dev/1Panel/agent/utils/common"
	"github.com/1Panel-dev/1Panel/agent/utils/compose"
	"github.com/1Panel-dev/1Panel/agent/utils/files"
	"github.com/pkg/errors"
)

func (u *BackupService) AppBackup(req dto.CommonBackup) (*model.BackupRecord, error) {
	app, err := appRepo.GetFirst(appRepo.WithKey(req.Name))
	if err != nil {
		return nil, err
	}
	install, err := appInstallRepo.GetFirst(repo.WithByName(req.DetailName), appInstallRepo.WithAppId(app.ID))
	if err != nil {
		return nil, err
	}
	timeNow := time.Now().Format(constant.DateTimeSlimLayout)
	itemDir := fmt.Sprintf("app/%s/%s", req.Name, req.DetailName)
	backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)

	fileName := req.FileName
	if req.FileName == "" {
		fileName = fmt.Sprintf("%s_%s.tar.gz", req.DetailName, timeNow+common.RandStrAndNum(5))
	}

	record := &model.BackupRecord{
		Type:              "app",
		Name:              req.Name,
		DetailName:        req.DetailName,
		SourceAccountIDs:  "1",
		DownloadAccountID: 1,
		FileDir:           itemDir,
		FileName:          fileName,
		TaskID:            req.TaskID,
		Status:            constant.StatusWaiting,
		Description:       req.Description,
	}
	if err := backupRepo.CreateRecord(record); err != nil {
		global.LOG.Errorf("save backup record failed, err: %v", err)
		return nil, err
	}

	if !req.IsImmediate {
		if err = handleAppBackup(&install, nil, record.ID, backupDir, fileName, "", req.Secret, req.TaskID); err != nil {
			markBackupFailed(record.ID, err)
			global.LOG.Errorf("backup app %s failed, err: %v", req.DetailName, err)
			return nil, err
		}
		return record, nil
	}

	backupTask, err := task.NewTaskWithOps(install.Name, task.TaskBackup, task.TaskScopeBackup, req.TaskID, install.ID)
	if err != nil {
		markBackupFailed(record.ID, err)
		record.Status = constant.StatusFailed
		record.Message = err.Error()
		return nil, err
	}
	if err = doAppBackup(&install, backupTask, backupDir, fileName, "", req.Secret); err != nil {
		markBackupFailed(record.ID, err)
		record.Status = constant.StatusFailed
		record.Message = err.Error()
		return nil, err
	}
	backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
	record.Status = constant.StatusSuccess
	return record, nil
}

func backupAppWithParentTask(install *model.AppInstall, parentTask *task.Task, fileName string) (*model.BackupRecord, error) {
	itemDir := fmt.Sprintf("app/%s/%s", install.App.Key, install.Name)
	backupDir := path.Join(global.Dir.LocalBackupDir, itemDir)
	record := &model.BackupRecord{
		Type:              "app",
		Name:              install.App.Key,
		DetailName:        install.Name,
		SourceAccountIDs:  "1",
		DownloadAccountID: 1,
		FileDir:           itemDir,
		FileName:          fileName,
		TaskID:            parentTask.TaskID,
		Status:            constant.StatusWaiting,
	}
	if err := backupRepo.CreateRecord(record); err != nil {
		return nil, err
	}
	if err := handleAppBackup(install, parentTask, record.ID, backupDir, fileName, "", "", parentTask.TaskID); err != nil {
		markBackupFailed(record.ID, err)
		record.Status = constant.StatusFailed
		record.Message = err.Error()
		return record, err
	}
	backupRepo.UpdateRecordByMap(record.ID, map[string]interface{}{"status": constant.StatusSuccess})
	record.Status = constant.StatusSuccess
	return record, nil
}

func (u *BackupService) AppRecover(req dto.CommonRecover) error {
	app, err := appRepo.GetFirst(appRepo.WithKey(req.Name))
	if err != nil {
		return err
	}
	install, err := appInstallRepo.GetFirst(repo.WithByName(req.DetailName), appInstallRepo.WithAppId(app.ID))
	if err != nil {
		return err
	}

	fileOp := files.NewFileOp()
	if !fileOp.Stat(req.File) {
		return buserr.WithName("ErrFileNotFound", req.File)
	}
	if _, err := compose.Down(install.GetComposePath()); err != nil {
		return err
	}
	if err := handleAppRecover(&install, nil, req.File, false, req.Secret, req.TaskID); err != nil {
		global.LOG.Errorf("recover app %s failed, err: %v", req.DetailName, err)
	}
	return nil
}

func backupDatabaseWithTask(parentTask *task.Task, resourceKey, tmpDir, name string, databaseID uint) error {
	switch resourceKey {
	case constant.AppMysql, constant.AppMariaDB:
		db, err := mysqlRepo.Get(repo.WithByID(databaseID))
		if err != nil {
			return err
		}
		parentTask.LogStart(task.GetTaskName(db.Name, task.TaskBackup, task.TaskScopeDatabase))
		databaseHelper := DatabaseHelper{Database: db.MysqlName, DBType: resourceKey, Name: db.Name}
		if err := handleMysqlBackup(databaseHelper, parentTask, 0, tmpDir, fmt.Sprintf("%s.sql.gz", name), "", ""); err != nil {
			return err
		}
		parentTask.LogSuccess(task.GetTaskName(db.Name, task.TaskBackup, task.TaskScopeDatabase))
	case constant.AppPostgresql:
		db, err := postgresqlRepo.Get(repo.WithByID(databaseID))
		if err != nil {
			return err
		}
		parentTask.LogStart(task.GetTaskName(db.Name, task.TaskBackup, task.TaskScopeDatabase))
		databaseHelper := DatabaseHelper{Database: db.PostgresqlName, DBType: resourceKey, Name: db.Name}
		if err := handlePostgresqlBackup(databaseHelper, parentTask, 0, tmpDir, fmt.Sprintf("%s.sql.gz", name), "", ""); err != nil {
			return err
		}
		parentTask.LogSuccess(task.GetTaskName(db.Name, task.TaskBackup, task.TaskScopeDatabase))
	}
	return nil
}

func handleAppBackup(install *model.AppInstall, parentTask *task.Task, recordID uint, backupDir, fileName, excludes, secret, taskID string) error {
	var (
		err        error
		backupTask *task.Task
	)
	backupTask = parentTask
	if parentTask == nil {
		backupTask, err = task.NewTaskWithOps(install.Name, task.TaskBackup, task.TaskScopeBackup, taskID, install.ID)
		if err != nil {
			return err
		}
	}

	itemHandler := func() error { return doAppBackup(install, backupTask, backupDir, fileName, excludes, secret) }
	if parentTask != nil {
		return itemHandler()
	}

	backupTask.AddSubTaskWithOps(task.GetTaskName(install.Name, task.TaskBackup, task.TaskScopeBackup), func(t *task.Task) error { return itemHandler() }, nil, 3, time.Hour)
	go func() {
		if err := backupTask.Execute(); err != nil {
			markBackupFailed(recordID, err)
			return
		}
		backupRepo.UpdateRecordByMap(recordID, map[string]interface{}{"status": constant.StatusSuccess})
	}()

	return nil
}

func handleAppRecover(install *model.AppInstall, parentTask *task.Task, recoverFile string, isRollback bool, secret, taskID string) error {
	var (
		err          error
		recoverTask  *task.Task
		isOk         = false
		rollbackFile string
	)
	recoverTask = parentTask
	if parentTask == nil {
		recoverTask, err = task.NewTaskWithOps(install.Name, task.TaskRecover, task.TaskScopeBackup, taskID, install.ID)
		if err != nil {
			return err
		}
	}

	recoverApp := func(t *task.Task) error {
		fileOp := files.NewFileOp()
		if !isRollback {
			rollbackFile = path.Join(global.Dir.TmpDir, fmt.Sprintf("app/%s_%s.tar.gz", install.Name, time.Now().Format(constant.DateTimeSlimLayout)))
			if err := handleAppBackup(install, recoverTask, 0, path.Dir(rollbackFile), path.Base(rollbackFile), "", "", taskID); err != nil {
				t.Log(fmt.Sprintf("backup app %s for rollback before recover failed, err: %v", install.Name, err))
			}
		}

		if err := fileOp.TarGzExtractPro(recoverFile, path.Dir(recoverFile), secret); err != nil {
			return err
		}
		tmpPath := strings.ReplaceAll(recoverFile, ".tar.gz", "")
		if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", tmpPath, ""); err != nil {
			return err
		}
		defer func() {
			if isRollback {
				_, _ = compose.UpWithoutPull(install.GetComposePath())
			} else {
				_, _ = compose.Up(install.GetComposePath())
			}
			_ = os.RemoveAll(strings.ReplaceAll(recoverFile, ".tar.gz", ""))
		}()

		if !fileOp.Stat(tmpPath+"/app.json") || !fileOp.Stat(tmpPath+"/app.tar.gz") {
			return errors.New(i18n.GetMsgByKey("AppBackupFileIncomplete"))
		}
		var backupInstall model.AppInstall
		appJson, err := os.ReadFile(tmpPath + "/app.json")
		if err != nil {
			return err
		}
		if err := json.Unmarshal(appJson, &backupInstall); err != nil {
			return fmt.Errorf("unmarshal app.json failed, err: %v", err)
		}
		if backupInstall.App.Key != install.App.Key || backupInstall.Name != install.Name {
			return errors.New(i18n.GetMsgByKey("AppAttributesNotMatch"))
		}
		backupEnvMap, err := getEnvMapByPath(path.Join(tmpPath, install.Name, ".env"))
		if err != nil {
			return err
		}
		installedEnvMap, err := getEnvMapByPath(install.GetEnvPath())
		if err != nil {
			return err
		}
		var mergedEnvContent string
		resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
		for _, resource := range resources {
			var database model.Database
			switch resource.From {
			case constant.AppResourceRemote:
				database, err = databaseRepo.Get(repo.WithByID(resource.LinkId))
				if err != nil {
					return err
				}
			case constant.AppResourceLocal:
				resourceApp, err := appInstallRepo.GetFirst(repo.WithByID(resource.LinkId))
				if err != nil {
					return err
				}
				database, err = databaseRepo.Get(databaseRepo.WithAppInstallID(resourceApp.ID), repo.WithByType(resource.Key), repo.WithByFrom(constant.AppResourceLocal), repo.WithByName(resourceApp.Name))
				if err != nil {
					return err
				}
			}
			switch database.Type {
			case constant.AppPostgresql:
				db, err := postgresqlRepo.Get(repo.WithByID(resource.ResourceId))
				if err != nil {
					return err
				}
				newDB, err := reCreatePostgresqlDB(db.ID, database, backupEnvMap)
				if err != nil {
					return err
				}
				backupInstall.Env, mergedEnvContent, err = buildRecoverEnv(backupInstall.Env, backupEnvMap, installedEnvMap)
				if err != nil {
					return err
				}
				_ = appInstallResourceRepo.BatchUpdateBy(map[string]interface{}{"resource_id": newDB.ID}, repo.WithByID(resource.ID))
				taskName := task.GetTaskName(db.Name, task.TaskRecover, task.TaskScopeDatabase)
				t.LogStart(taskName)
				if err := handlePostgresqlRecover(dto.CommonRecover{
					Name:       newDB.PostgresqlName,
					DetailName: newDB.Name,
					File:       fmt.Sprintf("%s/%s.sql.gz", tmpPath, install.Name),
				}, parentTask, true); err != nil {
					t.LogFailedWithErr(taskName, err)
					return err
				}
				t.LogSuccess(taskName)
			case constant.AppMysql, constant.AppMariaDB:
				db, err := mysqlRepo.Get(repo.WithByID(resource.ResourceId))
				if err != nil {
					return err
				}
				newDB, err := reCreateDB(db.ID, database, backupEnvMap, install.Name)
				if err != nil {
					return err
				}
				backupInstall.Env, mergedEnvContent, err = buildRecoverEnv(backupInstall.Env, backupEnvMap, installedEnvMap)
				if err != nil {
					return err
				}
				_ = appInstallResourceRepo.BatchUpdateBy(map[string]interface{}{"resource_id": newDB.ID}, repo.WithByID(resource.ID))
				taskName := task.GetTaskName(db.Name, task.TaskRecover, task.TaskScopeDatabase)
				t.LogStart(taskName)
				if err := handleMysqlRecover(dto.CommonRecover{
					Name:       newDB.MysqlName,
					DetailName: newDB.Name,
					File:       fmt.Sprintf("%s/%s.sql.gz", tmpPath, install.Name),
				}, parentTask, true); err != nil {
					t.LogFailedWithErr(taskName, err)
					return err
				}
				t.LogSuccess(taskName)
			}
		}

		appDir := install.GetPath()
		backPath := fmt.Sprintf("%s_bak", appDir)
		_ = fileOp.Rename(appDir, backPath)
		_ = fileOp.CreateDir(appDir, constant.DirPerm)

		deCompressName := i18n.GetWithName("DeCompressFile", "app.tar.gz")
		t.LogStart(deCompressName)
		if err := fileOp.TarGzExtractPro(tmpPath+"/app.tar.gz", install.GetAppPath(), ""); err != nil {
			t.LogFailedWithErr(deCompressName, err)
			_ = fileOp.DeleteDir(appDir)
			_ = fileOp.Rename(backPath, appDir)
			return err
		}
		t.LogSuccess(deCompressName)
		_ = fileOp.DeleteDir(backPath)

		if len(mergedEnvContent) != 0 {
			envPath := fmt.Sprintf("%s/%s/.env", install.GetAppPath(), install.Name)
			file, err := os.OpenFile(envPath, os.O_WRONLY|os.O_TRUNC, 0640)
			if err != nil {
				return err
			}
			defer file.Close()
			_, _ = file.WriteString(mergedEnvContent)
		}

		backupInstall.ID = install.ID
		backupInstall.Status = constant.StatusRunning
		backupInstall.AppId = install.AppId
		backupInstall.AppDetailId = install.AppDetailId
		backupInstall.App.ID = install.AppId
		if err := appInstallRepo.Save(context.Background(), &backupInstall); err != nil {
			global.LOG.Errorf("save db app install failed, err: %v", err)
			return err
		}
		isOk = true

		return nil
	}

	if parentTask != nil {
		return recoverApp(parentTask)
	}

	rollBackApp := func(t *task.Task) {
		if isRollback {
			return
		}
		if !isOk {
			t.Log(i18n.GetMsgByKey("RecoverFailedStartRollBack"))
			if err := handleAppRecover(install, t, rollbackFile, true, "", ""); err != nil {
				t.LogFailedWithErr(i18n.GetMsgByKey("Rollback"), err)
				return
			}
			t.LogSuccess(i18n.GetMsgByKey("Rollback"))
			_ = os.RemoveAll(rollbackFile)
		} else {
			_ = os.RemoveAll(rollbackFile)
		}
	}
	recoverTask.AddSubTask(task.GetTaskName(install.Name, task.TaskRecover, task.TaskScopeBackup), recoverApp, rollBackApp)
	go func() {
		_ = recoverTask.Execute()
	}()
	return nil
}

func doAppBackup(install *model.AppInstall, parentTask *task.Task, backupDir, fileName, excludes, secret string) error {
	fileOp := files.NewFileOp()
	tmpDir := fmt.Sprintf("%s/%s", backupDir, strings.ReplaceAll(fileName, ".tar.gz", ""))
	if !fileOp.Stat(tmpDir) {
		if err := os.MkdirAll(tmpDir, os.ModePerm); err != nil {
			return fmt.Errorf("mkdir %s failed, err: %v", backupDir, err)
		}
	}
	defer func() {
		_ = os.RemoveAll(tmpDir)
	}()

	remarkInfo, _ := json.Marshal(install)
	remarkInfoPath := fmt.Sprintf("%s/app.json", tmpDir)
	if err := fileOp.SaveFile(remarkInfoPath, string(remarkInfo), fs.ModePerm); err != nil {
		return err
	}

	appPath := install.GetPath()
	parentTask.LogStart(i18n.GetMsgByKey("TaskBackup"))
	if err := fileOp.TarGzCompressPro(true, appPath, path.Join(tmpDir, "app.tar.gz"), "", excludes); err != nil {
		return err
	}

	resources, _ := appInstallResourceRepo.GetBy(appInstallResourceRepo.WithAppInstallId(install.ID))
	for _, resource := range resources {
		if err := backupDatabaseWithTask(parentTask, resource.Key, tmpDir, install.Name, resource.ResourceId); err != nil {
			return err
		}
	}
	parentTask.LogStart(i18n.GetMsgByKey("CompressDir"))
	if err := fileOp.TarGzCompressPro(true, tmpDir, path.Join(backupDir, fileName), secret, ""); err != nil {
		return err
	}
	parentTask.Log(i18n.GetWithName("CompressFileSuccess", fileName))
	return nil
}

func reCreateDB(dbID uint, database model.Database, envMap map[string]interface{}, appInstallName string) (*model.DatabaseMysql, error) {
	mysqlService := NewIMysqlService()
	ctx := context.Background()
	if err := deleteMysqlDatabaseForResourceOwner(
		ctx,
		dto.MysqlDBDelete{ID: dbID, Database: database.Name, Type: database.Type, DeleteBackup: false, ForceDelete: true},
		dto.DBResource{Type: constant.TypeApp, Name: appInstallName},
	); err != nil {
		return nil, err
	}

	dbInfo := getDBCreateInfoFromEnv(envMap, "utf8mb4")
	createDB, err := mysqlService.Create(context.Background(), dto.MysqlDBCreate{
		Name:       dbInfo.Name,
		From:       database.From,
		Database:   database.Name,
		Format:     dbInfo.Format,
		Permission: "%",
	})
	if err != nil {
		return nil, err
	}
	if len(dbInfo.User) != 0 {
		if err := ensureMysqlDBUser(mysqlService, database, dbInfo); err != nil {
			return nil, err
		}
	}
	updateCronjobsDBRef(dbID, createDB.ID)
	return createDB, nil
}

func ensureMysqlDBUser(mysqlService IMysqlService, database model.Database, dbInfo dbRecreateInfo) error {
	const host = "%"
	users, err := mysqlService.ListUsers(dto.MysqlUserSearch{Database: database.Name})
	if err != nil {
		return err
	}
	var oldUser dto.MysqlUser
	exists := false
	for _, user := range users {
		if user.Username == dbInfo.User && user.Host == host && !user.IsDelete {
			oldUser = user
			exists = true
			break
		}
	}
	if exists {
		if len(oldUser.Password) != 0 && oldUser.Password != dbInfo.Password {
			return buserr.New("ErrDbUserNotValid")
		}
	} else {
		if err := mysqlService.CreateUser(dto.MysqlUserCreate{
			Database: database.Name,
			Username: dbInfo.User,
			Host:     host,
			Password: dbInfo.Password,
		}); err != nil {
			return err
		}
	}
	return mysqlService.GrantUser(dto.MysqlGrantCreate{
		Database: database.Name,
		DB:       dbInfo.Name,
		Username: dbInfo.User,
		Host:     host,
	})
}

func reCreatePostgresqlDB(dbID uint, database model.Database, envMap map[string]interface{}) (*model.DatabasePostgresql, error) {
	postgresqlService := NewIPostgresqlService()
	_ = postgresqlService.Delete(context.Background(), dto.PostgresqlDBDelete{
		ID:           dbID,
		Type:         database.Type,
		Database:     database.Name,
		DeleteBackup: false,
		ForceDelete:  true,
	})

	dbInfo := getDBCreateInfoFromEnv(envMap, "UTF8")
	createDB, err := postgresqlService.Create(context.Background(), dto.PostgresqlDBCreate{
		Name:      dbInfo.Name,
		From:      database.From,
		Database:  database.Name,
		Format:    dbInfo.Format,
		Username:  dbInfo.User,
		Password:  dbInfo.Password,
		SuperUser: true,
	})
	if err != nil {
		return nil, err
	}
	updateCronjobsDBRef(dbID, createDB.ID)
	return createDB, nil
}

type dbRecreateInfo struct {
	Name     string
	User     string
	Password string
	Format   string
}

func getDBCreateInfoFromEnv(envMap map[string]interface{}, defaultFormat string) dbRecreateInfo {
	name, _ := envMap["PANEL_DB_NAME"].(string)
	user, _ := envMap["PANEL_DB_USER"].(string)
	password, _ := envMap["PANEL_DB_USER_PASSWORD"].(string)
	format, _ := envMap["format"].(string)
	if len(format) == 0 {
		format = defaultFormat
	}
	return dbRecreateInfo{
		Name:     name,
		User:     user,
		Password: password,
		Format:   format,
	}
}

func updateCronjobsDBRef(oldDBID, newDBID uint) {
	cronjobs, _ := cronjobRepo.List(cronjobRepo.WithByDbName(fmt.Sprintf("%v", oldDBID)))
	for _, job := range cronjobs {
		_ = cronjobRepo.Update(job.ID, map[string]interface{}{"db_name": fmt.Sprintf("%v", newDBID)})
	}
}

func buildRecoverEnv(appEnv string, backupEnvMap, installedEnvMap map[string]interface{}) (string, string, error) {
	currentHostVal, hasCurrentHost := installedEnvMap["PANEL_DB_HOST"]
	if hasCurrentHost && fmt.Sprintf("%v", currentHostVal) != "" {
		backupHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", backupEnvMap["PANEL_DB_HOST"])
		currentHost := fmt.Sprintf("\"PANEL_DB_HOST\":\"%v\"", currentHostVal)
		appEnv = strings.ReplaceAll(appEnv, backupHost, currentHost)
		if _, ok := backupEnvMap["CASDOOR_DATASOURCE_NAME"]; ok {
			backupEnvMap["CASDOOR_DATASOURCE_NAME"] = strings.ReplaceAll(
				fmt.Sprintf("%v", backupEnvMap["CASDOOR_DATASOURCE_NAME"]),
				fmt.Sprintf("%v", backupEnvMap["PANEL_DB_HOST"]),
				fmt.Sprintf("%v", currentHostVal),
			)
		}
		backupEnvMap["PANEL_DB_HOST"] = currentHostVal
	}
	newEnvMap := make(map[string]string, len(backupEnvMap))
	handleMap(backupEnvMap, newEnvMap)
	mergedEnvContent, err := gotenv.Marshal(newEnvMap)
	if err != nil {
		return appEnv, "", err
	}
	return appEnv, mergedEnvContent, nil
}

func getEnvMapByPath(envPath string) (map[string]interface{}, error) {
	envMap := make(map[string]interface{})
	envFile, err := os.ReadFile(envPath)
	if err != nil {
		return envMap, err
	}
	lines := strings.Split(string(envFile), "\n")
	for _, line := range lines {
		line = strings.TrimSpace(line)
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		parts := strings.SplitN(line, "=", 2)
		if len(parts) == 2 {
			value := strings.TrimSpace(parts[1])
			if len(value) >= 2 {
				if (value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'') {
					value = value[1 : len(value)-1]
				}
			}
			envMap[parts[0]] = value
		}
	}
	return envMap, nil
}
