<?php
/**
 * DBA GeoBot Validator — Content quality hard gates (country-agnostic)
 *
 * All checks are HARD FAILS — article must pass ALL or be rejected.
 * Uses country config from DBA_GeoBot_Registry to adapt rules
 * per country (language, H2 sections, linking rules, etc.).
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  ZERO CONFLICT: Completely separate from core DBA_Validator
 *  and DBA_NewsBot_Validator.
 * ═══════════════════════════════════════════════════════════════════════
 *
 * Gates:
 *   1. Required JSON keys present
 *   2. Word count within range
 *   3. H2 sections present
 *   4. Commune name appears in content
 *   5. No forbidden elements (<hr>, <strong>, title links, duplicate H1 in content)
 *   6. Plagiarism / uniqueness check (sentence-level)
 *   7. Excerpt length check
 *   8. Banned template phrases (instant rejection if found)
 *   9. Structural variety check (anti-cookie-cutter) — NEW
 *  10. Opener / transition repetition check — NEW
 *  11. Title deduplication (90-day window, 85% similarity) — NEW
 *
 * @since 2.8.0
 * @updated 2.17.0 — Added Gates 9-11 for anti-spam structural enforcement
 */

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

class DBA_GeoBot_Validator {

    private static $instance = null;

    public static function get_instance() {
        if ( null === self::$instance ) self::$instance = new self();
        return self::$instance;
    }

    /**
     * Validate a generated article for a given country.
     *
     * @param array  $article      Parsed JSON from OpenAI (title, content_html, slug, excerpt, etc.)
     * @param string $country_key  'france', 'italy', etc.
     * @param string $commune      Target commune name.
     * @return array               Array of error strings. Empty = PASS.
     */
    public function validate( array $article, string $country_key, string $commune ) {

        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return array( 'Unknown country: ' . $country_key );

        $errors = array();

        // ── Gate 1: Required keys ──
        $required_keys = array( 'title', 'slug', 'excerpt', 'content_html' );
        foreach ( $required_keys as $key ) {
            if ( empty( $article[ $key ] ) ) {
                $errors[] = "Missing required key: {$key}";
            }
        }
        if ( ! empty( $errors ) ) return $errors; // Can't validate further without content

        $html    = $article['content_html'];
        $title   = $article['title'];
        $text    = wp_strip_all_tags( $html );

        // ── Gate 2: Word count ──
        $wc_min = $config['word_count_min'] ?? 800;
        $wc_max = $config['word_count_max'] ?? 1200;
        $wc     = str_word_count( $text );
        // For non-Latin-space languages, fallback to character-based estimate
        if ( $wc < 50 ) {
            $wc = (int) ( mb_strlen( $text, 'UTF-8' ) / 5 );
        }

        if ( $wc < $wc_min * 0.85 ) { // 15% tolerance
            $errors[] = "Word count too low: {$wc} (minimum ~{$wc_min})";
        }
        if ( $wc > $wc_max * 1.25 ) { // 25% tolerance
            $errors[] = "Word count too high: {$wc} (maximum ~{$wc_max})";
        }

        // ── Gate 3: H2 sections ──
        $required_h2s = $config['h2_sections'] ?? array();
        if ( ! empty( $required_h2s ) ) {
            $html_lower = mb_strtolower( $html, 'UTF-8' );
            $missing_h2 = 0;
            foreach ( $required_h2s as $h2 ) {
                $h2_lower = mb_strtolower( $h2, 'UTF-8' );
                // Check for section title text in content (inside <h2> tags)
                if ( strpos( $html_lower, $h2_lower ) === false ) {
                    $missing_h2++;
                }
            }
            // Allow up to 3 missing sections (AI may adapt headings slightly)
            if ( $missing_h2 > 3 ) {
                $errors[] = "Too many missing H2 sections ({$missing_h2} of " . count( $required_h2s ) . ")";
            }
        }

        // ── Gate 4: Commune name in content ──
        if ( mb_stripos( $html, $commune ) === false ) {
            $errors[] = "Commune name '{$commune}' not found in content";
        }

        // ── Gate 5: Forbidden elements ──
        $link_rules = $config['link_rules'] ?? array();

        if ( ! empty( $link_rules['no_hr'] ) && preg_match( '/<hr\s*\/?>/i', $html ) ) {
            $errors[] = "Forbidden element: <hr> found in content";
        }
        if ( ! empty( $link_rules['no_strong'] ) && preg_match( '/<strong\b/i', $html ) ) {
            $errors[] = "Forbidden element: <strong> found in content";
        }
        if ( ! empty( $link_rules['no_city_link_in_h1'] ) ) {
            // Check if title contains an <a> tag
            if ( preg_match( '/<a\s/i', $title ) ) {
                $errors[] = "Post title must not contain links";
            }
        }

        // Check: content_html must NOT start with the post title as an H-tag (WordPress renders H1)
        $title_plain = wp_strip_all_tags( $title );
        if ( preg_match( '/^\s*<h[12][^>]*>/i', $html ) ) {
            // Extract the text inside the first heading
            if ( preg_match( '/^\s*<h[12][^>]*>(.*?)<\/h[12]>/is', $html, $m ) ) {
                $first_heading_text = wp_strip_all_tags( $m[1] );
                $sim = 0;
                similar_text( mb_strtolower( $title_plain ), mb_strtolower( $first_heading_text ), $sim );
                if ( $sim > 80 ) {
                    $errors[] = "content_html must NOT repeat the post title as a heading (WordPress renders it as H1 automatically)";
                }
            }
        }

        // ── Gate 6: Plagiarism / sentence uniqueness ──
        $plagiarism_errors = $this->check_sentence_uniqueness( $text, $country_key, $commune );
        if ( ! empty( $plagiarism_errors ) ) {
            $errors = array_merge( $errors, $plagiarism_errors );
        }

        // ── Gate 7: Excerpt length ──
        $excerpt = $article['excerpt'] ?? '';
        if ( mb_strlen( $excerpt, 'UTF-8' ) > 180 ) {
            $errors[] = "Excerpt too long: " . mb_strlen( $excerpt, 'UTF-8' ) . " chars (max 160-180)";
        }

        // ── Gate 8: Banned template phrases ──
        $banned_errors = $this->check_banned_phrases( $text, $country_key );
        if ( ! empty( $banned_errors ) ) {
            $errors = array_merge( $errors, $banned_errors );
        }

        // ── Gate 9: Structural variety (anti-cookie-cutter) — HARD GATE ──
        $structure_errors = $this->check_structure_variety( $html, $country_key );
        if ( ! empty( $structure_errors ) ) {
            $errors = array_merge( $errors, $structure_errors );
        }

        // ── Gate 10: Opener / transition repetition — HARD GATE ──
        $opener_errors = $this->check_opener_variety( $html, $country_key );
        if ( ! empty( $opener_errors ) ) {
            $errors = array_merge( $errors, $opener_errors );
        }

        // ── Gate 11: Title deduplication (85% similarity, 90-day window) ──
        $title_errors = $this->check_title_uniqueness( $title, $country_key, $commune );
        if ( ! empty( $title_errors ) ) {
            $errors = array_merge( $errors, $title_errors );
        }

        return $errors;
    }

    /**
     * Sentence-level plagiarism check.
     * Compares generated sentences against previously published GeoBot posts.
     * A sentence is flagged if it matches >90% of an existing sentence.
     *
     * @param string $text         Plain text of the article.
     * @param string $country_key  Country key.
     * @param string $commune      Current commune (excluded from self-match).
     * @return array               Error messages.
     */
    private function check_sentence_uniqueness( string $text, string $country_key, string $commune ) {

        $errors = array();
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return $errors;

        // Get recent GeoBot posts for this country
        $meta_key = $config['meta_prefix'] . 'auto_created';
        $recent_posts = get_posts( array(
            'post_type'      => 'post',
            'posts_per_page' => 30,
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'meta_query'     => array(
                array( 'key' => $meta_key, 'compare' => 'EXISTS' ),
            ),
            'fields' => 'ids',
        ));

        if ( empty( $recent_posts ) ) return $errors;

        // Build corpus of existing sentences
        $existing_sentences = array();
        foreach ( $recent_posts as $post_id ) {
            $content = wp_strip_all_tags( get_post_field( 'post_content', $post_id ) );
            $sentences = $this->extract_sentences( $content );
            foreach ( $sentences as $s ) {
                if ( mb_strlen( $s, 'UTF-8' ) > 30 ) { // Only check non-trivial sentences
                    $existing_sentences[] = $s;
                }
            }
        }

        if ( empty( $existing_sentences ) ) return $errors;

        // Check new article sentences
        $new_sentences = $this->extract_sentences( $text );
        $duplicates = 0;
        $total_checked = 0;

        foreach ( $new_sentences as $new_s ) {
            if ( mb_strlen( $new_s, 'UTF-8' ) < 30 ) continue;
            $total_checked++;

            $new_norm = $this->normalize_sentence( $new_s );

            foreach ( $existing_sentences as $existing_s ) {
                $existing_norm = $this->normalize_sentence( $existing_s );
                $similarity = 0;
                similar_text( $new_norm, $existing_norm, $similarity );

                if ( $similarity >= 90 ) {
                    $duplicates++;
                    break; // One match is enough to flag this sentence
                }
            }
        }

        // Allow up to 15% duplicate sentences (some structural phrases will repeat)
        $max_allowed_pct = 15;
        if ( $total_checked > 0 ) {
            $dup_pct = ( $duplicates / $total_checked ) * 100;
            if ( $dup_pct > $max_allowed_pct ) {
                $errors[] = "Plagiarism warning: {$duplicates}/{$total_checked} sentences (" . round( $dup_pct, 0 ) . "%) match existing posts (max {$max_allowed_pct}%)";
            }
        }

        return $errors;
    }

    /**
     * Gate 9: Structural variety check.
     *
     * Compares the new article's skeleton (H2 count, paragraphs per section,
     * avg paragraph length) against recent posts. If too many are identical,
     * the article is REJECTED to prevent cookie-cutter spam.
     *
     * @param string $html         Generated HTML content.
     * @param string $country_key  Country key.
     * @return array               Error messages. Empty = pass.
     */
    private function check_structure_variety( string $html, string $country_key ) {

        $errors = array();

        // Only enforce if Kaizen class is loaded (it computes structure hashes)
        if ( ! class_exists( 'DBA_GeoBot_Kaizen' ) ) return $errors;

        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return $errors;

        $new_hash = DBA_GeoBot_Kaizen::compute_structure_hash( $html );

        // Get recent posts that have structure hashes stored
        $meta_key = $config['meta_prefix'] . 'auto_created';
        $recent   = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'posts_per_page' => 10,
            'meta_query'     => array(
                array( 'key' => $meta_key, 'compare' => 'EXISTS' ),
            ),
            'fields'  => 'ids',
            'orderby' => 'date',
            'order'   => 'DESC',
        ) );

        if ( count( $recent ) < 3 ) return $errors; // Not enough data to judge

        $matches = 0;
        foreach ( $recent as $pid ) {
            $existing_hash = get_post_meta( $pid, DBA_GeoBot_Kaizen::META_STRUCTURE_HASH, true );
            if ( $existing_hash && $existing_hash === $new_hash ) {
                $matches++;
            }
        }

        // HARD FAIL: If 4+ of last 10 posts have identical structure, reject
        if ( $matches >= 4 ) {
            $errors[] = "Structure clone detected: {$matches} of the last " . count( $recent ) . " posts have IDENTICAL paragraph structure ({$new_hash}). Article must vary its section lengths, paragraph counts, and layout.";
        }

        return $errors;
    }

    /**
     * Gate 10: Opener / transition variety check.
     *
     * Checks that section openers (first 3 words of each section) and
     * transition words aren't recycled verbatim from recent articles.
     *
     * @param string $html         Generated HTML content.
     * @param string $country_key  Country key.
     * @return array               Error messages. Empty = pass.
     */
    private function check_opener_variety( string $html, string $country_key ) {

        $errors = array();

        if ( ! class_exists( 'DBA_GeoBot_Kaizen' ) ) return $errors;

        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return $errors;

        $new_opener = DBA_GeoBot_Kaizen::compute_opener_signature( $html );
        if ( empty( $new_opener ) ) return $errors;

        $meta_key = $config['meta_prefix'] . 'auto_created';
        $recent   = get_posts( array(
            'post_type'      => 'post',
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'posts_per_page' => 10,
            'meta_query'     => array(
                array( 'key' => $meta_key, 'compare' => 'EXISTS' ),
            ),
            'fields'  => 'ids',
            'orderby' => 'date',
            'order'   => 'DESC',
        ) );

        if ( count( $recent ) < 3 ) return $errors;

        $new_parts    = explode( '|', $new_opener );
        $high_overlap = 0;

        foreach ( $recent as $pid ) {
            $existing_opener = get_post_meta( $pid, DBA_GeoBot_Kaizen::META_OPENER_SIGNATURE, true );
            if ( empty( $existing_opener ) ) continue;

            $existing_parts = explode( '|', $existing_opener );
            $overlap        = count( array_intersect( $new_parts, $existing_parts ) );
            $total          = max( count( $new_parts ), 1 );

            if ( $overlap / $total > 0.7 ) { // 70%+ of openers are recycled
                $high_overlap++;
            }
        }

        // HARD FAIL: If 4+ recent posts have highly similar openers
        if ( $high_overlap >= 4 ) {
            $errors[] = "Opener repetition: {$high_overlap} of the last " . count( $recent ) . " posts start sections with the same words. Each section must begin with a unique approach.";
        }

        // Also check for excessive use of the SAME transition within this article
        $transition_errors = $this->check_internal_transition_spam( $html, $country_key );
        if ( ! empty( $transition_errors ) ) {
            $errors = array_merge( $errors, $transition_errors );
        }

        return $errors;
    }

    /**
     * Check for excessive repetition of transition words WITHIN a single article.
     *
     * @param string $html
     * @param string $country_key
     * @return array
     */
    private function check_internal_transition_spam( string $html, string $country_key ) {

        $errors = array();
        $text   = mb_strtolower( wp_strip_all_tags( $html ), 'UTF-8' );

        // Transitions that AI loves to overuse
        $transitions = array(
            'moreover', 'furthermore', 'in addition', 'additionally', 'notably',
            'de plus', 'en outre', 'par ailleurs', 'notamment',
            'inoltre', 'in particolare', 'nello specifico',
            'además', 'en particular', 'cabe destacar',
            'além disso', 'em particular', 'vale ressaltar',
        );

        $offenders = array();
        foreach ( $transitions as $t ) {
            $count = mb_substr_count( $text, $t );
            if ( $count >= 3 ) { // Same transition 3+ times in ONE article = spam
                $offenders[] = "\"{$t}\" ({$count}×)";
            }
        }

        if ( ! empty( $offenders ) ) {
            $errors[] = "Transition word spam within article: " . implode( ', ', $offenders ) . ". Each transition word should appear at most twice. Use varied connectors.";
        }

        return $errors;
    }

    /**
     * Gate 11: Title deduplication.
     *
     * Checks that the new title is not too similar to any existing GeoBot title
     * published in the last 90 days. Uses 85% similarity threshold.
     *
     * @param string $title        New article title.
     * @param string $country_key  Country key.
     * @param string $commune      Current commune.
     * @return array               Error messages. Empty = pass.
     */
    private function check_title_uniqueness( string $title, string $country_key, string $commune ) {

        $errors = array();
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return $errors;

        $meta_key = $config['meta_prefix'] . 'auto_created';

        $recent_posts = get_posts( array(
            'post_type'      => 'post',
            'posts_per_page' => 50,
            'post_status'    => array( 'publish', 'draft', 'pending' ),
            'meta_query'     => array(
                array( 'key' => $meta_key, 'compare' => 'EXISTS' ),
            ),
            'date_query' => array( array( 'after' => '90 days ago' ) ),
            'fields' => 'ids',
        ));

        if ( empty( $recent_posts ) ) return $errors;

        $new_title_norm = mb_strtolower( trim( $title ), 'UTF-8' );

        // Remove the commune name to compare the "template" part of the title
        $new_title_skeleton = str_ireplace( $commune, '___', $new_title_norm );

        foreach ( $recent_posts as $pid ) {
            $existing_title = mb_strtolower( get_the_title( $pid ), 'UTF-8' );
            $existing_commune = get_post_meta( $pid, $config['meta_prefix'] . 'commune', true );

            // Skip self-commune (different commune = different title is fine)
            if ( $existing_commune && mb_strtolower( $existing_commune ) === mb_strtolower( $commune ) ) {
                continue; // Same commune re-generation, skip
            }

            // Compare title skeletons (with commune names replaced)
            $existing_skeleton = $existing_commune
                ? str_ireplace( $existing_commune, '___', $existing_title )
                : $existing_title;

            $sim = 0;
            similar_text( $new_title_skeleton, $existing_skeleton, $sim );

            if ( $sim >= 85 ) {
                $errors[] = "Title too similar to existing post #{$pid}: \"{$existing_title}\" ({$sim}% match after city name removal). Vary the title structure.";
                break; // One match is enough
            }
        }

        return $errors;
    }

    /**
     * Extract sentences from text.
     */
    private function extract_sentences( string $text ) {
        // Split on sentence-ending punctuation followed by space or end
        $sentences = preg_split( '/(?<=[.!?])\s+/', $text, -1, PREG_SPLIT_NO_EMPTY );
        return array_map( 'trim', $sentences );
    }

    /**
     * Normalize a sentence for comparison (lowercase, remove extra spaces).
     */
    private function normalize_sentence( string $s ) {
        $s = mb_strtolower( trim( $s ), 'UTF-8' );
        $s = preg_replace( '/\s+/', ' ', $s );
        return $s;
    }

    /**
     * Gate 8: Check for banned template phrases.
     * These are extracted from the original article template and must NEVER
     * appear in generated content — their presence means the AI is copying
     * the template instead of composing original text.
     *
     * @param string $text         Plain text of the article.
     * @param string $country_key  Country key.
     * @return array               Error messages.
     */
    private function check_banned_phrases( string $text, string $country_key ) {
        $errors = array();
        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return $errors;

        $banned = $config['banned_phrases'] ?? array();
        if ( empty( $banned ) ) return $errors;

        $text_lower = mb_strtolower( $text, 'UTF-8' );
        $found = array();

        foreach ( $banned as $phrase ) {
            $phrase_lower = mb_strtolower( $phrase, 'UTF-8' );
            if ( mb_strpos( $text_lower, $phrase_lower ) !== false ) {
                $found[] = $phrase;
            }
        }

        if ( ! empty( $found ) ) {
            $count = count( $found );
            $examples = array_slice( $found, 0, 3 );
            $errors[] = "Banned template phrases detected ({$count}): \"" . implode( '", "', $examples ) . '"'
                . ( $count > 3 ? ' + ' . ( $count - 3 ) . ' more' : '' )
                . ' — Article must be rewritten with original sentences.';
        }

        return $errors;
    }

    /**
     * Post-generation content cleanup.
     * Applies hard rules that the AI might miss.
     *
     * @param string $html         Generated HTML content.
     * @param string $country_key  Country key.
     * @return string              Cleaned HTML.
     */
    public static function cleanup_html( string $html, string $country_key ) {

        $config = DBA_GeoBot_Registry::get_country( $country_key );
        if ( ! $config ) return $html;

        $rules = $config['link_rules'] ?? array();

        // Remove <hr> if forbidden
        if ( ! empty( $rules['no_hr'] ) ) {
            $html = preg_replace( '/<hr\s*\/?>/i', '', $html );
        }

        // Remove <strong> if forbidden
        if ( ! empty( $rules['no_strong'] ) ) {
            $html = preg_replace( '/<\/?strong\b[^>]*>/i', '', $html );
        }

        // Remove any "Last updated" lines (multilingual)
        $html = preg_replace( '/<p[^>]*>\s*(?:Last updated|Dernière mise à jour|Ultimo aggiornamento|Última actualización|Última atualização)[^<]*<\/p>/i', '', $html );

        // Clean up empty paragraphs
        $html = preg_replace( '/<p>\s*<\/p>/', '', $html );

        // Normalize whitespace
        $html = preg_replace( '/\n{3,}/', "\n\n", $html );

        return trim( $html );
    }
}
