<?php

declare(strict_types=1);

namespace PrettyLinks\Pro\Licensing;

defined('ABSPATH') || exit;

use PrettyLinks\Licensing\EditionMismatch;
use PrettyLinks\Licensing\LicenseClient;
use PrettyLinks\Licensing\LicenseManager;
use PrettyLinks\Licensing\ProState;

/**
 * Ports v3.x `PrliUpdateController::queue_update` + `plugin_info`.
 *
 * Hooks:
 *   pre_set_site_transient_update_plugins → queueUpdate
 *   plugins_api (priority 11)             → pluginInfo
 *
 * Preserves the exact transient keys used by 3.x (`prli_update_info`,
 * `prli_license_info`). Preserves the v3.x slug `pretty-link/pretty-link.php`
 * for the main plugin update entry.
 */
class UpdateChecker
{
    public const PLUGIN_SLUG = 'pretty-link/pretty-link.php';

    public const FALLBACK_SLUG = 'pretty-link-pro-developer';

    public const UPDATE_INFO_TTL = 12;

    /**
     * License manager.
     *
     * @var LicenseManager
     */
    private LicenseManager $manager;

    /**
     * License client.
     *
     * @var LicenseClient
     */
    private LicenseClient $client;

    /**
     * Currently installed plugin version.
     *
     * @var string
     */
    private string $currentVersion;

    /**
     * Constructor.
     *
     * @param LicenseManager $manager        License manager.
     * @param LicenseClient  $client         License client.
     * @param string         $currentVersion Currently installed plugin version.
     */
    public function __construct(LicenseManager $manager, LicenseClient $client, string $currentVersion)
    {
        $this->manager        = $manager;
        $this->client         = $client;
        $this->currentVersion = $currentVersion;
    }

    /**
     * Register the WP hooks. Only the filters that need a license are gated;
     * the plugin_info filter is always registered so addon modals still work.
     */
    public function register(): void
    {
        add_filter('pre_set_site_transient_update_plugins', [$this, 'queueUpdate']);
        add_filter('plugins_api', [$this, 'pluginInfo'], 11, 3);
        add_action('prli_plugin_edition_changed', [$this, 'clearUpdateTransients']);
    }

    /**
     * Injects the plugin-update entry for Pretty Links (if a newer version is
     * available on the Mothership). Called once when WP refreshes the
     * `update_plugins` site transient.
     *
     * @param  mixed $transient The WP update_plugins transient object.
     * @return mixed
     */
    public function queueUpdate($transient)
    {
        if (empty($transient) || !is_object($transient)) {
            return $transient;
        }

        $info = get_site_transient(LicenseManager::TRANSIENT_UPDATE_INFO);
        if (!is_array($info)) {
            $info = $this->fetchUpdateInfo();
            if (is_array($info)) {
                set_site_transient(
                    LicenseManager::TRANSIENT_UPDATE_INFO,
                    $info,
                    self::UPDATE_INFO_TTL * HOUR_IN_SECONDS
                );
            }
        }

        $currVersion = is_array($info) ? (string) ($info['curr_version'] ?? '') : '';
        $downloadUrl = is_array($info) ? (string) ($info['download_url'] ?? '') : '';

        // With `pro/` on disk, the ONLY package that may ever be offered for
        // our slug is a licensed Pro build. Pretty Links Pro shares the
        // `pretty-link` slug with the free wp.org plugin, so WordPress core
        // injects the wp.org *Lite* package into this transient on its own
        // whenever wp.org reports a newer version. `fetchUpdateInfo()` returns
        // a non-empty download URL ONLY when the license is valid and the
        // installed edition matches (active key, correct edition, mothership
        // reachable); every failure mode — no/expired/invalid license,
        // mothership unreachable, edition mismatch — yields an empty URL. In
        // any of those cases we must REMOVE the entry entirely so a manual or
        // automatic update can never overwrite Pro with Lite and wipe `pro/`.
        //
        // This restores the v3 behavior (PrliUpdateController::queue_update
        // unset the entry on any license/lookup failure); v4 regressed by
        // returning the transient with core's Lite entry left in place (#729).
        if (ProState::isProInstalled() && $downloadUrl === '') {
            // Edition mismatch is the one no-URL case that still needs a row:
            // WordPress only fires `in_plugin_update_message-<slug>`
            // (EditionMismatch::pluginUpdateRowMessage — the Plugins-screen
            // warning) when an update row exists for the plugin. Inject one
            // with an EMPTY package so the warning shows on plugins.php, yet
            // nothing can install: an empty package overwrites core's wp.org
            // Lite entry and WordPress treats `response[...]->package` as
            // authoritative, so there is still no Lite-over-Pro clobber (it
            // errors on click instead). Mirrors v3, which injected the same
            // empty-package row and hooked its mismatch notice on it.
            if (
                $currVersion !== ''
                && version_compare($currVersion, $this->currentVersion, '>')
                && EditionMismatch::detect() !== null
            ) {
                if (!isset($transient->response) || !is_array($transient->response)) {
                    $transient->response = [];
                }
                $transient->response[self::PLUGIN_SLUG] = (object) [
                    'id'          => $currVersion,
                    'plugin'      => self::PLUGIN_SLUG,
                    'slug'        => 'pretty-link',
                    'new_version' => $currVersion,
                    'url'         => 'https://prettylinks.com/pl/update/url',
                    'package'     => '',
                ];
                return $transient;
            }

            // No licensed package and no mismatch to surface (no/expired/invalid
            // license, or the mothership was unreachable): remove the entry so
            // core's wp.org Lite package can never overwrite Pro.
            if (isset($transient->response[self::PLUGIN_SLUG])) {
                unset($transient->response[self::PLUGIN_SLUG]);
            }
            return $transient;
        }

        if ($currVersion === '') {
            return $transient;
        }

        if ($downloadUrl !== '' && version_compare($currVersion, $this->currentVersion, '>')) {
            if (!isset($transient->response) || !is_array($transient->response)) {
                $transient->response = [];
            }
            $transient->response[self::PLUGIN_SLUG] = (object) [
                'id'          => $currVersion,
                'plugin'      => self::PLUGIN_SLUG,
                'slug'        => 'pretty-link',
                'new_version' => $currVersion,
                'url'         => 'https://prettylinks.com/pl/update/url',
                'package'     => $downloadUrl,
            ];
        } elseif (isset($transient->response[self::PLUGIN_SLUG])) {
            unset($transient->response[self::PLUGIN_SLUG]);
        }

        return $transient;
    }

    /**
     * Fills the modal shown on the Plugins screen when the user clicks
     * "View details".
     *
     * @param  mixed  $api    Result from earlier plugins_api filters.
     * @param  string $action The plugins_api action being performed.
     * @param  object $args   The plugins_api request arguments.
     * @return mixed
     */
    public function pluginInfo($api, $action, $args) // phpcs:ignore Squiz.Commenting.FunctionComment.ScalarTypeHintMissing -- WP plugins_api filter callback signature is fixed; $action stays untyped.
    {
        if ($action !== 'plugin_information') {
            return $api;
        }
        if (!isset($args->slug) || !preg_match('#^pretty-link-(basic|plus|pro)$#', (string) $args->slug)) {
            return $api;
        }

        // Tighter than v3's "key present" gate: expired licenses stop
        // receiving plugin-info lookups so the Plugins modal doesn't show
        // a download link that will 403 on the mothership.
        if (!ProState::isProInstalledAndActivated()) {
            return $api;
        }
        $key = (string) get_option(LicenseManager::OPTION_LICENSE_KEY, '');
        if ($key === '') {
            return $api;
        }

        $callArgs = [];
        if ($this->manager->edgeEnabled()) {
            $callArgs['edge'] = 'true';
        }

        $info = $this->client->pluginInformation((string) $args->slug, $key, $callArgs);
        if (!is_array($info) || isset($info['error'])) {
            return $api;
        }

        global $wp_version;
        if (isset($info['requires'])) {
            $info['requires'] = $wp_version;
        }
        if (isset($info['tested'])) {
            $info['tested'] = $wp_version;
        }
        if (isset($info['compatibility'])) {
            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- $wp_version is read here as array keys, not reassigned.
            $info['compatibility'] = [$wp_version => [$wp_version => [100, 0, 0]]];
        }

        return (object) $info;
    }

    /**
     * Clears the plugin-update and license-info transients.
     *
     * @return void
     */
    public function clearUpdateTransients(): void
    {
        delete_site_transient('update_plugins');
        delete_site_transient(LicenseManager::TRANSIENT_UPDATE_INFO);
        delete_site_transient(LicenseManager::TRANSIENT_ADDONS);
        delete_site_transient(LicenseManager::TRANSIENT_ALL_ADDONS);
    }

    /**
     * Force-refresh the plugin update info. Mirrors v3's
     * `PrliUpdateController::manually_queue_update()` — call after
     * activate/deactivate/edge-toggle so the Plugins page sees the new
     * download URL or current version without waiting 12h for the cache to
     * expire.
     */
    public function refreshUpdateInfo(): void
    {
        delete_site_transient(LicenseManager::TRANSIENT_UPDATE_INFO);
        delete_site_transient('update_plugins');

        $info = $this->fetchUpdateInfo();
        if (is_array($info)) {
            set_site_transient(
                LicenseManager::TRANSIENT_UPDATE_INFO,
                $info,
                self::UPDATE_INFO_TTL * HOUR_IN_SECONDS
            );
        }
    }

    /**
     * Fetches the latest plugin version info from the licensing endpoint.
     *
     * @return array<string, mixed>|null
     */
    private function fetchUpdateInfo(): ?array
    {
        $args = [];
        if ($this->manager->edgeEnabled()) {
            $args['edge'] = 'true';
        }

        // Activated installs get the licensed update URL; everyone else
        // (Lite, expired Pro key, Pro-without-key) falls through to the
        // anonymous latest-version endpoint so "update available" still
        // surfaces — it just won't carry a download URL.
        $key = (string) get_option(LicenseManager::OPTION_LICENSE_KEY, '');
        if (!ProState::isProInstalledAndActivated() || $key === '') {
            $response = $this->client->latestVersion(self::FALLBACK_SLUG, $args);
            if (isset($response['error']) || empty($response['version'])) {
                return null;
            }
            return [
                'curr_version' => (string) $response['version'],
                'download_url' => '',
            ];
        }

        $response = $this->client->versionInfo($key, $args);
        if (is_array($response) && !isset($response['error']) && !empty($response['version'])) {
            set_site_transient(
                LicenseManager::TRANSIENT_LICENSE_INFO,
                $response,
                24 * HOUR_IN_SECONDS
            );
            // V3 parity: blank the download URL when the installed edition
            // doesn't match the licensed edition. Otherwise WP would happily
            // auto-install the licensed edition over the installed one,
            // clobbering the user's intended install.
            $downloadUrl = (string) ($response['url'] ?? '');
            if (EditionMismatch::detect() !== null) {
                $downloadUrl = '';
            }
            return [
                'curr_version' => (string) $response['version'],
                'download_url' => $downloadUrl,
            ];
        }

        // Fallback to anonymous latest endpoint.
        $fallback = $this->client->latestVersion(self::FALLBACK_SLUG, $args);
        if (isset($fallback['error']) || empty($fallback['version'])) {
            return null;
        }
        return [
            'curr_version' => (string) $fallback['version'],
            'download_url' => '',
        ];
    }
}
