<?php
namespace EncypherProvenance;

if (! defined('ABSPATH')) {
    exit;
}

require_once __DIR__ . '/class-encypher-logger.php';

/**
 * Self-hosted update integration.
 *
 * Direct-download builds declare Encypher as their Update URI. WordPress calls
 * the matching hostname filter during its normal plugin update check; this
 * class returns metadata from the Encypher release manifest. WordPress remains
 * in control of installation: the site administrator can update manually or
 * enable WordPress auto-updates. Managed and WordPress.org builds exclude this
 * class and rewrite or remove the Update URI at package-build time.
 *
 * `plugins_api` powers the native "View details" modal. Package downloads are
 * verified against the manifest's SHA-256 before WordPress installs them.
 */
class Updater
{
    private const UPDATE_URI = 'https://encypher.com/downloads/wordpress-plugin';
    private const UPDATE_FILTER = 'update_plugins_encypher.com';
    private const MANIFEST_URL = self::UPDATE_URI . '/update';
    private const CACHE_KEY = 'encypher_provenance_update_manifest';
    private const CACHE_TTL = 6 * HOUR_IN_SECONDS;
    private const NEGATIVE_CACHE_TTL = 15 * MINUTE_IN_SECONDS;

    private string $plugin_basename;
    private string $plugin_slug;

    public function __construct()
    {
        $this->plugin_basename = plugin_basename(ENCYPHER_PROVENANCE_PLUGIN_FILE);
        // Directory name is the canonical plugin slug, e.g. "encypher-provenance".
        $this->plugin_slug = dirname($this->plugin_basename);
    }

    public function register_hooks(): void
    {
        add_filter(self::UPDATE_FILTER, [$this, 'provide_update'], 10, 4);
        add_filter('plugins_api', [$this, 'plugin_details'], 10, 3);
        add_action('upgrader_process_complete', [$this, 'flush_cache'], 10, 2);
        add_filter('upgrader_pre_download', [$this, 'verify_package_before_download'], 10, 3);
    }

    /**
     * Fetch and cache the remote manifest. Returns an associative array on
     * success, or null when the manifest is unreachable or malformed. A failed
     * fetch is negatively cached for a short window so a down endpoint does not
     * stall every admin page load.
     *
     * @return array<string,mixed>|null
     */
    private function get_manifest(): ?array
    {
        $cached = get_transient(self::CACHE_KEY);
        if (is_array($cached)) {
            return $cached;
        }
        if ('none' === $cached) {
            return null;
        }

        $url = apply_filters('encypher_provenance_update_manifest_url', self::MANIFEST_URL);
        // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get -- cron/admin update-manifest check against the fixed Encypher release host; result transient-cached, not a front-end call.
        $response = wp_remote_get(
            $url,
            [
                'timeout' => 8, // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- admin/cron update-manifest check; transient-cached, not a front-end request.
                'headers' => ['Accept' => 'application/json'],
            ]
        );

        if (is_wp_error($response) || 200 !== (int) wp_remote_retrieve_response_code($response)) {
            set_transient(self::CACHE_KEY, 'none', self::NEGATIVE_CACHE_TTL);
            return null;
        }

        $data = json_decode(wp_remote_retrieve_body($response), true);
        if (! is_array($data) || empty($data['version']) || empty($data['download_url'])) {
            set_transient(self::CACHE_KEY, 'none', self::NEGATIVE_CACHE_TTL);
            return null;
        }

        if (! self::is_trusted_package_url((string) $data['download_url'], $url)) {
            Logger::log(sprintf(
                'Encypher: rejecting update manifest with untrusted download_url host: %s',
                wp_parse_url((string) $data['download_url'], PHP_URL_HOST) ?: '(unparseable)'
            ));
            set_transient(self::CACHE_KEY, 'none', self::NEGATIVE_CACHE_TTL);
            return null;
        }

        set_transient(self::CACHE_KEY, $data, self::CACHE_TTL);
        return $data;
    }

    /**
     * Confirm a manifest-supplied download URL is safe to trust: same host
     * and scheme as the manifest URL that named it, so a compromised or
     * misconfigured manifest response cannot redirect installs to an
     * arbitrary host. Public and static (like evaluate()) so it is directly
     * unit-testable (see tests/test-updater-decision.php).
     */
    public static function is_trusted_package_url(string $download_url, string $manifest_url): bool
    {
        $pkg = wp_parse_url($download_url);
        $man = wp_parse_url($manifest_url);
        if (empty($pkg['host']) || empty($man['host'])) {
            return false;
        }
        // The package must be served from the same host+scheme as the manifest
        // that named it. A compromised or misconfigured manifest response
        // cannot redirect installs to an arbitrary host: the manifest URL
        // itself is either the hardcoded encypher.com endpoint or an explicit
        // filter override (self-hosted/on-prem deployments), so this check
        // travels correctly with that override.
        $required_scheme = $man['scheme'] ?? 'https';
        if (($pkg['scheme'] ?? '') !== $required_scheme) {
            return false;
        }
        return strcasecmp($pkg['host'], $man['host']) === 0;
    }

    /**
     * Supply this plugin's release metadata through WordPress's Update URI API.
     *
     * WordPress compares the returned `version` with the installed plugin and
     * records the item in either `response` or `no_update`. The payload omits
     * `autoupdate`, leaving automatic-install policy to WordPress and the site.
     *
     * @param array<string,mixed>|false $update
     * @param array<string,mixed>       $plugin_data
     * @param string                    $plugin_file
     * @param string[]                  $locales
     * @return array<string,mixed>|false
     */
    public function provide_update($update, $plugin_data, $plugin_file, $locales)
    {
        unset($locales);
        if (false !== $update || $plugin_file !== $this->plugin_basename) {
            return $update;
        }

        $manifest = $this->get_manifest();
        if (null === $manifest) {
            return false;
        }

        return self::build_update($manifest, $this->plugin_slug);
    }

    /**
     * Intercepts WordPress's native package download for OUR plugin's update
     * only (never touches other plugins/themes updating in the same batch),
     * downloads it ourselves, and verifies its SHA-256 against the value the
     * manifest published for this exact release before handing WordPress a
     * verified local file to install. Rejects the update entirely — never
     * falls back to an unverified install — on any mismatch OR when a valid,
     * matching, hashed manifest cannot be obtained for a package that is
     * unambiguously ours.
     *
     * "Ours" is decided by comparing $package's host against the manifest
     * URL's OWN host (self::MANIFEST_URL, or its filtered override) — not
     * against the live manifest response, which may be exactly what is
     * currently unavailable. This lets a transient manifest-fetch failure
     * (network blip, negative cache, stale entry) correctly fail CLOSED for
     * our own release host instead of being indistinguishable from "not our
     * package" and silently falling through to WordPress's unverified
     * downloader — the exact supply-chain gap this hook exists to close.
     *
     * Residual risk (documented, not silently claimed away): this defends
     * against transport/CDN tampering, host confusion, and a temporarily
     * unavailable manifest. It does NOT defend against a full compromise of
     * the marketing-site app server itself, since the same origin serves
     * both the hash and the bytes in that case. Closing that residual risk
     * requires out-of-band release signing (a keypair held outside the web
     * app, used at release-build time): a process change, not a code
     * change, and outside this fix's scope.
     *
     * @param bool|WP_Error $reply
     * @param string $package
     * @param \Plugin_Upgrader $upgrader
     * @return bool|string|WP_Error
     */
    public function verify_package_before_download($reply, $package, $upgrader)
    {
        if (false !== $reply) {
            return $reply;
        }

        $manifest_url = apply_filters('encypher_provenance_update_manifest_url', self::MANIFEST_URL);
        $our_host = self::url_host($manifest_url);
        $package_host = self::url_host($package);
        if (null === $our_host || null === $package_host || $our_host !== $package_host) {
            // Not our release host — some other plugin/theme updating in
            // the same batch. Let WordPress handle it normally.
            return $reply;
        }

        $manifest = $this->get_manifest();
        $manifest_matches_package = is_array($manifest)
            && !empty($manifest['download_url'])
            && $package === $manifest['download_url'];
        $expected_sha256 = (is_array($manifest) && isset($manifest['sha256']))
            ? strtolower((string) $manifest['sha256'])
            : '';

        if (! $manifest_matches_package || '' === $expected_sha256 || ! preg_match('/^[a-f0-9]{64}$/', $expected_sha256)) {
            // Our own release host, but no valid, matching, hashed manifest
            // available right now (fetch failed, negative-cached, or the
            // manifest changed since this package URL was queued). Fail
            // closed: a legitimate update attempt simply retries later once
            // the manifest is reachable again.
            return new \WP_Error(
                'encypher_update_unverifiable',
                __('Encypher Provenance update rejected: could not obtain a verified release manifest for this package. Please try again shortly.', 'encypher-provenance')
            );
        }

        if (! function_exists('download_url')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        $tmp_file = download_url($package);
        if (is_wp_error($tmp_file)) {
            return $tmp_file;
        }

        $actual_sha256 = (string) hash_file('sha256', $tmp_file);
        if (! hash_equals($expected_sha256, $actual_sha256)) {
            wp_delete_file($tmp_file); // Remove the temp package fetched by download_url() when its checksum fails.
            return new \WP_Error(
                'encypher_update_hash_mismatch',
                __('Encypher Provenance update rejected: downloaded package checksum did not match the release manifest. This may indicate a corrupted download or a tampered release channel.', 'encypher-provenance')
            );
        }

        return $tmp_file;
    }

    /**
     * Lowercased host of a URL, or null if unparseable/hostless.
     */
    private static function url_host(string $url): ?string
    {
        $host = wp_parse_url($url, PHP_URL_HOST);
        return is_string($host) && '' !== $host ? strtolower($host) : null;
    }

    /**
     * Build the payload expected by WordPress's Update URI hostname filter.
     *
     * WordPress owns version comparison and automatic-update policy. This
     * method only maps the authenticated Encypher manifest to the core shape.
     *
     * @param array<string,mixed> $manifest
     * @return array<string,mixed>|false
     */
    public static function build_update(array $manifest, string $slug)
    {
        $version = (string) ($manifest['version'] ?? '');
        $package = (string) ($manifest['download_url'] ?? '');
        if ('' === $version || '' === $package) {
            return false;
        }

        return [
            'slug' => $slug,
            'version' => $version,
            'url' => (string) ($manifest['homepage'] ?? ''),
            'package' => $package,
            'requires' => (string) ($manifest['requires'] ?? ''),
            'requires_php' => (string) ($manifest['requires_php'] ?? ''),
            'tested' => (string) ($manifest['tested'] ?? ''),
        ];
    }

    /**
     * Supply the "View details" modal payload for this plugin.
     *
     * @param mixed  $result
     * @param string $action
     * @param object $args
     * @return mixed
     */
    public function plugin_details($result, $action, $args)
    {
        if ('plugin_information' !== $action) {
            return $result;
        }
        if (! isset($args->slug) || $args->slug !== $this->plugin_slug) {
            return $result;
        }

        $manifest = $this->get_manifest();
        if (null === $manifest) {
            return $result;
        }

        $sections = isset($manifest['sections']) && is_array($manifest['sections']) ? $manifest['sections'] : [];

        return (object) [
            'name' => isset($manifest['name']) ? (string) $manifest['name'] : 'Encypher Provenance',
            'slug' => $this->plugin_slug,
            'version' => (string) $manifest['version'],
            'author' => isset($manifest['author']) ? (string) $manifest['author'] : 'Encypher Corporation',
            'homepage' => isset($manifest['homepage']) ? (string) $manifest['homepage'] : '',
            'requires' => isset($manifest['requires']) ? (string) $manifest['requires'] : '',
            'requires_php' => isset($manifest['requires_php']) ? (string) $manifest['requires_php'] : '',
            'tested' => isset($manifest['tested']) ? (string) $manifest['tested'] : '',
            'last_updated' => isset($manifest['last_updated']) ? (string) $manifest['last_updated'] : '',
            'download_link' => (string) $manifest['download_url'],
            'sections' => $sections,
        ];
    }

    /**
     * Drop the cached manifest after a plugin update completes so the next
     * update check reflects the freshly-installed version immediately.
     *
     * @param mixed              $upgrader
     * @param array<string,mixed> $data
     */
    public function flush_cache($upgrader, $data): void
    {
        if (! is_array($data)) {
            return;
        }
        if (($data['action'] ?? '') === 'update' && ($data['type'] ?? '') === 'plugin') {
            delete_transient(self::CACHE_KEY);
        }
    }
}
