<?php

declare(strict_types=1);

namespace Duplicator\Addons\ProBase\Telemetry;

use Duplicator\Core\MigrationMng;
use Duplicator\Installer\Models\MigrateData;
use Duplicator\Libs\Snap\SnapUtil;
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\Logging\DupLog;
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;

    /** @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, 1);
        add_action('duplicator_build_database_fail', [self::class, 'onDatabaseFail'], 100, 1);

        // 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   Discriminator (e.g. "backup_complete")
     * @param string               $eventDetail Primary categorical dimension (e.g. "scheduled")
     * @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_*, 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 $eventDetail, array $fields = [], $context = null): void
    {
        if (!TelemetryClient::isEnabled()) {
            return;
        }

        try {
            self::doTrack($eventType, $eventDetail, $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   Discriminator (e.g. "backup_complete")
     * @param string               $eventDetail Primary categorical dimension
     * @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 $eventDetail, 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 discriminator
         * @param string               $eventDetail Primary categorical dimension
         * @param mixed                $context     Source object, when provided by the
         *                                          originator (otherwise null)
         */
        $fields = apply_filters('duplicator_telemetry_event_fields', $fields, $eventType, $eventDetail, $context);

        // Apply the "manual" fallback for execution_type only after the filter
        // ran, so addons that own the value (ScheduleAddon) decide first and
        // we never have to rely on filter handler ordering.
        if (
            ($eventType === 'backup_complete' || $eventType === 'backup_failed')
            && !isset($fields['execution_type'])
        ) {
            $fields['execution_type'] = 'manual';
        }

        self::$buffer[] = [
            'event_type'   => $eventType,
            'event_detail' => $eventDetail,
            'fields'       => $fields,
        ];

        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['plugin_version'] = DUPLICATOR_VERSION;
        $envelope['timestamp']      = gmdate('c');
        $envelope['events']         = $events;

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

    /**
     * `duplicator_after_activation` — fired both on a fresh install and on
     * every plugin upgrade. The hook passes four args (old variant, old
     * version, new variant, new version); we only need the versions.
     * `$oldVersion === false` distinguishes install from upgrade.
     *
     * @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
    {
        $payload = self::buildPluginActivatedPayload($oldVersion, $newVersion);
        self::track('plugin_activated', $payload['activation_type'], $payload);
    }

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

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

    /**
     * `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_deactivated', '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);
        self::track('migration_complete', $payload['install_type'], $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'  => $data->restoreBackupMode ? '1' : '0',
            '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();

        // ScheduleAddon hooks `duplicator_telemetry_event_fields` to set
        // execution_type to "scheduled" or "run_now" when a relation exists.
        // ProBase only sets the "manual" fallback in track() when the filter
        // hasn't populated the field, avoiding hidden ordering dependencies.
        self::track('backup_complete', 'manual', [
            'archive_format'    => self::resolveArchiveFormat($package),
            'db_mode'           => self::resolveDbMode(),
            'components'        => StatsUtil::getStatsComponents(self::readPackageProp($package, 'components', [])),
            'multi_threaded'    => self::resolveMultiThreaded($package),
            'duration'          => self::computeTotalDuration($package),
            'archive_size'      => self::bytesToMb(self::readPackageProp($package, 'ZipSize', 0)),
            'scan_duration'     => self::sumStateDurations($package, [AbstractPackage::STATUS_SCANNING]),
            'db_duration'       => self::sumStateDurations(
                $package,
                self::statusRange(AbstractPackage::STATUS_DBSTART, AbstractPackage::STATUS_DBDONE)
            ),
            'archive_duration'  => self::sumStateDurations(
                $package,
                self::statusRange(AbstractPackage::STATUS_ARCSTART, AbstractPackage::STATUS_ARCDONE)
            ),
            'transfer_duration' => self::sumStateDurations(
                $package,
                self::statusRange(AbstractPackage::STATUS_STORAGE_PROCESSING, AbstractPackage::STATUS_COMPLETE)
            ),
            'retries_count'     => (int) self::readBuildProgressProp($package, 'retries', 0),
        ], $package);
    }

    /**
     * `duplicator_build_fail` — archive/transfer failure or cancellation.
     *
     * Cancellations (negative-status codes for cancel) are not emitted as
     * `backup_failed`; `transfer_cancelled` covers them. See plan
     * "Cancellation deduplication".
     *
     * @param AbstractPackage $package Failed backup package
     *
     * @return void
     */
    public static function onBackupFail(AbstractPackage $package): void
    {
        $phase = self::resolveFailurePhase($package);
        if ($phase === 'cancelled') {
            return;
        }

        self::trackBackupFailed($package, $phase);
    }

    /**
     * `duplicator_build_database_fail` — DB phase failed. Distinct from
     * archive/storage failures so root-cause signal is preserved.
     *
     * @param AbstractPackage $package Package that failed during DB phase
     *
     * @return void
     */
    public static function onDatabaseFail(AbstractPackage $package): void
    {
        self::trackBackupFailed($package, 'database');
    }

    /**
     * Build and emit a `backup_failed` event for the given phase. Bumps the
     * failure-streak counter as a side effect.
     *
     * @param AbstractPackage $package Failed backup package
     * @param string          $phase   Phase identifier (requirements, database, archive, storage)
     *
     * @return void
     */
    private static function trackBackupFailed(AbstractPackage $package, string $phase): void
    {
        TelemetrySnapshot::incrementFailureStreak();

        // ScheduleAddon hooks `duplicator_telemetry_event_fields` to set
        // execution_type when the package is tied to a schedule; the "manual"
        // fallback is applied in track() only when the filter hasn't set it.
        self::track('backup_failed', $phase, [
            'failure_phase'          => $phase,
            'archive_format'         => self::resolveArchiveFormat($package),
            'components'             => StatsUtil::getStatsComponents(self::readPackageProp($package, 'components', [])),
            'time_to_failure'        => self::computeTotalDuration($package),
            'processed_archive_size' => self::bytesToMb((int) self::readBuildProgressProp($package, 'processed_archive_size', 0)),
            'expected_archive_size'  => self::bytesToMb((int) self::readBuildProgressProp($package, 'expected_archive_size', 0)),
            'retries_count'          => (int) self::readBuildProgressProp($package, 'retries', 0),
        ], $package);
    }

    /**
     * `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('transfer_cancelled', $payload['storage_type'] ?? '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_failure', $payload['storage_type'] ?? 'unknown', $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,
            ];
        }

        $started  = (int) $uploadInfo->started_timestamp;
        $stopped  = (int) $uploadInfo->stopped_timestamp;
        $duration = ($stopped > $started) ? ($stopped - $started) : 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 status codes to a failure phase string.
     *
     * @param AbstractPackage $package Package whose status is inspected
     *
     * @return string One of: requirements, database, archive, storage, cancelled
     */
    private static function resolveFailurePhase(AbstractPackage $package): string
    {
        switch ((int) $package->getStatus()) {
            case AbstractPackage::STATUS_REQUIREMENTS_FAILED:
                return 'requirements';
            case AbstractPackage::STATUS_STORAGE_FAILED:
                return 'storage';
            case AbstractPackage::STATUS_STORAGE_CANCELLED:
            case AbstractPackage::STATUS_PENDING_CANCEL:
            case AbstractPackage::STATUS_BUILD_CANCELLED:
                return 'cancelled';
            case AbstractPackage::STATUS_ERROR:
            default:
                return 'archive';
        }
    }

    /**
     * Total wall-clock duration of the build, summing every recorded state.
     * Reads via the public `getStateDuration()` accessor — `state_times` is
     * protected on AbstractPackage and not directly visible.
     *
     * @param AbstractPackage $package Package whose state durations are summed
     *
     * @return int Duration in seconds
     */
    private static function computeTotalDuration(AbstractPackage $package): int
    {
        return self::sumStateDurations($package, [
            AbstractPackage::STATUS_PRE_PROCESS,
            AbstractPackage::STATUS_SCANNING,
            AbstractPackage::STATUS_SCAN_VALIDATION,
            AbstractPackage::STATUS_AFTER_SCAN,
            AbstractPackage::STATUS_START,
            AbstractPackage::STATUS_DBSTART,
            AbstractPackage::STATUS_DBDONE,
            AbstractPackage::STATUS_ARCSTART,
            AbstractPackage::STATUS_ARCVALIDATION,
            AbstractPackage::STATUS_ARCDONE,
            AbstractPackage::STATUS_COPIEDPACKAGE,
            AbstractPackage::STATUS_STORAGE_PROCESSING,
            AbstractPackage::STATUS_COMPLETE,
        ]);
    }

    /**
     * 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 int Duration in seconds
     */
    private static function sumStateDurations(AbstractPackage $package, array $statuses): int
    {
        $total = 0.0;
        foreach ($statuses as $status) {
            $duration = $package->getStateDuration($status);
            if ($duration > 0) {
                $total += $duration;
            }
        }

        return (int) round($total);
    }

    /**
     * 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 format short name from the live build mode set on
     * BuildProgress. Falls back to the global setting if BuildProgress
     * hasn't run yet (e.g. early build failure).
     *
     * @param AbstractPackage $package Package whose BuildProgress is inspected
     *
     * @return string One of: dup, zip, shellzip, unknown
     */
    private static function resolveArchiveFormat(AbstractPackage $package): string
    {
        $buildMode = (int) self::readBuildProgressProp($package, 'current_build_mode', -1);
        if ($buildMode < 0) {
            $buildMode = (int) GlobalEntity::getInstance()->archive_build_mode;
        }

        switch ($buildMode) {
            case PackageArchive::BUILD_MODE_DUP_ARCHIVE:
                return 'dup';
            case PackageArchive::BUILD_MODE_ZIP_ARCHIVE:
                return 'zip';
            case PackageArchive::BUILD_MODE_SHELL_EXEC:
                return 'shellzip';
            default:
                return 'unknown';
        }
    }

    /**
     * Resolve the database build mode from the global setting (the package
     * uses the global at build time, no per-package override). Worker
     * expects: mysqldump, php_single, php_multi.
     *
     * @return string
     */
    private static function resolveDbMode(): string
    {
        switch (GlobalEntity::getInstance()->package_phpdump_mode) {
            case WpDbUtils::PHPDUMP_MODE_SINGLE:
                return 'php_single';
            case WpDbUtils::PHPDUMP_MODE_MULTI:
                return 'php_multi';
            default:
                // mysqldump path is selected at build time when available;
                // PHPDUMP only kicks in as the fallback.
                return 'mysqldump';
        }
    }

    /**
     * Slot-friendly boolean string (`1` / `0`) for the `multi_threaded`
     * field — true when the active zip mode is multi-thread.
     *
     * @param AbstractPackage $package Package whose ziparchive_mode is inspected
     *
     * @return string
     */
    private static function resolveMultiThreaded(AbstractPackage $package): string
    {
        $mode = (int) self::readPackageProp($package, 'ziparchive_mode', PackageArchive::ZIP_MODE_SINGLE_THREAD);
        return $mode === PackageArchive::ZIP_MODE_MULTI_THREAD ? '1' : '0';
    }

    /**
     * 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);
    }
}
