<?php

declare(strict_types=1);

/**
 * Analysis Content Collector
 *
 * Single source of truth for assembling the text that ProRank's content
 * analysis (readability, word count, keyword stats) actually scores. The
 * default behavior matches the historical pipeline byte-for-byte: just the
 * post_content (or caller-supplied content). When the user opts in via the
 * `include_custom_fields_in_analysis` setting, this class additionally walks
 * the post's meta + ACF fields under a strict allow/deny model and appends
 * their visible text to the analyzable content.
 *
 * Hard guarantees:
 *   - Protected meta keys (leading underscore) are excluded unless the
 *     caller explicitly allowlists them.
 *   - A built-in denylist always strips well-known internal/system keys.
 *   - Serialized / non-string / binary-looking values are not blindly
 *     stringified; we flatten arrays conservatively and skip blobs.
 *   - Total appended custom-field text is capped (default 20 000 chars).
 *   - When the option is off, this class returns the original content
 *     untouched and reports `custom_fields_included=false`.
 *
 * @package ProRank\SEO\Core\Content
 * @since 1.3.1
 */

namespace ProRank\SEO\Core\Content;

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


class AnalysisContentCollector {

    /**
     * Soft cap on appended custom-field text per post.
     */
    public const MAX_CUSTOM_FIELD_CHARS = 20000;

    /**
     * Per-value soft cap to keep one runaway field from eating the whole budget.
     */
    public const MAX_VALUE_CHARS = 4000;

    /**
     * Maximum recursion depth when flattening array/object values.
     */
    private const MAX_FLATTEN_DEPTH = 3;

    /**
     * Always-excluded meta keys (regardless of allowlist) — internal/system
     * data that has no business in content analysis.
     */
    private const DEFAULT_DENYLIST = [
        '_edit_lock',
        '_edit_last',
        '_wp_attached_file',
        '_wp_attachment_metadata',
        '_wp_old_slug',
        '_wp_old_date',
        '_thumbnail_id',
        '_wp_page_template',
        '_wp_trash_meta_status',
        '_wp_trash_meta_time',
        '_menu_item_object',
        '_menu_item_object_id',
        '_menu_item_url',
        '_menu_item_xfn',
        '_menu_item_type',
        '_menu_item_target',
        '_menu_item_classes',
        '_menu_item_menu_item_parent',
    ];

    /**
     * Always-excluded ACF field types — non-text or media-typed fields whose
     * raw value is a URL or attachment ID, not analyzable copy.
     */
    private const ACF_SKIP_TYPES = [
        'image',
        'file',
        'gallery',
        'oembed',
        'icon_picker',
        'color_picker',
        'date_picker',
        'date_time_picker',
        'time_picker',
        'google_map',
        'taxonomy',
        'user',
        'post_object',
        'page_link',
        'relationship',
        'link',
        'repeater',         // skip the parent; recurse into subfields explicitly
        'flexible_content', // same
        'group',            // same
        'clone',            // ambiguous — skip
    ];

    /**
     * Collect the analyzable text for a post.
     *
     * @param int         $post_id      Post ID (0 = no post lookup, $base_content
     *                                  is treated as already-assembled text).
     * @param string|null $base_content Optional pre-fetched post_content; when
     *                                  null and $post_id > 0 we fetch it here.
     * @return array{
     *     text: string,
     *     custom_fields_included: bool,
     *     custom_field_count: int,
     *     custom_field_chars: int,
     *     custom_field_truncated: bool
     * }
     */
    public static function collect( int $post_id, ?string $base_content = null ): array {
        $base = is_string( $base_content ) ? $base_content : '';
        if ( $base === '' && $post_id > 0 ) {
            $base = (string) get_post_field( 'post_content', $post_id, 'raw' );
        }

        $settings = self::get_settings();
        $include_cf = ! empty( $settings['include_custom_fields_in_analysis'] );

        $result = [
            'text'                   => $base,
            'custom_fields_included' => false,
            'custom_field_count'     => 0,
            'custom_field_chars'     => 0,
            'custom_field_truncated' => false,
        ];

        if ( ! $include_cf || $post_id <= 0 ) {
            return $result;
        }

        $extracted = self::extract_custom_fields( $post_id, $settings );

        // Single assignment path — report truthfully whether or not any text
        // was actually accumulated. When chars > 0 we additionally append the
        // extracted text to the base content with a paragraph separator so
        // readability doesn't run the first sentence of a meta value into the
        // last sentence of the body.
        $result['custom_fields_included'] = true;
        $result['custom_field_count']     = $extracted['count'];
        $result['custom_field_chars']     = $extracted['chars'];
        $result['custom_field_truncated'] = $extracted['truncated'];

        if ( $extracted['chars'] > 0 ) {
            $separator      = $base === '' ? '' : "\n\n";
            $result['text'] = $base . $separator . $extracted['text'];
        }

        return $result;
    }

    /**
     * Read the relevant settings from the option blob, with conservative
     * defaults. Exposed for testability.
     *
     * @return array{
     *   include_custom_fields_in_analysis: bool,
     *   include_acf_fields_in_analysis: bool,
     *   custom_field_allowlist: array<string>,
     *   custom_field_denylist: array<string>
     * }
     */
    public static function get_settings(): array {
        $stored = function_exists( 'get_option' ) ? get_option( 'prorank_seo_content_analysis', [] ) : [];
        if ( ! is_array( $stored ) ) {
            $stored = [];
        }

        $allow = self::normalize_key_list( $stored['custom_field_allowlist'] ?? [] );
        $deny  = self::normalize_key_list( $stored['custom_field_denylist'] ?? [] );

        return [
            'include_custom_fields_in_analysis' => ! empty( $stored['include_custom_fields_in_analysis'] ),
            'include_acf_fields_in_analysis'    => array_key_exists( 'include_acf_fields_in_analysis', $stored )
                ? ! empty( $stored['include_acf_fields_in_analysis'] )
                : true,
            'custom_field_allowlist'            => $allow,
            'custom_field_denylist'             => $deny,
        ];
    }

    /**
     * Walk meta + ACF for a post and assemble appended analysis text.
     *
     * Dedupe strategy (prevents the same visible text from being added
     * twice, which would distort word count / keyword density / readability):
     *   - `$consumed_keys` — meta keys already consumed by the ACF pass;
     *     ACF stores its value under the same key, so the raw meta walker
     *     skips these. Also tracks ACF's companion key `_<name>` (the field
     *     reference like `field_abc123`) so it doesn't surface as a token.
     *   - `$seen_hashes` — sha1 of every successfully-appended cleaned text;
     *     a safety net for alias keys and serialized-blob expansions that
     *     re-produce the same content from a different key.
     *
     * @return array{text:string,count:int,chars:int,truncated:bool}
     */
    private static function extract_custom_fields( int $post_id, array $settings ): array {
        $allow_list = $settings['custom_field_allowlist'];
        $deny_list  = array_unique( array_merge( self::DEFAULT_DENYLIST, $settings['custom_field_denylist'] ) );
        $allow_set  = array_flip( $allow_list );
        $deny_set   = array_flip( $deny_list );

        $accumulated  = '';
        $count        = 0;
        $truncated    = false;
        $seen_hashes  = [];

        $append = function ( string $value ) use ( &$accumulated, &$count, &$truncated, &$seen_hashes ): bool {
            $value = self::clean_text( $value );
            if ( $value === '' ) {
                return false;
            }
            // Text-hash dedupe — drops alias duplicates without growing the
            // count or chars stats.
            $hash = sha1( $value );
            if ( isset( $seen_hashes[ $hash ] ) ) {
                return true; // not a duplicate-failure; keep iterating
            }
            if ( strlen( $value ) > self::MAX_VALUE_CHARS ) {
                $value = substr( $value, 0, self::MAX_VALUE_CHARS );
                $truncated = true;
                // Re-hash the truncated form so the safety net still works.
                $hash = sha1( $value );
                if ( isset( $seen_hashes[ $hash ] ) ) {
                    return true;
                }
            }
            $sep      = $accumulated === '' ? '' : "\n\n";
            $next_len = strlen( $accumulated ) + strlen( $sep ) + strlen( $value );
            if ( $next_len > self::MAX_CUSTOM_FIELD_CHARS ) {
                $remaining = self::MAX_CUSTOM_FIELD_CHARS - strlen( $accumulated ) - strlen( $sep );
                if ( $remaining <= 0 ) {
                    $truncated = true;
                    return false;
                }
                $accumulated         .= $sep . substr( $value, 0, $remaining );
                $truncated            = true;
                $seen_hashes[ $hash ] = true;
                $count++;
                return false; // budget hit, stop iterating
            }
            $accumulated         .= $sep . $value;
            $seen_hashes[ $hash ] = true;
            $count++;
            return true;
        };

        $consumed_keys = [];
        $stop          = false;

        // ACF first — it has field-type metadata so we can skip media /
        // relational / container fields without guessing, and resolve
        // select/checkbox/radio labels.
        if ( $settings['include_acf_fields_in_analysis'] && self::acf_available() ) {
            $stop = ! self::walk_acf_fields( $post_id, $deny_set, $allow_set, $append, $consumed_keys );
        }

        // Raw post meta — skip keys ACF already handled, then apply
        // allow/deny + the same text-hash safety net.
        if ( ! $stop ) {
            $meta = get_post_meta( $post_id );
            if ( is_array( $meta ) ) {
                foreach ( $meta as $key => $values ) {
                    if ( ! is_string( $key ) ) {
                        continue;
                    }
                    if ( isset( $consumed_keys[ $key ] ) ) {
                        continue; // already processed by ACF pass
                    }
                    if ( ! self::key_is_allowed( $key, $allow_set, $deny_set ) ) {
                        continue;
                    }
                    if ( ! is_array( $values ) ) {
                        continue;
                    }
                    foreach ( $values as $raw ) {
                        $flat = self::flatten_value( $raw );
                        if ( $flat === '' ) {
                            continue;
                        }
                        if ( ! $append( $flat ) ) {
                            $stop = true;
                            break 2;
                        }
                    }
                }
            }
        }

        return [
            'text'      => $accumulated,
            'count'     => $count,
            'chars'     => strlen( $accumulated ),
            'truncated' => $truncated,
        ];
    }

    /**
     * Detect ACF availability without requiring it.
     */
    private static function acf_available(): bool {
        return function_exists( 'get_field_objects' )
            && function_exists( 'acf_get_field_type' );
    }

    /**
     * Iterate ACF field objects for a post, calling $append() for each text-
     * shaped value. Returns false when the append budget was exhausted.
     *
     * Field types listed in self::ACF_SKIP_TYPES are skipped entirely. For
     * container fields (group, repeater, flexible_content) we don't recurse
     * here — those values also surface as raw post meta and will be picked
     * up by the meta walker if their leaf keys aren't denylisted.
     */
    private static function walk_acf_fields( int $post_id, array $deny_set, array $allow_set, callable $append, array &$consumed_keys ): bool {
        // ACF signature: get_field_objects( $post_id, $format_value = true,
        // $load_value = true ). We pass $format_value=false so media/file/
        // relationship fields stay as their raw stored value (and remain
        // cheap), and explicitly pass $load_value=true so the field values
        // are actually loaded. The previous call used the wrong arg order
        // and effectively disabled value loading.
        $fields = @get_field_objects( $post_id, false, true );
        if ( ! is_array( $fields ) ) {
            return true;
        }

        foreach ( $fields as $field ) {
            if ( ! is_array( $field ) ) {
                continue;
            }
            $type = isset( $field['type'] ) ? strtolower( (string) $field['type'] ) : '';
            $name = isset( $field['name'] ) ? (string) $field['name'] : '';

            // Whether we end up using the field's value or not, the raw meta
            // walker must NOT re-emit it later. ACF stores values under the
            // field name and the field reference under "_<name>". Mark both
            // consumed so neither surfaces twice.
            if ( $name !== '' ) {
                $consumed_keys[ $name ]        = true;
                $consumed_keys[ '_' . $name ]  = true;
            }

            if ( $type === '' || in_array( $type, self::ACF_SKIP_TYPES, true ) ) {
                continue;
            }
            if ( $name !== '' && ! self::key_is_allowed( $name, $allow_set, $deny_set ) ) {
                continue;
            }
            $value = $field['value'] ?? null;
            $flat = self::flatten_value( $value );
            if ( $flat === '' ) {
                continue;
            }
            // For select/checkbox/radio/true_false, prefer the label over the
            // stored value so analysis sees the visible-on-page text.
            if ( in_array( $type, [ 'select', 'checkbox', 'radio' ], true ) && ! empty( $field['choices'] ) && is_array( $field['choices'] ) ) {
                $labels = self::resolve_acf_choice_labels( $value, $field['choices'] );
                if ( $labels !== '' ) {
                    $flat = $labels;
                }
            } elseif ( $type === 'true_false' ) {
                $flat = ! empty( $value ) ? ( $field['ui_on_text'] ?? '' ) : ( $field['ui_off_text'] ?? '' );
                $flat = (string) $flat;
                if ( $flat === '' ) {
                    continue;
                }
            }

            if ( ! $append( $flat ) ) {
                return false;
            }
        }

        return true;
    }

    /**
     * Look up the human-readable label(s) for a select/checkbox/radio value.
     */
    private static function resolve_acf_choice_labels( $value, array $choices ): string {
        if ( is_array( $value ) ) {
            $parts = [];
            foreach ( $value as $v ) {
                $k = is_scalar( $v ) ? (string) $v : '';
                if ( $k !== '' && isset( $choices[ $k ] ) ) {
                    $parts[] = (string) $choices[ $k ];
                }
            }
            return implode( ', ', $parts );
        }
        if ( is_scalar( $value ) ) {
            $k = (string) $value;
            if ( isset( $choices[ $k ] ) ) {
                return (string) $choices[ $k ];
            }
        }
        return '';
    }

    /**
     * Allow/deny decision for a meta key.
     */
    private static function key_is_allowed( string $key, array $allow_set, array $deny_set ): bool {
        if ( isset( $deny_set[ $key ] ) ) {
            return false;
        }
        if ( $key[0] === '_' ) {
            // Protected key — must be explicitly allowlisted.
            return isset( $allow_set[ $key ] );
        }
        // If an allowlist is configured, restrict to it.
        if ( ! empty( $allow_set ) ) {
            return isset( $allow_set[ $key ] );
        }
        return true;
    }

    /**
     * Conservatively flatten a meta value to a string. Returns '' for
     * anything that is not analyzable text (booleans, IDs, binary blobs).
     */
    private static function flatten_value( $value, int $depth = 0 ): string {
        if ( $value === null || $value === false ) {
            return '';
        }
        if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) {
            // Numbers / true don't add content signal; skip rather than
            // injecting noise into readability stats.
            return '';
        }
        if ( is_string( $value ) ) {
            return self::flatten_string( $value );
        }
        if ( is_array( $value ) ) {
            if ( $depth >= self::MAX_FLATTEN_DEPTH ) {
                return '';
            }
            $parts = [];
            foreach ( $value as $v ) {
                $sub = self::flatten_value( $v, $depth + 1 );
                if ( $sub !== '' ) {
                    $parts[] = $sub;
                }
            }
            return implode( "\n", $parts );
        }
        return '';
    }

    /**
     * String-specific cleaning: PHP-unserialize when shaped like serialized
     * data; otherwise treat as plain text.
     */
    private static function flatten_string( string $value ): string {
        $value = trim( $value );
        if ( $value === '' ) {
            return '';
        }
        // Skip obviously binary content.
        if ( ! preg_match( '//u', $value ) ) {
            return '';
        }
        // Recurse into serialized arrays/objects.
        if ( is_serialized( $value ) ) {
            $decoded = @maybe_unserialize( $value );
            if ( $decoded !== $value ) {
                return self::flatten_value( $decoded );
            }
            return '';
        }
        // Bare URLs / numeric IDs aren't analyzable text.
        if ( preg_match( '#^https?://\S+$#i', $value ) ) {
            return '';
        }
        if ( preg_match( '/^\d+$/', $value ) ) {
            return '';
        }
        return $value;
    }

    /**
     * Apply WP text sanitization: strip shortcodes, HTML tags, decode
     * entities, normalize whitespace.
     */
    private static function clean_text( string $value ): string {
        $value = strip_shortcodes( $value );
        $value = wp_strip_all_tags( $value );
        $value = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        $value = preg_replace( '/\s+/u', ' ', (string) $value );
        return trim( (string) $value );
    }

    /**
     * Normalize a string|array setting to a flat list of trimmed keys.
     *
     * @param mixed $input
     * @return array<string>
     */
    private static function normalize_key_list( $input ): array {
        if ( is_string( $input ) ) {
            $items = preg_split( '/[\r\n,]+/', $input ) ?: [];
        } elseif ( is_array( $input ) ) {
            $items = $input;
        } else {
            return [];
        }
        $out = [];
        foreach ( $items as $item ) {
            $k = is_scalar( $item ) ? trim( (string) $item ) : '';
            if ( $k !== '' ) {
                $out[] = $k;
            }
        }
        return array_values( array_unique( $out ) );
    }
}
