<?php
/**
 * Link Monitor - Anti-Fraud Backlink Verification
 *
 * Monitors published guest posts to verify backlinks are:
 * - Still present on the page
 * - Not using nofollow/sponsored/ugc attributes
 * - Pointing to the correct URL
 *
 * @package Consolety
 */

namespace Consolety;

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

class LinkMonitor
{
    /**
     * @var Api
     */
    private $api;

    /**
     * Link status constants
     */
    const STATUS_OK = 'ok';
    const STATUS_MISSING = 'missing';
    const STATUS_NOFOLLOW = 'nofollow';
    const STATUS_SPONSORED = 'sponsored';
    const STATUS_UGC = 'ugc';
    const STATUS_WRONG_URL = 'wrong_url';
    const STATUS_PAGE_NOT_FOUND = 'page_not_found';
    const STATUS_ERROR = 'error';

    /**
     * Constructor
     */
    public function __construct()
    {
        $this->api = new Api();
    }

    /**
     * Validate a URL for SSRF safety.
     *
     * Only allows http/https, blocks private/loopback/link-local IPs,
     * and performs DNS pre-resolution to catch DNS rebinding.
     *
     * @param string $url
     * @return array{valid: bool, error: string}
     */
    private function validateUrl(string $url): array
    {
        $parsed = wp_parse_url($url);
        if (!$parsed || empty($parsed['scheme']) || empty($parsed['host'])) {
            return ['valid' => false, 'error' => 'Invalid URL format'];
        }

        $scheme = strtolower($parsed['scheme']);
        if (!in_array($scheme, ['http', 'https'], true)) {
            return ['valid' => false, 'error' => 'Only http/https schemes allowed'];
        }

        $host = $parsed['host'];

        // Reject IP-literal hostnames that are private/reserved
        if (filter_var($host, FILTER_VALIDATE_IP)) {
            if (!$this->isPublicIp($host)) {
                return ['valid' => false, 'error' => 'Private/reserved IP addresses are not allowed'];
            }
        }

        // DNS resolution check: resolve hostname BEFORE connecting
        // to catch DNS rebinding where hostname resolves to private IP
        // Check BOTH A (IPv4) and AAAA (IPv6) records — cURL/Requests can prefer IPv6
        $ips = gethostbynamel($host) ?: [];

        if (function_exists('dns_get_record')) {
            $dns6 = @dns_get_record($host, DNS_AAAA);
            if (!empty($dns6)) {
                $ips = array_merge($ips, array_column($dns6, 'ipv6'));
            }
        }

        if (empty($ips)) {
            return ['valid' => false, 'error' => 'DNS resolution failed for host'];
        }

        foreach ($ips as $ip) {
            if (!$this->isPublicIp($ip)) {
                return ['valid' => false, 'error' => 'Host resolves to private/reserved IP address'];
            }
        }

        return ['valid' => true, 'error' => ''];
    }

    /**
     * Check if an IP address is public (not private, loopback, link-local, etc.)
     *
     * PHP's FILTER_FLAG_NO_PRIV_RANGE covers: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, fc00::/7
     * PHP's FILTER_FLAG_NO_RES_RANGE covers: 127.0.0.0/8, 169.254.0.0/16, 0.0.0.0/8, ::1, and others
     *
     * @param string $ip
     * @return bool
     */
    private function isPublicIp(string $ip): bool
    {
        return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
    }

    /**
     * Initialize the monitor
     */
    public function init()
    {
        // Schedule daily cron job for link checking
        add_action('consolety_check_links', [$this, 'runScheduledCheck']);

        // Register activation/deactivation hooks
        add_action('consolety_activated', [$this, 'scheduleCheck']);
        add_action('consolety_deactivated', [$this, 'unscheduleCheck']);

        // AJAX handler for manual check
        add_action('wp_ajax_consolety_check_link', [$this, 'ajaxCheckLink']);
    }

    /**
     * Schedule the daily link check
     */
    public function scheduleCheck()
    {
        if (!wp_next_scheduled('consolety_check_links')) {
            // Schedule for 3 AM local time
            $timestamp = strtotime('tomorrow 3:00 AM');
            wp_schedule_event($timestamp, 'daily', 'consolety_check_links');
        }
    }

    /**
     * Unschedule the link check
     */
    public function unscheduleCheck()
    {
        $timestamp = wp_next_scheduled('consolety_check_links');
        if ($timestamp) {
            wp_unschedule_event($timestamp, 'consolety_check_links');
        }
    }

    /**
     * Run scheduled link check
     */
    public function runScheduledCheck()
    {
        // Get posts that need monitoring from the hub
        $response = $this->api->getPostsToMonitor();

        if (!$response['success'] || empty($response['data']['posts'])) {
            return;
        }

        $posts = $response['data']['posts'];

        foreach ($posts as $post) {
            $result = $this->checkLink(
                $post['published_url'],
                $post['backlink_url'],
                $post['anchor_text'] ?? null
            );

            // Report status back to hub
            $this->api->reportLinkStatus($post['guest_post_id'], $result['status'], [
                'details' => $result['details'] ?? '',
                'checked_at' => current_time('mysql'),
                'http_code' => $result['http_code'] ?? null,
            ]);

            // Small delay to avoid hammering servers
            usleep(500000); // 0.5 seconds
        }
    }

    /**
     * Check a single link
     *
     * @param string $pageUrl The page URL where the backlink should be
     * @param string $expectedBacklink The expected backlink URL
     * @param string|null $expectedAnchor Optional expected anchor text
     * @return array
     */
    public function checkLink($pageUrl, $expectedBacklink, $expectedAnchor = null)
    {
        $maxRedirects = 3;
        $maxBodySize  = 5 * 1024 * 1024; // 5 MB
        $currentUrl   = $pageUrl;
        $response     = null;
        $httpCode     = 0;

        // Follow redirects manually so we can SSRF-validate every hop
        for ($hop = 0; $hop <= $maxRedirects; $hop++) {
            // SSRF protection: validate EVERY URL (initial + each redirect target)
            $validation = $this->validateUrl($currentUrl);
            if (!$validation['valid']) {
                return [
                    'status' => self::STATUS_ERROR,
                    'details' => 'URL validation failed (hop ' . $hop . '): ' . $validation['error'],
                ];
            }

            $response = wp_remote_get($currentUrl, [
                'timeout'             => 30,
                'redirection'         => 0, // Do NOT follow redirects automatically
                'limit_response_size' => $maxBodySize, // Cap download BEFORE it hits PHP memory
                'user-agent'          => 'Consolety Link Monitor/1.0 (https://consolety.ai)',
                'sslverify'           => true,
            ]);

            if (is_wp_error($response)) {
                return [
                    'status' => self::STATUS_ERROR,
                    'details' => $response->get_error_message(),
                ];
            }

            $httpCode = wp_remote_retrieve_response_code($response);

            // If redirect: extract Location, validate on next iteration
            if (in_array($httpCode, [301, 302, 303, 307, 308], true)) {
                $location = wp_remote_retrieve_header($response, 'location');
                if (empty($location)) {
                    return [
                        'status' => self::STATUS_ERROR,
                        'http_code' => $httpCode,
                        'details' => 'Redirect without Location header',
                    ];
                }
                // Resolve relative redirects against current URL
                $currentUrl = \WP_Http::make_absolute_url($location, $currentUrl);
                continue;
            }

            // Not a redirect: we have the final response
            break;
        }

        // Exhausted all redirect hops without a final response
        if ($hop > $maxRedirects) {
            return [
                'status' => self::STATUS_ERROR,
                'details' => 'Too many redirects (limit: ' . $maxRedirects . ')',
            ];
        }

        // Check for 404 or other error codes
        if ($httpCode === 404) {
            return [
                'status' => self::STATUS_PAGE_NOT_FOUND,
                'http_code' => $httpCode,
                'details' => 'Page not found (404)',
            ];
        }

        if ($httpCode >= 400) {
            return [
                'status' => self::STATUS_ERROR,
                'http_code' => $httpCode,
                'details' => "HTTP error: {$httpCode}",
            ];
        }

        $body = wp_remote_retrieve_body($response);

        // Safety-net truncation (limit_response_size should already cap this)
        if (strlen($body) > $maxBodySize) {
            $body = substr($body, 0, $maxBodySize);
        }

        if (empty($body)) {
            return [
                'status' => self::STATUS_ERROR,
                'http_code' => $httpCode,
                'details' => 'Empty response body',
            ];
        }

        // Parse HTML and find the backlink
        return $this->analyzePageForLink($body, $expectedBacklink, $expectedAnchor);
    }

    /**
     * Analyze page HTML for the expected backlink
     *
     * @param string $html Page HTML content
     * @param string $expectedBacklink Expected backlink URL
     * @param string|null $expectedAnchor Expected anchor text
     * @return array
     */
    private function analyzePageForLink($html, $expectedBacklink, $expectedAnchor = null)
    {
        // Suppress libxml errors
        libxml_use_internal_errors(true);

        $doc = new \DOMDocument();
        $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));

        libxml_clear_errors();

        $xpath = new \DOMXPath($doc);
        $links = $xpath->query('//a[@href]');

        // Normalize expected URL for comparison
        $expectedNormalized = $this->normalizeUrl($expectedBacklink);

        $foundLink = null;
        $foundRel = [];

        foreach ($links as $link) {
            $href = $link->getAttribute('href');
            $hrefNormalized = $this->normalizeUrl($href);

            // Check if this link matches our expected backlink
            if ($this->urlsMatch($hrefNormalized, $expectedNormalized)) {
                $foundLink = $link;
                $rel = strtolower($link->getAttribute('rel'));
                $foundRel = array_filter(array_map('trim', explode(' ', $rel)));
                break;
            }
        }

        // Link not found
        if (!$foundLink) {
            return [
                'status' => self::STATUS_MISSING,
                'details' => 'Backlink not found on page',
            ];
        }

        // Check for problematic rel attributes
        $problems = [];

        if (in_array('nofollow', $foundRel)) {
            $problems[] = 'nofollow';
        }

        if (in_array('sponsored', $foundRel)) {
            $problems[] = 'sponsored';
        }

        if (in_array('ugc', $foundRel)) {
            $problems[] = 'ugc';
        }

        // Determine status based on problems found
        if (!empty($problems)) {
            // Return the most severe issue
            if (in_array('nofollow', $problems)) {
                return [
                    'status' => self::STATUS_NOFOLLOW,
                    'details' => 'Link has nofollow attribute',
                    'rel_attributes' => $problems,
                ];
            }
            if (in_array('sponsored', $problems)) {
                return [
                    'status' => self::STATUS_SPONSORED,
                    'details' => 'Link has sponsored attribute',
                    'rel_attributes' => $problems,
                ];
            }
            if (in_array('ugc', $problems)) {
                return [
                    'status' => self::STATUS_UGC,
                    'details' => 'Link has ugc attribute',
                    'rel_attributes' => $problems,
                ];
            }
        }

        // Check anchor text if specified
        $actualAnchor = trim($foundLink->textContent);
        $anchorMatch = true;

        if ($expectedAnchor !== null) {
            $anchorMatch = $this->anchorTextMatches($actualAnchor, $expectedAnchor);
        }

        // All good!
        return [
            'status' => self::STATUS_OK,
            'details' => 'Link verified successfully',
            'anchor_text' => $actualAnchor,
            'anchor_match' => $anchorMatch,
            'rel_attributes' => $foundRel,
        ];
    }

    /**
     * Normalize a URL for comparison
     *
     * @param string $url
     * @return string
     */
    private function normalizeUrl($url)
    {
        // Parse the URL
        $parsed = wp_parse_url($url);

        if (!$parsed) {
            return strtolower(trim($url));
        }

        // Build normalized URL
        $normalized = '';

        // Host (lowercase)
        if (isset($parsed['host'])) {
            // Remove www. prefix for comparison
            $host = strtolower($parsed['host']);
            $host = preg_replace('/^www\./', '', $host);
            $normalized .= $host;
        }

        // Path (lowercase, remove trailing slash)
        if (isset($parsed['path'])) {
            $path = strtolower($parsed['path']);
            $path = rtrim($path, '/');
            $normalized .= $path;
        }

        // Query string (sorted)
        if (isset($parsed['query'])) {
            parse_str($parsed['query'], $params);
            ksort($params);
            $normalized .= '?' . http_build_query($params);
        }

        return $normalized;
    }

    /**
     * Check if two URLs match (with some flexibility)
     *
     * @param string $url1
     * @param string $url2
     * @return bool
     */
    private function urlsMatch($url1, $url2)
    {
        // Direct match
        if ($url1 === $url2) {
            return true;
        }

        // Try with/without trailing slash
        if (rtrim($url1, '/') === rtrim($url2, '/')) {
            return true;
        }

        // Try without query string
        $url1NoQuery = strtok($url1, '?');
        $url2NoQuery = strtok($url2, '?');

        if ($url1NoQuery === $url2NoQuery) {
            return true;
        }

        return false;
    }

    /**
     * Check if anchor text matches (fuzzy match)
     *
     * @param string $actual
     * @param string $expected
     * @return bool
     */
    private function anchorTextMatches($actual, $expected)
    {
        // Normalize whitespace and case
        $actual = strtolower(preg_replace('/\s+/', ' ', trim($actual)));
        $expected = strtolower(preg_replace('/\s+/', ' ', trim($expected)));

        // Exact match
        if ($actual === $expected) {
            return true;
        }

        // Check if expected is contained in actual
        if (strpos($actual, $expected) !== false) {
            return true;
        }

        // Similar enough (85% or more similar)
        similar_text($actual, $expected, $percent);
        return $percent >= 85;
    }

    /**
     * AJAX handler for manual link check
     */
    public function ajaxCheckLink()
    {
        check_ajax_referer('consolety_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $pageUrl = sanitize_url(wp_unslash($_POST['page_url'] ?? ''));
        $backlink = sanitize_url(wp_unslash($_POST['backlink_url'] ?? ''));
        $anchor = sanitize_text_field(wp_unslash($_POST['anchor_text'] ?? ''));

        if (empty($pageUrl) || empty($backlink)) {
            wp_send_json_error(['message' => 'Missing required parameters']);
        }

        $result = $this->checkLink($pageUrl, $backlink, $anchor ?: null);

        wp_send_json_success($result);
    }

    /**
     * Get human-readable status label
     *
     * @param string $status
     * @return string
     */
    public static function getStatusLabel($status)
    {
        $labels = [
            self::STATUS_OK => __('OK - Link verified', 'consolety'),
            self::STATUS_MISSING => __('Missing - Link not found', 'consolety'),
            self::STATUS_NOFOLLOW => __('Warning - Has nofollow', 'consolety'),
            self::STATUS_SPONSORED => __('Warning - Has sponsored', 'consolety'),
            self::STATUS_UGC => __('Warning - Has ugc', 'consolety'),
            self::STATUS_WRONG_URL => __('Error - Wrong URL', 'consolety'),
            self::STATUS_PAGE_NOT_FOUND => __('Error - Page not found', 'consolety'),
            self::STATUS_ERROR => __('Error - Check failed', 'consolety'),
        ];

        return $labels[$status] ?? $status;
    }

    /**
     * Get status severity (for sorting/filtering)
     *
     * @param string $status
     * @return int 0=ok, 1=warning, 2=error
     */
    public static function getStatusSeverity($status)
    {
        $severities = [
            self::STATUS_OK => 0,
            self::STATUS_NOFOLLOW => 1,
            self::STATUS_SPONSORED => 1,
            self::STATUS_UGC => 1,
            self::STATUS_MISSING => 2,
            self::STATUS_WRONG_URL => 2,
            self::STATUS_PAGE_NOT_FOUND => 2,
            self::STATUS_ERROR => 2,
        ];

        return $severities[$status] ?? 2;
    }
}
