<?php
/**
 * Rank Booster Heading Fix handler
 *
 * Applies (and reverts) the heading-family content fixes:
 *   • long_h1_h2_tag    — shorten an existing over-long H1/H2.
 *   • missing_h1_tag    — promote a top heading to H1, or insert one.
 *
 * A long "H1" is very often the post TITLE rendered as the page H1 by the theme
 * (so it isn't in post_content at all). We therefore also match against the post
 * title and, when it matches, shorten the title itself.
 *
 * Reverting uses a whole-record snapshot taken immediately before the edit
 * (post_content + post_title for post_content builders, _elementor_data for
 * Elementor). This keeps revert dead simple and always correct for a single fix.
 */

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

class Rank_Booster_Heading_Fix {

    /** Post-content builders whose visible headings live in post_content. */
    const POST_CONTENT_EDITORS = array( 'classic', 'gutenberg', 'divi', 'wpbakery' );

    /**
     * Editors where it is SAFE to INSERT/promote heading markup.
     *
     * These are the builders whose canonical content lives in post_content
     * (classic, gutenberg, and the shortcode builders Divi & WPBakery) plus
     * Elementor (where we inject via its own widget API).
     *
     * Deliberately EXCLUDED: builders that store their layout in meta and treat
     * post_content as a derived cache — SiteOrigin (panels_data), Oxygen, Beaver
     * Builder, Brizy, Thrive, Breakdance. Writing to post_content there desyncs /
     * corrupts the builder (this broke a live SiteOrigin page).
     */
    const INSERT_SAFE_EDITORS = array( 'classic', 'gutenberg', 'divi', 'wpbakery', 'elementor' );

    /** Meta key prefix for the pre-edit snapshot used on revert. */
    const BACKUP_META_PREFIX = '_rank_cf_backup_';

    /**
     * Whether a heading fix may run on a given editor.
     *
     * - missing_h1_tag INSERTS markup → only insert-safe editors.
     * - long_h1_h2_tag only REPLACES existing heading text or updates the post
     *   title / a custom field. The shared content-fix precheck owns the
     *   page-builder positive list before any AI request.
     */
    public static function is_editor_supported( $validator, $editor_type ) {
        if ( $validator === 'missing_h1_tag' ) {
            return in_array( $editor_type, self::INSERT_SAFE_EDITORS, true );
        }
        if ( $validator === 'long_h1_h2_tag' ) {
            return true;
        }
        return false;
    }

    /**
     * Apply a heading fix.
     *
     * @param array $context post_id, editor_type, validator, parameters, solution, mode
     * @return array|WP_Error { success, original_value } on success.
     */
    public function apply( $context ) {
        $post_id     = (int) $context['post_id'];
        $editor_type = $context['editor_type'];
        $validator   = $context['validator'];
        $parameters  = isset( $context['parameters'] ) && is_array( $context['parameters'] ) ? $context['parameters'] : array();
        $solution    = trim( (string) $context['solution'] );

        $post = get_post( $post_id );
        if ( ! $post ) {
            return new WP_Error( 'post_not_found', 'Post not found.' );
        }
        if ( $solution === '' ) {
            return new WP_Error( 'empty_solution', 'No replacement heading text was provided.' );
        }

        // Note: we intentionally do NOT hard-fail on unknown editors here.
        // long_h1_h2_tag can still be fixed via the post-title or custom-field
        // fallbacks regardless of the page builder. missing_h1_tag does require a
        // supported editor (it inserts markup) — enforced in that branch below.

        // Snapshot before any edit so revert can restore exactly.
        $this->snapshot( $post_id, $editor_type, $validator );

        $io       = new Rank_Booster_Heading_IO();
        $original = '';
        $changed  = false;

        if ( $validator === 'long_h1_h2_tag' ) {
            $tag_type = isset( $parameters['type'] ) ? $parameters['type'] : 'h1';
            $current  = isset( $parameters['tag'] ) ? (string) $parameters['tag'] : '';
            $original = $current;

            if ( $current === '' ) {
                $this->clear_snapshot( $post_id, $validator );
                return new WP_Error( 'missing_parameters', 'The issue is missing the original heading text.' );
            }

            // 1) Try the editor's own content (in-page heading).
            if ( in_array( $editor_type, self::POST_CONTENT_EDITORS, true ) ) {
                $new_content = $io->replace_heading_in_content( $post->post_content, $tag_type, $current, $solution );
                $changed     = ( $new_content !== $post->post_content );
                if ( $changed ) {
                    $this->save_post_content( $post_id, $new_content );
                }
            } elseif ( $editor_type === 'elementor' ) {
                $changed = $io->replace_elementor_heading( $post_id, $tag_type, $current, $solution );
            }
            // Other builders (SiteOrigin, Oxygen, …) fall through to the
            // post-title + custom-field fallbacks below.

            // 2) Fallback: the long heading is the post TITLE (theme-rendered H1).
            if ( ! $changed && $io->text_equals( $current, $post->post_title ) ) {
                $this->save_post_title( $post_id, $solution );
                $changed = true;
            }

            // 3) Fallback: the heading lives in an ACF / custom (meta) field.
            if ( ! $changed ) {
                $changed = $this->replace_in_custom_fields(
                    $post_id, $validator, $tag_type, $current, $solution,
                    isset( $context['page_url'] ) ? $context['page_url'] : '', $io
                );
            }
        } elseif ( $validator === 'missing_h1_tag' ) {
            $original = 'missing';
            // Inserting markup is only safe on editors we fully control.
            if ( ! self::is_editor_supported( $validator, $editor_type ) ) {
                $this->clear_snapshot( $post_id, $validator );
                return new WP_Error(
                    'unsupported_editor',
                    sprintf( 'Inserting a missing H1 is not supported for the "%s" editor.', $editor_type )
                );
            }
            if ( $editor_type === 'elementor' ) {
                $changed = $io->insert_elementor_heading( $post_id, 'h1', $solution );
            } else {
                $new_content = $io->promote_or_insert_h1( $post->post_content, $solution );
                $changed     = ( $new_content !== $post->post_content );
                if ( $changed ) {
                    $this->save_post_content( $post_id, $new_content );
                }
            }
        } else {
            $this->clear_snapshot( $post_id, $validator );
            return new WP_Error( 'unknown_validator', 'Unknown heading fix: ' . $validator );
        }

        if ( ! $changed ) {
            $this->log_no_change( $context, $post, $parameters, $io );
            // Nothing was modified — drop the snapshot so we don't leave stale data.
            $this->clear_snapshot( $post_id, $validator );
            return new WP_Error(
                'no_change',
                'Could not locate the heading to update on this page. It may live in a page builder, theme template or custom field we can\'t edit directly.'
            );
        }

        return array(
            'success'        => true,
            'original_value' => $original,
        );
    }

    /**
     * Revert a previously applied heading fix by restoring the pre-edit snapshot.
     *
     * @param array $context post_id, editor_type, validator
     * @return array|WP_Error
     */
    public function revert( $context ) {
        $post_id   = (int) $context['post_id'];
        $validator = $context['validator'];

        $backup = get_post_meta( $post_id, self::BACKUP_META_PREFIX . $validator, true );
        if ( empty( $backup ) || ! is_array( $backup ) || empty( $backup['field'] ) ) {
            return new WP_Error( 'no_backup', 'No revert snapshot found for this fix.' );
        }

        // Restore a custom/ACF field if that's what we changed.
        if ( isset( $backup['custom_field']['key'] ) ) {
            if ( ! class_exists( 'Rank_Booster_CustomFields' ) ) {
                $file = RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-customfields.php';
                if ( file_exists( $file ) ) {
                    require_once $file;
                }
            }
            if ( class_exists( 'Rank_Booster_CustomFields' ) ) {
                Rank_Booster_CustomFields::apply_slots(
                    $post_id,
                    array( $backup['custom_field']['key'] => $backup['custom_field']['value'] )
                );
            }
        }

        if ( $backup['field'] === 'elementor' ) {
            if ( isset( $backup['elementor'] ) ) {
                update_post_meta( $post_id, '_elementor_data', wp_slash( $backup['elementor'] ) );
            }
            // Title may have changed via the fallback even on Elementor pages.
            if ( isset( $backup['post_title'] ) ) {
                $this->save_post_title( $post_id, $backup['post_title'] );
            }
            if ( class_exists( 'Rank_Booster_Elementor' ) ) {
                Rank_Booster_Elementor::clear_cache( $post_id );
            }
        } else {
            // Restore both content and title in one update.
            $fields = array( 'ID' => $post_id );
            if ( isset( $backup['post_content'] ) ) {
                $fields['post_content'] = $backup['post_content'];
            }
            if ( isset( $backup['post_title'] ) ) {
                $fields['post_title'] = $backup['post_title'];
            }
            if ( count( $fields ) > 1 ) {
                wp_update_post( $fields );
            }
        }

        $this->clear_snapshot( $post_id, $validator );

        return array( 'success' => true );
    }

    /* ───────────────── custom-field fallback ───────────────── */

    /**
     * Try to shorten the heading inside an ACF / custom (meta) field.
     *
     * Reuses the Page-Booster custom-field engine: gather candidate prose fields,
     * (optionally) keep only ones that actually render on the page, then replace
     * the heading either inside the field's HTML (<hN>) or, when the whole field
     * IS the heading (e.g. a dedicated title meta), replace the whole value.
     *
     * @return bool True when a field was updated.
     */
    private function replace_in_custom_fields( $post_id, $validator, $tag_type, $current, $solution, $page_url, $io ) {
        if ( ! class_exists( 'Rank_Booster_CustomFields' ) ) {
            $file = RANK_PLUGIN_DIR . 'includes/rank-booster/class-rank-booster-customfields.php';
            if ( ! file_exists( $file ) ) {
                return false;
            }
            require_once $file;
        }

        $candidates = Rank_Booster_CustomFields::gather_candidates( $post_id );
        if ( empty( $candidates ) ) {
            return false;
        }

        // ── Match in memory first (cheap) — no network. ──────────────────────
        // Only fields that actually contain the heading are considered, so we
        // never pay for a render probe on the common "no match" path.
        $matches = array();
        foreach ( $candidates as $cand ) {
            $value = (string) $cand['value'];
            if ( $value === '' ) {
                continue;
            }

            // (a) Heading tag embedded inside the field's HTML.
            $replaced = $io->replace_heading_in_content( $value, $tag_type, $current, $solution );
            if ( $replaced !== $value ) {
                $matches[] = array( 'key' => $cand['key'], 'old' => $value, 'new' => $replaced );
                continue;
            }
            // (b) The whole field IS the heading (e.g. a dedicated title meta).
            if ( $io->text_equals( $value, $current ) ) {
                $matches[] = array( 'key' => $cand['key'], 'old' => $value, 'new' => $solution );
            }
        }

        if ( empty( $matches ) ) {
            return false; // Fast exit — no HTTP performed.
        }

        // ── Disambiguate only when needed. ───────────────────────────────────
        // A single match on a long, unique heading string is safe to apply
        // directly. Only when multiple fields match do we spend one render-probe
        // request to pick the one that actually renders on the page.
        $chosen = $matches[0];
        if ( count( $matches ) > 1 && ! empty( $page_url ) && class_exists( 'Rank_Booster_Render_Probe' ) ) {
            $visible = Rank_Booster_CustomFields::select_visible_fields( $candidates, $page_url );
            foreach ( $visible as $vis ) {
                foreach ( $matches as $m ) {
                    if ( $m['key'] === $vis['key'] ) {
                        $chosen = $m;
                        break 2;
                    }
                }
            }
        }

        // Record the field's original value for an exact revert, then write.
        $this->backup_custom_field( $post_id, $validator, $chosen['key'], $chosen['old'] );
        Rank_Booster_CustomFields::apply_slots( $post_id, array( $chosen['key'] => $chosen['new'] ) );
        return true;
    }

    /* ───────────────── internals ───────────────── */

    /**
     * Store a snapshot of everything we might modify (content, title, and the
     * Elementor tree when relevant) so revert is always exact.
     */
    private function snapshot( $post_id, $editor_type, $validator ) {
        $record = array(
            'field'      => ( $editor_type === 'elementor' ) ? 'elementor' : 'post',
            'post_title' => get_post_field( 'post_title', $post_id ),
        );

        if ( $editor_type === 'elementor' ) {
            $raw = get_post_meta( $post_id, '_elementor_data', true );
            $record['elementor'] = is_string( $raw ) ? $raw : wp_json_encode( $raw );
        } else {
            $record['post_content'] = get_post_field( 'post_content', $post_id );
        }

        update_post_meta( $post_id, self::BACKUP_META_PREFIX . $validator, $record );
    }

    /**
     * Augment the existing snapshot with the original value of a custom field
     * we're about to change, so revert can restore it exactly.
     */
    private function backup_custom_field( $post_id, $validator, $key, $original_value ) {
        $record = get_post_meta( $post_id, self::BACKUP_META_PREFIX . $validator, true );
        if ( ! is_array( $record ) ) {
            $record = array( 'field' => 'post' );
        }
        $record['custom_field'] = array(
            'key'   => $key,
            'value' => $original_value,
        );
        update_post_meta( $post_id, self::BACKUP_META_PREFIX . $validator, $record );
    }

    private function clear_snapshot( $post_id, $validator ) {
        delete_post_meta( $post_id, self::BACKUP_META_PREFIX . $validator );
    }

    private function save_post_content( $post_id, $content ) {
        wp_update_post( array(
            'ID'           => $post_id,
            'post_content' => $content,
        ) );
    }

    private function save_post_title( $post_id, $title ) {
        wp_update_post( array(
            'ID'         => $post_id,
            'post_title' => $title,
        ) );
    }

    /**
     * Log the exact heading-matching inputs when a fix cannot find its target.
     *
     * Rank_Processor::add_debug_log() is gated by the existing Admin debug
     * setting. We log heading text only, not the rest of post_content.
     */
    private function log_no_change( $context, $post, $parameters, $io ) {
        if ( ! class_exists( 'Rank_Processor' ) || ! method_exists( 'Rank_Processor', 'add_debug_log' ) ) {
            return;
        }

        $current = isset( $parameters['tag'] ) ? (string) $parameters['tag'] : '';
        $details = array(
            'post_id'                  => (int) $post->ID,
            'page_url'                 => isset( $context['page_url'] ) ? $context['page_url'] : '',
            'editor_type'              => isset( $context['editor_type'] ) ? $context['editor_type'] : '',
            'validator'                => isset( $context['validator'] ) ? $context['validator'] : '',
            'requested_heading_type'   => isset( $parameters['type'] ) ? $parameters['type'] : '(missing; defaults to h1)',
            'requested_heading'        => $current,
            'normalized_requested'     => $io->get_normalized_text( $current ),
            'post_title'               => $post->post_title,
            'normalized_post_title'    => $io->get_normalized_text( $post->post_title ),
            'content_heading_candidates' => $io->get_heading_candidates( $post->post_content ),
        );

        Rank_Processor::add_debug_log(
            '[HeadingFix] no_change ' . wp_json_encode( $details, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
            'warning'
        );
    }
}
