<?php
/**
 * DBA NewsBot Validator — 6-check hard gate for PE/CM news posts
 *
 * All checks are HARD FAILS — article must pass ALL or be rejected.
 *
 * ═══════════════════════════════════════════════════════════════════════
 *  ZERO CONFLICT: Completely separate from core DBA_Validator.
 *  Core DBA_Validator handles EU satellite posts.
 *  This validator handles US PE/Capital Markets posts exclusively.
 * ═══════════════════════════════════════════════════════════════════════
 *
 * Gates:
 *   1. Geography check
 *   2. Company check
 *   3. Structure check
 *   4. Category check
 *   5. Source sanitation check
 *   6. Roundup-specific check (if applicable)
 *
 * @since 2.6.0
 */

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

class DBA_NewsBot_Validator {

    private static $instance = null;

    // Geographies now read from dashboard settings via DBA_NewsBot_Dedupe
    // Kept as fallback only
    private static $default_geos = array(
        'New York', 'Florida', 'Colorado', 'Texas', 'California', 'New Hampshire', 'Arizona',
    );

    // Category rules — default set, can be extended from dashboard
    // Structure: keyword => category name
    private static $default_category_rules = array(
        'buyout'              => 'Private equity',
        'sponsor'             => 'Private equity',
        'leveraged'           => 'Private equity',
        'private equity'      => 'Private equity',
        'pe firm'             => 'Private equity',
        'pe-backed'           => 'Private equity',
        'bond'                => 'Capital markets',
        'pipe'                => 'Capital markets',
        'placement'           => 'Capital markets',
        'capital markets'     => 'Capital markets',
        'debt offering'       => 'Capital markets',
        'direct lending'      => 'Private debt / Private credit',
        'private credit'      => 'Private debt / Private credit',
        'private debt'        => 'Private debt / Private credit',
        'mezzanine'           => 'Private debt / Private credit',
        'property'            => 'Real estate',
        'real estate'         => 'Real estate',
        'reit'                => 'Real estate',
        'commercial property' => 'Real estate',
        'ipo'                 => 'IPO',
        'listing'             => 'IPO',
        'public offering'     => 'IPO',
        'exit'                => 'IPO',
        'spac'                => 'IPO',
        'platform acquisition' => 'Mergers & Acquisitions',
        'merger'              => 'Mergers & Acquisitions',
        'acquisition'         => 'Mergers & Acquisitions',
        'm&a'                 => 'Mergers & Acquisitions',
        'takeover'            => 'Mergers & Acquisitions',
    );

    // Allowed primary categories — default set, extensible from dashboard
    private static $default_categories = array(
        'Private equity',
        'Capital markets',
        'Private debt / Private credit',
        'Real estate',
        'IPO',
        'Mergers & Acquisitions',
    );

    // Required H2 headings for individual posts (in order)
    private static $required_h2s = array(
        'Transaction overview',
        'Investor and capital markets context',
        'Market implications',
    );

    // Optional H2 (allowed but not required)
    private static $optional_h2s = array(
        'Why this matters for investors',
    );

    // Roundup H2 headings — now dynamic, reads from dashboard active geographies
    // Kept as fallback only
    private static $default_roundup_h2s = array(
        'New York', 'Florida', 'Colorado', 'Texas', 'California', 'New Hampshire', 'Arizona',
    );

    // Source/media words that must never appear
    private static $source_patterns = array(
        '/\baccording to\b/i',
        '/\breported by\b/i',
        '/\breuters\b/i',
        '/\bbloomberg\b/i',
        '/\bwall street journal\b/i',
        '/\bwsj\b/i',
        '/\bnew york times\b/i',
        '/\bnyt\b/i',
        '/\bcnbc\b/i',
        '/\bfinancial times\b/i',
        '/\bft\.com\b/i',
        '/\bthe information\b/i',
        '/\bpitchbook\b/i',
        '/\bpreqin\b/i',
        '/\bdealogic\b/i',
        '/\bsources say\b/i',
        '/\bsources familiar\b/i',
        '/\bpeople familiar\b/i',
        '/\banalysts say\b/i',
        '/\bjournalist/i',
        '/\breporter/i',
        '/\blast updated\b/i',
        '/\bupdated on\b/i',
        '/\bpublished on\b/i',
    );

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

    private function __construct() {}

    /**
     * Validate an individual PE/CM news post.
     *
     * @param array  $data     Parsed article data array.
     * @param string $geo      The geography this article targets.
     * @param string $category The assigned category.
     * @return array           Array of error strings. Empty = pass.
     */
    public function validate_individual( array $data, string $geo, string $category ) {
        $errors = array();

        // --- GATE 1: Geography check ---
        $errors = array_merge( $errors, $this->check_geography( $data, $geo ) );

        // --- GATE 2: Company check ---
        $errors = array_merge( $errors, $this->check_company( $data ) );

        // --- GATE 3: Structure check ---
        $errors = array_merge( $errors, $this->check_structure( $data ) );

        // --- GATE 4: Category check ---
        $errors = array_merge( $errors, $this->check_category( $category ) );

        // --- GATE 5: Source sanitation ---
        $errors = array_merge( $errors, $this->check_source_sanitation( $data ) );

        return $errors;
    }

    /**
     * Validate a roundup hub post.
     *
     * @param array $data Parsed roundup data array.
     * @return array      Array of error strings. Empty = pass.
     */
    public function validate_roundup( array $data ) {
        $errors = array();

        // Must have title
        if ( empty( $data['title'] ) ) {
            $errors[] = 'Roundup: Missing title';
        }

        // Must have content_html
        if ( empty( $data['content_html'] ) ) {
            $errors[] = 'Roundup: Missing content_html';
            return $errors; // can't check further
        }

        $content = $data['content_html'];

        // --- GATE 6: Roundup-specific checks ---

        // All active geographies must appear as H2s
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        foreach ( $active_geos as $geo_heading ) {
            if ( ! preg_match( '/<h2[^>]*>\s*' . preg_quote( $geo_heading, '/' ) . '\s*<\/h2>/i', $content ) ) {
                $errors[] = "Roundup: Missing geography heading: {$geo_heading}";
            }
        }

        // Each geography section must have either a summary + link OR "No qualifying update today."
        foreach ( $active_geos as $geo_heading ) {
            // Extract content between this H2 and the next H2 (or end)
            $pattern = '/<h2[^>]*>\s*' . preg_quote( $geo_heading, '/' ) . '\s*<\/h2>(.*?)(?=<h2|$)/si';
            if ( preg_match( $pattern, $content, $match ) ) {
                $section = trim( strip_tags( $match[1] ) );
                if ( empty( $section ) ) {
                    $errors[] = "Roundup: Empty section for {$geo_heading}";
                }
            }
        }

        // Category must be NEWS
        if ( ! empty( $data['category'] ) && strtoupper( $data['category'] ) !== 'NEWS' ) {
            $errors[] = 'Roundup: Category must be NEWS, got: ' . $data['category'];
        }

        // Source sanitation also applies to roundup
        $errors = array_merge( $errors, $this->check_source_sanitation( $data ) );

        return $errors;
    }

    // === INDIVIDUAL CHECK METHODS ===

    /**
     * GATE 1: Geography check
     */
    private function check_geography( array $data, string $geo ) {
        $errors = array();

        // Geography must be one of the active states (configured in dashboard)
        $active_geos = DBA_NewsBot_Dedupe::get_active_geo_names();
        $geo_found = false;
        foreach ( $active_geos as $allowed ) {
            if ( strcasecmp( trim( $geo ), $allowed ) === 0 ) {
                $geo_found = true;
                break;
            }
        }
        if ( ! $geo_found ) {
            $errors[] = "Geography not allowed: {$geo}";
        }

        // Geography must be stated in H1 (title)
        if ( ! empty( $data['title'] ) && ! empty( $geo ) ) {
            $title = $data['title'];
            $geo_slug = DBA_NewsBot_Dedupe::normalize_geo( $geo );
            $geo_label = str_replace( '-', ' ', $geo_slug );

            // Accept: full state name, slug form, abbreviation, or any major city in that state
            $title_lower = strtolower( $title );
            $geo_found_in_title = (
                stripos( $title, $geo ) !== false
                || stripos( $title_lower, $geo_label ) !== false
            );

            // Also check if any known city alias for this geo is in the title
            if ( ! $geo_found_in_title ) {
                $all_states = DBA_NewsBot_Dedupe::get_all_us_states();
                // Reverse lookup: find cities that map to this geo slug
                // The normalize_geo function maps city names to state slugs
                // So if the title contains "Phoenix" and geo is "Arizona", that's fine
                $content_text = strtolower( $title );
                // Simple: does the title contain ANY word that normalizes to the same geo?
                $title_words = preg_split( '/[\s,\-–—]+/', $title );
                foreach ( $title_words as $tw ) {
                    $tw = trim( $tw );
                    if ( strlen( $tw ) < 2 ) continue;
                    $mapped = DBA_NewsBot_Dedupe::normalize_geo( $tw );
                    if ( $mapped === $geo_slug ) {
                        $geo_found_in_title = true;
                        break;
                    }
                }
                // Also try two-word combos (e.g., "New York", "San Francisco")
                if ( ! $geo_found_in_title ) {
                    for ( $wi = 0; $wi < count( $title_words ) - 1; $wi++ ) {
                        $pair = trim( $title_words[ $wi ] ) . ' ' . trim( $title_words[ $wi + 1 ] );
                        $mapped = DBA_NewsBot_Dedupe::normalize_geo( $pair );
                        if ( $mapped === $geo_slug ) {
                            $geo_found_in_title = true;
                            break;
                        }
                    }
                }
            }

            if ( ! $geo_found_in_title ) {
                $errors[] = "Geography '{$geo}' not found in title: {$data['title']}";
            }
        }

        // Geography must appear in intro (first 500 chars of content)
        if ( ! empty( $data['content_html'] ) && ! empty( $geo ) ) {
            $intro = strtolower( substr( strip_tags( $data['content_html'] ), 0, 800 ) );
            $geo_slug  = DBA_NewsBot_Dedupe::normalize_geo( $geo );
            $geo_label = str_replace( '-', ' ', $geo_slug );

            $found_in_intro = (
                stripos( $intro, strtolower( $geo ) ) !== false
                || stripos( $intro, $geo_label ) !== false
            );

            // Also check city names that map to this geo
            if ( ! $found_in_intro ) {
                $intro_words = preg_split( '/[\s,\.\-–—]+/', $intro );
                foreach ( $intro_words as $iw ) {
                    $iw = trim( $iw );
                    if ( strlen( $iw ) < 2 ) continue;
                    if ( DBA_NewsBot_Dedupe::normalize_geo( $iw ) === $geo_slug ) {
                        $found_in_intro = true;
                        break;
                    }
                }
            }
            // Two-word combos
            if ( ! $found_in_intro ) {
                for ( $wi = 0; $wi < count( $intro_words ) - 1; $wi++ ) {
                    $pair = trim( $intro_words[ $wi ] ) . ' ' . trim( $intro_words[ $wi + 1 ] );
                    if ( DBA_NewsBot_Dedupe::normalize_geo( $pair ) === $geo_slug ) {
                        $found_in_intro = true;
                        break;
                    }
                }
            }

            if ( ! $found_in_intro ) {
                $errors[] = "Geography '{$geo}' not found in article introduction";
            }
        }

        return $errors;
    }

    /**
     * GATE 2: Company check
     */
    private function check_company( array $data ) {
        $errors = array();

        if ( empty( $data['company_name'] ) ) {
            $errors[] = 'Missing company_name in article data';
        }

        // Company name must link to official website
        if ( ! empty( $data['content_html'] ) && ! empty( $data['company_name'] ) ) {
            $company = $data['company_name'];
            $content_html = $data['content_html'];

            // Check if ANY <a> tag contains the company name (or a substantial part of it)
            $has_link = false;

            // Strategy 1: exact name inside an anchor
            if ( preg_match( '/<a\s[^>]*href=["\'][^"\']+["\'][^>]*>[^<]*' . preg_quote( $company, '/' ) . '[^<]*<\/a>/i', $content_html ) ) {
                $has_link = true;
            }

            // Strategy 2: first significant word(s) of company name inside an anchor
            if ( ! $has_link ) {
                // Get first 2 significant words (skip "The", "Inc", etc.)
                $name_words = preg_split( '/[\s,]+/', $company );
                $skip = array( 'the', 'inc', 'inc.', 'llc', 'corp', 'ltd', 'co', 'group', 'holdings' );
                $sig_words = array();
                foreach ( $name_words as $w ) {
                    if ( ! in_array( strtolower( $w ), $skip, true ) && strlen( $w ) > 1 ) {
                        $sig_words[] = $w;
                    }
                    if ( count( $sig_words ) >= 2 ) break;
                }
                if ( ! empty( $sig_words ) ) {
                    $pattern = preg_quote( implode( ' ', $sig_words ), '/' );
                    if ( preg_match( '/<a\s[^>]*href=["\'][^"\']+["\'][^>]*>[^<]*' . $pattern . '[^<]*<\/a>/i', $content_html ) ) {
                        $has_link = true;
                    }
                }
            }

            // Strategy 3: just check there's an <a> with an href that's not a .gov link
            if ( ! $has_link ) {
                // Count non-gov links — there should be at least one (the company link)
                if ( preg_match( '/<a\s[^>]*href=["\'](?!https?:\/\/[^"\']*\.gov)[^"\']+["\'][^>]*>/i', $content_html ) ) {
                    $has_link = true; // Lenient fallback: at least one non-gov link exists
                }
            }

            if ( ! $has_link ) {
                $errors[] = 'Company name is not linked to official website';
            }
        }

        // Must have official_url
        if ( empty( $data['official_url'] ) ) {
            $errors[] = 'Missing official_url for company';
        }

        return $errors;
    }

    /**
     * GATE 3: Structure check
     */
    private function check_structure( array $data ) {
        $errors = array();

        // One H1 only, correct format: [Company] – [Transaction] in [Location]
        if ( empty( $data['title'] ) ) {
            $errors[] = 'Missing title (H1)';
        } else {
            // Must contain " – " or " - " separator and "in" keyword
            $title = $data['title'];
            $has_separator = ( strpos( $title, ' – ' ) !== false || strpos( $title, ' - ' ) !== false || strpos( $title, ' — ' ) !== false );
            $has_in        = (bool) preg_match( '/\bin\b/i', $title );

            if ( ! $has_separator ) {
                $errors[] = 'Title format wrong: missing separator (–). Expected: [Company] – [Event] in [Location]';
            }
            if ( ! $has_in ) {
                $errors[] = 'Title format wrong: missing "in [Location]". Expected: [Company] – [Event] in [Location]';
            }
        }

        // Required H2s present
        if ( ! empty( $data['content_html'] ) ) {
            $content = $data['content_html'];

            foreach ( self::$required_h2s as $h2 ) {
                // Case-insensitive, allow attributes on h2 tag, trim whitespace
                $h2_lower = strtolower( $h2 );
                $content_lower = strtolower( $content );

                // Strategy 1: regex match
                $found_h2 = preg_match( '/<h2[^>]*>\s*' . preg_quote( $h2, '/' ) . '\s*<\/h2>/i', $content );

                // Strategy 2: plain text search (in case of extra tags inside h2)
                if ( ! $found_h2 ) {
                    $found_h2 = ( stripos( $content, $h2_lower ) !== false || stripos( $content, $h2 ) !== false );
                }

                // Strategy 3: check for key words (e.g., "transaction" + "overview")
                if ( ! $found_h2 ) {
                    $words = explode( ' ', $h2_lower );
                    $all_found = true;
                    foreach ( $words as $w ) {
                        if ( strlen( $w ) > 3 && stripos( $content, $w ) === false ) {
                            $all_found = false;
                            break;
                        }
                    }
                    $found_h2 = $all_found;
                }

                if ( ! $found_h2 ) {
                    $errors[] = "Missing required H2: {$h2}";
                }
            }

            // Dynamic word count from dashboard settings
            $pe_settings = get_option( 'dba_pe_settings', array() );
            $wc_min = intval( $pe_settings['word_count_min'] ?? 1000 );
            $wc_max = intval( $pe_settings['word_count_max'] ?? 1800 );

            $text       = strip_tags( $content );
            $text       = preg_replace( '/\s+/', ' ', trim( $text ) );
            $word_count = str_word_count( $text );

            // Hard floor: 50% of configured minimum (prompt enforces the real target)
            $hard_min = (int) ( $wc_min * 0.5 );
            if ( $word_count < $hard_min ) {
                $errors[] = "Word count too low: {$word_count} (minimum {$hard_min})";
            }

            $soft_max = $wc_max + 100; // Allow 100-word grace above max
            if ( $word_count > $soft_max ) {
                error_log( "DBA PE Validator: Word count {$word_count} exceeds {$wc_max} target (soft warning)" );
            }

            // Subtitle check — auto-fixed by generator, so just log, never reject
            if ( ! empty( $pe_settings['require_subtitle'] ?? true ) ) {
                $has_subtitle = preg_match( '/<p[^>]*class=["\'][^"\']*subtitle[^"\']*["\'][^>]*>/i', $content )
                    || preg_match( '/<p[^>]*subtitle[^>]*>/i', $content );
                if ( ! $has_subtitle ) {
                    error_log( 'DBA PE Validator: Subtitle missing — should have been auto-injected by generator' );
                }
            }

            // State/city .gov link check (configurable from dashboard)
            if ( ! empty( $pe_settings['require_gov_link'] ?? true ) ) {
                // Accept .gov links OR known official state websites (Florida uses .com)
                $has_gov = preg_match( '/<a\s[^>]*href=["\'][^"\']*\.gov[^"\']*["\'][^>]*>/i', $content );
                if ( ! $has_gov ) {
                    // Also accept known non-.gov official state sites
                    $alt_gov_domains = array( 'myflorida.com', 'az.gov', 'texas.gov', 'state.tx.us' );
                    foreach ( $alt_gov_domains as $domain ) {
                        if ( stripos( $content, $domain ) !== false ) {
                            $has_gov = true;
                            break;
                        }
                    }
                }
                if ( ! $has_gov ) {
                    $errors[] = 'Missing required .gov link — first mention of state/city must link to official .gov website';
                }
            }

            // No promotional or speculative language
            $speculative = array(
                '/\bexpected to soar\b/i',
                '/\bguaranteed\b/i',
                '/\bsure to\b/i',
                '/\bwill definitely\b/i',
                '/\bgame.?changer\b/i',
                '/\bground.?breaking\b/i',
                '/\brevolutionary\b/i',
            );
            foreach ( $speculative as $pat ) {
                if ( preg_match( $pat, $content ) ) {
                    $errors[] = 'Promotional/speculative language detected: ' . $pat;
                    break; // one is enough
                }
            }
        } else {
            $errors[] = 'Missing content_html';
        }

        return $errors;
    }

    /**
     * GATE 4: Category check
     */
    private function check_category( string $category ) {
        $errors = array();

        if ( empty( $category ) ) {
            $errors[] = 'No category assigned';
            return $errors;
        }

        $allowed = self::get_allowed_categories();
        $found = false;
        foreach ( $allowed as $cat ) {
            if ( strcasecmp( trim( $category ), $cat ) === 0 ) {
                $found = true;
                break;
            }
        }

        if ( ! $found ) {
            $errors[] = "Category not allowed for individual post: {$category}. Must be one of: " . implode( ', ', $allowed );
        }

        return $errors;
    }

    /**
     * GATE 5: Source sanitation
     */
    private function check_source_sanitation( array $data ) {
        $errors = array();

        $text_to_check = ( $data['title'] ?? '' ) . ' ' . ( $data['content_html'] ?? '' ) . ' ' . ( $data['excerpt'] ?? '' );

        foreach ( self::$source_patterns as $pattern ) {
            if ( preg_match( $pattern, $text_to_check, $match ) ) {
                $errors[] = 'Source/media reference found: "' . $match[0] . '"';
            }
        }

        return $errors;
    }

    // === UTILITY METHODS ===

    /**
     * Auto-assign category based on content using rule-based logic.
     * Reads rules from dashboard settings (custom_categories) + built-in defaults.
     *
     * @param string $title   Article title.
     * @param string $content Article body text.
     * @return string         Category name.
     */
    public static function assign_category( string $title, string $content ) {
        $text = strtolower( $title . ' ' . strip_tags( $content ) );

        // Merge built-in rules + any custom categories from dashboard
        $rules = self::get_category_rules();

        $scores = array();
        foreach ( $rules as $keyword => $category ) {
            if ( ! isset( $scores[ $category ] ) ) {
                $scores[ $category ] = 0;
            }
            // Count occurrences of keyword
            $count = substr_count( $text, strtolower( $keyword ) );
            if ( $count > 0 ) {
                $scores[ $category ] += $count;
            }
        }

        if ( empty( $scores ) || max( $scores ) === 0 ) {
            return 'Capital markets'; // safe default
        }

        arsort( $scores );
        $keys = array_keys( $scores );
        return $keys[0];
    }

    /**
     * Get the merged category rules (built-in + custom from dashboard).
     */
    public static function get_category_rules() {
        $pe_settings = get_option( 'dba_pe_settings', array() );
        $custom      = $pe_settings['custom_categories'] ?? array();

        // Start with built-in defaults
        $rules = self::$default_category_rules;

        // Merge custom categories: each custom entry has 'name' and 'keywords'
        if ( ! empty( $custom ) && is_array( $custom ) ) {
            foreach ( $custom as $cat ) {
                $name     = trim( $cat['name'] ?? '' );
                $keywords = $cat['keywords'] ?? '';
                if ( empty( $name ) ) continue;

                // Split keywords by comma
                $kw_list = array_map( 'trim', explode( ',', strtolower( $keywords ) ) );
                foreach ( $kw_list as $kw ) {
                    if ( $kw !== '' ) {
                        $rules[ $kw ] = $name;
                    }
                }
            }
        }

        return $rules;
    }

    /**
     * Get the list of allowed categories (built-in + custom from dashboard).
     */
    public static function get_allowed_categories() {
        $pe_settings = get_option( 'dba_pe_settings', array() );
        $custom      = $pe_settings['custom_categories'] ?? array();

        // Start with defaults
        $categories = self::$default_categories;

        // Add any custom categories
        if ( ! empty( $custom ) && is_array( $custom ) ) {
            foreach ( $custom as $cat ) {
                $name = trim( $cat['name'] ?? '' );
                if ( $name !== '' && ! in_array( $name, $categories, true ) ) {
                    $categories[] = $name;
                }
            }
        }

        return $categories;
    }

    /**
     * Get the list of allowed geographies (reads from dashboard).
     */
    public static function get_allowed_geos() {
        return DBA_NewsBot_Dedupe::get_active_geo_names();
    }
}
