<?php
/**
 * SEO-network sensor (the "benchmark network").
 *
 * Aggregate-only telemetry, available on every tier, ON BY DEFAULT for fresh
 * installs (setting `intel_enabled`, default on — surfaced + editable as a
 * consent step in onboarding and under Tools → SEO network) AND the site has
 * completed registration. Once a day it builds a SeoSnapshot from
 * Seoforge_Reports::aggregate() (the SAME numbers the local dashboard shows —
 * counts, percentages and p75 web-vitals, never a single visitor row) and
 * pushes it, HMAC-signed, to the central hub at shieldforge-intel.brainwerk.at.
 * Contributing is free; the richer agency fleet overview + peer benchmarks the
 * hub derives from the pooled data are a Pro/hub-side feature. Nothing
 * identifying ever leaves the site, preserving the "your data stays first-party"
 * stance.
 *
 * Transport mirrors the ShieldForge security sensor exactly:
 *   register : POST /v1/sites/register  (X-SF-Register-Token)
 *   push     : POST /v1/seo/snapshots   (X-SF-Site / X-SF-Timestamp / X-SF-Signature)
 *   signature: hash_hmac('sha256', "$ts\n$body", $secret)  (hex)
 *
 * Egress-locked installs (e.g. a school behind a strict firewall that blocks
 * outbound HTTPS) never push; the hub collects their snapshots via the inbound
 * pull route instead (the site makes zero outbound connections).
 *
 * Class name keeps the historical `Seoforge_Pro_*` prefix for backward
 * compatibility; the module itself is no longer premium-gated.
 *
 * @package Brainwerk SEO Suite
 */

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

class Seoforge_Intel {

    /** Settings keys (stored in the shared seoforge_settings option). */
    const OPT_ENABLED  = 'intel_enabled';
    const OPT_TOKEN    = 'intel_register_token';
    const OPT_VERTICAL = 'intel_vertical';

    /** Standalone option keys (credentials + runtime state). */
    const REG_OPTION   = 'seoforge_intel_registration';
    const STATE_OPTION = 'seoforge_intel_state';

    /** Cron. */
    const CRON_HOOK = 'seoforge_pro_intel_push';

    /** Default hub base URL (filterable). */
    const API_BASE = 'https://shieldforge-intel.brainwerk.at';

    /**
     * Below this many pageviews in the snapshot day, ANY aggregate (a country
     * share, an hour bucket, a check-fail rate...) risks describing a single
     * visitor rather than a population — the opposite of anonymous. Below
     * threshold we suppress the whole payload down to a bare marker.
     * Filterable so tests can exercise both branches without seeding traffic.
     */
    const MIN_PAGEVIEWS = 50;

    /** The 9 analyzer check keys — see Seoforge_Analyzer::analyze(). */
    const ANALYZER_CHECK_KEYS = array(
        'title_length', 'meta_description', 'focus_in_title', 'focus_in_slug',
        'focus_in_intro', 'internal_links', 'image_alt', 'word_count', 'readability',
    );

    /** Mainstream search-engine crawlers — "declared" alongside the AI-bot list. */
    const MAINSTREAM_BOTS = array(
        'Googlebot', 'Bingbot', 'DuckDuckBot', 'YandexBot', 'Baiduspider', 'Slurp', 'Applebot',
    );

    /** Allowed self-declared peer groups (mirrors the hub's Vertical enum). */
    public static function verticals() {
        return array( 'education', 'ecommerce', 'blog_media', 'corporate', 'nonprofit', 'government', 'other' );
    }

    public static function register( Seoforge_Loader $loader ) {
        $loader->add_action( self::CRON_HOOK, __CLASS__, 'cron_push' );
        $loader->add_action( 'init', __CLASS__, 'maybe_schedule' );
        $loader->add_action( 'admin_post_seoforge_intel_connect', __CLASS__, 'handle_connect' );
        $loader->add_action( 'admin_post_seoforge_intel_push_now', __CLASS__, 'handle_push_now' );
        $loader->add_action( 'admin_post_seoforge_intel_disconnect', __CLASS__, 'handle_disconnect' );
        $loader->add_action( 'admin_notices', __CLASS__, 'maybe_admin_notice' );
        $loader->add_action( 'admin_post_seoforge_intel_dismiss_notice', __CLASS__, 'handle_dismiss_notice' );
        $loader->add_action( 'rest_api_init', __CLASS__, 'register_relay_route' );
    }

    /** Option holding the inbound-pull relay secret (see relay_secret()). */
    const RELAY_SECRET_OPTION = 'seoforge_intel_relay_secret';

    /** Option flag: the one-time opt-in invitation has been dismissed. */
    const NOTICE_OPTION = 'seoforge_intel_notice_dismissed';

    /* ---------------------------------------------------------------- state */

    public static function opted_in() {
        return (bool) Seoforge_Settings::get( self::OPT_ENABLED, false );
    }

    public static function api_base() {
        $base = (string) apply_filters( 'seoforge_pro_intel_endpoint', self::API_BASE );
        return untrailingslashit( $base );
    }

    public static function get_registration() {
        $reg = is_multisite() ? get_site_option( self::REG_OPTION, array() ) : get_option( self::REG_OPTION, array() );
        return is_array( $reg ) ? $reg : array();
    }

    private static function set_registration( $site_id, $secret ) {
        $data = array(
            'site_id' => (string) $site_id,
            'secret'  => (string) $secret,
            'at'      => gmdate( 'Y-m-d\TH:i:s\Z' ),
        );
        if ( is_multisite() ) {
            update_site_option( self::REG_OPTION, $data );
        } else {
            update_option( self::REG_OPTION, $data, false );
        }
        return $data;
    }

    /** Public: Seoforge_Notary borrows this channel's credentials and must be able to invalidate them on a 401/403, same as push() does for itself below. */
    public static function clear_registration() {
        if ( is_multisite() ) {
            delete_site_option( self::REG_OPTION );
        } else {
            delete_option( self::REG_OPTION );
        }
    }

    public static function is_registered() {
        $reg = self::get_registration();
        return ! empty( $reg['site_id'] ) && ! empty( $reg['secret'] );
    }

    private static function state() {
        $s = get_option( self::STATE_OPTION, array() );
        return is_array( $s ) ? $s : array();
    }

    private static function set_state( array $changes ) {
        update_option( self::STATE_OPTION, array_merge( self::state(), $changes ), false );
    }

    /* ------------------------------------------------------------- transport */

    private static function sign( $secret, $ts, $body ) {
        return hash_hmac( 'sha256', $ts . "\n" . $body, $secret );
    }

    /**
     * One-time registration: obtain a pseudonymous site_id + shared secret.
     * Returns array{ok:bool,message:string}.
     */
    /**
     * The bootstrap token used to register this site with the hub.
     *
     * Priority: the per-site token the admin pasted in (private hubs / our own
     * egress-locked schools) wins; otherwise fall back to a build-level "app"
     * token that a distribution may bake in via the SEOFORGE_INTEL_APP_TOKEN
     * constant or the `seoforge_intel_app_token` filter, so a plain opt-in on
     * the public benchmark network works without the user pasting anything.
     */
    public static function register_token() {
        $token = trim( (string) Seoforge_Settings::get( self::OPT_TOKEN, '' ) );
        if ( '' !== $token ) {
            return $token;
        }
        $app = defined( 'SEOFORGE_INTEL_APP_TOKEN' ) ? (string) SEOFORGE_INTEL_APP_TOKEN : '';
        return trim( (string) apply_filters( 'seoforge_intel_app_token', $app ) );
    }

    public static function do_register( $force = false ) {
        if ( ! $force && self::is_registered() ) {
            return array( 'ok' => true, 'message' => 'already_registered' );
        }
        $token = self::register_token();
        if ( '' === $token ) {
            return array( 'ok' => false, 'message' => 'missing_register_token' );
        }
        $resp = wp_remote_post( self::api_base() . '/v1/sites/register', array(
            'timeout' => 15,
            'headers' => array(
                'Content-Type'        => 'application/json',
                'X-SF-Register-Token' => $token,
            ),
            'body'    => wp_json_encode( array( 'label' => home_url( '/' ) ) ),
        ) );
        if ( is_wp_error( $resp ) ) {
            return array( 'ok' => false, 'message' => $resp->get_error_message() );
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );
        $data = json_decode( wp_remote_retrieve_body( $resp ), true );
        if ( 200 !== $code || empty( $data['site_id'] ) || empty( $data['site_secret'] ) ) {
            $msg = is_array( $data ) && isset( $data['error'] ) ? $data['error'] : 'http_' . $code;
            return array( 'ok' => false, 'message' => (string) $msg );
        }
        self::set_registration( $data['site_id'], $data['site_secret'] );
        self::set_state( array( 'last_error' => '', 'registered_at' => gmdate( 'Y-m-d\TH:i:s\Z' ) ) );
        self::maybe_schedule();
        return array( 'ok' => true, 'message' => 'registered' );
    }

    /**
     * Build + push yesterday's aggregate snapshot. Returns array{ok,message}.
     */
    public static function push() {
        if ( ! self::opted_in() ) {
            return array( 'ok' => false, 'message' => 'not_opted_in' );
        }
        if ( ! self::is_registered() ) {
            return array( 'ok' => false, 'message' => 'not_registered' );
        }
        $reg      = self::get_registration();
        $snapshot = self::build_snapshot( $reg['site_id'] );
        if ( null === $snapshot ) {
            return array( 'ok' => false, 'message' => 'no_data' );
        }
        $body = wp_json_encode( array( 'snapshots' => array( $snapshot ) ) );
        $ts   = (string) time();
        $sig  = self::sign( $reg['secret'], $ts, $body );

        $resp = wp_remote_post( self::api_base() . '/v1/seo/snapshots', array(
            'timeout' => 20,
            'headers' => array(
                'Content-Type'   => 'application/json',
                'X-SF-Site'      => $reg['site_id'],
                'X-SF-Timestamp' => $ts,
                'X-SF-Signature' => $sig,
            ),
            'body'    => $body,
        ) );

        if ( is_wp_error( $resp ) ) {
            self::set_state( array( 'last_error' => $resp->get_error_message(), 'last_checked' => gmdate( 'Y-m-d\TH:i:s\Z' ) ) );
            return array( 'ok' => false, 'message' => $resp->get_error_message() );
        }
        $code = (int) wp_remote_retrieve_response_code( $resp );
        if ( 200 === $code ) {
            self::set_state( array( 'last_sent' => gmdate( 'Y-m-d\TH:i:s\Z' ), 'last_error' => '', 'last_checked' => gmdate( 'Y-m-d\TH:i:s\Z' ) ) );
            return array( 'ok' => true, 'message' => 'sent' );
        }
        // A rejected identity means our credentials are stale — force re-register.
        if ( 401 === $code || 403 === $code ) {
            self::clear_registration();
        }
        $data = json_decode( wp_remote_retrieve_body( $resp ), true );
        $msg  = is_array( $data ) && isset( $data['error'] ) ? $data['error'] : 'http_' . $code;
        self::set_state( array( 'last_error' => (string) $msg, 'last_checked' => gmdate( 'Y-m-d\TH:i:s\Z' ) ) );
        return array( 'ok' => false, 'message' => (string) $msg );
    }

    /**
     * Map Seoforge_Reports::aggregate() for the previous full UTC day onto a
     * SeoSnapshot. Returns null when there is nothing to report.
     */
    public static function build_snapshot( $site_id ) {
        if ( ! class_exists( 'Seoforge_Reports' ) ) {
            return null;
        }
        $start_ts = strtotime( gmdate( 'Y-m-d', time() - DAY_IN_SECONDS ) . ' 00:00:00 UTC' );
        $end_ts   = $start_ts + DAY_IN_SECONDS - 1;
        $agg      = Seoforge_Reports::aggregate( $start_ts, $end_ts );
        $k        = isset( $agg['kpis'] ) ? $agg['kpis'] : array();
        if ( empty( $k ) ) {
            return null;
        }

        $vertical = (string) Seoforge_Settings::get( self::OPT_VERTICAL, 'other' );
        if ( ! in_array( $vertical, self::verticals(), true ) ) {
            $vertical = 'other';
        }

        $snapshot = array(
            'site_id'      => (string) $site_id,
            'snapshot_id'  => self::snapshot_id( $site_id, 'day', $start_ts ),
            'period_start' => gmdate( 'Y-m-d\TH:i:s\Z', $start_ts ),
            'period_end'   => gmdate( 'Y-m-d\TH:i:s\Z', $end_ts ),
            'granularity'  => 'day',
            'vertical'     => $vertical,
        );
        if ( is_multisite() ) {
            $host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
            if ( $host ) {
                $snapshot['node'] = $host;
            }
        }

        $views         = (int) $k['views'];
        $min_pageviews = (int) apply_filters( 'seoforge_intel_min_pageviews', self::MIN_PAGEVIEWS );
        if ( $views < $min_pageviews ) {
            // At this volume a "country" or "check-fail" percentage is one
            // person — suppress everything except the bare period marker.
            $snapshot['low_volume'] = true;
            return $snapshot;
        }

        $metrics = array(
            'pageviews'        => (int) $k['views'],
            'unique_visitors'  => (int) $k['uniq'],
            'sessions'         => (int) $k['sessions'],
            'pages_per_session' => (float) $k['pps'],
            'bounce_pct'       => self::pct( $k['bounce_pct'] ),
            'avg_session_sec'  => (int) $k['duration_avg'],
            'engaged_pct'      => self::pct( $k['engaged_pct'] ),
            'new_pct'          => self::pct( $k['new_pct'] ),
            'scroll_avg_pct'   => self::pct( $k['scroll_avg'] ),
            'conversions'      => (int) $k['conversions'],
            'conv_rate'        => self::pct( $k['conv_rate'] ),
            'bot_pct'          => self::pct( $k['bot_pct'] ),
        );
        // Core Web Vitals only when actually measured.
        if ( ! empty( $k['lcp_p75'] ) ) {
            $metrics['lcp_ms'] = (int) $k['lcp_p75'];
        }
        if ( ! empty( $k['inp_p75'] ) ) {
            $metrics['inp_ms'] = (int) $k['inp_p75'];
        }
        if ( ! empty( $k['cls_p75'] ) ) {
            $metrics['cls_x1000'] = (int) round( $k['cls_p75'] * 1000 );
        }

        // Traffic-source mix.
        $src = isset( $agg['src_pct'] ) ? $agg['src_pct'] : array();
        $metrics['source_search_pct']   = self::pct( $src['search'] ?? 0 );
        $metrics['source_direct_pct']   = self::pct( $src['direct'] ?? 0 );
        $metrics['source_social_pct']   = self::pct( $src['social'] ?? 0 );
        $metrics['source_referral_pct'] = self::pct( $src['referral'] ?? 0 );
        $metrics['source_campaign_pct'] = self::pct( $src['campaign'] ?? 0 );
        if ( isset( $src['ai'] ) ) {
            $metrics['source_ai_pct'] = self::pct( $src['ai'] );
        }

        // Device mix.
        $dev = isset( $agg['dev_pct'] ) ? $agg['dev_pct'] : array();
        $metrics['device_mobile_pct']  = self::pct( $dev['mobile'] ?? 0 );
        $metrics['device_desktop_pct'] = self::pct( $dev['desktop'] ?? 0 );
        $metrics['device_tablet_pct']  = self::pct( $dev['tablet'] ?? 0 );

        // Top countries as CC => % of (geo-resolved) pageviews — aggregate only.
        // Hardened: a country with a handful of hits AND a tiny share is one
        // or two people, not a population — dropped rather than sent.
        $tc        = isset( $agg['top_countries'] ) ? (array) $agg['top_countries'] : array();
        $countries = self::countries_pct( $tc );
        if ( $countries ) {
            $metrics['top_countries'] = $countries;
        }

        // GEO/AEO configuration maturity (site setup state, not visitor data).
        $metrics['geo_llms_enabled']    = (bool) Seoforge_Settings::get( 'geo_llms_enabled', false );
        $metrics['geo_faq_enabled']     = (bool) Seoforge_Settings::get( 'seo_schema_faq_enabled', false );
        $blocked                        = (array) Seoforge_Settings::get( 'geo_blocked_ai_bots', array() );
        $metrics['geo_ai_bots_blocked'] = count( $blocked );

        $snapshot['metrics'] = $metrics;

        $blog_id = Seoforge_Utils::current_blog_id();
        $start_sql = gmdate( 'Y-m-d H:i:s', $start_ts );
        $end_sql   = gmdate( 'Y-m-d H:i:s', $end_ts );

        $analyzer = self::build_analyzer_group( $blog_id );
        if ( null !== $analyzer ) {
            $snapshot['analyzer'] = $analyzer;
        }

        $snapshot['timing'] = array(
            'hours'    => self::hours_pct( Seoforge_Stats::hour_profile( $start_sql, $end_sql, $blog_id ) ),
            'weekdays' => self::weekdays_pct( Seoforge_Stats::weekday_profile( 28, $blog_id ) ),
        );

        $ai = self::build_ai_group( $blog_id, $start_sql, $end_sql, $blocked );
        if ( null !== $ai ) {
            $snapshot['ai'] = $ai;
        }

        $snapshot['bots'] = self::build_bots_group( $blog_id, $start_sql, $end_sql );
        $snapshot['env']  = self::build_env_group( $views );

        $quality = self::build_quality_group( $blog_id, $start_sql, $end_sql );
        if ( null !== $quality ) {
            $snapshot['quality'] = $quality;
        }

        return $snapshot;
    }

    /* --------------------------------------------------- snapshot group builders
     * Every helper below is split into a pure computation (no WP/DB access —
     * directly unit-testable) plus a thin data-fetch wrapper, so the anonymity
     * rules (thresholds, banding, omission) can be tested without a database.
     */

    /**
     * Country hits -> CC => % of geo-resolved pageviews, top 20 by share.
     * Drops any country under BOTH 5 hits and 5% share — at that volume a
     * "country" is one or two visitors, not a population. Pure/testable.
     */
    public static function countries_pct( array $tc ) {
        $total = 0;
        foreach ( $tc as $c ) {
            $total += (int) $c['hits'];
        }
        if ( $total <= 0 ) {
            return array();
        }
        $countries = array();
        foreach ( array_slice( $tc, 0, 20 ) as $c ) {
            $cc = strtoupper( substr( (string) $c['country'], 0, 2 ) );
            if ( '' === $cc ) {
                continue;
            }
            $hits  = (int) $c['hits'];
            $share = round( $hits / $total * 100, 1 );
            if ( $hits < 5 && $share < 5 ) {
                continue;
            }
            $countries[ $cc ] = self::pct( $share );
        }
        return $countries;
    }

    /**
     * Analyzer score distribution + per-check failure rates. Source data lives
     * in the plugin's own wp_seoforge_meta table (score / score_payload — see
     * Seoforge_Analyzer::analyze_and_save()), one efficient join to wp_posts
     * instead of a get_post_meta() loop. Omitted entirely under 5 analysed
     * posts (n_posts is itself part of the payload so the hub can see why).
     */
    public static function build_analyzer_group( $blog_id ) {
        global $wpdb;
        $meta_t = Seoforge_Utils::table( 'meta' );
        $cap    = (int) apply_filters( 'seoforge_intel_analyzer_cap', 5000 );
        $rows   = (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT m.score AS score, m.score_payload AS score_payload FROM %i m
             INNER JOIN {$wpdb->posts} p ON p.ID = m.post_id
             WHERE m.blog_id = %d AND p.post_status = 'publish' AND m.score_payload IS NOT NULL
             ORDER BY m.id DESC LIMIT %d",
            $meta_t, (int) $blog_id, $cap
        ), ARRAY_A );
        return self::analyzer_group( $rows );
    }

    /** Pure: rows of {score, score_payload(JSON)} -> the analyzer group, or null under 5 posts. */
    public static function analyzer_group( array $rows ) {
        $n = count( $rows );
        if ( $n < 5 ) {
            return null;
        }
        $dist = array( '0_40' => 0, '41_60' => 0, '61_80' => 0, '81_100' => 0 );
        $fail = array_fill_keys( self::ANALYZER_CHECK_KEYS, 0 );
        foreach ( $rows as $r ) {
            $score = (int) $r['score'];
            if ( $score <= 40 ) {
                $dist['0_40']++;
            } elseif ( $score <= 60 ) {
                $dist['41_60']++;
            } elseif ( $score <= 80 ) {
                $dist['61_80']++;
            } else {
                $dist['81_100']++;
            }
            $payload = json_decode( (string) $r['score_payload'], true );
            if ( ! is_array( $payload ) ) {
                continue;
            }
            foreach ( self::ANALYZER_CHECK_KEYS as $key ) {
                $status = isset( $payload[ $key ]['status'] ) ? $payload[ $key ]['status'] : '';
                if ( 'fail' === $status || 'warn' === $status ) {
                    $fail[ $key ]++;
                }
            }
        }
        $dist_pct = array();
        foreach ( $dist as $key => $count ) {
            $dist_pct[ $key ] = (int) round( $count / $n * 100 );
        }
        $fail_pct = array();
        foreach ( $fail as $key => $count ) {
            $fail_pct[ $key ] = (int) round( $count / $n * 100 );
        }
        return array(
            'n_posts'        => $n,
            'score_dist'     => $dist_pct,
            'check_fail_pct' => $fail_pct,
        );
    }

    /** Pure: hour=>hits rows (any subset of 0..23) -> 24 integer percentages. */
    public static function hours_pct( array $rows ) {
        $counts = array_fill( 0, 24, 0 );
        $total  = 0;
        foreach ( $rows as $r ) {
            $h = (int) $r['hour'];
            if ( $h >= 0 && $h <= 23 ) {
                $counts[ $h ] += (int) $r['hits'];
                $total        += (int) $r['hits'];
            }
        }
        $out = array();
        foreach ( $counts as $c ) {
            $out[] = $total > 0 ? (int) round( $c / $total * 100 ) : 0;
        }
        return $out;
    }

    /** Pure: weekday(0=Mon..6=Sun)=>hits rows -> 7 integer percentages, Mon..Sun. */
    public static function weekdays_pct( array $rows ) {
        $counts = array_fill( 0, 7, 0 );
        $total  = 0;
        foreach ( $rows as $r ) {
            $wd = (int) $r['wd'];
            if ( $wd >= 0 && $wd <= 6 ) {
                $counts[ $wd ] += (int) $r['hits'];
                $total         += (int) $r['hits'];
            }
        }
        $out = array();
        foreach ( $counts as $c ) {
            $out[] = $total > 0 ? (int) round( $c / $total * 100 ) : 0;
        }
        return $out;
    }

    /**
     * KI-crawler visibility. Fetches every bot user_agent seen that day (one
     * grouped query — bot UA cardinality per day is small), classifies each
     * against the pinned AI-bot token list in PHP, and never sends the raw
     * user_agent string, only the canonical token names. Omitted entirely
     * when there is zero AI-crawler traffic AND zero AI-referral traffic.
     */
    /**
     * Fetch the raw bot-UA / AI-referral rows for one blog/day window. Split out
     * from build_ai_group() so Seoforge_History::capture_day() can reuse the exact
     * same query + classification the outbound snapshot uses, without inheriting
     * the outbound path's low-pageview suppression (that suppression happens one
     * level up, in build_snapshot(), by never calling this at all — see the class
     * docblock above "snapshot group builders").
     *
     * @return array{0: array, 1: int} [ua_rows, ai_referrals]
     */
    public static function fetch_ai_rows( $blog_id, $start_sql, $end_sql ) {
        global $wpdb;
        $visits_t = Seoforge_Utils::table( 'visits' );
        $ua_rows  = (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT user_agent, COUNT(*) AS hits FROM %i
             WHERE is_bot = 1 AND blog_id = %d AND created_at BETWEEN %s AND %s
             GROUP BY user_agent",
            $visits_t, (int) $blog_id, $start_sql, $end_sql
        ), ARRAY_A );
        $ai_referrals = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM %i
             WHERE is_bot = 0 AND source_type = 'ai' AND blog_id = %d AND created_at BETWEEN %s AND %s",
            $visits_t, (int) $blog_id, $start_sql, $end_sql
        ) );
        return array( $ua_rows, $ai_referrals );
    }

    public static function build_ai_group( $blog_id, $start_sql, $end_sql, array $blocked ) {
        list( $ua_rows, $ai_referrals ) = self::fetch_ai_rows( $blog_id, $start_sql, $end_sql );

        $llms_on = (bool) Seoforge_Settings::get( 'geo_llms_enabled', false );
        return self::ai_group( $ua_rows, $ai_referrals, array_values( $blocked ), array(
            'robots_txt'           => (bool) get_option( 'blog_public', 1 ),
            'llms_txt'             => $llms_on,
            'llms_txt_directives'  => $llms_on ? self::llms_directive_count() : 0,
        ) );
    }

    /**
     * Pure: {user_agent,hits} rows -> per-AI-family raw hit counts (first
     * matching family wins — one UA is one crawler). Shared by ai_group()
     * (which turns these into coarse percentages for the outbound snapshot)
     * and Seoforge_History::capture_day() (which stores the raw counts
     * locally, where there's no anonymity reason to band them).
     */
    public static function classify_ai_family_hits( array $ua_rows ) {
        $known      = Seoforge_Robots::ai_bots();
        $per_family = array_fill_keys( array_keys( $known ), 0 );
        foreach ( $ua_rows as $r ) {
            $ua   = (string) $r['user_agent'];
            $hits = (int) $r['hits'];
            foreach ( $known as $token => $label ) {
                if ( false !== stripos( $ua, $token ) ) {
                    $per_family[ $token ] += $hits;
                    break; // first matching family wins — one UA is one crawler.
                }
            }
        }
        return $per_family;
    }

    /** Pure: {user_agent,hits} bot rows + AI-referral count -> the ai group, or null when there's no AI signal at all. */
    public static function ai_group( array $ua_rows, $ai_referrals, array $blocked, array $config ) {
        $per_family = self::classify_ai_family_hits( $ua_rows );
        $total      = array_sum( $per_family );
        $ai_referrals = (int) $ai_referrals;
        if ( 0 === $total && 0 === $ai_referrals ) {
            return null;
        }
        $crawl_pct = array();
        if ( $total > 0 ) {
            foreach ( $per_family as $token => $hits ) {
                if ( $hits > 0 ) {
                    $crawl_pct[ $token ] = (int) round( $hits / $total * 100 );
                }
            }
        }
        return array_merge( array(
            'crawl_pct'         => $crawl_pct,
            'crawl_band'        => self::crawl_band( $total ),
            'crawl_to_referral' => self::crawl_to_referral( $total, $ai_referrals ),
            'blocked'           => array_values( $blocked ),
        ), $config );
    }

    /** Pure: exact crawl-hit count -> a coarse band, never the exact number. */
    public static function crawl_band( $n ) {
        $n = (int) $n;
        if ( $n <= 0 ) {
            return '0';
        }
        if ( $n < 100 ) {
            return '1-99';
        }
        if ( $n < 1000 ) {
            return '100-999';
        }
        return '1k+';
    }

    /** Pure: AI-crawler hits per 100 AI-referral visits, capped; -1 with no AI referrals. */
    public static function crawl_to_referral( $crawl_hits, $ai_referrals ) {
        $ai_referrals = (int) $ai_referrals;
        if ( $ai_referrals <= 0 ) {
            return -1;
        }
        return (int) min( 9999, round( (int) $crawl_hits / $ai_referrals * 100 ) );
    }

    /** Count of pages/posts the llms.txt manifest would currently list (config, not traffic), capped as the manifest itself is. */
    private static function llms_directive_count() {
        if ( ! function_exists( 'wp_count_posts' ) ) {
            return 0;
        }
        $pages = (int) ( wp_count_posts( 'page' )->publish ?? 0 );
        $posts = (int) ( wp_count_posts( 'post' )->publish ?? 0 );
        return min( 100, $pages ) + min( 50, $posts );
    }

    /**
     * Declared / suspect / cloaked bot mix. `cloaked_pct` has two mutually
     * exclusive sources, never stacked: when the hub's bot-reputation feed
     * (task P3, `Seoforge_Bot_Reputation`) is available, it is the hits whose
     * UA claims a family/pattern the hub rates `suspect` — a real, pooled
     * behavioural signal. Absent a feed, it falls back to the local heuristic:
     * a hit whose UA claims a known, well-behaved family (AI-bot list +
     * mainstream search engines) but whose response status was >= 400 — a
     * real crawler rarely hammers dead URLs. Read-time only; see
     * Seoforge_Bot_Reputation's docblock for why this never affects storage.
     */
    public static function build_bots_group( $blog_id, $start_sql, $end_sql ) {
        global $wpdb;
        $visits_t = Seoforge_Utils::table( 'visits' );
        $rows     = (array) $wpdb->get_results( $wpdb->prepare(
            "SELECT user_agent, status_code, COUNT(*) AS hits FROM %i
             WHERE is_bot = 1 AND blog_id = %d AND created_at BETWEEN %s AND %s
             GROUP BY user_agent, status_code",
            $visits_t, (int) $blog_id, $start_sql, $end_sql
        ), ARRAY_A );
        $known  = array_merge( array_keys( Seoforge_Robots::ai_bots() ), self::MAINSTREAM_BOTS );
        $result = self::classify_bot_rows( $rows, $known );
        $reputation_cloaked = self::cloaked_pct_from_reputation( $rows );
        if ( null !== $reputation_cloaked ) {
            $result['cloaked_pct'] = $reputation_cloaked;
        }
        return $result;
    }

    /**
     * Pure: hub-reputation-based cloaked_pct, or null when the feed has
     * nothing to say (caller keeps the local heuristic in that case — the
     * two signals are never summed).
     */
    public static function cloaked_pct_from_reputation( array $rows ) {
        if ( ! class_exists( 'Seoforge_Bot_Reputation' ) || ! Seoforge_Bot_Reputation::available() ) {
            return null;
        }
        $total   = 0;
        $suspect = 0;
        foreach ( $rows as $r ) {
            $hits = isset( $r['hits'] ) ? (int) $r['hits'] : 0;
            $ua   = isset( $r['user_agent'] ) ? (string) $r['user_agent'] : '';
            $total += $hits;
            if ( 'suspect' === Seoforge_Bot_Reputation::verdict_for( $ua ) ) {
                $suspect += $hits;
            }
        }
        if ( 0 === $total ) {
            return 0;
        }
        return (int) round( $suspect / $total * 100 );
    }

    /** Pure: {user_agent,status_code,hits} rows + known-token list -> declared/suspect/cloaked percentages. */
    public static function classify_bot_rows( array $rows, array $known_tokens ) {
        $total = 0;
        $declared = 0;
        $suspect  = 0;
        $cloaked  = 0;
        foreach ( $rows as $r ) {
            $hits   = (int) $r['hits'];
            $ua     = (string) $r['user_agent'];
            $status = (int) $r['status_code'];
            $total += $hits;
            $known  = false;
            foreach ( $known_tokens as $token ) {
                if ( '' !== $token && false !== stripos( $ua, $token ) ) {
                    $known = true;
                    break;
                }
            }
            if ( $known ) {
                $declared += $hits;
                if ( $status >= 400 ) {
                    $cloaked += $hits;
                }
            } else {
                $suspect += $hits;
            }
        }
        if ( 0 === $total ) {
            return array( 'declared_pct' => 0, 'suspect_pct' => 0, 'cloaked_pct' => 0 );
        }
        return array(
            'declared_pct' => (int) round( $declared / $total * 100 ),
            'suspect_pct'  => (int) round( $suspect / $total * 100 ),
            'cloaked_pct'  => (int) round( $cloaked / $total * 100 ),
        );
    }

    /** Coarse, deliberately non-identifying comparability bands — never exact versions or counts. */
    public static function build_env_group( $views ) {
        global $wpdb;
        $public_types = get_post_types( array( 'public' => true ) );
        $post_count   = 0;
        if ( ! empty( $public_types ) ) {
            $placeholders = implode( ',', array_fill( 0, count( $public_types ), '%s' ) );
            $post_count   = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ($placeholders)",
                $public_types
            ) );
        }
        $cache = (bool) ( ( defined( 'WP_CACHE' ) && WP_CACHE ) || ( defined( 'WP_CONTENT_DIR' ) && file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) ) );
        return array(
            'php_major'    => self::php_major_band( PHP_VERSION ),
            'wp_minor'     => self::wp_minor_band( get_bloginfo( 'version' ) ),
            'size_band'    => self::band4( $post_count ),
            'traffic_band' => self::band4( $views ),
            'cache'        => $cache,
        );
    }

    /** Pure: PHP version string ("8.3.31") -> major-only band ("8.x"). */
    public static function php_major_band( $version ) {
        $parts = explode( '.', (string) $version );
        return isset( $parts[0] ) && '' !== $parts[0] ? $parts[0] . '.x' : (string) $version;
    }

    /** Pure: WP version string ("6.7.1") -> major.minor band ("6.7"). */
    public static function wp_minor_band( $version ) {
        $parts = explode( '.', (string) $version );
        return ( isset( $parts[0] ) && isset( $parts[1] ) ) ? $parts[0] . '.' . $parts[1] : (string) $version;
    }

    /** Pure: shared 4-bucket band used for both post-count and pageview comparability. */
    public static function band4( $n ) {
        $n = (int) $n;
        if ( $n < 100 ) {
            return '<100';
        }
        if ( $n < 1000 ) {
            return '100-1k';
        }
        if ( $n < 10000 ) {
            return '1k-10k';
        }
        return '>10k';
    }

    /**
     * CWV p50s + sample count. `cwv_samples` travels alongside the medians so
     * the hub can discount a p50 built from a handful of beacons; omitted
     * entirely under 10 samples. No ttfb column exists in the visits table
     * today, so `ttfb_p75_ms` is intentionally never emitted (would otherwise
     * be an invented number).
     */
    public static function build_quality_group( $blog_id, $start_sql, $end_sql ) {
        global $wpdb;
        $visits_t = Seoforge_Utils::table( 'visits' );
        $samples  = (int) $wpdb->get_var( $wpdb->prepare(
            "SELECT COUNT(*) FROM %i WHERE blog_id = %d AND created_at BETWEEN %s AND %s
             AND ( lcp_ms IS NOT NULL OR inp_ms IS NOT NULL OR cls_x1000 IS NOT NULL )",
            $visits_t, (int) $blog_id, $start_sql, $end_sql
        ) );
        if ( $samples < 10 ) {
            return null;
        }
        return array(
            'cwv_samples'   => $samples,
            'lcp_p50_ms'    => self::median( $visits_t, 'lcp_ms', $blog_id, $start_sql, $end_sql ),
            'inp_p50_ms'    => self::median( $visits_t, 'inp_ms', $blog_id, $start_sql, $end_sql ),
            'cls_p50_x1000' => self::median( $visits_t, 'cls_x1000', $blog_id, $start_sql, $end_sql ),
        );
    }

    /** Median of one CWV column for the day, via the count+offset trick (no window functions needed). */
    private static function median( $tbl, $col, $blog_id, $start_sql, $end_sql ) {
        global $wpdb;
        // Column is always a literal per branch — never taken from a variable.
        switch ( $col ) {
            case 'lcp_ms':
                $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE blog_id = %d AND lcp_ms IS NOT NULL AND created_at BETWEEN %s AND %s", $tbl, (int) $blog_id, $start_sql, $end_sql ) );
                if ( 0 === $count ) {
                    return 0;
                }
                $offset = max( 0, (int) floor( $count * 0.5 ) - 1 );
                return (int) $wpdb->get_var( $wpdb->prepare( "SELECT lcp_ms FROM %i WHERE blog_id = %d AND lcp_ms IS NOT NULL AND created_at BETWEEN %s AND %s ORDER BY lcp_ms ASC LIMIT 1 OFFSET %d", $tbl, (int) $blog_id, $start_sql, $end_sql, $offset ) );
            case 'inp_ms':
                $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE blog_id = %d AND inp_ms IS NOT NULL AND created_at BETWEEN %s AND %s", $tbl, (int) $blog_id, $start_sql, $end_sql ) );
                if ( 0 === $count ) {
                    return 0;
                }
                $offset = max( 0, (int) floor( $count * 0.5 ) - 1 );
                return (int) $wpdb->get_var( $wpdb->prepare( "SELECT inp_ms FROM %i WHERE blog_id = %d AND inp_ms IS NOT NULL AND created_at BETWEEN %s AND %s ORDER BY inp_ms ASC LIMIT 1 OFFSET %d", $tbl, (int) $blog_id, $start_sql, $end_sql, $offset ) );
            case 'cls_x1000':
                $count = (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM %i WHERE blog_id = %d AND cls_x1000 IS NOT NULL AND created_at BETWEEN %s AND %s", $tbl, (int) $blog_id, $start_sql, $end_sql ) );
                if ( 0 === $count ) {
                    return 0;
                }
                $offset = max( 0, (int) floor( $count * 0.5 ) - 1 );
                return (int) $wpdb->get_var( $wpdb->prepare( "SELECT cls_x1000 FROM %i WHERE blog_id = %d AND cls_x1000 IS NOT NULL AND created_at BETWEEN %s AND %s ORDER BY cls_x1000 ASC LIMIT 1 OFFSET %d", $tbl, (int) $blog_id, $start_sql, $end_sql, $offset ) );
            default:
                return 0;
        }
    }

    /* ------------------------------------------------------- inbound-pull relay */

    /**
     * Inbound-pull transport for egress-locked sites (e.g. a school whose PHP
     * cannot make outbound HTTPS). Instead of the site pushing to the hub, the
     * hub GETs the daily snapshot from this authenticated, read-only route — the
     * site makes ZERO outbound connections. Mirrors the ShieldForge security
     * relay's dedicated plugin endpoint (`/shieldforge/v1/relay/queue`).
     */
    public static function register_relay_route() {
        register_rest_route( 'seoforge/v1', '/relay/snapshot', array(
            'methods'             => 'GET',
            'permission_callback' => array( __CLASS__, 'relay_permission' ),
            'callback'            => array( __CLASS__, 'relay_snapshot' ),
        ) );
    }

    /**
     * The shared secret the hub presents as `Authorization: Bearer <secret>`.
     * Set via the SEOFORGE_INTEL_RELAY_SECRET constant (preferred) or the
     * network/site option RELAY_SECRET_OPTION. Empty = pull disabled.
     */
    public static function relay_secret() {
        if ( defined( 'SEOFORGE_INTEL_RELAY_SECRET' ) && '' !== (string) SEOFORGE_INTEL_RELAY_SECRET ) {
            return (string) SEOFORGE_INTEL_RELAY_SECRET;
        }
        $opt = is_multisite()
            ? get_site_option( self::RELAY_SECRET_OPTION, '' )
            : get_option( self::RELAY_SECRET_OPTION, '' );
        return (string) $opt;
    }

    /** Stable site id for pulled snapshots: the registered id if we have one,
     * else a deterministic UUID derived from the site URL (unique per site,
     * stable across days so snapshot_ids stay idempotent). */
    private static function relay_site_id() {
        if ( self::is_registered() ) {
            $reg = self::get_registration();
            return (string) $reg['site_id'];
        }
        $h = md5( 'seoforge-relay|' . home_url( '/' ) );
        return sprintf(
            '%s-%s-4%s-8%s-%s',
            substr( $h, 0, 8 ), substr( $h, 8, 4 ), substr( $h, 13, 3 ), substr( $h, 17, 3 ), substr( $h, 20, 12 )
        );
    }

    /** Bearer check against relay_secret(); the site must also have opted in. */
    public static function relay_permission( $request ) {
        $secret = self::relay_secret();
        if ( '' === $secret || ! self::opted_in() ) {
            return new WP_Error( 'seoforge_relay_disabled', 'Relay disabled.', array( 'status' => 404 ) );
        }
        $auth = (string) $request->get_header( 'authorization' );
        if ( 0 === stripos( $auth, 'bearer ' ) ) {
            $auth = trim( substr( $auth, 7 ) );
        }
        if ( '' === $auth || ! hash_equals( $secret, $auth ) ) {
            return new WP_Error( 'seoforge_relay_forbidden', 'Bad relay secret.', array( 'status' => 403 ) );
        }
        return true;
    }

    /** Return { "snapshots": [ <yesterday's SeoSnapshot> ] } (or [] if no data). */
    public static function relay_snapshot( $request ) {
        $snap = self::build_snapshot( self::relay_site_id() );
        $out  = array( 'snapshots' => ( null === $snap ) ? array() : array( $snap ) );
        return new WP_REST_Response( $out, 200 );
    }

    /** Clamp to the hub's [0,100] percentage bound. */
    private static function pct( $v ) {
        $v = (float) $v;
        if ( $v < 0 ) {
            return 0.0;
        }
        return $v > 100 ? 100.0 : $v;
    }

    /**
     * Deterministic snapshot id so a re-push of the same period dedups on the
     * hub (idempotency key). UUID-shaped hash of site|granularity|day.
     */
    private static function snapshot_id( $site_id, $gran, $start_ts ) {
        $h = md5( $site_id . '|' . $gran . '|' . gmdate( 'Y-m-d', $start_ts ) );
        return sprintf(
            '%s-%s-%s-%s-%s',
            substr( $h, 0, 8 ), substr( $h, 8, 4 ), substr( $h, 12, 4 ), substr( $h, 16, 4 ), substr( $h, 20, 12 )
        );
    }

    /* ------------------------------------------------------------------ cron */

    public static function cron_push() {
        if ( ! self::opted_in() || ! self::is_registered() ) {
            return;
        }
        self::push();
    }

    /** Schedule the daily push when active; remove it when not. */
    public static function maybe_schedule() {
        // Opted in but not yet registered? Try to self-register (once every 6h)
        // so a plain onboarding opt-in works without the manual "Connect" step.
        // Only meaningful when a token is available and outbound HTTPS is open;
        // egress-locked sites never register here — the hub pulls from them.
        if ( self::opted_in() && ! self::is_registered() && '' !== self::register_token() ) {
            $st   = self::state();
            $last = isset( $st['last_register_try'] ) ? (int) $st['last_register_try'] : 0;
            if ( ( time() - $last ) > 6 * HOUR_IN_SECONDS ) {
                self::set_state( array( 'last_register_try' => time() ) );
                $r = self::do_register();
                if ( ! $r['ok'] ) {
                    self::set_state( array( 'last_error' => 'register: ' . $r['message'] ) );
                }
            }
        }

        $active = self::opted_in() && self::is_registered();
        $next   = wp_next_scheduled( self::CRON_HOOK );
        if ( $active && ! $next ) {
            // Just after 03:00 UTC, when yesterday is closed and traffic is low.
            wp_schedule_event( strtotime( 'tomorrow 03:10 UTC' ), 'daily', self::CRON_HOOK );
        } elseif ( ! $active && $next ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
        }
    }

    /* -------------------------------------------------------------- handlers */

    public static function handle_connect() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( '403', 403 );
        }
        check_admin_referer( 'seoforge_intel_connect' );

        $enabled  = ! empty( $_POST['intel_enabled'] );
        $token    = isset( $_POST['intel_register_token'] ) ? sanitize_text_field( wp_unslash( $_POST['intel_register_token'] ) ) : '';
        $vertical = isset( $_POST['intel_vertical'] ) ? sanitize_key( wp_unslash( $_POST['intel_vertical'] ) ) : 'other';
        if ( ! in_array( $vertical, self::verticals(), true ) ) {
            $vertical = 'other';
        }
        $was_enabled = (bool) Seoforge_Settings::get( self::OPT_ENABLED, false );
        Seoforge_Settings::update( array(
            self::OPT_ENABLED  => $enabled ? 1 : 0,
            self::OPT_TOKEN    => $token,
            self::OPT_VERTICAL => $vertical,
        ) );
        if ( $was_enabled !== $enabled && class_exists( 'Seoforge_Audit' ) ) {
            Seoforge_Audit::log( 'intel_opt_in_changed', array(
                'object_type' => 'intel',
                'summary'     => $enabled ? 'Opted in to the SEO benchmark network.' : 'Opted out of the SEO benchmark network.',
            ) );
        }

        if ( $was_enabled && ! $enabled && class_exists( 'Seoforge_Intel_Client' ) ) {
            Seoforge_Intel_Client::purge();
        }

        $status = 'saved';
        if ( $enabled && ! self::is_registered() ) {
            $r      = self::do_register();
            $status = $r['ok'] ? 'connected' : 'error_' . $r['message'];
        }
        self::maybe_schedule();
        if ( class_exists( 'Seoforge_Intel_Client' ) ) {
            Seoforge_Intel_Client::maybe_schedule();
        }
        wp_safe_redirect( add_query_arg( 'seoforge_intel', rawurlencode( $status ), self::back_url() ) );
        exit;
    }

    public static function handle_push_now() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( '403', 403 );
        }
        check_admin_referer( 'seoforge_intel_push_now' );
        $r = self::push();
        wp_safe_redirect( add_query_arg( 'seoforge_intel', rawurlencode( $r['ok'] ? 'pushed' : 'error_' . $r['message'] ), self::back_url() ) );
        exit;
    }

    public static function handle_disconnect() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( '403', 403 );
        }
        check_admin_referer( 'seoforge_intel_disconnect' );
        self::clear_registration();
        Seoforge_Settings::update( array( self::OPT_ENABLED => 0 ) );
        if ( class_exists( 'Seoforge_Intel_Client' ) ) {
            Seoforge_Intel_Client::purge();
        }
        if ( class_exists( 'Seoforge_Audit' ) ) {
            Seoforge_Audit::log( 'intel_opt_in_changed', array(
                'object_type' => 'intel',
                'summary'     => 'Opted out of the SEO benchmark network (disconnected).',
            ) );
        }
        $next = wp_next_scheduled( self::CRON_HOOK );
        if ( $next ) {
            wp_unschedule_event( $next, self::CRON_HOOK );
        }
        wp_safe_redirect( add_query_arg( 'seoforge_intel', 'disconnected', self::back_url() ) );
        exit;
    }

    private static function back_url() {
        $ref = wp_get_referer();
        return $ref ? $ref : admin_url( 'admin.php?page=brainwerk-seo-suite&tab=tools' );
    }

    /* -------------------------------------------------------- opt-in invite */

    /**
     * A single, dismissible invitation to join the benchmark network, shown
     * only on our own admin screens (never network-wide) and only while the
     * site has neither opted in nor dismissed it. Consent still happens on the
     * Tools card — this only points there.
     */
    public static function maybe_admin_notice() {
        if ( ! current_user_can( 'manage_options' ) || self::opted_in() ) {
            return;
        }
        $dismissed = is_multisite() ? get_site_option( self::NOTICE_OPTION ) : get_option( self::NOTICE_OPTION );
        if ( $dismissed ) {
            return;
        }
        $screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
        if ( ! $screen || false === strpos( (string) $screen->id, 'brainwerk-seo-suite' ) ) {
            return;
        }
        $join    = admin_url( 'admin.php?page=brainwerk-seo-suite&tab=tools' );
        $dismiss = wp_nonce_url( admin_url( 'admin-post.php?action=seoforge_intel_dismiss_notice' ), 'seoforge_intel_dismiss_notice' );
        echo '<div class="notice notice-info"><p><strong>'
            . esc_html__( 'Compare your SEO against your peers', 'brainwerk-seo-suite' ) . '</strong> — '
            . esc_html__( 'The SEO benchmark network is on by default: send one aggregate snapshot per day (counts and percentages only — never a single visitor) and see how you stack up. Review the details, or switch it off any time, on the Tools tab.', 'brainwerk-seo-suite' )
            . '</p><p>'
            . '<a class="button button-primary" href="' . esc_url( $join ) . '">' . esc_html__( 'Review & opt in', 'brainwerk-seo-suite' ) . '</a> '
            . '<a class="button" href="' . esc_url( $dismiss ) . '">' . esc_html__( 'No thanks', 'brainwerk-seo-suite' ) . '</a>'
            . '</p></div>';
    }

    public static function handle_dismiss_notice() {
        if ( ! current_user_can( 'manage_options' ) ) {
            wp_die( '403', 403 );
        }
        check_admin_referer( 'seoforge_intel_dismiss_notice' );
        if ( is_multisite() ) {
            update_site_option( self::NOTICE_OPTION, 1 );
        } else {
            update_option( self::NOTICE_OPTION, 1, false );
        }
        wp_safe_redirect( self::back_url() );
        exit;
    }

    /* ------------------------------------------------------------------- view */

    /** Admin card: status + connect / push / disconnect. Returns HTML. */
    public static function render_card() {
        $reg      = self::get_registration();
        $state    = self::state();
        $enabled  = self::opted_in();
        $token    = (string) Seoforge_Settings::get( self::OPT_TOKEN, '' );
        $vertical = (string) Seoforge_Settings::get( self::OPT_VERTICAL, 'other' );
        $endpoint = self::api_base();

        $out  = '<div class="seoforge-card" style="margin-bottom:18px;">';
        $out .= '<h2 style="margin-top:0;">' . esc_html__( 'SEO network — fleet & benchmarks', 'brainwerk-seo-suite' )
            . ' <span class="seoforge-pill" style="background:#fde68a;color:#92400e;">' . esc_html__( 'Beta', 'brainwerk-seo-suite' ) . '</span></h2>';
        $out .= '<p style="color:#50575e;font-size:13px;max-width:62em;">'
            . esc_html__( 'Opt in to send one aggregate snapshot per day (counts, percentages, p75 web-vitals, analyzer trends, an hour/weekday traffic profile and banded AI-crawler visibility — never a single visitor row) to the ShieldForge-Intel hub. Days below 50 pageviews send nothing at all, and crawler counts are only ever sent as coarse bands, so this stays genuinely anonymous — no data-processing agreement required. In return you get an agency fleet overview and anonymous peer benchmarks. You can disconnect any time; nothing identifying ever leaves this site.', 'brainwerk-seo-suite' )
            . '</p>';

        if ( self::is_registered() ) {
            $sid   = isset( $reg['site_id'] ) ? (string) $reg['site_id'] : '';
            $short = $sid ? substr( $sid, 0, 8 ) . '…' : '—';
            $out  .= '<p><span class="seoforge-pill seoforge-pill-good">' . esc_html__( 'Connected', 'brainwerk-seo-suite' ) . '</span> '
                . '<code>' . esc_html( $short ) . '</code></p>';
            $out  .= '<table class="form-table"><tbody>';
            $out  .= '<tr><th>' . esc_html__( 'Last sent', 'brainwerk-seo-suite' ) . '</th><td>'
                . esc_html( ! empty( $state['last_sent'] ) ? $state['last_sent'] . ' UTC' : '—' ) . '</td></tr>';
            if ( ! empty( $state['last_error'] ) ) {
                $out .= '<tr><th>' . esc_html__( 'Last error', 'brainwerk-seo-suite' ) . '</th><td style="color:#b32d2e;">' . esc_html( $state['last_error'] ) . '</td></tr>';
            }
            $out .= '</tbody></table>';

            if ( class_exists( 'Seoforge_Intel_Client' ) ) {
                $fs = Seoforge_Intel_Client::status();
                $out .= '<p>' . esc_html__( 'Peer benchmarks & bot-reputation feed:', 'brainwerk-seo-suite' ) . ' ';
                if ( $fs['verified'] && $fs['feed_version'] > 0 ) {
                    $out .= esc_html( sprintf(
                        /* translators: 1: feed version number, 2: fetch timestamp (UTC) */
                        __( 'version %1$d, fetched %2$s UTC', 'brainwerk-seo-suite' ),
                        $fs['feed_version'],
                        $fs['fetched_at']
                    ) );
                } else {
                    $out .= esc_html__( 'not available yet', 'brainwerk-seo-suite' );
                }
                $out .= '</p>';
            }

            $push_url = wp_nonce_url( admin_url( 'admin-post.php?action=seoforge_intel_push_now' ), 'seoforge_intel_push_now' );
            $pull_url = wp_nonce_url( admin_url( 'admin-post.php?action=seoforge_intel_feed_pull_now' ), 'seoforge_intel_feed_pull_now' );
            $disc_url = wp_nonce_url( admin_url( 'admin-post.php?action=seoforge_intel_disconnect' ), 'seoforge_intel_disconnect' );
            $out .= '<p style="display:flex;gap:10px;align-items:center;">'
                . '<a href="' . esc_url( $push_url ) . '" class="button button-primary">' . esc_html__( 'Send today\'s snapshot now', 'brainwerk-seo-suite' ) . '</a>'
                . '<a href="' . esc_url( $pull_url ) . '" class="button">' . esc_html__( 'Pull feed now', 'brainwerk-seo-suite' ) . '</a>'
                . '<a href="' . esc_url( $disc_url ) . '" class="button" style="color:#b32d2e;" onclick="return confirm(\'' . esc_js( __( 'Disconnect and stop sending snapshots?', 'brainwerk-seo-suite' ) ) . '\');">' . esc_html__( 'Disconnect', 'brainwerk-seo-suite' ) . '</a>'
                . '</p>';
        } else {
            if ( ! empty( $state['last_error'] ) ) {
                $out .= '<table class="form-table"><tbody>'
                    . '<tr><th>' . esc_html__( 'Last error', 'brainwerk-seo-suite' ) . '</th><td style="color:#b32d2e;">' . esc_html( $state['last_error'] ) . '</td></tr>'
                    . '</tbody></table>';
            }
            $out .= '<form method="post" action="' . esc_url( admin_url( 'admin-post.php' ) ) . '">';
            $out .= wp_nonce_field( 'seoforge_intel_connect', '_wpnonce', true, false );
            $out .= '<input type="hidden" name="action" value="seoforge_intel_connect">';
            $out .= '<table class="form-table"><tbody>';
            $out .= '<tr><th><label for="intel_enabled">' . esc_html__( 'Enable', 'brainwerk-seo-suite' ) . '</label></th><td>'
                . '<label><input type="checkbox" id="intel_enabled" name="intel_enabled" value="1" ' . checked( $enabled, true, false ) . '> '
                . esc_html__( 'Send a daily aggregate snapshot to the hub', 'brainwerk-seo-suite' ) . '</label></td></tr>';
            $out .= '<tr><th><label for="intel_register_token">' . esc_html__( 'Registration token', 'brainwerk-seo-suite' ) . '</label></th><td>'
                . '<input type="text" class="regular-text" id="intel_register_token" name="intel_register_token" value="' . esc_attr( $token ) . '" autocomplete="off">'
                . '<p class="description">' . esc_html__( 'One-time bootstrap token from your hub operator. Used once to register this site, then it can be cleared.', 'brainwerk-seo-suite' ) . '</p></td></tr>';
            $out .= '<tr><th><label for="intel_vertical">' . esc_html__( 'Industry (for peer benchmarks)', 'brainwerk-seo-suite' ) . '</label></th><td><select id="intel_vertical" name="intel_vertical">';
            foreach ( self::verticals() as $v ) {
                $out .= '<option value="' . esc_attr( $v ) . '" ' . selected( $vertical, $v, false ) . '>' . esc_html( $v ) . '</option>';
            }
            $out .= '</select></td></tr>';
            $out .= '</tbody></table>';
            $out .= '<p><button type="submit" class="button button-primary">' . esc_html__( 'Connect to hub', 'brainwerk-seo-suite' ) . '</button> '
                . '<span class="description" style="margin-left:8px;">' . esc_html( sprintf( /* translators: %s: hub URL */ __( 'Endpoint: %s', 'brainwerk-seo-suite' ), $endpoint ) ) . '</span></p>';
            $out .= '</form>';
        }
        $out .= '</div>';
        return $out;
    }
}
