<?php
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

/**
 * API key handling — store HASH at rest, never cleartext.
 *
 * The provided key flows from the LinkQuiver SaaS into the request header
 * (X-Linkquiver-Key). The plugin only ever needs to verify a presented key,
 * it never needs to read the cleartext back. So we hash with the site's
 * wp_salt('auth') and store the hex digest in the `linkquiver_api_key_hash`
 * option. A database dump (or any plugin reading wp_options) only sees the
 * hash, which is useless to authenticate API calls.
 *
 * Migration: existing installs that have the legacy cleartext option
 * `linkquiver_api_key` (or a `config.php` constant from a pre-configured
 * zip) auto-migrate on the next successful validate() — the cleartext
 * source is hashed, the hash is stored, and the cleartext source is wiped.
 */
class Linkquiver_API_Key {

    const HASH_OPTION    = 'linkquiver_api_key_hash';
    const LEGACY_OPTION  = 'linkquiver_api_key';

    /**
     * True when the plugin has an API key configured (in any form).
     * Used by the admin UI to decide which state to render.
     */
    public static function has_key() {
        if ( ! empty( get_option( self::HASH_OPTION, '' ) ) ) {
            return true;
        }
        if ( ! empty( get_option( self::LEGACY_OPTION, '' ) ) ) {
            return true;
        }
        self::load_preconfig_once();
        return defined( 'LINKQUIVER_API_KEY' ) && ! empty( LINKQUIVER_API_KEY );
    }

    /**
     * True when the plugin shipped with a pre-configured key (file on
     * disk). Once the key has been validated once and persisted as a
     * hash, the file is wiped and this returns false.
     */
    public static function is_preconfigured() {
        self::load_preconfig_once();
        return defined( 'LINKQUIVER_API_KEY' ) && ! empty( LINKQUIVER_API_KEY );
    }

    private static function load_preconfig_once() {
        static $loaded = false;
        if ( $loaded ) return;
        $loaded = true;
        $config_file = LINKQUIVER_PATH . 'config.php';
        if ( file_exists( $config_file ) ) {
            require_once $config_file;
        }
    }

    /**
     * Save a manually entered API key. Stores the salted hash; the
     * cleartext is discarded as soon as this function returns.
     */
    public static function save( $key ) {
        $key = trim( (string) $key );
        if ( '' === $key ) {
            return false;
        }
        update_option( self::HASH_OPTION, self::hash_key( $key ), false );
        // Drop any legacy cleartext that lingered from a previous version.
        delete_option( self::LEGACY_OPTION );
        // Drop the preconfigured constant file too — its cleartext is
        // redundant now that we have the hash.
        self::wipe_preconfig_file();
        return true;
    }

    /**
     * Validate the API key from the request.
     *
     * Constant-time comparison against the stored salted hash. When a
     * legacy cleartext source is still present (option or config.php
     * constant), it's matched, then hashed, then wiped — so the cleartext
     * never survives a successful authentication.
     */
    public static function validate( WP_REST_Request $request ) {
        if ( ! self::has_key() ) {
            return new WP_Error(
                'linkquiver_not_configured',
                'Plugin not configured. Please enter your LinkQuiver API key in Settings > LinkQuiver.',
                array( 'status' => 403 )
            );
        }

        // Brute-force throttle: 10 failed attempts per IP per 5 minutes.
        //
        // ORDRE IMPORTANT (corrigé le 30/07/2026) : le throttle est évalué APRÈS
        // la comparaison de clé, jamais avant.
        //
        // Le bucket est indexé sur REMOTE_ADDR, la seule source non forgeable
        // dont un plugin dispose. Mais derrière Cloudflare ou un reverse proxy
        // qui ne restaure pas l'IP réelle, REMOTE_ADDR est celle du proxy : TOUS
        // les appelants partagent alors un seul bucket. Quand le throttle passait
        // avant la comparaison, n'importe quel tiers coupait la publication
        // LinkQuiver sur ce site pour 5 minutes avec 10 requêtes à mauvaise clé,
        // en boucle — le vrai appelant recevait 429 sans que sa clé soit même lue.
        //
        // Ce qu'on perd en inversant : un attaquant au-delà du budget continue de
        // faire évaluer ses tentatives. Ce n'est rien. La clé fait 256 bits
        // (randomBytes(32) côté SaaS), donc le plafond ne protégeait contre aucune
        // attaque calculable ; il ne faisait que créer un DoS gratuit.
        // Le comptage reste, il borne juste l'effort inutile et alimente la trace.
        $ip           = self::client_ip();
        $throttle_key = 'lq_fail_' . md5( $ip );
        $fails        = (int) get_transient( $throttle_key );

        $provided_key = $request->get_header( 'X-Linkquiver-Key' );
        if ( empty( $provided_key ) || ! is_string( $provided_key ) ) {
            return self::deny( $throttle_key, $fails );
        }

        $provided_hash = self::hash_key( $provided_key );

        // 1) Preferred: compare against the persisted hash.
        $stored_hash = get_option( self::HASH_OPTION, '' );
        if ( ! empty( $stored_hash ) && hash_equals( $stored_hash, $provided_hash ) ) {
            self::record_success( $throttle_key, $fails );
            return true;
        }

        // 2) Legacy cleartext option — match, then migrate to hash + delete.
        $legacy = get_option( self::LEGACY_OPTION, '' );
        if ( ! empty( $legacy ) && hash_equals( $legacy, $provided_key ) ) {
            update_option( self::HASH_OPTION, $provided_hash, false );
            delete_option( self::LEGACY_OPTION );
            self::record_success( $throttle_key, $fails );
            return true;
        }

        // 3) Pre-configured constant (shipped in the zip) — match, then
        // persist the hash and try to wipe the source file from disk.
        self::load_preconfig_once();
        if ( defined( 'LINKQUIVER_API_KEY' ) && ! empty( LINKQUIVER_API_KEY ) && hash_equals( LINKQUIVER_API_KEY, $provided_key ) ) {
            update_option( self::HASH_OPTION, $provided_hash, false );
            self::wipe_preconfig_file();
            self::record_success( $throttle_key, $fails );
            return true;
        }

        return self::deny( $throttle_key, $fails );
    }

    /**
     * Refus d'une tentative INVALIDE. Une clé valide n'atteint jamais ce chemin,
     * donc le plafond ne peut plus verrouiller le vrai appelant.
     *
     * Au-delà du budget on renvoie 429 sans incrémenter davantage : le compteur
     * est déjà à son plafond, continuer à l'incrémenter ne ferait que prolonger la
     * fenêtre pour un attaquant qui, de toute façon, ne devine pas une clé de
     * 256 bits.
     */
    private static function deny( $throttle_key, $fails ) {
        if ( $fails >= 10 ) {
            return new WP_Error(
                'linkquiver_throttled',
                'Too many failed attempts. Try again later.',
                array( 'status' => 429 )
            );
        }

        self::record_failure( $throttle_key, $fails );
        return new WP_Error( 'linkquiver_unauthorized', 'Invalid or missing API key.', array( 'status' => 401 ) );
    }

    /**
     * Constant-time check of a raw presented key against the stored
     * credential, WITHOUT the throttle / migration side effects of validate().
     * Used by the pre-auth `rest_authentication_errors` gate, which has no
     * WP_REST_Request to hand and must not clear another plugin's auth error
     * unless the key is genuinely valid.
     *
     * @param string $key
     * @return bool
     */
    public static function verify_raw_key( $key ) {
        if ( ! is_string( $key ) || '' === $key || ! self::has_key() ) {
            return false;
        }
        $provided_hash = self::hash_key( $key );

        $stored_hash = get_option( self::HASH_OPTION, '' );
        if ( ! empty( $stored_hash ) && hash_equals( $stored_hash, $provided_hash ) ) {
            return true;
        }
        $legacy = get_option( self::LEGACY_OPTION, '' );
        if ( ! empty( $legacy ) && hash_equals( $legacy, $key ) ) {
            return true;
        }
        self::load_preconfig_once();
        if ( defined( 'LINKQUIVER_API_KEY' ) && ! empty( LINKQUIVER_API_KEY ) && hash_equals( LINKQUIVER_API_KEY, $key ) ) {
            return true;
        }
        return false;
    }

    /**
     * Ensure the API key is persisted as a salted hash in wp_options, migrating
     * from any legacy cleartext option or preconfigured constant if that's the
     * only place it currently lives. Idempotent and side-effect-free when the
     * hash already exists.
     *
     * Called right before self-update overwrites the plugin folder: the generic
     * release build ships without config.php, so a site whose key was still only
     * in the preconfig/legacy source (never validated → never migrated) would
     * otherwise lose its credential on overwrite. This closes that window.
     */
    public static function ensure_hash_persisted() {
        if ( ! empty( get_option( self::HASH_OPTION, '' ) ) ) {
            return; // already have a hash — nothing to do.
        }

        $legacy = get_option( self::LEGACY_OPTION, '' );
        if ( ! empty( $legacy ) ) {
            update_option( self::HASH_OPTION, self::hash_key( $legacy ), false );
            delete_option( self::LEGACY_OPTION );
            return;
        }

        self::load_preconfig_once();
        if ( defined( 'LINKQUIVER_API_KEY' ) && ! empty( LINKQUIVER_API_KEY ) ) {
            update_option( self::HASH_OPTION, self::hash_key( LINKQUIVER_API_KEY ), false );
            self::wipe_preconfig_file();
        }
    }

    /**
     * Hash an API key with the per-site auth salt. wp_salt('auth') is
     * unique per WordPress install, so a stolen hash can't be matched
     * against a precomputed rainbow table without also knowing the salt.
     */
    private static function hash_key( $key ) {
        return hash_hmac( 'sha256', (string) $key, wp_salt( 'auth' ) );
    }

    /**
     * Overwrite the bundled config.php with a no-op so the cleartext
     * constant is no longer available to other plugins / file readers on
     * disk. If we can't write to the plugin directory (read-only install,
     * disowned plugin file), we silently keep going — the DB hash is
     * authoritative from this point on.
     */
    private static function wipe_preconfig_file() {
        $config_file = LINKQUIVER_PATH . 'config.php';
        if ( ! file_exists( $config_file ) ) {
            return;
        }
        if ( ! is_writable( $config_file ) ) {
            return;
        }
        @file_put_contents(
            $config_file,
            "<?php\n// LinkQuiver preconfigured key has been migrated to a salted hash in wp_options.\n// This file no longer carries credentials.\n"
        );
    }

    private static function record_failure( $throttle_key, $fails ) {
        set_transient( $throttle_key, $fails + 1, 5 * MINUTE_IN_SECONDS );
    }

    private static function record_success( $throttle_key, $fails ) {
        if ( $fails > 0 ) {
            delete_transient( $throttle_key );
        }
        // The "last validated" marker only needs hourly granularity (the admin
        // UI shows a human_time_diff and treats <7 days as "connected"). Skip
        // the DB write when it's already fresh so a bulk publish doesn't do one
        // update_option per API call.
        $last = (int) get_option( 'linkquiver_last_validated_at', 0 );
        if ( ( time() - $last ) > HOUR_IN_SECONDS ) {
            update_option( 'linkquiver_last_validated_at', time(), false );
        }
    }

    private static function client_ip() {
        $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
        if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return $ip;
        }
        return '0.0.0.0';
    }
}
