<?php

declare(strict_types=1);

/**
 * Image Attribute Apply / History / Rollback (Task 9).
 *
 * Pairs with `ImageAttributePatternEngine` to:
 *
 *   - Build a dry-run preview (no writes).
 *   - Apply changes within a hard per-run cap.
 *   - Record each apply in a bounded option-backed log.
 *   - Roll back a previous run, but only when current attachment field
 *     values still match the values we recorded as "new" — if the user
 *     edited after the apply, the rollback for that row is refused with
 *     a `conflict` reason, never silently overwriting their edits.
 *
 * Per-attachment provenance is stored alongside the alt/title/caption
 * meta so the Media Library "generated by ProRank" filter can find them
 * cheaply with a `meta_query`.
 *
 * @package ProRank\SEO\Core\Media
 * @since   1.3.1
 */

namespace ProRank\SEO\Core\Media;

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


class ImageAttributeHistory {

    /** Per-attachment meta keys. Public so the Media filter helper can reuse them. */
    public const META_GENERATED  = '_prorank_image_attr_generated';
    public const META_CHANGED_AT = '_prorank_image_attr_changed_at';
    public const META_LAST_RUN   = '_prorank_image_attr_last_run';

    /** Option key + bound on the global run log. */
    public const OPTION_HISTORY = 'prorank_image_attr_history';
    public const HISTORY_MAX    = 50;

    /** Hard cap on attachments processed per dry-run or apply call. */
    public const RUN_CAP = 100;

    public const MODE_ONLY_MISSING     = 'only_missing';
    public const MODE_OVERWRITE        = 'overwrite';
    public const ALLOWED_FIELDS = [ 'alt', 'title', 'caption' ];

    /** R1 — dry-run token transient. Single-use, short TTL. */
    public const DRYRUN_TRANSIENT_PREFIX = 'prorank_iattr_dryrun_';
    public const DRYRUN_TTL = 300;

    /** R1 — history `status` field values. */
    public const STATUS_APPLIED          = 'applied';
    public const STATUS_ROLLED_BACK      = 'rolled_back';
    public const STATUS_ROLLBACK_PARTIAL = 'rollback_partial';
    public const STATUS_CONFLICT         = 'conflict';

    /**
     * Build a preview without writing anything.
     *
     * @param array{
     *   templates:array{alt?:string,title?:string,caption?:string},
     *   fields:array<int,string>,
     *   mode:string,
     *   attachment_ids:array<int,int>
     * } $args
     * @return array{
     *   total:int,
     *   would_write:int,
     *   skipped:int,
     *   items:array<int,array>,
     *   cap:int,
     *   capped:bool
     * }
     */
    public static function dry_run( array $args, int $user_id = 0 ): array {
        $args = self::normalize_args( $args );
        $items = [];
        $would_write = 0;
        $skipped = 0;

        foreach ( $args['attachment_ids'] as $id ) {
            $row = self::resolve_one( $id, $args );
            $items[] = $row;
            if ( ! empty( $row['skipped'] ) ) {
                $skipped++;
                continue;
            }
            // would_write = at least one allowed field actually differs.
            $write_any = false;
            foreach ( $row['fields_to_change'] as $f => $on ) {
                if ( $on ) {
                    $write_any = true;
                    break;
                }
            }
            if ( $write_any ) {
                $would_write++;
            } else {
                $skipped++;
            }
        }

        // R1 — mint a single-use dry-run token. The apply endpoint must
        // present this token AND the signature must still match (request
        // body + current attachment values + user) to write. Token TTL is
        // intentionally short (5 min) so a tab left open overnight cannot
        // surprise-apply against drifted content.
        $effective_user = $user_id > 0
            ? $user_id
            : ( function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0 );

        $signature = self::compute_dryrun_signature( $args, $items, $effective_user );
        $dry_run_id = self::generate_run_id();
        $stored = [
            'signature'  => $signature,
            'user_id'    => $effective_user,
            'created_at' => time(),
        ];
        if ( function_exists( 'set_transient' ) ) {
            set_transient( self::DRYRUN_TRANSIENT_PREFIX . $dry_run_id, $stored, self::DRYRUN_TTL );
        }

        return [
            'dry_run_id'  => $dry_run_id,
            'expires_in'  => self::DRYRUN_TTL,
            'total'       => count( $args['attachment_ids'] ),
            'would_write' => $would_write,
            'skipped'     => $skipped,
            'items'       => $items,
            'cap'         => self::RUN_CAP,
            'capped'      => $args['capped'],
        ];
    }

    /**
     * Apply changes and record the run.
     *
     * Returns the same shape as dry_run() plus `run_id` and per-row
     * `applied` flags. Writes only the fields that were enabled AND
     * differ AND pass the eligibility/permission gate.
     */
    public static function apply( array $args, int $user_id = 0, string $dry_run_id = '' ): array {
        $args = self::normalize_args( $args );

        // R1 — server-side dry-run gate. apply() MUST be preceded by a
        // dry_run() that minted a single-use token; the token's stored
        // signature must still match the live signature (request body +
        // current attachment values + same user). The token is consumed
        // before any write so a duplicate apply cannot replay.
        $items_for_sig = [];
        foreach ( $args['attachment_ids'] as $id ) {
            $items_for_sig[] = self::resolve_one( $id, $args );
        }
        $live_signature = self::compute_dryrun_signature( $args, $items_for_sig, $user_id );

        $gate = self::verify_dryrun_token( $dry_run_id, $live_signature, $user_id );
        if ( ! $gate['ok'] ) {
            return [
                'ok'      => false,
                'reason'  => $gate['reason'],
                'run_id'  => '',
                'total'   => count( $args['attachment_ids'] ),
                'applied' => 0,
                'skipped' => count( $args['attachment_ids'] ),
                'items'   => [],
                'cap'     => self::RUN_CAP,
                'capped'  => $args['capped'],
            ];
        }

        $items = [];
        $applied = 0;
        $skipped = 0;
        $changes = [];

        // Reuse the same resolved rows the signature was computed from so
        // we operate on a single consistent view of the world for both
        // verification and writes.
        foreach ( $items_for_sig as $row ) {
            if ( ! empty( $row['skipped'] ) ) {
                $items[] = $row;
                $skipped++;
                continue;
            }

            $write = [];
            $before = [];
            $after = [];
            foreach ( $row['fields_to_change'] as $field => $on ) {
                if ( ! $on ) {
                    continue;
                }
                $before[ $field ] = $row['before'][ $field ];
                $after[ $field ]  = $row['after'][ $field ];
                $write[ $field ]  = $row['after'][ $field ];
            }
            if ( empty( $write ) ) {
                $row['applied'] = false;
                $row['skipped_reason'] = 'no_changes';
                $items[] = $row;
                $skipped++;
                continue;
            }

            self::write_fields( (int) $row['id'], $write );

            // Per-attachment provenance for the Media Library filter.
            update_post_meta( (int) $row['id'], self::META_GENERATED, 1 );
            update_post_meta( (int) $row['id'], self::META_CHANGED_AT, time() );

            $row['applied'] = true;
            $items[] = $row;
            $applied++;
            $changes[] = [
                'id'     => (int) $row['id'],
                'before' => $before,
                'after'  => $after,
            ];
        }

        $run_id = self::record_run( [
            'user_id'   => $user_id,
            'mode'      => $args['mode'],
            'fields'    => $args['fields'],
            'templates' => $args['templates'],
            'changes'   => $changes,
            'status'    => self::STATUS_APPLIED,
        ] );

        // Tag each applied attachment with the run_id so rollback by id
        // can find them without scanning every history entry.
        foreach ( $changes as $c ) {
            update_post_meta( (int) $c['id'], self::META_LAST_RUN, $run_id );
        }

        return [
            'ok'          => true,
            'run_id'      => $run_id,
            'total'       => count( $args['attachment_ids'] ),
            'applied'     => $applied,
            'skipped'     => $skipped,
            'items'       => $items,
            'cap'         => self::RUN_CAP,
            'capped'      => $args['capped'],
        ];
    }

    /**
     * Rollback a previous run. Per-row conflict-aware:
     *
     *   - For each (id, field) in the recorded changes, compare the
     *     current DB value with the recorded `after` value.
     *   - If they match, restore the recorded `before` value.
     *   - If they do not match, report `conflict` and leave the field
     *     alone.
     */
    public static function rollback( string $run_id ): array {
        $run = self::find_run( $run_id );
        if ( ! $run ) {
            return [
                'ok'       => false,
                'reason'   => 'unknown_run',
                'restored' => 0,
                'conflict' => 0,
                'skipped'  => 0,
                'items'    => [],
            ];
        }
        // R1 — refuse re-rollback of a fully rolled-back run. A
        // `rollback_partial` run is still allowed to be retried (the
        // earlier conflicts may now match if the user re-aligned them).
        $current_status = (string) ( $run['status'] ?? self::STATUS_APPLIED );
        if ( $current_status === self::STATUS_ROLLED_BACK ) {
            return [
                'ok'       => false,
                'reason'   => 'already_rolled_back',
                'run_id'   => $run_id,
                'restored' => 0,
                'conflict' => 0,
                'skipped'  => 0,
                'items'    => [],
            ];
        }

        $restored = 0;
        $conflict = 0;
        $skipped  = 0;
        $items    = [];
        foreach ( (array) ( $run['changes'] ?? [] ) as $c ) {
            $att_id = (int) ( $c['id'] ?? 0 );
            if ( $att_id <= 0 ) {
                continue;
            }
            if ( ! self::user_can_edit_attachment( $att_id ) ) {
                $items[] = [ 'id' => $att_id, 'reason' => 'forbidden', 'fields_restored' => [], 'fields_conflicted' => [] ];
                $skipped++;
                continue;
            }
            $current = self::read_fields( $att_id );

            $fields_restored = [];
            $fields_conflicted = [];

            foreach ( [ 'alt', 'title', 'caption' ] as $field ) {
                if ( ! array_key_exists( $field, (array) ( $c['after'] ?? [] ) ) ) {
                    continue;
                }
                $expected_now = (string) $c['after'][ $field ];
                $restore_to   = (string) ( $c['before'][ $field ] ?? '' );
                if ( $current[ $field ] === $expected_now ) {
                    self::write_fields( $att_id, [ $field => $restore_to ] );
                    $fields_restored[] = $field;
                } else {
                    $fields_conflicted[] = $field;
                }
            }
            if ( ! empty( $fields_conflicted ) && empty( $fields_restored ) ) {
                $conflict++;
                $items[] = [
                    'id'                 => $att_id,
                    'reason'             => 'conflict',
                    'fields_restored'    => $fields_restored,
                    'fields_conflicted'  => $fields_conflicted,
                ];
                continue;
            }

            // R1 — provenance reconciliation. Only touch META_LAST_RUN /
            // META_CHANGED_AT / META_GENERATED if this run is the current
            // owner of the attachment's provenance. If a later applied
            // run B has stamped its own run_id, B's generated state is
            // still on the attachment — clearing here would wrongly hide
            // B from the "Generated by ProRank" filter.
            if ( ! empty( $fields_restored ) ) {
                $current_last_run = (string) get_post_meta( $att_id, self::META_LAST_RUN, true );
                if ( $current_last_run === $run_id ) {
                    // We are the latest writer for this attachment. Walk
                    // history backwards looking for an older applied run
                    // that still has touched this attachment, and pin
                    // provenance to that run. If none, clear provenance
                    // entirely — no remaining ProRank-generated state.
                    $prev = self::find_latest_applied_run_for_attachment( $att_id, $run_id );
                    if ( $prev ) {
                        update_post_meta( $att_id, self::META_LAST_RUN,   (string) $prev['run_id'] );
                        update_post_meta( $att_id, self::META_CHANGED_AT, (int)    $prev['ts'] );
                        update_post_meta( $att_id, self::META_GENERATED,  1 );
                    } else {
                        delete_post_meta( $att_id, self::META_GENERATED );
                        delete_post_meta( $att_id, self::META_LAST_RUN );
                        delete_post_meta( $att_id, self::META_CHANGED_AT );
                    }
                }
                // else: a newer run owns provenance — leave the three meta
                // keys alone so B keeps its "generated by ProRank" badge.
            }

            $restored++;
            $items[] = [
                'id'                 => $att_id,
                'reason'             => empty( $fields_conflicted ) ? 'ok' : 'partial',
                'fields_restored'    => $fields_restored,
                'fields_conflicted'  => $fields_conflicted,
            ];
        }

        // R1 — stamp the run's history entry with its new lifecycle status
        // + rolled_back_at so the UI can mark this run as rolled-back and
        // not let the user keep clicking "Rollback" on the same entry.
        $new_status = self::compute_rollback_status( $restored, $conflict );
        self::update_run_status( $run_id, $new_status, time() );
        return [
            'ok'       => true,
            'run_id'   => $run_id,
            'restored' => $restored,
            'conflict' => $conflict,
            'skipped'  => $skipped,
            'items'    => $items,
        ];
    }

    /**
     * Return the bounded history (newest first). Drops the bulky
     * `changes[*]` arrays unless `$with_changes` is true so the UI list
     * stays cheap.
     */
    public static function get_history( bool $with_changes = false ): array {
        $log = get_option( self::OPTION_HISTORY, [] );
        if ( ! is_array( $log ) ) {
            return [];
        }
        $out = [];
        foreach ( $log as $run ) {
            if ( ! is_array( $run ) ) {
                continue;
            }
            $summary = [
                'run_id'         => (string) ( $run['run_id'] ?? '' ),
                'ts'             => (int)    ( $run['ts'] ?? 0 ),
                'user_id'        => (int)    ( $run['user_id'] ?? 0 ),
                'mode'           => (string) ( $run['mode'] ?? '' ),
                'fields'         => array_values( (array) ( $run['fields'] ?? [] ) ),
                'templates'      => (array)  ( $run['templates'] ?? [] ),
                'change_count'   => is_array( $run['changes'] ?? null ) ? count( $run['changes'] ) : 0,
                // R1 — lifecycle status so the UI can mark rolled-back runs
                // and stop offering Rollback on them.
                'status'         => (string) ( $run['status'] ?? self::STATUS_APPLIED ),
                'rolled_back_at' => (int)    ( $run['rolled_back_at'] ?? 0 ),
            ];
            if ( $with_changes ) {
                $summary['changes'] = (array) ( $run['changes'] ?? [] );
            }
            $out[] = $summary;
        }
        return $out;
    }

    /**
     * Per-attachment edit gate. Used by both apply() and rollback() so
     * the bulk endpoint can't write to attachments the current user has
     * no edit rights on, even when called via the bulk path.
     */
    public static function user_can_edit_attachment( int $attachment_id ): bool {
        if ( ! function_exists( 'current_user_can' ) ) {
            return false;
        }
        return current_user_can( 'edit_post', $attachment_id );
    }

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

    private static function normalize_args( array $args ): array {
        $templates = (array) ( $args['templates'] ?? [] );
        $templates['alt']     = isset( $templates['alt'] )     ? (string) $templates['alt']     : '';
        $templates['title']   = isset( $templates['title'] )   ? (string) $templates['title']   : '';
        $templates['caption'] = isset( $templates['caption'] ) ? (string) $templates['caption'] : '';

        $raw_fields = (array) ( $args['fields'] ?? [] );
        $fields = [];
        foreach ( $raw_fields as $f ) {
            $f = is_scalar( $f ) ? strtolower( trim( (string) $f ) ) : '';
            if ( in_array( $f, self::ALLOWED_FIELDS, true ) ) {
                $fields[] = $f;
            }
        }
        $fields = array_values( array_unique( $fields ) );

        $mode = (string) ( $args['mode'] ?? self::MODE_ONLY_MISSING );
        if ( $mode !== self::MODE_OVERWRITE ) {
            $mode = self::MODE_ONLY_MISSING;
        }

        $ids = (array) ( $args['attachment_ids'] ?? [] );
        $ids = array_values( array_filter( array_map( 'intval', $ids ), static function ( $i ) { return $i > 0; } ) );
        $capped = false;
        if ( count( $ids ) > self::RUN_CAP ) {
            $ids = array_slice( $ids, 0, self::RUN_CAP );
            $capped = true;
        }

        return [
            'templates'      => $templates,
            'fields'         => $fields,
            'mode'           => $mode,
            'attachment_ids' => $ids,
            'capped'         => $capped,
        ];
    }

    /**
     * Resolve one attachment into a structured preview row. Returns
     * `skipped` + `skipped_reason` when the attachment is ineligible.
     */
    private static function resolve_one( int $attachment_id, array $args ): array {
        $row = [
            'id'                 => $attachment_id,
            'filename'           => '',
            'mime'               => '',
            'before'             => [ 'alt' => '', 'title' => '', 'caption' => '' ],
            'after'              => [ 'alt' => '', 'title' => '', 'caption' => '' ],
            'fields_to_change'   => [ 'alt' => false, 'title' => false, 'caption' => false ],
            'skipped'            => false,
            'skipped_reason'     => '',
            'applied'            => false,
        ];

        $post = function_exists( 'get_post' ) ? get_post( $attachment_id ) : null;
        if ( ! $post || ( $post->post_type ?? '' ) !== 'attachment' ) {
            $row['skipped'] = true;
            $row['skipped_reason'] = 'not_attachment';
            return $row;
        }
        $mime = (string) ( $post->post_mime_type ?? '' );
        $row['mime'] = $mime;
        if ( strpos( $mime, 'image/' ) !== 0 ) {
            $row['skipped'] = true;
            $row['skipped_reason'] = 'not_image';
            return $row;
        }
        $status = (string) ( $post->post_status ?? '' );
        if ( in_array( $status, [ 'trash', 'auto-draft' ], true ) ) {
            $row['skipped'] = true;
            $row['skipped_reason'] = 'inaccessible_status:' . $status;
            return $row;
        }
        if ( ! self::user_can_edit_attachment( $attachment_id ) ) {
            $row['skipped'] = true;
            $row['skipped_reason'] = 'forbidden';
            return $row;
        }

        $row['filename'] = basename( (string) ( function_exists( 'get_attached_file' ) ? get_attached_file( $attachment_id ) : '' ) );

        $proposed = ImageAttributePatternEngine::generate( $attachment_id, $args['templates'] );
        $current  = self::read_fields( $attachment_id );

        $row['before']['alt']     = $current['alt'];
        $row['before']['title']   = $current['title'];
        $row['before']['caption'] = $current['caption'];

        $row['after']['alt']     = $proposed['alt'];
        $row['after']['title']   = $proposed['title'];
        $row['after']['caption'] = $proposed['caption'];

        $enabled = array_flip( $args['fields'] );
        foreach ( self::ALLOWED_FIELDS as $field ) {
            if ( ! isset( $enabled[ $field ] ) ) {
                continue;
            }
            // Empty template means the user did not want to touch this field.
            if ( $args['templates'][ $field ] === '' ) {
                continue;
            }
            // Empty proposed value? Skip (engine never emits empty alt
            // unless caller didn't supply a template, but be defensive).
            if ( $proposed[ $field ] === '' ) {
                continue;
            }
            // "only_missing": skip when current value is non-empty.
            if ( $args['mode'] === self::MODE_ONLY_MISSING && $current[ $field ] !== '' ) {
                continue;
            }
            // No-op: proposed === current.
            if ( $proposed[ $field ] === $current[ $field ] ) {
                continue;
            }
            $row['fields_to_change'][ $field ] = true;
        }
        return $row;
    }

    /** Read the three attachment fields in one go. */
    private static function read_fields( int $attachment_id ): array {
        $post = function_exists( 'get_post' ) ? get_post( $attachment_id ) : null;
        $alt = (string) get_post_meta( $attachment_id, '_wp_attachment_image_alt', true );
        $title = $post ? (string) ( $post->post_title ?? '' ) : '';
        $caption = $post ? (string) ( $post->post_excerpt ?? '' ) : '';
        return [ 'alt' => $alt, 'title' => $title, 'caption' => $caption ];
    }

    /**
     * Write any subset of (alt, title, caption). Alt goes to post meta;
     * title/caption go through wp_update_post so revisions and i18n run.
     */
    private static function write_fields( int $attachment_id, array $write ): void {
        if ( array_key_exists( 'alt', $write ) ) {
            update_post_meta( $attachment_id, '_wp_attachment_image_alt', (string) $write['alt'] );
        }
        $post_update = [];
        if ( array_key_exists( 'title', $write ) ) {
            $post_update['post_title'] = (string) $write['title'];
        }
        if ( array_key_exists( 'caption', $write ) ) {
            $post_update['post_excerpt'] = (string) $write['caption'];
        }
        if ( ! empty( $post_update ) && function_exists( 'wp_update_post' ) ) {
            $post_update['ID'] = $attachment_id;
            wp_update_post( $post_update );
        }
    }

    /** Persist one run into the bounded option log; returns the run_id. */
    /**
     * R1 — verify a dry-run token: present, in transient, same user,
     * signature still matches. Single-use: deletes the transient on a
     * successful match so a duplicate apply call cannot replay.
     */
    private static function verify_dryrun_token( string $token, string $live_signature, int $user_id ): array {
        if ( $token === '' ) {
            return [ 'ok' => false, 'reason' => 'dry_run_required' ];
        }
        $key = self::DRYRUN_TRANSIENT_PREFIX . $token;
        $stored = function_exists( 'get_transient' ) ? get_transient( $key ) : false;
        if ( ! is_array( $stored ) ) {
            return [ 'ok' => false, 'reason' => 'dry_run_expired_or_unknown' ];
        }
        if ( (int) ( $stored['user_id'] ?? -1 ) !== $user_id ) {
            return [ 'ok' => false, 'reason' => 'dry_run_user_mismatch' ];
        }
        if ( ! hash_equals( (string) ( $stored['signature'] ?? '' ), $live_signature ) ) {
            return [ 'ok' => false, 'reason' => 'dry_run_drift' ];
        }
        // Single-use: consume before any write so a duplicate POST cannot
        // replay even if a network retry fires it twice.
        if ( function_exists( 'delete_transient' ) ) {
            delete_transient( $key );
        }
        return [ 'ok' => true ];
    }

    /**
     * R1 — signature payload. Covers everything that, if different,
     * means the user is staring at a different preview than they think:
     *  - normalized + capped attachment_ids (sorted)
     *  - selected fields (sorted)
     *  - mode
     *  - templates (alt/title/caption strings)
     *  - per-id before / after values for fields the engine would change
     *  - the user making the request
     */
    private static function compute_dryrun_signature( array $args, array $items, int $user_id ): string {
        $by_id = [];
        foreach ( $items as $row ) {
            if ( ! empty( $row['skipped'] ) ) {
                continue;
            }
            $by_id[] = [
                'id'     => (int) $row['id'],
                'fields' => array_keys( array_filter( (array) $row['fields_to_change'] ) ),
                'before' => $row['before'],
                'after'  => $row['after'],
            ];
        }
        // Stable order so a different array iteration cannot change the hash.
        usort( $by_id, static function ( $a, $b ) { return $a['id'] <=> $b['id']; } );

        $payload = [
            'v'         => 1,
            'ids'       => array_values( $args['attachment_ids'] ),
            'fields'    => $args['fields'],
            'mode'      => $args['mode'],
            'templates' => [
                'alt'     => (string) ( $args['templates']['alt']     ?? '' ),
                'title'   => (string) ( $args['templates']['title']   ?? '' ),
                'caption' => (string) ( $args['templates']['caption'] ?? '' ),
            ],
            'by_id'     => $by_id,
            'user_id'   => $user_id,
        ];
        $encoded = function_exists( 'wp_json_encode' )
            ? wp_json_encode( $payload )
            : json_encode( $payload );
        return sha1( (string) $encoded );
    }

    /**
     * R1 — walk the run log newest→oldest looking for an applied run
     * (other than $exclude_run_id) that touched $att_id. The history is
     * stored with newest at index 0 so we can stop at the first match.
     *
     * @return array|null  Run entry, or null if no prior applied run touched $att_id.
     */
    private static function find_latest_applied_run_for_attachment( int $att_id, string $exclude_run_id ): ?array {
        $log = get_option( self::OPTION_HISTORY, [] );
        if ( ! is_array( $log ) ) {
            return null;
        }
        foreach ( $log as $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            if ( (string) ( $entry['run_id'] ?? '' ) === $exclude_run_id ) {
                continue;
            }
            $status = (string) ( $entry['status'] ?? self::STATUS_APPLIED );
            if ( ! in_array( $status, [ self::STATUS_APPLIED, self::STATUS_ROLLBACK_PARTIAL ], true ) ) {
                continue;
            }
            foreach ( (array) ( $entry['changes'] ?? [] ) as $c ) {
                if ( (int) ( $c['id'] ?? 0 ) === $att_id ) {
                    return $entry;
                }
            }
        }
        return null;
    }

    /** R1 — translate restored/conflict counts into a stable run status. */
    private static function compute_rollback_status( int $restored, int $conflict ): string {
        if ( $restored > 0 && $conflict === 0 ) {
            return self::STATUS_ROLLED_BACK;
        }
        if ( $restored > 0 && $conflict > 0 ) {
            return self::STATUS_ROLLBACK_PARTIAL;
        }
        if ( $restored === 0 && $conflict > 0 ) {
            return self::STATUS_CONFLICT;
        }
        // restored=0 + conflict=0 means "nothing to rollback" (e.g. all rows
        // forbidden by perms) — leave the status as-is on the calling side.
        return self::STATUS_CONFLICT;
    }

    /** R1 — flip a run's status + record `rolled_back_at` timestamp. */
    private static function update_run_status( string $run_id, string $status, int $ts ): void {
        $log = get_option( self::OPTION_HISTORY, [] );
        if ( ! is_array( $log ) ) {
            return;
        }
        $changed = false;
        foreach ( $log as $i => $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            if ( (string) ( $entry['run_id'] ?? '' ) === $run_id ) {
                $log[ $i ]['status']         = $status;
                $log[ $i ]['rolled_back_at'] = $ts;
                $changed = true;
                break;
            }
        }
        if ( $changed ) {
            update_option( self::OPTION_HISTORY, $log, false );
        }
    }

    private static function record_run( array $run ): string {
        $run_id = self::generate_run_id();
        $entry = [
            'run_id'    => $run_id,
            'ts'        => time(),
            'user_id'   => (int) ( $run['user_id'] ?? 0 ),
            'mode'      => (string) ( $run['mode'] ?? '' ),
            'fields'    => array_values( (array) ( $run['fields'] ?? [] ) ),
            'templates' => (array) ( $run['templates'] ?? [] ),
            'changes'   => array_values( (array) ( $run['changes'] ?? [] ) ),
            'status'    => (string) ( $run['status'] ?? self::STATUS_APPLIED ),
        ];
        $log = get_option( self::OPTION_HISTORY, [] );
        if ( ! is_array( $log ) ) {
            $log = [];
        }
        array_unshift( $log, $entry );
        if ( count( $log ) > self::HISTORY_MAX ) {
            $log = array_slice( $log, 0, self::HISTORY_MAX );
        }
        update_option( self::OPTION_HISTORY, $log, false );
        return $run_id;
    }

    private static function find_run( string $run_id ): ?array {
        if ( $run_id === '' ) {
            return null;
        }
        $log = get_option( self::OPTION_HISTORY, [] );
        if ( ! is_array( $log ) ) {
            return null;
        }
        foreach ( $log as $entry ) {
            if ( is_array( $entry ) && (string) ( $entry['run_id'] ?? '' ) === $run_id ) {
                return $entry;
            }
        }
        return null;
    }

    private static function generate_run_id(): string {
        if ( function_exists( 'wp_generate_uuid4' ) ) {
            return (string) wp_generate_uuid4();
        }
        // RFC4122 v4 fallback so we don't depend on WP version.
        $bytes = function_exists( 'random_bytes' ) ? random_bytes( 16 ) : openssl_random_pseudo_bytes( 16 );
        $bytes[6] = chr( ( ord( $bytes[6] ) & 0x0f ) | 0x40 );
        $bytes[8] = chr( ( ord( $bytes[8] ) & 0x3f ) | 0x80 );
        return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $bytes ), 4 ) );
    }
}
