<?php
declare(strict_types=1);

namespace Help4BuilderSuite\Modules\SEO;

use Help4BuilderSuite\Support\AdminRequest;
use WP_Error;
use WP_HTML_Tag_Processor;
use WP_Post;

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

final class LinkHealthCrawler
{
    private const STATE_OPTION = 'h4bs_seo_link_health_state';
    private const QUEUE_OPTION = 'h4bs_seo_link_health_queue';
    private const RESULTS_OPTION = 'h4bs_seo_link_health_results';
    private const CRON_HOOK = 'h4bs_seo_link_health_batch';
    private const MAX_POSTS = 500;
    private const MAX_URLS = 2000;
    private const BATCH_SIZE = 12;
    private const MAX_ATTEMPTS = 2;

    public function boot(): void
    {
        add_action('admin_post_h4bs_seo_link_health_start', [$this, 'handleStart']);
        add_action('admin_post_h4bs_seo_link_health_batch', [$this, 'handleBatch']);
        add_action(self::CRON_HOOK, [$this, 'processBatch']);
    }

    public function handleStart(): void
    {
        $this->assertAdminRequest('h4bs_seo_link_health_start');
        $state = $this->startScan();
        $this->redirect([
            'h4bs_link_scan_started' => 1,
            'h4bs_link_scan_queued' => absint($state['queued'] ?? 0),
        ]);
    }

    public function handleBatch(): void
    {
        $this->assertAdminRequest('h4bs_seo_link_health_batch');
        $state = $this->processBatch();
        $this->redirect([
            'h4bs_link_scan_checked' => absint($state['last_batch_checked'] ?? 0),
        ]);
    }

    /**
     * @return array<string, mixed>
     */
    public function startScan(): array
    {
        $postTypes = get_post_types(['public' => true], 'names');
        unset($postTypes['attachment']);
        $posts = get_posts([
            'post_type' => array_values($postTypes),
            'post_status' => 'publish',
            'posts_per_page' => self::MAX_POSTS,
            'orderby' => 'ID',
            'order' => 'ASC',
            'no_found_rows' => true,
        ]);
        $postIds = array_map(static fn ($post): int => $post instanceof WP_Post ? $post->ID : 0, $posts);
        $postIds = array_values(array_filter(array_map('absint', $postIds)));
        /**
         * Filters the content IDs included in a bounded outbound-link scan.
         *
         * @param array<int, int> $postIds Published content IDs.
         */
        $postIds = apply_filters('h4bs_seo_link_health_post_ids', $postIds);
        $postIds = is_array($postIds) ? array_slice(array_values(array_unique(array_map('absint', $postIds))), 0, self::MAX_POSTS) : [];

        $urls = [];
        foreach ($postIds as $postId) {
            $post = get_post($postId);
            if (! $post instanceof WP_Post || (string) get_post_meta($postId, '_h4_seo_noindex', true) === '1') {
                continue;
            }
            $source = [
                'post_id' => $postId,
                'title' => get_the_title($post),
                'edit_url' => get_edit_post_link($postId, ''),
            ];
            foreach ($this->extractHtmlLinks($post->post_content . "\n" . $post->post_excerpt) as $url) {
                $this->addCandidate($urls, $url, $source);
            }
            $layout = get_post_meta($postId, '_h4_builder_layout', true);
            if (is_string($layout) && trim($layout) !== '') {
                $decoded = json_decode($layout, true);
                if (! is_array($decoded)) {
                    $decoded = json_decode(wp_unslash($layout), true);
                }
                if (is_array($decoded)) {
                    foreach ($this->extractBuilderLinks($decoded) as $url) {
                        $this->addCandidate($urls, $url, $source);
                    }
                }
            }
            if (count($urls) >= self::MAX_URLS) {
                break;
            }
        }
        $urls = array_slice($urls, 0, self::MAX_URLS, true);
        /**
         * Filters normalized external URLs before a link-health scan starts.
         *
         * @param array<string, array<string, mixed>> $urls URL-keyed queue entries.
         */
        $filtered = apply_filters('h4bs_seo_link_health_urls', $urls);
        $urls = is_array($filtered) ? array_slice($filtered, 0, self::MAX_URLS, true) : $urls;

        $queue = [];
        foreach ($urls as $url => $entry) {
            $normalized = $this->normalizeExternalUrl((string) $url);
            if ($normalized === '') {
                continue;
            }
            $queue[$normalized] = [
                'url' => $normalized,
                'sources' => is_array($entry['sources'] ?? null) ? array_slice($entry['sources'], 0, 8) : [],
                'attempts' => 0,
                'next_attempt' => 0,
            ];
        }
        $state = [
            'status' => $queue === [] ? 'complete' : 'queued',
            'started_at' => gmdate('c'),
            'completed_at' => $queue === [] ? gmdate('c') : '',
            'posts_scanned' => count($postIds),
            'queued' => count($queue),
            'remaining' => count($queue),
            'checked' => 0,
            'last_batch_checked' => 0,
            'counts' => [],
        ];
        update_option(self::QUEUE_OPTION, $queue, false);
        update_option(self::RESULTS_OPTION, [], false);
        update_option(self::STATE_OPTION, $state, false);
        wp_clear_scheduled_hook(self::CRON_HOOK);
        if ($queue !== []) {
            wp_schedule_single_event(time() + 5, self::CRON_HOOK);
        }
        return $state;
    }

    /**
     * @return array<string, mixed>
     */
    public function processBatch(): array
    {
        if (get_transient('h4bs_seo_link_health_lock')) {
            return $this->state();
        }
        set_transient('h4bs_seo_link_health_lock', '1', 5 * MINUTE_IN_SECONDS);
        try {
            $queue = $this->queue();
            $results = $this->results();
            $state = $this->state();
            $state['status'] = $queue === [] ? 'complete' : 'running';
            $checked = 0;
            $now = time();

            foreach ($queue as $url => $entry) {
                if ($checked >= self::BATCH_SIZE) {
                    break;
                }
                if (absint($entry['next_attempt'] ?? 0) > $now) {
                    continue;
                }
                unset($queue[$url]);
                $entry['attempts'] = absint($entry['attempts'] ?? 0) + 1;
                $result = $this->checkUrl((string) $url);
                $result['url'] = (string) $url;
                $result['sources'] = is_array($entry['sources'] ?? null) ? $entry['sources'] : [];
                $result['attempts'] = $entry['attempts'];
                $result['checked_at'] = gmdate('c');

                /**
                 * Filters a single outbound-link check result.
                 *
                 * @param array<string, mixed> $result Result.
                 * @param array<string, mixed> $entry Queue entry.
                 */
                $filtered = apply_filters('h4bs_seo_link_health_result', $result, $entry);
                $result = is_array($filtered) ? $filtered : $result;
                $results[$url] = $result;
                $checked++;

                if ($this->shouldRetry($result) && $entry['attempts'] < self::MAX_ATTEMPTS) {
                    $entry['next_attempt'] = time() + (5 * MINUTE_IN_SECONDS * $entry['attempts']);
                    $queue[$url] = $entry;
                }
            }

            $state['last_batch_checked'] = $checked;
            $state['checked'] = count($results);
            $state['remaining'] = count($queue);
            $state['last_batch_at'] = gmdate('c');
            $state['counts'] = $this->resultCounts($results);
            if ($queue === []) {
                $state['status'] = 'complete';
                $state['completed_at'] = gmdate('c');
            } else {
                $state['status'] = 'queued';
                $this->scheduleNextBatch($queue);
            }
            update_option(self::QUEUE_OPTION, $queue, false);
            update_option(self::RESULTS_OPTION, array_slice($results, -self::MAX_URLS, null, true), false);
            update_option(self::STATE_OPTION, $state, false);
            return $state;
        } finally {
            delete_transient('h4bs_seo_link_health_lock');
        }
    }

    /** @return array<string, mixed> */
    public function state(): array
    {
        $state = get_option(self::STATE_OPTION, []);
        return is_array($state) ? $state : [];
    }

    /** @return array<string, array<string, mixed>> */
    public function results(): array
    {
        $results = get_option(self::RESULTS_OPTION, []);
        return is_array($results) ? $results : [];
    }

    /**
     * @param array<string, mixed> $layout
     * @return array<int, string>
     */
    public function extractBuilderLinks(array $layout): array
    {
        $urls = [];
        $walk = function (mixed $value, string $key = '') use (&$walk, &$urls): void {
            if (is_array($value)) {
                foreach ($value as $childKey => $child) {
                    $walk($child, is_string($childKey) ? strtolower($childKey) : $key);
                }
                return;
            }
            if (! is_scalar($value)) {
                return;
            }
            $text = trim((string) $value);
            if ($text === '') {
                return;
            }
            if (str_contains($key, 'html') || str_contains($key, 'content') || str_contains($text, '<a ')) {
                $urls = array_merge($urls, $this->extractHtmlLinks($text));
            }
            if (
                str_contains($key, 'url')
                || str_contains($key, 'href')
                || str_contains($key, 'link')
                || str_contains($key, 'action')
            ) {
                if (preg_match_all('~https?://[^\s"\'<>]+~i', $text, $matches)) {
                    $urls = array_merge($urls, $matches[0]);
                }
            }
        };
        $walk($layout);
        return array_values(array_unique(array_map('strval', $urls)));
    }

    public function renderPanel(): void
    {
        $state = $this->state();
        $results = $this->results();
        $counts = is_array($state['counts'] ?? null) ? $state['counts'] : [];
        $started = AdminRequest::queryAbsInt('h4bs_link_scan_started');
        $queued = AdminRequest::queryAbsInt('h4bs_link_scan_queued');
        $checked = AdminRequest::queryAbsInt('h4bs_link_scan_checked');
        $issues = array_values(array_filter($results, static function (array $result): bool {
            return ! in_array((string) ($result['classification'] ?? ''), ['healthy', 'redirect', 'restricted'], true);
        }));
        usort($issues, static fn (array $a, array $b): int => strcmp((string) ($a['classification'] ?? ''), (string) ($b['classification'] ?? '')));
        ?>
        <hr style="margin:2rem 0;">
        <h2><?php esc_html_e('Outbound Link Health', 'help4-builder-suite'); ?></h2>
        <p><?php esc_html_e('Check external links found in published content and Builder layouts. The scan is administrator-triggered, SSRF-safe, capped at 500 content items and 2,000 unique URLs, and runs in small resumable batches.', 'help4-builder-suite'); ?></p>
        <?php if ($started > 0) : ?>
            <div class="notice notice-info inline"><p><?php echo esc_html(sprintf(
                /* translators: %d: Number of external URLs queued. */
                __('Outbound-link scan started with %d unique URL(s).', 'help4-builder-suite'),
                $queued
            )); ?></p></div>
        <?php endif; ?>
        <?php if ($checked > 0) : ?>
            <div class="notice notice-success inline"><p><?php echo esc_html(sprintf(
                /* translators: %d: Number of URLs checked. */
                __('Checked %d URL(s) in the manual batch.', 'help4-builder-suite'),
                $checked
            )); ?></p></div>
        <?php endif; ?>
        <div style="display:flex;flex-wrap:wrap;gap:.75rem;margin:0 0 1rem;">
            <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                <?php wp_nonce_field('h4bs_seo_link_health_start', 'h4bs_seo_link_health_nonce'); ?>
                <input type="hidden" name="action" value="h4bs_seo_link_health_start">
                <button type="submit" class="button button-primary"><?php esc_html_e('Start New Link Scan', 'help4-builder-suite'); ?></button>
            </form>
            <?php if (absint($state['remaining'] ?? 0) > 0) : ?>
                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                    <?php wp_nonce_field('h4bs_seo_link_health_batch', 'h4bs_seo_link_health_nonce'); ?>
                    <input type="hidden" name="action" value="h4bs_seo_link_health_batch">
                    <button type="submit" class="button"><?php esc_html_e('Check Next Batch Now', 'help4-builder-suite'); ?></button>
                </form>
            <?php endif; ?>
        </div>
        <div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:1rem;margin:1rem 0 1.5rem;">
            <?php foreach ([
                'checked' => __('checked', 'help4-builder-suite'),
                'remaining' => __('remaining', 'help4-builder-suite'),
                'healthy' => __('healthy', 'help4-builder-suite'),
                'redirect' => __('redirects', 'help4-builder-suite'),
                'broken' => __('broken', 'help4-builder-suite'),
                'server_error' => __('server errors', 'help4-builder-suite'),
                'request_error' => __('request errors', 'help4-builder-suite'),
            ] as $key => $label) : ?>
                <?php $value = in_array($key, ['checked', 'remaining'], true) ? absint($state[$key] ?? 0) : absint($counts[$key] ?? 0); ?>
                <div style="border:1px solid #dcdcde;background:#fff;border-radius:8px;padding:1rem;"><strong style="display:block;font-size:1.4rem;"><?php echo esc_html((string) $value); ?></strong><span><?php echo esc_html($label); ?></span></div>
            <?php endforeach; ?>
        </div>
        <?php if ($issues !== []) : ?>
            <h3><?php esc_html_e('Links Needing Review', 'help4-builder-suite'); ?></h3>
            <table class="widefat striped" style="margin-bottom:1.5rem;">
                <thead><tr><th><?php esc_html_e('URL', 'help4-builder-suite'); ?></th><th><?php esc_html_e('Result', 'help4-builder-suite'); ?></th><th><?php esc_html_e('Found on', 'help4-builder-suite'); ?></th><th><?php esc_html_e('Checked', 'help4-builder-suite'); ?></th></tr></thead>
                <tbody>
                <?php foreach (array_slice($issues, 0, 50) as $result) : ?>
                    <?php $sources = is_array($result['sources'] ?? null) ? $result['sources'] : []; ?>
                    <tr>
                        <td><a href="<?php echo esc_url((string) ($result['url'] ?? '')); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html((string) ($result['url'] ?? '')); ?></a></td>
                        <td><strong><?php echo esc_html((string) ($result['classification'] ?? '')); ?></strong><br><span class="description"><?php echo esc_html((string) ($result['http_status'] ?? 0)); ?> <?php echo esc_html((string) ($result['message'] ?? '')); ?></span></td>
                        <td>
                            <?php foreach (array_slice($sources, 0, 3) as $source) : ?>
                                <a href="<?php echo esc_url((string) ($source['edit_url'] ?? '')); ?>"><?php echo esc_html((string) ($source['title'] ?? '')); ?></a><br>
                            <?php endforeach; ?>
                        </td>
                        <td><?php echo esc_html((string) ($result['checked_at'] ?? '')); ?></td>
                    </tr>
                <?php endforeach; ?>
                </tbody>
            </table>
        <?php elseif ($results !== []) : ?>
            <div class="notice notice-success inline"><p><?php esc_html_e('No broken or failed outbound links were found in the completed checks.', 'help4-builder-suite'); ?></p></div>
        <?php endif;
    }

    /** @return array<int, string> */
    private function extractHtmlLinks(string $html): array
    {
        if ($html === '') {
            return [];
        }
        $urls = [];
        $processor = new WP_HTML_Tag_Processor($html);
        while ($processor->next_tag('a')) {
            $href = $processor->get_attribute('href');
            if (is_string($href) && trim($href) !== '') {
                $urls[] = html_entity_decode(trim($href), ENT_QUOTES | ENT_HTML5, 'UTF-8');
            }
        }
        return array_values(array_unique($urls));
    }

    /**
     * @param array<string, array<string, mixed>> $urls
     * @param array<string, mixed> $source
     */
    private function addCandidate(array &$urls, string $candidate, array $source): void
    {
        if (count($urls) >= self::MAX_URLS) {
            return;
        }
        $url = $this->normalizeExternalUrl($candidate);
        if ($url === '') {
            return;
        }
        if (! isset($urls[$url])) {
            $urls[$url] = ['url' => $url, 'sources' => []];
        }
        $sourceIds = array_map(static fn (array $item): int => absint($item['post_id'] ?? 0), $urls[$url]['sources']);
        if (! in_array(absint($source['post_id'] ?? 0), $sourceIds, true) && count($urls[$url]['sources']) < 8) {
            $urls[$url]['sources'][] = $source;
        }
    }

    private function normalizeExternalUrl(string $candidate): string
    {
        $candidate = trim($candidate);
        if (str_starts_with($candidate, '//')) {
            $candidate = (string) wp_parse_url(home_url('/'), PHP_URL_SCHEME) . ':' . $candidate;
        }
        $url = esc_url_raw($candidate, ['http', 'https']);
        if ($url === '') {
            return '';
        }
        $host = strtolower((string) wp_parse_url($url, PHP_URL_HOST));
        $homeHost = strtolower((string) wp_parse_url(home_url('/'), PHP_URL_HOST));
        if ($host === '' || $this->comparableHost($host) === $this->comparableHost($homeHost)) {
            return '';
        }
        $parts = wp_parse_url($url);
        if (! is_array($parts)) {
            return '';
        }
        unset($parts['fragment']);
        $scheme = strtolower((string) ($parts['scheme'] ?? ''));
        if (! in_array($scheme, ['http', 'https'], true)) {
            return '';
        }
        $normalized = $scheme . '://' . strtolower((string) $parts['host']);
        if (isset($parts['port'])) {
            $normalized .= ':' . absint($parts['port']);
        }
        $normalized .= (string) ($parts['path'] ?? '/');
        if (isset($parts['query']) && $parts['query'] !== '') {
            $normalized .= '?' . $parts['query'];
        }
        return esc_url_raw($normalized, ['http', 'https']);
    }

    private function comparableHost(string $host): string
    {
        return (string) preg_replace('/^www\./', '', strtolower(trim($host)));
    }

    /** @return array<string, mixed> */
    private function checkUrl(string $url): array
    {
        $args = [
            'timeout' => 6,
            'redirection' => 0,
            'user-agent' => 'Help4-Link-Health/' . H4BS_VERSION . '; ' . home_url('/'),
        ];
        $response = wp_safe_remote_head($url, $args);
        $status = is_wp_error($response) ? 0 : wp_remote_retrieve_response_code($response);
        if (! is_wp_error($response) && in_array($status, [403, 405, 501], true)) {
            $response = wp_safe_remote_get($url, $args + ['limit_response_size' => 65536]);
            $status = is_wp_error($response) ? 0 : wp_remote_retrieve_response_code($response);
        }
        if ($response instanceof WP_Error) {
            return [
                'classification' => 'request_error',
                'http_status' => 0,
                'message' => sanitize_text_field($response->get_error_message()),
            ];
        }
        $classification = match (true) {
            $status >= 200 && $status < 300 => 'healthy',
            $status >= 300 && $status < 400 => 'redirect',
            in_array($status, [401, 403], true) => 'restricted',
            in_array($status, [404, 410], true) => 'broken',
            $status === 429 => 'throttled',
            $status >= 500 => 'server_error',
            default => 'unexpected',
        };
        return [
            'classification' => $classification,
            'http_status' => $status,
            'message' => sanitize_text_field((string) wp_remote_retrieve_response_message($response)),
        ];
    }

    /** @param array<string, mixed> $result */
    private function shouldRetry(array $result): bool
    {
        return in_array((string) ($result['classification'] ?? ''), ['throttled', 'server_error', 'request_error'], true);
    }

    /** @return array<string, array<string, mixed>> */
    private function queue(): array
    {
        $queue = get_option(self::QUEUE_OPTION, []);
        return is_array($queue) ? $queue : [];
    }

    /** @param array<string, array<string, mixed>> $results @return array<string, int> */
    private function resultCounts(array $results): array
    {
        $counts = [];
        foreach ($results as $result) {
            $classification = sanitize_key((string) ($result['classification'] ?? 'unexpected'));
            $counts[$classification] = absint($counts[$classification] ?? 0) + 1;
        }
        return $counts;
    }

    /** @param array<string, array<string, mixed>> $queue */
    private function scheduleNextBatch(array $queue): void
    {
        if (wp_next_scheduled(self::CRON_HOOK)) {
            return;
        }
        $due = [];
        foreach ($queue as $entry) {
            $due[] = max(time() + 20, absint($entry['next_attempt'] ?? 0));
        }
        wp_schedule_single_event($due === [] ? time() + 20 : min($due), self::CRON_HOOK);
    }

    private function assertAdminRequest(string $action): void
    {
        if (! current_user_can('manage_options')) {
            wp_die(esc_html__('Unauthorized request.', 'help4-builder-suite'));
        }
        check_admin_referer($action, 'h4bs_seo_link_health_nonce');
    }

    /** @param array<string, int> $query */
    private function redirect(array $query): void
    {
        wp_safe_redirect(add_query_arg($query, admin_url('admin.php?page=h4bs-seo-control')));
        exit;
    }
}
