<?php
/**
 * AI crawler visibility log.
 *
 * Records which AI crawlers (GPTBot, ClaudeBot, PerplexityBot, ...) fetched
 * which post, aggregated per day. The LinkQuiver dashboard reads this through
 * GET /linkquiver/v1/ai-crawlers to answer the one question a link buyer
 * actually cares about: "did an LLM ever read the article my link sits in?"
 *
 * WHAT IS STORED, AND WHAT IS NOT
 * -------------------------------
 * One row per (day, bot, post_id) holding two counters. No IP address, no
 * User-Agent string, no timestamp finer than a day, nothing per-visitor. There
 * is nothing in this table that could identify a human, by design: the row is
 * already an aggregate before it is ever written.
 *
 * VERIFICATION, AND THE LIMIT OF IT
 * ---------------------------------
 * A User-Agent is a claim, not a fact — `curl -A GPTBot` is a one-liner. So a
 * hit only increments `verified_hits` when the *source IP* falls inside a range
 * the vendor itself publishes (openai.com/gptbot.json, claude.com/crawling/
 * bots.json, perplexity.ai/perplexitybot.json, ...). Those lists are aggregated
 * server-side by LinkQuiver and pulled here once a day, so a plugin update is
 * never needed when a vendor adds a subnet.
 *
 * Reverse DNS is deliberately NOT used: OpenAI's crawler IPs have no PTR record
 * at all (verified 2026-07-31, `dig -x 20.171.206.1` → NXDOMAIN), so an FCrDNS
 * check would silently score every genuine GPTBot hit as unverified.
 *
 * The honest boundary: this stops casual and accidental inflation — a scraper
 * wearing a bot UA, a monitoring probe, a competitor's crawler. It does NOT
 * stop the site owner, who runs the server and can write to this table
 * directly. Any figure collected on someone's own machine is, ultimately,
 * declared by them. Treat `verified_hits` as "plausible", never as proof, and
 * never price anything off it without an independent signal.
 *
 * @package Linkquiver
 */

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

class Linkquiver_AI_Crawlers {

    const TABLE_SUFFIX  = 'linkquiver_ai_hits';

    const OPT_ENABLED   = 'linkquiver_ai_crawler_enabled';
    const OPT_RANGES    = 'linkquiver_ai_crawler_ranges';
    const OPT_RANGES_AT = 'linkquiver_ai_crawler_ranges_at';
    const OPT_RETENTION = 'linkquiver_ai_crawler_retention_days';

    const CRON_REFRESH = 'linkquiver_ai_crawler_refresh_ranges';
    const CRON_PRUNE   = 'linkquiver_ai_crawler_prune';

    /**
     * Aggregated CIDR feed, built by LinkQuiver from each vendor's own
     * published list. One request a day to a host this site already trusts,
     * instead of five requests to five third parties. Pinned here: it is never
     * read from a request, and the payload only ever widens a *verification*
     * check, so the worst a bad response can do is score real hits as
     * unverified.
     */
    const RANGES_URL = 'https://linkquiver.com/api/wordpress/linkquiver/ai-crawler-ranges';

    /**
     * Canonical bot name => User-Agent tokens that identify it.
     *
     * Order matters: the first token that matches wins, so a longer token must
     * come before any shorter token it contains (Applebot-Extended before
     * Applebot, Claude-SearchBot before ClaudeBot).
     */
    private static function catalogue() {
        return array(
            // OpenAI
            'GPTBot'             => array( 'GPTBot' ),
            'OAI-SearchBot'      => array( 'OAI-SearchBot' ),
            'ChatGPT-User'       => array( 'ChatGPT-User' ),
            // Anthropic
            'Claude-SearchBot'   => array( 'Claude-SearchBot' ),
            'Claude-User'        => array( 'Claude-User' ),
            'ClaudeBot'          => array( 'ClaudeBot', 'anthropic-ai', 'Claude-Web' ),
            // Perplexity
            'PerplexityBot'      => array( 'PerplexityBot' ),
            'Perplexity-User'    => array( 'Perplexity-User' ),
            // Others
            'Applebot-Extended'  => array( 'Applebot-Extended' ),
            'Applebot'           => array( 'Applebot' ),
            'meta-externalagent' => array( 'meta-externalagent', 'FacebookBot' ),
            'Bytespider'         => array( 'Bytespider' ),
            'Amazonbot'          => array( 'Amazonbot' ),
            'CCBot'              => array( 'CCBot' ),
            'MistralAI-User'     => array( 'MistralAI-User' ),
            'cohere-ai'          => array( 'cohere-ai' ),
            'DuckAssistBot'      => array( 'DuckAssistBot' ),
            'YouBot'             => array( 'YouBot' ),
            'Diffbot'            => array( 'Diffbot' ),
        );
    }

    /**
     * All canonical bot names, for the dashboard and for validation.
     *
     * @return string[]
     */
    public static function bot_names() {
        return array_keys( self::catalogue() );
    }

    public static function table_name() {
        global $wpdb;
        return $wpdb->prefix . self::TABLE_SUFFIX;
    }

    public static function is_enabled() {
        $enabled = ( 'no' !== get_option( self::OPT_ENABLED, 'yes' ) );
        return (bool) apply_filters( 'linkquiver_ai_crawler_logging', $enabled );
    }

    public static function retention_days() {
        $days = (int) get_option( self::OPT_RETENTION, 90 );
        if ( $days < 7 ) {
            $days = 7;
        }
        if ( $days > 730 ) {
            $days = 730;
        }
        return $days;
    }

    public static function init() {
        // priority 1: run before the redirect engine's catch-all 404 handler
        // (priority 99) can redirect the request away, so a bot that hits a
        // dead URL is still counted against post 0.
        add_action( 'template_redirect', array( __CLASS__, 'maybe_log_hit' ), 1 );

        add_action( self::CRON_REFRESH, array( __CLASS__, 'refresh_ranges' ) );
        add_action( self::CRON_PRUNE, array( __CLASS__, 'prune' ) );
    }

    /**
     * Register the two daily cron events. Idempotent: safe to call on every
     * activation and on every in-place upgrade.
     */
    public static function schedule_cron() {
        if ( ! wp_next_scheduled( self::CRON_REFRESH ) ) {
            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CRON_REFRESH );
        }
        if ( ! wp_next_scheduled( self::CRON_PRUNE ) ) {
            wp_schedule_event( time() + 2 * HOUR_IN_SECONDS, 'daily', self::CRON_PRUNE );
        }
    }

    public static function unschedule_cron() {
        wp_clear_scheduled_hook( self::CRON_REFRESH );
        wp_clear_scheduled_hook( self::CRON_PRUNE );
    }

    public static function create_table() {
        global $wpdb;

        $table   = self::table_name();
        $charset = $wpdb->get_charset_collate();

        // `hit_day` rather than `day`: DAY() is a MySQL function name and an
        // unquoted `day` column bites in enough contexts to not be worth it.
        // No IF NOT EXISTS — dbDelta parses this statement to diff columns and
        // the extra keywords break its parser (see Redirect_Engine::create_table).
        $sql = "CREATE TABLE {$table} (
            id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
            hit_day DATE NOT NULL,
            bot VARCHAR(40) NOT NULL,
            post_id BIGINT UNSIGNED NOT NULL DEFAULT 0,
            hits INT UNSIGNED NOT NULL DEFAULT 0,
            verified_hits INT UNSIGNED NOT NULL DEFAULT 0,
            PRIMARY KEY (id),
            UNIQUE KEY idx_day_bot_post (hit_day, bot, post_id),
            KEY idx_day (hit_day)
        ) {$charset};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );
    }

    public static function drop_table() {
        global $wpdb;
        $table = self::table_name();
        $wpdb->query( "DROP TABLE IF EXISTS {$table}" ); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
    }

    // ── Detection ───────────────────────────────────────────────────────

    /**
     * Canonical bot name for a User-Agent, or null.
     *
     * One compiled alternation over the whole catalogue: a single preg_match
     * per front-end request, which is the cost this feature imposes on pages
     * that are NOT bot traffic (the overwhelming majority).
     *
     * @param string $ua
     * @return string|null
     */
    public static function match_bot( $ua ) {
        if ( ! is_string( $ua ) || '' === $ua ) {
            return null;
        }

        static $regex = null;
        static $lookup = null;

        if ( null === $regex ) {
            $tokens = array();
            $lookup = array();
            foreach ( self::catalogue() as $canonical => $needles ) {
                foreach ( $needles as $needle ) {
                    $tokens[]                          = preg_quote( $needle, '#' );
                    $lookup[ strtolower( $needle ) ]   = $canonical;
                }
            }
            $regex = '#(' . implode( '|', $tokens ) . ')#i';
        }

        if ( ! preg_match( $regex, $ua, $m ) ) {
            return null;
        }
        $hit = strtolower( $m[1] );
        return isset( $lookup[ $hit ] ) ? $lookup[ $hit ] : null;
    }

    // ── Client IP ───────────────────────────────────────────────────────

    /**
     * The IP the request actually came from.
     *
     * REMOTE_ADDR is the only non-forgeable source, so it is the default. The
     * one exception is a site behind Cloudflare, where REMOTE_ADDR is always a
     * Cloudflare edge IP and every crawler would score unverified. There we
     * read CF-Connecting-IP — but ONLY after checking REMOTE_ADDR against
     * Cloudflare's own published ranges, because that header is trivially
     * forged by anyone talking to the origin directly.
     *
     * @return string Empty string when no usable IP is available.
     */
    public static function client_ip() {
        $remote = isset( $_SERVER['REMOTE_ADDR'] )
            ? trim( (string) wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
            : '';
        if ( ! filter_var( $remote, FILTER_VALIDATE_IP ) ) {
            return '';
        }

        $forwarded = isset( $_SERVER['HTTP_CF_CONNECTING_IP'] )
            ? trim( (string) wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) )
            : '';
        if ( '' === $forwarded || ! filter_var( $forwarded, FILTER_VALIDATE_IP ) ) {
            return $remote;
        }

        $ranges = self::ranges();
        $proxy  = isset( $ranges['_proxies']['cloudflare'] ) ? $ranges['_proxies']['cloudflare'] : array();
        if ( ! empty( $proxy ) && self::ip_in_any_cidr( $remote, $proxy ) ) {
            return $forwarded;
        }

        return $remote;
    }

    // ── Verification ────────────────────────────────────────────────────

    /**
     * The cached CIDR feed: [ botName => ['1.2.3.0/24', ...], _proxies => [...] ].
     *
     * @return array
     */
    public static function ranges() {
        $cached = wp_cache_get( 'ai_crawler_ranges', 'linkquiver' );
        if ( is_array( $cached ) ) {
            return $cached;
        }
        $raw = get_option( self::OPT_RANGES, array() );
        if ( ! is_array( $raw ) ) {
            $raw = array();
        }
        wp_cache_set( 'ai_crawler_ranges', $raw, 'linkquiver', HOUR_IN_SECONDS );
        return $raw;
    }

    /**
     * Is this hit provably from the vendor it claims to be?
     *
     * Fail-closed on every unknown: no ranges cached yet, bot absent from the
     * feed, unparseable IP — all score false. A hit is never counted as
     * verified on the strength of something we could not check.
     *
     * @param string $bot
     * @param string $ip
     * @return bool
     */
    public static function is_verified( $bot, $ip ) {
        if ( '' === $ip ) {
            return false;
        }
        $ranges = self::ranges();
        if ( empty( $ranges[ $bot ] ) || ! is_array( $ranges[ $bot ] ) ) {
            return false;
        }
        return self::ip_in_any_cidr( $ip, $ranges[ $bot ] );
    }

    /**
     * @param string   $ip
     * @param string[] $cidrs
     * @return bool
     */
    public static function ip_in_any_cidr( $ip, $cidrs ) {
        foreach ( $cidrs as $cidr ) {
            if ( self::ip_in_cidr( $ip, $cidr ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Byte-wise CIDR containment, IPv4 and IPv6.
     *
     * inet_pton gives the address as raw bytes for both families, so one
     * implementation covers both: compare whole bytes, then the partial byte
     * with a mask. Anything malformed returns false rather than throwing —
     * this runs on hostile input.
     *
     * @param string $ip
     * @param string $cidr
     * @return bool
     */
    public static function ip_in_cidr( $ip, $cidr ) {
        if ( ! is_string( $cidr ) || false === strpos( $cidr, '/' ) ) {
            return false;
        }
        list( $subnet, $bits ) = explode( '/', $cidr, 2 );

        $ip_bin     = @inet_pton( $ip );
        $subnet_bin = @inet_pton( $subnet );
        if ( false === $ip_bin || false === $subnet_bin ) {
            return false;
        }
        // Never compare an IPv4 address against an IPv6 subnet, or vice versa:
        // inet_pton returns 4 bytes for one and 16 for the other.
        if ( strlen( $ip_bin ) !== strlen( $subnet_bin ) ) {
            return false;
        }

        $bits = (int) $bits;
        $max  = strlen( $ip_bin ) * 8;
        if ( $bits < 0 || $bits > $max ) {
            return false;
        }

        $whole_bytes = intdiv( $bits, 8 );
        $rest_bits   = $bits % 8;

        if ( $whole_bytes > 0 && 0 !== substr_compare( $ip_bin, $subnet_bin, 0, $whole_bytes ) ) {
            return false;
        }
        if ( 0 === $rest_bits ) {
            return true;
        }

        $mask = ~( ( 1 << ( 8 - $rest_bits ) ) - 1 ) & 0xFF;
        return ( ord( $ip_bin[ $whole_bytes ] ) & $mask ) === ( ord( $subnet_bin[ $whole_bytes ] ) & $mask );
    }

    // ── Logging ─────────────────────────────────────────────────────────

    /**
     * template_redirect handler. Everything that is not a plain front-end page
     * view by a catalogued bot exits here, cheaply.
     */
    public static function maybe_log_hit() {
        if ( is_admin() || wp_doing_cron() || wp_doing_ajax() ) {
            return;
        }
        if ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() ) {
            return;
        }
        if ( ! self::is_enabled() ) {
            return;
        }

        $ua = isset( $_SERVER['HTTP_USER_AGENT'] )
            ? (string) wp_unslash( $_SERVER['HTTP_USER_AGENT'] )
            : '';
        $bot = self::match_bot( $ua );
        if ( null === $bot ) {
            return;
        }

        $ip       = self::client_ip();
        $verified = self::is_verified( $bot, $ip );

        // Throttle AFTER verification, and only the unverified tier.
        //
        // A verified hit came from an IP inside a range the vendor publishes,
        // so it cannot be manufactured by a third party — throttling it would
        // only undercount real crawls, which run fast enough to blow through
        // any sane per-minute budget. Unverified hits are exactly the forgeable
        // ones, so they get the tight bucket; that is what protects the table
        // from a UA-spoofing flood.
        $budget = $verified
            ? (int) apply_filters( 'linkquiver_ai_crawler_rate_verified', 600 )
            : (int) apply_filters( 'linkquiver_ai_crawler_rate_unverified', 30 );
        if ( ! self::consume_budget( $ip, $verified, $budget ) ) {
            return;
        }

        $post_id = 0;
        if ( is_singular() ) {
            $post_id = (int) get_queried_object_id();
        }

        self::record( $bot, $post_id, $verified );
    }

    /**
     * Per-minute token bucket, keyed on a salted hash of the IP.
     *
     * The hash is one-way and salted per site (wp_salt), so the transient name
     * cannot be walked back to an address, and the same address on two sites
     * produces two unrelated keys.
     *
     * @return bool True when the hit may be recorded.
     */
    private static function consume_budget( $ip, $verified, $budget ) {
        if ( $budget <= 0 ) {
            return true; // budget disabled by filter
        }
        $key   = 'lq_ai_' . ( $verified ? 'v_' : 'u_' ) . substr( hash_hmac( 'sha256', $ip, wp_salt( 'auth' ) ), 0, 16 );
        $count = (int) get_transient( $key );
        if ( $count >= $budget ) {
            return false;
        }
        set_transient( $key, $count + 1, MINUTE_IN_SECONDS );
        return true;
    }

    /**
     * Increment the (day, bot, post) counter.
     *
     * A single INSERT ... ON DUPLICATE KEY UPDATE: atomic, so two crawlers
     * hitting the same post in the same millisecond cannot lose a count the
     * way a read-then-write would.
     */
    public static function record( $bot, $post_id, $verified ) {
        global $wpdb;

        $table = self::table_name();
        $day   = gmdate( 'Y-m-d' );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table is built from $wpdb->prefix, every value is bound.
        $wpdb->query( $wpdb->prepare(
            "INSERT INTO {$table} (hit_day, bot, post_id, hits, verified_hits)
             VALUES (%s, %s, %d, 1, %d)
             ON DUPLICATE KEY UPDATE hits = hits + 1, verified_hits = verified_hits + %d",
            $day,
            $bot,
            (int) $post_id,
            $verified ? 1 : 0,
            $verified ? 1 : 0
        ) );
    }

    // ── Maintenance ─────────────────────────────────────────────────────

    /**
     * Daily: pull the aggregated CIDR feed.
     *
     * Kept deliberately dumb — a failure leaves the previous feed in place
     * rather than clearing it, because an empty feed would silently downgrade
     * every subsequent hit to unverified.
     */
    public static function refresh_ranges() {
        if ( ! self::is_enabled() ) {
            return;
        }

        $url = (string) apply_filters( 'linkquiver_ai_crawler_ranges_url', self::RANGES_URL );

        $response = wp_remote_get( $url, array(
            'timeout'    => 15,
            'sslverify'  => true,
            // Neutral User-Agent on purpose. WordPress's default is
            // "WordPress/6.x; https://this-site.com", which would hand the
            // site's own URL to every request. Nothing here needs to know it.
            'user-agent' => 'LinkQuiver-Plugin/' . LINKQUIVER_VERSION,
            'headers'    => array( 'Accept' => 'application/json' ),
        ) );

        if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
            return;
        }

        $data = json_decode( wp_remote_retrieve_body( $response ), true );
        if ( ! is_array( $data ) || empty( $data['bots'] ) || ! is_array( $data['bots'] ) ) {
            return;
        }

        $clean = array();
        $known = self::bot_names();
        foreach ( $data['bots'] as $bot => $cidrs ) {
            if ( ! is_string( $bot ) || ! in_array( $bot, $known, true ) || ! is_array( $cidrs ) ) {
                continue;
            }
            $valid = array();
            foreach ( $cidrs as $cidr ) {
                if ( self::is_valid_cidr( $cidr ) ) {
                    $valid[] = $cidr;
                }
            }
            if ( ! empty( $valid ) ) {
                $clean[ $bot ] = $valid;
            }
        }

        if ( empty( $clean ) ) {
            return; // never overwrite a good feed with an empty one
        }

        if ( ! empty( $data['proxies']['cloudflare'] ) && is_array( $data['proxies']['cloudflare'] ) ) {
            $proxy = array();
            foreach ( $data['proxies']['cloudflare'] as $cidr ) {
                if ( self::is_valid_cidr( $cidr ) ) {
                    $proxy[] = $cidr;
                }
            }
            if ( ! empty( $proxy ) ) {
                $clean['_proxies'] = array( 'cloudflare' => $proxy );
            }
        }

        update_option( self::OPT_RANGES, $clean, false );
        update_option( self::OPT_RANGES_AT, time(), false );
        wp_cache_delete( 'ai_crawler_ranges', 'linkquiver' );
    }

    /**
     * A CIDR string we are willing to store. Structural check only — the
     * containment test re-parses anyway and fails closed on garbage.
     */
    public static function is_valid_cidr( $cidr ) {
        if ( ! is_string( $cidr ) || false === strpos( $cidr, '/' ) || strlen( $cidr ) > 64 ) {
            return false;
        }
        list( $subnet, $bits ) = explode( '/', $cidr, 2 );
        if ( ! filter_var( $subnet, FILTER_VALIDATE_IP ) ) {
            return false;
        }
        if ( ! ctype_digit( (string) $bits ) ) {
            return false;
        }
        $bits = (int) $bits;
        $max  = filter_var( $subnet, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6 ) ? 128 : 32;
        return $bits >= 0 && $bits <= $max;
    }

    /**
     * Daily: drop rows past the retention window.
     */
    public static function prune() {
        global $wpdb;
        $table  = self::table_name();
        $cutoff = gmdate( 'Y-m-d', time() - ( self::retention_days() * DAY_IN_SECONDS ) );
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $table from $wpdb->prefix, $cutoff bound.
        $wpdb->query( $wpdb->prepare( "DELETE FROM {$table} WHERE hit_day < %s", $cutoff ) );
    }

    // ── Reporting ───────────────────────────────────────────────────────

    /**
     * Aggregates for the last N days.
     *
     * @param int $days
     * @param int $top   Max rows in the per-post breakdown.
     * @return array
     */
    public static function report( $days = 30, $top = 100 ) {
        global $wpdb;

        $days = max( 1, min( 365, (int) $days ) );
        $top  = max( 1, min( 500, (int) $top ) );

        $table = self::table_name();
        $since = gmdate( 'Y-m-d', time() - ( ( $days - 1 ) * DAY_IN_SECONDS ) );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $by_bot = $wpdb->get_results( $wpdb->prepare(
            "SELECT bot, SUM(hits) AS hits, SUM(verified_hits) AS verified
             FROM {$table} WHERE hit_day >= %s GROUP BY bot ORDER BY hits DESC",
            $since
        ), ARRAY_A );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $by_day = $wpdb->get_results( $wpdb->prepare(
            "SELECT hit_day, SUM(hits) AS hits, SUM(verified_hits) AS verified
             FROM {$table} WHERE hit_day >= %s GROUP BY hit_day ORDER BY hit_day ASC",
            $since
        ), ARRAY_A );

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $by_post = $wpdb->get_results( $wpdb->prepare(
            "SELECT post_id, SUM(hits) AS hits, SUM(verified_hits) AS verified
             FROM {$table} WHERE hit_day >= %s AND post_id > 0
             GROUP BY post_id ORDER BY hits DESC LIMIT %d",
            $since,
            $top
        ), ARRAY_A );

        $posts = array();
        foreach ( (array) $by_post as $row ) {
            $post_id = (int) $row['post_id'];
            $posts[] = array(
                'post_id'  => $post_id,
                'url'      => get_permalink( $post_id ) ?: null,
                'title'    => get_the_title( $post_id ) ?: null,
                'hits'     => (int) $row['hits'],
                'verified' => (int) $row['verified'],
            );
        }

        $bots  = array();
        $total = 0;
        $vtot  = 0;
        foreach ( (array) $by_bot as $row ) {
            $bots[ (string) $row['bot'] ] = array(
                'hits'     => (int) $row['hits'],
                'verified' => (int) $row['verified'],
            );
            $total += (int) $row['hits'];
            $vtot  += (int) $row['verified'];
        }

        $daily = array();
        foreach ( (array) $by_day as $row ) {
            $daily[] = array(
                'day'      => (string) $row['hit_day'],
                'hits'     => (int) $row['hits'],
                'verified' => (int) $row['verified'],
            );
        }

        return array(
            'enabled'           => self::is_enabled(),
            'days'              => $days,
            'since'             => $since,
            'retention_days'    => self::retention_days(),
            'ranges_updated_at' => (int) get_option( self::OPT_RANGES_AT, 0 ),
            'total_hits'        => $total,
            'total_verified'    => $vtot,
            'bots'              => $bots,
            'daily'             => $daily,
            'posts'             => $posts,
        );
    }
}
