<?php

namespace WPSN\Modules\AI;

defined('ABSPATH') || exit;

/**
 * Local (rule-based) suggestion engine.
 *
 * Block C upgrades:
 * - TF-IDF-like keyword extraction (with transient DF caching)
 * - heading/structure analysis output
 * - optional internal link candidates (title match)
 */
final class LocalSuggestionEngine
{
    /**
     * @var string[]
     */
    private array $stopwords;

    private int $dfCacheTtl;

    public function __construct()
    {
        $this->stopwords = $this->defaultStopwords();
        $this->dfCacheTtl = (int) apply_filters('wpsn_ai_local_df_cache_ttl', 12 * HOUR_IN_SECONDS);
        $this->dfCacheTtl = max(300, min(7 * DAY_IN_SECONDS, $this->dfCacheTtl));
    }

    /**
     * @param array{post_id?:int,post_title?:string,excerpt?:string,content?:string,title_max_length?:int,description_max_length?:int,keywords_limit?:int} $input
     * @return array<string,mixed>
     */
    public function suggest(array $input): array
    {
        $postId = isset($input['post_id']) ? (int) $input['post_id'] : 0;
        $postTitle = isset($input['post_title']) ? trim((string) $input['post_title']) : '';
        $excerpt = isset($input['excerpt']) ? trim((string) $input['excerpt']) : '';
        $content = isset($input['content']) ? (string) $input['content'] : '';

        $titleMax = isset($input['title_max_length']) ? (int) $input['title_max_length'] : 60;
        $descMax  = isset($input['description_max_length']) ? (int) $input['description_max_length'] : 155;
        $kwLimit  = isset($input['keywords_limit']) ? (int) $input['keywords_limit'] : 12;

        $plain = $this->extractPlainText($content);

        // Title suggestion: use post title trimmed.
        $title = $postTitle !== '' ? $postTitle : $this->firstMeaningfulLine($plain);
        $title = $this->trimToLength($title, $titleMax);

        // Description suggestion: excerpt > first sentences.
        $descBase = $excerpt !== '' ? $this->extractPlainText($excerpt) : '';
        if ($descBase === '') {
            $descBase = $this->firstSentences($plain, $descMax + 60);
        }
        $description = $this->trimToLength($descBase, $descMax);

        $keywords = $this->extractKeywordsTfidf($plain, max(3, $kwLimit));

        // Extra diagnostics (ignored by older UIs, useful for later).
        $structure = $this->analyzeStructure($content, $plain);
        $internalLinks = $this->suggestInternalLinks($keywords, $postId, 5);

        return [
            'title' => $title,
            'description' => $description,
            'keywords' => $keywords,
            'structure' => $structure,
            'internal_links' => $internalLinks,
        ];
    }

    public function extractPlainText(string $html): string
    {
        $html = (string) $html;

        // Remove Gutenberg comments.
        $html = preg_replace('/<!--\s*\/?.*?\s*-->/s', ' ', $html) ?? $html;

        // Strip shortcodes.
        $html = strip_shortcodes($html);

        // Strip tags and decode entities.
        $text = wp_strip_all_tags($html, true);
        $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, get_bloginfo('charset'));

        // Normalize whitespace.
        $text = preg_replace('/\s+/u', ' ', $text) ?? $text;
        return trim($text);
    }

    private function firstMeaningfulLine(string $text): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }

        $parts = preg_split('/[\r\n\.\!\?]+/u', $text) ?: [];
        foreach ($parts as $p) {
            $p = trim((string) $p);
            if ($this->strlen($p) >= 20) {
                return $p;
            }
        }

        return trim((string) ($parts[0] ?? ''));
    }

    private function firstSentences(string $text, int $maxChars): string
    {
        $text = trim($text);
        if ($text === '') {
            return '';
        }
        // Take first chunk.
        return $this->trimToLength($text, max(40, $maxChars));
    }

    private function trimToLength(string $text, int $maxLen): string
    {
        $text = trim($text);
        if ($text === '' || $maxLen <= 0) {
            return '';
        }

        $len = $this->strlen($text);
        if ($len <= $maxLen) {
            return $text;
        }

        // Cut and then trim to word boundary.
        $cut = $this->substr($text, 0, $maxLen);
        $cut = preg_replace('/\s+\S*$/u', '', $cut) ?? $cut;
        $cut = trim($cut);

        if ($cut === '') {
            $cut = $this->substr($text, 0, $maxLen);
        }

        return rtrim($cut, "\t\n\r\0\x0B\-–—,.;:") . '…';
    }

    /**
     * TF-IDF-like keyword extraction.
     *
     * @return array<int, string>
     */
    private function extractKeywordsTfidf(string $text, int $limit): array
    {
        $tokens = $this->tokenize($text);
        if (empty($tokens)) {
            return [];
        }

        // Unigrams.
        $tf = [];
        foreach ($tokens as $t) {
            if (!isset($tf[$t])) {
                $tf[$t] = 0;
            }
            $tf[$t]++;
        }

        // Bigrams.
        $bi = [];
        $n = count($tokens);
        for ($i = 0; $i < $n - 1; $i++) {
            $a = $tokens[$i];
            $b = $tokens[$i + 1];
            if ($a === $b) {
                continue;
            }
            $k = $a . ' ' . $b;
            if (!isset($bi[$k])) {
                $bi[$k] = 0;
            }
            $bi[$k]++;
        }

        arsort($tf);
        arsort($bi);

        // Candidate set: top unigrams + bigrams by raw TF.
        $candidates = [];
        $maxUni = 30;
        $maxBi = 20;

        foreach (array_keys($bi) as $k) {
            $candidates[$k] = ['tf' => (int) $bi[$k], 'is_bigram' => true];
            if (count($candidates) >= $maxBi) {
                break;
            }
        }

        $uniCount = 0;
        foreach (array_keys($tf) as $k) {
            if (isset($candidates[$k])) {
                continue;
            }
            $candidates[$k] = ['tf' => (int) $tf[$k], 'is_bigram' => false];
            $uniCount++;
            if ($uniCount >= $maxUni) {
                break;
            }
        }

        $N = max(1, $this->corpusSize());

        // Compute TF-IDF-ish score.
        // NOTE: DF lookups are the most expensive part. We only calculate DF for the
        // most promising candidates (by raw TF) and fall back to idf=1 for the rest.

        $rank = [];
        foreach ($candidates as $term => $info) {
            $tfVal = (int) ($info['tf'] ?? 1);
            $isBi = !empty($info['is_bigram']);
            $rank[$term] = $tfVal * ($isBi ? 2 : 1);
        }
        arsort($rank);

        $dfCache = [];
        $maxDfTerms = min(count($rank), max($limit * 4, 24));
        $dfTerms = array_slice(array_keys($rank), 0, $maxDfTerms);
        foreach ($dfTerms as $term) {
            $dfCache[$term] = $this->docFrequency($term);
        }

        $scores = [];
        foreach ($candidates as $term => $info) {
            $tfVal = (int) ($info['tf'] ?? 1);
            $isBi = !empty($info['is_bigram']);

            $idf = 1.0;
            if (array_key_exists($term, $dfCache)) {
                $df = (int) $dfCache[$term];
                $idf = log(($N + 1) / ($df + 1)) + 1.0;
            }

            $lenBoost = 1.0;
            $termLen = $this->strlen($term);
            if ($termLen >= 10) {
                $lenBoost = 1.15;
            }

            $score = $tfVal * $idf * $lenBoost * ($isBi ? 1.5 : 1.0);
            $scores[$term] = $score;
        }

        arsort($scores);

        $out = [];
        foreach (array_keys($scores) as $term) {
            $out[] = $term;
            if (count($out) >= $limit) {
                break;
            }
        }

        return array_values(array_unique($out));
    }

    /**
     * @return string[]
     */
    private function tokenize(string $text): array
    {
        $text = function_exists('mb_strtolower') ? (string) mb_strtolower($text) : strtolower($text);
        $text = preg_replace('/[^\p{L}\p{N}\s]+/u', ' ', $text) ?? $text;
        $words = preg_split('/\s+/u', $text) ?: [];

        $filtered = [];
        foreach ($words as $w) {
            $w = trim((string) $w);
            if ($w === '') {
                continue;
            }
            if ($this->strlen($w) < 3) {
                continue;
            }
            if (in_array($w, $this->stopwords, true)) {
                continue;
            }
            // Skip mostly-numeric tokens.
            if (preg_match('/^\d+$/', $w)) {
                continue;
            }
            $filtered[] = $w;
        }

        return $filtered;
    }

    private function corpusSize(): int
    {
        static $cached = null;
        if (is_int($cached)) {
            return $cached;
        }

        $total = 0;
        $pts = $this->publicPostTypes();
        foreach ($pts as $pt) {
            $counts = wp_count_posts($pt);
            if (is_object($counts) && isset($counts->publish)) {
                $total += (int) $counts->publish;
            }
        }

        $cached = max(1, $total);
        return $cached;
    }

    /**
     * Document frequency estimate (how many published posts contain the term).
     */
    private function docFrequency(string $term): int
    {
        $term = trim($term);
        if ($term === '') {
            return 0;
        }

        $key = 'wpsn_df_' . md5($term);
        $cached = get_transient($key);
        if (is_int($cached)) {
            return max(0, $cached);
        }
        if (is_string($cached) && ctype_digit($cached)) {
            return (int) $cached;
        }

        global $wpdb;

        $pts = $this->publicPostTypes();
        if (empty($pts)) {
            return 0;
        }

        $placeholders = implode(',', array_fill(0, count($pts), '%s'));
        $like = '%' . $wpdb->esc_like($term) . '%';

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $sql = "SELECT COUNT(DISTINCT ID) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$placeholders}) AND (post_title LIKE %s OR post_content LIKE %s)";
        $params = array_merge($pts, [$like, $like]);

        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        $df = (int) $wpdb->get_var($wpdb->prepare($sql, $params));

        set_transient($key, $df, $this->dfCacheTtl);

        return max(0, $df);
    }

    /**
     * @return string[]
     */
    private function publicPostTypes(): array
    {
        static $types = null;
        if (is_array($types)) {
            return $types;
        }

        $pts = get_post_types(['public' => true], 'names');
        $out = [];
        foreach ($pts as $pt) {
            if ($pt === 'attachment') {
                continue;
            }
            $out[] = (string) $pt;
        }
        $types = $out;
        return $types;
    }

    /**
     * Heading/structure analysis.
     *
     * @return array<string,mixed>
     */
    private function analyzeStructure(string $html, string $plain): array
    {
        $counts = [
            'h1' => 0,
            'h2' => 0,
            'h3' => 0,
            'h4' => 0,
            'h5' => 0,
            'h6' => 0,
        ];

        $examples = [
            'h1' => [],
            'h2' => [],
            'h3' => [],
        ];

        if (preg_match_all('/<h([1-6])\b[^>]*>(.*?)<\/h\1>/is', $html, $m, PREG_SET_ORDER)) {
            foreach ($m as $match) {
                $lvl = isset($match[1]) ? (string) $match[1] : '';
                $txt = isset($match[2]) ? (string) $match[2] : '';
                $key = 'h' . $lvl;
                if (!isset($counts[$key])) {
                    continue;
                }
                $counts[$key]++;

                $t = trim($this->extractPlainText($txt));
                if ($t !== '' && isset($examples[$key]) && count($examples[$key]) < 3) {
                    $examples[$key][] = $this->trimToLength($t, 80);
                }
            }
        }

        $wc = $this->wordCount($plain);

        $issues = [];
        $score = 100;

        if ($counts['h1'] === 0) {
            $score -= 20;
            $issues[] = [
                'code' => 'missing_h1',
                'message' => __('No H1 heading detected.', 'wp-seo-next'),
            ];
        } elseif ($counts['h1'] > 1) {
            $score -= 10;
            $issues[] = [
                'code' => 'multiple_h1',
                'message' => __('Multiple H1 headings detected.', 'wp-seo-next'),
            ];
        }

        if ($counts['h2'] === 0) {
            $score -= 15;
            $issues[] = [
                'code' => 'missing_h2',
                'message' => __('No H2 subheadings detected.', 'wp-seo-next'),
            ];
        }

        if ($wc < 150) {
            $score -= 25;
            $issues[] = [
                'code' => 'very_thin_content',
                'message' => __('Very thin content (<150 words).', 'wp-seo-next'),
            ];
        } elseif ($wc < 300) {
            $score -= 15;
            $issues[] = [
                'code' => 'thin_content',
                'message' => __('Thin content (<300 words).', 'wp-seo-next'),
            ];
        }

        $score = max(0, min(100, $score));

        return [
            'score' => $score,
            'word_count' => $wc,
            'headings' => $counts,
            'heading_examples' => $examples,
            'issues' => $issues,
        ];
    }

    private function wordCount(string $plain): int
    {
        $t = trim($plain);
        if ($t === '') {
            return 0;
        }
        $parts = preg_split('/\s+/u', $t);
        if (!is_array($parts)) {
            return 0;
        }
        $parts = array_values(array_filter($parts, static function ($w) {
            return is_string($w) && trim($w) !== '';
        }));
        return count($parts);
    }

    /**
     * Internal link candidates based on keyword matches in titles.
     *
     * @param string[] $keywords
     * @return array<int,array<string,mixed>>
     */
    private function suggestInternalLinks(array $keywords, int $currentPostId, int $limit): array
    {
        $limit = max(0, min(20, (int) $limit));
        if ($limit <= 0 || empty($keywords)) {
            return [];
        }

        global $wpdb;

        $pts = $this->publicPostTypes();
        if (empty($pts)) {
            return [];
        }

        $placeholders = implode(',', array_fill(0, count($pts), '%s'));

        $out = [];
        $seen = [];

        // Prefer longer terms first.
        usort($keywords, function ($a, $b) {
            return strlen((string) $b) <=> strlen((string) $a);
        });

        $checked = 0;
        foreach ($keywords as $kw) {
            $kw = trim((string) $kw);
            if ($kw === '') {
                continue;
            }

            $checked++;
            if ($checked > 6) {
                break;
            }

            $like = '%' . $wpdb->esc_like($kw) . '%';
            $sql = "SELECT ID, post_title FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$placeholders}) AND ID <> %d AND post_title LIKE %s ORDER BY post_modified_gmt DESC LIMIT 5";
            $params = array_merge($pts, [$currentPostId, $like]);

            // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $rows = $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A);
            if (!is_array($rows)) {
                continue;
            }

            foreach ($rows as $r) {
                $pid = isset($r['ID']) ? (int) $r['ID'] : 0;
                if ($pid <= 0) {
                    continue;
                }
                if (isset($seen[$pid])) {
                    continue;
                }

                $seen[$pid] = true;

                $out[] = [
                    'post_id' => $pid,
                    'title' => isset($r['post_title']) ? (string) $r['post_title'] : (string) get_the_title($pid),
                    'url' => (string) get_permalink($pid),
                    'anchor' => $kw,
                ];

                if (count($out) >= $limit) {
                    break 2;
                }
            }
        }

        return $out;
    }

    /**
     * @return string[]
     */
    private function defaultStopwords(): array
    {
        // Minimal RU + EN stopwords list.
        return [
            // EN
            'the','and','or','a','an','to','of','in','on','for','with','is','are','was','were','be','been','it','this','that','these','those','as','at','by','from','but','not','you','your','we','our','they','their','i','me','my','us','them','can','will','just','about','into','over','after','before','more','most','less','very','also','than','then','there','here','when','where','what','which','who','whom','why','how',
            // RU
            'и','или','а','но','что','это','как','к','ко','в','во','на','за','по','для','с','со','из','у','о','об','от','до','при','без','над','под','про','же','ли','бы','не','да','нет','мы','вы','они','он','она','оно','я','ты','мы','ваш','ваша','ваши','наш','наша','наши','их','его','ее','свой','свои','тоже','также','ещё','еще','только','уже','очень','более','менее','чем','тогда','здесь','там','где','когда','почему','зачем','кто','кого','какой','какая','какие','какое',
        ];
    }

    private function strlen(string $s): int
    {
        if (function_exists('mb_strlen')) {
            return (int) mb_strlen($s);
        }
        return (int) strlen($s);
    }

    private function substr(string $s, int $start, int $len): string
    {
        if (function_exists('mb_substr')) {
            return (string) mb_substr($s, $start, $len);
        }
        return (string) substr($s, $start, $len);
    }
}
