<?php
/**
 * MAIL_MIGRATION (F24330): self-service copy of legacy mailboxes into Outlook - byte-exact
 * RFC822 copy at IMAP protocol level, dedup by Message-ID, source never modified.
 * Architecture, deployment and design rationale: Documentation-develop/MAIL-MIGRATION.md.
 */

namespace IMATHUZH\Qfq\External;

use IMATHUZH\Qfq\Core\Database\Database;
use IMATHUZH\Qfq\Core\Helper\EncryptDecrypt;
use IMATHUZH\Qfq\Core\Helper\Logger;
use IMATHUZH\Qfq\Core\Helper\Path;
use IMATHUZH\Qfq\Core\Imap\OAuth2TokenManager;
use IMATHUZH\Qfq\Core\Store\Store;

use Webklex\PHPIMAP\Client;
use Webklex\PHPIMAP\ClientManager;
use Webklex\PHPIMAP\Connection\Protocols\ImapProtocol;
use Webklex\PHPIMAP\Exceptions\ConnectionFailedException;
use Webklex\PHPIMAP\Exceptions\ImapServerErrorException;
use Webklex\PHPIMAP\Exceptions\RuntimeException as WebklexRuntimeException;

/**
 * Class MailMigration
 * @package qfq
 */
class MailMigration {

    // Logged at worker start (proves which code a deployment runs); bump on copy-behavior changes.
    const ENGINE_VERSION = 9;

    // reconcileAction() results
    const RECONCILE_KEEP = 'keep';
    const RECONCILE_MOVE = 'move';
    const RECONCILE_QUARANTINE = 'quarantine';

    // Special folder roles (resolveSpecialFolders() keys, RFC 6154 special-use)
    const ROLE_SENT = 'sent';
    const ROLE_DRAFTS = 'drafts';
    const ROLE_JUNK = 'junk';
    const ROLE_TRASH = 'trash';
    const ROLE_ARCHIVE = 'archive';

    // Special-use attribute (lowercase) => role
    private const SPECIAL_USE_FLAGS = [
        '\sent' => self::ROLE_SENT,
        '\drafts' => self::ROLE_DRAFTS,
        '\junk' => self::ROLE_JUNK,
        '\trash' => self::ROLE_TRASH,
        '\archive' => self::ROLE_ARCHIVE,
    ];

    // Only flags Exchange accepts on APPEND - any other keyword makes it reject the whole append.
    private const APPEND_FLAG_WHITELIST = ['\Seen', '\Answered', '\Flagged', '\Deleted', '\Draft', '$MDNSent'];

    // Thunderbird built-in tags => \Flagged; never map unknown keywords (NonJunk sits on nearly every mail).
    private const TAG_FLAG_KEYWORDS = ['$label1', '$label2', '$label3', '$label4', '$label5'];

    // Name heuristics (lowercase last segment) => role; fallback when LIST has no special-use attributes.
    private const SPECIAL_NAMES = [
        self::ROLE_SENT => ['sent', 'sent items', 'sent messages', 'sent-mail', 'gesendet', 'gesendete elemente', 'gesendete objekte'],
        self::ROLE_DRAFTS => ['drafts', 'draft', 'entwürfe', 'entwuerfe'],
        self::ROLE_JUNK => ['junk', 'junk e-mail', 'junk email', 'spam'],
        self::ROLE_TRASH => ['trash', 'deleted items', 'deleted messages', 'papierkorb', 'gelöschte elemente', 'geloeschte elemente'],
        self::ROLE_ARCHIVE => ['archive', 'archiv'],
    ];

    /**
     * @var Store
     */
    protected $store = null;

    /**
     * @var Database
     */
    private $db = null;

    private $imapLogLevelMin = null;
    private $imapLogAbsolute = null;
    private $encryptionKey = '';
    private $oauth2ClientId = '';

    private $maxConcurrent = MM_MAX_CONCURRENT_DEFAULT;
    private $chunkSize = MM_CHUNK_SIZE_DEFAULT;
    private $throttleMs = MM_THROTTLE_MS_DEFAULT;
    private $maxMessageBytes = 0;

    // Worker state of the currently processed job
    private $cnt = [];
    private $folderStats = [];
    private $currentFolder = '';
    private $lastError = '';
    private $appendFallbackLogged = [];

    /**
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     * @throws \UserReportException
     */
    public function __construct() {

        mb_internal_encoding("UTF-8");

        $this->store = Store::getInstance();
        $this->store->fillStoreSystemBySql();

        $this->imapLogLevelMin = Logger::nameToLevel($this->store->getVar(SYSTEM_IMAP_LOG_MODE, STORE_SYSTEM));
        $this->imapLogAbsolute = Path::absoluteImapLogFile();
        $this->encryptionKey = $this->store->getVar(SYSTEM_ENCRYPTION_KEY, STORE_SYSTEM, SANITIZE_ALLOW_ALL);
        $this->oauth2ClientId = trim((string)$this->store->getVar(SYSTEM_IMAP_OAUTH2_CLIENT_ID, STORE_SYSTEM));

        $this->db = new Database($this->store->getVar(SYSTEM_DB_INDEX_DATA, STORE_SYSTEM));

        $this->maxConcurrent = self::intConf($this->store->getVar(SYSTEM_MAIL_MIGRATION_MAX_CONCURRENT, STORE_SYSTEM), MM_MAX_CONCURRENT_DEFAULT, 1);
        $this->chunkSize = self::intConf($this->store->getVar(SYSTEM_MAIL_MIGRATION_CHUNK_SIZE, STORE_SYSTEM), MM_CHUNK_SIZE_DEFAULT, 1);
        $this->throttleMs = self::intConf($this->store->getVar(SYSTEM_MAIL_MIGRATION_THROTTLE_MS, STORE_SYSTEM), MM_THROTTLE_MS_DEFAULT, 0);
        $this->maxMessageBytes = self::intConf($this->store->getVar(SYSTEM_MAIL_MIGRATION_MAX_MESSAGE_MB, STORE_SYSTEM), MM_MAX_MESSAGE_MB_DEFAULT, 1) * 1024 * 1024;
    }

    /**
     * Mail count of a target folder for the dry-run listing ('missing' when not selectable).
     *
     * @param ImapProtocol $dstConn
     * @param string $path
     * @return string
     */
    private static function dryRunFolderCount(ImapProtocol $dstConn, string $path): string {
        try {
            $info = $dstConn->examineFolder($path)->validatedData();
            return (int)($info['exists'] ?? 0) . " mails";
        } catch (\Throwable $e) {
            return "missing";
        }
    }

    /**
     * Ext_conf int with default: unset/non-numeric falls back, numeric is clamped to $min.
     *
     * @param mixed $raw
     * @param int $default
     * @param int $min
     * @return int
     */
    public static function intConf($raw, int $default, int $min): int {
        if (!is_numeric($raw)) {
            return $default;
        }
        return max($min, (int)$raw);
    }

    // ---------------------------------------------------------------------------------------
    // Parent daemon
    // ---------------------------------------------------------------------------------------

    /**
     * Never returns: poll loop; concurrency counts the daemon's OWN live children, not DB rows.
     *
     * @return void
     */
    public function daemon(): void {

        $children = []; // jobId => ['proc' => resource]

        $this->logMM("Daemon started (pid " . getmypid() . ", maxConcurrent {$this->maxConcurrent})", LOG_LEVEL_ALL);

        while (true) {
            try {
                $this->reapWorkers($children);
                $this->recoverStaleJobs($children);

                while (count($children) < $this->maxConcurrent && ($jobId = $this->claimNextJob()) !== null) {
                    $this->spawnWorker($jobId, $children);
                }
            } catch (\Throwable $e) {
                // A transient DB hiccup must not kill the daemon - log and try again next cycle.
                $this->logMM("Daemon cycle failed: " . $e->getMessage(), LOG_LEVEL_ERROR);
            }

            sleep(MM_POLL_SLEEP_SECONDS);
        }
    }

    /**
     * Atomically claims the oldest queued job (guarded UPDATE, affected-rows = the lock); null if none.
     *
     * @return int|null
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function claimNextJob(): ?int {

        $row = $this->db->sql("SELECT `id` FROM `" . MM_TABLENAME . "` WHERE `status` = ? ORDER BY `modified` ASC LIMIT 1",
            ROW_EXPECT_0_1, [MM_STATUS_QUEUED]);
        if (empty($row['id'])) {
            return null;
        }

        $affected = $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `status` = ?, `heartbeat` = NOW(),"
            . " `workerPid` = 0, `lastError` = '', `lastMessage` = 'waiting for worker' WHERE `id` = ? AND `status` = ?",
            ROW_REGULAR, [MM_STATUS_RUNNING, $row['id'], MM_STATUS_QUEUED]);

        // 0 rows affected: another daemon raced us - simply try again next cycle.
        return $affected === 1 ? (int)$row['id'] : null;
    }

    /**
     * Spawns the worker shell-less (array command: tracked pid IS the php pid); output -> imap log.
     *
     * @param int $jobId
     * @param array $children
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function spawnWorker(int $jobId, array &$children): void {

        $cmd = [PHP_BINARY, __DIR__ . '/mail-migration.php', '--job=' . $jobId];
        $descriptors = [
            0 => ['file', '/dev/null', 'r'],
            1 => ['file', $this->imapLogAbsolute, 'a'],
            2 => ['file', $this->imapLogAbsolute, 'a'],
        ];

        $proc = proc_open($cmd, $descriptors, $pipes);
        if (!is_resource($proc)) {
            // Give the job back - the daemon retries next cycle.
            $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `status` = ?, `lastMessage` = 'worker start failed' WHERE `id` = ? AND `status` = ?",
                ROW_REGULAR, [MM_STATUS_QUEUED, $jobId, MM_STATUS_RUNNING]);
            $this->logMM("Job $jobId: proc_open failed - requeued", LOG_LEVEL_ERROR);
            return;
        }

        $children[$jobId] = ['proc' => $proc];
        $pid = proc_get_status($proc)['pid'] ?? 0;
        $this->logMM("Job $jobId: worker started (pid $pid)", LOG_LEVEL_ALL);
    }

    /**
     * Reaps finished children; a row still 'running' = worker died mid-flight => 'error' (no auto-requeue).
     *
     * @param array $children
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function reapWorkers(array &$children): void {

        foreach ($children as $jobId => $child) {
            $status = proc_get_status($child['proc']);
            if (!empty($status['running'])) {
                continue;
            }
            proc_close($child['proc']);
            unset($children[$jobId]);

            $exitCode = $status['exitcode'] ?? -1;
            $affected = $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `status` = ?, `finishedAt` = NOW(), `workerPid` = 0,"
                . " `lastError` = CONCAT('worker exited unexpectedly (exit code ', ?, ') - see imap log') WHERE `id` = ? AND `status` = ?",
                ROW_REGULAR, [MM_STATUS_ERROR, $exitCode, $jobId, MM_STATUS_RUNNING]);

            $this->logMM("Job $jobId: worker finished (exit code $exitCode"
                . ($affected === 1 ? ", died mid-flight => status 'error'" : "") . ")", LOG_LEVEL_ALL);
        }
    }

    /**
     * Requeues 'running' jobs with stale heartbeat + dead pid (reboot/OOM); own children are left to reapWorkers().
     *
     * @param array $children
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function recoverStaleJobs(array $children): void {

        $rows = $this->db->sql("SELECT `id`, `workerPid` FROM `" . MM_TABLENAME . "` WHERE `status` = ?"
            . " AND `heartbeat` < NOW() - INTERVAL " . MM_STALE_MINUTES . " MINUTE",
            ROW_REGULAR, [MM_STATUS_RUNNING]);

        foreach ($rows as $row) {
            $jobId = (int)$row['id'];
            if (isset($children[$jobId])) {
                continue;
            }
            if ((int)$row['workerPid'] > 0 && self::isPidAlive((int)$row['workerPid'])) {
                continue;
            }

            $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `status` = ?, `workerPid` = 0,"
                . " `lastMessage` = 'requeued after worker died' WHERE `id` = ? AND `status` = ?",
                ROW_REGULAR, [MM_STATUS_QUEUED, $jobId, MM_STATUS_RUNNING]);
            $this->logMM("Job $jobId: stale (dead worker) - requeued", LOG_LEVEL_ERROR);
        }
    }

    /**
     * Linux /proc check (no posix extension needed); a recycled pid only delays the stale requeue.
     *
     * @param int $pid
     * @return bool
     */
    public static function isPidAlive(int $pid): bool {
        return $pid > 0 && file_exists("/proc/$pid");
    }

    // ---------------------------------------------------------------------------------------
    // Worker: one job
    // ---------------------------------------------------------------------------------------

    /**
     * Runs one job to completion; $dryRun only connects and prints the folder plan + counts.
     *
     * @param int $jobId
     * @param bool $dryRun
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    public function processJob(int $jobId, bool $dryRun = false): void {

        $this->resetCounters(); // finishJob()/updateProgress() must work even when connect fails

        $job = $this->db->sql("SELECT * FROM `" . MM_TABLENAME . "` WHERE `id` = ?", ROW_EXPECT_0_1, [$jobId]);
        if (empty($job)) {
            echo "Job $jobId not found.\n";
            return;
        }

        if (!$dryRun && !$this->claimForWorker($jobId)) {
            echo "Job $jobId is not claimable (status '" . $job['status'] . "', workerPid " . $job['workerPid'] . ").\n";
            return;
        }
        $this->logMM("Job $jobId: worker claimed (engine v" . self::ENGINE_VERSION . ", pid " . getmypid() . ")", LOG_LEVEL_ALL);

        // A raw mail peaks at ~3 string copies in memory (body, concat, IMAP literal).
        ini_set('memory_limit', '768M');

        try {
            $src = $this->connectSource($job);
            $dst = $this->connectTarget($job);
        } catch (\Throwable $e) {
            $msg = "Connect failed: " . $e->getMessage();
            $this->logMM("Job $jobId: $msg", LOG_LEVEL_ERROR);
            if ($dryRun) {
                echo $msg . "\n";
            } else {
                $this->finishJob($jobId, MM_STATUS_ERROR, 'connect failed', $msg);
            }
            return;
        }

        try {
            $this->runMigration($jobId, $job, $src, $dst, $dryRun);
        } catch (\Throwable $e) {
            $msg = "Migration aborted: " . $e->getMessage();
            $this->logMM("Job $jobId: $msg", LOG_LEVEL_ERROR);
            if ($dryRun) {
                echo $msg . "\n";
            } else {
                $this->finishJob($jobId, MM_STATUS_ERROR, 'migration aborted', $msg);
            }
        }
    }

    /**
     * The actual per-job flow: folder plan, totals, target folders, reconciliation, copy loop.
     *
     * @param int $jobId
     * @param array $job
     * @param Client $src
     * @param Client $dst
     * @param bool $dryRun
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function runMigration(int $jobId, array $job, Client $src, Client $dst, bool $dryRun): void {

        /** @var ImapProtocol $srcConn */
        $srcConn = $src->getConnection();
        /** @var ImapProtocol $dstConn */
        $dstConn = $dst->getConnection();

        // Protocol-level LIST: Folder::parseAttributes() would drop the special-use attributes.
        $srcList = $srcConn->folders('', '*')->validatedData();
        $dstList = $dstConn->folders('', '*')->validatedData();

        $plan = self::buildFolderPlan($srcList, $dstList);
        $dstDelim = self::listDelimiter($dstList);

        // Fix the "X of Y" denominator upfront: one read-only EXAMINE per source folder.
        $mailsTotal = 0;
        foreach ($plan as &$entry) {
            $info = $srcConn->examineFolder($entry['src'])->validatedData();
            $entry['total'] = (int)($info['exists'] ?? 0);
            $mailsTotal += $entry['total'];
        }
        unset($entry);

        if ($dryRun) {
            echo "Dry run job $jobId: " . $job['srcUser'] . "@" . $job['srcHost'] . " -> " . $job['dstUser'] . "@" . $job['dstHost'] . "\n";
            foreach ($plan as $entry) {
                echo sprintf("  %-45s %6d mails -> %-45s %s\n", $entry['src'], $entry['total'],
                    $entry['dst'], self::dryRunFolderCount($dstConn, $entry['dst']));
            }
            echo "Total: " . count($plan) . " folders, $mailsTotal mails."
                . " Inbox reconciliation: " . (!empty($job['inboxReconcile']) ? 'on' : 'off') . "\n";
            return;
        }

        $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `mailsTotal` = ?, `foldersTotal` = ?, `heartbeat` = NOW() WHERE `id` = ?",
            ROW_REGULAR, [$mailsTotal, count($plan), $jobId]);

        // Create missing target folders first, parents before children.
        $reconcile = !empty($job['inboxReconcile']);
        $quarantinePath = MM_QUARANTINE_FOLDER . $dstDelim . 'INBOX';
        $failedFolders = $this->createTargetFolders($jobId, $dstConn, $plan, $dstList, $dstDelim, $reconcile ? $quarantinePath : null);

        // Reconcile BEFORE the copy: MOVEd inbox copies are then found by the per-folder dedup.
        if ($reconcile) {
            $srcToDst = [];
            foreach ($plan as $entry) {
                $srcToDst[$entry['src']] = $entry['dst'];
            }
            $this->reconcileInbox($jobId, $srcConn, $dstConn, $plan, $srcToDst, $failedFolders, $quarantinePath);
        }

        // Copy loop with per-folder reconnect retry; dedup makes the re-entered folder a delta.
        foreach ($plan as $entry) {
            if (isset($failedFolders[$entry['dst']])) {
                // Still counts as processed - 'folders x/y' must be able to reach y.
                $this->cnt['mailsFailed'] += $entry['total'];
                $this->cnt['foldersDone']++;
                $this->addFolderStats($entry['src'], $entry['dst'], $entry['total'], 0, 0, $entry['total']);
                $this->updateProgress($jobId);
                continue;
            }

            $attempt = 0;
            $cntSnapshot = $this->cnt;
            $statsSnapshot = $this->folderStats;
            while (true) {
                try {
                    $this->migrateFolder($jobId, $srcConn, $dstConn, $entry['src'], $entry['dst']);
                    break;
                } catch (\Throwable $e) {
                    $attempt++;
                    $this->logMM("Job $jobId: folder '" . $entry['src'] . "' attempt $attempt failed: " . $e->getMessage(), LOG_LEVEL_ERROR);
                    if ($attempt >= MM_RECONNECT_MAX) {
                        throw $e;
                    }
                    // Roll the folder's counters back - the retry re-walks it from scratch.
                    $this->cnt = $cntSnapshot;
                    $this->folderStats = $statsSnapshot;
                    sleep(10 * $attempt);
                    // Reconnect both sides; the target gets a fresh access token.
                    $src = $this->connectSource($job);
                    $dst = $this->connectTarget($job);
                    $srcConn = $src->getConnection();
                    $dstConn = $dst->getConnection();
                }
            }

            $this->cnt['foldersDone']++;
            $this->updateProgress($jobId);
        }

        $summary = sprintf("copied %d, skipped %d, failed %d of %d mails in %d folders",
            $this->cnt['mailsCopied'], $this->cnt['mailsSkipped'], $this->cnt['mailsFailed'], $mailsTotal, count($plan));
        if ($reconcile) {
            $summary .= sprintf(" - inbox: %d moved, %d quarantined (review '%s')",
                $this->cnt['mailsMoved'], $this->cnt['mailsQuarantined'], $quarantinePath);
        }

        $this->currentFolder = '';
        $this->finishJob($jobId, MM_STATUS_DONE, $summary, $this->lastError);
        $this->logMM("Job $jobId: done - $summary", LOG_LEVEL_ALL);
    }

    /**
     * Worker-side claim + counter reset; manual CLI runs may claim from any non-running state.
     *
     * @param int $jobId
     * @return bool
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function claimForWorker(int $jobId): bool {

        $reset = "`workerPid` = ?, `startedAt` = NOW(), `finishedAt` = NULL, `heartbeat` = NOW(),"
            . " `lastError` = '', `lastMessage` = 'migration started', `currentFolder` = '', `folderStats` = '',"
            . " `foldersDone` = 0, `mailsCopied` = 0, `mailsSkipped` = 0, `mailsFailed` = 0,"
            . " `mailsMoved` = 0, `mailsQuarantined` = 0, `bytesCopied` = 0";

        $affected = $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET $reset WHERE `id` = ? AND `status` = ? AND `workerPid` = 0",
            ROW_REGULAR, [getmypid(), $jobId, MM_STATUS_RUNNING]);
        if ($affected === 1) {
            return true;
        }

        // Manual CLI run: claim from a non-running state.
        $affected = $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `status` = ?, $reset"
            . " WHERE `id` = ? AND `status` IN (?,?,?,?)",
            ROW_REGULAR, [MM_STATUS_RUNNING, getmypid(), $jobId,
                MM_STATUS_AUTHORIZED, MM_STATUS_QUEUED, MM_STATUS_DONE, MM_STATUS_ERROR]);

        return $affected === 1;
    }

    /**
     * @param array $job
     * @return Client
     * @throws \Throwable
     */
    private function connectSource(array $job): Client {
        $password = EncryptDecrypt::getPlaintext($job['srcPasswordEnc'] ?? '', $this->encryptionKey);
        return self::buildImapClient($job['srcHost'], $job['srcUser'], $password, null);
    }

    /**
     * Target auth: fresh OAuth2 access token as password (refresh + flock inside OAuth2TokenManager).
     *
     * @param array $job
     * @return Client
     * @throws \Throwable
     */
    private function connectTarget(array $job): Client {
        $tokenManager = new OAuth2TokenManager($this->oauth2ClientId);
        $accessToken = $tokenManager->getAccessToken(self::tokenPathForJob((int)$job['id']));
        return self::buildImapClient($job['dstHost'], $job['dstUser'], $accessToken, IMAP_AUTHENTICATION_OAUTH);
    }

    /**
     * Connects ONE account (own ClientManager: src/dst often share the address and would collide).
     *
     * @param string $hostPort - 'host[:port]' (EmailSync::parseImapHost semantics)
     * @param string $username
     * @param string $password
     * @param string|null $authentication - null (classic) | IMAP_AUTHENTICATION_OAUTH
     * @param int|null $timeoutSeconds - override the default read timeout (probes use a short one)
     * @return Client
     * @throws \Throwable
     */
    public static function buildImapClient(string $hostPort, string $username, string $password, ?string $authentication, ?int $timeoutSeconds = null): Client {

        list($host, $port, $encryption) = EmailSync::parseImapHost($hostPort);

        $config = EmailSync::generateImapConfigArray([[
            IMAP_CRED_HOST => $host,
            IMAP_CRED_USERNAME => $username,
            IMAP_CRED_PASSWORD => $password,
            IMAP_CRED_AUTHENTICATION => $authentication,
            IMAP_CRED_REAUTH_NEEDED => false,
            IMAP_CRED_TOKEN_PATH => '',
            IMAP_CRED_FOLDER => '',
            IMAP_CRED_PORT => $port,
            IMAP_CRED_ENCRYPTION => $encryption,
            IMAP_CRED_VALIDATE_CERT => true,
        ]]);

        $accountKey = EmailSync::imapAccountKey($username);
        if ($timeoutSeconds !== null) {
            $config['accounts'][$accountKey]['timeout'] = $timeoutSeconds;
        }

        $client = (new ClientManager($config))->account($accountKey);
        $client->connect();

        return $client;
    }

    /**
     * Creates missing target folders, parents first (RFC 3501 guarantees no auto-creation of parents).
     *
     * @param int $jobId
     * @param ImapProtocol $dstConn
     * @param array $plan
     * @param array $dstList - protocol LIST result of the target
     * @param string $dstDelim
     * @param string|null $quarantinePath - null = reconciliation off
     * @return array - set of destination paths that could not be created
     * @throws \CodeException
     * @throws \UserFormException
     */
    private function createTargetFolders(int $jobId, ImapProtocol $dstConn, array $plan, array $dstList, string $dstDelim, ?string $quarantinePath): array {

        $existing = [];
        foreach (array_keys($dstList) as $path) {
            $existing[$path] = true;
        }

        $wanted = [];
        foreach ($plan as $entry) {
            $wanted[] = $entry['dst'];
        }
        if ($quarantinePath !== null) {
            $wanted[] = $quarantinePath;
        }

        $failed = [];
        foreach ($wanted as $path) {
            $prefix = '';
            foreach (explode($dstDelim, $path) as $part) {
                $prefix = ($prefix === '') ? $part : $prefix . $dstDelim . $part;
                if (isset($existing[$prefix]) || strcasecmp($prefix, 'INBOX') === 0) {
                    continue;
                }
                try {
                    $dstConn->createFolder($prefix);
                    $existing[$prefix] = true;
                    try {
                        // Best effort: some clients only show subscribed folders.
                        $dstConn->subscribeFolder($prefix);
                    } catch (\Throwable $ignored) {
                    }
                    $this->logMM("Job $jobId: created target folder '$prefix'", LOG_LEVEL_ALL);
                } catch (\Throwable $e) {
                    if ($this->isConnectionError($e)) {
                        throw $e;
                    }
                    // 'already exists' IS success - servers normalize names (case/blanks), APPEND resolves them.
                    if (preg_match('/already ?exists/i', $e->getMessage()) === 1) {
                        $existing[$prefix] = true;
                        continue;
                    }
                    $failed[$path] = true;
                    $this->lastError = "Cannot create target folder '$prefix': " . $e->getMessage();
                    $this->logMM("Job $jobId: " . $this->lastError, LOG_LEVEL_ERROR);
                    break;
                }
            }
        }

        return $failed;
    }

    /**
     * Inbox reconciliation, source = ground truth: filed source mails are MOVEd to their folder,
     * source-inbox mails stay, unknown mails are quarantined (see MAIL-MIGRATION.md).
     *
     * @param int $jobId
     * @param ImapProtocol $srcConn
     * @param ImapProtocol $dstConn
     * @param array $plan
     * @param array $srcToDst - source path => target path
     * @param array $failedFolders - set of target paths that could not be created
     * @param string $quarantinePath
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function reconcileInbox(int $jobId, ImapProtocol $srcConn, ImapProtocol $dstConn, array $plan,
                                    array  $srcToDst, array $failedFolders, string $quarantinePath): void {

        // Without MOVE webklex falls back to COPY + \Deleted + folder-wide EXPUNGE - skip instead.
        try {
            $caps = array_map('strtoupper', (array)$dstConn->getCapabilities()->validatedData());
        } catch (\Throwable $e) {
            $caps = [];
        }
        if (!in_array('MOVE', $caps)) {
            $this->logMM("Job $jobId: target lacks the IMAP MOVE capability - inbox reconciliation skipped", LOG_LEVEL_ERROR);
            return;
        }

        $this->currentFolder = 'INBOX (reconcile)';
        $this->setLastMessage($jobId, 'building source index for inbox reconciliation');

        // Pass 1: source map dedupKey => folder; INBOX first + first-wins => inbox duplicates resolve to 'keep'.
        $srcMap = [];
        foreach ($plan as $entry) {
            $srcConn->examineFolder($entry['src']); // read-only is enough for the header walk
            $uids = $srcConn->search(['ALL'])->validatedData();
            foreach (array_chunk($uids, $this->chunkSize) as $chunk) {
                $headers = $srcConn->headers($chunk, 'RFC822')->validatedData();
                foreach ($headers as $rawHeader) {
                    $key = self::dedupKey($rawHeader);
                    if (!isset($srcMap[$key])) {
                        $srcMap[$key] = $entry['src'];
                    }
                }
                unset($headers);
                gc_collect_cycles();
            }
            $this->heartbeat($jobId);
        }

        $this->setLastMessage($jobId, 'reconciling target inbox');

        // Pass 2: walk the target INBOX. SELECT (read-write) - MOVE modifies the folder.
        $dstConn->selectFolder('INBOX');
        $uids = $dstConn->search(['ALL'])->validatedData();
        $minAgeSeconds = MM_RECONCILE_MIN_AGE_MINUTES * 60;

        foreach (array_chunk($uids, $this->chunkSize) as $chunk) {
            $headers = $dstConn->headers($chunk, 'RFC822')->validatedData();

            foreach ($chunk as $uid) {
                if (!isset($headers[$uid])) {
                    continue; // vanished meanwhile (user is working in the mailbox)
                }
                // Age from Date: header (unparseable = fresh = keep); INTERNALDATE is unusable, see appendDateFromHeader().
                $age = self::headerDateAge($headers[$uid], time());
                list($action, $srcFolder) = self::reconcileAction(self::dedupKey($headers[$uid]), $srcMap, 'INBOX', $age, $minAgeSeconds);

                $target = '';
                if ($action === self::RECONCILE_MOVE) {
                    $target = $srcToDst[$srcFolder] ?? '';
                    // Unmapped or failed target folder, or mapped back to INBOX: leave the mail.
                    if ($target === '' || strcasecmp($target, 'INBOX') === 0 || isset($failedFolders[$target])) {
                        continue;
                    }
                } elseif ($action === self::RECONCILE_QUARANTINE) {
                    if (isset($failedFolders[$quarantinePath])) {
                        continue;
                    }
                    $target = $quarantinePath;
                } else {
                    continue;
                }

                try {
                    $dstConn->moveMessage($target, (int)$uid);
                    $this->cnt[$action === self::RECONCILE_MOVE ? 'mailsMoved' : 'mailsQuarantined']++;
                } catch (\Throwable $e) {
                    if ($this->isConnectionError($e)) {
                        throw $e;
                    }
                    $this->cnt['mailsFailed']++;
                    $this->lastError = "Reconcile MOVE uid $uid -> '$target' failed: " . $e->getMessage();
                    $this->logMM("Job $jobId: " . $this->lastError, LOG_LEVEL_ERROR);
                }
            }

            $this->updateProgress($jobId);
            gc_collect_cycles();
            if ($this->throttleMs > 0) {
                usleep($this->throttleMs * 1000);
            }
        }
    }

    /**
     * Copies one folder: raw RFC822 APPENDs, deduped by Message-ID against the target (ground truth).
     *
     * @param int $jobId
     * @param ImapProtocol $srcConn
     * @param ImapProtocol $dstConn
     * @param string $srcPath
     * @param string $dstPath
     * @return void
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function migrateFolder(int $jobId, ImapProtocol $srcConn, ImapProtocol $dstConn, string $srcPath, string $dstPath): void {

        $this->currentFolder = $srcPath;
        $this->setLastMessage($jobId, "migrating '$srcPath'");

        // Target dedup set, key => [uid, seen, flagged]; SELECT (not EXAMINE): the mirror STOREs here.
        $have = [];
        $dstConn->selectFolder($dstPath);
        $dstUids = $dstConn->search(['ALL'])->validatedData();
        foreach (array_chunk($dstUids, $this->chunkSize) as $chunk) {
            $dstHeaders = $dstConn->headers($chunk, 'RFC822')->validatedData();
            $dstFlags = $dstConn->flags($chunk)->validatedData();
            foreach ($dstHeaders as $dstUid => $rawHeader) {
                $have[self::dedupKey($rawHeader)] = [
                    'uid' => (int)$dstUid,
                    'seen' => in_array('\Seen', (array)($dstFlags[$dstUid] ?? [])),
                    'flagged' => in_array('\Flagged', (array)($dstFlags[$dstUid] ?? [])),
                ];
            }
            gc_collect_cycles();
        }
        unset($dstUids);
        $flagsSynced = 0;

        // Source walk. SELECT (read-write): the compensating '-FLAGS \Seen' restore needs it.
        $srcConn->selectFolder($srcPath);
        $uids = $srcConn->search(['ALL'])->validatedData();

        $fs = ['src' => $srcPath, 'dst' => $dstPath, 'total' => count($uids), 'copied' => 0, 'skipped' => 0, 'failed' => 0];

        foreach (array_chunk($uids, $this->chunkSize) as $chunk) {
            $headers = $srcConn->headers($chunk, 'RFC822')->validatedData();
            $flags = $srcConn->flags($chunk)->validatedData();
            $sizes = $srcConn->sizes($chunk)->validatedData();

            foreach ($chunk as $uid) {
                if (!isset($headers[$uid])) {
                    // Vanished between SEARCH and FETCH (mailbox in use): not copyable right now.
                    $this->cnt['mailsFailed']++;
                    $fs['failed']++;
                    continue;
                }

                $key = self::dedupKey($headers[$uid]);
                $mailFlags = (array)($flags[$uid] ?? []);
                $wasUnseen = !in_array('\Seen', $mailFlags);

                if (isset($have[$key])) {
                    // Dedup skip + mirror read/flag state onto the existing target copy (additive only).
                    $addFlags = [];
                    if (!$wasUnseen && !$have[$key]['seen']) {
                        $addFlags[] = '\Seen';
                    }
                    if (!$have[$key]['flagged'] && (in_array('\Flagged', $mailFlags) || self::hasTagKeyword($mailFlags))) {
                        $addFlags[] = '\Flagged';
                    }
                    if ($addFlags !== [] && $have[$key]['uid'] > 0) {
                        try {
                            $dstConn->store($addFlags, $have[$key]['uid'], null, '+', true);
                            $have[$key]['seen'] = $have[$key]['seen'] || in_array('\Seen', $addFlags);
                            $have[$key]['flagged'] = $have[$key]['flagged'] || in_array('\Flagged', $addFlags);
                            $flagsSynced++;
                        } catch (\Throwable $e) {
                            if ($this->isConnectionError($e)) {
                                $this->addFolderStats($fs['src'], $fs['dst'], $fs['total'], $fs['copied'], $fs['skipped'], $fs['failed'], true);
                                throw $e;
                            }
                            $this->logMM("Job $jobId: flag sync failed (target uid " . $have[$key]['uid'] . " in '$dstPath'): " . $e->getMessage(), LOG_LEVEL_ERROR);
                        }
                    }
                    $this->cnt['mailsSkipped']++;
                    $fs['skipped']++;
                    continue;
                }

                $size = (int)($sizes[$uid] ?? 0);
                if ($size > $this->maxMessageBytes) {
                    // Never pulled into memory - Office365 would reject the APPEND anyway.
                    $this->cnt['mailsFailed']++;
                    $fs['failed']++;
                    $this->lastError = "Mail too large (uid $uid in '$srcPath', " . round($size / 1048576) . " MB) - copy manually";
                    $this->logMM("Job $jobId: " . $this->lastError, LOG_LEVEL_ERROR);
                    continue;
                }

                try {
                    // RFC822.HEADER + RFC822.TEXT = the unparsed original; the TEXT fetch sets \Seen -> restore.
                    $body = $srcConn->content([$uid], 'RFC822')->validatedData()[$uid] ?? null;
                    if ($wasUnseen) {
                        $srcConn->store(['\Seen'], (int)$uid, null, '-', true);
                    }
                    if ($body === null) {
                        $this->cnt['mailsFailed']++;
                        $fs['failed']++;
                        continue;
                    }

                    $raw = $headers[$uid] . $body;
                    unset($body);
                    // Empty flag list -> null (Exchange may reject '()'); date from the Date: header, see appendDateFromHeader().
                    $appendFlags = self::appendFlags($mailFlags);
                    $this->appendWithFallback($dstConn, $dstPath, $raw,
                        $appendFlags === [] ? null : $appendFlags, self::appendDateFromHeader($headers[$uid]), $jobId, $srcPath, $uid);

                    $this->cnt['mailsCopied']++;
                    $this->cnt['bytesCopied'] += strlen($raw);
                    $fs['copied']++;
                    // Collapses within-folder duplicate Message-IDs; uid 0 = just appended, mirror skips it.
                    $have[$key] = ['uid' => 0, 'seen' => !$wasUnseen, 'flagged' => true];
                    unset($raw);
                } catch (\Throwable $e) {
                    if ($this->isConnectionError($e)) {
                        // Persist the per-folder stats gathered so far, then retry via reconnect.
                        $this->addFolderStats($fs['src'], $fs['dst'], $fs['total'], $fs['copied'], $fs['skipped'], $fs['failed'], true);
                        throw $e;
                    }
                    $this->cnt['mailsFailed']++;
                    $fs['failed']++;
                    $this->lastError = "APPEND failed (uid $uid in '$srcPath'): " . $e->getMessage();
                    $this->logMM("Job $jobId: " . $this->lastError, LOG_LEVEL_ERROR);
                }
            }

            $this->addFolderStats($fs['src'], $fs['dst'], $fs['total'], $fs['copied'], $fs['skipped'], $fs['failed'], true);
            $this->updateProgress($jobId);
            gc_collect_cycles();
            if ($this->throttleMs > 0) {
                usleep($this->throttleMs * 1000);
            }
        }

        // Deliberate final call: gives a zero-mail folder its folderStats entry (replaceLast dedups the rest).
        $this->addFolderStats($fs['src'], $fs['dst'], $fs['total'], $fs['copied'], $fs['skipped'], $fs['failed'], true);

        if ($flagsSynced > 0) {
            $this->logMM("Job $jobId: folder '$srcPath': mirrored read/flag state onto $flagsSynced existing target mails", LOG_LEVEL_ALL);
        }
    }

    // ---------------------------------------------------------------------------------------
    // Progress / job row helpers
    // ---------------------------------------------------------------------------------------

    private function resetCounters(): void {
        $this->cnt = ['foldersDone' => 0, 'mailsCopied' => 0, 'mailsSkipped' => 0, 'mailsFailed' => 0,
            'mailsMoved' => 0, 'mailsQuarantined' => 0, 'bytesCopied' => 0];
        $this->folderStats = [];
        $this->currentFolder = '';
        $this->lastError = '';
        $this->appendFallbackLogged = [];
    }

    /**
     * APPEND with a fallback ladder on 'Command Argument Error': retry without flags, then without date
     * (logged once per job/argument - pinpoints the offending argument; the dropped metadata is the price).
     *
     * @param ImapProtocol $dstConn
     * @param string $dstPath
     * @param string $raw
     * @param array|null $flags
     * @param string|null $date
     * @param int $jobId
     * @param string $srcPath
     * @param int|string $uid
     * @return void
     * @throws \Throwable - the last attempt's exception when every level fails
     */
    private function appendWithFallback(ImapProtocol $dstConn, string $dstPath, string $raw, ?array $flags, ?string $date, int $jobId, string $srcPath, $uid): void {

        $wantFlagged = $flags !== null && in_array('\Flagged', $flags);

        try {
            $response = $dstConn->appendMessage($dstPath, $raw, $flags, $date);
            $this->storeFlaggedAfterAppend($dstConn, $response, $wantFlagged);
            return;
        } catch (ImapServerErrorException $e) {
            if ($this->isConnectionError($e) || stripos($e->getMessage(), 'Command Argument Error') === false
                || ($flags === null && $date === null)) {
                throw $e;
            }
        }

        if ($flags !== null) {
            try {
                $response = $dstConn->appendMessage($dstPath, $raw, null, $date);
                $this->storeFlaggedAfterAppend($dstConn, $response, $wantFlagged);
                $this->logAppendFallback($jobId, 'flags', $srcPath, $uid);
                return;
            } catch (ImapServerErrorException $e) {
                if ($this->isConnectionError($e) || $date === null) {
                    throw $e;
                }
            }
        }

        $response = $dstConn->appendMessage($dstPath, $raw, null, null);
        $this->storeFlaggedAfterAppend($dstConn, $response, $wantFlagged);
        $this->logAppendFallback($jobId, 'date', $srcPath, $uid);
    }

    /**
     * Re-STOREs \Flagged on the appended mail (uid via APPENDUID, best effort) - Office365 forgets
     * \Flagged set via APPEND for non-IMAP protocols (imapsync FAQ.Office365); STORE sticks everywhere.
     *
     * @param ImapProtocol $dstConn
     * @param mixed $response - appendMessage() Response
     * @param bool $wantFlagged
     * @return void
     */
    private function storeFlaggedAfterAppend(ImapProtocol $dstConn, $response, bool $wantFlagged): void {
        if (!$wantFlagged) {
            return;
        }
        $uid = self::appendUidFromTokens($response->data());
        if ($uid > 0) {
            try {
                $dstConn->store(['\Flagged'], $uid, null, '+', true);
            } catch (\Throwable $ignored) {
                // Best effort - the flag is already set IMAP-side via APPEND.
            }
        }
    }

    /**
     * Uid from the UIDPLUS APPENDUID response code ('OK [APPENDUID <validity> <uid>] ...'); 0 when absent.
     *
     * @param mixed $data - Response::data() token lines (nested arrays/strings)
     * @return int
     */
    public static function appendUidFromTokens($data): int {
        $strings = [];
        $tokenLines = (array)$data; // array_walk_recursive needs a by-reference variable
        array_walk_recursive($tokenLines, function ($value) use (&$strings) {
            if (is_string($value)) {
                $strings[] = $value;
            }
        });
        if (preg_match('/\[APPENDUID (\d+) (\d+)\]/i', implode(' ', $strings), $matches) === 1) {
            return (int)$matches[2];
        }
        return 0;
    }

    /**
     * @param int $jobId
     * @param string $arg
     * @param string $srcPath
     * @param int|string $uid
     */
    private function logAppendFallback(int $jobId, string $arg, string $srcPath, $uid): void {
        if (isset($this->appendFallbackLogged[$arg])) {
            return;
        }
        $this->appendFallbackLogged[$arg] = true;
        $this->logMM("Job $jobId: APPEND succeeded only after dropping the '$arg' argument"
            . " (first occurrence: uid $uid in '$srcPath') - the target rejects it, this metadata is not preserved", LOG_LEVEL_ERROR);
    }

    /**
     * Adds or ($replaceLast) updates the folderStats entry of the current folder.
     */
    private function addFolderStats(string $src, string $dst, int $total, int $copied, int $skipped, int $failed, bool $replaceLast = false): void {
        $entry = ['src' => $src, 'dst' => $dst, 'total' => $total, 'copied' => $copied, 'skipped' => $skipped, 'failed' => $failed];
        $last = count($this->folderStats) - 1;
        if ($replaceLast && $last >= 0 && $this->folderStats[$last]['src'] === $src) {
            $this->folderStats[$last] = $entry;
        } else {
            $this->folderStats[] = $entry;
        }
    }

    /**
     * One UPDATE per chunk: counters + current folder + per-folder JSON + heartbeat.
     *
     * @param int $jobId
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function updateProgress(int $jobId): void {
        $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `foldersDone` = ?, `mailsCopied` = ?, `mailsSkipped` = ?,"
            . " `mailsFailed` = ?, `mailsMoved` = ?, `mailsQuarantined` = ?, `bytesCopied` = ?, `currentFolder` = ?,"
            . " `folderStats` = ?, `lastError` = ?, `heartbeat` = NOW() WHERE `id` = ?",
            ROW_REGULAR, [$this->cnt['foldersDone'], $this->cnt['mailsCopied'], $this->cnt['mailsSkipped'],
                $this->cnt['mailsFailed'], $this->cnt['mailsMoved'], $this->cnt['mailsQuarantined'],
                $this->cnt['bytesCopied'], $this->currentFolder,
                json_encode($this->folderStats, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
                mb_substr($this->lastError, 0, 2000), $jobId]);
    }

    /**
     * @param int $jobId
     * @param string $message
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function setLastMessage(int $jobId, string $message): void {
        $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `lastMessage` = ?, `currentFolder` = ?, `heartbeat` = NOW() WHERE `id` = ?",
            ROW_REGULAR, [mb_substr($message, 0, 512), $this->currentFolder, $jobId]);
    }

    /**
     * @param int $jobId
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function heartbeat(int $jobId): void {
        $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `heartbeat` = NOW() WHERE `id` = ?", ROW_REGULAR, [$jobId]);
    }

    /**
     * @param int $jobId
     * @param string $status
     * @param string $message
     * @param string $error
     * @throws \CodeException
     * @throws \DbException
     * @throws \UserFormException
     */
    private function finishJob(int $jobId, string $status, string $message, string $error = ''): void {
        $this->updateProgress($jobId);
        $this->db->sql("UPDATE `" . MM_TABLENAME . "` SET `status` = ?, `lastMessage` = ?, `lastError` = ?,"
            . " `finishedAt` = NOW(), `workerPid` = 0, `currentFolder` = '', `heartbeat` = NOW() WHERE `id` = ?",
            ROW_REGULAR, [$status, mb_substr($message, 0, 512), mb_substr($error, 0, 2000), $jobId]);
    }

    /**
     * Connection error (=> reconnect retry) vs per-message error (=> count & skip): classified by
     * exception TYPE + transport-message list (webklex reuses RuntimeException for non-transport cases).
     *
     * @param \Throwable $e
     * @return bool
     */
    private function isConnectionError(\Throwable $e): bool {
        if ($e instanceof ConnectionFailedException) {
            return true;
        }
        if ($e instanceof WebklexRuntimeException) {
            return preg_match('/empty response|failed to write|failed to send literal/i', $e->getMessage()) === 1;
        }
        if ($e instanceof ImapServerErrorException) {
            return preg_match('/\bBYE\b/', $e->getMessage()) === 1;
        }
        return false;
    }

    /**
     * @param string $msg
     * @param int $level
     */
    private function logMM(string $msg, int $level = LOG_LEVEL_ALL): void {
        Logger::logMessage(Logger::linePreTimestamp() . "Mail migration: $msg",
            $this->imapLogAbsolute, FILE_MODE_APPEND, $level, $this->imapLogLevelMin);
    }

    // ---------------------------------------------------------------------------------------
    // Pure helpers (unit tested)
    // ---------------------------------------------------------------------------------------

    /**
     * Token file name for a job - the path is derived, never stored or typed by the user.
     *
     * @param int $jobId
     * @return string
     */
    public static function tokenFileName(int $jobId): string {
        return 'mm-' . $jobId . '.token';
    }

    /**
     * @param int $jobId
     * @return string
     * @throws \CodeException
     * @throws \UserFormException
     */
    public static function tokenPathForJob(int $jobId): string {
        return Path::absoluteProject(MM_TOKEN_DIR, self::tokenFileName($jobId));
    }

    /**
     * First Message-ID header value of a raw RFC822 header block ('' if none).
     *
     * @param string $rawHeader
     * @return string
     */
    public static function extractMessageId(string $rawHeader): string {
        // Unfold RFC 5322 folded header lines first.
        $unfolded = preg_replace("/\r?\n[ \t]+/", ' ', $rawHeader);
        if (preg_match('/^message-id[ \t]*:[ \t]*(.+?)[ \t\r]*$/im', $unfolded, $matches) === 1) {
            return trim($matches[1]);
        }
        return '';
    }

    /**
     * Dedup key: Message-ID, or md5 of the raw header (valid both sides - the copy is byte-identical).
     *
     * @param string $rawHeader
     * @return string
     */
    public static function dedupKey(string $rawHeader): string {
        $messageId = self::extractMessageId($rawHeader);
        return $messageId !== '' ? $messageId : MM_DEDUP_FALLBACK_PREFIX . md5($rawHeader);
    }

    /**
     * Special folders of a LIST result: RFC 6154 special-use attributes first, name heuristics second.
     *
     * @param array $listResult - [path => ['delimiter' => x, 'flags' => [...]]]
     * @return array - [role => path]
     */
    public static function resolveSpecialFolders(array $listResult): array {

        $special = [];

        foreach ($listResult as $path => $meta) {
            foreach ((array)($meta['flags'] ?? []) as $flag) {
                $role = self::SPECIAL_USE_FLAGS[strtolower($flag)] ?? null;
                if ($role !== null && !isset($special[$role])) {
                    $special[$role] = $path;
                }
            }
        }

        foreach ($listResult as $path => $meta) {
            $delimiter = self::entryDelimiter($meta);
            $segments = explode($delimiter, $path);
            $lastSegment = mb_strtolower(trim(end($segments)));
            foreach (self::SPECIAL_NAMES as $role => $names) {
                if (!isset($special[$role]) && in_array($lastSegment, $names)) {
                    $special[$role] = $path;
                }
            }
        }

        return $special;
    }

    /**
     * Source path => target path: special folders (incl. subfolders) remap, the rest swaps the
     * delimiter (safe on UTF7-IMAP names: modified base64 never contains '/' or '.').
     *
     * @param string $srcPath
     * @param string $srcDelim
     * @param string $dstDelim
     * @param array $srcSpecial - [role => source path]
     * @param array $dstSpecial - [role => target path]
     * @return string
     */
    public static function mapFolderPath(string $srcPath, string $srcDelim, string $dstDelim, array $srcSpecial, array $dstSpecial): string {

        if (strcasecmp($srcPath, 'INBOX') === 0) {
            return 'INBOX';
        }

        foreach ($srcSpecial as $role => $specialPath) {
            if (!isset($dstSpecial[$role])) {
                continue; // target has no resolved counterpart: keep the source name
            }
            if (strcasecmp($srcPath, $specialPath) === 0) {
                return $dstSpecial[$role];
            }
            $prefix = $specialPath . $srcDelim;
            if (strncasecmp($srcPath, $prefix, strlen($prefix)) === 0) {
                $rest = substr($srcPath, strlen($prefix));
                return $dstSpecial[$role] . $dstDelim . str_replace($srcDelim, $dstDelim, $rest);
            }
        }

        return str_replace($srcDelim, $dstDelim, $srcPath);
    }

    /**
     * APPEND flags: whitelist only (any other keyword makes Exchange reject the whole append,
     * 'BAD Command Argument Error. 11'); Thunderbird tag keywords map to \Flagged.
     *
     * @param array $rawFlags
     * @return array
     */
    public static function appendFlags(array $rawFlags): array {
        $result = [];
        foreach ($rawFlags as $flag) {
            foreach (self::APPEND_FLAG_WHITELIST as $allowed) {
                if (strcasecmp($flag, $allowed) === 0) {
                    $result[$allowed] = $allowed; // canonical casing, deduped
                    break;
                }
            }
        }
        if (self::hasTagKeyword($rawFlags)) {
            $result['\Flagged'] = '\Flagged';
        }
        return array_values($result);
    }

    /**
     * True when the raw source flags contain a Thunderbird tag keyword (TAG_FLAG_KEYWORDS).
     *
     * @param array $rawFlags
     * @return bool
     */
    public static function hasTagKeyword(array $rawFlags): bool {
        foreach ($rawFlags as $flag) {
            foreach (self::TAG_FLAG_KEYWORDS as $tag) {
                if (strcasecmp($flag, $tag) === 0) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Reconciliation decision for one pre-existing target INBOX mail.
     *
     * @param string $key - dedupKey of the mail
     * @param array $srcKeyFolderMap - dedupKey => source folder path (whole source mailbox)
     * @param string $srcInboxPath - source inbox path ('INBOX')
     * @param int $ageSeconds - mail age (INTERNALDATE)
     * @param int $minAgeSeconds - never quarantine younger mails (dual-delivery race guard)
     * @return array - [action RECONCILE_*, source folder path ('' unless RECONCILE_MOVE)]
     */
    public static function reconcileAction(string $key, array $srcKeyFolderMap, string $srcInboxPath, int $ageSeconds, int $minAgeSeconds): array {

        // md5 fallback keys never match across two servers (different Received chains) => always keep.
        if (strpos($key, MM_DEDUP_FALLBACK_PREFIX) === 0) {
            return [self::RECONCILE_KEEP, ''];
        }

        if (isset($srcKeyFolderMap[$key])) {
            if (strcasecmp($srcKeyFolderMap[$key], $srcInboxPath) === 0) {
                return [self::RECONCILE_KEEP, ''];
            }
            return [self::RECONCILE_MOVE, $srcKeyFolderMap[$key]];
        }

        return $ageSeconds >= $minAgeSeconds ? [self::RECONCILE_QUARANTINE, ''] : [self::RECONCILE_KEEP, ''];
    }

    /**
     * First Date: header value of a raw RFC822 header block, or null.
     *
     * @param string $rawHeader
     * @return \DateTime|null
     */
    private static function headerDate(string $rawHeader): ?\DateTime {
        $unfolded = preg_replace("/\r?\n[ \t]+/", ' ', $rawHeader);
        if (preg_match('/^date[ \t]*:[ \t]*(.+?)[ \t\r]*$/im', $unfolded, $matches) !== 1) {
            return null;
        }
        try {
            return new \DateTime($matches[1]); // RFC 2822 date parser
        } catch (\Exception $e) {
            return null;
        }
    }

    /**
     * APPEND date-time from the Date: header (null = omit) - INTERNALDATE is unusable: webklex'
     * decodeLine() truncates a quoted string sitting directly before the closing paren (Dovecot).
     *
     * @param string $rawHeader
     * @return string|null
     */
    public static function appendDateFromHeader(string $rawHeader): ?string {
        $dt = self::headerDate($rawHeader);
        return $dt === null ? null : $dt->format('d-M-Y H:i:s O');
    }

    /**
     * Mail age in seconds from the Date: header; missing/unparseable = 0 = 'fresh' = safe keep.
     *
     * @param string $rawHeader
     * @param int $now - unix timestamp
     * @return int
     */
    public static function headerDateAge(string $rawHeader, int $now): int {
        $dt = self::headerDate($rawHeader);
        if ($dt === null) {
            return 0;
        }
        return max(0, $now - $dt->getTimestamp());
    }

    /**
     * Ordered folder plan (selectable source folders + mapped targets): INBOX first, parents before children.
     *
     * @param array $srcList - [path => ['delimiter' => x, 'flags' => [...]]]
     * @param array $dstList
     * @return array - [ ['src' => path, 'dst' => path, 'total' => 0], ... ]
     */
    public static function buildFolderPlan(array $srcList, array $dstList): array {

        $srcSpecial = self::resolveSpecialFolders($srcList);
        $dstSpecial = self::resolveSpecialFolders($dstList);
        $dstDelim = self::listDelimiter($dstList);

        $plan = [];
        foreach ($srcList as $path => $meta) {
            $flagsLower = array_map('strtolower', (array)($meta['flags'] ?? []));
            if (in_array('\noselect', $flagsLower)) {
                continue; // container only - created implicitly as parent of its children
            }
            $srcDelim = self::entryDelimiter($meta);
            $plan[] = [
                'src' => $path,
                'dst' => self::mapFolderPath($path, $srcDelim, $dstDelim, $srcSpecial, $dstSpecial),
                'srcDelim' => $srcDelim,
                'total' => 0,
            ];
        }

        usort($plan, function ($a, $b) {
            $aInbox = strcasecmp($a['src'], 'INBOX') === 0 ? 0 : 1;
            $bInbox = strcasecmp($b['src'], 'INBOX') === 0 ? 0 : 1;
            if ($aInbox !== $bInbox) {
                return $aInbox - $bInbox;
            }
            $aDepth = substr_count($a['src'], $a['srcDelim']);
            $bDepth = substr_count($b['src'], $b['srcDelim']);
            if ($aDepth !== $bDepth) {
                return $aDepth - $bDepth;
            }
            return strcasecmp($a['src'], $b['src']);
        });

        return $plan;
    }

    /**
     * Delimiter of one LIST entry ('/' fallback for NIL/flat servers).
     *
     * @param array $meta
     * @return string
     */
    public static function entryDelimiter(array $meta): string {
        $delimiter = (string)($meta['delimiter'] ?? '');
        return ($delimiter === '' || strcasecmp($delimiter, 'NIL') === 0) ? '/' : $delimiter;
    }

    /**
     * Hierarchy delimiter of a LIST result (first entry wins, '/' fallback).
     *
     * @param array $listResult
     * @return string
     */
    public static function listDelimiter(array $listResult): string {
        foreach ($listResult as $meta) {
            return self::entryDelimiter($meta);
        }
        return '/';
    }
}
