<?php

namespace WPSN\Modules\Analysis;

use WPSN\Core\AbstractModule;
use WPSN\Domain\SeoDocumentContext;
use WPSN\Modules\Analysis\Admin\AnalysisAdmin;
use WPSN\SEO\ContentExtractor;
use WPSN\SEO\MetaKeys;
use WPSN\SEO\SeoMetaRepository;
use WPSN\SEO\InternalLinkEstimator;

defined('ABSPATH') || exit;

/**
 * Stage 40: On-page analysis (rule-based).
 *
 * - Focus keyword field
 * - Simple SEO score + checks
 * - Editor panel (via REST) + cached score for list tables
 */
final class AnalysisModule extends AbstractModule
{
    private ContentExtractor $extractor;
    private InternalLinkEstimator $linkEstimator;

    public function __construct(\WPSN\Core\Container $container)
    {
        parent::__construct($container);
        $this->extractor = new ContentExtractor();
        $this->linkEstimator = new InternalLinkEstimator();
    }

    public function id(): string
    {
        return 'analysis';
    }

    public function name(): string
    {
        return __('On-page analysis', 'wp-seo-next');
    }

    public function description(): string
    {
        return __('Rule-based checks + SEO score + focus keyword.', 'wp-seo-next');
    }

    public function defaultEnabled(): bool
    {
        return true;
    }

    public function register(): void
    {
        // Meta keys must be registered early for Gutenberg/REST.
        add_action('init', [$this, 'registerMetaKeys'], 1);

        // Editor REST endpoint.
        add_action('rest_api_init', [$this, 'registerRestRoutes']);

        // Recompute on post save.
        add_action('save_post', [$this, 'handleSavePost'], 20, 2);

        // Recompute when bulk/apply/import modifies SEO meta (save_post not triggered).
        add_action('seo/meta/updated', [$this, 'handleMetaUpdated'], 30, 2);

        // Admin list columns.
        add_action('init', [$this, 'registerAdminColumns'], 20);

        if (is_admin()) {
            $admin = new AnalysisAdmin($this->container);
            $admin->register();
        }
    }

    public function registerMetaKeys(): void
    {
        $postTypes = get_post_types(['public' => true], 'names');
        foreach ($postTypes as $pt) {
            if ($pt === 'attachment') {
                continue;
            }

            register_post_meta($pt, MetaKeys::FOCUS_KEYWORD, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => true,
                'sanitize_callback' => 'sanitize_text_field',
                'auth_callback'     => static function (bool $allowed, string $metaKey, int $postId): bool {
                    return current_user_can('edit_post', $postId);
                },
            ]);

            // Cache keys (not exposed in REST; updated by us).
            register_post_meta($pt, MetaKeys::ANALYSIS_SCORE, [
                'type'              => 'integer',
                'single'            => true,
                'show_in_rest'      => false,
                'sanitize_callback' => static function ($v): int {
                    return max(0, min(100, (int) $v));
                },
                'auth_callback'     => static function (): bool {
                    return current_user_can('edit_posts');
                },
            ]);

            register_post_meta($pt, MetaKeys::ANALYSIS_JSON, [
                'type'              => 'string',
                'single'            => true,
                'show_in_rest'      => false,
                'sanitize_callback' => static function ($v): string {
                    return is_string($v) ? $v : '';
                },
                'auth_callback'     => static function (): bool {
                    return current_user_can('edit_posts');
                },
            ]);

            register_post_meta($pt, MetaKeys::ANALYSIS_UPDATED_AT, [
                'type'              => 'integer',
                'single'            => true,
                'show_in_rest'      => false,
                'sanitize_callback' => static function ($v): int {
                    return max(0, (int) $v);
                },
                'auth_callback'     => static function (): bool {
                    return current_user_can('edit_posts');
                },
            ]);
        }
    }

    public function registerRestRoutes(): void
    {
        register_rest_route('wpsn/v1', '/analysis', [
            'methods'             => 'POST',
            'permission_callback' => function (\WP_REST_Request $request): bool {
                $postId = (int) $request->get_param('post_id');
                return $postId > 0 && current_user_can('edit_post', $postId);
            },
            'callback'            => function (\WP_REST_Request $request) {
                $postId = (int) $request->get_param('post_id');

                $rawTitle = $request->get_param('title');
                $rawContent = $request->get_param('content');
                $rawExcerpt = $request->get_param('excerpt');

                $title = is_string($rawTitle) ? $rawTitle : '';
                $content = is_string($rawContent) ? $rawContent : '';
                $excerpt = is_string($rawExcerpt) ? $rawExcerpt : '';

                $focus = $request->get_param('focus_keyword');
                $focus = is_string($focus) ? $focus : '';

                // Allow passing meta overrides (for unsaved editor state).
                $metaIn = $request->get_param('meta');
                $metaIn = is_array($metaIn) ? $metaIn : [];
                $allowed = [
                    MetaKeys::TITLE,
                    MetaKeys::DESCRIPTION,
                    MetaKeys::ROBOTS_INDEX,
                    MetaKeys::ROBOTS_FOLLOW,
                    MetaKeys::OG_IMAGE,
                ];
                $overrides = [];
                foreach ($allowed as $key) {
                    if (array_key_exists($key, $metaIn)) {
                        $v = $metaIn[$key];
                        $overrides[$key] = is_scalar($v) ? (string) $v : '';
                    }
                }

                $analysis = $this->analyzePost($postId, [
                    'title' => $title,
                    'content' => $content,
                    'excerpt' => $excerpt,
                    'focus_keyword' => $focus,
                    'meta_overrides' => $overrides,
                ]);

                // Optional persist.
                $persist = (bool) $request->get_param('persist');
                if ($persist) {
                    $this->persistAnalysis($postId, $analysis);
                }

                return rest_ensure_response($analysis);
            },
            'args'                => [
                'post_id' => ['type' => 'integer', 'required' => true],
                'title' => ['type' => 'string', 'required' => false],
                'content' => ['type' => 'string', 'required' => false],
                'excerpt' => ['type' => 'string', 'required' => false],
                'focus_keyword' => ['type' => 'string', 'required' => false],
                'meta' => ['type' => 'object', 'required' => false],
                'persist' => ['type' => 'boolean', 'required' => false],
            ],
        ]);
    }

    public function handleSavePost(int $postId, \WP_Post $post): void
    {
        if (wp_is_post_autosave($postId) || wp_is_post_revision($postId)) {
            return;
        }

        if (!in_array($post->post_status, ['publish', 'draft', 'pending', 'future', 'private'], true)) {
            return;
        }

        // Avoid running on non-public types like attachment.
        if ($post->post_type === 'attachment') {
            return;
        }

        $analysis = $this->analyzePost($postId, []);
        $this->persistAnalysis($postId, $analysis);
    }

    public function handleMetaUpdated(int $objectId, string $type = 'post'): void
    {
        if ($type !== 'post') {
            return;
        }
        $postId = (int) $objectId;
        if ($postId <= 0) {
            return;
        }
        $post = get_post($postId);
        if (!$post || $post->post_type === 'attachment') {
            return;
        }

        $analysis = $this->analyzePost($postId, []);
        $this->persistAnalysis($postId, $analysis);
    }

    public function registerAdminColumns(): void
    {
        if (!is_admin()) {
            return;
        }

        $postTypes = get_post_types(['public' => true], 'names');
        foreach ($postTypes as $pt) {
            if ($pt === 'attachment') {
                continue;
            }

            add_filter('manage_' . $pt . '_posts_columns', function (array $cols) {
                // Place after title.
                $out = [];
                foreach ($cols as $k => $v) {
                    $out[$k] = $v;
                    if ($k === 'title') {
                        $out['wpsn_seo_score'] = __('SEO score', 'wp-seo-next');
                    }
                }
                if (!isset($out['wpsn_seo_score'])) {
                    $out['wpsn_seo_score'] = __('SEO score', 'wp-seo-next');
                }
                return $out;
            });

            add_action('manage_' . $pt . '_posts_custom_column', function (string $column, int $postId) {
                if ($column !== 'wpsn_seo_score') {
                    return;
                }

                $score = get_post_meta($postId, MetaKeys::ANALYSIS_SCORE, true);
                $score = is_numeric($score) ? (int) $score : null;
                if ($score === null) {
                    echo '<span style="opacity:.65">—</span>';
                    return;
                }

                $badgeStyle = 'display:inline-block;min-width:36px;text-align:center;padding:2px 8px;border-radius:999px;font-weight:600;font-size:12px;border:1px solid rgba(0,0,0,.12);';
                if ($score >= 80) {
                    $badgeStyle .= 'border-color:rgba(0,128,0,.25);';
                } elseif ($score >= 55) {
                    $badgeStyle .= 'border-color:rgba(230,150,0,.35);';
                } else {
                    $badgeStyle .= 'border-color:rgba(200,0,0,.35);';
                }
                echo '<span style="' . esc_attr($badgeStyle) . '">' . esc_html((string) $score) . '</span>';
            }, 10, 2);
        }
    }

    /**
     * Analyze a post and return a normalized report.
     *
     * @param array{title?:string, content?:string, excerpt?:string, focus_keyword?:string, meta_overrides?:array<string,string>} $args
     * @return array<string,mixed>
     */
    private function analyzePost(int $postId, array $args): array
    {
        $post = get_post($postId);
        if (!$post) {
            return [
                'score' => 0,
                'status' => 'bad',
                'checks' => [],
                'stats' => [],
            ];
        }

        $focus = isset($args['focus_keyword']) ? (string) $args['focus_keyword'] : '';
        $focus = trim($focus);
        if ($focus === '') {
            $stored = get_post_meta($postId, MetaKeys::FOCUS_KEYWORD, true);
            $focus = is_string($stored) ? trim($stored) : '';
        }

        $overrides = isset($args['meta_overrides']) && is_array($args['meta_overrides']) ? $args['meta_overrides'] : [];

        // Resolve SEO meta using repository, so analysis matches front-end output.
        /** @var SeoMetaRepository $repo */
        $repo = $this->container->get('meta_repo');
        $ctx = new SeoDocumentContext(
            SeoDocumentContext::TYPE_SINGULAR,
            $postId,
            $post->post_type,
            get_permalink($postId) ?: home_url('/'),
            1
        );
        $meta = $repo->forContextWithOverrides($ctx, $overrides);

        // Build HTML from provided content (editor state) or from DB.
        $contentRaw = isset($args['content']) ? (string) $args['content'] : '';
        $html = '';
        if (trim($contentRaw) !== '') {
            $html = $this->renderContentToHtml($postId, $contentRaw);
        } else {
            $html = $this->extractor->getPostHtml($postId, ['context' => 'analysis']);
        }

        $text = $this->extractor->extractPlainText($html);

        // Let integrations contribute extra analysis text (custom fields, builders, etc.).
        $extra = apply_filters('wpsn/content/extra_text', '', $postId, 'analysis');
        if (is_array($extra)) {
            $extra = implode("\n", array_map('strval', $extra));
        }
        if (is_string($extra) && trim($extra) !== '') {
            $text = trim($text . "\n" . $this->extractor->extractPlainText($extra));
        }
        $wordCount = $this->wordCount($text);
        $firstText = $this->firstChars($text, 220);

        $h2Count = preg_match_all('/<h2\b/i', $html) ?: 0;
        $h3Count = preg_match_all('/<h3\b/i', $html) ?: 0;

        [$imgCount, $missingAlt] = $this->imageAltStats($html);
        [$internalLinks, $externalLinks] = $this->linkStats($html);

        // Inbound internal links (best-effort estimate).
        $permalink = get_permalink($postId);
        $inboundLinks = 0;
        if (is_string($permalink) && $permalink !== '') {
            $inboundLinks = $this->linkEstimator->estimateInbound($postId, $permalink);
        }

        $titleLen = $this->mbLen($meta->title);
        $descLen  = $this->mbLen($meta->description);

        $checks = [];
        $passed = 0;
        $total = 0;

        $add = function (string $id, string $label, string $status, string $message = '', $value = null) use (&$checks, &$passed, &$total): void {
            $checks[] = [
                'id' => $id,
                'label' => $label,
                'status' => $status,
                'message' => $message,
                'value' => $value,
            ];
            if (in_array($status, ['good', 'bad', 'warn'], true)) {
                $total++;
            }
            if ($status === 'good') {
                $passed++;
            }
        };

        // Title length.
        if ($titleLen === 0) {
            $add('title_missing', __('SEO title', 'wp-seo-next'), 'bad', __('Missing SEO title.', 'wp-seo-next'), $titleLen);
        } elseif ($titleLen < 20) {
            $add('title_too_short', __('SEO title length', 'wp-seo-next'), 'warn', __('Title is quite short.', 'wp-seo-next'), $titleLen);
        } elseif ($titleLen > 60) {
            $add('title_too_long', __('SEO title length', 'wp-seo-next'), 'warn', __('Title is too long (may truncate).', 'wp-seo-next'), $titleLen);
        } else {
            $add('title_ok', __('SEO title length', 'wp-seo-next'), 'good', __('Looks good.', 'wp-seo-next'), $titleLen);
        }

        // Description length.
        if ($descLen === 0) {
            $add('desc_missing', __('Meta description', 'wp-seo-next'), 'warn', __('Missing meta description.', 'wp-seo-next'), $descLen);
        } elseif ($descLen < 50) {
            $add('desc_short', __('Meta description length', 'wp-seo-next'), 'warn', __('Description is short.', 'wp-seo-next'), $descLen);
        } elseif ($descLen > 160) {
            $add('desc_long', __('Meta description length', 'wp-seo-next'), 'warn', __('Description is long (may truncate).', 'wp-seo-next'), $descLen);
        } else {
            $add('desc_ok', __('Meta description length', 'wp-seo-next'), 'good', __('Looks good.', 'wp-seo-next'), $descLen);
        }

        // Word count.
        if ($wordCount === 0) {
            $add('content_missing', __('Content length', 'wp-seo-next'), 'bad', __('No readable content found.', 'wp-seo-next'), $wordCount);
        } elseif ($wordCount < 150) {
            $add('content_short', __('Content length', 'wp-seo-next'), 'warn', __('Content is very short.', 'wp-seo-next'), $wordCount);
        } elseif ($wordCount < 300) {
            $add('content_medium', __('Content length', 'wp-seo-next'), 'warn', __('Content could be longer for competitive queries.', 'wp-seo-next'), $wordCount);
        } else {
            $add('content_ok', __('Content length', 'wp-seo-next'), 'good', __('Good length.', 'wp-seo-next'), $wordCount);
        }

        // Headings.
        $headingTotal = $h2Count + $h3Count;
        if ($headingTotal === 0) {
            $add('headings_missing', __('Headings (H2/H3)', 'wp-seo-next'), 'warn', __('Add H2/H3 headings to structure the page.', 'wp-seo-next'), $headingTotal);
        } else {
            $add('headings_ok', __('Headings (H2/H3)', 'wp-seo-next'), 'good', sprintf(__('Found %d headings.', 'wp-seo-next'), $headingTotal), $headingTotal);
        }

        // Outgoing internal links.
        if ($internalLinks === 0) {
            $add('out_links_missing', __('Outgoing internal links', 'wp-seo-next'), 'warn', __('Add internal links to related pages.', 'wp-seo-next'), $internalLinks);
        } else {
            $add('out_links_ok', __('Outgoing internal links', 'wp-seo-next'), 'good', sprintf(__('Found %d internal links.', 'wp-seo-next'), $internalLinks), $internalLinks);
        }

        // Inbound internal links (orphan hint).
        if ($inboundLinks === 0) {
            $add('in_links_missing', __('Inbound internal links', 'wp-seo-next'), 'warn', __('No inbound links detected (this may be an orphan page).', 'wp-seo-next'), $inboundLinks);
        } elseif ($inboundLinks === 1) {
            $add('in_links_low', __('Inbound internal links', 'wp-seo-next'), 'warn', __('Only 1 inbound link detected. Consider adding more internal links to this page.', 'wp-seo-next'), $inboundLinks);
        } else {
            $add('in_links_ok', __('Inbound internal links', 'wp-seo-next'), 'good', sprintf(__('Detected %d inbound links.', 'wp-seo-next'), $inboundLinks), $inboundLinks);
        }

        // Images alt.
        if ($imgCount === 0) {
            $add('images_none', __('Images ALT', 'wp-seo-next'), 'info', __('No images found.', 'wp-seo-next'), 0);
        } elseif ($missingAlt > 0) {
            $add('images_missing_alt', __('Images ALT', 'wp-seo-next'), 'warn', sprintf(__('Missing ALT on %d images.', 'wp-seo-next'), $missingAlt), $missingAlt);
        } else {
            $add('images_alt_ok', __('Images ALT', 'wp-seo-next'), 'good', __('All images have ALT.', 'wp-seo-next'), $imgCount);
        }

        // Social image.
        if (isset($meta->robots['noindex']) && (bool) $meta->robots['noindex']) {
            $add('noindex', __('Indexing', 'wp-seo-next'), 'warn', __('This page is set to noindex.', 'wp-seo-next'), 'noindex');
        }

        if (trim((string) $meta->ogImage) === '') {
            $add('og_image_missing', __('Social image (OG)', 'wp-seo-next'), 'warn', __('Missing OpenGraph image.', 'wp-seo-next'), '');
        } else {
            $add('og_image_ok', __('Social image (OG)', 'wp-seo-next'), 'good', __('OpenGraph image is set.', 'wp-seo-next'), $meta->ogImage);
        }

        // Focus keyword checks.
        if ($focus === '') {
            $add('focus_missing', __('Focus keyword', 'wp-seo-next'), 'info', __('Set a focus keyword to enable keyword checks.', 'wp-seo-next'), '');
        } else {
            $kw = $this->normalize($focus);
            $inTitle = $this->containsNormalized($meta->title, $kw);
            $inDesc  = $this->containsNormalized($meta->description, $kw);
            $inFirst = $this->containsNormalized($firstText, $kw);

            $add('kw_in_title', __('Keyword in title', 'wp-seo-next'), $inTitle ? 'good' : 'warn', $inTitle ? __('Found in SEO title.', 'wp-seo-next') : __('Not found in SEO title.', 'wp-seo-next'));
            $add('kw_in_desc', __('Keyword in description', 'wp-seo-next'), $inDesc ? 'good' : 'warn', $inDesc ? __('Found in meta description.', 'wp-seo-next') : __('Not found in meta description.', 'wp-seo-next'));
            $add('kw_in_intro', __('Keyword in first paragraph', 'wp-seo-next'), $inFirst ? 'good' : 'warn', $inFirst ? __('Found near the top.', 'wp-seo-next') : __('Not found near the top.', 'wp-seo-next'));
        }

        $score = 0;
        if ($total > 0) {
            $score = (int) round(($passed / $total) * 100);
        }

        $status = 'bad';
        if ($score >= 80) {
            $status = 'good';
        } elseif ($score >= 55) {
            $status = 'ok';
        }

        return [
            'score' => $score,
            'status' => $status,
            'focus_keyword' => $focus,
            'checks' => $checks,
            'stats' => [
                'word_count' => $wordCount,
                'title_length' => $titleLen,
                'description_length' => $descLen,
                'h2' => $h2Count,
                'h3' => $h3Count,
                'images' => $imgCount,
                'missing_alt' => $missingAlt,
                'internal_links' => $internalLinks,
                'external_links' => $externalLinks,
                'inbound_internal_links' => $inboundLinks,
            ],
        ];
    }

    /**
     * Persist analysis cache to postmeta.
     *
     * @param array<string,mixed> $analysis
     */
    private function persistAnalysis(int $postId, array $analysis): void
    {
        $score = isset($analysis['score']) ? (int) $analysis['score'] : 0;
        $score = max(0, min(100, $score));

        update_post_meta($postId, MetaKeys::ANALYSIS_SCORE, (string) $score);
        update_post_meta($postId, MetaKeys::ANALYSIS_JSON, wp_json_encode($analysis));
        update_post_meta($postId, MetaKeys::ANALYSIS_UPDATED_AT, (string) time());
    }

    private function renderContentToHtml(int $postId, string $contentRaw): string
    {
        $content = (string) $contentRaw;

        if (function_exists('do_blocks')) {
            $content = (string) do_blocks($content);
        }

        $content = (string) do_shortcode($content);
        $content = (string) wpautop($content);

        return (string) apply_filters('wpsn/content/html', $content, $postId, 'analysis');
    }

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

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

        // Unicode-aware tokenization.
        $parts = preg_split('/\s+/u', $text) ?: [];
        $count = 0;
        foreach ($parts as $p) {
            $p = trim($p);
            if ($p === '') {
                continue;
            }
            // Count only tokens that contain at least one letter or digit.
            if (preg_match('/[\p{L}\p{N}]/u', $p)) {
                $count++;
            }
        }
        return $count;
    }

    /**
     * @return array{0:int,1:int} [images, missing_alt]
     */
    private function imageAltStats(string $html): array
    {
        $html = (string) $html;
        if (trim($html) === '') {
            return [0, 0];
        }

        $count = 0;
        $missing = 0;

        if (preg_match_all('/<img\b[^>]*>/i', $html, $m)) {
            foreach ($m[0] as $tag) {
                $count++;
                // Has alt attribute?
                if (!preg_match('/\salt\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', $tag, $am)) {
                    $missing++;
                    continue;
                }
                $alt = $am[1] ?? '';
                $alt = trim($alt, "\"' ");
                if ($alt === '') {
                    $missing++;
                }
            }
        }

        return [$count, $missing];
    }

    /**
     * @return array{0:int,1:int} [internal, external]
     */
    private function linkStats(string $html): array
    {
        $html = (string) $html;
        if (trim($html) === '') {
            return [0, 0];
        }

        $internal = 0;
        $external = 0;

        $home = home_url('/');
        $homeHost = (string) wp_parse_url($home, PHP_URL_HOST);

        if (preg_match_all('/<a\b[^>]*\shref\s*=\s*("[^"]+"|\'[^\']+\'|[^\s>]+)/i', $html, $m)) {
            foreach ($m[1] as $hrefRaw) {
                $href = trim($hrefRaw, "\"' ");
                if ($href === '' || strpos($href, '#') === 0 || strpos($href, 'mailto:') === 0 || strpos($href, 'tel:') === 0) {
                    continue;
                }

                $host = (string) wp_parse_url($href, PHP_URL_HOST);
                if ($host === '') {
                    // Relative URL.
                    $internal++;
                    continue;
                }

                if ($homeHost !== '' && $host === $homeHost) {
                    $internal++;
                } else {
                    $external++;
                }
            }
        }

        return [$internal, $external];
    }

    private function firstChars(string $text, int $max): string
    {
        $text = trim((string) $text);
        if ($text === '') {
            return '';
        }
        $max = max(10, $max);
        if (function_exists('mb_substr')) {
            return (string) mb_substr($text, 0, $max);
        }
        return substr($text, 0, $max);
    }

    private function normalize(string $s): string
    {
        $s = (string) $s;
        $s = html_entity_decode($s, ENT_QUOTES | ENT_HTML5, get_bloginfo('charset'));
        $s = wp_strip_all_tags($s);
        $s = trim($s);
        $s = function_exists('mb_strtolower') ? (string) mb_strtolower($s) : strtolower($s);
        $s = preg_replace('/\s+/u', ' ', $s) ?? $s;
        return trim($s);
    }

    private function containsNormalized(string $haystack, string $needleNormalized): bool
    {
        $hay = $this->normalize($haystack);
        if ($needleNormalized === '') {
            return false;
        }
        return strpos($hay, $needleNormalized) !== false;
    }
}
