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

class Linkquiver_Rest_API {

    const NAMESPACE = 'linkquiver/v1';

    /**
     * Reject meta keys that touch WordPress core, capability, session, user
     * level, password, or known sensitive option-like prefixes. The plugin's
     * API key is a content-publishing credential, not an auth/session one —
     * it must not be usable to forge user-state postmeta.
     */
    private static function is_meta_key_denied( $key ) {
        if ( '' === $key ) return true;

        // Hard-block these exact keys (and any leading-underscore variant).
        $exact = array(
            'wp_capabilities',
            'wp_user_level',
            'session_tokens',
            'wp_user_settings',
            'wp_user_settings_time',
            'community-events-location',
            'syntax_highlighting',
            'rich_editing',
            'admin_color',
            'use_ssl',
            'show_admin_bar_front',
            'locale',
            'user_activation_key',
            'user_pass',
            // Reserved for the idempotency machinery — must only be set by
            // the publish() flow itself, never via user-supplied meta, else
            // a caller could mark an unrelated post as already-published
            // and short-circuit future legitimate publishes.
            '_lq_idem_key',
        );
        foreach ( $exact as $deny ) {
            if ( $key === $deny || $key === '_' . $deny ) return true;
        }

        // Block prefixes that namespace core / auth state.
        $deny_prefixes = array(
            '_wp_',
            'wp_',
            'session_',
            '_session_',
            '_capabilities',
            '_user_level',
            '_password',
        );
        foreach ( $deny_prefixes as $prefix ) {
            if ( 0 === strpos( $key, $prefix ) ) return true;
        }

        return false;
    }

    /**
     * Register read-only routes.
     */
    public function register_readonly_routes() {
        register_rest_route( self::NAMESPACE, '/health', array(
            'methods'             => 'GET',
            'callback'            => array( $this, 'health_check' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        register_rest_route( self::NAMESPACE, '/categories', array(
            'methods'             => 'GET',
            'callback'            => array( $this, 'get_categories' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        register_rest_route( self::NAMESPACE, '/authors', array(
            'methods'             => 'GET',
            'callback'            => array( $this, 'get_authors' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        // Paginated dump of post_content RAW (unfiltered, exactly what
        // /wp/v2/posts?context=edit returns to an Application Password). The
        // internal-linking engine needs the SOURCE markup: the rendered HTML
        // served publicly has run through shortcodes, wpautop and block
        // rendering, so a paragraph located in it can never be written back.
        register_rest_route( self::NAMESPACE, '/posts', array(
            'methods'             => 'GET',
            'callback'            => array( $this, 'list_posts_raw' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        // Aggregated AI-crawler visibility. Read-only, no per-visitor data
        // exists to return — the table is already an aggregate.
        register_rest_route( self::NAMESPACE, '/ai-crawlers', array(
            'methods'             => 'GET',
            'callback'            => array( $this, 'ai_crawlers' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
            'args'                => array(
                'days' => array(
                    'default'           => 30,
                    'sanitize_callback' => 'absint',
                ),
                'top'  => array(
                    'default'           => 100,
                    'sanitize_callback' => 'absint',
                ),
            ),
        ) );

        register_rest_route( self::NAMESPACE, '/redirects', array(
            array(
                'methods'             => 'GET',
                'callback'            => array( $this, 'list_redirects' ),
                'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
            ),
            array(
                'methods'             => 'POST',
                'callback'            => array( $this, 'push_redirects' ),
                'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
            ),
            array(
                'methods'             => 'DELETE',
                'callback'            => array( $this, 'flush_redirects' ),
                'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
            ),
        ) );
    }

    /**
     * Register all routes.
     */
    public function register_routes() {
        $this->register_readonly_routes();

        // Create post or page
        register_rest_route( self::NAMESPACE, '/publish', array(
            'methods'             => 'POST',
            'callback'            => array( $this, 'publish' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        // Upload media
        register_rest_route( self::NAMESPACE, '/media', array(
            'methods'             => 'POST',
            'callback'            => array( $this, 'upload_media' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        // Update SEO meta (Rank Math / Yoast / native)
        register_rest_route( self::NAMESPACE, '/seo/(?P<post_id>\d+)', array(
            'methods'             => 'POST',
            'callback'            => array( $this, 'update_seo' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        // Update an existing post
        register_rest_route( self::NAMESPACE, '/publish/(?P<post_id>\d+)', array(
            array(
                'methods'             => 'PUT',
                'callback'            => array( $this, 'update_post' ),
                'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
            ),
            array(
                'methods'             => 'DELETE',
                'callback'            => array( $this, 'delete_post' ),
                'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
            ),
        ) );

        // Internal linking — apply paragraph swaps IN PLACE. The swap happens
        // here, inside WordPress, instead of the caller PUTting the whole
        // rewritten article back: see apply_links() for why that matters.
        register_rest_route( self::NAMESPACE, '/links', array(
            'methods'             => 'POST',
            'callback'            => array( $this, 'apply_links' ),
            'permission_callback' => array( 'Linkquiver_API_Key', 'validate' ),
        ) );

        // Self-update — overwrite-install THIS plugin from its PINNED, SIGNED
        // build so LinkQuiver can push a new version over REST (no SSH). Source
        // is hard-pinned + signature-verified, so an authenticated caller can
        // only ever reinstall our current build. See class-self-update.php.
        $self_update = new Linkquiver_Self_Update();
        register_rest_route( self::NAMESPACE, '/self-update', array(
            'methods'             => 'POST',
            'callback'            => array( $self_update, 'run' ),
            'permission_callback' => array( $self_update, 'permission' ),
        ) );

        // Theme Engine — full site design pushed as HTML/CSS.
        // Auth: LinkQuiver API key OR a logged-in administrator (Application
        // Passwords), so a local AI agent can drive the design without the
        // platform key.
        register_rest_route( self::NAMESPACE, '/theme', array(
            array(
                'methods'             => 'POST',
                'callback'            => array( $this, 'save_theme' ),
                'permission_callback' => array( $this, 'theme_permission' ),
            ),
            array(
                'methods'             => 'GET',
                'callback'            => array( $this, 'get_theme' ),
                'permission_callback' => array( $this, 'theme_permission' ),
            ),
            array(
                'methods'             => 'DELETE',
                'callback'            => array( $this, 'delete_theme' ),
                'permission_callback' => array( $this, 'theme_permission' ),
            ),
        ) );
    }

    /**
     * Theme Engine routes accept either the LinkQuiver API key or a logged-in
     * user allowed to author raw HTML/CSS. The theme payload is echoed
     * unescaped into every frontend page.
     *
     * DEUX capacites, pas une. `unfiltered_html` seul ne suffit pas : sur un
     * site simple, le role `editor` par defaut la possede. Un editeur pouvait
     * donc se creer un Application Password et reecrire l'integralite des
     * gabarits du site — un privilege qui demande normalement
     * `edit_theme_options`, reserve a l'administrateur (et au super-admin sur
     * multisite).
     *
     * On garde `unfiltered_html` en plus, pour la raison d'origine : sur
     * multisite un admin de sous-site a `manage_options` mais PAS
     * `unfiltered_html`, donc l'exiger l'empeche d'injecter du <script> a
     * l'echelle du site. Exiger les deux conserve cette protection et exclut
     * l'editeur du site simple.
     */
    public function theme_permission( WP_REST_Request $request ) {
        if ( current_user_can( 'edit_theme_options' ) && current_user_can( 'unfiltered_html' ) ) {
            return true;
        }
        return Linkquiver_API_Key::validate( $request );
    }

    /**
     * POST /linkquiver/v1/theme
     */
    public function save_theme( WP_REST_Request $request ) {
        $payload = $request->get_json_params();
        if ( ! is_array( $payload ) ) {
            return new WP_Error( 'lq_theme_invalid_payload', 'JSON body expected.', array( 'status' => 400 ) );
        }

        $engine = new Linkquiver_Theme_Engine();
        $result = $engine->save( $payload );
        if ( is_wp_error( $result ) ) {
            return $result;
        }

        return rest_ensure_response( array_merge( array( 'success' => true ), $result ) );
    }

    /**
     * GET /linkquiver/v1/theme
     */
    public function get_theme( WP_REST_Request $request ) {
        $engine = new Linkquiver_Theme_Engine();
        return rest_ensure_response( array(
            'active'             => $engine->is_active(),
            'version'            => $engine->get_version(),
            'updated_at'         => $engine->get_updated_at(),
            'global_header_html' => $engine->get_header_html(),
            'global_footer_html' => $engine->get_footer_html(),
            'global_css'         => (string) get_option( Linkquiver_Theme_Engine::OPT_GLOBAL_CSS, '' ),
            'home_body_html'     => $engine->get_home_body_html(),
            'home_css'           => (string) get_option( Linkquiver_Theme_Engine::OPT_HOME_CSS, '' ),
        ) );
    }

    /**
     * DELETE /linkquiver/v1/theme — soft deactivation, HTML/CSS preserved.
     */
    public function delete_theme( WP_REST_Request $request ) {
        $engine = new Linkquiver_Theme_Engine();
        return rest_ensure_response( array_merge( array( 'success' => true ), $engine->deactivate() ) );
    }

    /**
     * GET /linkquiver/v1/health
     */
    public function health_check( WP_REST_Request $request ) {
        $health = new Linkquiver_Health_Check();
        return rest_ensure_response( $health->run() );
    }

    /**
     * GET /ai-crawlers?days=30&top=100
     *
     * Which AI crawlers read which post, per day. `verified` counts only the
     * hits whose source IP sat inside a range the vendor publishes; see the
     * header of class-ai-crawlers.php for what that does and does not prove.
     */
    public function ai_crawlers( WP_REST_Request $request ) {
        return rest_ensure_response(
            Linkquiver_AI_Crawlers::report(
                (int) $request->get_param( 'days' ),
                (int) $request->get_param( 'top' )
            )
        );
    }

    /**
     * Build the JSON response describing an existing post for the publish
     * endpoint. Shared by the cold-path (fresh insert) and the replay path
     * (Idempotency-Key hit) so the caller can't tell them apart structurally.
     */
    private function build_publish_response( WP_Post $post, $idempotent_replay = false ) {
        $payload = array(
            'success' => true,
            'post'    => array(
                'id'     => $post->ID,
                'link'   => get_permalink( $post->ID ),
                'status' => $post->post_status,
                'type'   => $post->post_type,
                'slug'   => $post->post_name,
            ),
        );
        if ( $idempotent_replay ) {
            // Surface the replay for client-side logging. Functionally a
            // no-op : the caller treats both cases as "success".
            $payload['idempotent_replay'] = true;
        }
        return rest_ensure_response( $payload );
    }

    /**
     * Look up a post previously created with this Idempotency-Key. Returns
     * the WP_Post on hit, null on miss. Scoped to the post types LinkQuiver
     * manages so we never replay against an unrelated CPT.
     */
    private function find_post_by_idem_key( $idem_key ) {
        if ( '' === $idem_key ) {
            return null;
        }
        $ids = get_posts( array(
            'post_type'        => $this->managed_post_types(),
            'post_status'      => 'any',
            'meta_key'         => '_lq_idem_key',
            'meta_value'       => $idem_key,
            'posts_per_page'   => 1,
            'fields'           => 'ids',
            'no_found_rows'    => true,
            'suppress_filters' => false,
        ) );
        if ( empty( $ids ) ) {
            return null;
        }
        return get_post( $ids[0] );
    }

    /**
     * Stale lock TTL. A lock older than this is considered orphaned (PHP-FPM
     * killed the request before `finally` could clean it up, OOM, SIGKILL,
     * etc.) and can be stolen. 90s is well above any realistic publish
     * duration (`wp_insert_post` + meta + featured image is rarely > 30s on
     * a healthy host) but well below the Trigger.dev 60s minimum retry
     * delay, so a legitimate slow request is never stolen mid-flight.
     */
    const IDEM_LOCK_STALE_AFTER = 90;

    /**
     * Try to grab an exclusive, atomic lock for a given idempotency key.
     * Uses INSERT IGNORE on wp_options.option_name (UNIQUE INDEX) so two
     * concurrent requests with the same key can never both pass — exactly
     * one succeeds, the other gets to wait and replay.
     *
     * If the INSERT loses the race AND the existing lock is older than
     * IDEM_LOCK_STALE_AFTER seconds, the lock is considered orphaned
     * (request died before its `finally` could clean up), stolen, and we
     * retry the INSERT once. This is the only safety net against a hard
     * crash leaving an article permanently un-publishable through the
     * plugin path.
     *
     * Returns the lock name to pass to release_idem_lock(), or '' if the
     * lock is held by a live request.
     */
    private function acquire_idem_lock( $idem_key ) {
        global $wpdb;
        $lock_name = 'lq_idem_' . substr( hash( 'sha256', $idem_key ), 0, 40 );

        $inserted = $wpdb->query(
            $wpdb->prepare(
                "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
                $lock_name,
                (string) time()
            )
        );
        if ( 1 === (int) $inserted ) {
            return $lock_name;
        }

        // Lost the race. Check whether the existing lock is stale enough
        // to steal — covers the "PHP-FPM killed the holder before finally
        // ran" case that would otherwise wedge the key forever.
        $held_at = (int) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s",
                $lock_name
            )
        );
        if ( $held_at > 0 && ( time() - $held_at ) > self::IDEM_LOCK_STALE_AFTER ) {
            $this->release_idem_lock( $lock_name );
            $retry = $wpdb->query(
                $wpdb->prepare(
                    "INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
                    $lock_name,
                    (string) time()
                )
            );
            if ( 1 === (int) $retry ) {
                return $lock_name;
            }
        }

        return '';
    }

    /**
     * Release a lock previously acquired with acquire_idem_lock().
     */
    private function release_idem_lock( $lock_name ) {
        if ( '' === $lock_name ) return;
        global $wpdb;
        $wpdb->query(
            $wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name = %s",
                $lock_name
            )
        );
    }

    /**
     * POST /linkquiver/v1/publish
     *
     * Headers:
     * - Idempotency-Key (optional) : opaque string up to 255 chars. When set,
     *   the plugin guarantees that two requests carrying the same key never
     *   produce two posts. Same key + same site = same post, replay-safe.
     *   Stored on post_meta `_lq_idem_key`. Concurrent requests are serialized
     *   via an atomic wp_options lock.
     *
     * Body JSON:
     * - title (string, required)
     * - content (string, required)
     * - status (string: draft|publish, default: draft)
     * - post_type (string: post|page, default: post)
     * - category_id (int, optional)
     * - author_id (int, optional)
     * - featured_media_id (int, optional)
     * - slug (string, optional)
     * - excerpt (string, optional)
     * - date (string ISO 8601, optional)
     * - meta (object, optional) - custom fields (the `_lq_idem_key` key is
     *   reserved and silently dropped here; set it via the header instead)
     */
    public function publish( WP_REST_Request $request ) {
        $params = $request->get_json_params();

        // ─── Idempotency fast path ──────────────────────────────────────
        // Trim then cap at 255 chars : long enough for a UUID, short enough
        // that a malicious caller can't blow up the options table. Empty
        // header → fall through to the legacy "always insert" path.
        $idem_key = trim( (string) $request->get_header( 'idempotency_key' ) );
        if ( strlen( $idem_key ) > 255 ) {
            $idem_key = substr( $idem_key, 0, 255 );
        }

        if ( '' !== $idem_key ) {
            $existing = $this->find_post_by_idem_key( $idem_key );
            if ( $existing ) {
                return $this->build_publish_response( $existing, true );
            }
        }

        if ( empty( $params['title'] ) || empty( $params['content'] ) ) {
            return new WP_Error(
                'linkquiver_missing_fields',
                'title and content are required.',
                array( 'status' => 400 )
            );
        }

        // ─── Acquire idempotency lock (concurrent requests guard) ───────
        // If two requests with the same key land within milliseconds, the
        // SELECT above races the INSERT below. The wp_options UNIQUE INDEX
        // gives us an atomic claim : the loser waits, then replays.
        $lock_name = '';
        if ( '' !== $idem_key ) {
            $lock_name = $this->acquire_idem_lock( $idem_key );
            if ( '' === $lock_name ) {
                // Lost the race — another in-flight request holds the lock.
                // Poll for up to 5s for it to finish, then replay the post.
                for ( $i = 0; $i < 20; $i++ ) {
                    usleep( 250000 ); // 250ms
                    $existing = $this->find_post_by_idem_key( $idem_key );
                    if ( $existing ) {
                        return $this->build_publish_response( $existing, true );
                    }
                }
                // Concurrent request is still in flight after 5s. Tell the
                // caller to retry — they'll either hit the fast path (the
                // other request finished) or re-acquire the lock cleanly.
                return new WP_Error(
                    'linkquiver_idem_in_progress',
                    'A publish with this Idempotency-Key is already in flight. Retry shortly.',
                    array( 'status' => 409 )
                );
            }

            // Re-check UNDER the lock before inserting. The fast-path SELECT at
            // the top of publish() ran outside any lock: a sibling request could
            // have stamped its `_lq_idem_key` in the window between that SELECT
            // (miss) here and our acquisition of the lock (e.g. it released the
            // lock just before we grabbed it). Without this second look-up two
            // requests with the same key could each insert a post. This closes
            // the "no race window" guarantee the header contract promises.
            $existing = $this->find_post_by_idem_key( $idem_key );
            if ( $existing ) {
                $this->release_idem_lock( $lock_name );
                return $this->build_publish_response( $existing, true );
            }
        }

        try {
            $post_type = in_array( $params['post_type'] ?? 'post', array( 'post', 'page' ), true )
                ? ( $params['post_type'] ?? 'post' )
                : 'post';

            $post_data = array(
                'post_title'   => sanitize_text_field( $params['title'] ),
                'post_content' => wp_kses_post( $params['content'] ),
                'post_status'  => in_array( $params['status'] ?? 'draft', array( 'draft', 'publish', 'pending', 'private', 'future' ), true )
                    ? ( $params['status'] ?? 'draft' )
                    : 'draft',
                'post_type'    => $post_type,
            );

            if ( ! empty( $params['excerpt'] ) ) {
                $post_data['post_excerpt'] = sanitize_textarea_field( $params['excerpt'] );
            }

            if ( ! empty( $params['slug'] ) ) {
                $post_data['post_name'] = sanitize_title( $params['slug'] );
            }

            if ( ! empty( $params['author_id'] ) ) {
                $author = get_user_by( 'ID', absint( $params['author_id'] ) );
                if ( $author ) {
                    $post_data['post_author'] = $author->ID;
                }
            }

            if ( ! empty( $params['date'] ) ) {
                $post_data['post_date']     = sanitize_text_field( $params['date'] );
                $post_data['post_date_gmt'] = get_gmt_from_date( $params['date'] );
            }

            $post_id = wp_insert_post( $post_data, true );

            if ( is_wp_error( $post_id ) ) {
                return new WP_Error(
                    'linkquiver_publish_failed',
                    $post_id->get_error_message(),
                    array( 'status' => 500 )
                );
            }

            // Stamp the idempotency key FIRST, before category / image / meta
            // side effects. If any of those fail, a retry with the same key
            // still lands on this same post rather than creating a sibling.
            if ( '' !== $idem_key ) {
                update_post_meta( $post_id, '_lq_idem_key', $idem_key );
            }

            // Assign category
            if ( ! empty( $params['category_id'] ) && 'post' === $post_type ) {
                wp_set_post_categories( $post_id, array( absint( $params['category_id'] ) ) );
            }

            // Attach featured image
            if ( ! empty( $params['featured_media_id'] ) ) {
                set_post_thumbnail( $post_id, absint( $params['featured_media_id'] ) );
            }

            // Custom meta fields — accept SEO + author-content keys, reject any
            // WordPress core / capability / session keys to prevent the API key
            // from being used to corrupt user/auth state via postmeta.
            if ( ! empty( $params['meta'] ) && is_array( $params['meta'] ) ) {
                foreach ( $params['meta'] as $key => $value ) {
                    $clean_key = sanitize_key( $key );
                    if ( self::is_meta_key_denied( $clean_key ) ) {
                        continue;
                    }
                    // sanitize_textarea_field preserves newlines (meta values can
                    // legitimately be multi-line — SEO descriptions, structured
                    // data JSON, etc.). update_post_meta itself escapes the value
                    // for DB storage so this is the right level of sanitization.
                    update_post_meta( $post_id, $clean_key, sanitize_textarea_field( (string) $value ) );
                }
            }

            $post = get_post( $post_id );
            return $this->build_publish_response( $post, false );
        } finally {
            $this->release_idem_lock( $lock_name );
        }
    }

    /**
     * Post-types managed by LinkQuiver. Toute mutation (update/delete) via cette
     * API est restreinte à cet ensemble pour éviter qu'une clé API compromise
     * puisse altérer ou supprimer des contenus hors périmètre (menu items,
     * attachments, custom post types métier du site...).
     */
    private function managed_post_types() {
        return array( 'post', 'page' );
    }

    /**
     * Garde IDOR : renvoie WP_Error si le post est absent OU d'un type non géré.
     */
    private function ensure_managed_post( $post_id ) {
        $post = get_post( $post_id );
        if ( ! $post ) {
            return new WP_Error(
                'linkquiver_not_found',
                'Post not found.',
                array( 'status' => 404 )
            );
        }
        if ( ! in_array( $post->post_type, $this->managed_post_types(), true ) ) {
            return new WP_Error(
                'linkquiver_forbidden',
                'This post type is not managed by LinkQuiver.',
                array( 'status' => 403 )
            );
        }
        return $post;
    }

    /**
     * PUT /linkquiver/v1/publish/{post_id}
     */
    public function update_post( WP_REST_Request $request ) {
        $post_id = absint( $request->get_param( 'post_id' ) );
        $post    = $this->ensure_managed_post( $post_id );
        if ( is_wp_error( $post ) ) {
            return $post;
        }

        $params    = $request->get_json_params();
        $post_data = array( 'ID' => $post_id );

        if ( ! empty( $params['title'] ) ) {
            $post_data['post_title'] = sanitize_text_field( $params['title'] );
        }
        if ( ! empty( $params['content'] ) ) {
            $post_data['post_content'] = wp_kses_post( $params['content'] );
        }
        if ( ! empty( $params['status'] ) ) {
            $candidate = sanitize_text_field( $params['status'] );
            // Reject an unknown status with a 400 instead of silently coercing to
            // 'draft'. A typo upstream ("publlish") must NOT unpublish a live
            // article — the caller has to see the error and fix the payload.
            if ( ! in_array( $candidate, array( 'draft', 'publish', 'pending', 'private', 'future' ), true ) ) {
                return new WP_Error(
                    'linkquiver_invalid_status',
                    sprintf( 'Unknown post status "%s".', $candidate ),
                    array( 'status' => 400 )
                );
            }
            $post_data['post_status'] = $candidate;
        }
        if ( ! empty( $params['excerpt'] ) ) {
            $post_data['post_excerpt'] = sanitize_textarea_field( $params['excerpt'] );
        }

        // Scheduling : accept ISO 8601 date. Required when status='future' to
        // actually plan the post; also useful to repost-date a draft/publish.
        if ( ! empty( $params['date'] ) ) {
            $post_data['post_date']     = sanitize_text_field( $params['date'] );
            $post_data['post_date_gmt'] = get_gmt_from_date( $params['date'] );
        }

        // Allow toggling the slug (rename), e.g. when adjusting a draft.
        if ( ! empty( $params['slug'] ) ) {
            $post_data['post_name'] = sanitize_title( $params['slug'] );
        }

        if ( ! empty( $params['author_id'] ) ) {
            $author = get_user_by( 'ID', absint( $params['author_id'] ) );
            if ( $author ) {
                $post_data['post_author'] = $author->ID;
            }
        }

        $result = wp_update_post( $post_data, true );

        if ( is_wp_error( $result ) ) {
            return new WP_Error(
                'linkquiver_update_failed',
                $result->get_error_message(),
                array( 'status' => 500 )
            );
        }

        // Featured image : symmetric with publish(). `featured_media_id: 0`
        // explicitly clears the thumbnail; a positive id sets it. Absent key =
        // leave untouched (isset guard, not !empty, so 0 is honoured).
        if ( array_key_exists( 'featured_media_id', $params ) ) {
            $media_id = absint( $params['featured_media_id'] );
            if ( $media_id > 0 ) {
                set_post_thumbnail( $post_id, $media_id );
            } else {
                delete_post_thumbnail( $post_id );
            }
        }

        // Update categories : accepte un ID unique ou un array d'IDs.
        // Limité aux post_type qui supportent la taxonomie 'category' (donc pas
        // 'page' par défaut).
        if ( isset( $params['category_id'] ) || isset( $params['categories'] ) ) {
            $raw = isset( $params['categories'] ) ? $params['categories'] : $params['category_id'];
            $ids = array();
            if ( is_array( $raw ) ) {
                foreach ( $raw as $v ) {
                    $n = absint( $v );
                    if ( $n > 0 ) {
                        $ids[] = $n;
                    }
                }
            } else {
                $n = absint( $raw );
                if ( $n > 0 ) {
                    $ids[] = $n;
                }
            }
            if ( ! empty( $ids ) && is_object_in_taxonomy( $post->post_type, 'category' ) ) {
                wp_set_post_categories( $post_id, $ids, false );
            }
        }

        if ( ! empty( $params['meta'] ) && is_array( $params['meta'] ) ) {
            foreach ( $params['meta'] as $key => $value ) {
                $clean_key = sanitize_key( $key );
                if ( self::is_meta_key_denied( $clean_key ) ) {
                    continue;
                }
                // sanitize_textarea_field preserves newlines (meta values can
                // legitimately be multi-line — SEO descriptions, structured
                // data JSON, etc.). update_post_meta itself escapes the value
                // for DB storage so this is the right level of sanitization.
                update_post_meta( $post_id, $clean_key, sanitize_textarea_field( (string) $value ) );
            }
        }

        $post = get_post( $post_id );

        return rest_ensure_response( array(
            'success' => true,
            'post'    => array(
                'id'     => $post->ID,
                'link'   => get_permalink( $post->ID ),
                'status' => $post->post_status,
            ),
        ) );
    }

    /**
     * GET /linkquiver/v1/posts
     *
     * Query string:
     * - page      (int, default 1)
     * - per_page  (int, default 50, max 100)
     * - post_type (post|page, default post)
     * - status    (any WP status, default publish)
     * - include   (comma-separated ids) — fetch specific posts. Implies
     *             status=any: a donor flipped to draft since the report must
     *             read back as itself, not as a silent 404.
     *
     * `content` is $post->post_content VERBATIM — no shortcode expansion, no
     * wpautop, no block rendering. That is the whole point: a caller that
     * intends to write back must locate its paragraph in the source markup.
     */
    public function list_posts_raw( WP_REST_Request $request ) {
        $page      = max( 1, absint( $request->get_param( 'page' ) ?: 1 ) );
        $per_page  = absint( $request->get_param( 'per_page' ) ?: 50 );
        $per_page  = max( 1, min( 100, $per_page ) );
        $post_type = sanitize_key( $request->get_param( 'post_type' ) ?: 'post' );
        $status    = sanitize_key( $request->get_param( 'status' ) ?: 'publish' );

        if ( ! in_array( $post_type, $this->managed_post_types(), true ) ) {
            return new WP_Error(
                'linkquiver_forbidden',
                'This post type is not managed by LinkQuiver.',
                array( 'status' => 403 )
            );
        }

        $include = array();
        $include_raw = $request->get_param( 'include' );
        if ( ! empty( $include_raw ) ) {
            $parts = is_array( $include_raw ) ? $include_raw : explode( ',', (string) $include_raw );
            foreach ( $parts as $part ) {
                $id = absint( trim( (string) $part ) );
                if ( $id > 0 ) {
                    $include[] = $id;
                }
            }
            if ( empty( $include ) ) {
                return new WP_Error( 'linkquiver_invalid_include', 'No usable id in "include".', array( 'status' => 400 ) );
            }
            $include   = array_slice( array_unique( $include ), 0, 100 );
            $per_page  = count( $include );
            $page      = 1;
            $status    = 'any';
        }

        $args = array(
            'post_type'              => $post_type,
            'post_status'            => $status,
            'posts_per_page'         => $per_page,
            'paged'                  => $page,
            'orderby'                => 'ID',
            'order'                  => 'ASC',
            'ignore_sticky_posts'    => true,
            'no_found_rows'          => false,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        );
        if ( ! empty( $include ) ) {
            $args['post__in'] = $include;
        }

        $query = new WP_Query( $args );

        $posts = array();
        foreach ( $query->posts as $post ) {
            $posts[] = array(
                'id'       => $post->ID,
                'link'     => get_permalink( $post->ID ),
                'title'    => $post->post_title,
                'content'  => $post->post_content,
                // Real ISO 8601 in UTC. post_modified_gmt is 'Y-m-d H:i:s': the
                // space makes it a non-standard date string whose parsing is
                // engine-defined, and the caller compares it against Date.now()
                // to leave a recently edited post alone.
                'modified' => $post->post_modified_gmt
                    ? str_replace( ' ', 'T', $post->post_modified_gmt ) . 'Z'
                    : null,
                'status'   => $post->post_status,
                'slug'     => $post->post_name,
            );
        }

        return rest_ensure_response( array(
            'posts'       => $posts,
            'page'        => $page,
            'per_page'    => $per_page,
            'total'       => (int) $query->found_posts,
            'total_pages' => (int) $query->max_num_pages,
        ) );
    }

    /**
     * Reject a replacement paragraph carrying active content.
     *
     * apply_links() writes without wp_kses_post (see there), so this is the
     * one gate on the only bytes that are NEW. Everything else in the article
     * is what WordPress already stored and is never re-filtered. Note the API
     * key is already an admin-grade credential — it can publish arbitrary HTML
     * through /publish and reinstall the plugin through /self-update — so this
     * is defense in depth, not the trust boundary.
     */
    private static function is_paragraph_unsafe( $html ) {
        if ( preg_match( '#<\s*/?\s*(script|iframe|object|embed|form|style|link|meta|base)\b#i', $html ) ) {
            return true;
        }
        // Inline event handlers: on<word>= , tolerating whitespace around '='.
        if ( preg_match( '#\son[a-z]+\s*=#i', $html ) ) {
            return true;
        }
        if ( preg_match( '#(javascript|vbscript)\s*:#i', $html ) ) {
            return true;
        }
        if ( preg_match( '#data\s*:\s*text/html#i', $html ) ) {
            return true;
        }
        return false;
    }

    /**
     * POST /linkquiver/v1/links
     *
     * Body: { "items": [ { "post_id": int, "paragraph_before": string,
     *                      "paragraph_after": string }, ... ] }
     *
     * Applies each swap to $post->post_content and saves. Two decisions worth
     * spelling out:
     *
     * 1. The swap happens HERE rather than the caller PUTting the whole
     *    rewritten article to /publish/{id}. That endpoint runs the body
     *    through wp_kses_post(), which on an article we did not author strips
     *    iframes, data-* attributes, embed markup — silently. Swapping in
     *    place means the ~99% of the article we did not touch is never
     *    rewritten at all.
     *
     * 2. The save deliberately bypasses the kses filters. On an API-key
     *    request there is no logged-in user, so current_user_can(
     *    'unfiltered_html' ) is false and WordPress would hook
     *    wp_filter_post_kses onto content_save_pre — mangling the untouched
     *    remainder through the back door. We remove those two filters for the
     *    duration of the write and restore exactly what was there.
     *    is_paragraph_unsafe() guards the new bytes instead.
     *
     * A paragraph that is absent, or present more than once, is reported
     * `stale` and skipped — never guessed at. Same invariant as the engine.
     */
    public function apply_links( WP_REST_Request $request ) {
        $params = $request->get_json_params();
        $items  = ( is_array( $params ) && isset( $params['items'] ) && is_array( $params['items'] ) )
            ? $params['items']
            : null;
        if ( null === $items ) {
            return new WP_Error( 'linkquiver_invalid_payload', 'Expected { items: [...] }.', array( 'status' => 400 ) );
        }
        if ( count( $items ) > 500 ) {
            return new WP_Error( 'linkquiver_too_many_items', 'At most 500 items per call.', array( 'status' => 400 ) );
        }

        // Group by post so a donor with 6 replacements is written once.
        $by_post = array();
        $results = array();
        foreach ( $items as $index => $item ) {
            $post_id = isset( $item['post_id'] ) ? absint( $item['post_id'] ) : 0;
            $before  = isset( $item['paragraph_before'] ) ? (string) $item['paragraph_before'] : '';
            $after   = isset( $item['paragraph_after'] ) ? (string) $item['paragraph_after'] : '';

            if ( ! $post_id || '' === $before || '' === $after || $before === $after ) {
                $results[ $index ] = array( 'index' => $index, 'post_id' => $post_id, 'status' => 'invalid' );
                continue;
            }
            if ( self::is_paragraph_unsafe( $after ) ) {
                $results[ $index ] = array( 'index' => $index, 'post_id' => $post_id, 'status' => 'rejected_unsafe' );
                continue;
            }
            $by_post[ $post_id ][] = array( 'index' => $index, 'before' => $before, 'after' => $after );
        }

        $applied = 0;
        $stale   = 0;
        $failed  = 0;

        foreach ( $by_post as $post_id => $swaps ) {
            // VERROU PAR ARTICLE, sur toute la séquence lecture-modification-écriture.
            //
            // Sans lui, deux appels concurrents sur le même article lisent le même
            // post_content, appliquent chacun SON échange, et le second écrase le
            // premier. Le lien perdu est alors annoncé `applied` : la ligne d'audit
            // côté SaaS dit « injecté » alors que rien n'est en ligne, ce qui est
            // pire qu'un échec franc. Reproduit sur ce serveur le 31/07/2026 :
            // 8 appels simultanés, 8 réponses `applied`, 7 liens réellement en base.
            //
            // GET_LOCK est atomique et lié à la connexion : si PHP meurt en cours de
            // route, MySQL relâche tout seul, aucun verrou orphelin à balayer.
            $lock_acquired = $this->acquire_post_lock( $post_id );

            $post = $this->ensure_managed_post( $post_id );
            if ( is_wp_error( $post ) ) {
                $this->release_post_lock( $post_id, $lock_acquired );
                foreach ( $swaps as $s ) {
                    $results[ $s['index'] ] = array(
                        'index'   => $s['index'],
                        'post_id' => $post_id,
                        'status'  => 'failed',
                        'error'   => $post->get_error_message(),
                    );
                    $failed++;
                }
                continue;
            }

            // Relire SOUS le verrou, sans passer par le cache d'objets : sur un
            // site à cache persistant (Redis, Memcached), get_post() peut servir
            // la version d'avant l'écriture du concurrent qu'on vient d'attendre,
            // et le verrou n'aurait alors rien protégé.
            clean_post_cache( $post_id );
            $post = get_post( $post_id );
            if ( ! $post ) {
                $this->release_post_lock( $post_id, $lock_acquired );
                foreach ( $swaps as $s ) {
                    $results[ $s['index'] ] = array(
                        'index'   => $s['index'],
                        'post_id' => $post_id,
                        'status'  => 'failed',
                        'error'   => 'Post disparu pendant l\'attente du verrou.',
                    );
                    $failed++;
                }
                continue;
            }

            $content = $post->post_content;
            $landed  = array();

            foreach ( $swaps as $s ) {
                // The host paragraph must still exist AND be unique in the
                // CURRENT content (previous swaps included).
                if ( 1 !== substr_count( $content, $s['before'] ) ) {
                    $results[ $s['index'] ] = array( 'index' => $s['index'], 'post_id' => $post_id, 'status' => 'stale' );
                    $stale++;
                    continue;
                }
                $next = self::replace_once( $content, $s['before'], $s['after'] );
                if ( $next === $content ) {
                    $results[ $s['index'] ] = array( 'index' => $s['index'], 'post_id' => $post_id, 'status' => 'stale' );
                    $stale++;
                    continue;
                }
                $content  = $next;
                $landed[] = $s;
            }

            if ( empty( $landed ) ) {
                $this->release_post_lock( $post_id, $lock_acquired );
                continue;
            }

            $had_kses = has_filter( 'content_save_pre', 'wp_filter_post_kses' );
            if ( $had_kses ) {
                remove_filter( 'content_save_pre', 'wp_filter_post_kses' );
                remove_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' );
            }
            // wp_update_post() expects slashed data; $post_content read from
            // get_post() is unslashed, so re-slash before handing it back.
            $updated = wp_update_post(
                array( 'ID' => $post_id, 'post_content' => wp_slash( $content ) ),
                true
            );
            if ( $had_kses ) {
                add_filter( 'content_save_pre', 'wp_filter_post_kses' );
                add_filter( 'content_filtered_save_pre', 'wp_filter_post_kses' );
            }

            $this->release_post_lock( $post_id, $lock_acquired );

            if ( is_wp_error( $updated ) ) {
                foreach ( $landed as $s ) {
                    $results[ $s['index'] ] = array(
                        'index'   => $s['index'],
                        'post_id' => $post_id,
                        'status'  => 'failed',
                        'error'   => $updated->get_error_message(),
                    );
                    $failed++;
                }
                continue;
            }

            foreach ( $landed as $s ) {
                $results[ $s['index'] ] = array( 'index' => $s['index'], 'post_id' => $post_id, 'status' => 'applied' );
                $applied++;
            }
        }

        ksort( $results );

        return rest_ensure_response( array(
            'success' => true,
            'applied' => $applied,
            'stale'   => $stale,
            'failed'  => $failed,
            'results' => array_values( $results ),
        ) );
    }

    /** Combien de temps on accepte d'attendre le verrou d'un article, en secondes. */
    const POST_LOCK_TIMEOUT = 10;

    /**
     * Nom du verrou MySQL pour un article. Préfixé par le nom de la base : les
     * verrous GET_LOCK sont GLOBAUX au serveur MySQL, donc sans ce préfixe deux
     * WordPress mutualisés sur la même instance se bloqueraient mutuellement sur
     * des articles qui n'ont rien à voir.
     */
    private function post_lock_name( $post_id ) {
        global $wpdb;
        return substr( 'lq_post_' . md5( $wpdb->dbname . '|' . $wpdb->prefix . '|' . (int) $post_id ), 0, 64 );
    }

    /**
     * Prend un verrou exclusif sur un article, ou renonce au bout de
     * POST_LOCK_TIMEOUT secondes.
     *
     * Échec = on continue SANS verrou, délibérément. GET_LOCK manque sur de rares
     * configurations (certains proxys SQL, quelques MySQL managés), et refuser
     * d'écrire dans ce cas casserait le maillage sur ces sites pour se prémunir
     * d'une course qui demande deux appels simultanés sur le MÊME article. On
     * revient alors exactement au comportement d'avant ce verrou, pas pire.
     *
     * @return bool Verrou réellement tenu.
     */
    private function acquire_post_lock( $post_id ) {
        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- valeurs bindées.
        $got = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, %d)', $this->post_lock_name( $post_id ), self::POST_LOCK_TIMEOUT ) );
        return '1' === (string) $got;
    }

    private function release_post_lock( $post_id, $acquired ) {
        if ( ! $acquired ) {
            return;
        }
        global $wpdb;
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- valeur bindée.
        $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $this->post_lock_name( $post_id ) ) );
    }

    /**
     * str_replace limited to the first occurrence. The caller has already
     * checked uniqueness; this keeps the guarantee even if the needle somehow
     * repeats, where str_replace() would rewrite every copy.
     */
    private static function replace_once( $haystack, $needle, $replacement ) {
        $pos = strpos( $haystack, $needle );
        if ( false === $pos ) {
            return $haystack;
        }
        return substr_replace( $haystack, $replacement, $pos, strlen( $needle ) );
    }

    /**
     * DELETE /linkquiver/v1/publish/{post_id}
     *
     * Query string:
     * - force (bool, default false): bypass trash and delete permanently.
     */
    public function delete_post( WP_REST_Request $request ) {
        $post_id = absint( $request->get_param( 'post_id' ) );
        $post    = $this->ensure_managed_post( $post_id );
        if ( is_wp_error( $post ) ) {
            return $post;
        }

        $force_raw = $request->get_param( 'force' );
        $force     = filter_var( $force_raw, FILTER_VALIDATE_BOOLEAN );

        $result = wp_delete_post( $post_id, $force );

        if ( ! $result ) {
            return new WP_Error(
                'linkquiver_delete_failed',
                'Failed to delete post.',
                array( 'status' => 500 )
            );
        }

        return rest_ensure_response( array(
            'success' => true,
            'deleted' => true,
            'forced'  => (bool) $force,
            'post_id' => $post_id,
        ) );
    }

    /**
     * POST /linkquiver/v1/media
     *
     * Accepts:
     * - multipart/form-data with "file" field (+ optional "alt" field)
     * - OR JSON body with "url" (remote URL to sideload) and optional
     *   "filename" / "alt"
     *
     * `alt`, when present, is written to the attachment's
     * `_wp_attachment_image_alt` meta so featured images ship with alt text.
     */
    public function upload_media( WP_REST_Request $request ) {
        $media_handler = new Linkquiver_Media_Handler();

        $content_type = $request->get_content_type();

        // JSON body with remote URL
        if ( ! empty( $content_type['value'] ) && false !== strpos( $content_type['value'], 'application/json' ) ) {
            $params = $request->get_json_params();

            if ( empty( $params['url'] ) ) {
                return new WP_Error(
                    'linkquiver_missing_url',
                    'url is required for remote media upload.',
                    array( 'status' => 400 )
                );
            }

            return rest_ensure_response(
                $media_handler->sideload_from_url(
                    esc_url_raw( $params['url'] ),
                    sanitize_file_name( $params['filename'] ?? '' ),
                    absint( $params['post_id'] ?? 0 ),
                    sanitize_text_field( $params['alt'] ?? '' )
                )
            );
        }

        // Multipart file upload
        $files = $request->get_file_params();

        if ( empty( $files['file'] ) ) {
            return new WP_Error(
                'linkquiver_missing_file',
                'No file provided. Send a "file" field or a JSON body with "url".',
                array( 'status' => 400 )
            );
        }

        return rest_ensure_response(
            $media_handler->upload_file(
                $files['file'],
                absint( $request->get_param( 'post_id' ) ?? 0 ),
                sanitize_text_field( $request->get_param( 'alt' ) ?? '' )
            )
        );
    }

    /**
     * POST /linkquiver/v1/seo/{post_id}
     *
     * Body JSON:
     * - title (string)
     * - description (string)
     * - focus_keyword (string, optional)
     */
    public function update_seo( WP_REST_Request $request ) {
        $post_id = absint( $request->get_param( 'post_id' ) );
        $post    = $this->ensure_managed_post( $post_id );
        if ( is_wp_error( $post ) ) {
            return $post;
        }
        $params  = $request->get_json_params();

        $seo_handler = new Linkquiver_SEO_Handler();

        $result = $seo_handler->update(
            $post_id,
            sanitize_text_field( $params['title'] ?? '' ),
            sanitize_text_field( $params['description'] ?? '' ),
            sanitize_text_field( $params['focus_keyword'] ?? '' )
        );

        return rest_ensure_response( $result );
    }

    /**
     * GET /linkquiver/v1/categories
     */
    public function get_categories( WP_REST_Request $request ) {
        $categories = get_categories( array(
            'hide_empty' => false,
            'number'     => 100,
        ) );

        $result = array();
        foreach ( $categories as $cat ) {
            $result[] = array(
                'id'          => $cat->term_id,
                'name'        => $cat->name,
                'slug'        => $cat->slug,
                'description' => $cat->description,
                'parent'      => $cat->parent,
                'count'       => $cat->count,
            );
        }

        return rest_ensure_response( array( 'categories' => $result ) );
    }

    /**
     * GET /linkquiver/v1/authors
     */
    public function get_authors( WP_REST_Request $request ) {
        $users = get_users( array(
            'role__in' => array( 'administrator', 'editor', 'author' ),
            'number'   => 100,
        ) );

        $result = array();
        foreach ( $users as $user ) {
            $result[] = array(
                'id'         => $user->ID,
                'name'       => $user->display_name,
                'slug'       => $user->user_nicename,
                'avatar_url' => get_avatar_url( $user->ID, array( 'size' => 96 ) ),
            );
        }

        return rest_ensure_response( array( 'authors' => $result ) );
    }

    // ── Redirects ───────────────────────────────────────────────────────

    /**
     * GET /linkquiver/v1/redirects
     */
    public function list_redirects( WP_REST_Request $request ) {
        // Paginate at the SQL level so a 50k-row table doesn't blow up memory.
        // Defaults preserve the historical behaviour (first 1000, alpha order).
        $limit     = absint( $request->get_param( 'limit' ) ) ?: 1000;
        $offset    = absint( $request->get_param( 'offset' ) );
        $redirects = Linkquiver_Redirect_Engine::list_all( $limit, $offset );

        return rest_ensure_response( array(
            'count'     => count( $redirects ),
            'total'     => Linkquiver_Redirect_Engine::count(),
            'limit'     => max( 1, min( 5000, $limit ) ),
            'offset'    => $offset,
            'redirects' => $redirects,
        ) );
    }

    /**
     * POST /linkquiver/v1/redirects
     *
     * Body JSON:
     * - redirects (array): bulk import, replaces all existing
     *     Each item: { old_path: "/old/url.html", new_url: "https://...", post_id?: 123, type?: 301|302|307|308|410 }
     * - OR single redirect:
     *     old_path, new_url, post_id?, type?
     *   type: 410 = Gone (resource permanently removed). new_url may be
     *   omitted/empty when type is 410 — the engine serves a 410 response
     *   instead of redirecting.
     */
    public function push_redirects( WP_REST_Request $request ) {
        $params = $request->get_json_params();

        // Bulk import
        if ( ! empty( $params['redirects'] ) && is_array( $params['redirects'] ) ) {
            $count = Linkquiver_Redirect_Engine::import( $params['redirects'] );

            return rest_ensure_response( array(
                'success'  => true,
                'imported' => $count,
                'message'  => sprintf( '%d redirects imported.', $count ),
            ) );
        }

        // Single redirect. A 410 rule has no destination, so it's allowed
        // through without new_url.
        $is_410 = 410 === absint( $params['type'] ?? 0 );
        if ( ! empty( $params['old_path'] ) && ( ! empty( $params['new_url'] ) || $is_410 ) ) {
            $id = Linkquiver_Redirect_Engine::upsert(
                sanitize_text_field( $params['old_path'] ),
                $is_410 ? '' : esc_url_raw( $params['new_url'] ),
                absint( $params['post_id'] ?? 0 ) ?: null,
                absint( $params['type'] ?? 301 )
            );

            if ( 0 === $id ) {
                return new WP_Error(
                    'linkquiver_invalid_redirect',
                    'new_url must be an absolute http(s) URL.',
                    array( 'status' => 400 )
                );
            }

            return rest_ensure_response( array(
                'success' => true,
                'id'      => $id,
                'total'   => Linkquiver_Redirect_Engine::count(),
            ) );
        }

        return new WP_Error(
            'linkquiver_invalid_redirects',
            'Provide either { redirects: [...] } for bulk import or { old_path, new_url } for a single redirect.',
            array( 'status' => 400 )
        );
    }

    /**
     * DELETE /linkquiver/v1/redirects
     */
    public function flush_redirects( WP_REST_Request $request ) {
        Linkquiver_Redirect_Engine::flush_all();

        return rest_ensure_response( array(
            'success' => true,
            'message' => 'All redirects flushed.',
        ) );
    }
}
