<?php
/**
 * Native WordPress update channel.
 *
 * WHY THIS EXISTS
 * ---------------
 * Until now the only way this plugin could be updated was a push from the
 * LinkQuiver platform to POST /linkquiver/v1/self-update. That covers connected
 * sites and nothing else: a site whose API key was rotated, whose owner left the
 * platform, or that was installed from a zip by hand would run stale code
 * forever and never see so much as a notice in wp-admin. This wires the plugin
 * into WordPress's own update flow so "1 update available" shows up on the
 * Plugins screen and the admin can click it, connected or not.
 *
 * SECURITY MODEL — deliberately different from the usual self-hosted updater
 * -------------------------------------------------------------------------
 * The common pattern is: fetch update.json, take `download_url` from it, pin the
 * host it points at, maybe check a SHA-256 the same server also published. That
 * trusts one server for both the artifact and its checksum, so compromising the
 * server compromises both.
 *
 * Here the manifest is a version ANNOUNCEMENT and nothing more. It cannot name
 * the artifact: the package URL handed to WordPress is the same constant the
 * REST self-update route pins, and the bytes are verified against the RSA-2048
 * public key embedded in class-self-update.php before anything is unpacked. The
 * matching private key lives in CI, never on a server. So a fully compromised
 * linkquiver.com can announce whatever version it likes and still cannot get
 * this site to install code we did not sign.
 *
 * FAIL-CLOSED
 * -----------
 * Every failure in the download/verify path returns a WP_Error, which aborts the
 * upgrade. Notably: if the signature cannot be fetched, the install stops. It
 * would be easy to "let it through and log a warning" when a check cannot run —
 * that is precisely the branch that turns a verified channel into an unverified
 * one on the day it matters.
 *
 * OUTBOUND TRAFFIC
 * ----------------
 * This is the plugin's first unprompted outbound connection: one manifest GET
 * per site roughly twice a day, cached 12h. It sends no site identity — the
 * User-Agent is overridden because WordPress's default appends the site's own
 * home URL to every remote request. Disable with the `LINKQUIVER_DISABLE_UPDATER`
 * constant or the `linkquiver_updater_enabled` filter.
 *
 * @package Linkquiver
 */

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

class Linkquiver_Updater {

    const MANIFEST_URL = 'https://linkquiver.com/api/wordpress/linkquiver/self-update/manifest';

    const SLUG          = 'linkquiver';
    const CACHE_KEY     = 'linkquiver_update_manifest';
    const NEGATIVE      = '__lq_no_manifest__';
    const CACHE_TTL     = 12 * HOUR_IN_SECONDS;
    const NEGATIVE_TTL  = 900; // 15 min — do not hammer a down endpoint

    public static function init() {
        add_filter( 'pre_set_site_transient_update_plugins', array( __CLASS__, 'inject_update' ) );
        add_filter( 'plugins_api', array( __CLASS__, 'plugin_details' ), 10, 3 );
        add_filter( 'upgrader_pre_download', array( __CLASS__, 'download_and_verify' ), 10, 4 );
        add_action( 'upgrader_process_complete', array( __CLASS__, 'flush_cache' ), 10, 2 );
    }

    public static function basename() {
        return plugin_basename( LINKQUIVER_PATH . 'linkquiver.php' );
    }

    /**
     * Three ways to turn the update check off, in precedence order:
     * a wp-config constant, then a filter.
     */
    public static function is_enabled() {
        if ( defined( 'LINKQUIVER_DISABLE_UPDATER' ) && LINKQUIVER_DISABLE_UPDATER ) {
            return false;
        }
        return (bool) apply_filters( 'linkquiver_updater_enabled', true );
    }

    private static function manifest_url() {
        return (string) apply_filters( 'linkquiver_updater_manifest_url', self::MANIFEST_URL );
    }

    // ── Manifest ────────────────────────────────────────────────────────

    /**
     * Fetch (or read from cache) the version manifest.
     *
     * The negative result is cached under a distinct sentinel STRING rather
     * than an empty array, so "endpoint was down" and "endpoint returned
     * nothing useful" can never be mistaken for a valid payload by a caller
     * that only checks for null.
     *
     * @param bool $force Skip the cache (used when the admin opens the details modal).
     * @return array|null
     */
    public static function fetch_manifest( $force = false ) {
        if ( ! $force ) {
            $cached = get_site_transient( self::CACHE_KEY );
            if ( self::NEGATIVE === $cached ) {
                return null;
            }
            if ( is_array( $cached ) && ! empty( $cached['version'] ) ) {
                return $cached;
            }
        }

        $response = wp_remote_get( self::manifest_url(), array(
            'timeout'    => 8,
            'sslverify'  => true,
            'user-agent' => 'LinkQuiver-Plugin/' . LINKQUIVER_VERSION,
            'headers'    => array( 'Accept' => 'application/json' ),
        ) );

        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            set_site_transient( self::CACHE_KEY, self::NEGATIVE, self::NEGATIVE_TTL );
            return null;
        }

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) || empty( $data['version'] ) || ! is_string( $data['version'] ) ) {
            set_site_transient( self::CACHE_KEY, self::NEGATIVE, self::NEGATIVE_TTL );
            return null;
        }

        // Accept x.y.z with an optional pre-release suffix, nothing exotic.
        if ( ! preg_match( '/^\d+\.\d+\.\d+(-(alpha|beta|rc|dev)\d*)?$/i', $data['version'] ) ) {
            set_site_transient( self::CACHE_KEY, self::NEGATIVE, self::NEGATIVE_TTL );
            return null;
        }

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

    // ── WordPress update plumbing ───────────────────────────────────────

    /**
     * Build the object WordPress expects in the update transient.
     *
     * `package` is OUR pinned constant, never a manifest field. That single
     * line is what makes the manifest untrusted-by-construction.
     */
    private static function build_update_object( array $manifest ) {
        $sanitize = static function ( $value, $fallback = '' ) {
            return ( isset( $value ) && is_string( $value ) ) ? sanitize_text_field( $value ) : $fallback;
        };

        return (object) array(
            'id'           => self::basename(),
            'slug'         => self::SLUG,
            'plugin'       => self::basename(),
            'new_version'  => $manifest['version'],
            'url'          => 'https://linkquiver.com',
            'package'      => Linkquiver_Self_Update::source_zip(),
            'tested'       => $sanitize( $manifest['tested'] ?? null ),
            'requires'     => $sanitize( $manifest['requires'] ?? null ),
            'requires_php' => $sanitize( $manifest['requires_php'] ?? null ),
            'icons'        => array(),
            'banners'      => array(),
            'banners_rtl'  => array(),
        );
    }

    /**
     * @param object $transient
     * @return object
     */
    public static function inject_update( $transient ) {
        if ( ! self::is_enabled() || ! is_object( $transient ) ) {
            return $transient;
        }

        $manifest = self::fetch_manifest();
        if ( null === $manifest ) {
            return $transient;
        }

        $update = self::build_update_object( $manifest );
        $file   = self::basename();

        if ( version_compare( $manifest['version'], LINKQUIVER_VERSION, '>' ) ) {
            if ( ! isset( $transient->response ) || ! is_array( $transient->response ) ) {
                $transient->response = array();
            }
            $transient->response[ $file ] = $update;
        } else {
            // Listing it under no_update is what makes the "Enable auto-updates"
            // link appear on the Plugins screen for a plugin that is current.
            if ( ! isset( $transient->no_update ) || ! is_array( $transient->no_update ) ) {
                $transient->no_update = array();
            }
            $transient->no_update[ $file ] = $update;
        }

        return $transient;
    }

    /**
     * Feed the "View details" modal.
     *
     * `sections` is remote HTML rendered inside wp-admin, so it goes through
     * wp_kses_post() — same allowlist core uses for post content. Without it a
     * compromised manifest endpoint would be a stored-XSS vector in the admin,
     * which is a much cheaper attack than getting a signed zip installed.
     */
    public static function plugin_details( $result, $action, $args ) {
        if ( ! self::is_enabled() || 'plugin_information' !== $action ) {
            return $result;
        }
        if ( ! isset( $args->slug ) || self::SLUG !== $args->slug ) {
            return $result;
        }

        $manifest = self::fetch_manifest( true );
        if ( null === $manifest ) {
            return $result;
        }

        $sections = array();
        $raw      = isset( $manifest['sections'] ) && is_array( $manifest['sections'] ) ? $manifest['sections'] : array();
        foreach ( $raw as $key => $html ) {
            if ( ! is_string( $key ) || ! is_string( $html ) ) {
                continue;
            }
            $clean_key = sanitize_key( $key );
            if ( '' === $clean_key ) {
                continue;
            }
            $sections[ $clean_key ] = wp_kses_post( $html );
        }

        $text = static function ( $value, $fallback = '' ) {
            return ( isset( $value ) && is_string( $value ) ) ? sanitize_text_field( $value ) : $fallback;
        };

        return (object) array(
            'name'              => 'LinkQuiver',
            'slug'              => self::SLUG,
            'version'           => $manifest['version'],
            'author'            => '<a href="https://linkquiver.com">LinkQuiver</a>',
            'author_profile'    => 'https://linkquiver.com',
            'homepage'          => 'https://linkquiver.com',
            'requires'          => $text( $manifest['requires'] ?? null ),
            'requires_php'      => $text( $manifest['requires_php'] ?? null ),
            'tested'            => $text( $manifest['tested'] ?? null ),
            'last_updated'      => $text( $manifest['last_updated'] ?? null ),
            'short_description' => $text( $manifest['short_description'] ?? null ),
            'sections'          => $sections,
            'download_link'     => Linkquiver_Self_Update::source_zip(),
            'banners'           => array(),
            'icons'             => array(),
        );
    }

    // ── Download + signature verification ───────────────────────────────

    /**
     * Replace WordPress's download step for OUR package with download + verify.
     *
     * Returning a path short-circuits core's own download; returning a WP_Error
     * aborts the upgrade. Both are exactly what we want, and it is the only hook
     * that hands us the artifact before it is unpacked while still letting us
     * refuse it.
     *
     * Scope is doubly narrow — the upgrade must be for our plugin file AND the
     * package URL must be the constant we pinned. Another plugin rewriting the
     * transient to point somewhere else does not get our verification stamped
     * on it; it simply is not us, and we return untouched.
     *
     * @param bool|WP_Error|string $reply
     * @param string               $package
     * @param WP_Upgrader          $upgrader
     * @param array                $hook_extra
     * @return bool|WP_Error|string
     */
    public static function download_and_verify( $reply, $package, $upgrader, $hook_extra = array() ) {
        if ( false !== $reply ) {
            return $reply; // another filter already handled it
        }
        if ( ! is_array( $hook_extra ) || ( $hook_extra['plugin'] ?? '' ) !== self::basename() ) {
            return $reply;
        }
        if ( ! is_string( $package ) || $package !== Linkquiver_Self_Update::source_zip() ) {
            return $reply;
        }

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

        $tmp = download_url( $package, 60 );
        if ( is_wp_error( $tmp ) ) {
            return $tmp;
        }

        $sig = Linkquiver_Self_Update::fetch_signature();
        if ( is_wp_error( $sig ) ) {
            @unlink( $tmp );
            return new WP_Error(
                'linkquiver_signature_unavailable',
                __( 'LinkQuiver update aborted: the release signature could not be retrieved, so the download could not be verified.', 'linkquiver' )
            );
        }

        $verified = Linkquiver_Self_Update::verify_zip_signature( $tmp, $sig );
        if ( is_wp_error( $verified ) ) {
            @unlink( $tmp );
            return new WP_Error(
                'linkquiver_signature_mismatch',
                sprintf(
                    /* translators: %s: technical reason the signature check failed. */
                    __( 'LinkQuiver update aborted: the downloaded package failed signature verification (%s). Nothing was installed.', 'linkquiver' ),
                    $verified->get_error_message()
                )
            );
        }

        // The site's key may still live only in the legacy cleartext source; the
        // release build ships without config.php, so persist the hash before the
        // folder is overwritten or the credential is lost with the file.
        Linkquiver_API_Key::ensure_hash_persisted();

        return $tmp;
    }

    public static function flush_cache( $upgrader, $hook_extra ) {
        if ( ! is_array( $hook_extra ) || ( $hook_extra['type'] ?? '' ) !== 'plugin' ) {
            return;
        }
        $plugins = isset( $hook_extra['plugins'] ) && is_array( $hook_extra['plugins'] )
            ? $hook_extra['plugins']
            : array( $hook_extra['plugin'] ?? '' );

        if ( in_array( self::basename(), $plugins, true ) ) {
            delete_site_transient( self::CACHE_KEY );
            delete_site_transient( 'update_plugins' );
        }
    }
}
