<?php
/**
 * Static helpers: IP detection + anonymization, UA parsing, referrer parsing,
 * search-engine recognition, visitor hashing, table names.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Utils {

    /** Map of host substrings to source-engine names (lowercased). */
    private static $search_engines = array(
        'google.'    => 'Google',
        'bing.'      => 'Bing',
        'duckduckgo' => 'DuckDuckGo',
        'yahoo.'     => 'Yahoo',
        'yandex.'    => 'Yandex',
        'baidu.'     => 'Baidu',
        'ecosia.'    => 'Ecosia',
        'qwant.'     => 'Qwant',
        'startpage'  => 'Startpage',
        'brave.com'  => 'Brave Search',
    );

    /**
     * Map of host substrings to AI answer-engine names (Generative Engine
     * Optimization). Checked BEFORE search engines so e.g. gemini.google.com
     * counts as AI, not as Google Search.
     */
    private static $ai_engines = array(
        'chatgpt.com'        => 'ChatGPT',
        'chat.openai.com'    => 'ChatGPT',
        'openai.com'         => 'ChatGPT',
        'perplexity.ai'      => 'Perplexity',
        'perplexity'         => 'Perplexity',
        'gemini.google'      => 'Gemini',
        'bard.google'        => 'Gemini',
        'copilot.microsoft'  => 'Copilot',
        'copilot.cloud.microsoft' => 'Copilot',
        'claude.ai'          => 'Claude',
        'you.com'            => 'You.com',
        'poe.com'            => 'Poe',
        'x.ai'               => 'Grok',
        'grok.com'           => 'Grok',
        'deepseek.com'       => 'DeepSeek',
        'chat.mistral.ai'    => 'Le Chat',
        'phind.com'          => 'Phind',
        'andisearch.com'     => 'Andi',
    );

    /** Map of host substrings to social-network names. */
    private static $social_networks = array(
        'facebook.'  => 'Facebook',
        'fb.com'     => 'Facebook',
        't.co'       => 'X / Twitter',
        'twitter.'   => 'X / Twitter',
        'x.com'      => 'X / Twitter',
        'instagram.' => 'Instagram',
        'linkedin.'  => 'LinkedIn',
        'lnkd.in'    => 'LinkedIn',
        'pinterest.' => 'Pinterest',
        'youtube.'   => 'YouTube',
        'youtu.be'   => 'YouTube',
        'reddit.'    => 'Reddit',
        'tiktok.'    => 'TikTok',
        't.me'       => 'Telegram',
        'whatsapp.'  => 'WhatsApp',
        'mastodon.'  => 'Mastodon',
        'bsky.'      => 'Bluesky',
        'threads.'   => 'Threads',
    );

    /** Common bot UA fragments (lowercased). */
    private static $bot_signatures = array(
        'bot', 'crawler', 'spider', 'slurp', 'mediapartners', 'adsbot',
        'archiver', 'preview', 'facebookexternalhit', 'whatsapp', 'telegrambot',
        'discordbot', 'slackbot', 'curl/', 'wget/', 'python-requests',
        'go-http-client', 'java/', 'apachebench', 'headless',
    );

    /**
     * SEOFORGE_TRUSTED_PROXIES gates which REMOTE_ADDR values we trust to hand us
     * a forwarded-for header. Keep this list narrow (your actual reverse
     * proxy/load-balancer IPs only) — an over-broad range (e.g. 0.0.0.0/0)
     * lets any client forge X-Forwarded-For / X-Real-IP and spoof their IP.
     *
     * Even without SEOFORGE_TRUSTED_PROXIES configured, a private/loopback/
     * reserved REMOTE_ADDR (e.g. 127.0.0.1 from a local nginx → php-fpm hop,
     * or a container sidecar/CDN edge) is treated as a local reverse proxy
     * and forwarded headers are trusted — otherwise every visitor behind
     * such a setup resolves to the proxy's own IP. A normal PUBLIC
     * REMOTE_ADDR that isn't on the trusted list is never trusted, or any
     * client could spoof its IP via headers.
     */
    public static function get_real_ip() {
        $remote = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
        $remote = filter_var( $remote, FILTER_VALIDATE_IP ) ? $remote : '';

        $trusted     = defined( 'SEOFORGE_TRUSTED_PROXIES' ) ? (array) SEOFORGE_TRUSTED_PROXIES : array();
        $local_proxy = '' !== $remote && ! self::is_public_ip( $remote );
        $trust_headers = ( ! empty( $trusted ) && self::ip_in_list( $remote, $trusted ) ) || $local_proxy;

        if ( $trust_headers ) {
            foreach ( array( 'HTTP_CF_CONNECTING_IP', 'HTTP_TRUE_CLIENT_IP', 'HTTP_X_REAL_IP' ) as $h ) {
                if ( ! empty( $_SERVER[ $h ] ) ) {
                    $candidate = trim( sanitize_text_field( wp_unslash( $_SERVER[ $h ] ) ) );
                    if ( self::is_public_ip( $candidate ) ) {
                        return $candidate;
                    }
                }
            }
            if ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
                $xff = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
                foreach ( explode( ',', $xff ) as $hop ) {
                    $candidate = trim( $hop );
                    if ( self::is_public_ip( $candidate ) ) {
                        return $candidate;
                    }
                }
            }
        }
        return $remote;
    }

    /** True if $ip is a syntactically valid IP that is not private/loopback/reserved. */
    public static function is_public_ip( $ip ) {
        return false !== filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
    }

    public static function anonymize_ip( $ip ) {
        if ( ! $ip || ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
            return '';
        }
        if ( filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ) {
            $parts = explode( '.', $ip );
            $parts[3] = '0';
            return implode( '.', $parts );
        }
        $packed = inet_pton( $ip );
        if ( false === $packed ) {
            return '';
        }
        $masked = substr( $packed, 0, 8 ) . str_repeat( "\0", 8 );
        return inet_ntop( $masked );
    }

    /**
     * Daily-rotating per-visitor hash (no PII stored).
     * SHA1( ip + UA + daily-salt ).
     */
    public static function visitor_hash( $ip, $ua ) {
        $salt = (string) Seoforge_Settings::get( 'privacy_visitor_hash_salt', '' );
        $rotate = (bool) Seoforge_Settings::get( 'privacy_hash_rotate_daily', true );
        $day = $rotate ? gmdate( 'Y-m-d' ) : '';
        return sha1( $ip . '|' . $ua . '|' . $salt . '|' . $day );
    }

    public static function ip_in_cidr( $ip, $cidr ) {
        if ( strpos( $cidr, '/' ) === false ) {
            return $ip === $cidr;
        }
        list( $subnet, $mask ) = explode( '/', $cidr, 2 );
        $mask = (int) $mask;

        $ip_packed     = @inet_pton( $ip );
        $subnet_packed = @inet_pton( $subnet );
        if ( false === $ip_packed || false === $subnet_packed || strlen( $ip_packed ) !== strlen( $subnet_packed ) ) {
            return false;
        }

        $bytes_full   = intdiv( $mask, 8 );
        $bits_partial = $mask % 8;

        if ( substr( $ip_packed, 0, $bytes_full ) !== substr( $subnet_packed, 0, $bytes_full ) ) {
            return false;
        }
        if ( 0 === $bits_partial ) {
            return true;
        }
        $mask_byte = chr( ( 0xFF << ( 8 - $bits_partial ) ) & 0xFF );
        return ( $ip_packed[ $bytes_full ] & $mask_byte ) === ( $subnet_packed[ $bytes_full ] & $mask_byte );
    }

    public static function ip_in_list( $ip, array $list ) {
        foreach ( $list as $entry ) {
            $entry = trim( $entry );
            if ( '' === $entry ) {
                continue;
            }
            if ( self::ip_in_cidr( $ip, $entry ) ) {
                return true;
            }
        }
        return false;
    }

    public static function safe_user_agent() {
        $ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) : '';
        return mb_substr( sanitize_text_field( $ua ), 0, 255 );
    }

    public static function safe_request_uri() {
        $uri = isset( $_SERVER['REQUEST_URI'] ) ? wp_unslash( $_SERVER['REQUEST_URI'] ) : '';
        return mb_substr( esc_url_raw( $uri ), 0, 500 );
    }

    public static function safe_referrer() {
        $r = isset( $_SERVER['HTTP_REFERER'] ) ? wp_unslash( $_SERVER['HTTP_REFERER'] ) : '';
        return $r ? mb_substr( esc_url_raw( $r ), 0, 500 ) : '';
    }

    public static function current_blog_id() {
        return is_multisite() ? (int) get_current_blog_id() : 0;
    }

    /**
     * True for users who may legitimately see network-wide analytics or a
     * named *other* site's data — super admins and anyone explicitly
     * granted manage_network_options. Every other user (including a plain
     * subsite Administrator, who always has manage_options for their own
     * site) is confined to their own current blog, no matter what
     * ?seoforge_site=… or stored usermeta requests.
     */
    public static function can_view_network_scope() {
        return is_super_admin() || current_user_can( 'manage_network_options' );
    }

    /**
     * Raw analytics-scope token chosen for the current viewer.
     *
     * Returns one of: 'current' (this site), 'all' (whole network), or a
     * numeric blog id as a string. Reads ?seoforge_site=… and remembers the
     * choice in user meta so it survives across the Dashboard/Visitors/
     * Pages/Sources tabs and the live REST poll (which carries no query
     * string). Always 'current' on single-site installs. A non-network user
     * can never select (or keep a previously stored) scope other than their
     * own current blog — see can_view_network_scope().
     */
    public static function stats_scope_token() {
        if ( ! is_multisite() ) {
            return 'current';
        }
        $can_network = self::can_view_network_scope();
        // Read-only view switch, not a state-changing action — no nonce needed.
        if ( isset( $_GET['seoforge_site'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            $choice = sanitize_key( wp_unslash( $_GET['seoforge_site'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            if ( ! $can_network && 'current' !== $choice ) {
                // Reject silently — fall back to the current blog and never
                // persist the rejected choice to user meta.
                return 'current';
            }
            update_user_meta( get_current_user_id(), 'seoforge_stats_scope', $choice );
            return $choice;
        }
        $stored = (string) get_user_meta( get_current_user_id(), 'seoforge_stats_scope', true );
        if ( '' !== $stored ) {
            // A stale privileged value from before this user lost network
            // capabilities (or from before this fix shipped) must not keep
            // working.
            return ( ! $can_network && 'current' !== $stored ) ? 'current' : $stored;
        }
        // Sensible default: the network dashboard aggregates the whole network;
        // a single subsite admin sees just their own site.
        return ( $can_network && is_network_admin() ) ? 'all' : 'current';
    }

    /**
     * Resolve the analytics scope to a value the Stats queries understand:
     * an int blog_id to filter by, or null for the whole network. On
     * single-site this is always 0 (the only blog_id ever written), which
     * keeps the indexed blog_id path in use and matches existing rows.
     *
     * Defensive re-check against can_view_network_scope() here too (not
     * just in stats_scope_token()), since usermeta written before this fix
     * shipped could already hold a stale 'all'/foreign-blog-id value.
     *
     * @return int|null
     */
    public static function stats_scope() {
        if ( ! is_multisite() ) {
            return 0;
        }
        $choice = self::stats_scope_token();
        $can_network = self::can_view_network_scope();
        if ( ! $can_network ) {
            return (int) get_current_blog_id();
        }
        if ( 'all' === $choice ) {
            return null; // whole network — no blog_id filter.
        }
        if ( 'current' === $choice ) {
            return (int) get_current_blog_id();
        }
        $bid = (int) $choice;
        if ( $bid > 0 && get_site( $bid ) ) {
            return $bid;
        }
        return (int) get_current_blog_id();
    }

    /**
     * Resolve a plugin table name. $name is validated against the fixed
     * set of tables this plugin creates, so the returned identifier is
     * safe to interpolate into SQL (values still go through prepare()).
     */
    public static function table( $name ) {
        global $wpdb;
        $allowed = array( 'visits', 'sessions', 'redirects', '404', 'meta', 'daily', 'audit', 'history' );
        if ( ! in_array( (string) $name, $allowed, true ) ) {
            _doing_it_wrong( __METHOD__, 'Unknown SEOForge table requested.', '0.7.2' );
            return $wpdb->base_prefix . 'seoforge_visits';
        }
        return $wpdb->base_prefix . 'seoforge_' . $name;
    }

    /**
     * Returns ['type' => one of self|search|social|referral|direct,
     *          'name' => human label,
     *          'host' => referrer host,
     *          'search_term' => extracted keyword if any].
     */
    public static function classify_referrer( $referrer_url, $own_host = '' ) {
        $out = array(
            'type'        => 'direct',
            'name'        => 'Direct',
            'host'        => '',
            'search_term' => '',
        );
        if ( '' === $referrer_url ) {
            return $out;
        }
        $parts = wp_parse_url( $referrer_url );
        if ( empty( $parts['host'] ) ) {
            return $out;
        }
        $host = strtolower( $parts['host'] );
        $out['host'] = $host;

        $suffix = '.' . $own_host;
        $host_suffix_match = $own_host && strlen( $host ) > strlen( $suffix )
            && substr( $host, -strlen( $suffix ) ) === $suffix;
        if ( $own_host && ( $host === $own_host || $host_suffix_match ) ) {
            $out['type'] = 'self';
            $out['name'] = 'Direct / Internal';
            return $out;
        }
        // AI answer engines first (Generative Engine Optimization) — a citation
        // click from ChatGPT/Gemini/Copilot/Perplexity etc.
        foreach ( self::$ai_engines as $needle => $name ) {
            if ( false !== strpos( $host, $needle ) ) {
                $out['type'] = 'ai';
                $out['name'] = $name;
                return $out;
            }
        }
        foreach ( self::$search_engines as $needle => $name ) {
            if ( false !== strpos( $host, $needle ) ) {
                $out['type'] = 'search';
                $out['name'] = $name;
                if ( ! empty( $parts['query'] ) ) {
                    parse_str( $parts['query'], $q );
                    foreach ( array( 'q', 'query', 'p', 'wd', 'text' ) as $param ) {
                        if ( ! empty( $q[ $param ] ) ) {
                            $out['search_term'] = mb_substr( sanitize_text_field( $q[ $param ] ), 0, 255 );
                            break;
                        }
                    }
                }
                return $out;
            }
        }
        foreach ( self::$social_networks as $needle => $name ) {
            if ( false !== strpos( $host, $needle ) ) {
                $out['type'] = 'social';
                $out['name'] = $name;
                return $out;
            }
        }
        $out['type'] = 'referral';
        $out['name'] = $host;
        return $out;
    }

    /**
     * Human label + one-sentence explainer for a source_type key, for use
     * next to the traffic-source pills on the dashboard and Sources tab.
     * Keys match classify_referrer()'s 'type' plus 'campaign' (UTM override).
     *
     * @return array{label:string,tip:string}
     */
    public static function source_type_meta( $type ) {
        $map = array(
            'direct'   => array(
                'label' => __( 'Direct', 'brainwerk-seo-suite' ),
                'tip'   => __( 'No referrer was sent — a typed URL, bookmark, app link, QR code, or a link clicked from an email or chat app.', 'brainwerk-seo-suite' ),
            ),
            'self'     => array(
                'label' => __( 'Direct / Internal', 'brainwerk-seo-suite' ),
                'tip'   => __( 'The visitor came from another page on this same site — internal navigation, not an external source.', 'brainwerk-seo-suite' ),
            ),
            'search'   => array(
                'label' => __( 'Search engines', 'brainwerk-seo-suite' ),
                'tip'   => __( 'The visitor clicked through from a search engine such as Google, Bing, or DuckDuckGo.', 'brainwerk-seo-suite' ),
            ),
            'social'   => array(
                'label' => __( 'Social media', 'brainwerk-seo-suite' ),
                'tip'   => __( 'The visitor arrived from a social network such as Facebook, X/Twitter, or LinkedIn.', 'brainwerk-seo-suite' ),
            ),
            'referral' => array(
                'label' => __( 'Referral', 'brainwerk-seo-suite' ),
                'tip'   => __( 'The visitor followed a link from another website that isn\'t a search engine, social network, or AI answer engine.', 'brainwerk-seo-suite' ),
            ),
            'campaign' => array(
                'label' => __( 'Campaign', 'brainwerk-seo-suite' ),
                'tip'   => __( 'The link carried UTM tracking parameters (utm_source, utm_medium, …), so it\'s attributed to that campaign.', 'brainwerk-seo-suite' ),
            ),
            'ai'       => array(
                'label' => __( 'AI answer engines', 'brainwerk-seo-suite' ),
                'tip'   => __( 'The visitor arrived from a citation or link in an AI assistant such as ChatGPT, Gemini, Copilot, Perplexity, or Claude.', 'brainwerk-seo-suite' ),
            ),
        );
        if ( isset( $map[ $type ] ) ) {
            return $map[ $type ];
        }
        return array(
            'label' => ucfirst( (string) $type ),
            'tip'   => '',
        );
    }

    /**
     * True when $path ends in a known static-asset extension — not a page
     * view. Shared by Seoforge_Tracker (live requests) and
     * Seoforge_Log_Reconciler (post-hoc access-log parsing) so the two never
     * drift apart on what counts as "not a page view".
     */
    public static function is_static_asset_path( $path ) {
        return (bool) preg_match( '/\.(?:ico|png|jpe?g|gif|webp|avif|svg|bmp|css|js|mjs|map|woff2?|ttf|otf|eot|txt|xml|json|pdf|zip|mp4|webm|mp3|wav|webmanifest)$/i', (string) $path );
    }

    /**
     * True when $uri contains any of $excluded_paths as a substring — the
     * same matching Seoforge_Tracker uses for the user-configured
     * `tracking_exclude_paths` setting. Shared so a reconciliation pass
     * applies literally the same rule the live tracker applied.
     */
    public static function matches_excluded_path( $uri, array $excluded_paths ) {
        foreach ( $excluded_paths as $needle ) {
            $needle = trim( (string) $needle );
            if ( '' !== $needle && false !== strpos( $uri, $needle ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Best-effort approximation of the tracker's is_admin()/wp_doing_ajax()/
     * REST-request exclusion for contexts that have no live request state to
     * ask — namely, a raw nginx access-log line. There is no log-based
     * equivalent of is_admin() to "reuse", so this is a documented
     * path-shape proxy, not a re-derivation of the same signal: wp-admin,
     * wp-login.php, wp-cron.php, xmlrpc.php and the REST prefix, PLUS
     * whatever the site owner already listed in `tracking_exclude_paths`
     * (that half genuinely is the same reused rule as matches_excluded_path()).
     */
    public static function is_admin_or_excluded_path( $path, array $extra_excluded_paths = array() ) {
        $builtin = array( '/wp-admin', '/wp-login.php', '/wp-cron.php', '/xmlrpc.php', '/wp-json' );
        return self::matches_excluded_path( $path, array_merge( $builtin, $extra_excluded_paths ) );
    }

    public static function is_bot( $user_agent ) {
        $ua = strtolower( (string) $user_agent );
        if ( '' === $ua ) {
            return true;
        }
        foreach ( self::$bot_signatures as $sig ) {
            if ( false !== strpos( $ua, $sig ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Returns ['device' => mobile|tablet|desktop, 'browser' => name, 'os' => name].
     * Lightweight UA sniffing — good enough for analytics overview without external deps.
     */
    public static function parse_ua( $user_agent ) {
        $ua = (string) $user_agent;
        $lc = strtolower( $ua );

        $device = 'desktop';
        if ( false !== strpos( $lc, 'tablet' ) || false !== strpos( $lc, 'ipad' ) ) {
            $device = 'tablet';
        } elseif ( false !== strpos( $lc, 'mobile' ) || false !== strpos( $lc, 'android' ) || false !== strpos( $lc, 'iphone' ) ) {
            $device = 'mobile';
        }

        $browser = 'Other';
        // Order matters — Edge before Chrome, Opera before Chrome, etc.
        if ( false !== strpos( $lc, 'edg/' ) || false !== strpos( $lc, 'edge/' ) ) {
            $browser = 'Edge';
        } elseif ( false !== strpos( $lc, 'opr/' ) || false !== strpos( $lc, 'opera' ) ) {
            $browser = 'Opera';
        } elseif ( false !== strpos( $lc, 'firefox' ) ) {
            $browser = 'Firefox';
        } elseif ( false !== strpos( $lc, 'chrome' ) ) {
            $browser = 'Chrome';
        } elseif ( false !== strpos( $lc, 'safari' ) ) {
            $browser = 'Safari';
        }

        $os = 'Other';
        if ( false !== strpos( $lc, 'windows' ) ) {
            $os = 'Windows';
        } elseif ( false !== strpos( $lc, 'iphone' ) || false !== strpos( $lc, 'ipad' ) || false !== strpos( $lc, 'ipod' ) ) {
            $os = 'iOS';
        } elseif ( false !== strpos( $lc, 'android' ) ) {
            $os = 'Android';
        } elseif ( false !== strpos( $lc, 'mac os' ) || false !== strpos( $lc, 'macintosh' ) ) {
            $os = 'macOS';
        } elseif ( false !== strpos( $lc, 'linux' ) ) {
            $os = 'Linux';
        }

        return array(
            'device'  => $device,
            'browser' => $browser,
            'os'      => $os,
        );
    }

    /**
     * Extract path part of a URL, with leading slash, no trailing slash unless root.
     */
    public static function url_path( $url ) {
        $p = wp_parse_url( $url );
        $path = isset( $p['path'] ) ? $p['path'] : '/';
        if ( '' === $path ) {
            $path = '/';
        }
        return mb_substr( $path, 0, 255 );
    }

    public static function dnt_active() {
        return ! empty( $_SERVER['HTTP_DNT'] ) && '1' === (string) $_SERVER['HTTP_DNT'];
    }

    /**
     * Pure decision combining the tracking_respect_dnt setting with the raw
     * DNT header signal — shared by every tracking entry point (server-side
     * tracker, beacon enqueue, beacon REST route) so a visitor's DNT choice
     * is honoured consistently everywhere, not just wherever someone
     * remembered to check it. $dnt_header_active defaults to the live
     * request header; overridable so this stays unit-testable.
     */
    public static function should_respect_dnt( $setting_enabled, $dnt_header_active = null ) {
        $dnt_header_active = null === $dnt_header_active ? self::dnt_active() : (bool) $dnt_header_active;
        return (bool) $setting_enabled && $dnt_header_active;
    }

    /**
     * Rotating public beacon token (cache-tolerant). Not a secret-grade nonce —
     * it raises the bar above header spoofing. Window is long so full-page caches
     * keep working; pages cached longer than ~2x the window lose only beacon-based
     * metrics (CWV/engagement), never server-side pageviews.
     */
    public static function beacon_token( $offset = 0 ) {
        $ttl    = max( 1, (int) apply_filters( 'seoforge_beacon_token_ttl_days', 30 ) );
        $window = (int) floor( time() / ( $ttl * DAY_IN_SECONDS ) ) - (int) $offset;
        return substr( hash_hmac( 'sha256', 'seoforge_beacon|' . $window, wp_salt( 'nonce' ) ), 0, 24 );
    }

    public static function beacon_token_valid( $token ) {
        $token = (string) $token;
        if ( '' === $token ) {
            return false;
        }
        return hash_equals( self::beacon_token( 0 ), $token ) || hash_equals( self::beacon_token( 1 ), $token );
    }

    /**
     * Resolve every A/AAAA record for $host. Shared by is_safe_remote_url()
     * and safe_remote_request() so both ever see the exact same lookup.
     * Filterable so tests can simulate DNS answers (including a rebinding
     * scenario — a filter callback that returns a different IP on a second
     * call) without touching real DNS.
     */
    private static function resolve_ips( $host ) {
        if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
            return array( $host );
        }
        $ips = array();
        $v4  = @gethostbynamel( $host );
        if ( is_array( $v4 ) ) { $ips = array_merge( $ips, $v4 ); }
        $aaaa = @dns_get_record( $host, DNS_AAAA );
        if ( is_array( $aaaa ) ) {
            foreach ( $aaaa as $r ) {
                if ( ! empty( $r['ipv6'] ) ) { $ips[] = $r['ipv6']; }
            }
        }
        return apply_filters( 'seoforge_resolved_ips', $ips, $host );
    }

    /**
     * Single DNS lookup + safety verdict, shared by is_safe_remote_url() and
     * safe_remote_request() so a request only ever resolves a hostname ONCE —
     * the property that makes DNS-rebinding mitigation possible at all. If
     * is_safe_remote_url() and the eventual wp_remote_*() call each resolved
     * independently, a hostile DNS answer could differ between the two and
     * point the real connection somewhere the check never saw.
     *
     * @return array{safe:bool,host:string,ip:?string}
     */
    private static function evaluate_remote_url( $url ) {
        $url  = (string) $url;
        $host = (string) wp_parse_url( $url, PHP_URL_HOST );
        $sch  = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
        $safe = ( '' !== $host ) && in_array( $sch, array( 'http', 'https' ), true );
        $ip   = null;
        if ( $safe ) {
            foreach ( self::resolve_ips( $host ) as $candidate ) {
                if ( filter_var( $candidate, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
                    $ip = $candidate;
                    break;
                }
            }
            $safe = null !== $ip; // no resolved IP, or every one was private/reserved → refuse
        }
        /** Allow site owners to override (e.g. an intentional internal webhook). */
        $safe = (bool) apply_filters( 'seoforge_allow_remote_url', $safe, $url );
        return array( 'safe' => $safe, 'host' => $host, 'ip' => $ip );
    }

    /**
     * True if $url is a safe outbound target: http/https scheme and a host that does
     * NOT resolve to a loopback/private/link-local/reserved IP. Blocks SSRF to
     * internal services and cloud metadata (169.254.169.254). Filterable.
     */
    public static function is_safe_remote_url( $url ) {
        return self::evaluate_remote_url( $url )['safe'];
    }

    /**
     * SSRF-guarded wp_remote_get()/wp_remote_post(), with DNS-rebinding
     * mitigation: naively calling is_safe_remote_url() and then wp_remote_*()
     * lets each do its own DNS lookup, and nothing stops the two resolving
     * to different IPs — a hostile authoritative DNS server can answer the
     * first lookup with a public IP (passing the guard) and the second,
     * moments later, with 127.0.0.1 or a cloud metadata address. This
     * resolves once (evaluate_remote_url(), above) and pins the connection
     * to that exact IP via CURLOPT_RESOLVE through the `http_api_curl`
     * filter WordPress's HTTP API already exposes for the cURL transport.
     *
     * Residual risk, documented rather than hidden: this only takes effect
     * when WP_Http actually uses the cURL transport (WP_Http_Curl). A site
     * forced onto the streams/fsockopen transport (no cURL extension, or a
     * plugin swapping the transport) still gets the up-front guard check
     * but not the pin — WordPress's HTTP API gives us no equivalent hook
     * for that transport, and re-implementing socket-level connection
     * pinning ourselves would be a fragile, hard-to-maintain hack for a
     * transport combination we don't otherwise support. On any normal host
     * (PHP's cURL extension is effectively universal) the pin applies.
     *
     * @param string $method 'get' or 'post'.
     * @param string $url
     * @param array  $args   Passed through to wp_remote_get()/wp_remote_post().
     * @return array|WP_Error
     */
    public static function safe_remote_request( $method, $url, array $args = array() ) {
        $verdict = self::evaluate_remote_url( $url );
        if ( ! $verdict['safe'] ) {
            return new WP_Error( 'seoforge_unsafe_url', 'Refused to contact an unsafe or unresolvable URL.' );
        }

        $host = $verdict['host'];
        $ip   = $verdict['ip'];

        $pin = null;
        // No pin needed when the URL already names a bare IP — there's no
        // hostname for a rebinding answer to hijack.
        if ( $ip && ! filter_var( $host, FILTER_VALIDATE_IP ) && defined( 'CURLOPT_RESOLVE' ) ) {
            $scheme = strtolower( (string) wp_parse_url( $url, PHP_URL_SCHEME ) );
            $port   = wp_parse_url( $url, PHP_URL_PORT );
            $port   = $port ? (int) $port : ( 'https' === $scheme ? 443 : 80 );
            $entry  = false !== strpos( $ip, ':' ) ? "{$host}:{$port}:[{$ip}]" : "{$host}:{$port}:{$ip}";
            $pin    = function ( $handle ) use ( $entry ) {
                curl_setopt( $handle, CURLOPT_RESOLVE, array( $entry ) ); // phpcs:ignore
            };
            add_action( 'http_api_curl', $pin );
        }

        $result = 'post' === $method ? wp_remote_post( $url, $args ) : wp_remote_get( $url, $args );

        if ( $pin ) {
            remove_action( 'http_api_curl', $pin );
        }
        return $result;
    }
}
