// *****************************************************************************
// * This file is part of the FreeFileSync project. It is distributed under    *
// * GNU General Public License: https://www.gnu.org/licenses/gpl-3.0          *
// * Copyright (C) Zenju (zenju AT freefilesync DOT org) - All Rights Reserved *
// *****************************************************************************

#include "algorithm.h"
#include <zen/crc.h>
#include <zen/guid.h>
#include <zen/file_access.h> //needed for TempFileBuffer only
#include "norm_filter.h"
#include "db_file.h"
#include "cmp_filetime.h"
#include "status_handler_impl.h"
#include "../afs/concrete.h"
#include "../afs/native.h"

using namespace zen;
using namespace fff;


void fff::swapGrids(const MainConfiguration& mainCfg, FolderComparison& folderCmp,
                    PhaseCallback& callback /*throw X*/) //throw X
{
    for (BaseFolderPair& baseFolder : asRange(folderCmp))
        baseFolder.flip();

    redetermineSyncDirection(extractDirectionCfg(folderCmp, mainCfg),
                             callback); //throw FileError
}

//----------------------------------------------------------------------------------------------

namespace
{
//visitFSObjectRecursively? nope, see premature end of traversal in processFolder()
class SetSyncDirViaDifferences
{
public:
    static void execute(const DirectionByDiff& dirs, ContainerObject& conObj) { SetSyncDirViaDifferences(dirs).recurse(conObj); }

private:
    SetSyncDirViaDifferences(const DirectionByDiff& dirs) : dirs_(dirs) {}

    void recurse(ContainerObject& conObj) const
    {
        for (FilePair& file : conObj.files())
            processFile(file);
        for (SymlinkPair& link : conObj.symlinks())
            processLink(link);
        for (FolderPair& folder : conObj.subfolders())
            processFolder(folder);
    }

    void processFile(FilePair& file) const
    {
        const CompareFileResult cat = file.getCategory();

        //##################### schedule old temporary files for deletion ####################
        if (cat == FILE_LEFT_ONLY && endsWith(file.getItemName<SelectSide::left>(), AFS::TEMP_FILE_ENDING))
            return file.setSyncDir(SyncDirection::left);
        else if (cat == FILE_RIGHT_ONLY && endsWith(file.getItemName<SelectSide::right>(), AFS::TEMP_FILE_ENDING))
            return file.setSyncDir(SyncDirection::right);
        //####################################################################################

        switch (cat)
        {
            case FILE_EQUAL:
                //file.setSyncDir(SyncDirection::none);
                break;
            case FILE_RENAMED:
                if (dirs_.leftNewer == dirs_.rightNewer)
                    file.setSyncDir(dirs_.leftNewer); //treat "rename" like a "file update"
                else
                    file.setSyncDirConflict(txtDiffName_);
                break;
            case FILE_LEFT_ONLY:
                file.setSyncDir(dirs_.leftOnly);
                break;
            case FILE_RIGHT_ONLY:
                file.setSyncDir(dirs_.rightOnly);
                break;
            case FILE_LEFT_NEWER:
                file.setSyncDir(dirs_.leftNewer);
                break;
            case FILE_RIGHT_NEWER:
                file.setSyncDir(dirs_.rightNewer);
                break;
            case FILE_TIME_INVALID:
                if (dirs_.leftNewer == dirs_.rightNewer) //e.g. "Mirror" sync variant
                    file.setSyncDir(dirs_.leftNewer);
                else
                    file.setSyncDirConflict(file.getCategoryCustomDescription());
                break;
            case FILE_DIFFERENT_CONTENT:
                if (dirs_.leftNewer == dirs_.rightNewer)
                    file.setSyncDir(dirs_.leftNewer);
                else
                    file.setSyncDirConflict(txtDiffContent_);
                break;
            case FILE_CONFLICT:
                file.setSyncDirConflict(file.getCategoryCustomDescription()); //take over category conflict: allow *manual* resolution only!
                break;
        }
    }

    void processLink(SymlinkPair& symlink) const
    {
        switch (symlink.getLinkCategory())
        {
            case SYMLINK_EQUAL:
                //symlink.setSyncDir(SyncDirection::none);
                break;
            case SYMLINK_RENAMED:
                if (dirs_.leftNewer == dirs_.rightNewer)
                    symlink.setSyncDir(dirs_.leftNewer);
                else
                    symlink.setSyncDirConflict(txtDiffName_);
                break;
            case SYMLINK_LEFT_ONLY:
                symlink.setSyncDir(dirs_.leftOnly);
                break;
            case SYMLINK_RIGHT_ONLY:
                symlink.setSyncDir(dirs_.rightOnly);
                break;
            case SYMLINK_LEFT_NEWER:
                symlink.setSyncDir(dirs_.leftNewer);
                break;
            case SYMLINK_RIGHT_NEWER:
                symlink.setSyncDir(dirs_.rightNewer);
                break;
            case SYMLINK_TIME_INVALID:
                if (dirs_.leftNewer == dirs_.rightNewer)
                    symlink.setSyncDir(dirs_.leftNewer);
                else
                    symlink.setSyncDirConflict(symlink.getCategoryCustomDescription());
                break;
            case SYMLINK_DIFFERENT_CONTENT:
                if (dirs_.leftNewer == dirs_.rightNewer)
                    symlink.setSyncDir(dirs_.leftNewer);
                else
                    symlink.setSyncDirConflict(txtDiffContent_);
                break;
            case SYMLINK_CONFLICT:
                symlink.setSyncDirConflict(symlink.getCategoryCustomDescription()); //take over category conflict: allow *manual* resolution only!
                break;
        }
    }

    void processFolder(FolderPair& folder) const
    {
        const CompareDirResult cat = folder.getDirCategory();

        //########### schedule abandoned temporary recycle bin directory for deletion  ##########
        if (cat == DIR_LEFT_ONLY && endsWith(folder.getItemName<SelectSide::left>(), AFS::TEMP_FILE_ENDING))
            return setSyncDirectionRec(SyncDirection::left, folder); //
        else if (cat == DIR_RIGHT_ONLY && endsWith(folder.getItemName<SelectSide::right>(), AFS::TEMP_FILE_ENDING))
            return setSyncDirectionRec(SyncDirection::right, folder); //don't recurse below!
        //#######################################################################################

        switch (cat)
        {
            case DIR_EQUAL:
                //folder.setSyncDir(SyncDirection::none);
                break;
            case DIR_RENAMED:
                if (dirs_.leftNewer == dirs_.rightNewer)
                    folder.setSyncDir(dirs_.leftNewer);
                else
                    folder.setSyncDirConflict(txtDiffName_);
                break;
            case DIR_LEFT_ONLY:
                folder.setSyncDir(dirs_.leftOnly);
                break;
            case DIR_RIGHT_ONLY:
                folder.setSyncDir(dirs_.rightOnly);
                break;
            case DIR_CONFLICT:
                folder.setSyncDirConflict(folder.getCategoryCustomDescription()); //take over category conflict: allow *manual* resolution only!
                break;
        }

        recurse(folder);
    }

    const DirectionByDiff dirs_;
    const Zstringc txtDiffName_ = utfTo<Zstringc>(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE +
                                                  _("The items have different names, but it's unknown which side was renamed."));
    const Zstringc txtDiffContent_ = utfTo<Zstringc>(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE +
                                                     _("The items have different content, but it's unknown which side has changed."));
};

//---------------------------------------------------------------------------------------------------------------

//test if non-equal items exist in scanned data
bool allItemsCategoryEqual(const ContainerObject& conObj)
{
    for (const FilePair& file : conObj.files())
        if (file.getCategory() != FILE_EQUAL)
            return false;

    for (const SymlinkPair& symlink : conObj.symlinks())
        if (symlink.getLinkCategory() != SYMLINK_EQUAL)
            return false;

    for (const FolderPair& folder : conObj.subfolders())
        if (folder.getDirCategory() != DIR_EQUAL || !allItemsCategoryEqual(folder)) //short-circuit behavior!
            return false;

    return true;
}
}

bool fff::allElementsEqual(const FolderComparison& folderCmp)
{
    for (const BaseFolderPair& baseFolder : asRange(folderCmp))
        if (!allItemsCategoryEqual(baseFolder))
            return false;

    return true;
}

//---------------------------------------------------------------------------------------------------------------

namespace
{
template <SelectSide side> inline
CudAction compareDbEntry(const FilePair& file, const InSyncFile* dbFile, unsigned int fileTimeTolerance,
                         const std::vector<unsigned int>& ignoreTimeShiftMinutes, bool renamedOrMoved)
{
    if (file.isEmpty<side>())
        return dbFile ? (renamedOrMoved ? CudAction::update: CudAction::delete_) : CudAction::noChange;
    else if (!dbFile)
        return (renamedOrMoved ? CudAction::update : CudAction::create);

    const InSyncDescrFile& descrDb = selectParam<side>(dbFile->left, dbFile->right);

    return sameFileTime(file.getLastWriteTime<side>(), descrDb.modTime, fileTimeTolerance, ignoreTimeShiftMinutes) &&
           //- we do *not* consider file ID, but only *user-visual* changes. E.g. user moving data to some other medium should not be considered a change!
           file.getFileSize<side>() == dbFile->fileSize ?
           CudAction::noChange : CudAction::update;
}


//check whether database entry is in sync considering *current* comparison settings
inline
bool stillInSync(const InSyncFile& dbFile, CompareVariant compareVar, unsigned int fileTimeTolerance, const std::vector<unsigned int>& ignoreTimeShiftMinutes)
{
    switch (compareVar)
    {
        case CompareVariant::timeSize:
            if (dbFile.cmpVar == CompareVariant::content) return true; //special rule: this is certainly "good enough" for CompareVariant::timeSize!

            //case-sensitive file name match is a database invariant!
            return sameFileTime(dbFile.left.modTime, dbFile.right.modTime, fileTimeTolerance, ignoreTimeShiftMinutes);

        case CompareVariant::content:
            //case-sensitive file name match is a database invariant!
            return dbFile.cmpVar == CompareVariant::content;
        //in contrast to comparison, we don't care about modification time here!

        case CompareVariant::size: //file size/case-sensitive file name always matches on both sides for an "in-sync" database entry
            return true;
    }
    assert(false);
    return false;
}

//--------------------------------------------------------------------

//check whether database entry and current item match: *irrespective* of current comparison settings
template <SelectSide side> inline
CudAction compareDbEntry(const SymlinkPair& symlink, const InSyncSymlink* dbSymlink, unsigned int fileTimeTolerance,
                         const std::vector<unsigned int>& ignoreTimeShiftMinutes, bool renamedOrMoved)
{
    if (symlink.isEmpty<side>())
        return dbSymlink ? (renamedOrMoved ? CudAction::update: CudAction::delete_) : CudAction::noChange;
    else if (!dbSymlink)
        return (renamedOrMoved ? CudAction::update : CudAction::create);

    const InSyncDescrLink& descrDb = selectParam<side>(dbSymlink->left, dbSymlink->right);

    return sameFileTime(symlink.getLastWriteTime<side>(), descrDb.modTime, fileTimeTolerance, ignoreTimeShiftMinutes) ?
           CudAction::noChange : CudAction::update;
}


//check whether database entry is in sync considering *current* comparison settings
inline
bool stillInSync(const InSyncSymlink& dbLink, CompareVariant compareVar, unsigned int fileTimeTolerance, const std::vector<unsigned int>& ignoreTimeShiftMinutes)
{
    switch (compareVar)
    {
        case CompareVariant::timeSize:
            if (dbLink.cmpVar == CompareVariant::content || dbLink.cmpVar == CompareVariant::size)
                return true; //special rule: this is already "good enough" for CompareVariant::timeSize!

            //case-sensitive symlink name match is a database invariant!
            return sameFileTime(dbLink.left.modTime, dbLink.right.modTime, fileTimeTolerance, ignoreTimeShiftMinutes);

        case CompareVariant::content:
        case CompareVariant::size: //== categorized by content! see comparison.cpp, ComparisonBuffer::compareBySize()
            //case-sensitive symlink name match is a database invariant!
            return dbLink.cmpVar == CompareVariant::content || dbLink.cmpVar == CompareVariant::size;
    }
    assert(false);
    return false;
}

//--------------------------------------------------------------------

//check whether database entry and current item match: *irrespective* of current comparison settings
template <SelectSide side> inline
CudAction compareDbEntry(const FolderPair& folder, const InSyncFolder* dbFolder, bool renamedOrMoved)
{
    if (folder.isEmpty<side>())
        return dbFolder ? (renamedOrMoved ? CudAction::update: CudAction::delete_) : CudAction::noChange;
    else if (!dbFolder)
        return (renamedOrMoved ? CudAction::update : CudAction::create);

    return CudAction::noChange;
}


inline
bool stillInSync(const InSyncFolder& dbFolder)
{
    //case-sensitive folder name match is a database invariant!
    return true;
}

//----------------------------------------------------------------------------------------------

class DetectMovedFiles
{
public:
    static void execute(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder)
    {
        DetectMovedFiles(baseFolder, dbFolder);
        baseFolder.removeDoubleEmpty(); //see findAndSetMovePair()
    }

private:
    DetectMovedFiles(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder) :
        cmpVar_           (baseFolder.getCompVariant()),
        fileTimeTolerance_(baseFolder.getFileTimeTolerance()),
        ignoreTimeShiftMinutes_(baseFolder.getIgnoredTimeShift())
    {
        recurse(baseFolder, &dbFolder, &dbFolder);

        purgeDuplicates<SelectSide::left >(filesL_,  exLeftOnlyById_);
        purgeDuplicates<SelectSide::right>(filesR_, exRightOnlyById_);

        if ((!exLeftOnlyById_ .empty() || !exLeftOnlyByPath_ .empty()) &&
            (!exRightOnlyById_.empty() || !exRightOnlyByPath_.empty()))
            detectMovePairs(dbFolder);
    }

    void recurse(ContainerObject& conObj, const InSyncFolder* dbFolderL, const InSyncFolder* dbFolderR)
    {
        for (FilePair& file : conObj.files())
        {
            file.setMovePair(nullptr); //discard remnants from previous move detection and start fresh (e.g. consider manual folder rename)

            const AFS::FingerPrint filePrintL = file.isEmpty<SelectSide::left >() ? 0 : file.getFilePrint<SelectSide::left >();
            const AFS::FingerPrint filePrintR = file.isEmpty<SelectSide::right>() ? 0 : file.getFilePrint<SelectSide::right>();

            if (filePrintL != 0) filesL_.push_back(&file); //collect *all* prints for uniqueness check!
            if (filePrintR != 0) filesR_.push_back(&file); //

            auto getDbEntry = [](const InSyncFolder* dbFolder, const Zstring& fileName) -> const InSyncFile*
            {
                if (dbFolder)
                    if (const auto it = dbFolder->files.find(fileName);
                        it != dbFolder->files.end())
                        return &it->second;
                return nullptr;
            };

            if (const CompareFileResult cat = file.getCategory();
                cat == FILE_LEFT_ONLY)
            {
                if (const InSyncFile* dbEntry = getDbEntry(dbFolderL, file.getItemName<SelectSide::left>()))
                    exLeftOnlyByPath_.emplace(dbEntry, &file);
            }
            else if (cat == FILE_RIGHT_ONLY)
            {
                if (const InSyncFile* dbEntry = getDbEntry(dbFolderR, file.getItemName<SelectSide::right>()))
                    exRightOnlyByPath_.emplace(dbEntry, &file);
            }
        }

        for (FolderPair& folder : conObj.subfolders())
        {
            auto getDbEntry = [](const InSyncFolder* dbFolder, const ZstringNorm& folderName) -> const InSyncFolder*
            {
                if (dbFolder)
                    if (const auto it = dbFolder->folders.find(folderName);
                        it != dbFolder->folders.end())
                        return &it->second;
                return nullptr;
            };
            const ZstringNorm itemNameL = folder.getItemName<SelectSide::left >();
            const ZstringNorm itemNameR = folder.getItemName<SelectSide::right>();

            const InSyncFolder* dbEntryL = getDbEntry(dbFolderL, itemNameL);
            const InSyncFolder* dbEntryR = dbFolderL == dbFolderR && itemNameL == itemNameR ?
                                           dbEntryL : getDbEntry(dbFolderR, itemNameR);

            recurse(folder, dbEntryL, dbEntryR);
        }
    }

    template <SelectSide side>
    static void purgeDuplicates(std::vector<FilePair*>& files,
                                std::unordered_map<AFS::FingerPrint, FilePair*>& exOneSideById)
    {
        if (!files.empty())
        {
            std::sort(files.begin(), files.end(), [](const FilePair* lhs, const FilePair* rhs)
            { return lhs->getFilePrint<side>() < rhs->getFilePrint<side>(); });

            AFS::FingerPrint prevPrint = files[0]->getFilePrint<side>();

            for (auto it = files.begin() + 1; it != files.end(); ++it)
                if (const AFS::FingerPrint filePrint = (*it)->getFilePrint<side>();
                    prevPrint != filePrint)
                    prevPrint = filePrint;
                else //duplicate file ID! NTFS hard link/symlink?
                {
                    const auto dupFirst = it - 1;
                    const auto dupLast = std::find_if(it + 1, files.end(), [prevPrint](const FilePair* file)
                    { return file->getFilePrint<side>() != prevPrint; });

                    //remove from model: do *not* store invalid file prints in sync.ffs_db!
                    std::for_each(dupFirst, dupLast, [](FilePair* file) { file->clearFilePrint<side>(); });
                    it = dupLast - 1;
                }

            //collect unique file prints for files existing on one side only:
            constexpr CompareFileResult oneSideOnlyTag = side == SelectSide::left ? FILE_LEFT_ONLY : FILE_RIGHT_ONLY;

            for (FilePair* file : files)
                if (file->getCategory() == oneSideOnlyTag)
                    if (const AFS::FingerPrint filePrint = file->getFilePrint<side>();
                        filePrint != 0) //skip duplicates marked by clearFilePrint()
                        exOneSideById.emplace(filePrint, file);
        }
    }

    void detectMovePairs(const InSyncFolder& container) const
    {
        for (const auto& [fileName, dbAttrib] : container.files)
            findAndSetMovePair(dbAttrib);

        for (const auto& [folderName, subFolder] : container.folders)
            detectMovePairs(subFolder);
    }

    template <SelectSide side>
    static bool sameSizeAndDate(const FilePair& file, const InSyncFile& dbFile)
    {
        return file.getFileSize<side>() == dbFile.fileSize &&
               file.getLastWriteTime<side>() == selectParam<side>(dbFile.left, dbFile.right).modTime;
        /* do NOT consider FAT_FILE_TIME_PRECISION_SEC:
            1. if DB contains file metadata collected during folder comparison we can be as precise as we want here
            2. if DB contains file metadata *estimated* directly after file copy:
                - most file systems store file times with sub-second precision...
                - ...except for FAT, but FAT does not have stable file IDs after file copy anyway (see comment below)
            => file time comparison with seconds precision is fine!

        PS: *never* allow a tolerance as container predicate!!
            => no strict weak ordering relation! reason: no transitivity of equivalence!          */
    }

    template <SelectSide side>
    FilePair* getAssocFilePair(const InSyncFile& dbFile) const
    {
        const std::unordered_map<const InSyncFile*, FilePair*>& exOneSideByPath = selectParam<side>(exLeftOnlyByPath_, exRightOnlyByPath_);
        const std::unordered_map<AFS::FingerPrint,  FilePair*>& exOneSideById   = selectParam<side>(exLeftOnlyById_,   exRightOnlyById_);

        if (const auto it = exOneSideByPath.find(&dbFile);
            it != exOneSideByPath.end())
            return it->second; //if there is an association by path, don't care if there is also an association by ID,
        //even if the association by path doesn't match time and size while the association by ID does!
        //there doesn't seem to be (any?) value in allowing this!

        if (const AFS::FingerPrint filePrint = selectParam<side>(dbFile.left, dbFile.right).filePrint;
            filePrint != 0)
            if (const auto it = exOneSideById.find(filePrint);
                it != exOneSideById.end())
                return it->second;

        return nullptr;
    }

    void findAndSetMovePair(const InSyncFile& dbFile) const
    {
        if (stillInSync(dbFile, cmpVar_, fileTimeTolerance_, ignoreTimeShiftMinutes_))
            if (FilePair* fileLeftOnly = getAssocFilePair<SelectSide::left>(dbFile))
                if (sameSizeAndDate<SelectSide::left>(*fileLeftOnly, dbFile))
                    if (FilePair* fileRightOnly = getAssocFilePair<SelectSide::right>(dbFile))
                        if (sameSizeAndDate<SelectSide::right>(*fileRightOnly, dbFile))
                        {
                            if (!fileLeftOnly ->getMovePair() &&                   //needless checks? (file prints are unique in this context)
                                !fileRightOnly->getMovePair() &&                   //
                                fileLeftOnly ->getCategory() == FILE_LEFT_ONLY && //is it possible we could get conflicting matches!?
                                fileRightOnly->getCategory() == FILE_RIGHT_ONLY)  //=> likely 'yes', but only in obscure cases
                                //--------------- found a match ---------------
                            {
                                //move pair is just a 'rename' => combine:
                                if (&fileLeftOnly->parent() == &fileRightOnly->parent())
                                {
                                    fileLeftOnly->setSyncedTo<SelectSide::right>(fileLeftOnly->getFileSize<SelectSide::left>(),
                                                                                 fileRightOnly->getLastWriteTime<SelectSide::right>(), //lastWriteTimeTrg
                                                                                 fileLeftOnly ->getLastWriteTime<SelectSide::left >(), //lastWriteTimeSrc

                                                                                 fileRightOnly->getFilePrint<SelectSide::right>(), //filePrintTrg
                                                                                 fileLeftOnly ->getFilePrint<SelectSide::left >(), //filePrintSrc

                                                                                 fileRightOnly->isFollowedSymlink<SelectSide::right>(),  //isSymlinkTrg
                                                                                 fileLeftOnly ->isFollowedSymlink<SelectSide::left >()); //isSymlinkSrc

                                    fileLeftOnly->setItemName<SelectSide::right>(fileRightOnly->getItemName<SelectSide::right>());

                                    assert(fileLeftOnly->isActive() && fileRightOnly->isActive()); //can this fail? excluded files are not added during comparison...
                                    if (fileLeftOnly->isActive() != fileRightOnly->isActive()) //just in case
                                        fileLeftOnly->setActive(false);

                                    fileRightOnly->removeItem<SelectSide::right>(); //=> call ContainerObject::removeDoubleEmpty() later!
                                }
                                else //regular move pair: mark it!
                                    fileLeftOnly->setMovePair(fileRightOnly);
                            }
                            else
                                assert(fileLeftOnly->getMovePair() == fileRightOnly);
                        }
    }

    const CompareVariant cmpVar_;
    const unsigned int fileTimeTolerance_;
    const std::vector<unsigned int> ignoreTimeShiftMinutes_;

    std::vector<FilePair*> filesL_; //collection of *all* file items (with non-null filePrint)
    std::vector<FilePair*> filesR_; // => detect duplicate file IDs

    std::unordered_map<AFS::FingerPrint, FilePair*>  exLeftOnlyById_;
    std::unordered_map<AFS::FingerPrint, FilePair*> exRightOnlyById_;

    std::unordered_map<const InSyncFile*, FilePair*>  exLeftOnlyByPath_;
    std::unordered_map<const InSyncFile*, FilePair*> exRightOnlyByPath_;

    /*  Detect Renamed Files:

         X  ->  |_|      Create right
        |_| ->   Y       Delete right

        resolve as: Move/Rename Y to X on right

        Algorithm:
        ----------
        DB-file left  <--- (name, size, date) --->  DB-file right
              |                                          |
              |  (file ID, size, date)                   |  (file ID, size, date)
              |            or                            |            or
              |  (file path, size, date)                 |  (file path, size, date)
             \|/                                        \|/
        file left only                             file right only

       FAT caveat: file IDs are generally not stable when file is either moved or renamed!
         1. Move/rename operations on FAT cannot be detected reliably.
         2. database generally contains wrong file ID on FAT after renaming from .ffs_tmp files => correct file IDs in database only after next sync
         3. even exFAT screws up (but less than FAT) and changes IDs after file move. Did they learn nothing from the past?           */
};

//----------------------------------------------------------------------------------------------

class SetSyncDirViaChanges
{
public:
    static void execute(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder, const DirectionByChange& dirs)
    { SetSyncDirViaChanges(baseFolder, dbFolder, dirs); }

private:
    SetSyncDirViaChanges(BaseFolderPair& baseFolder, const InSyncFolder& dbFolder, const DirectionByChange& dirs) :
        dirs_(dirs),
        cmpVar_                (baseFolder.getCompVariant()),
        fileTimeTolerance_     (baseFolder.getFileTimeTolerance()),
        ignoreTimeShiftMinutes_(baseFolder.getIgnoredTimeShift())
    {
        //-> considering filter not relevant:
        //  if stricter filter than last time: all ok;
        //  if less strict filter (if file ex on both sides -> conflict, fine; if file ex. on one side: copy to other side: fine)
        recurse(baseFolder, &dbFolder);
    }

    void recurse(ContainerObject& conObj, const InSyncFolder* dbFolder) const
    {
        for (FilePair& file : conObj.files())
            processFile(file, dbFolder);
        for (SymlinkPair& symlink : conObj.symlinks())
            processSymlink(symlink, dbFolder);
        for (FolderPair& folder : conObj.subfolders())
            processDir(folder, dbFolder);
    }

    void processFile(FilePair& file, const InSyncFolder* dbFolder) const
    {
        const CompareFileResult cat = file.getCategory();
        if (cat == FILE_EQUAL)
            return;
        else if (cat == FILE_CONFLICT) //take over category conflict: allow *manual* resolution only!
            return file.setSyncDirConflict(file.getCategoryCustomDescription());

        //##################### schedule old temporary files for deletion ####################
        if (cat == FILE_LEFT_ONLY && endsWith(file.getItemName<SelectSide::left>(), AFS::TEMP_FILE_ENDING))
            return file.setSyncDir(SyncDirection::left);
        else if (cat == FILE_RIGHT_ONLY && endsWith(file.getItemName<SelectSide::right>(), AFS::TEMP_FILE_ENDING))
            return file.setSyncDir(SyncDirection::right);
        //####################################################################################

        //try to find corresponding database entry
        auto getDbEntry = [dbFolder](const ZstringNorm& fileName) -> const InSyncFile*
        {
            if (dbFolder)
                if (auto it = dbFolder->files.find(fileName);
                    it != dbFolder->files.end())
                    return &it->second;
            return nullptr;
        };
        const ZstringNorm itemNameL = file.getItemName<SelectSide::left >();
        const ZstringNorm itemNameR = file.getItemName<SelectSide::right>();

        const InSyncFile* dbEntryL = getDbEntry(itemNameL);
        const InSyncFile* dbEntryR = itemNameL == itemNameR ? dbEntryL : getDbEntry(itemNameR);

        if (dbEntryL && dbEntryR && dbEntryL != dbEntryR) //conflict: which db entry to use?
            return file.setSyncDirConflict(txtDbAmbiguous_);

        if (const InSyncFile* dbEntry = dbEntryL ? dbEntryL : dbEntryR;
            dbEntry && !stillInSync(*dbEntry, cmpVar_, fileTimeTolerance_, ignoreTimeShiftMinutes_)) //check *before* misleadingly reporting txtNoSideChanged_
            return file.setSyncDirConflict(txtDbNotInSync_);

        //consider renamed/moved files as "updated" with regards to "changes"-based sync settings: https://freefilesync.org/forum/viewtopic.php?t=10594
        const bool renamedOrMoved = cat == FILE_RENAMED || file.getMovePair();
        const CudAction changeL = compareDbEntry<SelectSide::left >(file, dbEntryL, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved);
        const CudAction changeR = compareDbEntry<SelectSide::right>(file, dbEntryR, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved);

        setSyncDirForChange(file, changeL, changeR);
    }

    void processSymlink(SymlinkPair& symlink, const InSyncFolder* dbFolder) const
    {
        const CompareSymlinkResult cat = symlink.getLinkCategory();
        if (cat == SYMLINK_EQUAL)
            return;
        else if (cat == SYMLINK_CONFLICT) //take over category conflict: allow *manual* resolution only!
            return symlink.setSyncDirConflict(symlink.getCategoryCustomDescription());

        //try to find corresponding database entry
        auto getDbEntry = [dbFolder](const ZstringNorm& linkName) -> const InSyncSymlink*
        {
            if (dbFolder)
                if (auto it = dbFolder->symlinks.find(linkName);
                    it != dbFolder->symlinks.end())
                    return &it->second;
            return nullptr;
        };
        const ZstringNorm itemNameL = symlink.getItemName<SelectSide::left >();
        const ZstringNorm itemNameR = symlink.getItemName<SelectSide::right>();

        const InSyncSymlink* dbEntryL = getDbEntry(itemNameL);
        const InSyncSymlink* dbEntryR = itemNameL == itemNameR ? dbEntryL : getDbEntry(itemNameR);

        if (dbEntryL && dbEntryR && dbEntryL != dbEntryR) //conflict: which db entry to use?
            return symlink.setSyncDirConflict(txtDbAmbiguous_);

        if (const InSyncSymlink* dbEntry = dbEntryL ? dbEntryL : dbEntryR;
            dbEntry && !stillInSync(*dbEntry, cmpVar_, fileTimeTolerance_, ignoreTimeShiftMinutes_))
            return symlink.setSyncDirConflict(txtDbNotInSync_);

        const bool renamedOrMoved = cat == SYMLINK_RENAMED;
        const CudAction changeL = compareDbEntry<SelectSide::left >(symlink, dbEntryL, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved);
        const CudAction changeR = compareDbEntry<SelectSide::right>(symlink, dbEntryR, fileTimeTolerance_, ignoreTimeShiftMinutes_, renamedOrMoved);

        setSyncDirForChange(symlink, changeL, changeR);
    }

    void processDir(FolderPair& folder, const InSyncFolder* dbFolder) const
    {
        const CompareDirResult cat = folder.getDirCategory();

        //########### schedule abandoned temporary recycle bin directory for deletion  ##########
        if (cat == DIR_LEFT_ONLY && endsWith(folder.getItemName<SelectSide::left>(), AFS::TEMP_FILE_ENDING))
            return setSyncDirectionRec(SyncDirection::left, folder); //
        else if (cat == DIR_RIGHT_ONLY && endsWith(folder.getItemName<SelectSide::right>(), AFS::TEMP_FILE_ENDING))
            return setSyncDirectionRec(SyncDirection::right, folder); //don't recurse below!
        //#######################################################################################

        //try to find corresponding database entry
        auto getDbEntry = [dbFolder](const ZstringNorm& folderName) -> const InSyncFolder*
        {
            if (dbFolder)
                if (auto it = dbFolder->folders.find(folderName);
                    it != dbFolder->folders.end())
                    return &it->second;
            return nullptr;
        };

        const ZstringNorm itemNameL = folder.getItemName<SelectSide::left >();
        const ZstringNorm itemNameR = folder.getItemName<SelectSide::right>();

        const InSyncFolder* dbEntryL = getDbEntry(itemNameL);
        const InSyncFolder* dbEntryR = itemNameL == itemNameR ? dbEntryL : getDbEntry(itemNameR);

        if (dbEntryL && dbEntryR && dbEntryL != dbEntryR) //conflict: which db entry to use?
        {
            auto onFsItem = [&](FileSystemObject& fsObj)
            {
                if (fsObj.getCategory() != FILE_EQUAL)
                    fsObj.setSyncDirConflict(txtDbAmbiguous_);
            };
            return visitFSObjectRecursively(static_cast<FileSystemObject&>(folder), onFsItem, onFsItem, onFsItem);
        }
        const InSyncFolder* dbEntry = dbEntryL ? dbEntryL : dbEntryR; //exactly one side nullptr? => change in upper/lower case!

        if (cat == DIR_EQUAL)
            ;
        else if (cat == DIR_CONFLICT) //take over category conflict: allow *manual* resolution only!
            folder.setSyncDirConflict(folder.getCategoryCustomDescription());
        else
        {
            if (dbEntry && !stillInSync(*dbEntry))
                folder.setSyncDirConflict(txtDbNotInSync_);
            else
            {
                const bool renamedOrMoved = cat == DIR_RENAMED;
                const CudAction changeL = compareDbEntry<SelectSide::left >(folder, dbEntryL, renamedOrMoved);
                const CudAction changeR = compareDbEntry<SelectSide::right>(folder, dbEntryR, renamedOrMoved);

                setSyncDirForChange(folder, changeL, changeR);
            }
        }

        recurse(folder, dbEntry);
    }

    template <SelectSide side>
    SyncDirection getSyncDirForChange(CudAction change) const
    {
        const auto& changedirs = selectParam<side>(dirs_.left, dirs_.right);
        switch (change)
        {
            case CudAction::noChange: return SyncDirection::none;
            case CudAction::create:  return changedirs.create;
            case CudAction::update:  return changedirs.update;
            case CudAction::delete_: return changedirs.delete_;
        }
        throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");
    }

    void setSyncDirForChange(FileSystemObject& fsObj, CudAction changeL, CudAction changeR) const
    {
        const SyncDirection dirL = getSyncDirForChange<SelectSide::left >(changeL);
        const SyncDirection dirR = getSyncDirForChange<SelectSide::right>(changeR);
        if (changeL != CudAction::noChange)
        {
            if (changeR != CudAction::noChange) //both sides changed
            {
                if (dirL == dirR) //but luckily agree on direction
                    fsObj.setSyncDir(dirL);
                else
                    fsObj.setSyncDirConflict(txtBothSidesChanged_);
            }
            else //change on left
                fsObj.setSyncDir(dirL);
        }
        else
        {
            if (changeR != CudAction::noChange) //change on right
                fsObj.setSyncDir(dirR);
            else //no change on either side
                fsObj.setSyncDirConflict(txtNoSideChanged_); //obscure, but possible if user widens "fileTimeTolerance"
        }
    }

    //need ref-counted strings! see FileSystemObject::syncDirectionConflict_
    const Zstringc txtBothSidesChanged_ = utfTo<Zstringc>(_("Both sides have changed since last synchronization."));
    const Zstringc txtNoSideChanged_    = utfTo<Zstringc>(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + _("No change since last synchronization."));
    const Zstringc txtDbNotInSync_      = utfTo<Zstringc>(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + _("The database entry is not in sync, considering current settings."));
    const Zstringc txtDbAmbiguous_      = utfTo<Zstringc>(_("Cannot determine sync-direction:") + L'\n' + TAB_SPACE + _("The database entry is ambiguous."));

    const DirectionByChange dirs_;
    const CompareVariant cmpVar_;
    const unsigned int fileTimeTolerance_;
    const std::vector<unsigned int> ignoreTimeShiftMinutes_;
};
}


std::vector<std::pair<BaseFolderPair*, SyncDirectionConfig>> fff::extractDirectionCfg(FolderComparison& folderCmp, const MainConfiguration& mainCfg)
{
    if (folderCmp.empty())
        return {};

    //merge first and additional pairs
    std::vector<LocalPairConfig> allPairs;
    allPairs.push_back(mainCfg.firstPair);
    allPairs.insert(allPairs.end(),
                    mainCfg.additionalPairs.begin(), //add additional pairs
                    mainCfg.additionalPairs.end());

    if (folderCmp.size() != allPairs.size())
        throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");

    std::vector<std::pair<BaseFolderPair*, SyncDirectionConfig>> output;

    for (auto it = folderCmp.begin(); it != folderCmp.end(); ++it)
    {
        BaseFolderPair& baseFolder = it->ref();
        const LocalPairConfig& lpc = allPairs[it - folderCmp.begin()];

        output.emplace_back(&baseFolder, lpc.localSyncCfg ? lpc.localSyncCfg->directionCfg : mainCfg.syncCfg.directionCfg);
    }
    return output;
}


void fff::redetermineSyncDirection(const std::vector<std::pair<BaseFolderPair*, SyncDirectionConfig>>& directCfgs,
                                   PhaseCallback& callback /*throw X*/) //throw X
{
    if (directCfgs.empty())
        return;

    std::unordered_set<const BaseFolderPair*> pairsToSkip;
    std::unordered_map<const BaseFolderPair*, SharedRef<const InSyncFolder>> lastSyncStates;

    //best effort: always set sync directions (even on DB load error and when user cancels during file loading)
    ZEN_ON_SCOPE_EXIT
    (
        //*INDENT-OFF*
        for (const auto& [baseFolder, dirCfg] : directCfgs)
            if (!pairsToSkip.contains(baseFolder))
            {
                //if only one folder is selected instead of a pair, sync directions don't make sense: (user already received warning during comparison)
                if (AFS::isNullPath(baseFolder->getAbstractPath<SelectSide::left >()) ||
                    AFS::isNullPath(baseFolder->getAbstractPath<SelectSide::right>()))
                {
                    SetSyncDirViaDifferences::execute({.leftOnly   = SyncDirection::none,
                                                       .rightOnly  = SyncDirection::none,
                                                       .leftNewer  = SyncDirection::none,
                                                       .rightNewer = SyncDirection::none}, *baseFolder);
                }
                else if (const DirectionByDiff* diffDirs = std::get_if<DirectionByDiff>(&dirCfg.dirs))
                    SetSyncDirViaDifferences::execute(*diffDirs, *baseFolder);
                else
                {
                    const DirectionByChange& changeDirs = std::get<DirectionByChange>(dirCfg.dirs);

                    auto it = lastSyncStates.find(baseFolder);
                    if (const InSyncFolder* lastSyncState = it != lastSyncStates.end() ? &it->second.ref() : nullptr)
                    {
                        //detect moved files (*before* setting sync directions: might combine moved files into single file pairs, which changes category!)
                        DetectMovedFiles::execute(*baseFolder, *lastSyncState);                

                        SetSyncDirViaChanges::execute(*baseFolder, *lastSyncState, changeDirs);
                    }
                    else //fallback:
                    {
                        std::wstring msg = _("Database file is not available: Setting default directions for synchronization.");
                        if (directCfgs.size() > 1)
                            msg += SPACED_DASH + getShortDisplayNameForFolderPair(baseFolder->getAbstractPath<SelectSide::left >(),
                                                                                  baseFolder->getAbstractPath<SelectSide::right>());                        
                        try { callback.logMessage(msg, PhaseCallback::MsgType::warning); /*throw X*/} catch (...) {};

                        SetSyncDirViaDifferences::execute(getDiffDirDefault(changeDirs), *baseFolder);
                    }
                }
            }
        //*INDENT-ON*
    );

    std::vector<const BaseFolderPair*> baseFoldersForDbLoad;
    for (const auto& [baseFolder, dirCfg] : directCfgs)
        if (std::get_if<DirectionByChange>(&dirCfg.dirs))
        {
            if (allItemsCategoryEqual(*baseFolder)) //nothing to do: don't even try to open DB files
                pairsToSkip.insert(baseFolder);
            else
                baseFoldersForDbLoad.push_back(baseFolder);
        }

    //(try to) load sync-database files
    lastSyncStates = loadLastSynchronousState(baseFoldersForDbLoad,
                                              callback /*throw X*/); //throw X

    callback.updateStatus(_("Calculating sync directions...")); //throw X
    callback.requestUiUpdate(true /*force*/); //throw X
}

//---------------------------------------------------------------------------------------------------------------

void fff::setSyncDirectionRec(SyncDirection newDirection, FileSystemObject& fsObj)
{
    auto onFsItem = [newDirection](FileSystemObject& fsObj2)
    {
        if (fsObj2.getCategory() != FILE_EQUAL)
            fsObj2.setSyncDir(newDirection);
    };
    visitFSObjectRecursively(fsObj, onFsItem, onFsItem, onFsItem);
}

//--------------- functions related to filtering ------------------------------------------------------------------------------------

void fff::setActiveStatus(bool newStatus, FolderComparison& folderCmp)
{
    auto onFsItem = [newStatus](FileSystemObject& fsObj) { fsObj.setActive(newStatus); };

    for (BaseFolderPair& baseFolder : asRange(folderCmp))
        visitFSObjectRecursively(baseFolder, onFsItem, onFsItem, onFsItem);
}


void fff::setActiveStatus(bool newStatus, FileSystemObject& fsObj)
{
    auto onFsItem = [newStatus](FileSystemObject& fsObj2) { fsObj2.setActive(newStatus); };

    visitFSObjectRecursively(fsObj, onFsItem, onFsItem, onFsItem);
}


namespace
{
enum FilterStrategy
{
    STRATEGY_SET,
    STRATEGY_AND
    //STRATEGY_OR ->  usage of inOrExcludeAllRows doesn't allow for strategy "or"
};

template <FilterStrategy strategy> struct Eval;

template <>
struct Eval<STRATEGY_SET> //process all elements
{
    template <class T>
    static bool process(const T& obj) { return true; }
};

template <>
struct Eval<STRATEGY_AND>
{
    template <class T>
    static bool process(const T& obj) { return obj.isActive(); }
};


template <FilterStrategy strategy>
class ApplyPathFilter
{
public:
    static void execute(ContainerObject& conObj, const PathFilter& filter) { ApplyPathFilter(conObj, filter); }

private:
    ApplyPathFilter(ContainerObject& conObj, const PathFilter& filter) : filter_(filter)  { recurse(conObj); }

    void recurse(ContainerObject& conObj) const
    {
        for (FilePair& file : conObj.files())
            processFile(file);
        for (SymlinkPair& symlink : conObj.symlinks())
            processLink(symlink);
        for (FolderPair& folder : conObj.subfolders())
            processDir(folder);
    }

    void processFile(FilePair& file) const
    {
        if (Eval<strategy>::process(file))
            file.setActive(file.passFileFilter(filter_));
    }

    void processLink(SymlinkPair& symlink) const
    {
        if (Eval<strategy>::process(symlink))
            symlink.setActive(symlink.passFileFilter(filter_));
    }

    void processDir(FolderPair& folder) const
    {
        bool childItemMightMatch = true;
        const bool filterPassed = folder.passDirFilter(filter_, &childItemMightMatch);

        if (Eval<strategy>::process(folder))
            folder.setActive(filterPassed);

        if (!childItemMightMatch) //use same logic like directory traversing: evaluate filter in subdirs only if objects *could* match
        {
            //exclude all files dirs in subfolders => incompatible with STRATEGY_OR!
            auto onFsItem = [](FileSystemObject& fsObj) { fsObj.setActive(false); };
            visitFSObjectRecursively(static_cast<ContainerObject&>(folder), onFsItem, onFsItem, onFsItem);
            return;
        }

        recurse(folder);
    }

    const PathFilter& filter_;
};


template <FilterStrategy strategy>
class ApplySoftFilter //falsify only! -> can run directly after "hard/base filter"
{
public:
    static void execute(ContainerObject& conObj, const SoftFilter& timeSizeFilter) { ApplySoftFilter(conObj, timeSizeFilter); }

private:
    ApplySoftFilter(ContainerObject& conObj, const SoftFilter& timeSizeFilter) : timeSizeFilter_(timeSizeFilter) { recurse(conObj); }

    void recurse(fff::ContainerObject& conObj) const
    {
        for (FilePair& file : conObj.files())
            processFile(file);
        for (SymlinkPair& symlink : conObj.symlinks())
            processLink(symlink);
        for (FolderPair& folder : conObj.subfolders())
            processDir(folder);
    }

    void processFile(FilePair& file) const
    {
        if (Eval<strategy>::process(file))
        {
            if (file.isEmpty<SelectSide::left>())
                file.setActive(matchSize<SelectSide::right>(file) &&
                               matchTime<SelectSide::right>(file));
            else if (file.isEmpty<SelectSide::right>())
                file.setActive(matchSize<SelectSide::left>(file) &&
                               matchTime<SelectSide::left>(file));
            else
                /* the only case with partially unclear semantics:
                   file and time filters may match or not match on each side, leaving a total of 16 combinations for both sides!

                               ST S T -       ST := match size and time
                               ---------       S := match size only
                            ST |I|I|I|I|       T := match time only
                            ------------       - := no match
                             S |I|E|?|E|
                            ------------       I := include row
                             T |I|?|E|E|       E := exclude row
                            ------------       ? := unclear
                             - |I|E|E|E|
                            ------------
                  let's set ? := E                                          */
                file.setActive((matchSize<SelectSide::right>(file) &&
                                matchTime<SelectSide::right>(file)) ||
                               (matchSize<SelectSide::left>(file) &&
                                matchTime<SelectSide::left>(file)));
        }
    }

    void processLink(SymlinkPair& symlink) const
    {
        if (Eval<strategy>::process(symlink))
        {
            if (symlink.isEmpty<SelectSide::left>())
                symlink.setActive(matchTime<SelectSide::right>(symlink));
            else if (symlink.isEmpty<SelectSide::right>())
                symlink.setActive(matchTime<SelectSide::left>(symlink));
            else
                symlink.setActive(matchTime<SelectSide::right>(symlink) ||
                                  matchTime<SelectSide::left> (symlink));
        }
    }

    void processDir(FolderPair& folder) const
    {
        if (Eval<strategy>::process(folder))
            folder.setActive(timeSizeFilter_.matchFolder()); //if date filter is active we deactivate all folders: effectively gets rid of empty folders!

        recurse(folder);
    }

    template <SelectSide side, class T>
    bool matchTime(const T& obj) const
    {
        return timeSizeFilter_.matchTime(obj.template getLastWriteTime<side>());
    }

    template <SelectSide side, class T>
    bool matchSize(const T& obj) const
    {
        return timeSizeFilter_.matchSize(obj.template getFileSize<side>());
    }

    const SoftFilter timeSizeFilter_;
};
}


void fff::addHardFiltering(BaseFolderPair& baseFolder, const Zstring& excludeFilter)
{
    ApplyPathFilter<STRATEGY_AND>::execute(baseFolder, NameFilter(FilterConfig().includeFilter, excludeFilter));
}


void fff::addSoftFiltering(BaseFolderPair& baseFolder, const SoftFilter& timeSizeFilter)
{
    if (!timeSizeFilter.isNull()) //since we use STRATEGY_AND, we may skip a "null" filter
        ApplySoftFilter<STRATEGY_AND>::execute(baseFolder, timeSizeFilter);
}


void fff::applyFiltering(FolderComparison& folderCmp, const MainConfiguration& mainCfg)
{
    if (folderCmp.empty())
        return;
    else if (folderCmp.size() != mainCfg.additionalPairs.size() + 1)
        throw std::logic_error(std::string(__FILE__) + '[' + numberTo<std::string>(__LINE__) + "] Contract violation!");

    //merge first and additional pairs
    std::vector<LocalPairConfig> allPairs;
    allPairs.push_back(mainCfg.firstPair);
    allPairs.insert(allPairs.end(),
                    mainCfg.additionalPairs.begin(), //add additional pairs
                    mainCfg.additionalPairs.end());

    for (auto it = allPairs.begin(); it != allPairs.end(); ++it)
    {
        BaseFolderPair& baseFolder = folderCmp[it - allPairs.begin()].ref();

        const NormalizedFilter normFilter = normalizeFilters(mainCfg.globalFilter, it->localFilter);

        //"set" hard filter
        ApplyPathFilter<STRATEGY_SET>::execute(baseFolder, normFilter.nameFilter.ref());

        //"and" soft filter
        addSoftFiltering(baseFolder, normFilter.timeSizeFilter);
    }
}


namespace
{
template <SelectSide side, class T> inline
bool matchesTime(const T& obj, time_t timeFrom, time_t timeTo)
{
    return timeFrom <= obj.template getLastWriteTime<side>() &&
           /**/               obj.template getLastWriteTime<side>() <= timeTo;
}
}


void fff::applyTimeSpanFilter(FolderComparison& folderCmp, time_t timeFrom, time_t timeTo)
{
    for (BaseFolderPair& baseFolder : asRange(folderCmp))
    {
        visitFSObjectRecursively(baseFolder, [](FolderPair& folder) { folder.setActive(false); },

        [timeFrom, timeTo](FilePair& file)
        {
            if (file.isEmpty<SelectSide::left>())
                file.setActive(matchesTime<SelectSide::right>(file, timeFrom, timeTo));
            else if (file.isEmpty<SelectSide::right>())
                file.setActive(matchesTime<SelectSide::left>(file, timeFrom, timeTo));
            else
                file.setActive(matchesTime<SelectSide::right>(file, timeFrom, timeTo) ||
                               matchesTime<SelectSide::left>(file, timeFrom, timeTo));
        },

        [timeFrom, timeTo](SymlinkPair& symlink)
        {
            if (symlink.isEmpty<SelectSide::left>())
                symlink.setActive(matchesTime<SelectSide::right>(symlink, timeFrom, timeTo));
            else if (symlink.isEmpty<SelectSide::right>())
                symlink.setActive(matchesTime<SelectSide::left>(symlink, timeFrom, timeTo));
            else
                symlink.setActive(matchesTime<SelectSide::right>(symlink, timeFrom, timeTo) ||
                                  matchesTime<SelectSide::left> (symlink, timeFrom, timeTo));
        });
    }
}


std::optional<PathDependency> fff::getPathDependency(const AbstractPath& itemPathL, const AbstractPath& itemPathR)
{
    if (!AFS::isNullPath(itemPathL) && !AFS::isNullPath(itemPathR))
    {
        if (itemPathL.afsDevice == itemPathR.afsDevice)
        {
            const std::vector<Zstring> relPathL = splitCpy(itemPathL.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip);
            const std::vector<Zstring> relPathR = splitCpy(itemPathR.afsPath.value, FILE_NAME_SEPARATOR, SplitOnEmpty::skip);

            const bool leftParent = relPathL.size() <= relPathR.size();

            const auto& relPathP = leftParent ? relPathL : relPathR;
            const auto& relPathC = leftParent ? relPathR : relPathL;

            if (std::equal(relPathP.begin(), relPathP.end(), relPathC.begin(), [](const Zstring& lhs, const Zstring& rhs) { return equalNoCase(lhs, rhs); }))
            {
                Zstring relDirPath;
                std::for_each(relPathC.begin() + relPathP.size(), relPathC.end(), [&](const Zstring& itemName)
                {
                    relDirPath = appendPath(relDirPath, itemName);
                });

                return PathDependency{leftParent ? itemPathL : itemPathR, relDirPath};
            }
        }
    }
    return {};
}


std::optional<PathDependency> fff::getFolderPathDependency(const AbstractPath& folderPathL, const PathFilter& filterL,
                                                           const AbstractPath& folderPathR, const PathFilter& filterR)
{
    if (std::optional<PathDependency> pd = getPathDependency(folderPathL, folderPathR))
    {
        const PathFilter& filterP = pd->itemPathParent == folderPathL ? filterL : filterR;
        //if there's a dependency, check if the sub directory is (fully) excluded via filter
        //=> easy to check but still insufficient in general:
        // - one folder may have a *.txt include-filter, the other a *.lng include filter => no dependencies, but "childItemMightMatch = true" below!
        // - user may have manually excluded the conflicting items or changed the filter settings without running a re-compare
        bool childItemMightMatch = true;
        if (pd->relPath.empty() || filterP.passDirFilter(pd->relPath, &childItemMightMatch) || childItemMightMatch)
            return pd;
    }
    return {};
}

//############################################################################################################

namespace
{
template <SelectSide side>
void copyToAlternateFolderFrom(const std::vector<const FileSystemObject*>& rowsToCopy,
                               const AbstractPath& targetFolderPath,
                               bool keepRelPaths,
                               bool overwriteIfExists,
                               ProcessCallback& callback /*throw X*/) //throw X
{
    auto reportItemInfo = [&](const std::wstring& msgTemplate, const AbstractPath& itemPath) //throw X
    {
        reportInfo(replaceCpy(msgTemplate, L"%x", fmtPath(AFS::getDisplayPath(itemPath))), callback); //throw X
    };
    const std::wstring txtCreatingFile  (_("Creating file %x"         ));
    const std::wstring txtCreatingFolder(_("Creating folder %x"       ));
    const std::wstring txtCreatingLink  (_("Creating symbolic link %x"));

    auto copyItem = [&](const AbstractPath& targetPath, //throw FileError
                        const std::function<void(const std::function<void()>& deleteTargetItem)>& copyItemPlain) //throw FileError
    {
        //start deleting existing target as required by copyFileTransactional():
        //best amortized performance if "already existing" is the most common case
        std::exception_ptr deletionError;
        auto tryDeleteTargetItem = [&]
        {
            if (overwriteIfExists)
                try { AFS::removeFilePlain(targetPath); /*throw FileError*/ }
                catch (FileError&) { deletionError = std::current_exception(); } //probably "not existing" error, defer evaluation
            //else: copyFileTransactional() => undefined behavior! (e.g. fail/overwrite/auto-rename)
        };

        try
        {
            copyItemPlain(tryDeleteTargetItem); //throw FileError
        }
        catch (FileError&)
        {
            bool alreadyExisting = false;
            try
            {
                AFS::getItemType(targetPath); //throw FileError
                alreadyExisting = true;
            }
            catch (FileError&) {} //=> not yet existing (=> fine, no path issue) or access error:
            //- let's pretend it doesn't happen :> if it does, worst case: the retry fails with (useless) already existing error
            //- itemExists()? too expensive, considering that "already existing" is the most common case

            if (alreadyExisting)
            {
                if (deletionError)
                    std::rethrow_exception(deletionError);
                throw;
            }

            //parent folder missing  => create + retry
            //parent folder existing (maybe externally created shortly after copy attempt) => retry
            if (const std::optional<AbstractPath>& targetParentPath = AFS::getParentPath(targetPath))
                AFS::createFolderIfMissingRecursion(*targetParentPath); //throw FileError

            //retry:
            copyItemPlain(nullptr /*deleteTargetItem*/); //throw FileError
        }
    };

    for (const FileSystemObject* fsObj : rowsToCopy)
        tryReportingError([&]
    {
        const Zstring& relPath = keepRelPaths ? fsObj->getRelativePath<side>() : fsObj->getItemName<side>();
        const AbstractPath sourcePath = fsObj->getAbstractPath<side>();
        const AbstractPath targetPath = AFS::appendRelPath(targetFolderPath, relPath);

        visitFSObject(*fsObj, [&](const FolderPair& folder)
        {
            ItemStatReporter statReporter(1, 0, callback);
            reportItemInfo(txtCreatingFolder, targetPath); //throw X

            AFS::createFolderIfMissingRecursion(targetPath); //throw FileError
            statReporter.reportDelta(1, 0);
            //folder might already exist: see creation of intermediate directories below
        },

        [&](const FilePair& file)
        {
            ItemStatReporter statReporter(1, file.getFileSize<side>(), callback);
            reportItemInfo(txtCreatingFile, targetPath); //throw X

            std::wstring statusMsg = replaceCpy(txtCreatingFile, L"%x", fmtPath(AFS::getDisplayPath(targetPath)));
            PercentStatReporter percentReporter(statusMsg, file.getFileSize<side>(), statReporter);

            const FileAttributes attr = file.getAttributes<side>();
            const AFS::StreamAttributes sourceAttr{attr.modTime, attr.fileSize, attr.filePrint};

            copyItem(targetPath, [&](const std::function<void()>& deleteTargetItem) //throw FileError
            {
                //already existing + !overwriteIfExists: undefined behavior! (e.g. fail/overwrite/auto-rename)
                const AFS::FileCopyResult result = AFS::copyFileTransactional(sourcePath, sourceAttr, targetPath, //throw FileError, ErrorFileLocked, X
                                                                              false /*copyFilePermissions*/, true /*transactionalCopy*/, deleteTargetItem,
                                                                              [&](int64_t bytesDelta)
                {
                    percentReporter.updateDeltaAndStatus(bytesDelta); //throw X
                    callback.requestUiUpdate(); //throw X  => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! e.g. during first few seconds: STATUS_PERCENT_DELAY!
                });

                if (result.errorModTime) //log only; no popup
                    callback.logMessage(result.errorModTime->toString(), PhaseCallback::MsgType::warning);
            });
            statReporter.reportDelta(1, 0);
        },

        [&](const SymlinkPair& symlink)
        {
            ItemStatReporter statReporter(1, 0, callback);
            reportItemInfo(txtCreatingLink, targetPath); //throw X

            copyItem(targetPath, [&](const std::function<void()>& deleteTargetItem) //throw FileError
            {
                deleteTargetItem(); //throw FileError
                AFS::copySymlink(sourcePath, targetPath, false /*copyFilePermissions*/); //throw FileError
            });
            statReporter.reportDelta(1, 0);
        });
    }, callback); //throw X
}
}


void fff::copyToAlternateFolder(const std::vector<const FileSystemObject*>& selectionL,
                                const std::vector<const FileSystemObject*>& selectionR,
                                const Zstring& targetFolderPathPhrase,
                                bool keepRelPaths, bool overwriteIfExists,
                                WarningDialogs& warnings,
                                ProcessCallback& callback /*throw X*/) //throw X
{
    assert(std::all_of(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty<SelectSide::left >(); }));
    assert(std::all_of(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty<SelectSide::right>(); }));

    const int itemTotal = static_cast<int>(selectionL.size() + selectionR.size());
    int64_t bytesTotal = 0;

    for (const FileSystemObject* fsObj : selectionL)
        visitFSObject(*fsObj, [](const FolderPair& folder) {},
    [&](const FilePair& file) { bytesTotal += static_cast<int64_t>(file.getFileSize<SelectSide::left>()); }, [](const SymlinkPair& symlink) {});

    for (const FileSystemObject* fsObj : selectionR)
        visitFSObject(*fsObj, [](const FolderPair& folder) {},
    [&](const FilePair& file) { bytesTotal += static_cast<int64_t>(file.getFileSize<SelectSide::right>()); }, [](const SymlinkPair& symlink) {});

    callback.initNewPhase(itemTotal, bytesTotal, ProcessPhase::none); //throw X
    //------------------------------------------------------------------------------

    const AbstractPath targetFolderPath = createAbstractPath(targetFolderPathPhrase);

    copyToAlternateFolderFrom<SelectSide::left >(selectionL, targetFolderPath, keepRelPaths, overwriteIfExists, callback);
    copyToAlternateFolderFrom<SelectSide::right>(selectionR, targetFolderPath, keepRelPaths, overwriteIfExists, callback);
}

//############################################################################################################

namespace
{
template <SelectSide side>
void deleteFilesOneSide(const std::vector<FileSystemObject*>& rowsToDelete,
                        bool moveToRecycler,
                        bool& recyclerMissingReportOnce,
                        bool& warnRecyclerMissing, //WarningDialogs::warnRecyclerMissing
                        const std::unordered_map<const BaseFolderPair*, SyncDirectionConfig>& baseFolderCfgs,
                        PhaseCallback& callback /*throw X*/) //throw X
{
    const std::wstring txtDelFilePermanent_ = _("Deleting file %x");
    const std::wstring txtDelFileRecycler_  = _("Moving file %x to the recycle bin");

    const std::wstring txtDelSymlinkPermanent_ = _("Deleting symbolic link %x");
    const std::wstring txtDelSymlinkRecycler_  = _("Moving symbolic link %x to the recycle bin");

    const std::wstring txtDelFolderPermanent_ = _("Deleting folder %x");
    const std::wstring txtDelFolderRecycler_  = _("Moving folder %x to the recycle bin");

    auto removeFile = [&](const AbstractPath& filePath, ItemStatReporter<PhaseCallback>& statReporter)
    {
        if (moveToRecycler)
            try
            {
                reportInfo(replaceCpy(txtDelFileRecycler_, L"%x", fmtPath(AFS::getDisplayPath(filePath))), statReporter); //throw X
                AFS::moveToRecycleBinIfExists(filePath); //throw FileError, RecycleBinUnavailable
            }
            catch (const RecycleBinUnavailable& e)
            {
                if (!recyclerMissingReportOnce)
                {
                    recyclerMissingReportOnce = true;
                    callback.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X
                }
                callback.logMessage(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(filePath))) +
                                    L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X
                AFS::removeFileIfExists(filePath); //throw FileError
            }
        else
        {
            reportInfo(replaceCpy(txtDelFilePermanent_, L"%x", fmtPath(AFS::getDisplayPath(filePath))), statReporter); //throw X
            AFS::removeFileIfExists(filePath); //throw FileError
        }
        statReporter.reportDelta(1, 0);
    };

    auto removeSymlink = [&](const AbstractPath& symlinkPath, ItemStatReporter<PhaseCallback>& statReporter)
    {
        if (moveToRecycler)
            try
            {
                reportInfo(replaceCpy(txtDelSymlinkRecycler_, L"%x", fmtPath(AFS::getDisplayPath(symlinkPath))), statReporter); //throw X
                AFS::moveToRecycleBinIfExists(symlinkPath); //throw FileError, RecycleBinUnavailable
            }
            catch (const RecycleBinUnavailable& e)
            {
                if (!recyclerMissingReportOnce)
                {
                    recyclerMissingReportOnce = true;
                    callback.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X
                }
                callback.logMessage(replaceCpy(txtDelSymlinkPermanent_, L"%x", fmtPath(AFS::getDisplayPath(symlinkPath))) +
                                    L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X
                AFS::removeSymlinkIfExists(symlinkPath); //throw FileError
            }
        else
        {
            reportInfo(replaceCpy(txtDelSymlinkPermanent_, L"%x", fmtPath(AFS::getDisplayPath(symlinkPath))), statReporter); //throw X
            AFS::removeSymlinkIfExists(symlinkPath); //throw FileError
        }
        statReporter.reportDelta(1, 0);
    };

    auto removeFolder = [&](const AbstractPath& folderPath, ItemStatReporter<PhaseCallback>& statReporter)
    {
        auto removeFolderPermanently = [&]
        {
            auto onBeforeDeletion = [&](const std::wstring& msgTemplate, const std::wstring& displayPath)
            {
                reportInfo(replaceCpy(msgTemplate, L"%x", fmtPath(displayPath)), statReporter); //throw X
                statReporter.reportDelta(1, 0); //it would be more correct to report *after* work was done!
            };

            AFS::removeFolderIfExistsRecursion(folderPath,
            [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelFilePermanent_,    displayPath); },
            [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelSymlinkPermanent_, displayPath); },
            [&](const std::wstring& displayPath) { onBeforeDeletion(txtDelFolderPermanent_,  displayPath); }); //throw FileError, X
        };

        if (moveToRecycler)
            try
            {
                reportInfo(replaceCpy(txtDelFolderRecycler_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw X
                AFS::moveToRecycleBinIfExists(folderPath); //throw FileError, RecycleBinUnavailable
                statReporter.reportDelta(1, 0);
            }
            catch (const RecycleBinUnavailable& e)
            {
                if (!recyclerMissingReportOnce)
                {
                    recyclerMissingReportOnce = true;
                    callback.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X
                }
                callback.logMessage(replaceCpy(txtDelFolderPermanent_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))) +
                                    L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X
                removeFolderPermanently(); //throw FileError, X
            }
        else
        {
            reportInfo(replaceCpy(txtDelFolderPermanent_, L"%x", fmtPath(AFS::getDisplayPath(folderPath))), statReporter); //throw X
            removeFolderPermanently(); //throw FileError, X
        }
    };


    for (FileSystemObject* fsObj : rowsToDelete) //all pointers are required(!) to be bound
        tryReportingError([&]
    {
        ItemStatReporter statReporter(1, 0, callback);

        if (!fsObj->isEmpty<side>()) //element may be implicitly deleted, e.g. if parent folder was deleted first
        {
            visitFSObject(*fsObj, [&](FolderPair& folder)
            {
                if (folder.isFollowedSymlink<side>())
                    removeSymlink(folder.getAbstractPath<side>(), statReporter); //throw FileError, X
                else
                    removeFolder(folder.getAbstractPath<side>(), statReporter); //throw FileError, X

                folder.removeItem<side>(); //removes recursively!
            },

            [&](FilePair& file)
            {
                if (file.isFollowedSymlink<side>())
                    removeSymlink(file.getAbstractPath<side>(), statReporter); //throw FileError, X
                else
                    removeFile(file.getAbstractPath<side>(), statReporter); //throw FileError, X

                file.removeItem<side>();
            },

            [&](SymlinkPair& symlink)
            {
                removeSymlink(symlink.getAbstractPath<side>(), statReporter); //throw FileError, X
                symlink.removeItem<side>();
            });
            //------- no-throw from here on -------
            const CompareFileResult catOld = fsObj->getCategory();

            //update sync direction: don't call redetermineSyncDirection() because user may have manually changed directions
            if (catOld == CompareFileResult::FILE_EQUAL)
            {
                const SyncDirection newDir = [&]
                {
                    const SyncDirectionConfig& dirCfg = baseFolderCfgs.find(&fsObj->base())->second; //not found? let it crash!

                    if (const DirectionByDiff* diffDirs = std::get_if<DirectionByDiff>(&dirCfg.dirs))
                        return side == SelectSide::left ? diffDirs->rightOnly : diffDirs->leftOnly;
                    else
                    {
                        const DirectionByChange& changeDirs = std::get<DirectionByChange>(dirCfg.dirs);
                        return side == SelectSide::left ? changeDirs.left.delete_ : changeDirs.right.delete_;
                    }
                }();

                setSyncDirectionRec(newDir, *fsObj); //set new direction (recursively)
            }
            //else: keep old syncDir_
        }
    }, callback); //throw X
}
}


void fff::deleteFiles(const std::vector<FileSystemObject*>& selectionL,
                      const std::vector<FileSystemObject*>& selectionR,
                      const std::vector<std::pair<BaseFolderPair*, SyncDirectionConfig>>& directCfgs,
                      bool moveToRecycler,
                      bool& warnRecyclerMissing,
                      ProcessCallback& callback /*throw X*/) //throw X
{
    assert(std::all_of(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty<SelectSide::left >(); }));
    assert(std::all_of(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty<SelectSide::right>(); }));

    const int itemCount = static_cast<int>(selectionL.size() + selectionR.size());
    callback.initNewPhase(itemCount, 0, ProcessPhase::none); //throw X
    //------------------------------------------------------------------------------

    ZEN_ON_SCOPE_EXIT
    (
        //*INDENT-OFF*
        for (const auto& [baseFolder, dirCfg] : directCfgs)
            baseFolder->removeDoubleEmpty();
        //*INDENT-ON*
    );

    //build up mapping from base directory to corresponding direction config
    std::unordered_map<const BaseFolderPair*, SyncDirectionConfig> baseFolderCfgs;
    for (const auto& [baseFolder, dirCfg] : directCfgs)
        baseFolderCfgs[baseFolder] = dirCfg;

    bool recyclerMissingReportOnce = false;
    deleteFilesOneSide<SelectSide::left >(selectionL, moveToRecycler, recyclerMissingReportOnce, warnRecyclerMissing, baseFolderCfgs, callback); //throw X
    deleteFilesOneSide<SelectSide::right>(selectionR, moveToRecycler, recyclerMissingReportOnce, warnRecyclerMissing, baseFolderCfgs, callback); //
}

//############################################################################################################

namespace
{
template <SelectSide side>
void renameItemsOneSide(const std::vector<FileSystemObject*>& selection,
                        const std::span<const Zstring> newNames,
                        const std::unordered_map<const BaseFolderPair*, SyncDirectionConfig>& baseFolderCfgs,
                        PhaseCallback& callback /*throw X*/) //throw X
{
    assert(selection.size() == newNames.size());

    const std::wstring txtRenamingFileXtoY_  {_("Renaming file %x to %y")};
    const std::wstring txtRenamingLinkXtoY_  {_("Renaming symbolic link %x to %y")};
    const std::wstring txtRenamingFolderXtoY_{_("Renaming folder %x to %y")};

    for (size_t i = 0; i < selection.size(); ++i)
        tryReportingError([&]
    {
        FileSystemObject& fsObj = *selection[i];
        const Zstring& newName = newNames[i];

        assert(!fsObj.isEmpty<side>());

        auto haveNameClash = [newNameNorm = getUnicodeNormalForm(newName)](const FileSystemObject& fsObj2)
        {
            return !fsObj2.isEmpty<side>() && getUnicodeNormalForm(fsObj2.getItemName<side>()) == newNameNorm;
        };

        const bool nameAlreadyExisting = [&]
        {
            for (const FilePair& file : fsObj.parent().files())
                if (haveNameClash(file))
                    return true;

            for (const SymlinkPair& symlink : fsObj.parent().symlinks())
                if (haveNameClash(symlink))
                    return true;

            for (const FolderPair& folder : fsObj.parent().subfolders())
                if (haveNameClash(folder))
                    return true;
            return false;
        }();

        //---------------------------------------------------------------
        ItemStatReporter statReporter(1, 0, callback);

        const std::wstring* txtRenamingXtoY_ = nullptr;
        visitFSObject(fsObj,
        [&](const FolderPair&   folder) { txtRenamingXtoY_ = &txtRenamingFolderXtoY_; },
        [&](const FilePair&       file) { txtRenamingXtoY_ = &txtRenamingFileXtoY_; },
        [&](const SymlinkPair& symlink) { txtRenamingXtoY_ = &txtRenamingLinkXtoY_; });

        reportInfo(replaceCpy(replaceCpy(*txtRenamingXtoY_, L"%x", fmtPath(AFS::getDisplayPath(fsObj.getAbstractPath<side>()))),
                              L"%y", fmtPath(newName)), statReporter); //throw X

        if (haveNameClash(fsObj))
            return assert(false); //theoretically possible, but practically showRenameDialog() won't return until there is an actual name change

        if (nameAlreadyExisting) //avoid inconsistent file model: expecting moveAndRenameItem() to fail (ERROR_ALREADY_EXISTS) is not good enough
            return callback.reportFatalError(replaceCpy(replaceCpy(_("Cannot rename %x to %y."),
                                                                   L"%x", fmtPath(AFS::getDisplayPath(fsObj.getAbstractPath<side>()))),
                                                        L"%y", fmtPath(newName)) + L"\n\n" +
                                             replaceCpy(_("The name %x is already used by another item."), L"%x", fmtPath(newName))); //throw X

        AFS::moveAndRenameItem(fsObj.getAbstractPath<side>(),
                               AFS::appendRelPath(fsObj.parent().getAbstractPath<side>(), newName)); //throw FileError, (ErrorMoveUnsupported)
        //------- no-throw from here on -------
        statReporter.reportDelta(1, 0);

        const CompareFileResult catOld = fsObj.getCategory();

        fsObj.setItemName<side>(newName);

#warning("TODO: some users want to manually fix renamed folders/files: combine them here, don't require a re-compare!")

        //update sync direction: don't call redetermineSyncDirection() because user may have manually changed directions
        if (catOld == CompareFileResult::FILE_EQUAL)
        {
            const SyncDirection newDir = [&]
            {
                const SyncDirectionConfig& dirCfg = baseFolderCfgs.find(&fsObj.base())->second; //not found? let it crash!

                if (const DirectionByDiff* diffDirs = std::get_if<DirectionByDiff>(&dirCfg.dirs))
                    return side == SelectSide::left ? diffDirs->leftNewer : diffDirs->rightNewer;
                else
                {
                    const DirectionByChange& changeDirs = std::get<DirectionByChange>(dirCfg.dirs);
                    return side == SelectSide::left ? changeDirs.left.update : changeDirs.right.update;
                }
            }();

            fsObj.setSyncDir(newDir); //folder? => do not recurse!
        }
        //else: keep old syncDir_
        else if (fsObj.getCategory() == FILE_EQUAL) //edge-case, but possible
            fsObj.setSyncDir(SyncDirection::none); //shouldn't matter, but avoids hitting some asserts

    }, callback); //throw X
}
}


void fff::renameItems(const std::vector<FileSystemObject*>& selectionL,
                      const std::span<const Zstring> newNamesL,
                      const std::vector<FileSystemObject*>& selectionR,
                      const std::span<const Zstring> newNamesR,
                      const std::vector<std::pair<BaseFolderPair*, SyncDirectionConfig>>& directCfgs,
                      ProcessCallback& callback /*throw X*/) //throw X
{
    assert(std::all_of(selectionL.begin(), selectionL.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty<SelectSide::left >(); }));
    assert(std::all_of(selectionR.begin(), selectionR.end(), [](const FileSystemObject* fsObj) { return !fsObj->isEmpty<SelectSide::right>(); }));

    const int itemCount = static_cast<int>(selectionL.size() + selectionR.size());
    callback.initNewPhase(itemCount, 0, ProcessPhase::none); //throw X
    //------------------------------------------------------------------------------

    //build up mapping from base directory to corresponding direction config
    std::unordered_map<const BaseFolderPair*, SyncDirectionConfig> baseFolderCfgs;
    for (const auto& [baseFolder, dirCfg] : directCfgs)
        baseFolderCfgs[baseFolder] = dirCfg;

    renameItemsOneSide<SelectSide::left >(selectionL, newNamesL, baseFolderCfgs, callback); //throw X
    renameItemsOneSide<SelectSide::right>(selectionR, newNamesR, baseFolderCfgs, callback); //
}

//############################################################################################################

void fff::deleteListOfFiles(const std::vector<Zstring>& filesToDeletePaths,
                            std::vector<Zstring>& deletedPaths,
                            bool moveToRecycler,
                            bool& warnRecyclerMissing,
                            ProcessCallback& cb /*throw X*/) //throw X
{
    assert(deletedPaths.empty());

    cb.initNewPhase(static_cast<int>(filesToDeletePaths.size()), 0 /*bytesTotal*/, ProcessPhase::none); //throw X

    bool recyclerMissingReportOnce = false;

    for (const Zstring& filePath : filesToDeletePaths)
        tryReportingError([&]
    {
        const AbstractPath cfgPath = createItemPathNative(filePath);
        ItemStatReporter statReporter(1, 0, cb);

        if (moveToRecycler)
            try
            {
                reportInfo(replaceCpy(_("Moving file %x to the recycle bin"), L"%x", fmtPath(AFS::getDisplayPath(cfgPath))), cb); //throw X
                AFS::moveToRecycleBinIfExists(cfgPath); //throw FileError, RecycleBinUnavailable
            }
            catch (const RecycleBinUnavailable& e)
            {
                if (!recyclerMissingReportOnce)
                {
                    recyclerMissingReportOnce = true;
                    cb.reportWarning(e.toString() + L"\n\n" + _("Ignore and delete permanently each time recycle bin is unavailable?"), warnRecyclerMissing); //throw X
                }
                cb.logMessage(replaceCpy(_("Deleting file %x"), L"%x", fmtPath(AFS::getDisplayPath(cfgPath))) +
                              L" [" + _("Recycle bin unavailable") + L']', PhaseCallback::MsgType::warning); //throw X
                AFS::removeFileIfExists(cfgPath); //throw FileError
            }
        else
        {
            reportInfo(replaceCpy(_("Deleting file %x"), L"%x", fmtPath(AFS::getDisplayPath(cfgPath))), cb); //throw X
            AFS::removeFileIfExists(cfgPath); //throw FileError
        }

        statReporter.reportDelta(1, 0);
        deletedPaths.push_back(filePath);
    }, cb); //throw X
}

//############################################################################################################

TempFileBuffer::~TempFileBuffer()
{
    if (!tempFolderPath_.empty())
        try
        {
            removeDirectoryPlainRecursion(tempFolderPath_); //throw FileError
        }
        catch (const FileError& e) { logExtraError(e.toString()); }
}


void TempFileBuffer::createTempFolderPath() //throw FileError
{
    if (tempFolderPath_.empty())
    {
        //generate random temp folder path e.g. C:\Users\Zenju\AppData\Local\Temp\FFS-068b2e88
        const uint32_t shortGuid = getCrc32(generateGUID()); //no need for full-blown (pseudo-)random numbers for this one-time invocation

        const Zstring& tempPathTmp = appendPath(getTempFolderPath(), //throw FileError
                                                Zstr("FFS-") + printNumber<Zstring>(Zstr("%08x"), static_cast<unsigned int>(shortGuid)));

        createDirectoryIfMissingRecursion(tempPathTmp); //throw FileError

        tempFolderPath_ = tempPathTmp;
    }
}


Zstring TempFileBuffer::getAndCreateFolderPath() //throw FileError
{
    createTempFolderPath(); //throw FileError
    return tempFolderPath_;
}


//returns empty if not available (item not existing, error during copy)
Zstring TempFileBuffer::getTempPath(const FileDescriptor& descr) const
{
    auto it = tempFilePaths_.find(descr);
    if (it != tempFilePaths_.end())
        return it->second;
    return Zstring();
}


void TempFileBuffer::createTempFiles(const std::set<FileDescriptor>& workLoad, ProcessCallback& callback /*throw X*/) //throw X
{
    const int itemTotal = static_cast<int>(workLoad.size());
    int64_t bytesTotal = 0;

    for (const FileDescriptor& descr : workLoad)
        bytesTotal += descr.attr.fileSize;

    callback.initNewPhase(itemTotal, bytesTotal, ProcessPhase::none); //throw X
    //------------------------------------------------------------------------------

    const std::wstring errMsg = tryReportingError([&]
    {
        createTempFolderPath(); //throw FileError
    }, callback); //throw X
    if (!errMsg.empty()) return;

    for (const FileDescriptor& descr : workLoad)
    {
        assert(!tempFilePaths_.contains(descr)); //ensure correct stats, NO overwrite-copy => caller-contract!

        MemoryStreamOut cookie; //create hash to distinguish different versions and file locations
        writeNumber   (cookie, descr.attr.modTime);
        writeNumber   (cookie, descr.attr.fileSize);
        writeNumber   (cookie, descr.attr.filePrint);
        writeNumber   (cookie, descr.attr.isFollowedSymlink);
        writeContainer(cookie, AFS::getInitPathPhrase(descr.path));

        const uint16_t crc16 = getCrc16(cookie.ref());
        const Zstring descrHash = printNumber<Zstring>(Zstr("%04x"), static_cast<unsigned int>(crc16));

        const Zstring fileName = AFS::getItemName(descr.path);

        auto it = findLast(fileName.begin(), fileName.end(), Zstr('.')); //gracefully handle case of missing "."
        const Zstring tempFileName = Zstring(fileName.begin(), it) + Zstr('~') + descrHash + Zstring(it, fileName.end());

        const Zstring tempFilePath = appendPath(tempFolderPath_, tempFileName);
        const AFS::StreamAttributes sourceAttr{descr.attr.modTime, descr.attr.fileSize, descr.attr.filePrint};

        tryReportingError([&]
        {
            std::wstring statusMsg = replaceCpy(_("Creating file %x"), L"%x", fmtPath(tempFilePath));

            ItemStatReporter statReporter(1, descr.attr.fileSize, callback);
            PercentStatReporter percentReporter(statusMsg, descr.attr.fileSize, statReporter);

            reportInfo(std::move(statusMsg), callback); //throw X

            //already existing: undefined behavior! (e.g. fail/overwrite/auto-rename)
            /*const AFS::FileCopyResult result =*/
            AFS::copyFileTransactional(descr.path, sourceAttr, //throw FileError, ErrorFileLocked, X
                                       createItemPathNative(tempFilePath),
                                       false /*copyFilePermissions*/, true /*transactionalCopy*/, nullptr /*onDeleteTargetFile*/,
                                       [&](int64_t bytesDelta)
            {
                percentReporter.updateDeltaAndStatus(bytesDelta); //throw X
                callback.requestUiUpdate(); //throw X  => not reliably covered by PercentStatReporter::updateDeltaAndStatus()! e.g. during first few seconds: STATUS_PERCENT_DELAY!
            });
            //result.errorModTime? => irrelevant for temp files!
            statReporter.reportDelta(1, 0);

            tempFilePaths_[descr] = tempFilePath;
        }, callback); //throw X
    }
}
