<?php
/**
 * Rank Booster Custom Fields / ACF Support
 *
 * Some themes render the visible page from custom fields (ACF) or plain
 * postmeta instead of post_content. In that case our normal post_content
 * rewrite never reaches the frontend — the classic "only the H1 changed"
 * silent failure.
 *
 * This provider reuses the stable-ID slot model (extract → serialize → LLM →
 * reimport) but sources its slots from custom fields, and uses the rendered
 * frontend (Rank_Booster_Render_Probe) to decide WHICH fields are part of the
 * visible main content. Fields that don't render on the page are left alone.
 *
 * SAFETY: because this writes to arbitrary fields, it is disabled by default
 * and only engages when:
 *   - the `rank_booster_enable_customfields` filter returns true, AND
 *   - post_content has LOW frontend coverage (so we're not hijacking a normal
 *     post whose content really is in post_content), AND
 *   - the candidate fields have HIGH frontend coverage (we found the real text).
 */

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

class Rank_Booster_CustomFields {

    /** Slot key prefix; the rest is the (encoded) field key. */
    const SLOT_PREFIX = 'cf-';

    /** Min individual field coverage to treat a field as "visible". */
    const FIELD_VISIBLE = 0.5;

    /**
     * Master switch. On by default; a site can disable it via filter:
     *   add_filter( 'rank_booster_enable_customfields', '__return_false' );
     */
    public static function enabled() {
        return (bool) apply_filters( 'rank_booster_enable_customfields', true );
    }

    /**
     * Write a diagnostic line to the admin-viewable debug log (Settings →
     * Enable debug logging → Refresh Logs). No-op unless debug is enabled.
     */
    private static function log( $message ) {
        if ( class_exists( 'Rank_Processor' ) ) {
            Rank_Processor::add_debug_log( '[CustomFields] ' . $message );
        }
    }

    /* ═══════════════════════════════════════════
       Detection gate
       ═══════════════════════════════════════════
       Should the custom-fields provider handle this post instead of the
       generic post_content path? Requires a render probe, so callers should
       only invoke this for classic/gutenberg posts (no dedicated builder). */

    /**
     * @param int    $post_id
     * @param string $url   The live URL to probe.
     * @return bool
     */
    public static function should_handle( $post_id, $url ) {
        if ( ! self::enabled() ) {
            self::log( "post=$post_id SKIP: provider disabled via filter." );
            return false;
        }
        if ( empty( $url ) ) {
            self::log( "post=$post_id SKIP: no URL to probe." );
            return false;
        }

        $candidates = self::gather_candidates( $post_id );
        self::log( "post=$post_id url=$url candidates=" . count( $candidates )
            . ' keys=[' . implode( ', ', array_map( function ( $c ) {
                return $c['key'] . ( $c['acf'] ? '(acf)' : '' ) . ':' . strlen( wp_strip_all_tags( $c['value'] ) ) . 'c';
            }, $candidates ) ) . ']' );
        if ( empty( $candidates ) ) {
            self::log( "post=$post_id SKIP: no candidate text fields found." );
            return false;
        }

        // post_content coverage — if the visible page is mostly post_content,
        // do NOT take over; the normal pipeline is correct.
        $post_content = (string) get_post_field( 'post_content', $post_id );
        $pc_cov = Rank_Booster_Render_Probe::coverage( $url, $post_content );
        if ( ! empty( $pc_cov['error'] ) ) {
            self::log( "post=$post_id SKIP: probe failed (" . $pc_cov['error'] . '). Falling back to post_content path.' );
            return false; // can't verify — stay safe, use default path
        }
        self::log( "post=$post_id post_content coverage=" . round( $pc_cov['score'], 2 )
            . " ({$pc_cov['covered']}/{$pc_cov['total']} snippets) threshold=" . Rank_Booster_Render_Probe::COVERAGE_OK );
        if ( $pc_cov['score'] >= Rank_Booster_Render_Probe::COVERAGE_OK ) {
            self::log( "post=$post_id SKIP: post_content already covers the page; using normal pipeline." );
            return false;
        }

        // Do the candidate fields cover the visible content well?
        $selected = self::select_visible_fields( $candidates, $url );
        if ( empty( $selected ) ) {
            self::log( "post=$post_id SKIP: no candidate field's text was found in the rendered page." );
            return false;
        }
        $combined = implode( "\n", wp_list_pluck( $selected, 'value' ) );
        $cf_cov   = Rank_Booster_Render_Probe::coverage( $url, $combined );
        $decision = $cf_cov['score'] >= Rank_Booster_Render_Probe::COVERAGE_OK;
        self::log( "post=$post_id selected_fields=" . count( $selected )
            . ' [' . implode( ', ', wp_list_pluck( $selected, 'key' ) ) . ']'
            . ' combined coverage=' . round( $cf_cov['score'], 2 )
            . " ({$cf_cov['covered']}/{$cf_cov['total']}) → " . ( $decision ? 'ENGAGE custom-fields' : 'SKIP (coverage too low)' ) );

        return $decision;
    }

    /* ═══════════════════════════════════════════
       1. Gather candidate fields
       ═══════════════════════════════════════════ */

    /**
     * Collect editable text fields: ACF (wysiwyg/textarea/text) + prose postmeta.
     *
     * @param int $post_id
     * @return array  List of [ 'key'=>storage key, 'acf'=>bool, 'value'=>html ]
     */
    public static function gather_candidates( $post_id ) {
        $out = array();

        // ── ACF fields ──
        if ( function_exists( 'get_field_objects' ) ) {
            $objects = get_field_objects( $post_id );
            if ( is_array( $objects ) ) {
                foreach ( $objects as $name => $obj ) {
                    $type  = isset( $obj['type'] ) ? $obj['type'] : '';
                    $value = isset( $obj['value'] ) ? $obj['value'] : '';
                    if ( ! in_array( $type, array( 'wysiwyg', 'textarea', 'text' ), true ) ) {
                        continue;
                    }
                    if ( ! is_string( $value ) || ! self::is_prose( $value ) ) {
                        continue;
                    }
                    $out[ $name ] = array(
                        'key'   => $name,
                        'acf'   => true,
                        'value' => $value,
                    );
                }
            }
        }

        // ── Plain postmeta (non-ACF) ──
        $all_meta = get_post_meta( $post_id );
        if ( is_array( $all_meta ) ) {
            foreach ( $all_meta as $key => $values ) {
                if ( isset( $out[ $key ] ) ) {
                    continue;
                }
                // Skip internal/hidden keys and ACF's own field-pointer keys.
                if ( $key === '' || $key[0] === '_' ) {
                    continue;
                }
                $value = is_array( $values ) ? reset( $values ) : $values;
                if ( ! is_string( $value ) || ! self::is_prose( $value ) ) {
                    continue;
                }
                $out[ $key ] = array(
                    'key'   => $key,
                    'acf'   => false,
                    'value' => $value,
                );
            }
        }

        return array_values( $out );
    }

    /* ═══════════════════════════════════════════
       2. Select fields that are actually visible
       ═══════════════════════════════════════════ */

    /**
     * Keep only fields whose text appears in the rendered main content, ordered
     * by their first appearance position (reading order).
     *
     * @param array  $candidates
     * @param string $url
     * @return array  Filtered + ordered candidates (each gains 'pos').
     */
    public static function select_visible_fields( $candidates, $url ) {
        $html = Rank_Booster_Render_Probe::fetch( $url );
        if ( is_wp_error( $html ) ) {
            self::log( "select: probe fetch failed for $url — " . $html->get_error_message() );
            return array();
        }
        $rendered = Rank_Booster_Render_Probe::extract_main_text( $html );
        self::log( 'select: fetched ' . strlen( $html ) . ' bytes, main-text=' . strlen( $rendered ) . ' chars' );
        if ( $rendered === '' ) {
            self::log( 'select: rendered main text empty (page may render via JS or be blocked).' );
            return array();
        }

        $kept = array();
        foreach ( $candidates as $cand ) {
            $score = Rank_Booster_Render_Probe::score_against( $rendered, $cand['value'] );
            self::log( "select: field '{$cand['key']}' coverage=" . round( $score['score'], 2 )
                . " ({$score['covered']}/{$score['total']}) " . ( ( $score['total'] > 0 && $score['score'] >= self::FIELD_VISIBLE ) ? 'KEPT' : 'dropped' ) );
            if ( $score['total'] === 0 || $score['score'] < self::FIELD_VISIBLE ) {
                continue;
            }
            // Position of the first snippet in the rendered text → reading order.
            $snips = Rank_Booster_Render_Probe::snippets( $cand['value'] );
            $pos   = PHP_INT_MAX;
            foreach ( $snips as $s ) {
                $at = strpos( $rendered, $s );
                if ( $at !== false && $at < $pos ) {
                    $pos = $at;
                }
            }
            $cand['pos'] = $pos;
            $kept[]      = $cand;
        }

        usort( $kept, function ( $a, $b ) {
            return $a['pos'] <=> $b['pos'];
        } );

        return $kept;
    }

    /* ═══════════════════════════════════════════
       3. Slot serialization (reuses <div id> convention)
       ═══════════════════════════════════════════ */

    private static function make_slot_key( $field_key ) {
        return self::SLOT_PREFIX . rtrim( strtr( base64_encode( $field_key ), '+/', '-_' ), '=' );
    }

    private static function parse_slot_key( $slot_key ) {
        if ( strpos( $slot_key, self::SLOT_PREFIX ) !== 0 ) {
            return null;
        }
        $b64  = strtr( substr( $slot_key, strlen( self::SLOT_PREFIX ) ), '-_', '+/' );
        $b64 .= str_repeat( '=', ( 4 - strlen( $b64 ) % 4 ) % 4 );
        $key  = base64_decode( $b64, true );
        return $key === false ? null : $key;
    }

    /**
     * Build the LLM slot HTML from selected fields.
     *
     * @param array $selected  Output of select_visible_fields().
     * @return string
     */
    public static function slots_to_html( $selected ) {
        $parts = array();
        foreach ( $selected as $cand ) {
            $parts[] = '<div id="' . esc_attr( self::make_slot_key( $cand['key'] ) ) . '">' . $cand['value'] . '</div>';
        }
        return implode( "\n", $parts );
    }

    /**
     * Parse API-returned HTML back into [ field_key => improved html ].
     */
    public static function html_to_slots( $html ) {
        if ( empty( $html ) ) {
            return array();
        }
        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        $dom->loadHTML(
            '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>' . $html . '</body></html>',
            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
        );
        libxml_clear_errors();

        $xpath = new DOMXPath( $dom );
        $slots = array();
        foreach ( $xpath->query( '//div[@id]' ) as $node ) {
            $id = $node->getAttribute( 'id' );
            $field_key = self::parse_slot_key( $id );
            if ( $field_key === null ) {
                continue;
            }
            $slots[ $field_key ] = trim( self::inner_html( $node ) );
        }
        return $slots;
    }

    private static function inner_html( DOMNode $node ) {
        $html = '';
        foreach ( $node->childNodes as $child ) {
            $html .= $node->ownerDocument->saveHTML( $child );
        }
        return $html;
    }

    /**
     * Return draft HTML that is NOT inside a cf- slot div — i.e. standalone
     * appended content such as an FAQ or quick-summary section.
     *
     * @param string $html
     * @return string
     */
    public static function extract_non_slot_html( $html ) {
        if ( empty( $html ) ) {
            return '';
        }
        $dom = new DOMDocument();
        libxml_use_internal_errors( true );
        $dom->loadHTML(
            '<html><head><meta http-equiv="Content-Type" content="text/html; charset=utf-8"></head><body>' . $html . '</body></html>',
            LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
        );
        libxml_clear_errors();

        $body = $dom->getElementsByTagName( 'body' )->item( 0 );
        if ( ! $body ) {
            return '';
        }
        $extra = '';
        foreach ( $body->childNodes as $node ) {
            if ( $node->nodeType === XML_ELEMENT_NODE
                && strtolower( $node->nodeName ) === 'div'
                && $node->hasAttribute( 'id' )
                && strpos( $node->getAttribute( 'id' ), self::SLOT_PREFIX ) === 0
            ) {
                continue; // a slot div — not appended content
            }
            $extra .= $dom->saveHTML( $node );
        }
        return trim( $extra );
    }

    /* ═══════════════════════════════════════════
       Build the final field_key => value updates
       (slot rewrites + appended FAQ / summary)
       ═══════════════════════════════════════════ */

    /**
     * Turn an API draft into the final map of field updates.
     *
     * Handles two output shapes:
     *  - content-rewrite → <div id="cf-..."> slots → per-field rewrites.
     *  - add-faq / quick-summary → standalone HTML with no slots → appended
     *    (FAQ) or prepended (summary) into the page's MAIN text field.
     *
     * @param int    $post_id
     * @param string $draft_html
     * @return array  [ field_key => final html value ]
     */
    public static function build_field_updates( $post_id, $draft_html, $position = 'auto' ) {
        $updates = self::html_to_slots( $draft_html );

        $non_slot = self::extract_non_slot_html( $draft_html );
        if ( $non_slot !== '' ) {
            $append = self::resolve_append( $position );

            // The draft's slots are in frontend reading order, so:
            //   append (FAQ)     → bottom of the LAST field
            //   prepend (summary) → top of the FIRST field
            $ordered = array_keys( $updates );
            if ( ! empty( $ordered ) ) {
                $target = $append ? end( $ordered ) : reset( $ordered );
            } else {
                // No slots (e.g. summary-only draft): fall back to the longest field.
                $target = self::main_field_key( $post_id, array() );
            }

            if ( $target !== '' ) {
                $base = isset( $updates[ $target ] ) ? $updates[ $target ] : (string) get_post_meta( $post_id, $target, true );
                $updates[ $target ] = $append ? ( $base . "\n" . $non_slot ) : ( $non_slot . "\n" . $base );
            }
        }

        return $updates;
    }

    /**
     * Append content to the bottom unless it's explicitly a summary (top).
     * Position comes from the job's check type (add_faq vs quick_summary), so
     * no content guessing is needed. Default: bottom.
     *
     * @param string $position  'append' | 'prepend' | 'auto'
     * @return bool  True = append (bottom).
     */
    public static function resolve_append( $position ) {
        return $position !== 'prepend';
    }

    /**
     * Pick the page's main text field — the longest current value among the
     * given candidate keys, or among all gathered candidates when none given.
     *
     * @param int   $post_id
     * @param array $prefer_keys  Field keys to prefer (e.g. the rewritten slots).
     * @return string  Field key, or '' if none.
     */
    private static function main_field_key( $post_id, $prefer_keys = array() ) {
        $candidates = self::gather_candidates( $post_id );
        $best_key   = '';
        $best_len   = -1;

        foreach ( $candidates as $cand ) {
            // When prefer_keys is set, restrict to those (the optimized fields).
            if ( ! empty( $prefer_keys ) && ! in_array( $cand['key'], $prefer_keys, true ) ) {
                continue;
            }
            $len = strlen( wp_strip_all_tags( (string) $cand['value'] ) );
            if ( $len > $best_len ) {
                $best_len = $len;
                $best_key = $cand['key'];
            }
        }

        // If prefer_keys yielded nothing (pure FAQ with no slots), fall back to
        // the longest of all candidate fields.
        if ( $best_key === '' && ! empty( $prefer_keys ) ) {
            return self::main_field_key( $post_id, array() );
        }
        return $best_key;
    }

    /* ═══════════════════════════════════════════
       4. Apply improved text back to the fields
       ═══════════════════════════════════════════ */

    /**
     * Apply an API draft to the post's custom fields: per-field rewrites plus
     * any appended FAQ/summary merged into the main text field.
     *
     * @param int    $post_id
     * @param string $draft_html
     * @return bool
     */
    public static function apply_draft( $post_id, $draft_html, $position = 'auto' ) {
        return self::apply_slots( $post_id, self::build_field_updates( $post_id, $draft_html, $position ) );
    }

    /**
     * Write improved field values. Uses ACF update_field() when the candidate
     * was an ACF field, otherwise update_post_meta(). Only overwrites fields
     * that currently exist (never creates new ones).
     *
     * @param int   $post_id
     * @param array $slots  [ field_key => improved html ]
     * @return bool
     */
    public static function apply_slots( $post_id, $slots ) {
        if ( empty( $slots ) ) {
            return false;
        }
        // Re-read candidates so we know which keys are ACF vs plain meta.
        $is_acf = array();
        foreach ( self::gather_candidates( $post_id ) as $cand ) {
            $is_acf[ $cand['key'] ] = $cand['acf'];
        }

        $wrote = 0;
        foreach ( $slots as $field_key => $value ) {
            if ( ! array_key_exists( $field_key, $is_acf ) ) {
                continue; // unknown/no-longer-present field — skip for safety
            }
            if ( $is_acf[ $field_key ] && function_exists( 'update_field' ) ) {
                update_field( $field_key, $value, $post_id );
            } else {
                update_post_meta( $post_id, $field_key, $value );
            }
            $wrote++;
        }
        return $wrote > 0;
    }

    /* ═══════════════════════════════════════════
       Shared prose heuristic (same as the tree builders)
       ═══════════════════════════════════════════ */

    public static function is_prose( $value ) {
        if ( ! is_string( $value ) || $value === '' ) {
            return false;
        }
        if ( preg_match( '/<\s*(iframe|script|embed|object|video|audio|form|svg|canvas)\b/i', $value ) ) {
            return false;
        }
        $plain = trim( wp_strip_all_tags( $value ) );
        if ( $plain === '' ) {
            return false;
        }
        if ( preg_match( '#^(https?:)?//#i', $plain ) ) {
            return false;
        }
        return strlen( $plain ) >= 30 && substr_count( $plain, ' ' ) >= 3;
    }
}
