<?php
/**
 * Name: Broken Link Checker
 * Description: Scan post and page content for broken internal and external links with smart caching to identify 404 errors and help maintain site quality
 * Category: Utilities
 * Icon: -
 * UseCases: [
 *   {
 *     "title": "Add Useful Functionality",
 *     "description": "Extend WordPress with helpful tools that make site management easier."
 *   },
 *   {
 *     "title": "Save Time",
 *     "description": "Automate or simplify tasks that would otherwise require manual work or additional tools."
 *   }
 * ]
 * Added: 2025-07-22
 * Tags: links, broken, checker, seo, maintenance
 * WhereToFind: {
 *   "location": "Switchboard Settings",
 *   "url": "",
 *   "instructions": "Click 'Scan for Broken Links' button in module settings to check all posts and pages for dead or invalid URLs.",
 *   "newTab": false
 * }
 */

// Prevent direct access
if (!defined('ABSPATH')) exit;

class SwitchboardBrokenLinkChecker {

    private $max_execution_time = 45; // Seconds (stay under PHP timeout)
    private $start_time;
    private $checked_urls_cache = [];

    public function __construct() {
        add_action('wp_ajax_scan_broken_links', [$this, 'ajax_scan']);
    }

    public function ajax_scan() {
        // Security: Verify nonce
        check_ajax_referer('scan_broken_links', 'nonce');

        // Security: Check user permissions
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => esc_html__('Unauthorized access', 'wpswitchboard')]);
            return;
        }

        // Performance: Set time limit
        @set_time_limit($this->max_execution_time + 10); // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- Intentional, set_time_limit may be disabled
        $this->start_time = microtime(true);

        // Scan for broken links
        $result = $this->scan_all_posts();

        wp_send_json_success([
            'broken_links' => $result['broken_links'],
            'total_checked' => $result['total_urls'],
            'total_posts' => $result['total_posts'],
            'scan_time' => round($result['scan_time'], 2),
            'timeout_reached' => $result['timeout_reached']
        ]);
    }
    
    private function scan_all_posts() {
        // Performance: Query only necessary fields
        $posts = get_posts([
            'post_type' => ['post', 'page'],
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'fields' => 'all', // We need content
            'no_found_rows' => true, // Performance: Skip pagination count
            'update_post_meta_cache' => false, // Performance: Skip meta cache
            'update_post_term_cache' => false  // Performance: Skip term cache
        ]);

        $broken_links = [];
        $total_urls_checked = 0;
        $timeout_reached = false;

        foreach ($posts as $post) {
            // Performance: Check timeout
            if ($this->is_timeout_reached()) {
                $timeout_reached = true;
                break;
            }

            $links = $this->extract_links($post->post_content);

            foreach ($links as $url) {
                // Performance: Check timeout again for long scans
                if ($this->is_timeout_reached()) {
                    $timeout_reached = true;
                    break 2;
                }

                // Security & Validation: Sanitize URL
                $url = $this->sanitize_url($url);
                if (!$url) {
                    continue;
                }

                // Performance: Skip if already checked
                if (isset($this->checked_urls_cache[$url])) {
                    if (!$this->checked_urls_cache[$url]['working']) {
                        $broken_links[] = $this->create_broken_link_entry($post, $url, $this->checked_urls_cache[$url]);
                    }
                    continue;
                }

                // Check the link
                $total_urls_checked++;
                $result = $this->check_link($url);
                $this->checked_urls_cache[$url] = $result;

                if (!$result['working']) {
                    $broken_links[] = $this->create_broken_link_entry($post, $url, $result);
                }
            }
        }

        return [
            'broken_links' => $broken_links,
            'total_urls' => $total_urls_checked,
            'total_posts' => count($posts),
            'scan_time' => microtime(true) - $this->start_time,
            'timeout_reached' => $timeout_reached
        ];
    }

    private function is_timeout_reached() {
        return (microtime(true) - $this->start_time) > $this->max_execution_time;
    }

    private function create_broken_link_entry($post, $url, $result) {
        // Bug fix: Handle empty post titles
        $post_title = !empty($post->post_title) ? wp_strip_all_tags($post->post_title) : '(No Title)';

        return [
            'post_id' => absint($post->ID),
            'post_title' => $post_title,
            'edit_url' => esc_url(get_edit_post_link($post->ID)),
            'url' => esc_url($url),
            'status' => sanitize_text_field($result['error']),
            'type' => sanitize_text_field($result['type'])
        ];
    }

    private function extract_links($content) {
        if (empty($content)) {
            return [];
        }

        $links = [];

        // Security: Limit regex complexity to prevent ReDoS attacks
        $content = substr($content, 0, 500000); // Max 500KB per post

        // Extract from Classic Editor HTML
        if (preg_match_all('/<a[^>]+href=["\']([^"\']+)["\'][^>]*>/i', $content, $matches)) {
            $links = array_merge($links, $matches[1]);
        }

        // Extract from Gutenberg blocks (JSON format)
        if (preg_match_all('/"url":"([^"]+)"/i', $content, $block_matches)) {
            $links = array_merge($links, $block_matches[1]);
        }

        return array_unique($links);
    }

    private function sanitize_url($url) {
        // Trim whitespace
        $url = trim($url);

        // Bug fix: Skip anchor-only URLs and invalid schemes
        if (empty($url) ||
            $url === '#' ||
            strpos($url, '#') === 0 || // #section anchors
            strpos($url, 'javascript:') === 0 ||
            strpos($url, 'mailto:') === 0 ||
            strpos($url, 'tel:') === 0 ||
            strpos($url, 'data:') === 0 ||
            strpos($url, 'blob:') === 0 ||
            strpos($url, '{') !== false || // Skip placeholder/template URLs
            strpos($url, '{{') !== false) { // Skip Handlebars/Mustache templates
            return false;
        }

        // Bug fix: Properly decode HTML entities (may be double-encoded)
        $url = html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8');
        $url = html_entity_decode($url, ENT_QUOTES | ENT_HTML5, 'UTF-8'); // Decode twice for double-encoding

        return $url;
    }

    private function get_link_type($url) {
        $home_url = home_url();

        if (strpos($url, $home_url) !== false || strpos($url, '/') === 0) {
            return 'Internal';
        }
        return 'External';
    }

    private function check_link($url) {
        // Convert relative URLs to absolute
        if (strpos($url, '/') === 0) {
            $url = home_url($url);
        }

        // Security: Validate URL before making requests
        if (!filter_var($url, FILTER_VALIDATE_URL)) {
            return [
                'working' => false,
                'error' => 'Invalid URL format',
                'type' => 'Unknown'
            ];
        }

        // Check internal links differently (faster and more accurate)
        if (strpos($url, home_url()) !== false) {
            return $this->check_internal_link($url);
        }

        // Check external links
        return $this->check_external_link($url);
    }

    private function check_internal_link($url) {
        // Performance: Try URL to post ID first (no HTTP request)
        $post_id = url_to_postid($url);

        if ($post_id > 0) {
            // Security: Use get_post() which respects permissions
            $post = get_post($post_id);

            if (!$post) {
                return [
                    'working' => false,
                    'error' => '404 - Post not found',
                    'type' => 'Internal'
                ];
            }

            if ($post->post_status !== 'publish') {
                return [
                    'working' => false,
                    'error' => 'Post exists but not published (status: ' . esc_html($post->post_status) . ')',
                    'type' => 'Internal'
                ];
            }

            return ['working' => true, 'error' => '', 'type' => 'Internal'];
        }

        // For non-post URLs (archives, custom endpoints, etc.)
        // Security: Use sslverify and timeout
        $response = wp_remote_head($url, [
            'timeout' => 5,
            'redirection' => 2,
            'httpversion' => '1.1',
            'sslverify' => false, // Internal site, skip SSL verify for speed
            'headers' => [
                'Cache-Control' => 'no-cache'
            ]
        ]);

        if (is_wp_error($response)) {
            return [
                'working' => false,
                'error' => esc_html($response->get_error_message()),
                'type' => 'Internal'
            ];
        }

        $status_code = wp_remote_retrieve_response_code($response);

        // Bug fix: Handle empty/false status codes
        if (empty($status_code)) {
            return [
                'working' => false,
                'error' => 'No response from server',
                'type' => 'Internal'
            ];
        }

        if ($status_code >= 200 && $status_code < 400) {
            return ['working' => true, 'error' => '', 'type' => 'Internal'];
        }

        return [
            'working' => false,
            'error' => 'HTTP ' . absint($status_code),
            'type' => 'Internal'
        ];
    }

    private function check_external_link($url) {
        // Security: Block local/private IPs (SSRF protection)
        $parsed = wp_parse_url($url);
        if (isset($parsed['host'])) {
            $host = strtolower($parsed['host']);

            // Bug fix: Block localhost variants including IPv6
            $localhost_variants = ['localhost', '127.0.0.1', '0.0.0.0', '::1', '[::1]'];

            if (in_array($host, $localhost_variants) ||
                preg_match('/^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./', $host) ||
                preg_match('/^fe80::/i', $host)) { // IPv6 link-local
                return [
                    'working' => false,
                    'error' => 'Blocked - Private/local address',
                    'type' => 'External'
                ];
            }
        }

        // Performance & Security: Make request with proper headers
        $response = wp_remote_head($url, [
            'timeout' => 8,
            'redirection' => 3,
            'httpversion' => '1.1',
            'sslverify' => true,
            'user-agent' => 'Mozilla/5.0 (compatible; WordPress/Switchboard-LinkChecker/1.0)',
            'headers' => [
                'Accept' => '*/*',
                'Cache-Control' => 'no-cache'
            ]
        ]);

        if (is_wp_error($response)) {
            $error_msg = $response->get_error_message();

            // Make error messages user-friendly
            if (strpos($error_msg, 'Operation timed out') !== false) {
                $error_msg = 'Connection timeout';
            } elseif (strpos($error_msg, 'Could not resolve host') !== false) {
                $error_msg = 'Domain not found';
            }

            return [
                'working' => false,
                'error' => esc_html($error_msg),
                'type' => 'External'
            ];
        }

        $status_code = wp_remote_retrieve_response_code($response);

        // Bug fix: Handle empty/false status codes
        if (empty($status_code)) {
            return [
                'working' => false,
                'error' => 'No response from server',
                'type' => 'External'
            ];
        }

        // Success status codes
        if ($status_code >= 200 && $status_code < 400) {
            return ['working' => true, 'error' => '', 'type' => 'External'];
        }

        // User-friendly error messages
        $error_messages = [
            400 => 'HTTP 400 - Bad Request',
            401 => 'HTTP 401 - Unauthorized',
            403 => 'HTTP 403 - Forbidden (access denied)',
            404 => 'HTTP 404 - Page Not Found',
            410 => 'HTTP 410 - Gone (permanently deleted)',
            429 => 'HTTP 429 - Too Many Requests',
            500 => 'HTTP 500 - Server Error',
            502 => 'HTTP 502 - Bad Gateway',
            503 => 'HTTP 503 - Service Unavailable',
        ];

        $error = isset($error_messages[$status_code]) ? $error_messages[$status_code] : 'HTTP ' . absint($status_code);

        return [
            'working' => false,
            'error' => $error,
            'type' => 'External'
        ];
    }

    /**
     * Render settings panel
     *
     * @param array $settings Current module settings
     * @return void Outputs HTML directly
     */
    public function render_settings($settings = []) {
        // Prepare i18n strings for JavaScript
        $i18n = [
            'startScan' => __('Start Scan', 'wpswitchboard'),
            'scanning' => __('Scanning...', 'wpswitchboard'),
            'scanFailed' => __('Scan failed. Please try again.', 'wpswitchboard'),
            'scanResults' => __('Scan Results', 'wpswitchboard'),
            'postsScanned' => __('Posts Scanned', 'wpswitchboard'),
            'uniqueUrlsChecked' => __('Unique URLs Checked', 'wpswitchboard'),
            'brokenLinks' => __('Broken Links', 'wpswitchboard'),
            'scanDuration' => __('Scan Duration', 'wpswitchboard'),
            'timeoutWarning' => __('Timeout Warning:', 'wpswitchboard'),
            // translators: %s is the number of seconds the scan took
            'timeoutMessage' => __('Scan stopped after %s seconds. Some links may not have been checked. Try scanning again or reduce content.', 'wpswitchboard'),
            'excellent' => __('Excellent!', 'wpswitchboard'),
            'noBrokenLinks' => __('No broken links found in your published content.', 'wpswitchboard'),
            'actionRequired' => __('Action Required:', 'wpswitchboard'),
            // translators: %d is the number of broken links found
            'brokenLinksFound' => __('Found %d broken link(s). Click the post title to edit and fix each link.', 'wpswitchboard'),
            'postPage' => __('Post/Page', 'wpswitchboard'),
            'brokenUrl' => __('Broken URL', 'wpswitchboard'),
            'type' => __('Type', 'wpswitchboard'),
            'error' => __('Error', 'wpswitchboard'),
            'internal' => __('Internal', 'wpswitchboard'),
            'external' => __('External', 'wpswitchboard')
        ];
        ?>
        <input type="hidden" name="module" value="broken-link-checker">
        <input type="hidden" id="broken-link-checker-i18n" value="<?php echo esc_attr(wp_json_encode($i18n)); ?>">

        <div class="sb-sp-group" style="margin-bottom: 24px;">
            <h3 class="sb-sp-heading"><?php esc_html_e('Broken Link Checker', 'wpswitchboard'); ?></h3>
            <p class="sb-sp-description"><?php esc_html_e('Scan your posts and pages for broken internal and external links. Checks both Classic Editor and Gutenberg content.', 'wpswitchboard'); ?></p>
        </div>

        <div class="sb-sp-info-box sb-sp-info-box-info" style="margin-bottom: 24px;">
            <h4 class="sb-sp-heading-sm"><?php esc_html_e('How It Works', 'wpswitchboard'); ?></h4>
            <p class="sb-sp-description" style="margin: 0;">
                <?php esc_html_e('Click "Start Scan" to check all published posts and pages for broken links. The scanner will test both internal and external URLs and report any that return error responses (404, 500, etc.).', 'wpswitchboard'); ?>
            </p>
        </div>

        <div class="sb-sp-button-group" style="justify-content: flex-start; margin-bottom: 24px;">
            <button type="button" id="scan-links" class="button button-primary button-small">
                <span class="dashicons dashicons-search" style="margin-top: 3px;"></span> <?php esc_html_e('Start Scan', 'wpswitchboard'); ?>
            </button>
        </div>

        <div id="scan-progress" style="display: none; margin-bottom: 24px;">
            <div style="background: #f3f4f6; border-radius: 8px; height: 8px; overflow: hidden; margin-bottom: 12px;">
                <div id="progress-bar" style="width: 0%; background: var(--primary-500); height: 100%; transition: width 0.3s ease; border-radius: 8px;"></div>
            </div>
            <p class="sb-sp-description" style="margin: 0; font-size: 13px; color: #64748b;">
                <?php esc_html_e('Scanning...', 'wpswitchboard'); ?> <span id="scan-status"></span>
            </p>
        </div>

        <div id="scan-results"></div>
            
            <script>
            jQuery(document).ready(function($) {
                const i18n = JSON.parse($('#broken-link-checker-i18n').val());

                $('#scan-links').on('click', function() {
                    const button = $(this);
                    button.prop('disabled', true).html('<span class="dashicons dashicons-update" style="animation: spin 1s linear infinite; margin-top: 3px;"></span> ' + i18n.scanning);
                    $('#scan-progress').show();
                    $('#scan-results').html('');

                    $.ajax({
                        url: ajaxurl,
                        type: 'POST',
                        data: {
                            action: 'scan_broken_links',
                            nonce: '<?php echo esc_attr(wp_create_nonce('scan_broken_links')); ?>'
                        },
                        success: function(response) {
                            if (response.success) {
                                displayResults(response.data);
                            } else {
                                $('#scan-results').html('<div class="sb-sp-alert sb-sp-alert-error"><p>' + response.data + '</p></div>');
                            }
                        },
                        error: function() {
                            $('#scan-results').html('<div class="sb-sp-alert sb-sp-alert-error"><p>' + i18n.scanFailed + '</p></div>');
                        },
                        complete: function() {
                            button.prop('disabled', false).html('<span class="dashicons dashicons-search" style="margin-top: 3px;"></span> ' + i18n.startScan);
                            $('#scan-progress').hide();
                        }
                    });
                });

                function displayResults(data) {
                    let html = '<div class="sb-sp-card sb-sp-p-4" style="margin-top: 24px;">';
                    html += '<div class="sb-sp-flex sb-sp-items-center sb-sp-justify-between sb-sp-mb-4">';
                    html += '<h3 class="sb-sp-heading">' + i18n.scanResults + '</h3>';
                    html += '<span class="sb-sp-badge sb-sp-badge-secondary">' + data.scan_time + 's</span>';
                    html += '</div>';

                    // Scan statistics
                    html += '<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px;">';
                    html += '<div class="sb-sp-card" style="padding: 16px; text-align: center; box-shadow: none !important; border: 1px solid #e5e7eb;">';
                    html += '<div style="font-size: 28px; font-weight: 700; color: var(--primary-500); margin-bottom: 4px;">' + data.total_posts + '</div>';
                    html += '<div class="sb-sp-text-xs" style="color: #64748b;">' + i18n.postsScanned + '</div>';
                    html += '</div>';
                    html += '<div class="sb-sp-card" style="padding: 16px; text-align: center; box-shadow: none !important; border: 1px solid #e5e7eb;">';
                    html += '<div style="font-size: 28px; font-weight: 700; color: #f59e0b; margin-bottom: 4px;">' + data.total_checked + '</div>';
                    html += '<div class="sb-sp-text-xs" style="color: #64748b;">' + i18n.uniqueUrlsChecked + '</div>';
                    html += '</div>';
                    html += '<div class="sb-sp-card" style="padding: 16px; text-align: center; box-shadow: none !important; border: 1px solid ' + (data.broken_links.length > 0 ? '#ef4444' : 'var(--primary-500)') + ';">';
                    html += '<div style="font-size: 28px; font-weight: 700; color: ' + (data.broken_links.length > 0 ? '#ef4444' : 'var(--primary-500)') + '; margin-bottom: 4px;">' + data.broken_links.length + '</div>';
                    html += '<div class="sb-sp-text-xs" style="color: #64748b;">' + i18n.brokenLinks + '</div>';
                    html += '</div>';
                    html += '</div>';

                    if (data.timeout_reached) {
                        html += '<div class="sb-sp-info-box sb-sp-info-box-tip" style="margin-bottom: 24px;">';
                        html += '<h4 class="sb-sp-heading-sm">' + i18n.timeoutWarning + '</h4>';
                        html += '<p class="sb-sp-description" style="margin: 0;">' + i18n.timeoutMessage.replace('%s', data.scan_time) + '</p>';
                        html += '</div>';
                    }

                    if (data.broken_links.length === 0) {
                        html += '<div class="sb-sp-info-box sb-sp-info-box-success">';
                        html += '<h4 class="sb-sp-heading-sm">' + i18n.excellent + '</h4>';
                        html += '<p class="sb-sp-description" style="margin: 0;">' + i18n.noBrokenLinks + '</p>';
                        html += '</div>';
                    } else {
                        html += '<div class="sb-sp-info-box sb-sp-info-box-tip" style="margin-bottom: 24px;">';
                        html += '<h4 class="sb-sp-heading-sm">' + i18n.actionRequired + '</h4>';
                        html += '<p class="sb-sp-description" style="margin: 0;">' + i18n.brokenLinksFound.replace('%d', data.broken_links.length) + '</p>';
                        html += '</div>';
                        
                        html += '<div class="sb-sp-card" style="box-shadow: none !important; border: 1px solid #e5e7eb; overflow: hidden; padding: 0;">';
                        html += '<div style="max-height: 500px; overflow-y: auto;">';
                        html += '<table class="widefat" style="margin: 0; border: 0; border-collapse: collapse;">';
                        html += '<thead>';
                        html += '<tr style="background: #f9fafb; border-bottom: 2px solid #e5e7eb;">';
                        html += '<th style="padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap;">' + i18n.postPage + '</th>';
                        html += '<th style="padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.5px;">' + i18n.brokenUrl + '</th>';
                        html += '<th style="padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap;">' + i18n.type + '</th>';
                        html += '<th style="padding: 12px 16px; text-align: left; font-size: 12px; font-weight: 600; color: #374151; text-transform: uppercase; letter-spacing: 0.5px; white-space: nowrap;">' + i18n.error + '</th>';
                        html += '</tr>';
                        html += '</thead>';
                        html += '<tbody>';

                        data.broken_links.forEach(function(link, index) {
                            const isEven = index % 2 === 0;
                            html += '<tr style="border-bottom: 1px solid #f3f4f6; ' + (isEven ? 'background: #ffffff;' : 'background: #f9fafb;') + '">';
                            html += '<td style="padding: 12px 16px;"><a href="' + link.edit_url + '" target="_blank" style="color: var(--primary-500); text-decoration: none; font-weight: 500;">' + link.post_title + '</a></td>';
                            html += '<td style="padding: 12px 16px; word-break: break-all; font-family: \'Courier New\', monospace; font-size: 12px; color: #64748b;">' + link.url + '</td>';
                            html += '<td style="padding: 12px 16px;"><span class="sb-sp-badge sb-sp-badge-gray">' + link.type + '</span></td>';
                            html += '<td style="padding: 12px 16px; color: #ef4444; font-size: 13px; font-weight: 500;">' + link.status + '</td>';
                            html += '</tr>';
                        });

                        html += '</tbody>';
                        html += '</table>';
                        html += '</div>';
                        html += '</div>';
                    }

                    html += '</div>';
                    $('#scan-results').html(html);
                }
            });
            </script>
        </div>
        <?php
        
    }

    /**
     * Register data locations for cleanup system
     *
     * @return array Data storage locations
     */
    public function register_data_locations() {
        // This module doesn't store any persistent data
        // It only uses in-memory cache during scans
        return [
            'options' => [],
            'option_patterns' => [],
            'tables' => [],
            'post_meta' => [],
            'user_meta' => [],
        ];
    }
}

$switchboard_instance = new SwitchboardBrokenLinkChecker();
Switchboard_Data_Registry::get_instance()->auto_register($switchboard_instance, __FILE__);
