<?php

declare(strict_types=1);

/**
 * Inclusive-Language Analyzer (Task 7).
 *
 * Lightweight, deterministic, no-AI text scanner that flags a curated set of
 * terms with suggested replacements. Designed to be:
 *
 *   - Conservative — starter dictionary is intentionally small to avoid
 *     drowning users in false positives.
 *   - Editable — `prorank_inclusive_language_dictionary` filter lets devs
 *     extend or replace entries; per-site ignored terms (settings) drop
 *     matches without modifying the dictionary.
 *   - Safe — does not modify post content, does not call external APIs,
 *     and skips HTML tags, <script>/<style>/<code>/<pre>/<a>/<head>
 *     contents, shortcodes, URLs, and email addresses before matching.
 *
 * @package ProRank\SEO\Core\Content
 * @since 1.3.1
 */

namespace ProRank\SEO\Core\Content;

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


class InclusiveLanguageAnalyzer {

    public const MAX_ISSUES_RETURNED = 50;

    /**
     * Starter dictionary. Severity is intentionally biased toward 'low' so
     * the default UX is "advisory, not blocking". Each entry includes a
     * short, context-free suggestion.
     */
    private const DEFAULT_DICTIONARY = [
        // Tech-jargon with widely-adopted replacements.
        [ 'term' => 'blacklist',  'category' => 'tech-jargon', 'severity' => 'low',
          'suggestion' => 'blocklist / denylist' ],
        [ 'term' => 'whitelist',  'category' => 'tech-jargon', 'severity' => 'low',
          'suggestion' => 'allowlist' ],
        [ 'term' => 'master',     'category' => 'tech-jargon', 'severity' => 'low',
          'suggestion' => 'primary / main / leader' ],
        [ 'term' => 'slave',      'category' => 'tech-jargon', 'severity' => 'medium',
          'suggestion' => 'replica / follower / secondary' ],
        // Ableist phrasing — keep limited to clear cases.
        [ 'term' => 'crazy',      'category' => 'ableist',     'severity' => 'low',
          'suggestion' => 'wild / surprising / unexpected' ],
        [ 'term' => 'insane',     'category' => 'ableist',     'severity' => 'low',
          'suggestion' => 'wild / remarkable / unreasonable' ],
        [ 'term' => 'lame',       'category' => 'ableist',     'severity' => 'low',
          'suggestion' => 'weak / disappointing' ],
        [ 'term' => 'tone-deaf',  'category' => 'ableist',     'severity' => 'low',
          'suggestion' => 'insensitive / out of touch' ],
        [ 'term' => 'tone deaf',  'category' => 'ableist',     'severity' => 'low',
          'suggestion' => 'insensitive / out of touch' ],
        [ 'term' => 'dumb',       'category' => 'ableist',     'severity' => 'low',
          'suggestion' => 'unwise / ill-considered' ],
        // Gendered defaults.
        [ 'term' => 'manpower',   'category' => 'gendered',    'severity' => 'low',
          'suggestion' => 'workforce / staff' ],
        [ 'term' => 'manmade',    'category' => 'gendered',    'severity' => 'low',
          'suggestion' => 'human-made / synthetic' ],
        [ 'term' => 'man-made',   'category' => 'gendered',    'severity' => 'low',
          'suggestion' => 'human-made / synthetic' ],
        [ 'term' => 'mankind',    'category' => 'gendered',    'severity' => 'low',
          'suggestion' => 'humankind / people' ],
        [ 'term' => 'salesman',   'category' => 'gendered',    'severity' => 'low',
          'suggestion' => 'salesperson' ],
        [ 'term' => 'chairman',   'category' => 'gendered',    'severity' => 'low',
          'suggestion' => 'chair / chairperson' ],
        // Other exclusive defaults.
        [ 'term' => 'grandfathered', 'category' => 'exclusive', 'severity' => 'low',
          'suggestion' => 'pre-existing / legacy' ],
        [ 'term' => 'sanity check',  'category' => 'ableist',   'severity' => 'low',
          'suggestion' => 'quick check / verification' ],
        [ 'term' => 'cripple',       'category' => 'ableist',   'severity' => 'medium',
          'suggestion' => 'disable / weaken / hobble' ],
    ];

    /**
     * Analyze a chunk of HTML or plain text.
     *
     * @param string $text   Post content (HTML or plain). Untouched on return.
     * @param array  $config {
     *   @type array $ignored_terms  Lowercased term strings to drop from output.
     * }
     * @return array{
     *   total_issues:int,
     *   severity_counts:array{high:int,medium:int,low:int},
     *   issues:array,
     *   truncated:bool
     * }
     */
    public static function analyze( string $text, array $config = [] ): array {
        $cleaned = self::clean_for_analysis( $text );

        $ignored = [];
        foreach ( (array) ( $config['ignored_terms'] ?? [] ) as $term ) {
            $t = is_scalar( $term ) ? strtolower( trim( (string) $term ) ) : '';
            if ( $t !== '' ) {
                $ignored[ $t ] = true;
            }
        }

        $dictionary = self::get_dictionary();
        $issues = [];

        foreach ( $dictionary as $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            $term = isset( $entry['term'] ) ? (string) $entry['term'] : '';
            if ( $term === '' ) {
                continue;
            }
            if ( isset( $ignored[ strtolower( $term ) ] ) ) {
                continue;
            }

            // Word-boundary, case-insensitive, unicode-aware. Multi-word
            // terms (e.g. "tone deaf") tolerate any whitespace run between
            // their words so HTML-collapsed spaces still match.
            $needle = preg_quote( $term, '/' );
            $needle = preg_replace( '/\\\\\s+/', '\s+', $needle );
            $pattern = '/(?<![\p{L}\p{N}_-])' . $needle . '(?![\p{L}\p{N}_-])/iu';

            if ( ! preg_match_all( $pattern, $cleaned, $matches, PREG_OFFSET_CAPTURE ) ) {
                continue;
            }
            foreach ( $matches[0] as $match ) {
                $matched_text = (string) $match[0];
                $offset = (int) $match[1];
                $issues[] = [
                    'term'         => $term,
                    'matched_text' => $matched_text,
                    'category'     => (string) ( $entry['category'] ?? 'other' ),
                    'severity'     => (string) ( $entry['severity'] ?? 'low' ),
                    'suggestion'   => (string) ( $entry['suggestion'] ?? '' ),
                    'context'      => self::extract_context( $cleaned, $offset, strlen( $matched_text ) ),
                ];
            }
        }

        $severity_counts = [ 'high' => 0, 'medium' => 0, 'low' => 0 ];
        foreach ( $issues as $i ) {
            $sev = $i['severity'] ?? 'low';
            if ( isset( $severity_counts[ $sev ] ) ) {
                $severity_counts[ $sev ]++;
            } else {
                $severity_counts['low']++;
            }
        }

        $total = count( $issues );
        $truncated = $total > self::MAX_ISSUES_RETURNED;
        if ( $truncated ) {
            $issues = array_slice( $issues, 0, self::MAX_ISSUES_RETURNED );
        }

        return [
            'total_issues'    => $total,
            'severity_counts' => $severity_counts,
            'issues'          => $issues,
            'truncated'       => $truncated,
        ];
    }

    /**
     * Fast count-only path used by the post-list column. Returns the cached
     * total when the per-post cache signature (content + settings + dictionary)
     * matches, otherwise re-analyzes and writes the cache. Cache is stored in
     * two post-meta keys so a meta_query can power the "needs review" filter
     * without unpacking JSON.
     *
     * The signature meta lives under `_prorank_inclusive_lang_hash` for
     * backward compatibility with R1 — old content-only hashes simply never
     * match the new signature shape and trigger a graceful re-analyze.
     */
    public static function analyze_post( int $post_id ): int {
        if ( $post_id <= 0 ) {
            return 0;
        }
        $settings = self::get_settings();
        $content = (string) get_post_field( 'post_content', $post_id, 'raw' );

        // Feature disabled — drop any stale cache so the admin column and
        // `meta_query`-driven "needs review" filter cannot keep showing a
        // Review count for posts that would now be a Pass.
        if ( empty( $settings['enabled'] ) ) {
            if ( (string) get_post_meta( $post_id, '_prorank_inclusive_lang_hash', true ) !== ''
                || (string) get_post_meta( $post_id, '_prorank_inclusive_lang_total', true ) !== '' ) {
                self::clear_cache( $post_id );
            }
            return 0;
        }

        $dictionary = self::get_dictionary();
        $signature = self::cache_signature( $content, $settings, $dictionary );
        $cached_signature = (string) get_post_meta( $post_id, '_prorank_inclusive_lang_hash', true );
        if ( $cached_signature === $signature ) {
            return (int) get_post_meta( $post_id, '_prorank_inclusive_lang_total', true );
        }
        $result = self::analyze( $content, [
            'ignored_terms' => $settings['ignored_terms'],
        ] );
        $total = (int) $result['total_issues'];
        update_post_meta( $post_id, '_prorank_inclusive_lang_hash', $signature );
        update_post_meta( $post_id, '_prorank_inclusive_lang_total', $total );
        return $total;
    }

    /**
     * Invalidate one post's cache. Hooked from `save_post` so REST/editor/
     * Quick Edit writes all invalidate too — registration must happen at
     * boot, not from an admin-only path.
     */
    public static function clear_cache( int $post_id ): void {
        if ( $post_id <= 0 ) {
            return;
        }
        delete_post_meta( $post_id, '_prorank_inclusive_lang_hash' );
        delete_post_meta( $post_id, '_prorank_inclusive_lang_total' );
    }

    /**
     * Bulk-clear inclusive-language cache for every post. Used when the
     * feature is toggled off or the ignored-terms set changes — we cannot
     * scan the meta in O(1) to re-key it, and the safer behavior is to
     * recompute lazily on next render than to leave stale positive counts
     * powering the "needs review" filter.
     */
    public static function flush_all_cache(): void {
        if ( function_exists( 'delete_post_meta_by_key' ) ) {
            delete_post_meta_by_key( '_prorank_inclusive_lang_hash' );
            delete_post_meta_by_key( '_prorank_inclusive_lang_total' );
        }
    }

    /**
     * Register cache-invalidation hooks at plugin boot (not admin_init).
     *
     * - `save_post` covers classic admin, REST, Quick Edit, and import paths.
     * - `update_option_prorank_seo_content_analysis` triggers a bulk flush
     *    when inclusive-related settings change, so the admin column / filter
     *    cannot keep showing Review counts that the new settings would clear.
     */
    public static function register_hooks(): void {
        if ( ! function_exists( 'add_action' ) ) {
            return;
        }
        static $registered = false;
        if ( $registered ) {
            return;
        }
        $registered = true;
        add_action( 'save_post', [ self::class, 'clear_cache' ] );
        add_action( 'update_option_prorank_seo_content_analysis', [ self::class, 'on_settings_updated' ], 10, 2 );
    }

    /**
     * Fires when `prorank_seo_content_analysis` actually changes. We only
     * bulk-flush when an inclusive-language-relevant field changed; other
     * content-analysis edits (custom-field allowlist, readability targets,
     * etc.) should not invalidate the inclusive-language cache.
     */
    public static function on_settings_updated( $old_value, $new_value ): void {
        $old_value = is_array( $old_value ) ? $old_value : [];
        $new_value = is_array( $new_value ) ? $new_value : [];

        $old_enabled = array_key_exists( 'enable_inclusive_language_analysis', $old_value )
            ? ! empty( $old_value['enable_inclusive_language_analysis'] )
            : true;
        $new_enabled = array_key_exists( 'enable_inclusive_language_analysis', $new_value )
            ? ! empty( $new_value['enable_inclusive_language_analysis'] )
            : true;

        $old_ignored = self::normalize_ignored_terms( (array) ( $old_value['ignored_inclusive_language_terms'] ?? [] ) );
        $new_ignored = self::normalize_ignored_terms( (array) ( $new_value['ignored_inclusive_language_terms'] ?? [] ) );

        if ( $old_enabled === $new_enabled && $old_ignored === $new_ignored ) {
            return;
        }
        self::flush_all_cache();
    }

    /**
     * Build the per-post cache signature. Any change in any input — content,
     * enabled flag, ignored-terms set, or dictionary — yields a new signature,
     * so the next `analyze_post()` re-runs and rewrites the meta.
     */
    private static function cache_signature( string $content, array $settings, array $dictionary ): string {
        $payload = [
            'v'       => 2,
            'content' => sha1( $content ),
            'enabled' => ! empty( $settings['enabled'] ) ? 1 : 0,
            'ignored' => self::normalize_ignored_terms( (array) ( $settings['ignored_terms'] ?? [] ) ),
            'dict'    => self::dictionary_signature( $dictionary ),
        ];
        $encoded = function_exists( 'wp_json_encode' )
            ? wp_json_encode( $payload )
            : json_encode( $payload );
        return sha1( (string) $encoded );
    }

    /**
     * Stable, case-insensitive normalization for ignored-term arrays so
     * settings round-tripping cannot drift the signature.
     *
     * @return array<int,string>
     */
    private static function normalize_ignored_terms( array $terms ): array {
        $norm = [];
        foreach ( $terms as $t ) {
            $t = is_scalar( $t ) ? strtolower( trim( (string) $t ) ) : '';
            if ( $t !== '' ) {
                $norm[ $t ] = true;
            }
        }
        $out = array_keys( $norm );
        sort( $out, SORT_STRING );
        return $out;
    }

    /**
     * Hash the dictionary contents (term/category/severity/suggestion) so a
     * developer wiring the `prorank_inclusive_language_dictionary` filter
     * causes the per-post cache to invalidate on next render.
     */
    private static function dictionary_signature( array $dictionary ): string {
        $rows = [];
        foreach ( $dictionary as $entry ) {
            if ( ! is_array( $entry ) ) {
                continue;
            }
            $rows[] = strtolower(
                (string) ( $entry['term'] ?? '' )
                . '|' . (string) ( $entry['category'] ?? '' )
                . '|' . (string) ( $entry['severity'] ?? '' )
                . '|' . (string) ( $entry['suggestion'] ?? '' )
            );
        }
        sort( $rows, SORT_STRING );
        return sha1( implode( "\n", $rows ) );
    }

    /**
     * Read the relevant slice of `prorank_seo_content_analysis` for this
     * feature, with conservative defaults. Exposed for tests + the admin
     * integration.
     *
     * @return array{enabled:bool,ignored_terms:array<string>}
     */
    public static function get_settings(): array {
        $stored = function_exists( 'get_option' ) ? get_option( 'prorank_seo_content_analysis', [] ) : [];
        if ( ! is_array( $stored ) ) {
            $stored = [];
        }
        $enabled = array_key_exists( 'enable_inclusive_language_analysis', $stored )
            ? ! empty( $stored['enable_inclusive_language_analysis'] )
            : true;
        $raw_ignored = $stored['ignored_inclusive_language_terms'] ?? [];
        $ignored = [];
        if ( is_string( $raw_ignored ) ) {
            $raw_ignored = preg_split( '/[\r\n,]+/', $raw_ignored ) ?: [];
        }
        if ( is_array( $raw_ignored ) ) {
            foreach ( $raw_ignored as $t ) {
                $clean = is_scalar( $t ) ? trim( (string) $t ) : '';
                if ( $clean !== '' ) {
                    $ignored[] = $clean;
                }
            }
        }
        return [
            'enabled'       => (bool) $enabled,
            'ignored_terms' => array_values( array_unique( $ignored ) ),
        ];
    }

    /**
     * Default dictionary, run through the `prorank_inclusive_language_dictionary`
     * filter so developers can extend or replace it entirely.
     *
     * @return array<int,array{term:string,category:string,severity:string,suggestion:string}>
     */
    public static function get_dictionary(): array {
        $dict = self::DEFAULT_DICTIONARY;
        if ( function_exists( 'apply_filters' ) ) {
            $filtered = apply_filters( 'prorank_inclusive_language_dictionary', $dict );
            if ( is_array( $filtered ) ) {
                $dict = $filtered;
            }
        }
        return $dict;
    }

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

    /**
     * Strip everything that should not be scanned (scripts, styles, code,
     * shortcodes, HTML tags, URLs, emails) before matching. The returned
     * text is whitespace-normalized and safe to run word-boundary regex on.
     */
    private static function clean_for_analysis( string $text ): string {
        if ( $text === '' ) {
            return '';
        }
        // <script>/<style>/<code>/<pre>/<a>/<head> including their contents.
        $text = preg_replace(
            '#<(script|style|code|pre|a|head)\b[^>]*>.*?</\\1\s*>#isu',
            ' ',
            $text
        ) ?? $text;
        // Shortcodes (both paired and self-closing). The paired form was
        // already covered by script-style stripping if matched, but most
        // shortcodes are atomic — strip those before tag stripping.
        $text = preg_replace( '/\[(\w[\w-]*)(?:[^\]]*)\](?:.*?\[\/\1\])?/s', ' ', $text ) ?? $text;
        $text = preg_replace( '/\[\/?\w[\w-]*(?:[^\]]*)\]/', ' ', $text ) ?? $text;
        // Remaining HTML tags.
        $text = preg_replace( '/<[^>]*>/u', ' ', $text ) ?? $text;
        // URLs + emails — replace with whitespace so word boundaries hold.
        $text = preg_replace( '#https?://\S+#u', ' ', $text ) ?? $text;
        $text = preg_replace( '/\b[\w.+\-]+@[\w.\-]+\.[\w.\-]+\b/u', ' ', $text ) ?? $text;
        // HTML entities → text equivalents.
        $text = html_entity_decode( (string) $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
        // Whitespace normalization.
        $text = preg_replace( '/\s+/u', ' ', (string) $text ) ?? '';
        return trim( (string) $text );
    }

    /**
     * Build a short context window around a match.
     */
    private static function extract_context( string $text, int $offset, int $length, int $radius = 40 ): string {
        $len = strlen( $text );
        $start = max( 0, $offset - $radius );
        $end = min( $len, $offset + $length + $radius );
        $snippet = substr( $text, $start, $end - $start );
        if ( $start > 0 ) {
            $snippet = '…' . ltrim( $snippet );
        }
        if ( $end < $len ) {
            $snippet = rtrim( $snippet ) . '…';
        }
        return $snippet;
    }
}
