<?php

declare(strict_types=1);

/**
 * Keyword-Map Applier (Task 6).
 *
 * Wraps the *production* internal-linking pipeline already shipped in
 * InternalLinkingEndpoints.php so all of the existing safety behavior is
 * preserved verbatim:
 *
 *   - rules come from the `prorank_auto_link_rules` option (the UI source)
 *   - `resolve_active_auto_link_rules()` does the enabled+priority filtering
 *   - `apply_auto_link_rules_to_post()` runs each rule through
 *     `safe_link_insert()` which uses DOMDocument/XPath to skip
 *     <h1>..<h6>, <a>, <nav>, <footer>, <header>, <button>, <script>,
 *     <style>, <code>, <pre>, honors whole_word, case_sensitive,
 *     max_links_per_post, max_per_paragraph, dedupe per anchor text.
 *
 * Additions in T6:
 *
 *   1. Server-enforced dry-run gating. `dry_run()` stores a transient
 *      keyed by `dry_run_id`; `apply()` requires that id and refuses
 *      missing / expired / stale-rule tokens.
 *
 *   2. Revision-based rollback. Before writing the rewritten body we call
 *      `wp_save_post_revision()` to capture the pre-apply state, and store
 *      only `{ post_id, revision_id, new_content_hash, match_count }` in
 *      the per-run option. Full post bodies stay in the WP revisions table,
 *      not in `wp_options`.
 *
 *   3. Self-link skipping. Rules whose `target_url` resolves to the post
 *      currently being processed are excluded before the engine runs.
 *
 *   4. Hard cap of MAX_POSTS_PER_RUN posts changed per apply.
 *
 * @package ProRank\SEO\Modules\Content\InternalLinking\AutoLink
 * @since 1.3.1
 */

namespace ProRank\SEO\Modules\Content\InternalLinking\AutoLink;

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


class KeywordMapApplier {

    public const RUNS_OPTION_KEY      = 'prorank_seo_keyword_map_runs';
    public const DRY_RUN_TRANSIENT_PREFIX = 'prorank_kmap_dryrun_';
    public const DRY_RUN_TTL          = 300; // 5 minutes
    public const MAX_POSTS_PER_RUN    = 25;
    public const MAX_RUNS             = 50;
    private const SNIPPET_RADIUS      = 80;
    private const MEMORY_HEADROOM_BYTES = 67108864;

    public static function check_permission(): bool {
        return function_exists( 'current_user_can' ) && current_user_can( 'manage_options' );
    }

    /**
     * Dry-run: produce a preview + mint a single-use token apply() will verify.
     *
     * @return array{
     *   dry_run_id:string, posts_scanned:int, posts_changed:int,
     *   total_matches:int, items:array
     * }
     */
    public static function dry_run( array $rule_ids = [], int $post_limit = 100 ): array {
        $post_limit = max( 1, min( self::MAX_POSTS_PER_RUN, $post_limit ) );

        $rules = self::filter_rules( self::get_rules(), $rule_ids );
        $candidate_ids = self::candidate_post_ids( $post_limit );

        $base = [
            'dry_run_id'    => '',
            'posts_scanned' => count( $candidate_ids ),
            'posts_changed' => 0,
            'total_matches' => 0,
            'items'         => [],
            'capped'        => false,
            'errors'        => [],
        ];

        if ( empty( $rules ) || empty( $candidate_ids ) ) {
            return $base;
        }

        $rule_counts = [];
        foreach ( $rules as $rule ) {
            $id = (string) ( $rule['id'] ?? '' );
            if ( $id !== '' ) {
                $rule_counts[ $id ] = (int) get_option( 'prorank_auto_link_count_' . $id, 0 );
            }
        }
        $target_inbound_counts = [];
        $settings = self::default_settings();
        $max_links = (int) ( $settings['max_links_per_post'] ?? 5 );

        $items = [];
        $posts_changed = 0;
        $total_matches = 0;
        $capped = false;
        $errors = [];

        foreach ( $candidate_ids as $post_id ) {
            if ( self::memory_budget_exhausted() ) {
                $capped = true;
                $errors[] = 'memory_budget_exhausted';
                break;
            }

            $post = get_post( $post_id );
            if ( ! $post instanceof \WP_Post ) {
                continue;
            }
            $post_rules = self::filter_rules_for_post( $rules, $post );
            if ( empty( $post_rules ) ) {
                continue;
            }
            // Use the production pipeline in-memory only (no DB write).
            $local_rule_counts = $rule_counts;
            $local_inbound = $target_inbound_counts;
            $result = \ProRank\SEO\Core\RestApi\apply_auto_link_rules_to_post(
                $post,
                $post_rules,
                $local_rule_counts,
                $local_inbound,
                $settings,
                $max_links
            );
            if ( empty( $result['changed'] ) ) {
                continue;
            }
            $matches = self::diff_snippets( (string) $post->post_content, (string) $result['content'] );
            $items[] = [
                'post_id'     => (int) $post->ID,
                'post_title'  => get_the_title( (int) $post->ID ),
                'permalink'   => get_permalink( (int) $post->ID ),
                'match_count' => (int) $result['links_in_post'],
                'changes'     => (array) ( $result['changes'] ?? [] ),
                'matches'     => $matches,
            ];
            $posts_changed++;
            $total_matches += (int) $result['links_in_post'];
            unset( $post, $post_rules, $local_rule_counts, $local_inbound, $result, $matches );
            self::collect_cycles();

            if ( count( $items ) >= self::MAX_POSTS_PER_RUN ) {
                break;
            }
        }

        $dry_run_id = self::mint_dry_run_token( $rules, $candidate_ids, $rule_ids, $post_limit, $items );

        return [
            'dry_run_id'    => $dry_run_id,
            'posts_scanned' => count( $candidate_ids ),
            'posts_changed' => $posts_changed,
            'total_matches' => $total_matches,
            'items'         => $items,
            'capped'        => $capped,
            'errors'        => $errors,
        ];
    }

    /**
     * Apply: refuses without a valid dry_run_id token; uses revisions for rollback.
     */
    public static function apply( array $rule_ids = [], int $post_limit = 100, string $dry_run_id = '' ): array {
        $post_limit = max( 1, min( self::MAX_POSTS_PER_RUN, $post_limit ) );

        if ( $dry_run_id === '' ) {
            return self::error_result( 'dry_run_required' );
        }
        $token = get_transient( self::DRY_RUN_TRANSIENT_PREFIX . $dry_run_id );
        if ( ! is_array( $token ) ) {
            return self::error_result( 'dry_run_invalid_or_expired' );
        }

        $rules = self::filter_rules( self::get_rules(), $rule_ids );
        $candidate_ids = self::candidate_post_ids( $post_limit );

        // Stale-input guard: rules or candidate set changed since dry-run.
        $rules_hash = self::compute_rules_snapshot( $rules );
        $posts_hash = self::compute_posts_snapshot( $candidate_ids );
        if ( $rules_hash !== ( $token['rules_hash'] ?? '' ) ) {
            delete_transient( self::DRY_RUN_TRANSIENT_PREFIX . $dry_run_id );
            return self::error_result( 'rules_changed_since_dry_run' );
        }
        if ( $posts_hash !== ( $token['posts_hash'] ?? '' ) ) {
            delete_transient( self::DRY_RUN_TRANSIENT_PREFIX . $dry_run_id );
            return self::error_result( 'posts_changed_since_dry_run' );
        }
        // Single-use token.
        delete_transient( self::DRY_RUN_TRANSIENT_PREFIX . $dry_run_id );

        if ( empty( $rules ) || empty( $candidate_ids ) ) {
            return [ 'run_id' => '', 'posts_changed' => 0, 'total_matches' => 0, 'capped' => false, 'errors' => [] ];
        }

        $run_id        = self::generate_run_id();
        $entries       = [];
        $posts_changed = 0;
        $total_matches = 0;
        $capped        = false;
        $errors        = [];

        $rule_counts = [];
        foreach ( $rules as $rule ) {
            $id = (string) ( $rule['id'] ?? '' );
            if ( $id !== '' ) {
                $rule_counts[ $id ] = (int) get_option( 'prorank_auto_link_count_' . $id, 0 );
            }
        }
        $target_inbound_counts = [];
        $settings = self::default_settings();
        $max_links = (int) ( $settings['max_links_per_post'] ?? 5 );

        foreach ( $candidate_ids as $post_id ) {
            if ( self::memory_budget_exhausted() ) {
                $capped = true;
                $errors[] = 'memory_budget_exhausted';
                break;
            }

            if ( $posts_changed >= self::MAX_POSTS_PER_RUN ) {
                $capped = true;
                break;
            }
            $post = get_post( $post_id );
            if ( ! $post instanceof \WP_Post ) {
                continue;
            }
            $post_rules = self::filter_rules_for_post( $rules, $post );
            if ( empty( $post_rules ) ) {
                continue;
            }
            $original = (string) $post->post_content;
            $result = \ProRank\SEO\Core\RestApi\apply_auto_link_rules_to_post(
                $post,
                $post_rules,
                $rule_counts,
                $target_inbound_counts,
                $settings,
                $max_links
            );
            if ( empty( $result['changed'] ) ) {
                continue;
            }
            $rewritten = (string) $result['content'];

            // Race guard: re-fetch immediately before write.
            $current = get_post_field( 'post_content', $post_id, 'raw' );
            if ( $current !== $original ) {
                $errors[] = 'post_' . $post_id . '_changed_during_apply';
                continue;
            }

            // Capture a revision *before* the write so rollback has a safe
            // reference. WP returns the new revision id (or null if revisions
            // are disabled for this post type); we still proceed but mark the
            // entry as having no snapshot.
            $revision_id = self::create_pre_apply_revision( $post );

            $update = wp_update_post( [
                'ID'           => $post_id,
                'post_content' => $rewritten,
            ], true );
            if ( is_wp_error( $update ) || $update === 0 ) {
                $errors[] = 'post_' . $post_id . '_update_failed';
                continue;
            }

            $entries[] = [
                'post_id'         => (int) $post_id,
                'revision_id'     => (int) $revision_id,
                'old_content_hash'=> sha1( $original ),
                'new_content_hash'=> sha1( $rewritten ),
                'match_count'     => (int) $result['links_in_post'],
                'has_snapshot'    => $revision_id > 0,
            ];
            $posts_changed++;
            $total_matches += (int) $result['links_in_post'];
            unset( $post, $post_rules, $original, $result, $rewritten, $current, $update );
            self::collect_cycles();
        }

        if ( $posts_changed > 0 ) {
            self::record_run( [
                'run_id'        => $run_id,
                'timestamp'     => time(),
                'user_id'       => get_current_user_id(),
                'rule_ids'      => array_values( array_map( static fn( $r ) => (string) ( $r['id'] ?? '' ), $rules ) ),
                'posts_changed' => $posts_changed,
                'total_matches' => $total_matches,
                'entries'       => $entries,
                'status'        => 'applied',
            ] );
        }

        return [
            'run_id'        => $posts_changed > 0 ? $run_id : '',
            'posts_changed' => $posts_changed,
            'total_matches' => $total_matches,
            'capped'        => $capped,
            'errors'        => $errors,
        ];
    }

    public static function get_runs( int $limit = 20 ): array {
        $limit = max( 1, min( self::MAX_RUNS, $limit ) );
        return array_slice( self::read_runs(), 0, $limit );
    }

    /**
     * Rollback restores each post from its revision reference iff the
     * current content hash still matches the post-apply hash.
     */
    public static function rollback( string $run_id ): array {
        $runs = self::read_runs();
        $found = null;
        foreach ( $runs as $idx => $run ) {
            if ( ( $run['run_id'] ?? '' ) === $run_id ) { $found = [ $idx, $run ]; break; }
        }
        if ( $found === null ) {
            return [ 'run_id' => $run_id, 'restored' => 0, 'conflicts' => 0, 'missing' => 0, 'details' => [ 'error' => 'run_not_found' ] ];
        }
        [ $idx, $run ] = $found;
        if ( ( $run['status'] ?? '' ) === 'rolled_back' ) {
            return [ 'run_id' => $run_id, 'restored' => 0, 'conflicts' => 0, 'missing' => 0, 'details' => [ 'error' => 'already_rolled_back' ] ];
        }

        $restored = $conflicts = $missing = 0;
        $details  = [];

        foreach ( (array) ( $run['entries'] ?? [] ) as $entry ) {
            $post_id     = (int) ( $entry['post_id'] ?? 0 );
            $revision_id = (int) ( $entry['revision_id'] ?? 0 );
            if ( $post_id <= 0 ) {
                $missing++; continue;
            }
            if ( $revision_id <= 0 || empty( $entry['has_snapshot'] ) ) {
                $missing++;
                $details[] = [ 'post_id' => $post_id, 'status' => 'no_snapshot' ];
                continue;
            }

            $current = get_post_field( 'post_content', $post_id, 'raw' );
            if ( ! is_string( $current ) ) {
                $missing++;
                $details[] = [ 'post_id' => $post_id, 'status' => 'missing' ];
                continue;
            }
            if ( sha1( $current ) !== (string) ( $entry['new_content_hash'] ?? '' ) ) {
                $conflicts++;
                $details[] = [ 'post_id' => $post_id, 'status' => 'conflict' ];
                continue;
            }

            $revision = self::get_revision( $revision_id );
            if ( ! $revision ) {
                $missing++;
                $details[] = [ 'post_id' => $post_id, 'status' => 'revision_missing' ];
                continue;
            }
            $old_content = (string) $revision->post_content;
            if ( $old_content === '' || sha1( $old_content ) !== (string) ( $entry['old_content_hash'] ?? '' ) ) {
                // Revision content mutated or doesn't match what we recorded.
                $missing++;
                $details[] = [ 'post_id' => $post_id, 'status' => 'revision_hash_mismatch' ];
                continue;
            }

            $update = wp_update_post( [
                'ID'           => $post_id,
                'post_content' => $old_content,
            ], true );
            if ( is_wp_error( $update ) || $update === 0 ) {
                $missing++;
                $details[] = [ 'post_id' => $post_id, 'status' => 'update_failed' ];
                continue;
            }
            $restored++;
            $details[] = [ 'post_id' => $post_id, 'status' => 'restored' ];
        }

        if ( $restored > 0 ) {
            $runs[ $idx ]['status']         = 'rolled_back';
            $runs[ $idx ]['rolled_back_at'] = time();
            $runs[ $idx ]['rollback_user']  = get_current_user_id();
            update_option( self::RUNS_OPTION_KEY, $runs, false );
        }

        return [ 'run_id' => $run_id, 'restored' => $restored, 'conflicts' => $conflicts, 'missing' => $missing, 'details' => $details ];
    }

    public static function reset_for_tests(): void {
        delete_option( self::RUNS_OPTION_KEY );
    }

    // -----------------------------------------------------------------
    // Internals
    // -----------------------------------------------------------------

    private static function error_result( string $code ): array {
        return [ 'run_id' => '', 'posts_changed' => 0, 'total_matches' => 0, 'capped' => false, 'errors' => [ $code ] ];
    }

    private static function mint_dry_run_token( array $rules, array $candidate_ids, array $rule_ids, int $post_limit, array $items ): string {
        $dry_run_id = 'dr_' . wp_generate_password( 18, false );
        set_transient( self::DRY_RUN_TRANSIENT_PREFIX . $dry_run_id, [
            'rules_hash'  => self::compute_rules_snapshot( $rules ),
            'posts_hash'  => self::compute_posts_snapshot( $candidate_ids ),
            'rule_ids'    => $rule_ids,
            'post_limit'  => $post_limit,
            'created_at'  => time(),
            'item_count'  => count( $items ),
        ], self::DRY_RUN_TTL );
        return $dry_run_id;
    }

    private static function compute_rules_snapshot( array $rules ): string {
        $payload = array_map( static function ( $r ) {
            return [
                'id'        => (string) ( $r['id'] ?? '' ),
                'keyword'   => (string) ( $r['keyword'] ?? '' ),
                'target'    => (string) ( $r['target_url'] ?? '' ),
                'target_id' => (int) ( $r['target_id'] ?? 0 ),
                'enabled'   => (bool) ( $r['enabled'] ?? true ),
                'whole'     => (bool) ( $r['whole_word'] ?? true ),
                'case'      => (bool) ( $r['case_sensitive'] ?? false ),
                'max_post'  => (int) ( $r['max_links_per_post'] ?? 1 ),
                'priority'  => (int) ( $r['priority'] ?? 0 ),
                'exclude'   => (string) ( $r['exclude_posts'] ?? '' ),
            ];
        }, $rules );
        return sha1( wp_json_encode( $payload ) ?: '' );
    }

    private static function compute_posts_snapshot( array $post_ids ): string {
        sort( $post_ids );
        return sha1( implode( ',', array_map( 'intval', $post_ids ) ) );
    }

    /**
     * Read all keyword-map rules from the existing option, then apply the
     * same enabled+priority sort that the production /run-auto-linking
     * endpoint uses.
     */
    private static function get_rules(): array {
        $rules = get_option( 'prorank_auto_link_rules', [] );
        if ( ! is_array( $rules ) ) {
            return [];
        }
        $resolved = \ProRank\SEO\Core\RestApi\resolve_active_auto_link_rules( $rules );
        return is_array( $resolved['rules'] ?? null ) ? $resolved['rules'] : [];
    }

    /**
     * Optionally narrow the resolved rule set to a caller-supplied subset.
     */
    private static function filter_rules( array $rules, array $rule_ids ): array {
        if ( empty( $rule_ids ) ) {
            return $rules;
        }
        $wanted = array_flip( array_map( 'strval', $rule_ids ) );
        $out = [];
        foreach ( $rules as $rule ) {
            $id = (string) ( $rule['id'] ?? '' );
            if ( $id !== '' && isset( $wanted[ $id ] ) ) {
                $out[] = $rule;
            }
        }
        return $out;
    }

    /**
     * Per-post rule filtering: drop self-link rules so a post never links to
     * itself. Three independent checks, any of which is sufficient:
     *
     *   1. target_id == current post ID
     *   2. url_to_postid(target_url) == current post ID (WP resolver,
     *      handles permalink shape changes)
     *   3. canonical (host + path) of the target equals the canonical of
     *      this post's permalink, after normalizing scheme (http/https),
     *      host case, trailing slash, query, fragment, and resolving
     *      relative URLs against the site home.
     */
    private static function filter_rules_for_post( array $rules, \WP_Post $post ): array {
        $self_id  = (int) $post->ID;
        $self_key = self::canonical_target_key( (string) get_permalink( $self_id ) );

        $out = [];
        foreach ( $rules as $rule ) {
            $target_id  = (int) ( $rule['target_id'] ?? 0 );
            $target_url = (string) ( $rule['target_url'] ?? '' );

            // (1) Direct target_id match.
            if ( $target_id > 0 && $target_id === $self_id ) {
                continue;
            }

            // (2) WordPress's own URL→post id resolver.
            if ( $target_url !== '' && function_exists( 'url_to_postid' ) ) {
                $resolved = (int) url_to_postid( self::absolute_url( $target_url ) );
                if ( $resolved > 0 && $resolved === $self_id ) {
                    continue;
                }
            }

            // (3) Host+path canonical match. Handles http/https, trailing
            // slash, query/fragment, and relative URLs like "/page-slug".
            $target_key = self::canonical_target_key( $target_url );
            if ( $target_key !== '' && $target_key === $self_key ) {
                continue;
            }

            $out[] = $rule;
        }
        return $out;
    }

    /**
     * Resolve a URL against the site home if it's relative, returning an
     * absolute URL. Empty input returns ''.
     */
    private static function absolute_url( string $url ): string {
        $url = trim( $url );
        if ( $url === '' ) {
            return '';
        }
        if ( preg_match( '#^https?://#i', $url ) ) {
            return $url;
        }
        if ( ! function_exists( 'home_url' ) ) {
            return $url;
        }
        if ( $url[0] === '/' ) {
            return rtrim( home_url( '' ), '/' ) . $url;
        }
        return rtrim( home_url( '' ), '/' ) . '/' . ltrim( $url, '/' );
    }

    /**
     * Canonical "host+path" key used for self-link comparison and
     * duplicate-target detection. Returns '' when no usable host/path
     * could be derived (e.g. mailto:, javascript:, garbage input).
     */
    private static function canonical_target_key( string $url ): string {
        $url = self::absolute_url( $url );
        if ( $url === '' ) {
            return '';
        }
        if ( preg_match( '#^(javascript|mailto|tel|data):#i', $url ) ) {
            return '';
        }
        $parts = function_exists( 'wp_parse_url' ) ? wp_parse_url( $url ) : parse_url( $url );
        if ( ! is_array( $parts ) ) {
            return '';
        }
        $host = isset( $parts['host'] ) ? strtolower( (string) $parts['host'] ) : '';
        $path = isset( $parts['path'] ) ? (string) $parts['path'] : '/';
        $path = '/' . ltrim( $path, '/' );
        $path = rtrim( $path, '/' );
        if ( $path === '' ) {
            $path = '/';
        }
        if ( $host === '' && $path === '/' ) {
            return '';
        }
        return strtolower( $host . $path );
    }

    /**
     * Return the IDs of candidate posts in a stable order so the dry-run
     * token snapshot stays deterministic for the same input set.
     *
     * @return array<int>
     */
    private static function candidate_post_ids( int $limit ): array {
        $types = [ 'post', 'page' ];
        if ( function_exists( 'apply_filters' ) ) {
            $types = (array) apply_filters( 'prorank_keyword_map_post_types', $types );
        }
        $types = array_values( array_filter( array_unique( $types ), 'is_string' ) );

        $query = new \WP_Query( [
            'post_type'      => $types,
            'post_status'    => 'publish',
            'posts_per_page' => $limit,
            'orderby'        => 'modified',
            'order'          => 'DESC',
            'no_found_rows'  => true,
            'fields'         => 'ids',
            'has_password'   => false,
        ] );
        $ids = is_array( $query->posts ) ? array_map( 'intval', $query->posts ) : [];
        sort( $ids );
        return $ids;
    }

    /**
     * Defaults that mirror the keys `apply_auto_link_rules_to_post()` reads.
     */
    private static function default_settings(): array {
        $stored = get_option( 'prorank_seo_internal_linking', [] );
        $stored = is_array( $stored ) ? $stored : [];
        return array_merge( [
            'max_links_per_post'         => 5,
            'max_links_per_paragraph'    => 2,
            'skip_sentences_with_links'  => true,
            'skip_sentences'             => 0,
            'open_internal_new_tab'      => false,
            'open_external_new_tab'      => false,
            'add_nofollow'               => false,
            'add_title_attribute'        => false,
            'max_inbound_links'          => 0,
        ], $stored );
    }

    private static function memory_budget_exhausted(): bool {
        $limit = self::memory_limit_bytes();
        if ( $limit <= 0 ) {
            return false;
        }

        return memory_get_usage( true ) >= max( 0, $limit - self::MEMORY_HEADROOM_BYTES );
    }

    private static function memory_limit_bytes(): int {
        $raw = ini_get( 'memory_limit' );
        if ( ! is_string( $raw ) || $raw === '' || $raw === '-1' ) {
            return 0;
        }

        $raw = trim( $raw );
        $unit = strtolower( substr( $raw, -1 ) );
        $value = (float) $raw;

        if ( $unit === 'g' ) {
            $value *= 1024 * 1024 * 1024;
        } elseif ( $unit === 'm' ) {
            $value *= 1024 * 1024;
        } elseif ( $unit === 'k' ) {
            $value *= 1024;
        }

        return (int) $value;
    }

    private static function collect_cycles(): void {
        if ( function_exists( 'gc_collect_cycles' ) ) {
            gc_collect_cycles();
        }
    }

    /**
     * Capture a revision pointing at the current post state. Returns the
     * revision id, or 0 when revisions are disabled for this post type.
     */
    private static function create_pre_apply_revision( \WP_Post $post ): int {
        if ( ! function_exists( 'wp_save_post_revision' ) ) {
            return 0;
        }
        // WP only creates a revision when the post actually changed since the
        // last revision; force-create by passing the current post id.
        $rev = wp_save_post_revision( (int) $post->ID );
        if ( is_int( $rev ) && $rev > 0 ) {
            return $rev;
        }
        // Fallback: locate the most-recent existing revision (its content
        // equals the current post body since we haven't written yet).
        if ( function_exists( 'wp_get_post_revisions' ) ) {
            $revs = wp_get_post_revisions( (int) $post->ID, [ 'numberposts' => 1, 'orderby' => 'date', 'order' => 'DESC' ] );
            if ( is_array( $revs ) && ! empty( $revs ) ) {
                $first = reset( $revs );
                if ( $first instanceof \WP_Post ) {
                    return (int) $first->ID;
                }
            }
        }
        return 0;
    }

    private static function get_revision( int $revision_id ): ?\WP_Post {
        if ( function_exists( 'wp_get_post_revision' ) ) {
            $rev = wp_get_post_revision( $revision_id );
            if ( $rev instanceof \WP_Post ) {
                return $rev;
            }
        }
        $rev = get_post( $revision_id );
        return $rev instanceof \WP_Post ? $rev : null;
    }

    private static function read_runs(): array {
        $runs = get_option( self::RUNS_OPTION_KEY, [] );
        if ( ! is_array( $runs ) ) {
            return [];
        }
        usort( $runs, static function ( $a, $b ) {
            return (int) ( $b['timestamp'] ?? 0 ) <=> (int) ( $a['timestamp'] ?? 0 );
        } );
        return $runs;
    }

    private static function record_run( array $entry ): void {
        $runs = self::read_runs();
        array_unshift( $runs, $entry );
        if ( count( $runs ) > self::MAX_RUNS ) {
            $runs = array_slice( $runs, 0, self::MAX_RUNS );
        }
        update_option( self::RUNS_OPTION_KEY, $runs, false );
    }

    private static function generate_run_id(): string {
        return 'kmrun_' . wp_date( 'YmdHis' ) . '_' . substr( wp_generate_password( 6, false ), 0, 6 );
    }

    /**
     * Lightweight before/after snippet diff. Bounded to 5 snippets per post.
     *
     * @return array<int,array{before:string,after:string}>
     */
    private static function diff_snippets( string $before, string $after ): array {
        if ( $before === $after ) {
            return [];
        }
        $snippets = [];
        $a = 0; $b = 0;
        $la = strlen( $before ); $lb = strlen( $after );
        while ( $a < $la && $b < $lb ) {
            $sync = 0;
            while ( $a + $sync < $la && $b + $sync < $lb && $before[ $a + $sync ] === $after[ $b + $sync ] ) {
                $sync++;
            }
            $a += $sync; $b += $sync;
            if ( $a >= $la || $b >= $lb ) break;

            $needle = substr( $before, $a, 32 );
            $rb = $needle === '' ? false : strpos( $after, $needle, $b );
            if ( $rb === false ) { $rb = $lb; }
            $ra = $rb === $lb ? $la : $a + 32;

            $cs = max( 0, $a - self::SNIPPET_RADIUS );
            $cea = min( $la, $ra + self::SNIPPET_RADIUS );
            $ceb = min( $lb, $rb + self::SNIPPET_RADIUS );
            $snippets[] = [
                'before' => substr( $before, $cs, $cea - $cs ),
                'after'  => substr( $after,  $cs, $ceb - $cs ),
            ];
            $a = $ra; $b = $rb;
            if ( count( $snippets ) >= 5 ) break;
        }
        return $snippets;
    }
}
