<?php

declare(strict_types=1);

namespace Duplicator\Addons\ProBase\Telemetry;

use Duplicator\Core\Exceptions\DupliException;
use Duplicator\Core\MigrationMng;
use Duplicator\Core\Upgrade\UpgradePlugin;
use Duplicator\Installer\Models\MigrateData;
use Duplicator\Libs\Snap\SnapIO;
use Duplicator\Libs\Snap\SnapUtil;
use Duplicator\Libs\WpUtils\WpArchiveUtils;
use Duplicator\Libs\WpUtils\WpDbUtils;
use Duplicator\Models\GlobalEntity;
use Duplicator\Package\AbstractPackage;
use Duplicator\Package\Archive\PackageArchive;
use Duplicator\Package\Storage\UploadInfo;
use Duplicator\Utils\Lock\LockUtil;
use Duplicator\Utils\Logging\DupLog;
use Duplicator\Utils\Settings\ServerThrottle;
use Duplicator\Utils\UsageStatistics\StatsUtil;
use Throwable;

/**
 * Per-request event buffer with a single flush on `shutdown`.
 *
 * Each tracked occurrence is appended to a static buffer; on shutdown a
 * single HTTP POST drains the buffer to the worker. No cross-request queue,
 * no retries, no transients — fire-and-forget by design (plan §"Behaviour").
 *
 * The buffer is bounded by EVENT_CAP as a safety valve; if a single
 * pageload exceeds the cap an early flush is forced.
 */
class TelemetryEvents
{
    const EVENT_CAP = 50;

    /**
     * Option holding the timestamp of the last `install` event sent, used to
     * back off repeated install sends (install is once-per-site-lifetime).
     */
    const INSTALL_EVENT_TIME_OPTION = 'dupli_opt_telemetry_install_event_time';

    // Wire values match the DupliException severity classification.
    const SEVERITY_ERROR   = DupliException::SEVERITY_ERROR;
    const SEVERITY_HANDLED = DupliException::SEVERITY_HANDLED;

    /** @var array<int, array<string, mixed>> */
    private static $buffer = [];

    /** @var bool */
    private static $shutdownRegistered = false;

    /**
     * Register lifecycle hooks. Called from ProBase::init().
     *
     * Wires the core-only events: plugin install/upgrade/deactivate,
     * migration completion, and backup lifecycle. Events sourced from
     * other addons (schedule_created, storage_failure with phase detail,
     * staging_*) are wired once those addons are integrated.
     *
     * @return void
     */
    public static function init(): void
    {
        // Plugin lifecycle.
        add_action('duplicator_after_activation', [self::class, 'onPluginActivated'], 100, 4);
        add_action('duplicator_after_deactivation', [self::class, 'onPluginDeactivated'], 100, 0);

        // Migration / restore / recovery completion. The hook fires once,
        // on the first admin login of a freshly installed/migrated site.
        add_action(MigrationMng::HOOK_FIRST_LOGIN_AFTER_INSTALL, [self::class, 'onMigrationComplete'], 100, 1);

        // Backup lifecycle. Late priority so the package state (status,
        // ZipSize, state_times, ...) is finalized before we read it.
        add_action('duplicator_package_transfer_completed', [self::class, 'onBackupComplete'], 100, 1);
        add_action('duplicator_build_fail', [self::class, 'onBackupFail'], 100, 3);

        // Storage transfer lifecycle. UploadInfo lives in core, so probase
        // owns the originator; storage addons only need to be present for
        // their getSType() to be reachable.
        add_action('duplicator_transfer_failed', [self::class, 'onTransferFailed'], 100, 1);
        add_action('duplicator_transfer_cancelled', [self::class, 'onTransferCancelled'], 100, 1);
    }

    /**
     * Append an event to the buffer.
     *
     * Single gate: callers can invoke unconditionally — `track()` checks
     * `TelemetryClient::isEnabled()` itself and bails when disabled.
     *
     * @param string               $eventType    Event family (e.g. "backup_build")
     * @param string               $eventSubType Outcome/variant within the family
     *                                           (e.g. "complete"); '' to omit
     * @param array<string, mixed> $fields       Typed fields for this event type
     * @param mixed                $context      Optional source object passed to the
     *                                           enrichment filter (e.g. the AbstractPackage
     *                                           for backup_build, the UploadInfo for storage
     *                                           events). Addons read it to compute their
     *                                           own fields without re-querying state.
     *
     * @return void
     */
    public static function track(string $eventType, string $eventSubType = '', array $fields = [], $context = null): void
    {
        if (!TelemetryClient::isEnabled()) {
            return;
        }

        try {
            self::doTrack($eventType, $eventSubType, $fields, $context);
        } catch (Throwable $t) {
            DupLog::traceException($t, 'Telemetry track() failed, event dropped: ' . $eventType);
        }
    }

    /**
     * Internal worker for {@see track()}. Kept separate so the public entry
     * point is a thin never-throwing wrapper.
     *
     * @param string               $eventType    Event family
     * @param string               $eventSubType Outcome/variant within the family
     * @param array<string, mixed> $fields       Typed fields for this event type
     * @param mixed                $context      Optional source object for the enrichment filter
     *
     * @return void
     */
    private static function doTrack(string $eventType, string $eventSubType, array $fields, $context): void
    {
        /**
         * Hook for addons to enrich an event's `fields` payload before it
         * is buffered. Loose coupling: an addon can `add_filter()` here
         * without depending on probase.
         *
         * Filter handlers typically branch on $eventType and ignore events
         * they don't care about (return $fields unchanged).
         *
         * @param array<string, mixed> $fields       Fields built by the originator
         * @param string               $eventType    Event family
         * @param string               $eventSubType Outcome/variant within the family
         * @param mixed                $context      Source object, when provided by the
         *                                           originator (otherwise null)
         */
        $fields = apply_filters('duplicator_telemetry_event_fields', $fields, $eventType, $eventSubType, $context);

        $event = ['event_type' => $eventType];
        if ($eventSubType !== '') {
            $event['event_sub_type'] = $eventSubType;
        }
        $event['fields'] = $fields;

        self::$buffer[] = $event;

        if (count(self::$buffer) >= self::EVENT_CAP) {
            self::flush();
            return;
        }

        if (!self::$shutdownRegistered) {
            self::$shutdownRegistered = true;
            add_action('shutdown', [self::class, 'flush'], 100);
        }
    }

    /**
     * Drain the buffer with a single POST. Always clears the buffer, even
     * on transport failure (fire-and-forget).
     *
     * Public so it can be invoked directly from the shutdown hook and from
     * tests; idempotent on an empty buffer.
     *
     * @return void
     */
    public static function flush(): void
    {
        if (empty(self::$buffer)) {
            return;
        }

        $events       = self::$buffer;
        self::$buffer = [];

        if (!TelemetryClient::isEnabled()) {
            return;
        }

        $envelope                     = TelemetrySnapshot::collectIdentity();
        $envelope['protocol_version'] = TelemetryClient::PROTOCOL_VERSION;
        $envelope['plugin_version']   = DUPLICATOR_VERSION;
        $envelope['timestamp']        = gmdate('c');
        $envelope                     = array_merge($envelope, TelemetrySnapshot::collectEnvironment());
        $envelope['events']           = $events;

        TelemetryClient::post(TelemetryClient::ROUTE_EVENTS, $envelope);
    }

    /**
     * `duplicator_after_activation` — old/new variant and version feed the
     * activation_type discriminator.
     *
     * @param string       $oldVariant Previous installed variant, '' on first install
     * @param false|string $oldVersion Previous installed version, false on first install
     * @param string       $newVariant Variant that just took effect
     * @param string       $newVersion Version that just took effect
     *
     * @return void
     */
    public static function onPluginActivated($oldVariant, $oldVersion, $newVariant, string $newVersion): void
    {
        if (!TelemetryClient::isEnabled()) {
            return;
        }

        $payload = self::buildPluginActivatedPayload($oldVersion, $newVersion, (string) $oldVariant, $newVariant);
        $subType = $payload['activation_type'];
        unset($payload['activation_type']);

        if (!self::canSendLifecycleEvent($subType)) {
            return;
        }

        self::track('plugin_activation', $subType, $payload);
    }

    /**
     * Gate for plugin lifecycle events.
     *
     * A site whose option writes do not persist re-enters the activation path
     * on every admin request, flooding the worker with duplicate lifecycle
     * events: the site must prove it can persist state (the version option is
     * actually stored) before any lifecycle event is sent. On top of that,
     * `install` is once-per-site-lifetime by nature, so a repeated install
     * within the backoff window is suppressed. Public for testability.
     *
     * @param string $subType Activation type discriminator (e.g. "install")
     *
     * @return bool True when the event may be sent
     */
    public static function canSendLifecycleEvent(string $subType): bool
    {
        if (!UpgradePlugin::isStoredVersionPersisted()) {
            return false;
        }

        if ($subType !== 'install') {
            return true;
        }

        $lastSent = (int) get_option(self::INSTALL_EVENT_TIME_OPTION, 0);
        if ($lastSent > 0 && (time() - $lastSent) < DAY_IN_SECONDS) {
            return false;
        }

        update_option(self::INSTALL_EVENT_TIME_OPTION, time(), false);
        return true;
    }

    /**
     * Build the plugin_activated payload. Pure function for testability.
     *
     * activation_type priority: install > variant_switch > reactivation > upgrade.
     *
     * @param false|string $currentVersion Previous installed version, false on first install
     * @param string       $newVersion     Version that just took effect
     * @param string       $oldVariant     Previous installed variant, '' on first install
     * @param string       $newVariant     Variant that just took effect
     *
     * @return array<string, mixed>
     */
    public static function buildPluginActivatedPayload(
        $currentVersion,
        string $newVersion,
        string $oldVariant = '',
        string $newVariant = ''
    ): array {
        $isInstall = ($currentVersion === false);

        if ($isInstall) {
            $detail = 'install';
        } elseif ($oldVariant !== '' && $oldVariant !== $newVariant) {
            $detail = 'variant_switch';
        } elseif ((string) $currentVersion === $newVersion) {
            $detail = 'reactivation';
        } else {
            $detail = 'upgrade';
        }

        return [
            'activation_type'  => $detail,
            'previous_version' => $isInstall ? '' : (string) $currentVersion,
            'new_version'      => $newVersion,
            'previous_variant' => strtolower($oldVariant),
            'new_variant'      => strtolower($newVariant),
        ];
    }

    /**
     * `duplicator_after_deactivation` — counterpart to activation. No
     * payload beyond the discriminator: the snapshot already carries the
     * environment/license context.
     *
     * @return void
     */
    public static function onPluginDeactivated(): void
    {
        self::track('plugin_activation', 'deactivate', []);
    }

    /**
     * `duplicator_first_login_after_install` — the site has just been
     * restored / migrated / installed-fresh from a package, and the
     * administrator has logged in for the first time. The migration
     * payload mirrors what `InstallerData` sends to Connect, so the
     * worker has the same level of detail without depending on Connect.
     *
     * `install_type` is the primary categorical: 14 values from
     * `StatsUtil::getInstallType()`. `rbackup_*` is restore-backup,
     * `recovery_*` is recovery-point, the rest are migrations.
     *
     * @param MigrateData $data Migration data from MigrationMng
     *
     * @return void
     */
    public static function onMigrationComplete(MigrateData $data): void
    {
        $payload = self::buildMigrationCompletePayload($data);
        $subType = $payload['install_type'];
        unset($payload['install_type']);
        self::track('migration', $subType, $payload);
    }

    /**
     * Build the migration_complete payload. Pure function for testability.
     *
     * @param MigrateData $data Migration data from MigrationMng
     *
     * @return array<string, mixed>
     */
    public static function buildMigrationCompletePayload(MigrateData $data): array
    {
        return [
            'install_type'         => StatsUtil::getInstallType($data->installType),
            'logic_modes'          => StatsUtil::getLogicModes($data->logicModes),
            'template'             => StatsUtil::getTemplate($data->template),
            'archive_type'         => $data->archiveType,
            'components'           => StatsUtil::getStatsComponents($data->components),
            'restore_backup_mode'  => (bool) $data->restoreBackupMode,
            'source_phpv'          => SnapUtil::getVersion($data->phpVersion, 3),
            'target_phpv'          => SnapUtil::getVersion(phpversion(), 3),
            'site_size'            => round((float) $data->siteSize / MB_IN_BYTES, 2),
            'site_files_count'     => (int) $data->siteNumFiles,
            'site_db_size'         => round((float) $data->siteDbSize / MB_IN_BYTES, 2),
            'site_db_tables_count' => (int) $data->siteDBNumTables,
        ];
    }

    /**
     * `duplicator_package_transfer_completed` — backup is fully built and
     * transferred to all storages.
     *
     * Also resets the failure streak so the snapshot reflects a clean run.
     *
     * @param AbstractPackage $package Completed backup package
     *
     * @return void
     */
    public static function onBackupComplete(AbstractPackage $package): void
    {
        TelemetrySnapshot::resetFailureStreak();

        self::track('backup_build', 'complete', self::buildBackupDescriptorFields($package), $package);
    }

    /**
     * `duplicator_build_fail` — archive/transfer failure or cancellation.
     *
     * Emits `backup_build/cancelled` for cancellations and `backup_build/failed`
     * for real failures. The failure streak is bumped only on a real failure,
     * never on cancel.
     *
     * @param AbstractPackage $package        Failed backup package
     * @param int             $previousStatus Status the build was in when it failed
     * @param Throwable       $exception      The failure cause
     *
     * @return void
     */
    public static function onBackupFail(
        AbstractPackage $package,
        int $previousStatus,
        Throwable $exception
    ): void {
        $phase = self::resolveFailurePhase($previousStatus);
        if ($phase === 'cancelled') {
            self::track('backup_build', 'cancelled', self::buildBackupDescriptorFields($package), $package);
            return;
        }

        $reason = AbstractPackage::failReasonFromException($exception);
        self::trackBackupFailed($package, $phase, $reason, $exception, $previousStatus, self::resolveSeverity($exception));
    }

    /**
     * Failure severity from the exception: a DupliException carries its own
     * code-to-severity classification, any other throwable is `error`.
     *
     * @param Throwable $exception The failure cause
     *
     * @return string SEVERITY_ERROR or SEVERITY_HANDLED
     */
    private static function resolveSeverity(Throwable $exception): string
    {
        return $exception instanceof DupliException ? $exception->getSeverity() : self::SEVERITY_ERROR;
    }

    /**
     * Build and emit a `backup_build/failed` event for the given phase. Bumps
     * the failure-streak counter as a side effect.
     *
     * `severity` distinguishes an unhandled failure (`error`, a potential
     * plugin bug) from an expected/handled condition (`handled`, e.g. an
     * expired license).
     *
     * @param AbstractPackage $package        Failed backup package
     * @param string          $phase          Phase identifier (requirements, database, archive, storage)
     * @param int             $reason         One of AbstractPackage::FAIL_REASON_*
     * @param Throwable       $exception      The failure cause
     * @param int             $previousStatus Status the build was in when it failed
     * @param string          $severity       SEVERITY_ERROR or SEVERITY_HANDLED
     *
     * @return void
     */
    private static function trackBackupFailed(
        AbstractPackage $package,
        string $phase,
        int $reason,
        Throwable $exception,
        int $previousStatus,
        string $severity
    ): void {
        TelemetrySnapshot::incrementFailureStreak();

        self::track(
            'backup_build',
            'failed',
            self::buildBackupFailedFields($package, $phase, $reason, $exception, $previousStatus, $severity),
            $package
        );
    }

    /**
     * Build the `backup_failed` field payload: the shared backup descriptor
     * plus the failure-specific dimensions. Kept separate (and not throwing)
     * so the field shape can be asserted in tests without going through the
     * gated track() path.
     *
     * @param AbstractPackage $package        Failed backup package
     * @param string          $phase          Phase identifier (requirements, database, archive, storage)
     * @param int             $reason         One of AbstractPackage::FAIL_REASON_*
     * @param Throwable       $exception      The failure cause
     * @param int             $previousStatus Status the build was in when it failed
     * @param string          $severity       SEVERITY_ERROR or SEVERITY_HANDLED
     *
     * @return array<string, mixed>
     */
    private static function buildBackupFailedFields(
        AbstractPackage $package,
        string $phase,
        int $reason,
        Throwable $exception,
        int $previousStatus,
        string $severity
    ): array {
        return array_merge(
            self::buildBackupDescriptorFields($package),
            [
                'failure_phase'  => $phase,
                'failure_origin' => self::resolveFailureOrigin($reason),
                'failure_code'   => $previousStatus,
                'severity'       => $severity,
                'error_message'  => self::errorLocationAndHash($exception),
            ]
        );
    }

    /**
     * Identify a failure by code location + a hash of the message, never by its
     * text. Ships `<file>:<line>:<hash>`, file relative to the site root and hash
     * a digest of the normalized message, so the same failure collides on one
     * hash while the raw message (DB credentials, table names, …) is never sent.
     *
     * The file keeps only its base-path prefix stripped (so `<file>:<line>` stays
     * a recognizable relative location). Only the first line of the message is
     * hashed: throw sites put variable diagnostic detail (command output, failure
     * summaries, wrapped exception messages) on the following lines so it stays
     * in logs without fragmenting the fingerprint. The hashed line is normalized
     * harder: every path-like token becomes `<path>` and every number `<num>`,
     * so the same logical failure hashes identically even when the embedded file
     * names, directories or counters differ between sites.
     *
     * @param Throwable $exception The failure cause
     *
     * @return string `file:line:hash`
     */
    private static function errorLocationAndHash(Throwable $exception): string
    {
        $file       = self::stripBasePaths($exception->getFile());
        $message    = $exception->getMessage();
        $message    = substr($message, 0, strcspn($message, "\r\n"));
        $normalized = self::normalizeMessage($message);

        return $file . ':' . $exception->getLine() . ':' . sha1($normalized);
    }

    /**
     * Normalize an exception message into a stable, path-free fingerprint.
     *
     * Replaces every path-like token (POSIX `/a/b/c` or Windows `C:\a\b`,
     * including bare relative paths such as `wp-content/uploads/foo.zip`) with
     * `<path>`, then every remaining number with `<num>`. The intent is that the
     * same kind of failure produces the same digest regardless of the concrete
     * file names, directories, IDs or sizes the message happens to carry — and
     * that no absolute path ever reaches the hash input.
     *
     * @param string $message Raw exception message
     *
     * @return string Normalized message (never sent as-is, only hashed)
     */
    private static function normalizeMessage(string $message): string
    {
        $message = str_replace('\\', '/', $message);

        // Collapse any run of path segments (`a/b`, `/a/b/c.ext`, `C:/a/b`) into
        // a single <path> token. Requires at least one slash so plain words are
        // left untouched.
        $message = preg_replace('#[A-Za-z]:/[^\s\'"]*|/?(?:[^\s/\'"]+/)+[^\s/\'"]*#', '<path>', $message);

        return preg_replace('/\d+/', '<num>', (string) $message);
    }

    /**
     * Strip the wpcontent, abs and home base paths — in both their configured
     * and symlink-resolved forms — from a file path so no absolute prefix leaks.
     *
     * @param string $subject The file path to sanitize
     *
     * @return string
     */
    private static function stripBasePaths(string $subject): string
    {
        $subject = str_replace('\\', '/', $subject);

        $paths = WpArchiveUtils::getOriginalPaths();
        $bases = [];
        foreach ([$paths['wpcontent'], $paths['abs'], $paths['home']] as $original) {
            $bases[] = SnapIO::safePathTrailingslashit($original, false);
            $bases[] = SnapIO::safePathTrailingslashit($original, true);
        }

        $bases = array_unique($bases);
        usort($bases, static fn(string $a, string $b): int => strlen($b) <=> strlen($a));

        foreach ($bases as $base) {
            $subject = str_ireplace($base, '', $subject);
        }

        // Last resort when no base matches the reported file: cut at the
        // content dir so no absolute prefix leaks.
        if (($pos = strripos($subject, '/wp-content/')) !== false) {
            $subject = substr($subject, $pos + strlen('/wp-content/'));
        }

        return $subject;
    }

    /**
     * Fields describing the backup itself — identical for `backup_complete`
     * and `backup_failed`, so the worker sees the same descriptor regardless
     * of outcome. Original site size / counts come from the persisted scan,
     * the rest from the live build config.
     *
     * @param AbstractPackage $package Backup package
     *
     * @return array<string, mixed>
     */
    private static function buildBackupDescriptorFields(AbstractPackage $package): array
    {
        $archive = $package->Archive;
        $dbInfo  = $package->Database->info;

        $global = GlobalEntity::getInstance();

        return [
            'archive_engine'      => self::resolveArchiveEngine($package),
            'db_mode'             => self::resolveDbMode($package),
            'client_side_kickoff' => $package->isClientSideKickoff(),
            'execution_type'      => (defined('WP_CLI') && WP_CLI) ? 'wp-cli' : 'manual',
            'lock_mode'           => self::resolveLockMode(),
            'server_throttle'     => ServerThrottle::microsecondsFromThrottle((int) $global->server_load_reduction) / 1000000,
            'max_build_time'      => (int) $global->max_package_runtime_in_min * 60,
            'components'          => StatsUtil::getStatsComponents(self::readPackageProp($package, 'components', [])),
            'files_count'         => $archive->FileCount + $archive->DirCount,
            'files_size'          => self::bytesToMb($archive->scanSize),
            'db_tables_count'     => $dbInfo->tablesFinalCount,
            'db_rows_count'       => $dbInfo->tablesRowCount,
            'db_size'             => self::bytesToMb($dbInfo->tablesSizeOnDisk),
            'archive_size'        => self::bytesToMb(self::resolveArchiveSize($package)),
            'build_duration'      => self::buildDuration($package),
            'transfer_duration'   => self::transferDuration($package),
            'durations'           => self::phaseDurations($package),
            'retries_count'       => (int) self::readBuildProgressProp($package, 'retries', 0),
        ];
    }

    /**
     * Archive file size in bytes, read directly from disk so it works for any
     * build engine (shell-exec never fills processed_archive_size). Looks in
     * the temp location first (mid-build), then the final one (after the move
     * at COPIEDPACKAGE); returns 0 when the file isn't on disk yet.
     *
     * @param AbstractPackage $package Backup package
     *
     * @return int
     */
    private static function resolveArchiveSize(AbstractPackage $package): int
    {
        $tmpPath = DUPLICATOR_SSDIR_PATH_TMP . '/' . $package->getArchiveFilename();
        if (file_exists($tmpPath)) {
            return SnapIO::filesize($tmpPath, true);
        }

        $finalPath = $package->getLocalPackageFilePath(AbstractPackage::FILE_TYPE_ARCHIVE);
        if ($finalPath !== false) {
            return SnapIO::filesize($finalPath, true);
        }

        return 0;
    }

    /**
     * Map a FAIL_REASON_* enum to the worker-side string. Separate dimension
     * from `failure_phase`: phase is the build phase, origin is what triggered
     * the stop.
     *
     * @param int $reason One of AbstractPackage::FAIL_REASON_*
     *
     * @return string
     */
    private static function resolveFailureOrigin(int $reason): string
    {
        switch ($reason) {
            case AbstractPackage::FAIL_REASON_MAX_BUILD_TIME:
                return 'on_max_build_time';
            case AbstractPackage::FAIL_REASON_STUCK:
                return 'on_preprocess';
            default:
                return 'on_build';
        }
    }

    /**
     * `duplicator_transfer_cancelled` — explicit user cancel during upload.
     *
     * UploadInfo lives in core, so probase resolves the storage_type / phase
     * / duration directly. Storage addons can still hook
     * `duplicator_telemetry_event_fields` for further enrichment.
     *
     * @param UploadInfo|mixed $uploadInfo Upload state from the hook
     *
     * @return void
     */
    public static function onTransferCancelled($uploadInfo = null): void
    {
        $payload = self::buildStorageTransferPayload($uploadInfo);
        self::track('storage_transfer', 'cancelled', $payload, $uploadInfo);
    }

    /**
     * `duplicator_transfer_failed` — transfer failed (auth, installer copy,
     * archive copy). Phase is derived from `copied_installer` /
     * `copied_archive` flags (plan §"storage_failure").
     *
     * @param UploadInfo|mixed $uploadInfo Upload state from the hook
     *
     * @return void
     */
    public static function onTransferFailed($uploadInfo = null): void
    {
        $payload = self::buildStorageTransferPayload($uploadInfo);
        if ($uploadInfo instanceof UploadInfo) {
            $payload['failure_count'] = (int) $uploadInfo->failure_count;
        }
        self::track('storage_transfer', 'failed', $payload, $uploadInfo);
    }

    /**
     * Build the field payload shared by `transfer_cancelled` and
     * `storage_failure`: storage_type (short name), phase (auth / installer
     * / archive), duration (seconds).
     *
     * @param UploadInfo|mixed $uploadInfo Upload state from the hook
     *
     * @return array<string, mixed>
     */
    private static function buildStorageTransferPayload($uploadInfo): array
    {
        if (!$uploadInfo instanceof UploadInfo) {
            return [
                'storage_type' => 'unknown',
                'phase'        => 'unknown',
                'duration'     => 0.0,
            ];
        }

        $started  = (int) $uploadInfo->started_timestamp;
        $stopped  = (int) $uploadInfo->stopped_timestamp;
        $duration = ($stopped > $started) ? (float) ($stopped - $started) : 0.0;

        return [
            'storage_type' => self::storageTypeName((int) $uploadInfo->getStorage()::getSType()),
            'phase'        => self::resolveTransferPhase($uploadInfo),
            'duration'     => $duration,
        ];
    }

    /**
     * Map the transfer state flags on UploadInfo to a coarse phase label:
     * - nothing copied yet -> auth
     * - installer copied, archive in progress -> installer
     * - archive in progress / done -> archive
     *
     * @param UploadInfo $uploadInfo Upload state
     *
     * @return string One of: auth, installer, archive
     */
    private static function resolveTransferPhase(UploadInfo $uploadInfo): string
    {
        if (!$uploadInfo->copied_installer) {
            return 'auth';
        }
        if (!$uploadInfo->copied_archive) {
            return 'installer';
        }
        return 'archive';
    }

    /**
     * Map a storage SType integer to the worker-side short name used by the
     * SLOT_MAP. Unknown types return `unknown` so the worker can still
     * aggregate (instead of dropping the event).
     *
     * @param int $sType AbstractStorageEntity::getSType() value
     *
     * @return string
     */
    public static function storageTypeName(int $sType): string
    {
        switch ($sType) {
            case 0:
                return 'local';
            case 1:
                return 'dropbox';
            case 2:
                return 'ftp';
            case 3:
                return 'gdrive';
            case 4:
                return 's3';
            case 5:
                return 'sftp';
            case 7:
                return 'onedrive';
            case 8:
                return 's3_compatible';
            case 9:
                return 'backblaze';
            case 10:
                return 'wasabi';
            case 11:
                return 'cloudflare';
            case 12:
                return 'vultr';
            case 13:
                return 'dream';
            case 14:
                return 'digitalocean';
            case 16:
                return 'googlecloud';
            case 17:
                return 'dupcloud';
            case 18:
                return 'incremental';
            default:
                return 'unknown';
        }
    }

    /**
     * Map the status the build was in when it failed to a failure phase string.
     *
     * Receives the status captured before it was overwritten with STATUS_ERROR,
     * so the phase is preserved even when the build is forced to ERROR: an
     * exception raised while uploading carries STORAGE_PROCESSING, not the
     * negative STORAGE_FAILED terminal state.
     *
     * @param int $previousStatus Status the build was in when it failed
     *
     * @return string One of: requirements, bootstrap, start, database, archive, storage, cancelled
     */
    private static function resolveFailurePhase(int $previousStatus): string
    {
        switch (true) {
            case $previousStatus === AbstractPackage::STATUS_REQUIREMENTS_FAILED:
                return 'requirements';
            case $previousStatus === AbstractPackage::STATUS_STORAGE_FAILED:
                return 'storage';
            case $previousStatus === AbstractPackage::STATUS_STORAGE_CANCELLED:
            case $previousStatus === AbstractPackage::STATUS_PENDING_CANCEL:
            case $previousStatus === AbstractPackage::STATUS_BUILD_CANCELLED:
                return 'cancelled';
            case $previousStatus >= AbstractPackage::STATUS_PRE_PROCESS && $previousStatus <= AbstractPackage::STATUS_AFTER_SCAN:
                return 'bootstrap';
            case $previousStatus === AbstractPackage::STATUS_START:
                return 'start';
            case $previousStatus >= AbstractPackage::STATUS_DBSTART && $previousStatus <= AbstractPackage::STATUS_DBDONE:
                return 'database';
            case $previousStatus >= AbstractPackage::STATUS_COPIEDPACKAGE:
                return 'storage';
            default:
                return 'archive';
        }
    }

    /**
     * Wall-clock seconds spent building the package: scan, database dump and
     * archive creation (everything up to and including ARCDONE).
     *
     * @param AbstractPackage $package Package whose state durations are summed
     *
     * @return float Seconds, 3-decimal precision
     */
    private static function buildDuration(AbstractPackage $package): float
    {
        return self::sumStateDurations($package, self::statusRange(
            AbstractPackage::STATUS_PRE_PROCESS,
            AbstractPackage::STATUS_ARCDONE
        ));
    }

    /**
     * Wall-clock seconds spent transferring the package to storage.
     *
     * @param AbstractPackage $package Package whose state durations are summed
     *
     * @return float Seconds, 3-decimal precision
     */
    private static function transferDuration(AbstractPackage $package): float
    {
        return self::sumStateDurations($package, self::statusRange(
            AbstractPackage::STATUS_COPIEDPACKAGE,
            AbstractPackage::STATUS_COMPLETE
        ));
    }

    /**
     * Per-phase breakdown of the build as a `key:seconds,key2:seconds` string
     * (a single scalar field, the worker protocol only accepts scalars).
     * Reported alongside the build_duration / transfer_duration aggregates.
     *
     * @param AbstractPackage $package Package whose state durations are summed
     *
     * @return string
     */
    private static function phaseDurations(AbstractPackage $package): string
    {
        $phases = [
            'scan'    => self::sumStateDurations($package, self::statusRange(
                AbstractPackage::STATUS_PRE_PROCESS,
                AbstractPackage::STATUS_START
            )),
            'db'      => self::sumStateDurations($package, self::statusRange(
                AbstractPackage::STATUS_DBSTART,
                AbstractPackage::STATUS_DBDONE
            )),
            'archive' => self::sumStateDurations($package, self::statusRange(
                AbstractPackage::STATUS_ARCSTART,
                AbstractPackage::STATUS_ARCDONE
            )),
            'copy'    => self::sumStateDurations($package, [AbstractPackage::STATUS_COPIEDPACKAGE]),
            'storage' => self::sumStateDurations($package, self::statusRange(
                AbstractPackage::STATUS_STORAGE_PROCESSING,
                AbstractPackage::STATUS_COMPLETE
            )),
        ];

        $parts = [];
        foreach ($phases as $name => $seconds) {
            $parts[] = $name . ':' . $seconds;
        }

        return implode(',', $parts);
    }

    /**
     * Sum durations across a set of statuses, skipping any status that has
     * no recorded timing. `getStateDuration()` returns -1 for those cases.
     *
     * @param AbstractPackage $package  Package whose state durations are read
     * @param int[]           $statuses STATUS_* enum values to sum
     *
     * @return float Seconds, 3-decimal precision
     */
    private static function sumStateDurations(AbstractPackage $package, array $statuses): float
    {
        $total = 0.0;
        foreach ($statuses as $status) {
            $duration = $package->getStateDuration($status);
            if ($duration > 0) {
                $total += $duration;
            }
        }

        return round($total, 3);
    }

    /**
     * Build the inclusive list of STATUS_* enum integers between two
     * boundaries — the AbstractPackage status sequence is dense within each
     * named range (e.g. DBSTART..DBDONE), so a numeric range is enough.
     *
     * @param int $from Lower bound (inclusive)
     * @param int $to   Upper bound (inclusive)
     *
     * @return int[]
     */
    private static function statusRange(int $from, int $to): array
    {
        return range($from, $to);
    }

    /**
     * Resolve the archive engine from the live build mode set on BuildProgress
     * (falling back to the global setting on an early failure).
     *
     * @param AbstractPackage $package Package whose BuildProgress is inspected
     *
     * @return string
     */
    private static function resolveArchiveEngine(AbstractPackage $package): string
    {
        $buildMode = (int) self::readBuildProgressProp($package, 'current_build_mode', -1);
        if ($buildMode < 0) {
            $buildMode = GlobalEntity::getInstance()->getBuildMode();
        }
        $zipMode = (int) self::readPackageProp($package, 'ziparchive_mode', PackageArchive::ZIP_MODE_MULTI_THREAD);

        return self::archiveEngineName($buildMode, $zipMode);
    }

    /**
     * Map a build mode + zip thread mode to the worker-side engine string.
     * Shared by the backup descriptor and the schedule_created event.
     *
     * @param int $buildMode PackageArchive::BUILD_MODE_*
     * @param int $zipMode   PackageArchive::ZIP_MODE_* (only relevant for the PHP zip engine)
     *
     * @return string One of: dup, zip_php_single, zip_php_multi, zip_shell, unknown
     */
    public static function archiveEngineName(int $buildMode, int $zipMode): string
    {
        switch ($buildMode) {
            case PackageArchive::BUILD_MODE_DUP_ARCHIVE:
                return 'dup';
            case PackageArchive::BUILD_MODE_SHELL_EXEC:
                return 'zip_shell';
            case PackageArchive::BUILD_MODE_ZIP_ARCHIVE:
                return $zipMode === PackageArchive::ZIP_MODE_MULTI_THREAD ? 'zip_php_multi' : 'zip_php_single';
            default:
                return 'unknown';
        }
    }

    /**
     * Resolve the database build mode recorded on the package at creation
     * time, so the event reports the mode the build actually used even if
     * the global setting changed afterwards. Worker expects: mysqldump,
     * php_dump_single, php_dump_multi.
     *
     * @param AbstractPackage $package Package whose recorded DB mode is read
     *
     * @return string
     */
    private static function resolveDbMode(AbstractPackage $package): string
    {
        switch ($package->Database->DBMode) {
            case WpDbUtils::BUILD_MODE_MYSQLDUMP:
                return 'mysqldump';
            case WpDbUtils::BUILD_MODE_PHP_SINGLE_THREAD:
                return 'php_dump_single';
            case WpDbUtils::BUILD_MODE_PHP_MULTI_THREAD:
            default:
                return 'php_dump_multi';
        }
    }

    /**
     * Resolve the process-lock engines currently held by the build.
     *
     * @return string One of: sql, file, both, unknown
     */
    private static function resolveLockMode(): string
    {
        $acquired = array_keys(LockUtil::getProcessLockInfo()['acquired']);
        sort($acquired);

        if ($acquired === ['file', 'sql']) {
            return 'both';
        }

        if ($acquired === ['file'] || $acquired === ['sql']) {
            return $acquired[0];
        }

        return 'unknown';
    }

    /**
     * Defensive property accessor for AbstractPackage. Properties on the
     * Pro package class are dynamic in some legacy paths.
     *
     * @param object $obj     Object to read the property from
     * @param string $prop    Property name
     * @param mixed  $default Value returned when the property is unset
     *
     * @return mixed
     */
    private static function readPackageProp($obj, string $prop, $default)
    {
        return isset($obj->$prop) ? $obj->$prop : $default;
    }

    /**
     * Read a property off $package->BuildProgress, which is the live build
     * state object. Returns the default when BuildProgress is absent.
     *
     * @param AbstractPackage $package Package holding BuildProgress
     * @param string          $prop    Property name on BuildProgress
     * @param mixed           $default Value returned when BuildProgress or property is missing
     *
     * @return mixed
     */
    private static function readBuildProgressProp(AbstractPackage $package, string $prop, $default)
    {
        $progress = self::readPackageProp($package, 'BuildProgress', null);
        if (!is_object($progress)) {
            return $default;
        }
        return isset($progress->$prop) ? $progress->$prop : $default;
    }

    /**
     * @param mixed $bytes Numeric byte count (cast to float)
     *
     * @return float Megabytes, rounded to 2 decimals
     */
    private static function bytesToMb($bytes): float
    {
        $bytes = (float) $bytes;
        if ($bytes <= 0) {
            return 0.0;
        }
        return round($bytes / MB_IN_BYTES, 2);
    }
}
