// Package bisync implements bisync
// Copyright (c) 2017-2020 Chris Nelson
package bisync

import (
	"bufio"
	"context"
	"errors"
	"fmt"
	"io"
	"os"
	"regexp"
	"slices"
	"sort"
	"strconv"
	"strings"
	"time"

	"github.com/rclone/rclone/cmd/bisync/bilib"
	"github.com/rclone/rclone/fs"
	"github.com/rclone/rclone/fs/accounting"
	"github.com/rclone/rclone/fs/filter"
	"github.com/rclone/rclone/fs/hash"
	"github.com/rclone/rclone/fs/operations"
)

// ListingHeader defines first line of a listing
const ListingHeader = "# bisync listing v1 from"

// lineRegex and lineFormat define listing line format
//
//	flags <- size -> <- hash -> id <------------ modtime -----------> "<----- remote"
//	-        3009805 md5:xxxxxx -  2006-01-02T15:04:05.000000000-0700 "12 - Wait.mp3"
//
// flags: "-" for a file and "d" for a directory (reserved)
// hash: "type:value" or "-" (example: "md5:378840336ab14afa9c6b8d887e68a340")
// id: "-" (reserved)
const lineFormat = "%s %8d %s %s %s %q\n"

var lineRegex = regexp.MustCompile(`^(\S) +(-?\d+) (\S+) (\S+) (\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{9}[+-]\d{4}) (".+")$`)

// timeFormat defines time format used in listings
const timeFormat = "2006-01-02T15:04:05.000000000-0700"

var (
	// TZ defines time zone used in listings
	TZ      = time.UTC
	tzLocal = false

	// LogTZ defines time zone used in logs (which may be different than that used in listings).
	// time.Local by default, but we force UTC on tests to make them deterministic regardless of tester's location.
	LogTZ = time.Local
)

// fileInfo describes a file
type fileInfo struct {
	size  int64
	time  time.Time
	hash  string
	id    string
	flags string
}

// fileList represents a listing
type fileList struct {
	list []string
	info map[string]*fileInfo
	hash hash.Type
}

func newFileList() *fileList {
	return &fileList{
		info: map[string]*fileInfo{},
		list: []string{},
	}
}

func (ls *fileList) empty() bool {
	if ls == nil {
		return true
	}
	return len(ls.list) == 0
}

func (ls *fileList) has(file string) bool {
	if file == "" {
		fs.Debugf(nil, "called ls.has() with blank string")
		return false
	}
	_, found := ls.info[file]
	if !found {
		// try unquoting
		file, _ = strconv.Unquote(`"` + file + `"`)
		_, found = ls.info[file]
	}
	return found
}

func (ls *fileList) get(file string) *fileInfo {
	info, found := ls.info[file]
	if !found {
		// try unquoting
		file, _ = strconv.Unquote(`"` + file + `"`)
		info = ls.info[fmt.Sprint(file)]
	}
	return info
}

// copy file from ls to dest
func (ls *fileList) getPut(file string, dest *fileList) {
	f := ls.get(file)
	dest.put(file, f.size, f.time, f.hash, f.id, f.flags)
}

func (ls *fileList) getPutAll(dest *fileList) {
	for file, f := range ls.info {
		dest.put(file, f.size, f.time, f.hash, f.id, f.flags)
	}
}

func (ls *fileList) remove(file string) {
	if ls.has(file) {
		ls.list = slices.Delete(ls.list, slices.Index(ls.list, file), slices.Index(ls.list, file)+1)
		delete(ls.info, file)
	}
}

func (ls *fileList) put(file string, size int64, modtime time.Time, hash, id string, flags string) {
	fi := ls.get(file)
	if fi != nil {
		fi.size = size
		// if already have higher precision of same time, avoid overwriting it
		if fi.time != modtime {
			if modtime.Before(fi.time) && fi.time.Sub(modtime) < time.Second {
				modtime = fi.time
			}
		}
		fi.time = modtime
		fi.hash = hash
		fi.id = id
		fi.flags = flags
	} else {
		fi = &fileInfo{
			size:  size,
			time:  modtime,
			hash:  hash,
			id:    id,
			flags: flags,
		}
		ls.info[file] = fi
		ls.list = append(ls.list, file)
	}
}

func (ls *fileList) getTryAlias(file, alias string) string {
	if ls.has(file) {
		return file
	} else if ls.has(alias) {
		return alias
	}
	return ""
}

func (ls *fileList) getTime(file string) time.Time {
	fi := ls.get(file)
	if fi == nil {
		return time.Time{}
	}
	return fi.time
}

func (ls *fileList) getSize(file string) int64 {
	fi := ls.get(file)
	if fi == nil {
		return 0
	}
	return fi.size
}

func (ls *fileList) getHash(file string) string {
	fi := ls.get(file)
	if fi == nil {
		return ""
	}
	return fi.hash
}

func (b *bisyncRun) fileInfoEqual(file1, file2 string, ls1, ls2 *fileList) bool {
	equal := true
	if ls1.isDir(file1) && ls2.isDir(file2) {
		return equal
	}
	if b.opt.Compare.Size {
		if sizeDiffers(ls1.getSize(file1), ls2.getSize(file2)) {
			b.indent("ERROR", file1, fmt.Sprintf("Size not equal in listing. Path1: %v, Path2: %v", ls1.getSize(file1), ls2.getSize(file2)))
			equal = false
		}
	}
	if b.opt.Compare.Modtime {
		if timeDiffers(b.fctx, ls1.getTime(file1), ls2.getTime(file2), b.fs1, b.fs2) {
			b.indent("ERROR", file1, fmt.Sprintf("Modtime not equal in listing. Path1: %v, Path2: %v", ls1.getTime(file1), ls2.getTime(file2)))
			equal = false
		}
	}
	if b.opt.Compare.Checksum && !b.queueOpt.ignoreListingChecksum {
		if b.hashDiffers(ls1.getHash(file1), ls2.getHash(file2), b.opt.Compare.HashType1, b.opt.Compare.HashType2, ls1.getSize(file1), ls2.getSize(file2)) {
			b.indent("ERROR", file1, fmt.Sprintf("Checksum not equal in listing. Path1: %v, Path2: %v", ls1.getHash(file1), ls2.getHash(file2)))
			equal = false
		}
	}
	return equal
}

// also returns false if not found
func (ls *fileList) isDir(file string) bool {
	fi := ls.get(file)
	if fi != nil {
		if fi.flags == "d" {
			return true
		}
	}
	return false
}

func (ls *fileList) beforeOther(other *fileList, file string) bool {
	thisTime := ls.getTime(file)
	thatTime := other.getTime(file)
	if thisTime.IsZero() || thatTime.IsZero() {
		return false
	}
	return thisTime.Before(thatTime)
}

func (ls *fileList) afterTime(file string, time time.Time) bool {
	fi := ls.get(file)
	if fi == nil {
		return false
	}
	return fi.time.After(time)
}

// sort by path name
func (ls *fileList) sort() {
	sort.SliceStable(ls.list, func(i, j int) bool {
		return ls.list[i] < ls.list[j]
	})
}

// save will save listing to a file.
func (ls *fileList) save(listing string) error {
	file, err := os.Create(listing)
	if err != nil {
		return err
	}
	ls.sort()

	hashName := ""
	if ls.hash != hash.None {
		hashName = ls.hash.String()
	}

	_, err = fmt.Fprintf(file, "%s %s\n", ListingHeader, time.Now().In(TZ).Format(timeFormat))
	if err != nil {
		_ = file.Close()
		_ = os.Remove(listing)
		return err
	}

	for _, remote := range ls.list {
		fi := ls.get(remote)

		time := fi.time.In(TZ).Format(timeFormat)

		hash := "-"
		if hashName != "" && fi.hash != "" {
			hash = hashName + ":" + fi.hash
		}

		id := fi.id
		if id == "" {
			id = "-"
		}

		flags := fi.flags
		if flags == "" {
			flags = "-"
		}

		_, err = fmt.Fprintf(file, lineFormat, flags, fi.size, hash, id, time, remote)
		if err != nil {
			_ = file.Close()
			_ = os.Remove(listing)
			return err
		}
	}

	return file.Close()
}

// loadListing will load listing from a file.
// The key is the path to the file relative to the Path1/Path2 base.
func (b *bisyncRun) loadListing(listing string) (*fileList, error) {
	file, err := os.Open(listing)
	if err != nil {
		return nil, err
	}
	defer func() {
		_ = file.Close()
	}()

	reader := bufio.NewReader(file)
	ls := newFileList()
	lastHashName := ""

	for {
		line, err := reader.ReadString('\n')
		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, err
		}

		line = strings.TrimSuffix(line, "\n")
		if line == "" || line[0] == '#' {
			continue
		}

		match := lineRegex.FindStringSubmatch(line)
		if match == nil {
			fs.Logf(listing, "Ignoring incorrect line: %q", line)
			continue
		}
		flags, sizeStr, hashStr := match[1], match[2], match[3]
		id, timeStr, nameStr := match[4], match[5], match[6]

		sizeVal, sizeErr := strconv.ParseInt(sizeStr, 10, 64)
		timeVal, timeErr := time.ParseInLocation(timeFormat, timeStr, TZ)
		nameVal, nameErr := strconv.Unquote(nameStr)

		hashName, hashVal, hashErr := parseHash(hashStr)
		if hashErr == nil && hashName != "" {
			if lastHashName == "" {
				lastHashName = hashName
				hashErr = ls.hash.Set(hashName)
			} else if hashName != lastHashName {
				fs.Logf(listing, "Inconsistent hash type in line: %q", line)
				continue
			}
		}

		if (flags != "-" && flags != "d") || id != "-" || sizeErr != nil || timeErr != nil || hashErr != nil || nameErr != nil {
			fs.Logf(listing, "Ignoring incorrect line: %q", line)
			continue
		}

		if ls.has(nameVal) {
			fs.Logf(listing, "Duplicate line (keeping latest): %q", line)
			if ls.afterTime(nameVal, timeVal) {
				continue
			}
		}

		ls.put(nameVal, sizeVal, timeVal.In(TZ), hashVal, id, flags)
	}

	return ls, nil
}

// saveOldListings saves the most recent successful listing, in case we need to rollback on error
func (b *bisyncRun) saveOldListings() {
	b.handleErr(b.listing1, "error saving old Path1 listing", bilib.CopyFileIfExists(b.listing1, b.listing1+"-old"), true, true)
	b.handleErr(b.listing2, "error saving old Path2 listing", bilib.CopyFileIfExists(b.listing2, b.listing2+"-old"), true, true)
}

// replaceCurrentListings saves both ".lst-new" listings as ".lst"
func (b *bisyncRun) replaceCurrentListings() {
	b.handleErr(b.newListing1, "error replacing Path1 listing", bilib.CopyFileIfExists(b.newListing1, b.listing1), true, true)
	b.handleErr(b.newListing2, "error replacing Path2 listing", bilib.CopyFileIfExists(b.newListing2, b.listing2), true, true)
}

// revertToOldListings reverts to the most recent successful listing
func (b *bisyncRun) revertToOldListings() {
	b.handleErr(b.listing1, "error reverting to old Path1 listing", bilib.CopyFileIfExists(b.listing1+"-old", b.listing1), true, true)
	b.handleErr(b.listing2, "error reverting to old Path2 listing", bilib.CopyFileIfExists(b.listing2+"-old", b.listing2), true, true)
}

func parseHash(str string) (string, string, error) {
	if str == "-" {
		return "", "", nil
	}
	if before, after, ok := strings.Cut(str, ":"); ok {
		name, val := before, after
		if name != "" && val != "" {
			return name, val, nil
		}
	}
	return "", "", fmt.Errorf("invalid hash %q", str)
}

// checkListing verifies that listing is not empty (unless resyncing)
func (b *bisyncRun) checkListing(ls *fileList, listing, msg string) error {
	if b.opt.Resync || !ls.empty() {
		return nil
	}
	fs.Errorf(nil, "Empty %s listing. Cannot sync to an empty directory: %s", msg, listing)
	b.critical = true
	b.retryable = true
	return fmt.Errorf("empty %s listing: %s", msg, listing)
}

// listingNum should be 1 for path1 or 2 for path2
func (b *bisyncRun) loadListingNum(listingNum int) (*fileList, error) {
	listingpath := b.basePath + ".path1.lst-new"
	if listingNum == 2 {
		listingpath = b.basePath + ".path2.lst-new"
	}

	if b.opt.DryRun {
		listingpath = strings.Replace(listingpath, ".lst-", ".lst-dry-", 1)
	}

	fs.Debugf(nil, "loading listing for path %d at: %s", listingNum, listingpath)
	return b.loadListing(listingpath)
}

func (b *bisyncRun) listDirsOnly(listingNum int) (*fileList, error) {
	var fulllisting *fileList
	dirsonly := newFileList()
	var err error

	if !b.opt.CreateEmptySrcDirs {
		return dirsonly, err
	}

	fulllisting, err = b.loadListingNum(listingNum)
	if err != nil {
		b.critical = true
		b.retryable = true
		fs.Debugf(nil, "Error loading listing to generate dirsonly list: %v", err)
		return dirsonly, err
	}

	for _, obj := range fulllisting.list {
		info := fulllisting.get(obj)

		if info.flags == "d" {
			fs.Debugf(nil, "found a dir: %s", obj)
			dirsonly.put(obj, info.size, info.time, info.hash, info.id, info.flags)
		} else {
			fs.Debugf(nil, "not a dir: %s", obj)
		}
	}

	return dirsonly, err
}

// modifyListing will modify the listing based on the results of the sync
func (b *bisyncRun) modifyListing(ctx context.Context, src fs.Fs, dst fs.Fs, results []Results, queues queues, is1to2 bool) (err error) {
	queue := queues.copy2to1
	direction := "2to1"
	if is1to2 {
		queue = queues.copy1to2
		direction = "1to2"
	}

	fs.Debugf(nil, "updating %s", direction)
	prettyprint(results, "results", fs.LogLevelDebug)
	prettyprint(queue, "queue", fs.LogLevelDebug)

	srcListing, dstListing := b.getListingNames(is1to2)
	srcList, err := b.loadListing(srcListing)
	if err != nil {
		return fmt.Errorf("cannot read prior listing: %w", err)
	}
	dstList, err := b.loadListing(dstListing)
	if err != nil {
		return fmt.Errorf("cannot read prior listing: %w", err)
	}
	// set list hash type
	if b.opt.Resync && !b.opt.IgnoreListingChecksum {
		if is1to2 {
			srcList.hash = b.opt.Compare.HashType1
			dstList.hash = b.opt.Compare.HashType2
		} else {
			srcList.hash = b.opt.Compare.HashType2
			dstList.hash = b.opt.Compare.HashType1
		}
		if b.opt.Compare.DownloadHash && srcList.hash == hash.None {
			srcList.hash = hash.MD5
		}
		if b.opt.Compare.DownloadHash && dstList.hash == hash.None {
			dstList.hash = hash.MD5
		}
	}

	b.debugFn(b.DebugName, func() {
		var rs ResultsSlice = results
		b.debug(b.DebugName, fmt.Sprintf("modifyListing direction: %s, results has name?: %v", direction, rs.has(b.DebugName)))
		b.debug(b.DebugName, fmt.Sprintf("modifyListing direction: %s, srcList has name?: %v, dstList has name?: %v", direction, srcList.has(b.DebugName), dstList.has(b.DebugName)))
	})

	srcWinners := newFileList()
	dstWinners := newFileList()
	errors := newFileList()
	ctxRecheck, filterRecheck := filter.AddConfig(ctx)

	for _, result := range results {
		if result.Name == "" {
			continue
		}

		if result.AltName != "" {
			b.aliases.Add(result.Name, result.AltName)
		}

		if result.Flags == "d" && !b.opt.CreateEmptySrcDirs {
			continue
		}

		// build src winners list
		if result.IsSrc && result.Src != "" && (result.Winner.Err == nil || result.Flags == "d") {
			srcWinners.put(result.Name, result.Size, result.Modtime, result.Hash, "-", result.Flags)
			prettyprint(result, "winner: copy to src", fs.LogLevelDebug)
		}

		// build dst winners list
		if result.IsWinner && result.Winner.Side != "none" && (result.Winner.Err == nil || result.Flags == "d") {
			dstWinners.put(result.Name, result.Size, result.Modtime, result.Hash, "-", result.Flags)
			prettyprint(result, "winner: copy to dst", fs.LogLevelDebug)
		}

		// build errors list
		if result.Err != nil || result.Winner.Err != nil {
			errors.put(result.Name, result.Size, result.Modtime, result.Hash, "-", result.Flags)
			if err := filterRecheck.AddFile(result.Name); err != nil {
				fs.Debugf(result.Name, "error adding file to recheck filter: %v", err)
			}
		}
	}

	ci := fs.GetConfig(ctx)
	updateLists := func(side string, winners, list *fileList) {
		for _, queueFile := range queue.ToList() {
			if !winners.has(queueFile) && list.has(queueFile) && !errors.has(queueFile) {
				// removals from side
				list.remove(queueFile)
				fs.Debugf(nil, "decision: removed from %s: %v", side, queueFile)
			} else if winners.has(queueFile) {
				// copies to side
				new := winners.get(queueFile)

				// handle normalization
				if side == "dst" {
					alias := b.aliases.Alias(queueFile)
					if alias != queueFile {
						// use the (non-identical) existing name, unless --fix-case
						if ci.FixCase {
							fs.Debugf(direction, "removing %s and adding %s as --fix-case was specified", alias, queueFile)
							list.remove(alias)
						} else {
							fs.Debugf(direction, "casing/unicode difference detected. using %s instead of %s", alias, queueFile)
							queueFile = alias
						}
					}
				}

				list.put(queueFile, new.size, new.time, new.hash, new.id, new.flags)
				fs.Debugf(nil, "decision: copied to %s: %v", side, queueFile)
			} else {
				fs.Debugf(queueFile, "file in queue but missing from %s transfers", side)
				if err := filterRecheck.AddFile(queueFile); err != nil {
					fs.Debugf(queueFile, "error adding file to recheck filter: %v", err)
				}
			}
		}
	}
	updateLists("src", srcWinners, srcList)
	updateLists("dst", dstWinners, dstList)

	// account for "deltaOthers" we handled separately
	if queues.deletedonboth.NotEmpty() {
		for file := range queues.deletedonboth {
			srcList.remove(file)
			dstList.remove(file)
		}
	}
	if b.renames.NotEmpty() && !b.opt.DryRun {
		// renamed on src and copied to dst
		for _, rename := range b.renames {
			srcOldName, srcNewName, dstOldName, dstNewName := rename.getNames(is1to2)
			fs.Debugf(nil, "%s: srcOldName: %v srcNewName: %v dstOldName: %v dstNewName: %v", direction, srcOldName, srcNewName, dstOldName, dstNewName)
			// we'll handle the other side when we go the other direction
			var new *fileInfo
			// we prefer to get the info from the newNamed versions
			// since they were actually copied as opposed to operations.MoveFile()'d.
			// the size/time/hash info is therefore fresher on the renames
			// but we'll settle for the original if we have to.
			if srcList.has(srcNewName) {
				new = srcList.get(srcNewName)
			} else if srcList.has(dstNewName) {
				new = srcList.get(dstNewName)
			} else if srcList.has(srcOldName) {
				new = srcList.get(srcOldName)
			} else {
				// something's odd, so let's recheck
				if err := filterRecheck.AddFile(srcOldName); err != nil {
					fs.Debugf(srcOldName, "error adding file to recheck filter: %v", err)
				}
			}
			if srcNewName != "" { // if it was renamed and not deleted
				if new == nil { // should not happen. log error and debug info
					b.handleErr(b.renames, "internal error", fmt.Errorf("missing info for %q. Please report a bug at https://github.com/rclone/rclone/issues", srcNewName), true, true)
					fs.PrettyPrint(srcList, "srcList for debugging", fs.LogLevelNotice)
					continue
				}
				srcList.put(srcNewName, new.size, new.time, new.hash, new.id, new.flags)
				dstList.put(srcNewName, new.size, new.time, new.hash, new.id, new.flags)
			}
			if srcNewName != srcOldName {
				srcList.remove(srcOldName)
			}
			if srcNewName != dstOldName {
				dstList.remove(dstOldName)
			}
		}
	}

	// recheck the ones we skipped because they were equal
	// we never got their info because they were never synced.
	// TODO: add flag to skip this? (since it re-lists)
	if queues.renameSkipped.NotEmpty() {
		skippedList := queues.renameSkipped.ToList()
		for _, file := range skippedList {
			if err := filterRecheck.AddFile(file); err != nil {
				fs.Debugf(file, "error adding file to recheck filter: %v", err)
			}
		}
	}
	// skipped dirs -- nothing to recheck, just add them
	// (they are not necessarily there already, if they are new)
	path1List := srcList
	path2List := dstList
	if !is1to2 {
		path1List = dstList
		path2List = srcList
	}
	if !queues.skippedDirs1.empty() {
		queues.skippedDirs1.getPutAll(path1List)
	}
	if !queues.skippedDirs2.empty() {
		queues.skippedDirs2.getPutAll(path2List)
	}

	if filterRecheck.HaveFilesFrom() {
		// also include any aliases
		recheckFiles := filterRecheck.Files()
		for recheckFile := range recheckFiles {
			alias := b.aliases.Alias(recheckFile)
			if recheckFile != alias {
				if err := filterRecheck.AddFile(alias); err != nil {
					fs.Debugf(alias, "error adding file to recheck filter: %v", err)
				}
			}
		}
		b.recheck(ctxRecheck, src, dst, srcList, dstList, is1to2)
	}

	if b.InGracefulShutdown {
		var toKeep []string
		var toRollback []string
		fs.Debugf(direction, "stats for %s", direction)
		trs := accounting.Stats(ctx).Transferred()
		for _, tr := range trs {
			b.debugFn(tr.Name, func() {
				prettyprint(tr, tr.Name, fs.LogLevelInfo)
			})
			if tr.Error == nil && tr.Bytes > 0 || tr.Size <= 0 {
				prettyprint(tr, "keeping: "+tr.Name, fs.LogLevelDebug)
				toKeep = append(toKeep, tr.Name)
			}
		}
		// Dirs (for the unlikely event that the shutdown was triggered post-sync during syncEmptyDirs)
		for _, r := range results {
			if r.Origin == "syncEmptyDirs" {
				if srcWinners.has(r.Name) || dstWinners.has(r.Name) {
					toKeep = append(toKeep, r.Name)
					fs.Infof(r.Name, "keeping empty dir")
				}
			}
		}
		oldSrc, oldDst := b.getOldLists(is1to2)
		prettyprint(oldSrc.list, "oldSrc", fs.LogLevelDebug)
		prettyprint(oldDst.list, "oldDst", fs.LogLevelDebug)
		prettyprint(srcList.list, "srcList", fs.LogLevelDebug)
		prettyprint(dstList.list, "dstList", fs.LogLevelDebug)
		combinedList := Concat(oldSrc.list, oldDst.list, srcList.list, dstList.list)
		for _, f := range combinedList {
			if !slices.Contains(toKeep, f) && !slices.Contains(toKeep, b.aliases.Alias(f)) && !b.opt.DryRun {
				toRollback = append(toRollback, f)
			}
		}
		b.prepareRollback(toRollback, srcList, dstList, is1to2)
		prettyprint(oldSrc.list, "oldSrc", fs.LogLevelDebug)
		prettyprint(oldDst.list, "oldDst", fs.LogLevelDebug)
		prettyprint(srcList.list, "srcList", fs.LogLevelDebug)
		prettyprint(dstList.list, "dstList", fs.LogLevelDebug)

		// clear stats so we only do this once
		accounting.Stats(ctx).RemoveDoneTransfers()
	}

	if b.DebugName != "" {
		b.debug(b.DebugName, fmt.Sprintf("%s pre-save srcList has it?: %v", direction, srcList.has(b.DebugName)))
		b.debug(b.DebugName, fmt.Sprintf("%s pre-save dstList has it?: %v", direction, dstList.has(b.DebugName)))
	}
	// update files
	err = srcList.save(srcListing)
	b.handleErr(srcList, "error saving srcList from modifyListing", err, true, true)
	err = dstList.save(dstListing)
	b.handleErr(dstList, "error saving dstList from modifyListing", err, true, true)

	return err
}

// recheck the ones we're not sure about
func (b *bisyncRun) recheck(ctxRecheck context.Context, src, dst fs.Fs, srcList, dstList *fileList, is1to2 bool) {
	var srcObjs []fs.Object
	var dstObjs []fs.Object
	var resolved []string
	var toRollback []string

	if err := operations.ListFn(ctxRecheck, src, func(obj fs.Object) {
		srcObjs = append(srcObjs, obj)
	}); err != nil {
		fs.Debugf(src, "error recchecking src obj: %v", err)
	}
	if err := operations.ListFn(ctxRecheck, dst, func(obj fs.Object) {
		dstObjs = append(dstObjs, obj)
	}); err != nil {
		fs.Debugf(dst, "error recchecking dst obj: %v", err)
	}

	putObj := func(obj fs.Object, list *fileList) {
		hashVal := ""
		if !b.opt.IgnoreListingChecksum {
			hashType := list.hash
			if hashType != hash.None {
				hashVal, _ = obj.Hash(ctxRecheck, hashType)
			}
			hashVal, _ = b.tryDownloadHash(ctxRecheck, obj, hashVal)
		}
		var modtime time.Time
		if b.opt.Compare.Modtime {
			modtime = obj.ModTime(ctxRecheck).In(TZ)
		}
		list.put(obj.Remote(), obj.Size(), modtime, hashVal, "-", "-")
	}

	for _, srcObj := range srcObjs {
		fs.Debugf(srcObj, "rechecking")
		for _, dstObj := range dstObjs {
			if srcObj.Remote() == dstObj.Remote() || srcObj.Remote() == b.aliases.Alias(dstObj.Remote()) {
				// note: unlike Equal(), WhichEqual() does not update the modtime in dest if sums match but modtimes don't.
				if b.opt.DryRun || b.WhichEqual(ctxRecheck, srcObj, dstObj, src, dst) {
					putObj(srcObj, srcList)
					putObj(dstObj, dstList)
					resolved = append(resolved, srcObj.Remote())
				} else {
					fs.Infof(srcObj, "files not equal on recheck: %v %v", srcObj, dstObj)
				}
			}
		}
		// if srcObj not resolved by now (either because no dstObj match or files not equal),
		// roll it back to old version, so it gets retried next time.
		// skip and error during --resync, as rollback is not possible
		if !slices.Contains(resolved, srcObj.Remote()) && !b.opt.DryRun {
			if b.opt.Resync {
				err := errors.New("no dstObj match or files not equal")
				b.handleErr(srcObj, "Unable to rollback during --resync", err, true, false)
			} else {
				toRollback = append(toRollback, srcObj.Remote())
			}
		}
	}
	if len(toRollback) > 0 {
		srcListing, dstListing := b.getListingNames(is1to2)
		oldSrc, err := b.loadListing(srcListing + "-old")
		b.handleErr(oldSrc, "error loading old src listing", err, true, true)
		oldDst, err := b.loadListing(dstListing + "-old")
		b.handleErr(oldDst, "error loading old dst listing", err, true, true)
		if b.critical {
			return
		}

		for _, item := range toRollback {
			b.rollback(item, oldSrc, srcList)
			b.rollback(item, oldDst, dstList)
		}
	}
}

func (b *bisyncRun) getListingNames(is1to2 bool) (srcListing string, dstListing string) {
	if is1to2 {
		return b.listing1, b.listing2
	}
	return b.listing2, b.listing1
}

func (b *bisyncRun) rollback(item string, oldList, newList *fileList) {
	alias := b.aliases.Alias(item)
	if oldList.has(item) {
		oldList.getPut(item, newList)
		fs.Debugf(nil, "adding to newlist: %s", item)
	} else if oldList.has(alias) {
		oldList.getPut(alias, newList)
		fs.Debugf(nil, "adding to newlist: %s", alias)
	} else {
		fs.Debugf(nil, "removing from newlist: %s (has it?: %v)", item, newList.has(item))
		prettyprint(newList.list, "newList", fs.LogLevelDebug)
		newList.remove(item)
		newList.remove(alias)
	}
}

func (b *bisyncRun) prepareRollback(toRollback []string, srcList, dstList *fileList, is1to2 bool) {
	if len(toRollback) > 0 {
		oldSrc, oldDst := b.getOldLists(is1to2)
		if b.critical {
			return
		}

		fs.Debugf("new lists", "src: (%v), dest: (%v)", len(srcList.list), len(dstList.list))

		for _, item := range toRollback {
			b.debugFn(item, func() {
				b.debug(item, fmt.Sprintf("pre-rollback oldSrc has it?: %v", oldSrc.has(item)))
				b.debug(item, fmt.Sprintf("pre-rollback oldDst has it?: %v", oldDst.has(item)))
				b.debug(item, fmt.Sprintf("pre-rollback srcList has it?: %v", srcList.has(item)))
				b.debug(item, fmt.Sprintf("pre-rollback dstList has it?: %v", dstList.has(item)))
			})
			b.rollback(item, oldSrc, srcList)
			b.rollback(item, oldDst, dstList)
			b.debugFn(item, func() {
				b.debug(item, fmt.Sprintf("post-rollback oldSrc has it?: %v", oldSrc.has(item)))
				b.debug(item, fmt.Sprintf("post-rollback oldDst has it?: %v", oldDst.has(item)))
				b.debug(item, fmt.Sprintf("post-rollback srcList has it?: %v", srcList.has(item)))
				b.debug(item, fmt.Sprintf("post-rollback dstList has it?: %v", dstList.has(item)))
			})
		}
	}
}

func (b *bisyncRun) getOldLists(is1to2 bool) (*fileList, *fileList) {
	srcListing, dstListing := b.getListingNames(is1to2)
	oldSrc, err := b.loadListing(srcListing + "-old")
	b.handleErr(oldSrc, "error loading old src listing", err, true, true)
	oldDst, err := b.loadListing(dstListing + "-old")
	b.handleErr(oldDst, "error loading old dst listing", err, true, true)
	fs.Debugf("get old lists", "is1to2: %v, oldsrc: %s (%v), olddest: %s (%v)", is1to2, srcListing+"-old", len(oldSrc.list), dstListing+"-old", len(oldDst.list))
	return oldSrc, oldDst
}

// Concat returns a new slice concatenating the passed in slices.
func Concat[S ~[]E, E any](ss ...S) S {
	size := 0
	for _, s := range ss {
		size += len(s)
		if size < 0 {
			panic("len out of range")
		}
	}
	newslice := slices.Grow[S](nil, size)
	for _, s := range ss {
		newslice = append(newslice, s...)
	}
	return newslice
}
