<?php
/*
 * BizGenius citations in your page HTML, for any PHP site (on WordPress, use the plugin instead).
 *
 * Search engines and AI assistants (ChatGPT, Claude, Perplexity) mostly don't run JavaScript, so they
 * see the BizGenius widget boxes empty. This file fills them on your server before the page is sent:
 * it finds each empty widget box, asks BizGenius for the same content the widget would show
 * (api/render.php) and prints it inside the box. Your boxes, their data-* settings and your
 * citation.min.js script stay exactly as they are; the widget still runs in the browser for search
 * and paging, and keeps the server copy on screen until its own data arrives.
 *
 * Setup, one of:
 *   - Every page with widget boxes, as the very first line (before any HTML):
 *         <?php require_once __DIR__ . '/bizgenius-ssr.php'; ?>
 *   - The whole site, without touching any page: load this file before every PHP script.
 *         Apache with mod_php, in .htaccess:  php_value auto_prepend_file "/full/path/to/bizgenius-ssr.php"
 *         PHP-FPM, in .user.ini:              auto_prepend_file = "/full/path/to/bizgenius-ssr.php"
 *   If your framework throws away output buffers, pass the finished HTML through bizgenius_ssr_fill($html).
 *
 * Optional settings: define them anywhere before the page finishes, or uncomment them here.
 */
// define('BIZGENIUS_SSR_KEY', 'YOUR_WIDGET_KEY');        // widget key, if it isn't in the page's meta tag or script URL
// define('BIZGENIUS_SSR_CACHE_DIR', '/path/to/cache');   // a writable folder (default: the system temp folder)
// define('BIZGENIUS_SSR_DISABLE', true);                 // switch it off
/*
 * Each box is cached for 12 hours, also when there is nothing to show; a failed request is retried after 30 minutes. If BizGenius
 * doesn't answer within 3 seconds, the page is sent unchanged and the widget loads in the browser as before.
 * Only HTML pages are touched: JSON, images and downloads pass straight through.
 *
 * Requires PHP 7.2+ with the curl extension (or allow_url_fopen).
 */

// Everything below is inside this "if", so a second copy of this file (say, auto_prepend_file plus a
// require of a copy elsewhere) is skipped instead of stopping the page with "Cannot redeclare function".
if (!function_exists('bizgenius_ssr_fill')):

if (!defined('BIZGENIUS_SSR_ENDPOINT')) {
    define('BIZGENIUS_SSR_ENDPOINT', 'https://www.bizgeniusapp.com/api/render.php');
}
define('BIZGENIUS_SSR_TTL', 12 * 3600);     // seconds a rendered box is reused
define('BIZGENIUS_SSR_MISS_TTL', 30 * 60);  // seconds before retrying a box whose request failed
define('BIZGENIUS_SSR_TIMEOUT', 3);         // seconds for all requests of one page (they run in parallel)
// What counts as an "empty" box: whitespace or non-breaking spaces (some editors save <div></div> as <div>&nbsp;</div>)
define('BIZGENIUS_SSR_EMPTY', '(?:\s|&nbsp;|&#160;|\xC2\xA0)*');

function bizgenius_ssr_buffer($chunk, $phase)
{
    if (($phase & PHP_OUTPUT_HANDLER_CLEAN) || (defined('BIZGENIUS_SSR_DISABLE') && BIZGENIUS_SSR_DISABLE)
        || !bizgenius_ssr_is_html()) {
        return $chunk;
    }
    try {
        return bizgenius_ssr_fill($chunk);
    } catch (Throwable $e) {
        return $chunk; // never break the page
    }
}

/** The page with every empty, supported widget box filled. Safe to call more than once. */
function bizgenius_ssr_fill($html)
{
    // A page sent in parts (ob_flush(), or a page over 4 MB) has its key in the first part
    static $key = '';
    static $sourcekeyword = '';
    if (!is_string($html) || stripos($html, 'bizgenius') === false) {
        return $html;
    }
    $found = bizgenius_ssr_find_key($html);
    if ($found !== '') {
        $key = $found;
    }
    $found = bizgenius_ssr_find_sourcekeyword($html);
    if ($found !== null) {
        $sourcekeyword = $found;
    }
    return bizgenius_ssr_fill_page($html, bizgenius_ssr_host(), $key, 'bizgenius_ssr_fetch_all', $sourcekeyword);
}

/** Whether this response is an HTML page (the Content-Type we are sending, or PHP's default). */
function bizgenius_ssr_is_html()
{
    if (isset($_SERVER['REQUEST_METHOD']) && $_SERVER['REQUEST_METHOD'] === 'HEAD') {
        return false;
    }
    foreach (headers_list() as $header) {
        if (stripos($header, 'content-type:') === 0) {
            return stripos($header, 'text/html') !== false;
        }
    }
    $default = (string)ini_get('default_mimetype');
    return $default === '' || stripos($default, 'text/html') !== false;
}

function bizgenius_ssr_host()
{
    $host = isset($_SERVER['HTTP_HOST']) ? strtolower((string)$_SERVER['HTTP_HOST']) : '';
    return preg_replace('/:\d+$/', '', $host);
}

/**
 * Fill every empty, supported widget box in $html. Pure function apart from $fetcher, which receives
 * a list of render.php URLs and returns url => html. Same page scan as the WordPress plugin's
 * bgssr_fill() (integrations/wordpress/bizgenius-ssr).
 *
 * The page's own URL is deliberately not sent (render.php's href is optional). Nothing in the response
 * depends on it, and the request URL is the cache key, so including it would give every page - and every
 * ?utm_source=... variant of it - its own cache entry and its own call to BizGenius.
 *
 * $sourcekeyword is the widget script URL's sourcekeyword=... (one account on several brand sites, each
 * showing only its own papers); it goes with every request, as citation.js sends it.
 */
function bizgenius_ssr_fill_page($html, $host, $key, $fetcher, $sourcekeyword = '')
{
    if (!is_string($html) || $html === '' || stripos($html, 'bizgenius') === false || $host === '' || $key === '') {
        return $html;
    }

    // Page-wide context that citation.js sends with the counts/form request.
    $form = bizgenius_ssr_container_attrs($html, 'bizgenius-citation-widget-container-form');
    $map = bizgenius_ssr_container_attrs($html, 'bizgenius-citation-widget-container-map');
    $labels = array();
    foreach (array('count-total', 'count-year', 'count-month', 'count-total-year-month') as $c) {
        $attrs = bizgenius_ssr_container_attrs($html, 'bizgenius-citation-widget-container-' . $c);
        if ($attrs !== null) {
            $labels[$c] = isset($attrs['label']) ? $attrs['label'] : '';
        }
    }

    $jobs = array(); // each: url, offset, length, and for product badges the product name

    // Empty boxes only: <div ...></div> (or <span>, as the AI Summary box may be) with nothing but whitespace
    // or &nbsp; inside.
    if (preg_match_all('~<(div|span)\b([^>]*)>(' . BIZGENIUS_SSR_EMPTY . ')</\1>~i', $html, $hits, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
        foreach ($hits as $hit) {
            $attrs = bizgenius_ssr_parse_attrs($hit[2][0]);
            $part = bizgenius_ssr_part($attrs);
            if ($part === '') {
                continue;
            }
            $query = array('key' => $key, 'domain' => $host, 'part' => $part, 'a' => bizgenius_ssr_data_attrs($attrs));
            if (strpos($part, 'count-') === 0 || $part === 'form') {
                // same page context for all of them, so they make one cached getData request
                // and show the same numbers
                $query['f'] = $form === null ? array() : $form;
                $query['m'] = $map === null ? array() : $map;
                $query['l'] = $labels;
            }
            if ($sourcekeyword !== '') {
                $query['sourcekeyword'] = $sourcekeyword;
            }
            $jobs[] = array('url' => BIZGENIUS_SSR_ENDPOINT . '?' . http_build_query($query),
                            'offset' => $hit[3][1], 'length' => strlen($hit[3][0]), 'product' => null);
        }
    }

    // Product citation-count badges (<span> or <div>, often dozens on a listing page): one request
    // per 100 products that share the same badge settings, not one per badge.
    $groups = array();
    if (preg_match_all('~<(span|div)\b([^>]*\bbizgenius-citation-widget-container-count-product\b[^>]*)>(' . BIZGENIUS_SSR_EMPTY . ')</\1>~i', $html, $hits, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) {
        foreach ($hits as $hit) {
            $data = bizgenius_ssr_data_attrs(bizgenius_ssr_parse_attrs($hit[2][0]));
            $product = isset($data['product-name']) ? trim($data['product-name']) : '';
            if ($product === '') {
                continue;
            }
            unset($data['product-name']);
            ksort($data);
            $groups[serialize($data)]['settings'] = $data;
            $groups[serialize($data)]['badges'][] = array('product' => $product, 'offset' => $hit[3][1], 'length' => strlen($hit[3][0]));
        }
    }
    foreach ($groups as $group) {
        $products = array_values(array_unique(array_map(function ($b) { return $b['product']; }, $group['badges'])));
        foreach (array_chunk($products, 100) as $chunk) {
            $url = BIZGENIUS_SSR_ENDPOINT . '?' . http_build_query(array('key' => $key, 'domain' => $host,
                'part' => 'count-product', 'products' => $chunk, 'a' => $group['settings'])
                + ($sourcekeyword !== '' ? array('sourcekeyword' => $sourcekeyword) : array()));
            foreach ($group['badges'] as $b) {
                if (in_array($b['product'], $chunk, true)) {
                    $jobs[] = array('url' => $url, 'offset' => $b['offset'], 'length' => $b['length'], 'product' => $b['product']);
                }
            }
        }
    }
    if (!$jobs) {
        return $html;
    }

    $urls = array();
    foreach ($jobs as $job) {
        $urls[$job['url']] = true;
    }
    $fragments = call_user_func($fetcher, array_keys($urls));

    // Badge responses are JSON: product => html.
    $badgeHtml = array();
    foreach ($jobs as $job) {
        if ($job['product'] !== null && !isset($badgeHtml[$job['url']])) {
            $badgeHtml[$job['url']] = array();
            $decoded = isset($fragments[$job['url']]) ? json_decode((string)$fragments[$job['url']], true) : null;
            foreach ((is_array($decoded) && isset($decoded['results']) && is_array($decoded['results'])) ? $decoded['results'] : array() as $r) {
                if (isset($r['product'], $r['html']) && is_string($r['html'])) {
                    $badgeHtml[$job['url']][(string)$r['product']] = $r['html'];
                }
            }
        }
    }

    // Insert from the end of the page backwards so earlier offsets stay valid.
    usort($jobs, function ($x, $y) { return $y['offset'] - $x['offset']; });
    foreach ($jobs as $job) {
        if ($job['product'] === null) {
            $fragment = isset($fragments[$job['url']]) ? $fragments[$job['url']] : '';
        } else {
            $fragment = isset($badgeHtml[$job['url']][$job['product']]) ? $badgeHtml[$job['url']][$job['product']] : '';
        }
        if (is_string($fragment) && strpos($fragment, 'bizgenius-ssr') !== false) {
            $html = substr_replace($html, $fragment, $job['offset'], $job['length']);
        }
    }
    return $html;
}

/** Which render.php part a box is, or '' if we don't server-render it. */
function bizgenius_ssr_part($attrs)
{
    $id = isset($attrs['id']) ? strtolower($attrs['id']) : '';
    $prefix = 'bizgenius-citation-widget-container-';
    $byId = array('list', 'list-top', 'list-new-papers-pubmed', 'list-new-papers-pmc', 'list-figure', 'count-total', 'count-year', 'count-month', 'count-total-year-month', 'form', 'summary');
    if (strpos($id, $prefix) === 0 && in_array(substr($id, strlen($prefix)), $byId, true)) {
        $part = substr($id, strlen($prefix));
        return $part === 'summary' ? 'aisummary' : $part;
    }
    $class = isset($attrs['class']) ? ' ' . strtolower($attrs['class']) . ' ' : '';
    if (strpos($class, ' bizgenius-top-journal-widget-container ') !== false) {
        return 'top-journal';
    }
    if (strpos($class, ' bizgenius-top-university-widget-container ') !== false) {
        return 'top-university';
    }
    if (strpos($class, ' bizgenius-top-company-widget-container ') !== false) {
        return 'top-company';
    }
    if (strpos($class, ' bizgenius-top-university-and-company-widget-container ') !== false) {
        return 'top-university-and-company';
    }
    if (strpos($class, ' bizgenius-citation-widget-container-aisummary ') !== false
        || strpos($class, ' bizgenius-citation-widget-container-summary ') !== false) {
        return 'aisummary';   // the summary box is the AI Summary under its short-lived earlier name
    }
    return '';
}

/** Widget key: BIZGENIUS_SSR_KEY, <meta name="bizgeniusapp-key">, or citation.min.js?key=... */
function bizgenius_ssr_find_key($html)
{
    if (defined('BIZGENIUS_SSR_KEY') && BIZGENIUS_SSR_KEY) {
        return (string)BIZGENIUS_SSR_KEY;
    }
    if (preg_match_all('~<meta\b([^>]*)>~i', $html, $metas)) {
        foreach ($metas[1] as $raw) {
            $attrs = bizgenius_ssr_parse_attrs($raw);
            if (isset($attrs['name'], $attrs['content']) && strtolower($attrs['name']) === 'bizgeniusapp-key') {
                return trim($attrs['content']);
            }
        }
    }
    // www.biz-genius.com/api/citation.min.js is the widget's old address, still on some pages
    if (preg_match('~(?:bizgeniusapp\.(?:com|cn)|biz-genius\.com)/api/citation[\w.]*\.js\?[^"\'>]*?\bkey=([^&"\'>\s]+)~i', $html, $m)) {
        return trim(rawurldecode(html_entity_decode($m[1], ENT_QUOTES)));
    }
    return '';
}

/**
 * The sourcekeyword=... of the widget's citation.min.js URL ('' if it has none), or null when the
 * script isn't in $html. Like citation.js, the first widget script on the page counts.
 */
function bizgenius_ssr_find_sourcekeyword($html)
{
    if (!preg_match('~<script\b[^>]*\bsrc\s*=\s*["\']?[^"\'>\s]*(?:bizgeniusapp\.(?:com|cn)|biz-genius\.com)/api/citation[\w.]*\.js(\?[^"\'>\s]*)?~i', $html, $m)) {
        return null;
    }
    parse_str(isset($m[1]) ? html_entity_decode(substr($m[1], 1), ENT_QUOTES) : '', $query);
    return isset($query['sourcekeyword']) && is_string($query['sourcekeyword']) ? trim($query['sourcekeyword']) : '';
}

/** Attributes of the element with this id, or null if it is not on the page. */
function bizgenius_ssr_container_attrs($html, $id)
{
    if (!preg_match('~<div\b([^>]*\bid\s*=\s*["\']' . preg_quote($id, '~') . '["\'][^>]*)>~i', $html, $m)) {
        return null;
    }
    return bizgenius_ssr_data_attrs(bizgenius_ssr_parse_attrs($m[1]));
}

function bizgenius_ssr_parse_attrs($raw)
{
    $out = array();
    if (preg_match_all('~([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s"\'=<>`]+))~', $raw, $m, PREG_SET_ORDER)) {
        foreach ($m as $a) {
            $value = isset($a[4]) && $a[4] !== '' ? $a[4] : (isset($a[3]) && $a[3] !== '' ? $a[3] : $a[2]);
            $out[strtolower($a[1])] = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
        }
    }
    return $out;
}

/** data-x attributes as x => value. */
function bizgenius_ssr_data_attrs($attrs)
{
    $out = array();
    foreach ($attrs as $name => $value) {
        if (strpos($name, 'data-') === 0) {
            $out[substr($name, 5)] = $value;
        }
    }
    return $out;
}

/** Fetch render.php URLs: cached boxes first, the rest in parallel. Returns url => html ('' = nothing). */
function bizgenius_ssr_fetch_all($urls)
{
    $out = array();
    $missing = array();
    foreach ($urls as $url) {
        $file = bizgenius_ssr_cache_file($url);
        // a file's mtime is when it expires minus BIZGENIUS_SSR_TTL (see bizgenius_ssr_cache_put)
        if ($file !== '' && is_file($file) && filemtime($file) > time() - BIZGENIUS_SSR_TTL) {
            $out[$url] = (string)@file_get_contents($file);
        } else {
            $missing[] = $url;
        }
    }
    if (!$missing) {
        return $out;
    }

    $fetched = function_exists('curl_multi_init') ? bizgenius_ssr_fetch_parallel($missing) : bizgenius_ssr_fetch_serial($missing);
    foreach ($missing as $url) {
        $body = isset($fetched[$url]) ? $fetched[$url] : null; // null = request failed, '' = nothing to show (204)
        $ok = is_string($body) && strpos($body, 'bizgenius-ssr') !== false;
        // "nothing to show" is an answer, so keep it as long as a box: a 204 (e.g. an uncited product's list),
        // or product badges that are all hidden at 0 ({"results":[...]} with no badge HTML in it)
        $answered = $ok || $body === '' || (is_string($body) && strncmp($body, '{"results":', 11) === 0);
        bizgenius_ssr_cache_put(bizgenius_ssr_cache_file($url), $ok ? $body : '',
            $answered ? BIZGENIUS_SSR_TTL : BIZGENIUS_SSR_MISS_TTL);
        $out[$url] = $ok ? $body : '';
    }
    return $out;
}

/** Cache file for one render.php URL, or '' if there is no writable cache folder. */
function bizgenius_ssr_cache_file($url)
{
    $dir = defined('BIZGENIUS_SSR_CACHE_DIR') ? (string)BIZGENIUS_SSR_CACHE_DIR : sys_get_temp_dir() . '/bizgenius-ssr';
    $dir = rtrim($dir, '/\\');
    if (!is_dir($dir) && !@mkdir($dir, 0700, true) && !is_dir($dir)) {
        return '';
    }
    return $dir . '/' . md5($url) . '.html';
}

/** Write a cache file that expires $ttl seconds from now, without readers ever seeing half a file. */
function bizgenius_ssr_cache_put($file, $body, $ttl)
{
    if ($file === '') {
        return;
    }
    $tmp = $file . '.' . getmypid() . '.tmp';
    if (@file_put_contents($tmp, $body) !== false && @rename($tmp, $file)) {
        @touch($file, time() - BIZGENIUS_SSR_TTL + $ttl);
    } else {
        @unlink($tmp);
    }
}

function bizgenius_ssr_user_agent()
{
    return 'BizGenius-SSR/1.0 (PHP; ' . bizgenius_ssr_host() . ')';
}

function bizgenius_ssr_fetch_parallel($urls)
{
    $multi = curl_multi_init();
    $handles = array();
    foreach ($urls as $url) {
        $h = curl_init($url);
        curl_setopt_array($h, array(
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_FOLLOWLOCATION => false,
            CURLOPT_CONNECTTIMEOUT => BIZGENIUS_SSR_TIMEOUT,
            CURLOPT_TIMEOUT        => BIZGENIUS_SSR_TIMEOUT,
            CURLOPT_USERAGENT      => bizgenius_ssr_user_agent(),
            CURLOPT_ENCODING       => '',
        ));
        curl_multi_add_handle($multi, $h);
        $handles[$url] = $h;
    }
    do {
        $status = curl_multi_exec($multi, $running);
        if ($running) {
            curl_multi_select($multi, 0.2);
        }
    } while ($running && $status === CURLM_OK);

    $out = array();
    foreach ($handles as $url => $h) {
        $code = (int)curl_getinfo($h, CURLINFO_HTTP_CODE);
        $body = curl_multi_getcontent($h);
        $out[$url] = ($code === 200 && is_string($body)) ? $body : ($code === 204 ? '' : null);
        curl_multi_remove_handle($multi, $h);
        if (PHP_VERSION_ID < 80000) {
            curl_close($h); // a no-op since PHP 8.0, deprecated in 8.5
        }
    }
    curl_multi_close($multi);
    return $out;
}

/** Without curl: one request at a time, all within BIZGENIUS_SSR_TIMEOUT seconds. */
function bizgenius_ssr_fetch_serial($urls)
{
    $out = array();
    $deadline = microtime(true) + BIZGENIUS_SSR_TIMEOUT;
    foreach ($urls as $url) {
        $left = $deadline - microtime(true);
        if ($left <= 0) {
            $out[$url] = null;
            continue;
        }
        $context = stream_context_create(array('http' => array(
            'timeout'         => $left,
            'follow_location' => 0,
            'ignore_errors'   => true,
            'user_agent'      => bizgenius_ssr_user_agent(),
        )));
        $fp = @fopen($url, 'rb', false, $context);
        if ($fp === false) {
            $out[$url] = null;
            continue;
        }
        $meta = stream_get_meta_data($fp);
        $body = stream_get_contents($fp);
        fclose($fp);
        $code = (isset($meta['wrapper_data'][0]) && preg_match('~^HTTP/\S+\s+(\d{3})~', (string)$meta['wrapper_data'][0], $m)) ? (int)$m[1] : 0;
        $out[$url] = ($code === 200 && is_string($body)) ? $body : ($code === 204 ? '' : null);
    }
    return $out;
}

// Start collecting the page (after the functions above exist: inside the "if" they are declared in order).
// The chunk size keeps a large download from being held in memory: past 4 MB the buffer is sent on in
// parts, and bizgenius_ssr_buffer() leaves anything that isn't HTML alone.
if (PHP_SAPI !== 'cli' && !(defined('BIZGENIUS_SSR_DISABLE') && BIZGENIUS_SSR_DISABLE)) {
    ob_start('bizgenius_ssr_buffer', 4 * 1024 * 1024);
}

endif;
