// SiYuan - Build Your Eternal Digital Garden
// Copyright (c) 2020-present, b3log.org
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

package model

import (
	"bytes"
	"crypto/sha256"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"math"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"sync"
	"time"

	"github.com/88250/gulu"
	"github.com/dustin/go-humanize"
	"github.com/emirpasic/gods/sets/hashset"
	"github.com/mattn/go-zglob"
	"github.com/siyuan-note/encryption"
	"github.com/siyuan-note/siyuan/kernel/cache"
	"github.com/siyuan-note/siyuan/kernel/filesys"
	"github.com/siyuan-note/siyuan/kernel/sql"
	"github.com/siyuan-note/siyuan/kernel/treenode"
	"github.com/siyuan-note/siyuan/kernel/util"
)

var (
	syncSameCount        = 0
	syncDownloadErrCount = 0
	fixSyncInterval      = 5 * time.Minute
	syncInterval         = fixSyncInterval
	syncPlanTime         = time.Now().Add(syncInterval)

	BootSyncSucc = -1 // -1：未执行，0：执行成功，1：执行失败
	ExitSyncSucc = -1
)

func AutoSync() {
	for {
		time.Sleep(5 * time.Second)
		if time.Now().After(syncPlanTime) {
			SyncData(false, false, false)
			syncPlanTime = time.Now().Add(syncInterval)
		}
	}
}

func SyncData(boot, exit, byHand bool) {
	defer util.Recover()

	if util.IsMutexLocked(&syncLock) {
		util.LogWarnf("sync has been locked")
		syncInterval = 30 * time.Second
		return
	}

	syncLock.Lock()
	defer syncLock.Unlock()

	if boot {
		util.IncBootProgress(3, "Syncing data from the cloud...")
		BootSyncSucc = 0
	}
	if exit {
		ExitSyncSucc = 0
	}
	if !IsSubscriber() || !Conf.Sync.Enabled || "" == Conf.Sync.CloudName || "" == Conf.E2EEPasswd {
		if byHand {
			if "" == Conf.Sync.CloudName {
				util.PushMsg(Conf.Language(123), 5000)
			} else if "" == Conf.E2EEPasswd {
				util.PushMsg(Conf.Language(11), 5000)
			} else if !Conf.Sync.Enabled {
				util.PushMsg(Conf.Language(124), 5000)
			}
		}
		return
	}

	if !IsValidCloudDirName(Conf.Sync.CloudName) {
		return
	}

	if boot {
		util.LogInfof("sync before boot")
	}
	if exit {
		util.LogInfof("sync before exit")
		util.PushMsg(Conf.Language(81), 1000*60*15)
	}

	if 7 < syncDownloadErrCount && !byHand {
		util.LogErrorf("sync download error too many times, cancel auto sync, try to sync by hand")
		util.PushErrMsg(Conf.Language(125), 1000*60*60)
		syncInterval = 64 * time.Minute
		return
	}

	now := util.CurrentTimeMillis()
	Conf.Sync.Synced = now

	util.BroadcastByType("main", "syncing", 0, Conf.Language(81), nil)
	defer func() {
		synced := util.Millisecond2Time(Conf.Sync.Synced).Format("2006-01-02-15:04:05") + "\n\n" + Conf.Sync.Stat
		msg := fmt.Sprintf(Conf.Language(82), synced)
		Conf.Sync.Stat = msg
		Conf.Save()
		util.BroadcastByType("main", "syncing", 1, msg, nil)
	}()

	WaitForWritingFiles()
	writingTreeLock.Lock()
	var err error
	// 将 data 变更同步到 sync
	if err = workspaceData2SyncDir(); nil != err {
		msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		writingTreeLock.Unlock()
		return
	}

	syncConf, err := getWorkspaceDataConf()
	if nil != err {
		msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		writingTreeLock.Unlock()
		return
	}
	writingTreeLock.Unlock()

	cloudSyncVer, err := getCloudSyncVer(Conf.Sync.CloudName)
	if nil != err {
		msg := fmt.Sprintf(Conf.Language(24), err.Error())
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		return
	}

	//util.LogInfof("sync [cloud=%d, local=%d]", cloudSyncVer, syncConf.SyncVer)
	if cloudSyncVer == syncConf.SyncVer {
		BootSyncSucc = 0
		ExitSyncSucc = 0
		syncSameCount++
		if 10 < syncSameCount {
			syncSameCount = 5
		}
		if !byHand {
			syncInterval = time.Minute * time.Duration(int(math.Pow(2, float64(syncSameCount))))
			if fixSyncInterval.Minutes() > syncInterval.Minutes() {
				syncInterval = time.Minute * 8
			}
			util.LogInfof("set sync interval to [%dm]", int(syncInterval.Minutes()))
		}

		Conf.Sync.Stat = Conf.Language(133)
		return
	}

	cloudUsedAssetSize, cloudUsedBackupSize, device, err := getCloudSync(Conf.Sync.CloudName)
	if nil != err {
		msg := fmt.Sprintf(Conf.Language(24), err.Error())
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		return
	}

	localSyncDirPath := Conf.Sync.GetSaveDir()
	syncSameCount = 0
	if cloudSyncVer < syncConf.SyncVer {
		// 上传

		if -1 == cloudSyncVer {
			// 初次上传
			IncWorkspaceDataVer()
			incLocalSyncVer()
		}

		start := time.Now()
		//util.LogInfof("sync [cloud=%d, local=%d] uploading...", cloudSyncVer, syncConf.SyncVer)
		syncSize, err := util.SizeOfDirectory(localSyncDirPath, false)
		if nil != err {
			util.PushErrMsg(fmt.Sprintf(Conf.Language(80), formatErrorMsg(err)), 7000)
			return
		}

		leftSyncSize := int64(Conf.User.UserSiYuanRepoSize) - cloudUsedAssetSize - cloudUsedBackupSize
		if leftSyncSize < syncSize {
			util.PushErrMsg(fmt.Sprintf(Conf.Language(43), byteCountSI(int64(Conf.User.UserSiYuanRepoSize))), 7000)
			if boot {
				BootSyncSucc = 1
			}
			if exit {
				ExitSyncSucc = 1
			}
			return
		}

		wroteFiles, transferSize, err := ossUpload(localSyncDirPath, "sync/"+Conf.Sync.CloudName, device, boot)
		if nil != err {
			util.PushClearMsg()
			IncWorkspaceDataVer() // 上传失败的话提升本地版本，以备下次上传

			msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
			Conf.Sync.Stat = msg
			util.PushErrMsg(msg, 7000)
			if boot {
				BootSyncSucc = 1
			}
			if exit {
				ExitSyncSucc = 1
			}
			return
		}

		util.PushClearMsg()
		elapsed := time.Now().Sub(start).Seconds()
		stat := fmt.Sprintf(Conf.Language(130), wroteFiles, humanize.Bytes(transferSize)) + fmt.Sprintf(Conf.Language(132), elapsed)
		util.LogInfof("sync [cloud=%d, local=%d, wroteFiles=%d, transferSize=%s] uploaded in [%.2fs]", cloudSyncVer, syncConf.SyncVer, wroteFiles, humanize.Bytes(transferSize), elapsed)

		Conf.Sync.Uploaded = now
		Conf.Sync.Stat = stat
		BootSyncSucc = 0
		ExitSyncSucc = 0
		if !byHand {
			syncInterval = fixSyncInterval
		}
		return
	}

	// 下载

	if !boot && !exit {
		CloseWatchAssets()
		defer WatchAssets()
	}

	start := time.Now()
	//util.LogInfof("sync [cloud=%d, local=%d] downloading...", cloudSyncVer, syncConf.SyncVer)

	// 使用索引文件进行解密验证 https://github.com/siyuan-note/siyuan/issues/3789
	var tmpFetchedFiles int
	var tmpTransferSize uint64
	err = ossDownload0(util.TempDir+"/sync", "sync/"+Conf.Sync.CloudName, "/"+pathJSON, &tmpFetchedFiles, &tmpTransferSize, boot || exit)
	if nil != err {
		util.PushClearMsg()
		msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		syncDownloadErrCount++
		return
	}
	data, err := os.ReadFile(filepath.Join(util.TempDir, "/sync/"+pathJSON))
	if nil != err {
		return
	}
	data, err = encryption.AESGCMDecryptBinBytes(data, Conf.E2EEPasswd)
	if nil != err {
		util.PushClearMsg()
		msg := Conf.Language(28)
		Conf.Sync.Stat = msg
		util.PushErrMsg(fmt.Sprintf(Conf.Language(80), msg), 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		Conf.Sync.Stat = msg
		syncDownloadErrCount++
		return
	}

	fetchedFiles, transferSize, err := ossDownload(localSyncDirPath, "sync/"+Conf.Sync.CloudName, boot || exit)
	if nil != err {
		util.PushClearMsg()
		msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		syncDownloadErrCount++
		return
	}
	util.PushClearMsg()

	// 恢复
	var upsertFiles, removeFiles []string
	if upsertFiles, removeFiles, err = syncDir2WorkspaceData(boot); nil != err {
		msg := fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
		Conf.Sync.Stat = msg
		util.PushErrMsg(msg, 7000)
		if boot {
			BootSyncSucc = 1
		}
		if exit {
			ExitSyncSucc = 1
		}
		syncDownloadErrCount++
		return
	}

	syncDownloadErrCount = 0

	// 清理空文件夹
	clearEmptyDirs(util.DataDir)

	elapsed := time.Now().Sub(start).Seconds()
	stat := fmt.Sprintf(Conf.Language(129), fetchedFiles, humanize.Bytes(transferSize)) + fmt.Sprintf(Conf.Language(131), elapsed)
	util.LogInfof("sync [cloud=%d, local=%d, fetchedFiles=%d, transferSize=%s] downloaded in [%.2fs]", cloudSyncVer, syncConf.SyncVer, fetchedFiles, humanize.Bytes(transferSize), elapsed)

	Conf.Sync.Downloaded = now
	Conf.Sync.Stat = stat
	BootSyncSucc = 0
	ExitSyncSucc = 0
	if !byHand {
		syncInterval = fixSyncInterval
	}

	if boot && gulu.File.IsExist(util.BlockTreePath) {
		// 在 blocktree 存在的情况下不会重建索引，所以这里需要刷新 blocktree 和 database

		if err = treenode.ReadBlockTree(); nil != err {
			os.RemoveAll(util.BlockTreePath)
			util.LogWarnf("removed block tree [%s] due to %s", util.BlockTreePath, err)
			return
		}

		for _, upsertFile := range upsertFiles {
			if !strings.HasSuffix(upsertFile, ".sy") {
				continue
			}

			upsertFile = filepath.ToSlash(upsertFile)
			box := upsertFile[:strings.Index(upsertFile, "/")]
			p := strings.TrimPrefix(upsertFile, box)
			tree, err0 := LoadTree(box, p)
			if nil != err0 {
				continue
			}
			treenode.ReindexBlockTree(tree)
			sql.UpsertTreeQueue(tree)
		}
		for _, removeFile := range removeFiles {
			if !strings.HasSuffix(removeFile, ".sy") {
				continue
			}
			id := strings.TrimSuffix(filepath.Base(removeFile), ".sy")
			block := treenode.GetBlockTree(id)
			if nil != block {
				treenode.RemoveBlockTreesByRootID(block.RootID)
				sql.RemoveTreeQueue(block.BoxID, block.RootID)
			}
		}
	}

	if !boot && !exit {
		// 增量索引
		for _, upsertFile := range upsertFiles {
			if !strings.HasSuffix(upsertFile, ".sy") {
				continue
			}

			upsertFile = filepath.ToSlash(upsertFile)
			box := upsertFile[:strings.Index(upsertFile, "/")]
			p := strings.TrimPrefix(upsertFile, box)
			tree, err0 := LoadTree(box, p)
			if nil != err0 {
				continue
			}
			treenode.ReindexBlockTree(tree)
			sql.UpsertTreeQueue(tree)
			//util.LogInfof("sync index tree [%s]", tree.ID)
		}
		for _, removeFile := range removeFiles {
			if !strings.HasSuffix(removeFile, ".sy") {
				continue
			}
			id := strings.TrimSuffix(filepath.Base(removeFile), ".sy")
			block := treenode.GetBlockTree(id)
			if nil != block {
				treenode.RemoveBlockTreesByRootID(block.RootID)
				sql.RemoveTreeQueue(block.BoxID, block.RootID)
				//util.LogInfof("sync remove tree [%s]", block.RootID)
			}
		}
		cache.ClearDocsIAL() // 同步后文档树文档图标没有更新 https://github.com/siyuan-note/siyuan/issues/4939
		util.ReloadUI()
	}
	return
}

// 清理 dir 下符合 ID 规则的空文件夹。
// 因为是深度遍历，所以可能会清理不完全空文件夹，每次遍历仅能清理叶子节点。但是多次调用后，可以清理完全。
func clearEmptyDirs(dir string) {
	var emptyDirs []string
	filepath.Walk(dir, func(path string, info fs.FileInfo, err error) error {
		if nil != err || !info.IsDir() || dir == path {
			return err
		}

		if util.IsIDPattern(info.Name()) {
			if files, readDirErr := os.ReadDir(path); nil == readDirErr && 0 == len(files) {
				emptyDirs = append(emptyDirs, path)
			}
		}
		return nil
	})
	for _, emptyDir := range emptyDirs {
		if err := os.RemoveAll(emptyDir); nil != err {
			util.LogErrorf("clear empty dir [%s] failed [%s]", emptyDir, err.Error())
		}
	}
}

func SetCloudSyncDir(name string) {
	if Conf.Sync.CloudName == name {
		return
	}

	syncLock.Lock()
	defer syncLock.Unlock()

	Conf.Sync.CloudName = name
	Conf.Save()
}

func SetSyncEnable(b bool) (err error) {
	syncLock.Lock()
	defer syncLock.Unlock()

	Conf.Sync.Enabled = b
	Conf.Save()
	return
}

var syncLock = sync.Mutex{}

// syncDir2WorkspaceData 将 sync 的数据更新到 data 中。
//   1. 删除 data 中冗余的文件
//   2. 将 sync 中新增/修改的文件解密后拷贝到 data 中
func syncDir2WorkspaceData(boot bool) (upsertFiles, removeFiles []string, err error) {
	start := time.Now()
	unchanged, removeFiles, err := unchangedSyncList()
	if nil != err {
		return
	}

	modified := modifiedSyncList(unchanged)
	decryptedDataDir, upsertFiles, err := recoverSyncData(modified)
	if nil != err {
		util.LogErrorf("decrypt data dir failed: %s", err)
		return
	}

	if boot {
		util.IncBootProgress(0, "Copying decrypted data...")
	}

	dataDir := util.DataDir
	if err = stableCopy(decryptedDataDir, dataDir); nil != err {
		util.LogErrorf("copy decrypted data dir from [%s] to data dir [%s] failed: %s", decryptedDataDir, dataDir, err)
		return
	}
	if elapsed := time.Since(start).Milliseconds(); 5000 < elapsed {
		util.LogInfof("sync data to workspace data elapsed [%dms]", elapsed)
	}
	return
}

// workspaceData2SyncDir 将 data 的数据更新到 sync 中。
//   1. 删除 sync 中多余的文件
//   2. 将 data 中新增/修改的文件加密后拷贝到 sync 中
func workspaceData2SyncDir() (err error) {
	start := time.Now()
	filesys.ReleaseAllFileLocks()

	passwd := Conf.E2EEPasswd
	unchanged, err := unchangedDataList(passwd)
	if nil != err {
		return
	}

	encryptedDataDir, err := prepareSyncData(passwd, unchanged)
	if nil != err {
		util.LogErrorf("encrypt data dir failed: %s", err)
		return
	}

	syncDir := Conf.Sync.GetSaveDir()
	if err = stableCopy(encryptedDataDir, syncDir); nil != err {
		util.LogErrorf("copy encrypted data dir from [%s] to sync dir [%s] failed: %s", encryptedDataDir, syncDir, err)
		return
	}
	if elapsed := time.Since(start).Milliseconds(); 5000 < elapsed {
		util.LogInfof("workspace data to sync data elapsed [%dms]", elapsed)
	}
	return
}

type CloudIndex struct {
	Hash string `json:"hash"`
	Size int64  `json:"size"`
}

func genCloudIndex(localDirPath string, excludes map[string]bool) (err error) {
	cloudIndex := map[string]*CloudIndex{}
	err = filepath.Walk(localDirPath, func(path string, info fs.FileInfo, err error) error {
		if nil != err {
			return err
		}
		if localDirPath == path || info.IsDir() || excludes[path] {
			return nil
		}

		if util.CloudSingleFileMaxSizeLimit < info.Size() {
			return nil
		}

		hash, hashErr := GetEtag(path)
		if nil != hashErr {
			util.LogErrorf("get file [%s] hash failed: %s", path, hashErr)
			return hashErr
		}

		p := strings.TrimPrefix(path, localDirPath)
		p = filepath.ToSlash(p)
		cloudIndex[p] = &CloudIndex{Hash: hash, Size: info.Size()}
		return nil
	})
	if nil != err {
		util.LogErrorf("walk sync dir [%s] failed: %s", localDirPath, err)
		return
	}
	data, err := gulu.JSON.MarshalJSON(cloudIndex)
	if nil != err {
		util.LogErrorf("marshal sync cloud index failed: %s", err)
		return
	}
	if err = os.WriteFile(filepath.Join(localDirPath, "index.json"), data, 0644); nil != err {
		util.LogErrorf("write sync cloud index failed: %s", err)
		return
	}
	return
}

func recoverSyncData(modified map[string]bool) (decryptedDataDir string, upsertFiles []string, err error) {
	passwd := Conf.E2EEPasswd
	decryptedDataDir = filepath.Join(util.WorkspaceDir, "incremental", "sync-decrypt")
	if err = os.RemoveAll(decryptedDataDir); nil != err {
		return
	}
	if err = os.MkdirAll(decryptedDataDir, 0755); nil != err {
		return
	}

	syncDir := Conf.Sync.GetSaveDir()
	meta := filepath.Join(syncDir, pathJSON)
	data, err := os.ReadFile(meta)
	if nil != err {
		return
	}
	data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
	if nil != err {
		err = errors.New(Conf.Language(40))
		return
	}

	metaJSON := map[string]string{}
	if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
		return
	}

	modTimes := map[string]time.Time{}
	now := time.Now().Format("2006-01-02-150405")
	filepath.Walk(syncDir, func(path string, info fs.FileInfo, _ error) error {
		if syncDir == path || pathJSON == info.Name() {
			return nil
		}

		// 如果不是新增或者修改则跳过
		if !modified[path] {
			return nil
		}

		encryptedP := strings.TrimPrefix(path, syncDir+string(os.PathSeparator))
		encryptedP = filepath.ToSlash(encryptedP)
		if "" == metaJSON[encryptedP] {
			return nil
		}

		plainP := filepath.Join(decryptedDataDir, metaJSON[encryptedP])
		plainP = filepath.FromSlash(plainP)

		p := strings.TrimPrefix(plainP, decryptedDataDir+string(os.PathSeparator))
		upsertFiles = append(upsertFiles, p)
		dataPath := filepath.Join(util.DataDir, p)
		if gulu.File.IsExist(dataPath) && !gulu.File.IsDir(dataPath) { // 不是目录的话说明必定是已经存在的文件，这些文件被覆盖需要生成历史
			genSyncHistory(now, dataPath)
		}

		if info.IsDir() {
			if err = os.MkdirAll(plainP, 0755); nil != err {
				return io.EOF
			}
		} else {
			if err = os.MkdirAll(filepath.Dir(plainP), 0755); nil != err {
				return io.EOF
			}

			var err0 error
			data, err0 = os.ReadFile(path)
			if nil != err0 {
				util.LogErrorf("read file [%s] failed: %s", path, err0)
				err = err0
				return io.EOF
			}
			if !strings.HasPrefix(encryptedP, ".siyuan") {
				data, err0 = encryption.AESGCMDecryptBinBytes(data, passwd)
				if nil != err0 {
					util.LogErrorf("decrypt file [%s] failed: %s", path, err0)
					err = errors.New(Conf.Language(40))
					return io.EOF
				}
			}

			if err0 = os.WriteFile(plainP, data, 0644); nil != err0 {
				util.LogErrorf("write file [%s] failed: %s", plainP, err0)
				err = err0
				return io.EOF
			}
		}

		fi, err0 := os.Stat(path)
		if nil != err0 {
			util.LogErrorf("stat file [%s] failed: %s", path, err0)
			err = err0
			return io.EOF
		}
		modTimes[plainP] = fi.ModTime()
		return nil
	})
	return
}

func prepareSyncData(passwd string, unchangedList map[string]bool) (encryptedDataDir string, err error) {
	encryptedDataDir = filepath.Join(util.WorkspaceDir, "incremental", "sync-encrypt")
	if err = os.RemoveAll(encryptedDataDir); nil != err {
		return
	}
	if err = os.MkdirAll(encryptedDataDir, 0755); nil != err {
		return
	}

	ctime := map[string]time.Time{}
	metaJSON := map[string]string{}
	filepath.Walk(util.DataDir, func(path string, info fs.FileInfo, _ error) error {
		if util.DataDir == path || nil == info {
			return nil
		}

		isDir := info.IsDir()
		if isCloudSkipFile(path, info) {
			if isDir {
				return filepath.SkipDir
			}
			return nil
		}

		plainP := strings.TrimPrefix(path, util.DataDir+string(os.PathSeparator))
		p := plainP

		if !strings.HasPrefix(plainP, ".siyuan") { // 配置目录下都用明文，其他文件需要映射文件名
			p = pathSha246(p, string(os.PathSeparator))
		}
		metaJSON[filepath.ToSlash(p)] = filepath.ToSlash(plainP)

		// 如果不是新增或者修改则跳过
		if unchangedList[path] {
			return nil
		}

		p = encryptedDataDir + string(os.PathSeparator) + p
		//util.LogInfof("update sync [%s] for data [%s]", p, path)
		if isDir {
			if err = os.MkdirAll(p, 0755); nil != err {
				return io.EOF
			}
			if fi, err0 := os.Stat(path); nil == err0 {
				ctime[p] = fi.ModTime()
			}
		} else {
			if err = os.MkdirAll(filepath.Dir(p), 0755); nil != err {
				return io.EOF
			}

			data, err0 := filesys.NoLockFileRead(path)
			if nil != err0 {
				util.LogErrorf("read file [%s] failed: %s", path, err0)
				err = err0
				return io.EOF
			}
			if !strings.HasPrefix(plainP, ".siyuan") {
				data, err0 = encryption.AESGCMEncryptBinBytes(data, passwd)
				if nil != err0 {
					util.LogErrorf("encrypt file [%s] failed: %s", path, err0)
					err = errors.New("encrypt file failed")
					return io.EOF
				}
			}

			err0 = os.WriteFile(p, data, 0644)
			if nil != err0 {
				util.LogErrorf("write file [%s] failed: %s", p, err0)
				err = err0
				return io.EOF
			}

			fi, err0 := os.Stat(path)
			if nil != err0 {
				util.LogErrorf("stat file [%s] failed: %s", path, err0)
				err = err0
				return io.EOF
			}
			ctime[p] = fi.ModTime()
		}
		return nil
	})
	if nil != err {
		return
	}

	for p, t := range ctime {
		if err = os.Chtimes(p, t, t); nil != err {
			return
		}
	}

	// 检查文件是否全部已经编入索引
	err = filepath.Walk(encryptedDataDir, func(path string, info fs.FileInfo, _ error) error {
		if encryptedDataDir == path {
			return nil
		}

		path = strings.TrimPrefix(path, encryptedDataDir+string(os.PathSeparator))
		path = filepath.ToSlash(path)
		if _, ok := metaJSON[path]; !ok {
			util.LogErrorf("not found sync path in meta [%s]", path)
			return errors.New(Conf.Language(27))
		}
		return nil
	})
	if nil != err {
		return
	}

	data, err := gulu.JSON.MarshalJSON(metaJSON)
	if nil != err {
		return
	}
	data, err = encryption.AESGCMEncryptBinBytes(data, passwd)
	if nil != err {
		return "", errors.New("encrypt file failed")
	}
	meta := filepath.Join(encryptedDataDir, pathJSON)
	if err = os.WriteFile(meta, data, 0644); nil != err {
		return
	}
	return
}

// modifiedSyncList 获取 sync 中新增和修改的文件列表。
func modifiedSyncList(unchangedList map[string]bool) (ret map[string]bool) {
	ret = map[string]bool{}
	syncDir := Conf.Sync.GetSaveDir()
	filepath.Walk(syncDir, func(path string, info fs.FileInfo, _ error) error {
		if syncDir == path || pathJSON == info.Name() {
			return nil
		}

		if !unchangedList[path] {
			ret[path] = true
		}
		return nil
	})
	return
}

// unchangedSyncList 获取 data 和 sync 一致（没有修改过）的文件列表，并删除 data 中不存在于 sync 中的多余文件。
func unchangedSyncList() (ret map[string]bool, removes []string, err error) {
	syncDir := Conf.Sync.GetSaveDir()
	meta := filepath.Join(syncDir, pathJSON)
	if !gulu.File.IsExist(meta) {
		return
	}
	data, err := os.ReadFile(meta)
	if nil != err {
		return
	}
	passwd := Conf.E2EEPasswd
	data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
	if nil != err {
		err = errors.New(Conf.Language(40))
		return
	}

	metaJSON := map[string]string{}
	if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
		return
	}

	syncIgnoreList := getSyncIgnoreList()
	excludes := map[string]bool{}
	ignores := syncIgnoreList.Values()
	for _, p := range ignores {
		relPath := p.(string)
		relPath = pathSha246(relPath, "/")
		relPath = filepath.Join(syncDir, relPath)
		excludes[relPath] = true
	}

	ret = map[string]bool{}
	sep := string(os.PathSeparator)
	filepath.Walk(util.DataDir, func(path string, info fs.FileInfo, _ error) error {
		if util.DataDir == path {
			return nil
		}

		if isCloudSkipFile(path, info) {
			if info.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}

		plainP := strings.TrimPrefix(path, util.DataDir+sep)
		dataP := plainP
		dataP = pathSha246(dataP, sep)
		syncP := filepath.Join(syncDir, dataP)

		if excludes[syncP] {
			return nil
		}

		if !gulu.File.IsExist(syncP) { //  sync 已经删除的文件
			removes = append(removes, path)
			if gulu.File.IsDir(path) {
				return filepath.SkipDir
			}
			return nil
		}

		stat, _ := os.Stat(syncP)
		syncModTime := stat.ModTime()
		if info.ModTime() == syncModTime {
			ret[syncP] = true
			return nil
		}
		return nil
	})

	// 在 data 中删除 sync 已经删除的文件
	now := time.Now().Format("2006-01-02-150405")
	for _, remove := range removes {
		genSyncHistory(now, remove)
		if ".siyuan" != filepath.Base(remove) {
			if err = os.RemoveAll(remove); nil != err {
				util.LogErrorf("remove [%s] failed: %s", remove, err)
			}
		}
	}
	return
}

// unchangedDataList 获取 sync 和 data 一致（没有修改过）的文件列表，并删除 sync 中不存在于 data 中的多余文件。
func unchangedDataList(passwd string) (ret map[string]bool, err error) {
	syncDir := Conf.Sync.GetSaveDir()
	meta := filepath.Join(syncDir, pathJSON)
	if !gulu.File.IsExist(meta) {
		return
	}
	data, err := os.ReadFile(meta)
	if nil != err {
		return
	}
	data, err = encryption.AESGCMDecryptBinBytes(data, passwd)
	if nil != err {
		return ret, errors.New(Conf.Language(40))
	}

	metaJSON := map[string]string{}
	if err = gulu.JSON.UnmarshalJSON(data, &metaJSON); nil != err {
		return
	}

	ret = map[string]bool{}
	var removeList []string
	filepath.Walk(syncDir, func(path string, info fs.FileInfo, _ error) error {
		if syncDir == path || pathJSON == info.Name() {
			return nil
		}

		encryptedP := strings.TrimPrefix(path, syncDir+string(os.PathSeparator))
		encryptedP = filepath.ToSlash(encryptedP)
		decryptedP := metaJSON[encryptedP]
		if "" == decryptedP {
			removeList = append(removeList, path)
			if gulu.File.IsDir(path) {
				return filepath.SkipDir
			}
			return nil
		}
		dataP := filepath.Join(util.DataDir, decryptedP)
		dataP = filepath.FromSlash(dataP)
		if !gulu.File.IsExist(dataP) { // data 已经删除的文件
			removeList = append(removeList, path)
			if gulu.File.IsDir(path) {
				return filepath.SkipDir
			}
			return nil
		}

		stat, _ := os.Stat(dataP)
		dataModTime := stat.ModTime()
		if info.ModTime() == dataModTime {
			ret[dataP] = true
			return nil
		}
		return nil
	})

	// 在 sync 中删除 data 中已经删除的文件
	for _, remove := range removeList {
		if strings.HasSuffix(remove, "index.json") {
			continue
		}

		if err = os.RemoveAll(remove); nil != err {
			util.LogErrorf("remove [%s] failed: %s", remove, err)
		}
	}
	return
}

func getWorkspaceDataConf() (conf *filesys.DataConf, err error) {
	conf = &filesys.DataConf{Updated: util.CurrentTimeMillis(), Device: Conf.System.ID}
	confPath := filepath.Join(Conf.Sync.GetSaveDir(), ".siyuan", "conf.json")
	if !gulu.File.IsExist(confPath) {
		os.MkdirAll(filepath.Dir(confPath), 0755)
		data, _ := gulu.JSON.MarshalIndentJSON(conf, "", "  ")
		if err = os.WriteFile(confPath, data, 0644); nil != err {
			util.LogErrorf("save sync conf [%s] failed: %s", confPath, err)
		}
		return
	}

	data, err := os.ReadFile(confPath)
	if nil != err {
		util.LogErrorf("read sync conf [%s] failed: %s", confPath, err)
		return
	}
	if err = gulu.JSON.UnmarshalJSON(data, conf); nil != err {
		filesys.IncWorkspaceDataVer(false, Conf.System.ID) // 尝试恢复 data/.siyuan/conf.json
		util.LogErrorf("unmarshal sync conf [%s] failed: %s", confPath, err)
		err = errors.New(Conf.Language(84))
		return
	}
	return
}

func incLocalSyncVer() {
	conf, err := getWorkspaceDataConf()
	if nil != err {
		return
	}

	conf.SyncVer++
	data, _ := gulu.JSON.MarshalIndentJSON(conf, "", "  ")
	confPath := filepath.Join(Conf.Sync.GetSaveDir(), ".siyuan", "conf.json")
	if err = os.WriteFile(confPath, data, 0644); nil != err {
		util.LogErrorf("save sync conf [%s] failed: %s", confPath, err)
	}
	return
}

func isCloudSkipFile(path string, info os.FileInfo) bool {
	baseName := info.Name()
	if strings.HasPrefix(baseName, ".") {
		if ".siyuan" == baseName {
			return false
		}
		return true
	}
	if "history" == baseName {
		if strings.HasSuffix(path, ".siyuan"+string(os.PathSeparator)+"history") {
			return true
		}
	}

	if (os.ModeSymlink == info.Mode()&os.ModeType) || (!strings.Contains(path, ".siyuan") && gulu.File.IsHidden(path)) {
		return true
	}
	return false
}

func CreateCloudSyncDir(name string) (err error) {
	syncLock.Lock()
	defer syncLock.Unlock()

	if !IsValidCloudDirName(name) {
		return errors.New(Conf.Language(37))
	}

	err = createCloudSyncDirOSS(name)
	if nil != err {
		return
	}
	return
}

func RemoveCloudSyncDir(name string) (err error) {
	syncLock.Lock()
	defer syncLock.Unlock()

	if "" == name {
		return
	}

	err = removeCloudDirPath("sync/" + name)
	if nil != err {
		return
	}

	if Conf.Sync.CloudName == name {
		Conf.Sync.CloudName = ""
		Conf.Save()
	}
	return
}

func ListCloudSyncDir() (syncDirs []*Sync, hSize string, err error) {
	syncDirs = []*Sync{}

	dirs, size, err := listCloudSyncDirOSS()
	for _, d := range dirs {
		dirSize := int64(d["size"].(float64))
		syncDirs = append(syncDirs, &Sync{
			Size:      dirSize,
			HSize:     humanize.Bytes(uint64(dirSize)),
			Updated:   d["updated"].(string),
			CloudName: d["name"].(string),
		})
	}
	hSize = humanize.Bytes(uint64(size))
	return
}

func genSyncHistory(now, p string) {
	dir := strings.TrimPrefix(p, util.DataDir+string(os.PathSeparator))
	if strings.Contains(dir, string(os.PathSeparator)) {
		dir = dir[:strings.Index(dir, string(os.PathSeparator))]
	}

	if ".siyuan" == dir || ".siyuan" == filepath.Base(p) {
		return
	}

	historyDir, err := util.GetHistoryDirNow(now, "sync")
	if nil != err {
		util.LogErrorf("get history dir failed: %s", err)
		return
	}

	relativePath := strings.TrimPrefix(p, util.DataDir)
	historyPath := filepath.Join(historyDir, relativePath)
	filesys.ReleaseFileLocks(p)
	if err = gulu.File.Copy(p, historyPath); nil != err {
		util.LogErrorf("gen sync history failed: %s", err)
		return
	}
}

func formatErrorMsg(err error) string {
	msg := err.Error()
	if strings.Contains(msg, "Permission denied") || strings.Contains(msg, "Access is denied") {
		msg = Conf.Language(33)
	} else if strings.Contains(msg, "Device or resource busy") {
		msg = Conf.Language(85)
	}
	msg = msg + " v" + util.Ver
	return msg
}

func IsValidCloudDirName(cloudDirName string) bool {
	if 64 < len(cloudDirName) {
		return false
	}

	chars := []byte{'~', '`', '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '+', '=',
		'[', ']', '{', '}', '\\', '|', ';', ':', '\'', '"', '<', ',', '>', '.', '?', '/', ' '}
	var charsStr string
	for _, char := range chars {
		charsStr += string(char)
	}

	if strings.ContainsAny(cloudDirName, charsStr) {
		return false
	}

	tmp := util.RemoveInvisible(cloudDirName)
	return tmp == cloudDirName
}

func getSyncIgnoreList() (ret *hashset.Set) {
	ret = hashset.New()
	ignore := filepath.Join(util.DataDir, ".siyuan", "syncignore")
	os.MkdirAll(filepath.Dir(ignore), 0755)
	if !gulu.File.IsExist(ignore) {
		if err := os.WriteFile(ignore, nil, 0644); nil != err {
			util.LogErrorf("create syncignore [%s] failed: %s", ignore, err)
			return
		}
	}
	data, err := os.ReadFile(ignore)
	if nil != err {
		util.LogErrorf("read syncignore [%s] failed: %s", ignore, err)
		return
	}
	dataStr := string(data)
	dataStr = strings.ReplaceAll(dataStr, "\r\n", "\n")
	lines := strings.Split(dataStr, "\n")

	// 默认忽略帮助文档
	lines = append(lines, "20210808180117-6v0mkxr/**/*")
	lines = append(lines, "20210808180117-czj9bvb/**/*")
	lines = append(lines, "20211226090932-5lcq56f/**/*")

	var parents []string
	for _, line := range lines {
		if idx := strings.Index(line, "/*"); -1 < idx {
			parent := line[:idx]
			parents = append(parents, parent)
		}
	}
	lines = append(lines, parents...)

	for _, line := range lines {
		line = strings.TrimSpace(line)
		if "" == line {
			continue
		}
		pattern := filepath.Join(util.DataDir, line)
		pattern = filepath.FromSlash(pattern)
		matches, globErr := zglob.Glob(pattern)
		if nil != globErr && globErr != os.ErrNotExist {
			util.LogErrorf("glob [%s] failed: %s", line, globErr)
			continue
		}
		for _, m := range matches {
			m = filepath.ToSlash(m)
			if strings.Contains(m, ".siyuan/history") {
				continue
			}

			m = strings.TrimPrefix(m, filepath.ToSlash(util.DataDir+string(os.PathSeparator)))
			ret.Add(m)
		}
	}
	return
}

func pathSha246(p, sep string) string {
	buf := bytes.Buffer{}
	parts := strings.Split(p, sep)
	for i, part := range parts {
		buf.WriteString(fmt.Sprintf("%x", sha256.Sum256([]byte(part)))[:7])
		if i < len(parts)-1 {
			buf.WriteString(sep)
		}
	}
	return buf.String()
}

func GetSyncDirection(cloudDirName string) (code int, msg string) { // 0：失败，10：上传，20：下载，30：一致
	if !IsSubscriber() {
		return
	}

	if "" == cloudDirName {
		return
	}

	if !IsValidCloudDirName(cloudDirName) {
		return
	}

	syncConf, err := getWorkspaceDataConf()
	if nil != err {
		msg = fmt.Sprintf(Conf.Language(80), formatErrorMsg(err))
		return
	}

	cloudSyncVer, err := getCloudSyncVer(cloudDirName)
	if nil != err {
		msg = fmt.Sprintf(Conf.Language(24), err.Error())
		return
	}
	if cloudSyncVer < syncConf.SyncVer {
		return 10, fmt.Sprintf(Conf.Language(89), cloudDirName) // 上传
	}
	if cloudSyncVer > syncConf.SyncVer {
		return 20, fmt.Sprintf(Conf.Language(90), cloudDirName) // 下载
	}
	return 30, fmt.Sprintf(Conf.Language(91), cloudDirName) // 一致
}

func IncWorkspaceDataVer() {
	filesys.IncWorkspaceDataVer(true, Conf.System.ID)
	syncSameCount = 0
	syncInterval = fixSyncInterval
	syncPlanTime = time.Now().Add(30 * time.Second)
}

func stableCopy(src, dest string) (err error) {
	if gulu.OS.IsWindows() {
		robocopy := "robocopy"
		cmd := exec.Command(robocopy, src, dest, "/DCOPY:T", "/E", "/IS", "/R:0", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/NS", "/NC")
		util.CmdAttr(cmd)
		var output []byte
		output, err = cmd.CombinedOutput()
		if strings.Contains(err.Error(), "exit status 16") {
			// 某些版本的 Windows 无法同步 https://github.com/siyuan-note/siyuan/issues/4197
			return gulu.File.Copy(src, dest)
		}

		if nil != err && strings.Contains(err.Error(), exec.ErrNotFound.Error()) {
			robocopy = os.Getenv("SystemRoot") + "\\System32\\" + "robocopy"
			cmd = exec.Command(robocopy, src, dest, "/DCOPY:T", "/E", "/IS", "/R:0", "/NFL", "/NDL", "/NJH", "/NJS", "/NP", "/NS", "/NC")
			util.CmdAttr(cmd)
			output, err = cmd.CombinedOutput()
		}
		if nil == err ||
			strings.Contains(err.Error(), "exit status 3") ||
			strings.Contains(err.Error(), "exit status 1") ||
			strings.Contains(err.Error(), "exit status 2") ||
			strings.Contains(err.Error(), "exit status 5") ||
			strings.Contains(err.Error(), "exit status 6") ||
			strings.Contains(err.Error(), "exit status 7") {
			return nil
		}
		util.LogErrorf("robocopy data from [%s] to [%s] failed: %s %s", src, dest, string(output), err)
	}
	return gulu.File.Copy(src, dest)
}
