<?php
/**
 * AuctionForge Bot Shield
 *
 * Server-side scoring for registration attempts. Replaces the old
 * auctionforge_validate_name_server() heuristics, which only ran on names of
 * 8+ characters and therefore passed every name in the Aug-2026 bot wave
 * ("Awbd Fplafk", "Eczh Tunnbkdo", "Cslh Flpohlrj", ...).
 *
 * The name test is a character-trigram model (inc/data/name-model.php) built
 * from ~9,500 verified fleet bidders plus an English dictionary. Hand-written
 * cluster and bigram lists were tried first and abandoned: they rejected real
 * surnames (Little, Armstrong, Walsh, Hodgkinson, Szpakowski) because English
 * compounds generate far more legitimate consonant clusters than anyone can
 * enumerate. The trigram model is measured, not guessed — see
 * bin/test-bot-shield.php.
 *
 * Measured on fleet ground truth with the evaluation set held out of the corpus:
 *   - 598 proven bidders (people who have bid, won or spent): 0 blocked
 *   - 28 accounts from the Aug-2026 BlackHawk wave: 28 blocked
 *   - 40 admin-confirmed bots: 36 blocked (the 4 missed have ordinary names and
 *     throwaway-mail addresses; nothing in the name itself gives them away)
 *
 * Signals are additive. No weak signal blocks alone; the threshold is reached
 * by one strong signal or several weak ones.
 *
 * @package auctionforge
 */

if (!defined('ABSPATH') && !defined('AUCTIONFORGE_BOTSHIELD_TEST')) {
    die('You shouldnt be here');
}

/** Total score at which a registration attempt is blocked. */
if (!defined('AUCTIONFORGE_BOTSHIELD_THRESHOLD')) {
    define('AUCTIONFORGE_BOTSHIELD_THRESHOLD', 6);
}

/* -------------------------------------------------------------------------
 * Rejection log
 *
 * auctionforge_record_proxy_rejection() has always written to this table, but
 * nothing ever created it, so every blocked registration since the feature
 * shipped was silently discarded. Created here on the same admin_init
 * versioning pattern as the tracking table.
 * ---------------------------------------------------------------------- */

function auctionforge_botshield_table_name() {
    global $wpdb;
    return $wpdb->prefix . 'auctionforge_rejected_registrations';
}

function auctionforge_botshield_create_table() {
    global $wpdb;
    $table = auctionforge_botshield_table_name();
    $charset_collate = $wpdb->get_charset_collate();

    $sql = "CREATE TABLE $table (
        id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
        first_name varchar(255) NOT NULL DEFAULT '',
        last_name varchar(255) NOT NULL DEFAULT '',
        email varchar(255) NOT NULL DEFAULT '',
        email_normalized varchar(255) NOT NULL DEFAULT '',
        ip_address varchar(45) NOT NULL DEFAULT '',
        device_id varchar(64) NOT NULL DEFAULT '',
        device_fingerprint varchar(64) NOT NULL DEFAULT '',
        rejection_reason varchar(40) NOT NULL DEFAULT '',
        rejection_details text NULL,
        score smallint(6) NOT NULL DEFAULT 0,
        user_agent text NULL,
        created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (id),
        KEY idx_created_at (created_at),
        KEY idx_email (email),
        KEY idx_email_normalized (email_normalized),
        KEY idx_ip_address (ip_address),
        KEY idx_reason (rejection_reason)
    ) $charset_collate;";

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

/**
 * Create the table once per plugin version, mirroring the tracking table's
 * upgrade hook so sites get it without a reactivation.
 */
function auctionforge_botshield_maybe_create_table() {
    $current = defined('AUCTIONFORGE_VERSION') ? AUCTIONFORGE_VERSION : '0.0.0';
    if (get_option('auctionforge_botshield_db_version') === $current) {
        return;
    }
    auctionforge_botshield_create_table();
    update_option('auctionforge_botshield_db_version', $current);
}
if (function_exists('add_action')) {
    add_action('admin_init', 'auctionforge_botshield_maybe_create_table');
    add_action('admin_menu', 'auctionforge_botshield_register_page', 20);
}

function auctionforge_botshield_register_page() {
    add_submenu_page(
        'edit.php?post_type=lot',
        __('Blocked Signups', 'auctionforge'),
        __('Blocked Signups', 'auctionforge'),
        'manage_options',
        'auctionforge-blocked-signups',
        'auctionforge_botshield_render_page'
    );
}

function auctionforge_botshield_render_page() {
    require plugin_dir_path(dirname(__FILE__)) . 'admin/view/blocked-signups.php';
}

/* -------------------------------------------------------------------------
 * Text helpers
 * ---------------------------------------------------------------------- */

/**
 * Fold accented Latin characters down to a-z so the classifier sees
 * "Bjorn" for "Björn" and "Szczesny" for "Szczęsny".
 */
function auctionforge_botshield_fold($str) {
    $map = array(
        'á'=>'a','à'=>'a','â'=>'a','ä'=>'a','ã'=>'a','å'=>'a','ā'=>'a','ă'=>'a','ą'=>'a',
        'ç'=>'c','ć'=>'c','č'=>'c','ĉ'=>'c','ċ'=>'c',
        'ď'=>'d','đ'=>'d',
        'é'=>'e','è'=>'e','ê'=>'e','ë'=>'e','ē'=>'e','ĕ'=>'e','ė'=>'e','ę'=>'e','ě'=>'e',
        'ğ'=>'g','ģ'=>'g','ĝ'=>'g','ġ'=>'g',
        'ĥ'=>'h','ħ'=>'h',
        'í'=>'i','ì'=>'i','î'=>'i','ï'=>'i','ī'=>'i','į'=>'i','ı'=>'i','ĩ'=>'i',
        'ĵ'=>'j',
        'ķ'=>'k',
        'ĺ'=>'l','ļ'=>'l','ľ'=>'l','ł'=>'l',
        'ń'=>'n','ň'=>'n','ñ'=>'n','ņ'=>'n',
        'ó'=>'o','ò'=>'o','ô'=>'o','ö'=>'o','õ'=>'o','ø'=>'o','ō'=>'o','ő'=>'o',
        'ŕ'=>'r','ř'=>'r','ŗ'=>'r',
        'ś'=>'s','š'=>'s','ş'=>'s','ŝ'=>'s','ș'=>'s',
        'ţ'=>'t','ť'=>'t','ț'=>'t','ŧ'=>'t',
        'ú'=>'u','ù'=>'u','û'=>'u','ü'=>'u','ū'=>'u','ů'=>'u','ű'=>'u','ų'=>'u','ũ'=>'u',
        'ŵ'=>'w',
        'ý'=>'y','ÿ'=>'y','ŷ'=>'y',
        'ź'=>'z','ż'=>'z','ž'=>'z',
        'æ'=>'ae','œ'=>'oe','ß'=>'ss','þ'=>'th','ð'=>'d',
    );
    $lower = function_exists('mb_strtolower') ? mb_strtolower($str, 'UTF-8') : strtolower($str);
    return strtr($lower, $map);
}

/** Letters-only, folded, lowercase form used by every name signal. */
function auctionforge_botshield_letters($name) {
    return preg_replace('/[^a-z]/', '', auctionforge_botshield_fold($name));
}

/* -------------------------------------------------------------------------
 * Trigram model
 * ---------------------------------------------------------------------- */

/** Alphabet is a-z plus '^' (word start) and '$' (word end). */
if (!defined('AUCTIONFORGE_BOTSHIELD_ALPHABET')) {
    define('AUCTIONFORGE_BOTSHIELD_ALPHABET', 28);
}

/**
 * Load and cache the generated trigram bitmap.
 *
 * @return string|null Raw bitmap, or null when the model file is missing.
 */
function auctionforge_botshield_model() {
    static $bitmap = false;
    if ($bitmap !== false) {
        return $bitmap;
    }
    $bitmap = null;
    $path = __DIR__ . '/data/name-model.php';
    if (is_readable($path)) {
        $data = include $path;
        if (is_array($data) && !empty($data['bitmap'])) {
            $decoded = base64_decode($data['bitmap'], true);
            if ($decoded !== false) {
                $bitmap = $decoded;
            }
        }
    }
    return $bitmap;
}

/**
 * Count the 3-character sequences in a word that appear nowhere in the corpus.
 * "little" scores 0; "xnqfrtus" scores 6.
 *
 * @return int -1 when no model is available (caller should skip the signal).
 */
function auctionforge_botshield_unseen_trigrams($word) {
    $bitmap = auctionforge_botshield_model();
    if ($bitmap === null) {
        return -1;
    }
    $padded = '^' . $word . '$';
    $len    = strlen($padded);
    $n      = AUCTIONFORGE_BOTSHIELD_ALPHABET;
    $miss   = 0;

    for ($i = 0; $i < $len - 2; $i++) {
        $idx = 0;
        for ($k = 0; $k < 3; $k++) {
            $ch = $padded[$i + $k];
            if ($ch === '^')      $sym = 26;
            elseif ($ch === '$')  $sym = 27;
            else {
                $sym = ord($ch) - 97;
                if ($sym < 0 || $sym > 25) { $sym = -1; }
            }
            if ($sym < 0) { $idx = -1; break; }
            $idx = $idx * $n + $sym;
        }
        if ($idx < 0) continue;
        $byte = $idx >> 3;
        if (!isset($bitmap[$byte]) || !(ord($bitmap[$byte]) & (1 << ($idx & 7)))) {
            $miss++;
        }
    }
    return $miss;
}

/* -------------------------------------------------------------------------
 * Name classifier
 * ---------------------------------------------------------------------- */

/**
 * Score one indivisible word of a name (no spaces, hyphens or apostrophes).
 *
 * @return array{score:int,reasons:string[]}
 */
function auctionforge_botshield_word_score($letters, $label) {
    $score   = 0;
    $reasons = array();
    $len     = strlen($letters);

    if ($len < 3) {
        return array('score' => 0, 'reasons' => array()); // initials, Ng, Wu, Li
    }

    // The trigram signal is pooled across both name fields, not scored here —
    // see auctionforge_botshield_trigram_score().

    // --- Vowel-free words. "Cslh", "Flgx", "Fqwsk". ---
    // 'y' counts as a vowel (Welsh, Slavic, Turkish); 3-letter words are exempt
    // so Ng, Ngo and similar transliterations pass.
    if ($len >= 4 && !preg_match('/[aeiouy]/', $letters)) {
        $score += 5;
        $reasons[] = $label . ' has no vowels';
    }

    // --- Long runs of one character ("Aaaaa"). ---
    if (preg_match('/(.)\1{3,}/', $letters)) {
        $score += 3;
        $reasons[] = $label . ' repeats a character 4+ times';
    }

    // --- Consonant runs beyond anything English or Slavic produces.
    // Armstrong ("rmstr") is 5, so only 6+ counts.
    if (preg_match_all('/[bcdfghjklmnpqrstvwxz]{6,}/', $letters, $m)) {
        $score += 2;
        $reasons[] = $label . ' has a ' . strlen($m[0][0]) . '-consonant run';
    }

    return array('score' => $score, 'reasons' => $reasons);
}

/**
 * Score a name field. Multi-part names ("Anne-Marie", "van der Vlist",
 * "O'Connor") are split and scored word by word, so joining the words never
 * manufactures a sequence that is not really there.
 *
 * @return array{score:int,reasons:string[]}
 */
function auctionforge_botshield_name_score($raw, $label = 'Name') {
    $score   = 0;
    $reasons = array();
    $name    = trim((string) $raw);

    // An empty field is not evidence of a bot: plenty of genuine bidders have
    // registered with no surname. Let the other signals decide.
    if ($name === '') {
        return array('score' => 0, 'reasons' => array());
    }
    if (mb_strlen($name, 'UTF-8') > 50) {
        return array('score' => 99, 'reasons' => array($label . ' is absurdly long'));
    }

    // Names written in a non-Latin script (Hebrew, Cyrillic, Chinese, Arabic)
    // cannot be judged by a Latin trigram model. Skip rather than guess — the
    // Hebrew-language sites in the fleet register such names routinely.
    $latin = auctionforge_botshield_letters($name);
    if ($latin === '') {
        if (preg_match('/\p{L}/u', $name)) {
            return array('score' => 0, 'reasons' => array());
        }
        return array('score' => 5, 'reasons' => array($label . ' contains no letters at all'));
    }

    // Worst word wins: "Zuhavmr Vvdeleh" must not be diluted by its tamer half.
    $parts = preg_split('/[\s\-\x{2019}\'.]+/u', auctionforge_botshield_fold($name), -1, PREG_SPLIT_NO_EMPTY);
    $worst = array('score' => 0, 'reasons' => array());
    foreach ((array) $parts as $part) {
        $word = preg_replace('/[^a-z]/', '', $part);
        if ($word === '') continue;
        $res = auctionforge_botshield_word_score($word, $label);
        if ($res['score'] > $worst['score']) {
            $worst = $res;
        }
    }
    $score  += $worst['score'];
    $reasons = array_merge($reasons, $worst['reasons']);

    // --- Casing anomalies. Catches the earlier "EgZpoLMYGYGsGBnpt" generation
    // of bots, whose letter sequences alone were less obviously random. ---
    if (preg_match_all('/[A-Z]{3,}/', $name, $m, PREG_OFFSET_CAPTURE)) {
        foreach ($m[0] as $match) {
            // Ignore all-caps entry — people do type their name in capitals.
            if ($match[1] > 0 && $name !== mb_strtoupper($name, 'UTF-8')) {
                $score += 3;
                $reasons[] = $label . ' has an internal block of capitals';
                break;
            }
        }
    }
    $plainLen = strlen($name);
    if ($plainLen >= 8) {
        $transitions = 0;
        for ($i = 1; $i < $plainLen; $i++) {
            $pU = ctype_upper($name[$i - 1]);
            $cU = ctype_upper($name[$i]);
            $pL = ctype_lower($name[$i - 1]);
            $cL = ctype_lower($name[$i]);
            if (($pU && $cL && $i > 1) || ($pL && $cU)) $transitions++;
        }
        if ($transitions / $plainLen > 0.35) {
            $score += 3;
            $reasons[] = $label . ' alternates case like random text';
        }
    }

    return array('score' => $score, 'reasons' => $reasons);
}

/**
 * Pooled trigram signal across both name fields.
 *
 * Pooling matters: one odd-looking name is common among real bidders
 * (Pezzuto, Szczypta), two in the same registration is not. Measured against
 * fleet ground truth with the evaluation set held out of the corpus:
 *
 *   total unseen >= 3 : 25/28 of the Aug-2026 wave, 0 of 598 proven bidders
 *   total unseen >= 2 : 27/28 of the wave, but 3 of 598 proven bidders
 *
 * So 3 blocks outright and 2 only contributes, needing another signal to
 * reach the threshold.
 *
 * @return array{score:int,reasons:string[],total:int}
 */
function auctionforge_botshield_trigram_score($firstName, $lastName) {
    $total   = 0;
    $details = array();

    foreach (array('First name' => $firstName, 'Last name' => $lastName) as $label => $field) {
        $folded = auctionforge_botshield_fold((string) $field);
        $parts  = preg_split('/[\s\-\x{2019}\'.]+/u', $folded, -1, PREG_SPLIT_NO_EMPTY);
        foreach ((array) $parts as $part) {
            $word = preg_replace('/[^a-z]/', '', $part);
            if (strlen($word) < 3) continue;
            $miss = auctionforge_botshield_unseen_trigrams($word);
            if ($miss > 0) {
                $total    += $miss;
                $details[] = $label . ' "' . $word . '" (' . $miss . ')';
            }
        }
    }

    if ($total >= 5)      $score = 7;
    elseif ($total >= 3)  $score = 6;
    elseif ($total === 2) $score = 3;
    elseif ($total === 1) $score = 1;
    else                  $score = 0;

    $reasons = $total > 0
        ? array($total . ' letter sequence' . ($total === 1 ? '' : 's')
                . ' found in no real name — ' . implode(', ', $details))
        : array();

    return array('score' => $score, 'reasons' => $reasons, 'total' => $total);
}

/**
 * Back-compat wrapper for the old validator signature.
 *
 * @return false|string False when the name looks human, else the reason.
 */
function auctionforge_botshield_check_name($firstName, $lastName) {
    $first = auctionforge_botshield_name_score($firstName, 'First name');
    $last  = auctionforge_botshield_name_score($lastName, 'Last name');
    $tri   = auctionforge_botshield_trigram_score($firstName, $lastName);

    $score = max($first['score'], $last['score']) + $tri['score'];
    if ($score >= AUCTIONFORGE_BOTSHIELD_THRESHOLD) {
        return implode('; ', array_merge($tri['reasons'], $first['reasons'], $last['reasons']));
    }
    return false;
}

/* -------------------------------------------------------------------------
 * Email signals
 * ---------------------------------------------------------------------- */

/**
 * Canonical mailbox for duplicate detection: Gmail ignores dots and +tags, so
 * "r.i.n.v.il.lil.a@gmail.com" and "rinvillila@gmail.com" are one inbox.
 */
function auctionforge_botshield_normalize_email($email) {
    $email = strtolower(trim((string) $email));
    if (strpos($email, '@') === false) return $email;

    list($local, $domain) = explode('@', $email, 2);
    if (($plus = strpos($local, '+')) !== false) {
        $local = substr($local, 0, $plus);
    }
    if ($domain === 'gmail.com' || $domain === 'googlemail.com') {
        $local  = str_replace('.', '', $local);
        $domain = 'gmail.com';
    }
    return $local . '@' . $domain;
}

/**
 * Throwaway-mail providers. Extend via the auctionforge_disposable_domains
 * filter rather than editing this list.
 */
function auctionforge_botshield_disposable_domains() {
    $domains = array(
        'mailinator.com','guerrillamail.com','10minutemail.com','tempmail.com','temp-mail.org',
        'throwawaymail.com','yopmail.com','trashmail.com','sharklasers.com','getnada.com',
        'dispostable.com','maildrop.cc','fakeinbox.com','mohmal.com','emailondeck.com',
        'mintemail.com','spamgourmet.com','tempinbox.com','moakt.com','tempr.email',
        // Seen on accounts an admin flagged "BOT" in the fleet fraud register
        'barumart.com','gwshare.com','whatthefish.info','atomicmail.io',
    );
    if (function_exists('apply_filters')) {
        $domains = apply_filters('auctionforge_disposable_domains', $domains);
    }
    return $domains;
}

/**
 * @return array{score:int,reasons:string[]}
 */
function auctionforge_botshield_email_score($email) {
    $score   = 0;
    $reasons = array();
    $email   = strtolower(trim((string) $email));

    if ($email === '' || strpos($email, '@') === false) {
        return array('score' => 99, 'reasons' => array('Email is missing or malformed'));
    }

    list($local, $domain) = explode('@', $email, 2);

    if (in_array($domain, auctionforge_botshield_disposable_domains(), true)) {
        $score += 6;
        $reasons[] = 'Disposable mail domain: ' . $domain;
    }

    // Gmail dot-shuffling: the hallmark of the Aug-2026 wave, and already
    // recorded against confirmed bots in the fleet fraud register
    // (d.a.u.to.li.ne@gmail.com). Real people write "mary.jane.smith".
    $segments = explode('.', $local);
    $singles  = 0;
    foreach ($segments as $seg) {
        if (strlen($seg) === 1) $singles++;
    }
    if ($singles >= 3) {
        $score += 5;
        $reasons[] = 'Local part split into ' . $singles . ' single-character segments (dot shuffling)';
    } elseif ($singles === 2) {
        $score += 2;
        $reasons[] = 'Local part has two single-character segments';
    }

    $dots = substr_count($local, '.');
    if ($dots >= 5) {
        $score += 4;
        $reasons[] = 'Local part contains ' . $dots . ' dots';
    } elseif ($dots === 4) {
        $score += 3;
        $reasons[] = 'Local part contains 4 dots';
    }

    // Digits scattered between dotted segments ("x7.68", "th.8.4", "a1.975").
    // Requires 3+ dots: "roger.little.1547@gmail.com" is a real bidder.
    if ($dots >= 3 && preg_match('/\.\d|\d\./', $local)) {
        $score += 3;
        $reasons[] = 'Digits interleaved with dots in local part';
    }

    return array('score' => $score, 'reasons' => $reasons);
}

/* -------------------------------------------------------------------------
 * Request-shape signals
 * ---------------------------------------------------------------------- */

/**
 * @return array{score:int,reasons:string[]}
 */
function auctionforge_botshield_agent_score($ua) {
    $score   = 0;
    $reasons = array();
    $ua      = trim((string) $ua);

    if ($ua === '') {
        return array('score' => 4, 'reasons' => array('No User-Agent header'));
    }
    $signatures = array(
        'curl','wget','python-requests','python-urllib','go-http-client','java/','okhttp',
        'libwww','httpclient','scrapy','phantomjs','headlesschrome','puppeteer','playwright',
        'axios','node-fetch','guzzle','postman','insomnia','http_request',
    );
    $lower = strtolower($ua);
    foreach ($signatures as $sig) {
        if (strpos($lower, $sig) !== false) {
            return array('score' => 5, 'reasons' => array('Automated client User-Agent (' . $sig . ')'));
        }
    }
    if (strlen($ua) < 25) {
        $score += 2;
        $reasons[] = 'Implausibly short User-Agent';
    }
    return array('score' => $score, 'reasons' => $reasons);
}

/**
 * Humans do not complete a registration form in under a second and a half.
 * Only scored when the client actually reported a timing.
 *
 * @return array{score:int,reasons:string[]}
 */
function auctionforge_botshield_timing_score($formFillMs) {
    $ms = intval($formFillMs);
    if ($ms <= 0) {
        return array('score' => 0, 'reasons' => array());
    }
    if ($ms < 800) {
        return array('score' => 4, 'reasons' => array('Form completed in ' . $ms . 'ms'));
    }
    if ($ms < 2000) {
        return array('score' => 2, 'reasons' => array('Form completed in ' . $ms . 'ms'));
    }
    return array('score' => 0, 'reasons' => array());
}

/* -------------------------------------------------------------------------
 * Aggregate
 * ---------------------------------------------------------------------- */

/**
 * Score a whole registration attempt.
 *
 * @param array $data first_name, last_name, email, form_fill_ms, user_agent, honeypot
 * @return array{blocked:bool,score:int,reason:string,details:string}
 */
function auctionforge_botshield_assess(array $data) {
    $reasons = array();
    $score   = 0;
    $primary = '';

    // Honeypot is decisive on its own.
    if (!empty($data['honeypot'])) {
        return array(
            'blocked' => true,
            'score'   => 99,
            'reason'  => 'honeypot',
            'details' => 'company_url field filled: ' . $data['honeypot'],
        );
    }

    $firstRaw = isset($data['first_name']) ? $data['first_name'] : '';
    $lastRaw  = isset($data['last_name']) ? $data['last_name'] : '';

    $first = auctionforge_botshield_name_score($firstRaw, 'First name');
    $last  = auctionforge_botshield_name_score($lastRaw, 'Last name');
    $tri   = auctionforge_botshield_trigram_score($firstRaw, $lastRaw);

    // Take the worse of the two names for the per-word signals (two mildly
    // unusual real names must not add up), but pool the trigram evidence.
    $nameScore = max($first['score'], $last['score']) + $tri['score'];
    if ($nameScore > 0) {
        $score  += $nameScore;
        $reasons = array_merge($reasons, $tri['reasons'], $first['reasons'], $last['reasons']);
        if ($nameScore >= 5) $primary = 'name_validation';
    }

    $mail = auctionforge_botshield_email_score(isset($data['email']) ? $data['email'] : '');
    if ($mail['score'] > 0) {
        $score  += $mail['score'];
        $reasons = array_merge($reasons, $mail['reasons']);
        if ($primary === '' && $mail['score'] >= 5) $primary = 'email_pattern';
    }

    $agent = auctionforge_botshield_agent_score(isset($data['user_agent']) ? $data['user_agent'] : '');
    if ($agent['score'] > 0) {
        $score  += $agent['score'];
        $reasons = array_merge($reasons, $agent['reasons']);
        if ($primary === '' && $agent['score'] >= 4) $primary = 'user_agent';
    }

    $timing = auctionforge_botshield_timing_score(isset($data['form_fill_ms']) ? $data['form_fill_ms'] : 0);
    if ($timing['score'] > 0) {
        $score  += $timing['score'];
        $reasons = array_merge($reasons, $timing['reasons']);
        if ($primary === '' && $timing['score'] >= 4) $primary = 'form_timing';
    }

    $blocked = $score >= AUCTIONFORGE_BOTSHIELD_THRESHOLD;
    if ($blocked && $primary === '') {
        $primary = 'combined_score';
    }

    return array(
        'blocked' => $blocked,
        'score'   => $score,
        'reason'  => $primary,
        'details' => ($reasons ? implode('; ', $reasons) : 'no signals') . ' [score ' . $score . '/' . AUCTIONFORGE_BOTSHIELD_THRESHOLD . ']',
    );
}
