<?php
/**
 * Plugin Name: LinkQuiver
 * Plugin URI: https://linkquiver.com
 * Description: Connecte votre site WordPress à LinkQuiver : publication assistée de contenu, gestion de redirections 301, et Theme Engine — un design complet en HTML/CSS pilotable par un agent IA via l'API REST. Aucune limite de durée.
 * Version: 3.6.0
 * Author: LinkQuiver
 * Author URI: https://linkquiver.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: linkquiver
 * Domain Path: /languages
 * Requires at least: 5.6
 * Requires PHP: 7.4
 */

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

define( 'LINKQUIVER_VERSION', '3.6.0' );
// Bumped whenever the DB schema (redirects table, AI crawler table) or option
// shape changes, so an in-place zip update (which does NOT re-fire the
// activation hook) can run migrations via the plugins_loaded check below.
define( 'LINKQUIVER_DB_VERSION', '2' );
define( 'LINKQUIVER_PATH', plugin_dir_path( __FILE__ ) );
define( 'LINKQUIVER_URL', plugin_dir_url( __FILE__ ) );

require_once LINKQUIVER_PATH . 'includes/class-api-key.php';
require_once LINKQUIVER_PATH . 'includes/class-rest-api.php';
require_once LINKQUIVER_PATH . 'includes/class-health-check.php';
require_once LINKQUIVER_PATH . 'includes/class-seo-handler.php';
require_once LINKQUIVER_PATH . 'includes/class-media-handler.php';
require_once LINKQUIVER_PATH . 'includes/class-cors.php';
require_once LINKQUIVER_PATH . 'includes/class-redirect-engine.php';
require_once LINKQUIVER_PATH . 'includes/class-self-update.php';
require_once LINKQUIVER_PATH . 'includes/class-updater.php';
require_once LINKQUIVER_PATH . 'includes/class-ai-crawlers.php';
require_once LINKQUIVER_PATH . 'includes/class-theme-engine.php';
require_once LINKQUIVER_PATH . 'includes/class-theme-shortcodes.php';
require_once LINKQUIVER_PATH . 'includes/class-platform-credentials.php';
require_once LINKQUIVER_PATH . 'admin/class-admin-page.php';

final class Linkquiver {

    private static $instance = null;

    public static function instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct() {
        Linkquiver_CORS::init();
        Linkquiver_Redirect_Engine::init();
        Linkquiver_AI_Crawlers::init();
        Linkquiver_Updater::init();

        $theme_engine = new Linkquiver_Theme_Engine();
        $theme_engine->init();

        $theme_shortcodes = new Linkquiver_Theme_Shortcodes();
        add_action( 'init', array( $theme_shortcodes, 'init' ) );

        // Load translations (FR/EN strings live in templates + admin). Harmless
        // no-op until a /languages bundle ships, but wires up the declared
        // Text Domain correctly.
        add_action( 'init', array( $this, 'load_textdomain' ) );

        // Run DB/option migrations on in-place updates (zip re-upload doesn't
        // re-fire register_activation_hook).
        //
        // PRIORITÉ 20, PAS 10 — et ce n'est pas cosmétique. Ce constructeur est
        // lui-même appelé DEPUIS `plugins_loaded` à la priorité 10. Or
        // WP_Hook::apply_filters itère `foreach ( $this->callbacks[$priority] )`
        // sur une COPIE du tableau : une callback ajoutée à la priorité en cours
        // n'est jamais vue par la boucle qui tourne. À 10, `maybe_upgrade` était
        // enregistrée puis silencieusement ignorée à chaque requête, donc aucune
        // migration ne s'appliquait jamais sur une mise à jour en place — seule
        // l'activation initiale créait les tables. Une priorité SUPÉRIEURE crée
        // un nouveau bucket, que `resort_active_iterations()` réinsère dans
        // l'itération en cours. Vérifié sur WP 7.0.2 le 31/07/2026.
        add_action( 'plugins_loaded', array( $this, 'maybe_upgrade' ), 20 );

        // Create the redirects table for freshly-created subsites on multisite.
        add_action( 'wp_initialize_site', array( $this, 'on_new_site' ), 20 );

        add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
        add_action( 'admin_init', array( $this, 'activation_redirect' ) );

        // Compat with "disable REST API" / forced-auth security plugins
        // (Wordfence, iThemes, etc.): let a valid X-Linkquiver-Key request reach
        // our own permission_callback instead of being blocked namespace-wide.
        add_filter( 'rest_authentication_errors', array( $this, 'allow_keyed_namespace_requests' ), 99 );

        // Interdit à tout cache (CDN, page cache) de stocker/rejouer une réponse
        // authentifiée par clé API. Voir send_private_cache_headers().
        add_filter( 'rest_post_dispatch', array( $this, 'send_private_cache_headers' ), 10, 3 );

        // Anti-footprint : retire le namespace de l'énumération REST publique.
        // Les routes restent pleinement fonctionnelles en appel direct (clé
        // API), seule la découverte via /wp-json/ est supprimée.
        add_filter( 'rest_index', array( $this, 'hide_namespace_from_index' ) );
        add_filter( 'rest_namespace_index', array( $this, 'hide_namespace_index' ), 10, 2 );

        if ( is_admin() ) {
            new Linkquiver_Admin_Page();
        }
    }

    public function load_textdomain() {
        load_plugin_textdomain( 'linkquiver', false, dirname( plugin_basename( __FILE__ ) ) . '/languages' );
    }

    /**
     * Idempotent migration runner. Ensures the redirects table exists and is
     * up to date after an in-place update, then records the DB version so this
     * short-circuits (one autoloaded get_option) on every subsequent request.
     */
    public function maybe_upgrade() {
        if ( get_option( 'linkquiver_db_version' ) === LINKQUIVER_DB_VERSION ) {
            return;
        }
        Linkquiver_Redirect_Engine::create_table(); // dbDelta, idempotent
        Linkquiver_AI_Crawlers::create_table();     // dbDelta, idempotent
        Linkquiver_AI_Crawlers::schedule_cron();
        update_option( 'linkquiver_db_version', LINKQUIVER_DB_VERSION );
    }

    /**
     * Create the redirects table on a newly-created multisite subsite so its
     * front-end redirect lookups don't hit a missing table.
     *
     * @param WP_Site $new_site
     */
    public function on_new_site( $new_site ) {
        if ( ! is_multisite() ) {
            return;
        }
        switch_to_blog( (int) $new_site->blog_id );
        Linkquiver_Redirect_Engine::create_table();
        Linkquiver_AI_Crawlers::create_table();
        update_option( 'linkquiver_db_version', LINKQUIVER_DB_VERSION );
        restore_current_blog();
    }

    /**
     * When another plugin has forced REST authentication and returned a blanket
     * WP_Error, clear it ONLY for a linkquiver/v1 request that presents a VALID
     * API key. Two guards make this safe:
     *   1. Route is taken from the REST route WordPress actually resolved
     *      (`$wp->query_vars['rest_route']`) and anchored at its start — not a
     *      substring of REQUEST_URI, which an attacker could pad
     *      (`/wp/v2/users?x=/wp-json/linkquiver/v1/`) to clear auth on a core route.
     *   2. The presented key is verified (constant-time) before we return true,
     *      so a bogus/empty X-Linkquiver-Key header can never clear the error.
     * Our route permission_callback still performs the real gate afterwards.
     */
    public function allow_keyed_namespace_requests( $result ) {
        // Already authenticated (cookie/nonce/app-password) — leave untouched.
        if ( true === $result || null === $result ) {
            return $result;
        }

        // Resolved REST route (anchored), set by WP during parse_request.
        $route = '';
        if ( isset( $GLOBALS['wp'] ) && ! empty( $GLOBALS['wp']->query_vars['rest_route'] ) ) {
            $route = (string) $GLOBALS['wp']->query_vars['rest_route'];
        }
        if ( '/linkquiver/v1' !== $route && 0 !== strpos( $route, '/linkquiver/v1/' ) ) {
            return $result;
        }

        $key = isset( $_SERVER['HTTP_X_LINKQUIVER_KEY'] )
            ? wp_unslash( $_SERVER['HTTP_X_LINKQUIVER_KEY'] )
            : '';
        if ( Linkquiver_API_Key::verify_raw_key( $key ) ) {
            return true;
        }
        return $result;
    }

    public function activation_redirect() {
        // Hooked on admin_init, which also fires on admin-ajax and some REST
        // bootstraps — bail unless this is a real admin screen so we never
        // attempt a redirect mid-API-call.
        if ( ! is_admin() ) {
            return;
        }
        if ( ! get_transient( 'linkquiver_activation_redirect' ) ) {
            return;
        }

        delete_transient( 'linkquiver_activation_redirect' );

        if ( isset( $_GET['activate-multi'] ) || is_network_admin() || wp_doing_ajax() ) {
            return;
        }

        if ( isset( $_GET['page'] ) && 'linkquiver' === $_GET['page'] ) {
            return;
        }

        wp_safe_redirect( admin_url( 'options-general.php?page=linkquiver' ) );
        exit;
    }

    public function register_rest_routes() {
        $rest_api = new Linkquiver_Rest_API();
        $rest_api->register_routes();

        // Lecture des jetons déposés par les plugins des marketplaces. Classe à
        // part : elle n'a rien à voir avec la publication, et la garder isolée
        // rend son allowlist lisible d'un coup d'œil.
        $platform_credentials = new Linkquiver_Platform_Credentials();
        $platform_credentials->register_routes();
    }

    /**
     * Marque toute réponse linkquiver/v1 comme non cachable.
     *
     * POURQUOI : beaucoup d'hébergements WordPress tournent derrière une règle
     * de cache globale (Cloudflare Cache Rules / Page Rules, LiteSpeed, WP
     * Rocket) qui avale aussi /wp-json/. Ces caches indexent sur l'URL seule —
     * l'en-tête X-Linkquiver-Key n'entre pas dans la clé de cache et n'est pas
     * dans Vary. Sans ces en-têtes, la première réponse mise en cache peut être
     * rejouée à n'importe quel appelant : soit un 401/403 périmé qui fait
     * passer un plugin sain pour cassé, soit — bien pire — un 200 authentifié
     * (catégories, auteurs, thème) servi à un visiteur anonyme.
     *
     * Un CDN configuré pour ignorer les en-têtes d'origine (Edge TTL forcé)
     * passera outre : c'est pourquoi le SaaS ajoute en plus un paramètre
     * anti-cache unique sur chaque GET (libs/wordpress/linkquiver-client.ts).
     *
     * @param WP_HTTP_Response $response
     * @param WP_REST_Server   $server
     * @param WP_REST_Request  $request
     * @return WP_HTTP_Response
     */
    public function send_private_cache_headers( $response, $server, $request ) {
        if ( ! ( $request instanceof WP_REST_Request ) || ! is_object( $response ) ) {
            return $response;
        }
        $route = ltrim( (string) $request->get_route(), '/' );
        if ( 0 !== strpos( $route, 'linkquiver/v1' ) ) {
            return $response;
        }
        if ( ! method_exists( $response, 'header' ) ) {
            return $response;
        }
        $response->header( 'Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0, private', true );
        $response->header( 'Pragma', 'no-cache', true );
        // La réponse sort avec DEUX en-têtes Vary : celui-ci, et le « Vary:
        // Origin » que le core repose via rest_send_cors_headers() sur
        // rest_pre_serve_request, donc APRÈS ce filtre (un header_remove ici ne
        // sert à rien, vérifié sur WP 7.0.2). C'est valide : RFC 9110 §5.3 dit
        // que plusieurs lignes de même nom équivalent à une liste séparée par
        // des virgules, donc tout cache voit bien l'union des trois en-têtes.
        $response->header( 'Vary', 'Origin, X-Linkquiver-Key, Authorization', true );
        return $response;
    }

    /**
     * Retire linkquiver/v1 (namespace + routes) de l'index public /wp-json/
     * pour ne laisser aucune trace dans l'énumération REST. Les routes
     * continuent de répondre en appel direct avec une clé API valide ;
     * seule la découverte est masquée.
     */
    public function hide_namespace_from_index( $response ) {
        $data = $response->get_data();

        if ( isset( $data['namespaces'] ) && is_array( $data['namespaces'] ) ) {
            $data['namespaces'] = array_values( array_filter(
                $data['namespaces'],
                function ( $ns ) { return 'linkquiver/v1' !== $ns; }
            ) );
        }

        if ( isset( $data['routes'] ) && is_array( $data['routes'] ) ) {
            foreach ( array_keys( $data['routes'] ) as $route ) {
                if ( 0 === strpos( $route, '/linkquiver/v1' ) ) {
                    unset( $data['routes'][ $route ] );
                }
            }
        }

        $response->set_data( $data );
        return $response;
    }

    /**
     * Renvoie un 404 identique à celui d'une route inexistante pour
     * /wp-json/linkquiver/v1, afin que l'index de namespace soit
     * indiscernable d'un namespace qui n'existe pas.
     */
    public function hide_namespace_index( $response, $request ) {
        $data = $response->get_data();
        if ( isset( $data['namespace'] ) && 'linkquiver/v1' === $data['namespace'] ) {
            return new WP_REST_Response( array(
                'code'    => 'rest_no_route',
                'message' => 'No route was found matching the URL and request method.',
                'data'    => array( 'status' => 404 ),
            ), 404 );
        }
        return $response;
    }

    /**
     * @param bool $network_wide True when network-activated on multisite —
     *                           create the table on every existing subsite.
     */
    public static function activate( $network_wide = false ) {
        if ( is_multisite() && $network_wide ) {
            $sites = function_exists( 'get_sites' ) ? get_sites( array( 'number' => 0 ) ) : array();
            foreach ( $sites as $site ) {
                switch_to_blog( (int) $site->blog_id );
                self::activate_single_site();
                restore_current_blog();
            }
            return;
        }
        self::activate_single_site();

        // Only redirect to Settings for a normal single-site activation.
        set_transient( 'linkquiver_activation_redirect', true, 60 );
    }

    private static function activate_single_site() {
        update_option( 'linkquiver_activated_at', time() );
        Linkquiver_Redirect_Engine::create_table();
        Linkquiver_AI_Crawlers::create_table();
        Linkquiver_AI_Crawlers::schedule_cron();
        update_option( 'linkquiver_db_version', LINKQUIVER_DB_VERSION );
    }

    public static function deactivate() {
        // No rewrite rules or CPTs are registered, so there is nothing to flush.
        // The AI-crawler cron events must go, though: WordPress keeps scheduled
        // hooks in the DB after deactivation, and a hook whose callback no
        // longer exists fires a "missed schedule" every run forever.
        Linkquiver_AI_Crawlers::unschedule_cron();
    }
    // Uninstall is handled by uninstall.php (WordPress standard).
}

register_activation_hook( __FILE__, array( 'Linkquiver', 'activate' ) );
register_deactivation_hook( __FILE__, array( 'Linkquiver', 'deactivate' ) );

// Settings link on plugins page
add_filter( 'plugin_action_links_' . plugin_basename( __FILE__ ), function ( $links ) {
    $url = admin_url( 'options-general.php?page=linkquiver' );
    array_unshift( $links, '<a href="' . esc_url( $url ) . '">Settings</a>' );
    return $links;
} );

add_action( 'plugins_loaded', array( 'Linkquiver', 'instance' ) );
