/**
 * PSA cert batch-comp helper.
 *
 *   node helper.js          â†’ start the local HTTP server (default; what `npm start` runs)
 *
 * Architecture: attaches to a Brave window launched by launch-brave.bat (which
 * exposes Chrome DevTools Protocol on port 9222). All page navigation happens inside
 * that real Brave instance, which has your auth + Cloudflare clearance, so CF sees
 * nothing automated. Helper takes a screenshot of each cert page and ships it to
 * Claude vision for price extraction. Results cached in cache.json for 24h.
 *
 * The same Express server also serves the website at http://localhost:5050.
 */

require('dotenv').config({ override: true });
const fs = require('fs');
const path = require('path');
const { spawn, execFile } = require('child_process');
const express = require('express');
const cors = require('cors');
const { chromium } = require('playwright');
const Anthropic = require('@anthropic-ai/sdk');

const BOT_VERSION = '2.8.8';   // 2.8.8: adb also resolves from android-tools INSIDE the bot folder (customer installs bundle it there)
                               // 2.8.7: a phone drop mid-lookup no longer mis-caches as "not found" (checks connection → retryable timeout instead)
                               // 2.8.6: Courtyard cache 2 days (per owner)
                               // 2.8.5: PC awake for bot lifetime (no idle-sleep mid-stall) + phone watchdog (keep-alive/auto-reconnect) + Courtyard cache
                               // 2.8.4: keep the Windows PC awake during pricing runs (ES_SYSTEM_REQUIRED holder, auto-release when idle)
                               // 2.8.3: REVERTED the 2.8.2 timing trims (couldn't validate live — phone was locked); back to known-good 2.8.1 timing
                               // 2.8.1: report confMax (5=premium in-row number, 3=normal green dots) so the app draws the right dot scale
                               // 2.8.0: parse new Sourcer/premium result row (status + in-row confidence); expose buyStatus/buying
                               // 2.7.1: sheet-never-closed = card-not-found (stop retrying); cross-platform adb
const PORT = parseInt(process.env.PORT || '5050', 10);
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
const CONCURRENCY = Math.max(1, Math.min(8, parseInt(process.env.CONCURRENCY || '3', 10)));
const URL_TEMPLATE = process.env.CARDLADDER_URL || 'https://app.cardladder.com/search?cert={cert}&grader={grader}';
// PSA cert numbers are 8â€“9 digits; CGC cert numbers are longer (10+). CardLadder's
// search takes a &grader= param, so we detect the grader from the cert's length and
// search the right one â€” that's what lets CGC slabs price instead of coming back empty.
function graderForCert(cert) {
  return String(cert || '').replace(/\D/g, '').length >= 10 ? 'cgc' : 'psa';
}
const CDP_URL = process.env.CDP_URL || 'http://localhost:9222';
const CACHE_TTL_MS = parseInt(process.env.CACHE_TTL_MS || String(24 * 60 * 60 * 1000), 10);
// Courtyard values cache longer than CardLadder: a phone scan costs ~25s (vs a fast web read), so
// re-running a batch shouldn't re-scan cards it just did. 2 days (the ↻ re-check forces a fresh scan).
const CY_CACHE_TTL_MS = parseInt(process.env.CY_CACHE_TTL_MS || String(2 * 24 * 60 * 60 * 1000), 10);
const PER_CERT_RETRIES = parseInt(process.env.PER_CERT_RETRIES || '1', 10);

// ── Keep the Windows PC awake for the bot's whole lifetime ────────────────────
// The machine sleeping mid-batch kills a multi-hour Courtyard run. We hold the SYSTEM awake
// (ES_CONTINUOUS|ES_SYSTEM_REQUIRED = 0x80000001) via a tiny hidden PowerShell holder for as long
// as the bot runs — NOT just during active pricing, because a phone drop stalls the run, and an
// idle-release would then sleep the PC and make recovery worse. Close the bot to let the PC sleep.
// The holder watches this bot's PID and exits on its own if the bot dies, so a hard kill never
// leaves the machine stuck awake. Windows-only, best-effort — a failure never blocks pricing.
let awakeProc = null;
function keepAwake() {
  if (process.platform !== 'win32' || awakeProc) return;
  try {
    const ps = "$ppid=" + process.pid + ";"
      + "$s='[DllImport(\"kernel32.dll\")]public static extern uint SetThreadExecutionState(uint e);';"
      + "$t=Add-Type -MemberDefinition $s -Name P -Namespace W -PassThru;"
      + "while($true){ if(-not (Get-Process -Id $ppid -ErrorAction SilentlyContinue)){break}; "
      + "$t::SetThreadExecutionState(2147483649)|Out-Null; Start-Sleep -Seconds 30 }";
    awakeProc = spawn('powershell', ['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-Command', ps], { windowsHide: true });
    awakeProc.on('error', () => { awakeProc = null; });
    awakeProc.on('exit',  () => { awakeProc = null; });
    console.log('[awake] holding the PC awake while the bot runs');
  } catch (e) { awakeProc = null; }
}
function releaseAwake() {
  if (awakeProc) { try { awakeProc.kill(); } catch (e) {} awakeProc = null; }
}
process.on('exit', releaseAwake);
// Tiered "settle" delay: the FIRST pass is fast (like it used to be) so normal
// cards fly; only the retry â€” which fires solely when a card comes back empty/bad â€”
// waits the longer, careful settle to catch slow/half-rendered pages.
const SETTLE_FAST_MS    = parseInt(process.env.SETTLE_FAST_MS    || '400', 10);
const SETTLE_CAREFUL_MS = parseInt(process.env.SETTLE_CAREFUL_MS || '2200', 10);
const API_KEY = process.env.HELPER_API_KEY || '';

// â”€â”€ cache (plain JSON file, no native deps) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
const CACHE_FILE = path.join(__dirname, 'cache.json');
let cacheData = {};
try {
  if (fs.existsSync(CACHE_FILE)) cacheData = JSON.parse(fs.readFileSync(CACHE_FILE, 'utf8'));
} catch (e) {
  console.warn('Could not load cache.json, starting fresh:', e.message);
  cacheData = {};
}
let cacheDirty = false;
function persistCache() {
  if (!cacheDirty) return;
  try { fs.writeFileSync(CACHE_FILE, JSON.stringify(cacheData)); cacheDirty = false; }
  catch (e) { console.warn('Could not persist cache:', e.message); }
}
setInterval(persistCache, 5000).unref();

function readCache(cert) {
  const row = cacheData[cert];
  if (!row) return null;
  if (Date.now() - row.ts > CACHE_TTL_MS) return null;
  return { ...row.result, cached: true, cachedAgeMs: Date.now() - row.ts };
}
function writeCache(cert, result) {
  if (!result || !result.found) return;
  cacheData[cert] = { result, ts: Date.now() };
  cacheDirty = true;
}
function cacheCount() { return Object.keys(cacheData).length; }
function cacheDelete(cert) {
  if (!cacheData[cert]) return 0;
  delete cacheData[cert]; cacheDirty = true; return 1;
}
function cacheClear() {
  const n = Object.keys(cacheData).length;
  cacheData = {}; cacheDirty = true; return n;
}

// â”€â”€ CDP attach (with retry) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
let cdpBrowser = null;
let ctx = null;
async function getContext() {
  if (ctx) return ctx;
  try { await ensureBrave(); } catch (e) { console.warn('[browser] auto-launch skipped:', e.message); }
  let lastErr;
  for (let i = 0; i < 30; i++) {
    try {
      cdpBrowser = await chromium.connectOverCDP(CDP_URL);
      ctx = cdpBrowser.contexts()[0] || await cdpBrowser.newContext();
      console.log(`[browser] attached at ${CDP_URL} on attempt ${i + 1}`);
      return ctx;
    } catch (e) {
      lastErr = e;
      if (i === 0) console.log(`[browser] waiting for browser at ${CDP_URL} ...`);
      await sleep(2000);
    }
  }
  throw new Error(`Could not attach at ${CDP_URL}: ${lastErr?.message}. Run launch-brave.bat and try again.`);
}

const sleep = ms => new Promise(r => setTimeout(r, ms));

// â”€â”€ auto-launch Brave (so you never launch it separately) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
// Finds the Brave executable on macOS or Windows and launches it with remote
// debugging on the CDP port, using the local brave-profile dir (so the CardLadder
// login sticks).
function findBrave() {
  let candidates;
  if (process.platform === 'darwin') {
    candidates = [
      '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
      process.env.HOME && path.join(process.env.HOME, 'Applications', 'Brave Browser.app', 'Contents', 'MacOS', 'Brave Browser'),
    ];
  } else if (process.platform === 'win32') {
    candidates = [
      process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),
      process.env['ProgramFiles(x86)'] && path.join(process.env['ProgramFiles(x86)'], 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),
      process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'BraveSoftware', 'Brave-Browser', 'Application', 'brave.exe'),
    ];
  } else {
    candidates = ['/usr/bin/brave-browser', '/usr/bin/brave', '/snap/bin/brave'];
  }
  return (candidates || []).filter(Boolean).find(p => { try { return fs.existsSync(p); } catch { return false; } }) || null;
}

async function cdpReachable(timeoutMs = 2000) {
  try {
    const r = await fetch(`${CDP_URL}/json/version`, { signal: AbortSignal.timeout(timeoutMs) });
    return r.ok;
  } catch { return false; }
}

let braveProc = null;
let ensureBravePromise = null;
async function _ensureBrave() {
  if (await cdpReachable()) return { launched: false, alreadyUp: true };
  if (!/localhost|127\.0\.0\.1/.test(CDP_URL)) {
    throw new Error(`No browser at ${CDP_URL} and it isn't local, so the helper can't launch it.`);
  }
  const brave = findBrave();
  if (!brave) {
    throw new Error('Could not find Brave. Install it from https://brave.com and try again.');
  }
  const profile = path.join(__dirname, 'brave-profile');
  try { fs.mkdirSync(profile, { recursive: true }); } catch {}
  const port = (CDP_URL.match(/:(\d+)/) || [])[1] || '9222';
  console.log(`[browser] no CDP at ${CDP_URL}; launching Brave (${brave})...`);
  braveProc = spawn(brave, [
    `--remote-debugging-port=${port}`,
    `--user-data-dir=${profile}`,
    '--no-default-browser-check',
    '--no-first-run',
    'https://app.cardladder.com',
  ], { detached: true, stdio: 'ignore' });
  braveProc.unref();
  for (let i = 0; i < 30; i++) {
    if (await cdpReachable()) { console.log('[browser] Brave is up and listening on CDP.'); return { launched: true }; }
    await sleep(1000);
  }
  throw new Error(`Launched Brave but CDP never came up on port ${port}.`);
}
function ensureBrave() {
  if (!ensureBravePromise) ensureBravePromise = _ensureBrave().finally(() => { ensureBravePromise = null; });
  return ensureBravePromise;
}

// â”€â”€ pricing extraction â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
// The Anthropic key can be set from the app's Setup tab (saved to config.json on
// this machine) or, as a fallback, ANTHROPIC_API_KEY in .env. config.json wins so
// a customer can paste their own key without touching any files.
const CONFIG_FILE = path.join(__dirname, 'config.json');
let appConfig = {};
try { if (fs.existsSync(CONFIG_FILE)) appConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); }
catch (e) { console.warn('Could not read config.json:', e.message); }
let currentKey = appConfig.anthropicKey || process.env.ANTHROPIC_API_KEY || '';
let anthropic = currentKey ? new Anthropic({ apiKey: currentKey }) : null;
function setAnthropicKey(key) {
  currentKey = (key || '').trim();
  anthropic = currentKey ? new Anthropic({ apiKey: currentKey }) : null;
  appConfig.anthropicKey = currentKey;
  try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(appConfig, null, 2)); }
  catch (e) { console.warn('Could not save config.json:', e.message); }
}
// â”€â”€ Server AI proxy (holds the OWNER's key + meters usage for billing). When a
// product token is set, the helper sends screenshots here instead of calling
// Anthropic directly. Falls back to a local Anthropic key if no token is set.
const PROXY_URL = process.env.PROXY_URL || appConfig.proxyUrl || 'https://ppfhgrurquvlyabtymbq.supabase.co/functions/v1/price-proxy';
let productToken = appConfig.productToken || process.env.PRODUCT_TOKEN || '';
function setProductToken(t) {
  productToken = (t || '').trim();
  appConfig.productToken = productToken;
  // Token wins and is metered/billed by the owner â€” drop any local Anthropic key so
  // pricing can never silently run (and bill) on the customer's own account.
  if (productToken) { currentKey = ''; anthropic = null; appConfig.anthropicKey = ''; }
  try { fs.writeFileSync(CONFIG_FILE, JSON.stringify(appConfig, null, 2)); }
  catch (e) { console.warn('Could not save config.json:', e.message); }
}
function mask(s) { return s ? s.slice(0, 8) + 'â€¦' + s.slice(-4) : ''; }
function keyStatus() {
  const hasToken = !!productToken;
  const hasKey = !!currentKey;
  return {
    keySet: hasToken || hasKey,                 // pricing is ready if either works
    tokenSet: hasToken,
    mode: hasToken ? 'proxy' : (hasKey ? 'direct' : 'none'),
    keyMasked: hasToken ? mask(productToken) : mask(currentKey),
  };
}

const PROMPT = (cert) => `You are reading a screenshot from cardladder.com. The page was opened by searching PSA cert #${cert} â€” read the comps for the ONE specific card that matches this cert, and refuse if the page is showing a different card or a list of cards.
You MUST return ONLY valid JSON. No markdown, no prose, no apologies, no explanations.
Even if some data is missing, ALWAYS return JSON. Use null / empty array for missing fields.

Required shape:
{
  "found": boolean,
  "certOnPage": string | null,
  "cardName": string | null,
  "grade": string | null,
  "sport": "pokemon" | "one_piece" | "dragon_ball" | "nba" | "nfl" | "mlb" | "nhl" | "soccer" | "racing" | "fighting" | "golf" | "other" | null,
  "language": "English" | "Japanese" | "Chinese" | "Korean" | "German" | "French" | "Italian" | "Spanish" | "Portuguese" | "Other" | null,
  "recentSales": number[],
  "lastSaleDate": string | null,
  "clValue": number | null,
  "salesCount": number | null,
  "note": string | null
}

Rules:
- "found" = true if the page identifies a specific card for this cert (a CL Value is visible OR a card name/title is shown). A long list of past SALES for that same card is NORMAL and expected â€” that is still found:true. Only use found:false when NO specific card is shown: a blank/still-loading page, a "no results / not found" message, or a grid/feed of clearly DIFFERENT card products (different names/players). When unsure but a CL Value or a card title is visible, prefer found:true.
- "certOnPage" = the grading cert / serial number printed on the page for the card shown (near the PSA/CGC grade, or a "Cert"/"Serial" label). Digits only. null if not visible. This â€” not the page layout â€” is what guards against reading the wrong card.
- "clValue" = the headline number labeled "CL Value" at the top of the page (e.g. "CL Value  $52.00" â†’ 52). This is the PRIMARY data point â€” return it whenever visible, even if the SALES list isn't.
- "recentSales" = up to 5 most recent prices from the SALES list, newest first. Bare numbers, no $ or commas. Return [] if prices aren't visible (e.g. layout doesn't show price column). DO NOT skip JSON because of this.
- "lastSaleDate" = the date of the MOST RECENT sale in the SALES list, in strict YYYY-MM-DD format. Convert relative dates ("3 days ago", "2 weeks ago") to absolute dates assuming today is ${new Date().toISOString().slice(0,10)}. Return null only if no sale dates are visible at all.
- "cardName" + "grade" come from the card identification line (e.g. "2022 Pokemon SW&SH Silver Tempest Blissey V TG22, Grader: PSA, Condition: 9").
- "sport" = the sport/category, using REAL knowledge of the player, set, and brand â€” do not guess from the brand alone (Donruss/Optic/Prizm/Select are used across many sports). Examples: Desmond Ridder / Justin Herbert / Saquon Barkley = nfl; an NBA player = nba; a baseball player (Bowman/Topps) = mlb; an NHL player = nhl; a Pokemon card = pokemon; Yu-Gi-Oh / One Piece / Dragon Ball / non-sport = the matching token or "other". Use "other" only if you genuinely cannot tell.
- "language" = the language printed on the card itself. Default to "English" if no other language is mentioned in the cardName or page content. Look for words like "Japanese", "Chinese", "Korean", "German" in the title or set name; these often appear inline (e.g. "2023 Pokemon Japanese..."). Use "Other" if a non-listed language is detected.
- "salesCount" = the "N results" count if shown.
- "note" can describe anything unusual but DO NOT replace JSON with prose.`;

function computeAvg3(recentSales) {
  if (!Array.isArray(recentSales) || recentSales.length === 0) return null;
  const top3 = recentSales.slice(0, 3).filter(n => typeof n === 'number' && isFinite(n));
  if (!top3.length) return null;
  const avg = top3.reduce((a, b) => a + b, 0) / top3.length;
  return Math.round(avg * 100) / 100;
}

async function extractPriceFromScreenshot(cert, imgBuffer) {
  // Preferred: the owner's proxy (holds the key + meters usage for billing).
  if (PROXY_URL && productToken) {
    const r = await fetch(PROXY_URL, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: 'Bearer ' + productToken, 'x-bot-version': BOT_VERSION },
      body: JSON.stringify({ cert, image: imgBuffer.toString('base64'), botVersion: BOT_VERSION }),
    });
    if (!r.ok) {
      // Surface trial/limit/paused clearly instead of a generic "no data" row.
      // 429 = cert limit reached, 403 = paused / trial expired.
      let reason = '';
      try { const j = await r.json(); reason = (j && j.error) || ''; } catch {}
      if (r.status === 429 || r.status === 403) {
        return { found: false, blocked: true, blockStatus: r.status, blockReason: reason || ('pricing service ' + r.status) };
      }
      throw new Error('pricing service ' + r.status + (reason ? ': ' + reason : ''));
    }
    return await r.json();
  }
  // Fallback: direct Anthropic with a local key.
  if (!anthropic) throw new Error('Pricing not connected yet â€” log into the app and it connects automatically.');
  const res = await anthropic.messages.create({
    model: MODEL,
    max_tokens: 700,
    messages: [{
      role: 'user',
      content: [
        // JPEG (not PNG) â€” ~8x smaller for this kind of page, so uploads and
        // vision inference are much faster with no loss of text legibility.
        { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: imgBuffer.toString('base64') } },
        { type: 'text', text: PROMPT(cert) },
      ],
    }],
  });
  const text = (res.content.find(c => c.type === 'text')?.text || '').trim();
  // Robust: strip fences, then recover the outermost { ... } from any surrounding prose.
  const t = text.replace(/```(?:json)?/gi, '').trim();
  let parsed = null;
  try { parsed = JSON.parse(t); } catch {}
  if (!parsed || typeof parsed !== 'object') {
    const s = t.indexOf('{'), e = t.lastIndexOf('}');
    if (s >= 0 && e > s) { try { parsed = JSON.parse(t.slice(s, e + 1)); } catch {} }
  }
  if (!parsed || typeof parsed !== 'object') return { found: false, note: 'unreadable AI reply: ' + text.slice(0, 140) };
  parsed.avg3Sales = computeAvg3(parsed.recentSales);
  return parsed;
}

// â”€â”€ single cert lookup (with retry on transient blank pages) â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
async function lookupCertOnce(cert, careful = false, opts = {}) {
  // After the CL Value is detected, a short paint buffer before the screenshot.
  // Slow mode (big lots) gets a longer buffer since we're prioritizing accuracy.
  const settleMs = careful ? SETTLE_CAREFUL_MS : (opts.slow ? 1500 : SETTLE_FAST_MS);
  const c = await getContext();
  const page = await c.newPage();
  await page.setViewportSize({ width: 1440, height: 2600 }).catch(() => {});
  const grader = graderForCert(cert);
  let url = URL_TEMPLATE.replace('{cert}', encodeURIComponent(cert)).replace('{grader}', grader);
  // Force the detected grader onto the URL even if the template hardcoded one (older
  // installs ship a .env with grader=psa that dotenv override:true would otherwise apply,
  // making CGC certs search as PSA and come back empty). Replace an existing grader= value,
  // or append one if the template has none.
  url = /[?&]grader=/i.test(url)
    ? url.replace(/([?&]grader=)[^&]*/i, '$1' + grader)
    : url + (url.includes('?') ? '&' : '?') + 'grader=' + grader;
  // Randomized pace before each navigation so a batch doesn't hit CardLadder in
  // tight bursts (which trips its per-account rate limit). Big lots (>50) run in
  // "slow" mode: 1-at-a-time (set by the caller) + a much longer gap here, so a
  // few-hundred-cert run crawls under CardLadder's radar instead of getting capped.
  const paceBase = opts.slow ? 1800 : 250;
  const paceJit  = opts.slow ? 2200 : 500;
  await sleep(paceBase + Math.floor(Math.random() * paceJit));
  const _t0 = Date.now();
  try {
    await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
    const _tGoto = Date.now();
    // Wait for the card data to render. NOTE: "CL Value" shows the "CL" as a
    // stylized logo, so it's NOT in innerText. The "N results" count renders
    // BEFORE the CL Value + sales prices, so keying on it screenshots too early
    // (missed comps). Wait specifically for MULTIPLE dollar prices â€” that only
    // happens once the CL Value AND the sales list have actually painted.
    // Signature is waitForFunction(fn, arg, options) â€” timeout MUST be the 3rd arg.
    // Wait until BOTH the sales list AND the CL Value have painted. The CL Value
    // renders as a GRAPHIC near the top of the page (the "CL Value" label is a
    // logo, so it's not findable text) â€” but its $ amount IS text and sits ABOVE
    // the sales list. So: require >=2 prices (sales loaded) AND a $ amount whose
    // on-screen position is in the top band (that's the CL Value, not a sale row).
    // Generous timeout â€” bigger in slow mode / under load. Cards that genuinely
    // have no CL Value time out here (top band stays empty) and we proceed.
    await page.waitForFunction(
      () => {
        const t = document.body.innerText || '';
        if (((t.match(/\$\s?[\d,]+(\.\d{2})?/g) || []).length) < 2) return false;
        const els = document.querySelectorAll('body *');
        for (const el of els) {
          if (el.childElementCount === 0 && /\$\s?[\d,]+/.test(el.textContent || '')) {
            const r = el.getBoundingClientRect();
            if (r.width > 0 && r.top >= 100 && r.top < 385) return true;   // CL Value in the top band
          }
        }
        return false;
      },
      undefined,
      { timeout: (opts.slow ? 16000 : 11000), polling: 250 }
    ).catch(() => {});
    const _tWait = Date.now();
    // Settle delay: let the page finish painting before the screenshot. Fast on
    // the first pass (normal cards), longer only on the careful retry.
    await page.waitForTimeout(settleMs);
    // Screenshot the VIEWPORT (no clip). The viewport was sized tall enough to
    // hold CL Value + the top of the sales list. Clipping BEYOND the viewport
    // (the old code) made Playwright hang until the 30s screenshot timeout â€” that
    // was the real slowdown. JPEG q78 keeps the image small + fast for vision.
    // animations:'disabled' + caret:'hide' â€” Cardladder is a live-updating SPA
    // that never stops animating; without this Playwright's screenshot waits for
    // stability forever and hits the timeout. This is the real cause of the
    // "no data" + slowness (blank shots after a 20-30s hang).
    const img = await page.screenshot({ type: 'jpeg', quality: 78, timeout: 20000, animations: 'disabled', caret: 'hide' });
    const _tShot = Date.now();
    try {
      const dir = path.join(__dirname, 'screenshots');
      if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
      fs.writeFileSync(path.join(dir, `cert-${cert}.jpg`), img);
    } catch {}
    const parsed = await extractPriceFromScreenshot(cert, img);
    // WRONG-CARD GUARD: if the page shows a cert number and it doesn't match the
    // one we asked for, we read an unrelated card (bad search result / no-match
    // page). Discard it so it becomes a "no data â€” re-comp" row instead of a
    // confidently-wrong comp. Only trigger on a clear full-length mismatch.
    if (parsed && parsed.found && !parsed.blocked) {
      const want = String(cert).replace(/\D/g, '');
      const got  = parsed.certOnPage ? String(parsed.certOnPage).replace(/\D/g, '') : '';
      if (got && want && got.length >= 6 && want.length >= 6 && got !== want) {
        console.warn(`[cert ${cert}] WRONG CARD â€” page cert ${got} != ${want}; discarding as no-data`);
        return { cert, url, grader, found: false, certOnPage: got, note: `wrong card: page showed cert ${got}, expected ${want}` };
      }
    }
    console.log(`[cert ${cert}] ${careful ? 'careful ' : ''}load ${_tGoto - _t0}ms Â· wait ${_tWait - _tGoto}ms Â· snap ${_tShot - _tWait - settleMs}ms Â· read ${Date.now() - _tShot}ms Â· total ${Date.now() - _t0}ms (${Math.round(img.length / 1024)}KB, found:${parsed.found})`);
    return { cert, url, grader, ...parsed };
  } catch (err) {
    console.error(`[cert ${cert}] lookup error:`, err.message);
    return { cert, url, grader, found: false, note: 'lookup error: ' + (err.message || String(err)).slice(0, 400) };
  } finally {
    await page.close().catch(() => {});
  }
}

async function lookupCert(cert, opts = {}) {
  const cached = readCache(cert);
  if (cached) {
    console.log(`[cert ${cert}] cache hit (age ${Math.round(cached.cachedAgeMs / 1000)}s)`);
    return cached;
  }
  for (let attempt = 0; attempt <= PER_CERT_RETRIES; attempt++) {
    // First pass is fast; any retry runs "careful" (longer settle) since a
    // found:false OR a found card with no CL Value usually means the page just
    // needed more time (the CL Value paints late as a skeleton).
    const r = await lookupCertOnce(cert, attempt > 0, opts);
    // A card WITH a CL Value is a confident hit â†’ cache + done.
    if (r.found && r.clValue != null) {
      writeCache(cert, r);
      return r;
    }
    if (r.blocked) return r;   // trial/limit/paused â€” a retry won't help
    if (attempt < PER_CERT_RETRIES) {
      console.log(`[cert ${cert}] attempt ${attempt + 1}: ${r.found ? 'no CL Value' : 'found:false'} â€” retrying carefully`);
      await sleep(1500);
    } else {
      // Last attempt: keep a found card even without a CL Value (some genuinely
      // have none). Don't cache it, so a manual â†» re-comp gets a fresh shot.
      return r;
    }
  }
}

// â”€â”€ concurrency-limited runner â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
async function runWithLimit(items, limit, worker, onResult) {
  const queue = items.slice();
  const inflight = [];
  while (queue.length || inflight.length) {
    while (queue.length && inflight.length < limit) {
      const item = queue.shift();
      const p = worker(item).then(r => {
        onResult && onResult(r);
        inflight.splice(inflight.indexOf(p), 1);
        return r;
      });
      inflight.push(p);
    }
    if (inflight.length) await Promise.race(inflight);
  }
}

// â”€â”€ Courtyard pricing â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
// Courtyard publishes a card-level market value (the same number its phone app shows)
// on courtyard.io. We look a card up from its CardLadder identity and accept a value
// ONLY on an exact match of card number + edition + grader + grade. A near miss is
// dangerous: 1999 Jungle #55 Mankey CGC 10 is $59.10 as 1st Edition but ~$24.80
// unlimited â€” 74% apart â€” so when in doubt we return nothing rather than a guess.
const COURTYARD_BASE = 'https://courtyard.io/marketplace';
const CY_INDEX = 'marketplace_prod_recently_listed';

// Courtyard's search is Algolia and it AND-matches every term, so each extra word
// SHRINKS the result set. That is what capped us at a ~15% hit rate: we were pushing
// set fragments ("Inferno X") into the query against titles that spell the same set
// differently ("M2-Inferno X" vs "Inferno X - M2"), so the query matched nothing and
// looked like Courtyard simply didn't have the card. It has them â€” dozens of copies.
//
// The fix is to narrow with FACETS instead of with words. Grader and Grade are real
// Algolia facets, so the server does the filtering and the right card lands on page 1
// (the grid only renders ~48 results, which is why a good card could be present and
// still never appear). Note the index is NOT listed-only â€” unlisted vaulted assets
// come back too, e.g. cert 6108870008 returns a card whose page says "Not listed".
function cyUrl(query, grader, gradeVariants, years) {
  const p = [enc(CY_INDEX + '[query]') + '=' + encodeURIComponent(query)];
  if (grader) p.push(enc(CY_INDEX + '[refinementList][metadata.Grader][0]') + '=' + enc(grader));
  (gradeVariants || []).forEach((g, i) =>
    p.push(enc(CY_INDEX + '[refinementList][metadata.Grade][' + i + ']') + '=' + enc(g)));
  // Years is a LIST: Courtyard and CardLadder disagree by a year on some sets (mostly
  // Japanese), and Algolia ORs values within a facet, so we ask for year-1/year/year+1
  // in a single request instead of running a whole second year-less ladder.
  (years || []).forEach((y, i) =>
    p.push(enc(CY_INDEX + '[refinementList][metadata.Year][' + i + ']') + '=' + enc(String(y))));
  return COURTYARD_BASE + '?' + p.join('&');
}
function cyYear(s) { const m = String(s || '').match(/\b((?:19|20)\d{2})\b/); return m ? m[1] : null; }
function cyYearNum(s) { const y = cyYear(s); return y ? parseInt(y, 10) : null; }

// Compare the FULL set number, not just the numerator. "057/091" (2024 Trick or Trade)
// and "057/198" (2022 Trick or Trade) are different cards from different sets that both
// start with 057 — matching on the numerator alone priced one as the other.
function cyNumDen(s) {
  const t = String(s || '');
  let m = t.match(/#\s*0*(\d+)\s*\/\s*0*(\d+)/);   // "#057/198"
  if (m) return { n: m[1], d: m[2] };
  m = t.match(/\b0*(\d{1,4})\s*\/\s*0*(\d+)\b/);   // bare "057/091"
  if (m) return { n: m[1], d: m[2] };
  m = t.match(/#\s*0*(\d+)/);                      // "#48"
  if (m) return { n: m[1], d: null };
  return { n: null, d: null };
}
function enc(s) { return encodeURIComponent(s); }

// The Grade facet stores the FULL tier string ("10 PRISTINE", "9 MINT", "8.5 NM-MT+"),
// which is exactly the CGC GEM MINT vs PRISTINE split we otherwise had to police by
// hand. Several variants can be passed at once â€” Algolia ORs within a facet, and a
// tier string that doesn't exist simply matches nothing, so extra guesses are free.
// If every guess is wrong we'd get a false miss, so courtyardValue keeps a
// grader-only attempt as a safety net.
function cyGradeFacets(gradeNum, wantPristine) {
  const g = String(gradeNum);
  switch (g) {
    case '10':  return wantPristine ? ['10 PRISTINE'] : ['10 GEM MINT'];
    case '9.5': return ['9.5 MINT+', '9.5 GEM MINT'];
    case '9':   return ['9 MINT'];
    case '8.5': return ['8.5 NM-MT+'];
    case '8':   return ['8 NM-MT'];
    case '7.5': return ['7.5 NM+'];
    case '7':   return ['7 NM'];
    case '6.5': return ['6.5 EX-NM+'];
    case '6':   return ['6 EX-NM', '6 EX-MT'];
    default:    return [];
  }
}

function cyNumber(s) {
  let m = String(s || '').match(/#\s*0*(\d+)/);            // "#48"
  if (m) return m[1];
  m = String(s || '').match(/\b0*(\d{1,4})\s*\/\s*\d+\b/); // bare "224/187"
  return m ? m[1] : null;
}
function cyFirstEd(s) { return /1st\s*ed/i.test(String(s || '')); }
function cyGrader(s) { const m = String(s || '').match(/\b(PSA|CGC|BGS|SGC)\b/i); return m ? m[1].toUpperCase() : null; }
function cyGrade(s) {
  let m = String(s || '').match(/\b(?:PSA|CGC|BGS|SGC)\s*(\d+(?:\.\d)?)/i);   // "CGC 10", "PSA 9.5"
  if (m) return parseFloat(m[1]);
  m = String(s || '').match(/\b(?:PSA|CGC|BGS|SGC)\s+\w+\s+(\d+(?:\.\d)?)/i); // "CGC Pristine 10"
  return m ? parseFloat(m[1]) : null;
}
// CGC 10 comes in two tiers â€” GEM MINT and the higher PRISTINE â€” and they price very
// differently (same card: $13.00 gem vs $28.90 pristine). Never cross-match them.
function cyPristine(s) { return /\bpristine\b/i.test(String(s || '')); }
// Poké Ball / Master Ball reverse-holo printings are DIFFERENT products from the plain
// card and carry the same set number, so without this three "#040 Glaceon CGC 10 GEM
// MINT" rows all satisfy every other check and the lookup can't tell them apart.
// Matching on these turns that ambiguity into a clean single hit.
function cyBall(s) {
  const t = String(s || '').toLowerCase();
  if (/master\s*ball/.test(t)) return 'master';
  if (/pok[eé]?\s*ball/.test(t)) return 'poke';
  return 'none';
}
// CardLadder puts the card's NAME right next to its number ("â€¦VSTAR Universe Holo
// Gengar #48", "â€¦#55/64 Mankey"). We build a short ladder of queries from the words on
// BOTH sides of the number. Keep queries SHORT: Courtyard AND-matches every term, so
// each extra word can only shrink the result set â€” narrowing is the facets' job.
function cyQueries(cardName) {
  const cleaned = String(cardName || '')
    .replace(/\b(19|20)\d{2}\b/g, ' ')
    .replace(/\b(pok[eÃ©]mon|pokemon|japanese|english)\b/gi, ' ')
    .replace(/\b(PSA|CGC|BGS|SGC)\b/gi, ' ')
    .replace(/\b(gem|mint|pristine|nm|mt)\b/gi, ' ')
    .replace(/\bunlimited\b/gi, ' ')        // CardLadder writes it; Courtyard never does
    .replace(/\b1st\s*ed(ition)?\b/gi, ' ') // cyFirstEd handles this; spelled inconsistently
    .replace(/[^A-Za-z0-9#/ ]+/g, ' ')
    .replace(/\s+/g, ' ').trim();
  const numRe = /#\s*\d+(\s*\/\s*\d+)?|\b\d{1,4}\s*\/\s*\d+\b/;
  const idx = cleaned.search(numRe);
  const before = idx >= 0 ? cleaned.slice(0, idx) : cleaned;
  const after  = idx >= 0 ? cleaned.slice(idx).replace(numRe, ' ') : '';
  const keep = w => w && !/^\d+$/.test(w) && w.length > 1;
  const bw = before.split(' ').filter(keep);
  const aw = after.split(' ').filter(keep);
  // The card's own name sits next to its number, but it can be on EITHER side:
  // "â€¦#55/64 Mankey" (after) vs "â€¦Gengar #48" (before). The old rule picked whichever
  // side had MORE words, which silently threw the name away â€” "1999 Jungle Unlimited
  // #55/64 Mankey" became the query "Jungle Unlimited", with no "Mankey" in it at all.
  // Courtyard AND-matches every term and never writes "Unlimited", so it returned zero
  // and looked like a card Courtyard doesn't stock. Query BOTH sides instead.
  // A one-word query is safe now: the grader/grade/year facets keep the result set
  // small, and the strict matcher below is what actually prevents a wrong answer.
  const singles = [bw[bw.length - 1], aw[0]].filter(w => w && w.length >= 4);
  const out = [
    bw.slice(-2).join(' '),
    aw.slice(0, 2).join(' '),
    ...singles,
  ];
  return [...new Set(out.filter(Boolean))].slice(0, 3);
}

async function courtyardValue(cardName, grader, gradeNum, gradeText) {
  const queries = cyQueries(cardName);
  const wantNum = cyNumDen(cardName);
  const wantFirst = cyFirstEd(cardName);
  const wantGrader = String(grader || '').toUpperCase();
  const wantPristine = cyPristine(gradeText || cardName);
  const wantBall = cyBall(cardName);
  if (!queries.length || !wantGrader || !(gradeNum > 0)) {
    return { value: null, reason: 'missing card identity', tried: queries };
  }
  // A card number is REQUIRED. Without one, wantNum is null and so is the number parsed
  // from titles like "#GG44/GG70", so the identity check degenerated to null === null and
  // matched anything. That is exactly how "2019 Tag All Stars Mew" got priced as a "2023
  // Crown Zenith Mewtwo VSTAR" at $802.30. No number means we cannot prove identity, so
  // we decline instead of guessing.
  if (!wantNum.n) {
    return { value: null, reason: 'no card number in the name — cannot confirm identity', tried: [] };
  }
  // Narrow server-side with the grader + grade facets, keeping the query itself broad.
  // Last attempt drops the grade facet: if our tier strings were all wrong we'd get zero
  // results, and a false miss is worse than one extra page load.
  const gradeFacets = cyGradeFacets(gradeNum, wantPristine);
  // Year is the strongest narrower AND the strongest correctness signal: across a real
  // 27-card run, every CORRECT match agreed on year while both WRONG matches were 2 and
  // 4 years off. Ask for year-1/year/year+1 in one request (Courtyard and CardLadder
  // disagree by a year on some Japanese sets) and drop the old year-less ladder — it only
  // ever surfaced candidates the year check below would now reject, and it was costing
  // three extra page loads on every miss.
  const wantYear = cyYearNum(cardName);
  const exact = wantYear ? [wantYear] : [];
  const last = queries[queries.length - 1];
  // Query the EXACT year first. Widening the facet to a ±1 window up front backfired:
  // it tripled the result set, saturated the ~48-row grid, and knocked a real match
  // (Lugia V #79) off page 1. The ±1 slack lives in the VERIFY step below instead, and
  // only becomes a query when the exact year finds nothing.
  const attempts = queries.map(q => ({ q, grades: gradeFacets, years: exact }));
  if (wantYear) attempts.push({ q: last, grades: gradeFacets, years: [wantYear - 1, wantYear + 1] });
  // Safety nets: drop the grade tier (in case our tier strings were wrong), then drop the
  // year entirely (for a card Courtyard dates further off) — both still fully verified.
  if (gradeFacets.length) attempts.push({ q: last, grades: [], years: exact });
  if (wantYear) attempts.push({ q: last, grades: gradeFacets, years: [] });

  const c = await getContext();
  const page = await c.newPage();
  const tried = [];
  try {
    await page.setViewportSize({ width: 1440, height: 2400 }).catch(() => {});
    for (const a of attempts) {
      const q = a.q;
      await page.goto(cyUrl(q, wantGrader, a.grades, a.years), { waitUntil: 'domcontentloaded', timeout: 45000 });
      // Results render client-side â€” wait for an FMV to paint, then let the grid settle.
      // DO NOT "optimize" this. Three separate attempts all silently broke accuracy:
      //   - shorter timeout      -> read a half-rendered grid, real matches became misses
      //   - waitUntil:'commit'   -> same, partial result set
      //   - bail on "Nothing found" -> that empty state flashes WHILE loading, so every
      //                                lookup returned 0 candidates
      // A miss costs ~70s here. That is the accepted price of never guessing a price.
      await page.waitForFunction(() => /FMV\s*\$/.test(document.body.innerText), { timeout: 15000 }).catch(() => {});
      await sleep(900);
      const text = await page.evaluate(() => document.body.innerText);
      const lines = text.split('\n').map(s => s.trim()).filter(Boolean);
      const items = [];
      for (let i = 0; i < lines.length; i++) {
        const m = lines[i].match(/^FMV\s*\$([\d,.]+)/i);
        if (!m) continue;
        let j = i - 1;
        while (j >= 0 && /^\$[\d,.]+$/.test(lines[j])) j--;   // skip the asking-price line
        if (j < 0) continue;
        items.push({ title: lines[j], fmv: parseFloat(m[1].replace(/,/g, '')) });
      }
      // Courtyard's grid renders ~48 rows. At that count we are looking at a TRUNCATED
      // result set, so "no match here" does not mean "not on Courtyard" — it means the
      // query was too broad to see everything. Tracked so the miss reason can say so.
      tried.push({
        q, year: (a.years && a.years.length) ? a.years.join(',') : 'any',
        grades: a.grades.join('|') || 'none',
        candidates: items.length, saturated: items.length >= 47,
      });
      const nameToken = (q.split(' ').pop() || '').toLowerCase();
      // Substring matching on the name token is NOT safe: "Mew" is a substring of
      // "Mewtwo", which is half of how a $802 Mewtwo VSTAR got returned for a Mew.
      const nameRe = nameToken
        ? new RegExp('\\b' + nameToken.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'i')
        : null;
      const hits = items.filter(it => {
        const gotNum = cyNumDen(it.title);
        const gotYear = cyYearNum(it.title);
        return (
          gotNum.n === wantNum.n &&
          // Denominators only compared when BOTH sides have one, so "#48" can still match
          // "#048/172" — but "057/091" can never match "057/198".
          (!wantNum.d || !gotNum.d || gotNum.d === wantNum.d) &&
          // Allow a year of slack for sets the two sites date differently; anything wider
          // is a different card. This is what separates every correct match from the wrong
          // ones in the real run (correct: same year; wrong: 2 and 4 years off).
          (!wantYear || !gotYear || Math.abs(gotYear - wantYear) <= 1) &&
          cyFirstEd(it.title) === wantFirst &&
          cyGrader(it.title) === wantGrader &&
          cyGrade(it.title) === gradeNum &&
          cyPristine(it.title) === wantPristine &&
          cyBall(it.title) === wantBall &&
          (!nameRe || nameRe.test(it.title))
        );
      });
      if (hits.length) {
        // Several genuinely DIFFERENT cards can satisfy every check above, because the
        // card number repeats across sets and we can't filter on set name. Real example:
        // 2016 "#11 Charizard - Holo PSA 10 GEM MINT" exists as Evolutions ($2,156),
        // XY Evolutions ($5,274) and Expansion 20th Anniversary ($514). Taking the first
        // result would be a coin flip across a 10x spread, so only answer when the
        // matches agree on a price; otherwise report nothing, same as any other miss.
        const vals = hits.map(h => h.fmv);
        const lo = Math.min(...vals), hi = Math.max(...vals);
        if (lo > 0 && (hi - lo) / lo > 0.02) {
          return {
            value: null,
            reason: 'ambiguous: ' + hits.length + ' different cards match this number/grade',
            conflicts: [...new Set(hits.map(h => h.title + ' = $' + h.fmv))].slice(0, 5),
            query: q, tried
          };
        }
        return { value: hits[0].fmv, matched: hits[0].title, query: q, tried };
      }
    }
    const truncated = tried.some(t => t.saturated);
    return {
      value: null,
      reason: truncated
        ? 'no exact match (Courtyard capped the result list, so the card may be there but unseen)'
        : 'no exact match',
      tried,
    };
  } catch (e) {
    return { value: null, reason: 'lookup error: ' + e.message, tried };
  } finally {
    await page.close().catch(() => {});
  }
}

// â”€â”€ HTTP server â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€â”€
// ---- Courtyard via the Android app (ADB) -----------------------------------
// The web scraper has to GUESS which card a CardLadder name means, and that guessing is
// where every wrong price came from (a "Mew" priced as a "Mewtwo VSTAR" at $802). The
// phone app takes a CERT, which identifies exactly one slab - right card, set, edition
// and grade, nothing to match. It also prices cards Courtyard doesn't hold in its vault:
// an Unlimited Jungle Mankey the web path can only refuse comes back as $24.80.
// Proven end-to-end 2026-07-21 on certs 6083862010 and 148646112.
const ADB_ENABLED = process.env.COURTYARD_PHONE !== '0';
// Cross-platform adb: bundled binary is adb.exe on Windows, adb on macOS/Linux. On a Mac,
// set ADB_PATH to the Android SDK's adb (e.g. ~/Library/Android/sdk/platform-tools/adb) if
// you didn't bundle one. This lets the same bot drive a real phone OR a Mac-hosted emulator.
const ADB_NAME = process.platform === 'win32' ? 'adb.exe' : 'adb';
const ADB_LOCAL = path.join(__dirname, 'android-tools', 'platform-tools', ADB_NAME);        // customer installs (bundled)
const ADB_PARENT = path.join(__dirname, '..', 'android-tools', 'platform-tools', ADB_NAME); // owner dev layout
const ADB_BIN = process.env.ADB_PATH || (fs.existsSync(ADB_LOCAL) ? ADB_LOCAL : ADB_PARENT);
const CY_PKG = 'io.courtyard.app';
let adbDevice = process.env.ADB_DEVICE || null;

function adbExec(args, timeoutMs = 30000) {
  return new Promise((resolve) => {
    execFile(ADB_BIN, args, { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
      resolve({ ok: !err, out: String(stdout || ''), err: String(stderr || (err && err.message) || '') });
    });
  });
}
async function adbShell(args, timeoutMs = 30000) {
  return adbExec(['-s', adbDevice, 'shell', ...args], timeoutMs);
}
// Binary variant — stdout as a Buffer, for the raw screencap framebuffer.
function adbExecRaw(args, timeoutMs = 30000) {
  return new Promise((resolve) => {
    execFile(ADB_BIN, args, { timeout: timeoutMs, maxBuffer: 96 * 1024 * 1024, encoding: 'buffer' },
      (err, stdout) => resolve(err ? null : stdout));
  });
}

// Courtyard shows a confidence rating as 1–3 green dots next to each result. The row is a
// single Compose element whose accessibility text is just "…CONFIDENCE" (no level), so the
// dots aren't readable from the tree — we read them from the raw framebuffer instead and
// count green blobs inside the matched row. Verified: full-confidence rows read 3.
// Which display to screenshot for the confidence dots. Multi-display foldables need a
// specific id (the Honor Magic V5's cover screen is 4630946324137792660) — set it in .env.
// A normal phone or a Mac-hosted emulator has ONE display, so leave it empty → default
// screencap. (The owner's Windows bot sets ADB_DISPLAY_ID in its .env to keep the foldable
// working; the Mac emulator sets nothing.)
const CY_DISPLAY = process.env.ADB_DISPLAY_ID || '';
async function readConfidence(desc, xml) {
  try {
    const re = new RegExp('<node\\b[^>]*content-desc="' +
      desc.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
      '"[^>]*bounds="\\[(\\d+),(\\d+)\\]\\[(\\d+),(\\d+)\\]"');
    const m = xml.match(re);
    if (!m) return null;
    const r = { x1: +m[1], y1: +m[2], x2: +m[3], y2: +m[4] };
    const capArgs = ['-s', adbDevice, 'exec-out', 'screencap'];
    if (CY_DISPLAY) capArgs.push('-d', CY_DISPLAY);   // only target a display on multi-display devices
    const raw = await adbExecRaw(capArgs);
    if (!raw || raw.length < 16) return null;
    const w = raw.readUInt32LE(0), h = raw.readUInt32LE(4);
    if (!(w > 0) || r.x2 > w) return null;   // wrong display (e.g. phone unfolded) — skip, don't guess
    const hdr = (raw.length === 16 + w * h * 4) ? 16 : 12;   // some builds add a colorspace word
    const isGreen = (x, y) => {
      const o = hdr + (y * w + x) * 4;
      const R = raw[o], G = raw[o + 1], B = raw[o + 2];
      return G > 120 && G - R > 45 && G - B > 45;
    };
    const x0 = Math.max(0, r.x2 - 240);
    // densest green row = the dot centerline
    let bestY = r.y1, best = 0;
    for (let y = r.y1; y < r.y2 && y < h; y++) {
      let cnt = 0; for (let x = x0; x < r.x2; x++) if (isGreen(x, y)) cnt++;
      if (cnt > best) { best = cnt; bestY = y; }
    }
    if (best === 0) return 0;   // no green dots at all
    // count green runs across the centerline band (dots nearly touch, so gap>=4 separates)
    let runs = 0, gap = 99;
    for (let x = x0; x < r.x2; x++) {
      let g = false;
      for (let y = bestY - 3; y <= bestY + 3; y++) if (y >= 0 && y < h && isGreen(x, y)) { g = true; break; }
      if (g) { if (gap >= 4) runs++; gap = 0; } else gap++;
    }
    return Math.min(runs, 3);
  } catch { return null; }
}

// Wireless debugging hands out a NEW port on every reboot, so never trust a stored
// address - rediscover over mDNS and reconnect.
async function adbDeviceReady() {
  const connected = async (addr) => (await adbExec(['devices'])).out.split('\n')
    .some(l => l.trim().startsWith(addr) && /\bdevice\b/.test(l));
  if (adbDevice && await connected(adbDevice)) return adbDevice;
  const m = await adbExec(['mdns', 'services'], 20000);
  const line = m.out.split('\n').find(l => /_adb-tls-connect/.test(l));
  const addr = line && (line.match(/(\d+\.\d+\.\d+\.\d+:\d+)/) || [])[1];
  if (addr) {
    await adbExec(['connect', addr], 20000);
    if (await connected(addr)) { adbDevice = addr; return adbDevice; }
  }
  const list = (await adbExec(['devices'])).out.split('\n').slice(1)
    .map(l => l.trim()).filter(l => /\tdevice$/.test(l)).map(l => l.split('\t')[0]);
  if (list.length) { adbDevice = list[0]; return adbDevice; }
  return null;
}

// Is a phone currently connected over ADB? Returns {address, model} or null. The DTEN
// conference board on the LAN also speaks ADB on :5555 — ignore it (phones use a random
// wireless-debug port, never :5555).
async function phoneConnected() {
  const out = (await adbExec(['devices'])).out;
  const addrs = out.split('\n').map(l => l.trim())
    .filter(l => /\tdevice$/.test(l)).map(l => l.split('\t')[0])
    .filter(a => !a.endsWith(':5555'));
  const addr = addrs.includes(adbDevice) ? adbDevice : addrs[0];
  if (!addr) return null;
  const model = (await adbExec(['-s', addr, 'shell', 'getprop', 'ro.product.model'])).out.trim();
  return { address: addr, model: model || 'phone' };
}

let dumpSeq = 0;
// Fresh filename every dump: a FAILED uiautomator dump leaves the PREVIOUS file in place,
// and parsing that stale XML looks like a perfectly healthy screen. That cost hours.
async function uiDump() {
  for (let i = 0; i < 4; i++) {
    const f = `/sdcard/cy_${process.pid}_${++dumpSeq}.xml`;
    const r = await adbShell(['uiautomator', 'dump', f], 30000);
    if (/dumped to/.test(r.out + r.err)) {
      const c = await adbShell(['cat', f], 30000);
      if (c.out.includes('<hierarchy')) return c.out;
    }
    await sleep(1500);
  }
  return null;
}
function uiNodes(xml) {
  return [...String(xml || '').matchAll(/<node\b[^>]*>/g)].map(m => {
    const n = m[0];
    const b = n.match(/bounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
    return {
      text: (n.match(/\stext="([^"]*)"/) || [, ''])[1],
      desc: (n.match(/content-desc="([^"]*)"/) || [, ''])[1],
      cx: b ? Math.round((+b[1] + +b[3]) / 2) : null,
      cy: b ? Math.round((+b[2] + +b[4]) / 2) : null,
    };
  });
}
// Match on content-desc, never on visible text or fixed coordinates: the labels are
// non-clickable children and only the PARENT carries the accessibility id, so this also
// survives the app nudging its layout around.
function findByDesc(xml, desc) {
  return uiNodes(xml).find(n => n.desc === desc && n.cx != null) || null;
}
function findByText(xml, text) {
  return uiNodes(xml).find(n => n.text === text && n.cx != null) || null;
}
async function tapByDesc(desc, retries = 3) {
  for (let i = 0; i < retries; i++) {
    const xml = await uiDump();
    const n = xml && findByDesc(xml, desc);
    if (n) { await adbShell(['input', 'tap', String(n.cx), String(n.cy)]); return true; }
    await sleep(1200);
  }
  return false;
}
async function cyForeground() {
  const r = await adbShell(['dumpsys', 'window'], 30000);
  return new RegExp('mCurrentFocus=Window\\{[^}]*' + CY_PKG.replace(/\./g, '\\.')).test(r.out);
}
// ALWAYS confirm the app is in front before tapping. Taps land on whatever is on screen -
// during the spike they went into the owner's Messages app because Courtyard had been
// backgrounded, and the stale dumps made it all look fine.
async function cyEnsureApp() {
  // Wake the screen first. A dark/dimmed screen swallows taps, which showed up as random
  // "sheet did not open" failures between lookups. (Also set stay_on_while_plugged_in=7 on
  // the device so it never sleeps while charging — this is the belt to that suspenders.)
  await adbShell(['input', 'keyevent', 'KEYCODE_WAKEUP']);
  if (await cyForeground()) return true;
  await adbShell(['monkey', '-p', CY_PKG, '-c', 'android.intent.category.LAUNCHER', '1'], 30000);
  for (let i = 0; i < 10; i++) { await sleep(1500); if (await cyForeground()) return true; }
  return false;
}

// A result row's content-desc. Two formats supported:
//   old: "Mankey #55/64, Jungle - Unlimited, CGC 10, $24.80, CONFIDENCE"
//   new (Sourcer/premium app): "Bulbasaur #133, Meg EN-Mega Evolution, PSA 8, NOT BUYING, $25.30, CONFIDENCE, 5"
// The new app inserts a BUYING STATUS before the value and appends a numeric CONFIDENCE. We
// capture: card, set, grader, grade, (optional) status, value, (optional) confidence number.
const CY_ROW_RE = /^(.+?),\s*(.+?),\s*(PSA|CGC|BGS|SGC)\s*([\d.]+),\s*(?:([^,$]*?),\s*)?\$([\d,]+(?:\.\d+)?)(?:,\s*CONFIDENCE,\s*(\d+))?/i;
function cyRows(xml) {
  return uiNodes(xml).map(n => n.desc).filter(d => CY_ROW_RE.test(d || ''));
}
// Count rather than de-duplicate. "Recent Scans" is a running list, and re-pricing a card
// that's already in it produces an IDENTICAL string - a set-difference sees nothing new
// and the lookup times out, which is exactly what happened on the first real run.
function cyRowCounts(xml) {
  const m = new Map();
  for (const d of cyRows(xml)) m.set(d, (m.get(d) || 0) + 1);
  return m;
}

// Cached Manual-Entry control positions. The sheet layout is fixed on a given device, so
// we locate the grader tabs / cert field / Clear / SEARCH ONCE and tap by coordinate on
// every later lookup — no per-tap screen scan. A failed cert entry drops the cache so the
// next lookup re-locates (handles the app updating or a display change).
let cyCtl = null;
function locateControls(xml) {
  const at = (desc) => { const n = findByDesc(xml, desc); return n ? [n.cx, n.cy] : null; };
  const field = at('Certificate number'), search = at('SEARCH');
  if (!field || !search) return null;   // sheet not actually open
  return { PSA: at('PSA'), CGC: at('CGC'), BGS: at('BGS'), field, search, clear: at('Clear input') };
}

// Stop switch: the app sets this (via /courtyard/stop) when the user wants their phone
// back. courtyardByCert checks it at every wait point and bails, leaving the app on a
// clean screen. Reset at the start of each intentional lookup so a stale stop can't kill
// the next one.
let cyAbort = false;

// One phone, one lookup at a time. phoneBusy counts queued+running lookups so the connection
// watchdog can stand aside while the phone is actively being driven.
let phoneChain = Promise.resolve();
let phoneBusy = 0;
function withPhone(fn) {
  phoneBusy++;
  const p = phoneChain.then(fn, fn);
  phoneChain = p.then(() => {}, () => {});
  p.then(() => { phoneBusy--; }, () => { phoneBusy--; });
  return p;
}
// Keep the wireless-ADB link alive & auto-reconnect. Wireless debugging drops when the phone
// dozes or its port rotates, which stalls a run. Every ~90s (only when NO lookup is in flight):
// if connected, nudge the phone awake + re-assert stay-awake-while-charging; if dropped,
// rediscover + reconnect over mDNS — so an overnight run self-heals instead of hanging. Best-effort.
let phoneWatchdog = null;
function startPhoneWatchdog() {
  if (phoneWatchdog || !ADB_ENABLED) return;
  phoneWatchdog = setInterval(async () => {
    if (phoneBusy > 0 || !adbDevice) return;   // a lookup keeps it warm; nothing to do if never connected
    try {
      const chk = await adbExec(['-s', adbDevice, 'get-state'], 8000);
      const alive = /device/.test(chk.out) && !/error|offline|not found|no devices/i.test((chk.out || '') + (chk.err || ''));
      if (alive) {
        await adbShell(['input', 'keyevent', 'KEYCODE_WAKEUP'], 8000).catch(() => {});
        await adbExec(['-s', adbDevice, 'shell', 'settings', 'put', 'global', 'stay_on_while_plugged_in', '7'], 8000).catch(() => {});
      } else {
        console.log('[phone] link dropped — reconnecting…');
        await adbDeviceReady();
      }
    } catch (e) { try { await adbDeviceReady(); } catch (e2) {} }
  }, 90 * 1000);
}

async function courtyardByCert(cert, graderIn, gradeHint) {
  cyAbort = false;   // fresh intentional lookup — clear any leftover stop
  // Bail helper: when the user hits Stop, close the entry sheet if it's open so the phone
  // is left on a clean screen, then return an "aborted" result the app knows to skip.
  const bail = async () => {
    try { await tapByDesc('Scan', 1); } catch (e) {}
    return { value: null, reason: 'stopped', aborted: true };
  };
  const c = String(cert || '').replace(/\D/g, '');
  if (!c) return { value: null, reason: 'no cert' };
  const grader = String(graderIn || graderForCert(c)).toUpperCase();
  if (!/^(PSA|CGC|BGS)$/.test(grader)) return { value: null, reason: `app has no ${grader} tab` };
  if (!await adbDeviceReady()) return { value: null, reason: 'phone not reachable over adb' };
  if (!await cyEnsureApp()) return { value: null, reason: 'could not foreground the Courtyard app' };

  // Start from a KNOWN state. A run that fails mid-search leaves the Manual Entry dialog
  // open, and then "Manual Entry" isn't on screen at all - the previous code just gave up
  // with "Manual Entry not found" and every later lookup failed the same way until someone
  // closed it by hand. Back out of it first.
  // Manual Entry is a bottom SHEET that only closes on a successful search, so a failed
  // run leaves it up and every later lookup dies with "Manual Entry not found". BACK,
  // scrim taps and swipe-down all fail to dismiss it - the top-right "Scan" toggle is
  // the control that actually flips back out of manual-entry mode.
  for (let i = 0; i < 2; i++) {
    const scr = await uiDump();
    if (!scr || !findByDesc(scr, 'Certificate number')) break;
    if (!await tapByDesc('Scan', 1)) await adbShell(['input', 'keyevent', '4']);
    await sleep(1500);
  }
  await tapByDesc('Scanner');
  await sleep(1200);

  // Snapshot the existing rows BEFORE opening the dialog. The app keeps a running
  // "Recent Scans" list, so reading the top row blind would return the PREVIOUS card's
  // price - and snapshotting while the dialog covers the list is just as bad, because
  // every old row would then look new once it closes.
  const beforeDump = await uiDump();
  const before = cyRowCounts(beforeDump);
  const topDesc = (xml) => {
    const rows = uiNodes(xml).filter(n => n.desc && CY_ROW_RE.test(n.desc) && n.cy != null).sort((a, b) => a.cy - b.cy);
    return rows[0] ? rows[0].desc : null;   // topmost Recent-Scans row
  };
  const beforeTop = topDesc(beforeDump);

  // Open the sheet, RETRYING the tap until the cert field appears. The "Manual Entry"
  // content-desc sits on a wide container whose center lands on dead space, so a single
  // tap there silently misses — tap the TEXT LABEL's position instead, and re-tap each
  // round in case a tap is dropped. The dump that finally sees the field is reused as the
  // control-locate below, so this loop costs no extra dumps once it's open.
  let openXml = null;
  for (let i = 0; i < 8; i++) {
    if (cyAbort) return bail();
    const xml = await uiDump();
    if (xml && findByDesc(xml, 'Certificate number')) { openXml = xml; break; }
    const n = xml && (findByText(xml, 'Manual Entry') || findByDesc(xml, 'Manual Entry'));
    if (n) await adbShell(['input', 'tap', String(n.cx), String(n.cy)]);
    await sleep(700);
  }
  if (!openXml) return { value: null, reason: 'Manual Entry sheet did not open' };
  if (cyAbort) return bail();

  // Locate every control from ONE dump instead of scanning the screen before each tap.
  // The Manual Entry sheet has a fixed layout, so the old approach — a separate dump to
  // find the grader tab, another for Clear, another for the field, another for SEARCH,
  // each with its own sleep — spent ~10s scanning before it even typed. We grab all the
  // coordinates once, cache them across lookups, and only "take a pic" once afterward to
  // confirm the cert actually landed (per the request). If it didn't, we re-locate + retry.
  const tap = (xy) => adbShell(['input', 'tap', String(xy[0]), String(xy[1])]);
  const doEntry = async (ctl) => {
    if (ctl[grader]) await tap(ctl[grader]);        // grader tab is mandatory
    await sleep(300);
    if (ctl.clear) { await tap(ctl.clear); await sleep(250); }
    await tap(ctl.field);
    await sleep(300);
    await adbShell(['input', '-d', '0', 'text', c]); // -d 0 or the text lands nowhere
    await sleep(450);
  };
  const certLanded = (xml) => {
    const f = xml && findByDesc(xml, 'Certificate number');
    return !!(f && String(f.text || '').replace(/\D/g, '') === c);
  };

  if (!cyCtl) {
    cyCtl = locateControls(openXml);   // reuse the dump that confirmed the sheet is open
    if (!cyCtl) return { value: null, reason: 'cert field not found' };
  }
  await doEntry(cyCtl);

  // The one verification "pic". If the cert isn't in the box, the cached coordinates are
  // stale (layout shifted, or the sheet was slow) — re-locate from a fresh dump and retry.
  let chk = await uiDump();
  if (!certLanded(chk)) {
    const relocated = locateControls(chk);
    if (relocated) cyCtl = relocated;
    await doEntry(cyCtl);
    chk = await uiDump();
    if (!certLanded(chk)) { cyCtl = null; return { value: null, reason: 'cert did not enter the box' }; }
  }
  await tap(cyCtl.search);

  // The search is done when the Manual Entry sheet closes and the priced row drops into
  // Recent Scans. Courtyard ALWAYS returns a value for a real cert (unlike the old browser
  // search), so a sheet that won't close or a row that won't render is a SLOW search — a
  // retryable timeout, never a genuine "no value". Some slabs (no stock image) fetch their
  // art and render a beat late, so we wait generously, then let the caller retry.
  const parse = (d) => {
    const m = d.match(CY_ROW_RE);
    if (!m) return null;
    // m[5] = buy STATUS ("NOT BUYING" / "BUYING …") — present only on the Sourcer/premium app.
    // m[6] = FMV. m[7] = numeric confidence the premium app prints in-row (old app has none).
    const status = (m[5] || '').trim();
    const descConf = m[7] != null ? parseInt(m[7], 10) : null;
    return {
      value: parseFloat(m[6].replace(/,/g, '')),
      card: m[1].trim(), set: m[2].trim(),
      grader: m[3].toUpperCase(), grade: m[4],
      buyStatus: status || null,
      buying: status ? !/not\s*buying/i.test(status) : null,
      descConf,
      matched: d, source: 'phone',
    };
  };
  // Accept the result row only once it's demonstrably OURS: a brand-new Recent-Scans entry,
  // or the top row changed from what it was before the search (the scanned card jumps to the
  // top). Require the grader to match so we never hand back a stale, wrong card.
  const pick = (xml) => {
    const after = cyRowCounts(xml);
    const fresh = [...after.keys()].filter(d => (after.get(d) || 0) > (before.get(d) || 0));
    const freshHit = fresh.map(parse).find(r => r && r.grader === grader);
    if (freshHit) return freshHit;
    const td = topDesc(xml);
    if (td && td !== beforeTop) { const p = parse(td); if (p && p.grader === grader) return p; }
    return null;
  };

  // A found card CLOSES the Manual Entry sheet and drops its row into Recent Scans. A cert
  // Courtyard can't find leaves the sheet OPEN and flashes a "card not found" toast (which
  // isn't in the accessibility tree, so we can't read it — the open sheet IS the signal).
  // We re-tap SEARCH a couple of times first so a dropped tap doesn't masquerade as
  // not-found, but once the sheet has stayed open through that, it's a real miss — give up,
  // don't hammer it. ~15 iters (~30s) is well past any valid search's close time (~10-15s).
  let closed = false, last = null;
  for (let i = 0; i < 15 && !closed; i++) {
    if (cyAbort) return bail();
    await sleep(800);
    last = await uiDump();
    if (last && !findByDesc(last, 'Certificate number')) { closed = true; break; }
    if ((i === 3 || i === 8) && cyCtl && cyCtl.search && certLanded(last)) {
      await tap(cyCtl.search);   // dropped SEARCH tap → re-fire it
    }
  }
  if (!closed) {
    await tapByDesc('Scan', 1);   // force the sheet shut so we can read Recent Scans
    await sleep(1000);
    last = await uiDump();
  }

  // Poll briefly for the row to resolve — slabs with no stock image fetch their art and
  // render a beat late, which is exactly the case that used to read as a false miss.
  let hit = pick(last);
  for (let i = 0; i < 8 && !hit; i++) {
    if (cyAbort) return bail();
    await sleep(700);
    last = await uiDump();
    hit = pick(last);
  }
  if (hit) {
    // Two Courtyard apps, two confidence scales: the premium/Sourcer app prints a 1–5 number in
    // the row; the normal app shows 1–3 green dots (read off the framebuffer). Report the value
    // AND which scale it's on (confMax) so the app draws 1–3 vs 1–5 dots to match the account.
    if (hit.descConf != null) { hit.confidence = hit.descConf; hit.confMax = 5; }
    else { hit.confidence = await readConfidence(hit.matched, last); hit.confMax = 3; }
    return hit;
  }
  if (!closed) {
    // A phone that DROPPED mid-lookup returns empty dumps, which reads exactly like "the sheet
    // never closed" — and would then get mis-cached as a definitive not-found, so re-running just
    // serves back the bad no-value. So before calling it not-found, confirm the phone is still
    // reachable. If it isn't, this is an infra failure → RETRYABLE timeout (never cached).
    let connected = false;
    try { connected = /device/.test((await adbExec(['-s', adbDevice, 'get-state'], 5000)).out || ''); } catch (e) {}
    if (!connected) return { value: null, reason: 'phone dropped mid-lookup — retry', timeout: true };
    // Sheet genuinely stayed open through the re-taps and no matching card is in Recent Scans →
    // Courtyard really can't find this cert (the "card not found" toast case). Definitive, cached.
    return { value: null, reason: 'card not found on Courtyard', notFound: true };
  }
  // Sheet closed but we couldn't read the row yet — that's transient, so allow a retry.
  return { value: null, reason: 'result did not render — retry', timeout: true };
}

function startServer() {
  if (!productToken && !currentKey) {
    console.log('Pricing connects automatically when you log into the app â€” nothing to set up here.');
  }
  const app = express();
  // Answer Chrome's Private Network Access preflight so a public HTTPS page
  // (the Netlify site) is allowed to reach this local helper. Must be set on
  // ALL responses, including the CORS preflight OPTIONS, so it goes before cors().
  app.use((req, res, next) => {
    res.header('Access-Control-Allow-Private-Network', 'true');
    res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
    res.header('Access-Control-Allow-Headers', 'Content-Type, x-api-key');
    res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
    if (req.method === 'OPTIONS') return res.sendStatus(204);
    next();
  });
  app.use(cors());
  app.use(express.json({ limit: '2mb' }));
  // A bot installed weeks ago froze its bundled copy of the app â€” which is how a
  // customer can end up on a stale Setup screen (e.g. the retired "paste your own
  // key" flow). Always send the root to the LIVE app so the UI is current; the live
  // page still talks back to THIS local bot for pricing.
  app.get(['/', '/index.html'], (req, res) => res.redirect(302, 'https://icevyytcg.com/'));
  // Any other bundled files still serve locally (HTML never cached, so a reinstall's
  // fresh copy can't be masked by a stale page).
  app.use(express.static(path.join(__dirname, 'public'), {
    setHeaders: (res, p) => { if (p.endsWith('.html')) res.setHeader('Cache-Control', 'no-store'); },
  }));

  function requireKey(req, res, next) {
    if (!API_KEY) return next(); // auth disabled when no HELPER_API_KEY set
    // Skip auth for same-machine pages (file:// loads send "null" origin,
    // localhost-served pages send http://localhost:PORT). Tunnel traffic from
    // the hosted Vercel site has a real https://... origin â†’ still needs key.
    const origin = req.get('origin') || '';
    if (!origin || origin === 'null' || origin === `http://localhost:${PORT}` || origin === `http://127.0.0.1:${PORT}`) {
      return next();
    }
    const k = req.query.key || req.headers['x-api-key'];
    if (k === API_KEY) return next();
    return res.status(401).json({ error: 'invalid or missing API key' });
  }

  app.get('/health', async (req, res) => {
    let cdpOk = false;
    try {
      const r = await fetch(`${CDP_URL}/json/version`, { signal: AbortSignal.timeout(2000) });
      cdpOk = r.ok;
    } catch {}
    res.json({
      ok: cdpOk,
      version: BOT_VERSION,
      cdpUrl: CDP_URL,
      browserAttached: cdpOk,
      keySet: !!anthropic,
      concurrency: CONCURRENCY,
      url_template: URL_TEMPLATE,
      cacheEntries: cacheCount(),
      cacheTtlHours: Math.round(CACHE_TTL_MS / 3600000),
    });
  });

  // Setup tab: read/write the Anthropic key. The key is stored locally (config.json)
  // and never returned in full â€” only a masked preview + whether it's set.
  app.get('/config', requireKey, (req, res) => {
    res.json({ ...keyStatus(), cardladder: false, model: MODEL });
  });
  app.post('/config', requireKey, async (req, res) => {
    // Product token (preferred): routes pricing through the owner's proxy.
    if (req.body && req.body.productToken !== undefined) {
      const t = (req.body.productToken || '').trim();
      if (!t) { setProductToken(''); return res.json({ ok: true, ...keyStatus() }); }
      try {
        // Probe the proxy: a valid token with no image returns 400 ("no image");
        // a bad token returns 401/403. So anything other than 401/403 = token OK.
        const pr = await fetch(PROXY_URL, {
          method: 'POST',
          headers: { 'content-type': 'application/json', authorization: 'Bearer ' + t },
          body: JSON.stringify({ probe: true }),
        });
        if (pr.status === 401 || pr.status === 403) {
          return res.status(400).json({ ok: false, error: 'That token was rejected â€” double-check it or ask for a new one.' });
        }
      } catch (e) {
        return res.status(400).json({ ok: false, error: 'Could not reach the pricing service to verify the token.' });
      }
      setProductToken(t);
      return res.json({ ok: true, ...keyStatus() });
    }
    // Own-key entry is RETIRED: pricing always routes through the product token/proxy
    // so usage is metered and billed to the owner â€” never a customer's own Anthropic
    // account. Old app builds may still POST an anthropicKey; accept the call but IGNORE
    // the key and report the real (token-based) status instead of switching to direct mode.
    if (req.body && 'anthropicKey' in req.body) {
      return res.json({ ok: true, ...keyStatus(), note: 'Pricing connects automatically with your subscription â€” no key needed.' });
    }
    return res.status(400).json({ ok: false, error: 'Nothing to update.' });
  });

  // Stop the in-flight Courtyard phone lookup so the user can use their phone. The current
  // lookup bails at its next checkpoint (within a second or two) and leaves a clean screen.
  app.get('/courtyard/stop', requireKey, (req, res) => { cyAbort = true; res.json({ ok: true }); });

  // --- Phone (ADB) connection control, so the owner can (re)connect from the app ---
  app.get('/phone/status', requireKey, async (req, res) => {
    try { const p = await phoneConnected(); res.json({ connected: !!p, device: p ? p.model : null, address: p ? p.address : null }); }
    catch (e) { res.json({ connected: false, error: e.message }); }
  });
  // Reconnect an ALREADY-PAIRED phone: rediscover its (changed) port over mDNS and connect.
  // This is the common case after the phone sleeps or briefly drops Wi-Fi.
  app.post('/phone/connect', requireKey, async (req, res) => {
    try {
      const d = await adbDeviceReady();
      const p = d ? await phoneConnected() : null;
      res.json({ ok: !!p, connected: !!p, device: p ? p.model : null, address: p ? p.address : null,
        reason: p ? null : 'no phone found — make sure Wireless debugging is ON and the phone is on this Wi-Fi' });
    } catch (e) { res.json({ ok: false, connected: false, reason: e.message }); }
  });
  // First-time / after-leaving-the-network PAIR: needs the 6-digit code + host:port from the
  // phone's "Pair device with pairing code" screen. Then connect on the (different) live port.
  app.post('/phone/pair', requireKey, async (req, res) => {
    try {
      const host = String((req.body && req.body.host) || '').trim();
      const port = String((req.body && req.body.port) || '').trim();
      const code = String((req.body && req.body.code) || '').replace(/\D/g, '');
      if (!host || !port || !code) return res.json({ ok: false, reason: 'need host, port and the 6-digit code' });
      await adbExec(['kill-server']); await sleep(1200);
      await adbExec(['start-server']); await sleep(1500);
      const pr = await adbExec(['pair', `${host}:${port}`, code], 30000);
      if (!/Successfully paired/i.test(pr.out + pr.err)) {
        return res.json({ ok: false, reason: (pr.out || pr.err || 'pairing failed — code may have expired; reopen the pair screen').trim().slice(0, 200) });
      }
      const d = await adbDeviceReady();
      const p = d ? await phoneConnected() : null;
      res.json({ ok: !!p, connected: !!p, device: p ? p.model : null, address: p ? p.address : null,
        reason: p ? null : 'paired, but could not connect — try Reconnect' });
    } catch (e) { res.json({ ok: false, reason: e.message }); }
  });

  // Courtyard market value for a card identity (name comes from the CardLadder read).
  app.get('/courtyard', requireKey, async (req, res) => {
    try {
      keepAwake();   // long phone run — don't let the PC sleep mid-batch
      // Cache by full card identity. A real 38-lookup run contained only 26 distinct
      // cards — re-running a batch, or the same card across two lots, was paying the
      // full browser round-trip every time. Refusals are cached too: they're the
      // expensive case (up to 6 page loads) and the 24h TTL lets restocks through.
      const cert = String(req.query.cert || '').replace(/\D/g, '');
      // Key phone lookups by CERT (+grader) only — the phone uses just the cert, so the
      // card name doesn't affect the result. This lets the pure-Courtyard tab and the
      // CY/CL tab share one cached answer for the same slab instead of re-scanning it.
      const grader = String(req.query.grader || graderForCert(cert)).toUpperCase();
      const ck = cert
        ? 'cyc:' + cert + '|' + grader
        : 'cy:' + [req.query.name, req.query.grader, req.query.grade, req.query.gradeText].join('|');
      // fresh=1 (the ↻ re-check button) bypasses the cache and re-scans on the phone.
      const fresh = req.query.fresh === '1';
      const hit = fresh ? null : cacheData[ck];
      if (hit && Date.now() - hit.ts < CY_CACHE_TTL_MS) {   // Courtyard caches ~30 days (see CY_CACHE_TTL_MS)
        return res.json({ ...hit.result, cached: true });
      }
      // Courtyard is PHONE-ONLY now. A cert is one exact slab, so the phone app gives the
      // authoritative value with zero card-matching. The old web scraper is retired: it's
      // the same Courtyard data reached by guessing a card from a name, which is where every
      // wrong price came from — if the phone (Courtyard's own app) has no value, the web
      // wouldn't either. No cert / phone disabled = no Courtyard value, cleanly.
      let r = null;
      if (ADB_ENABLED && cert) {
        const gh = (String(req.query.grade || '').match(/[\d.]+/) || [''])[0];
        r = await withPhone(() => courtyardByCert(cert, req.query.grader, gh));
      } else {
        r = { value: null, reason: cert ? 'phone pricing disabled' : 'no cert on this row', source: 'phone' };
      }
      // Cache real answers: a hit (value) OR a definitive "card not found" — both are stable
      // for a given cert+grader, so caching them stops a re-run from re-scanning. A transient
      // timeout is NOT cached (it'd pin a good card to a false blank for 24h).
      if (r.value != null || r.notFound) {
        cacheData[ck] = { result: r, ts: Date.now() };
        cacheDirty = true;
      }
      // Append every lookup to a JSONL log. A big batch run is the only way to tell a
      // correct refusal (card genuinely not in the vault, or wrong edition/variant) from
      // a fixable miss (result list truncated), and the app only shows a flat "no CY".
      try {
        fs.appendFileSync(path.join(__dirname, 'courtyard-log.jsonl'), JSON.stringify({
          ts: new Date().toISOString(),
          cert: cert || null, source: r.source || null, phoneReason: r.phoneReason || null,
          name: req.query.name, grader: req.query.grader,
          grade: req.query.grade, gradeText: req.query.gradeText,
          value: r.value, confidence: (r.confidence != null ? r.confidence : null),
          reason: r.reason || null, matched: r.matched || null,
          query: r.query || null, conflicts: r.conflicts || null,
          saturated: (r.tried || []).some(t => t.saturated),
          tried: (r.tried || []).map(t => `${t.q}/y${t.year}/${t.grades}=${t.candidates}`),
        }) + '\n');
      } catch (e) { /* logging must never break a lookup */ }
      res.json(r);
    } catch (e) {
      res.status(500).json({ value: null, reason: e.message });
    }
  });

  app.get('/comp/stream', requireKey, async (req, res) => {
    const certs = parseCerts(req.query.certs);
    if (!certs.length) { res.status(400).end('no valid certs'); return; }
    keepAwake();   // a big lot can take a while — keep the PC awake through the run
    // Slow mode (big lots): 1-at-a-time + long gaps to avoid CardLadder's per-account cap.
    const slow = req.query.slow === '1';
    const conc = slow ? 1 : CONCURRENCY;

    res.setHeader('Content-Type', 'text/event-stream');
    res.setHeader('Cache-Control', 'no-cache');
    res.setHeader('Connection', 'keep-alive');
    res.flushHeaders();

    const total = certs.length;
    let done = 0;
    res.write(`event: start\ndata: ${JSON.stringify({ total })}\n\n`);

    await runWithLimit(certs, conc, (c) => lookupCert(c, { slow }), (r) => {
      done++;
      res.write(`event: result\ndata: ${JSON.stringify({ ...r, done, total })}\n\n`);
    });

    res.write('event: done\ndata: {}\n\n');
    res.end();
  });

  app.post('/comp/batch', requireKey, async (req, res) => {
    const certs = parseCerts(req.body.certs);
    if (!certs.length) return res.status(400).json({ error: 'no valid certs' });
    const results = [];
    await runWithLimit(certs, CONCURRENCY, lookupCert, r => results.push(r));
    res.json({ results });
  });

  app.delete('/cache/:cert', requireKey, (req, res) => {
    res.json({ deleted: cacheDelete(req.params.cert) });
  });
  app.delete('/cache', requireKey, (req, res) => {
    res.json({ deleted: cacheClear() });
  });

  // Launch (or re-launch) Brave from the app â€” handy if you closed the Brave window.
  app.post('/launch-brave', requireKey, async (req, res) => {
    try {
      const r = await ensureBrave();
      if (r.launched) { ctx = null; cdpBrowser = null; } // force a fresh attach next lookup
      res.json({ ok: true, ...r });
    } catch (e) {
      res.status(500).json({ ok: false, error: e.message });
    }
  });

  // Free a TCP port by killing whatever is listening on it (cross-platform).
  function freePort(port) {
    return new Promise((resolve) => {
      try {
        const { exec } = require('child_process');
        const cmd = process.platform === 'win32'
          ? `powershell -NoProfile -Command "Get-NetTCPConnection -LocalPort ${port} -State Listen -ErrorAction SilentlyContinue | ForEach-Object { Stop-Process -Id $_.OwningProcess -Force -ErrorAction SilentlyContinue }"`
          : `lsof -ti tcp:${port} | xargs kill -9`;
        exec(cmd, () => resolve());
      } catch (e) { resolve(); }
    });
  }
  function onListening() {
    process.title = 'Icevyy TCG Bot - keep this window open';
    console.log('\n>>> Icevyy TCG Bot is running. Keep this window open while you price cards. <<<\n');
    // Open the app in the default browser once, shortly after the server binds.
    setTimeout(() => {
      const url = 'https://icevyytcg.com/';
      try {
        const { exec } = require('child_process');
        if (process.platform === 'win32') exec('start "" "' + url + '"');
        else if (process.platform === 'darwin') exec('open "' + url + '"');
        else exec('xdg-open "' + url + '"');
      } catch (e) {}
    }, 3500);
    console.log(`PSA comp helper â†’ http://localhost:${PORT}`);
    console.log(`  website:       http://localhost:${PORT}/`);
    console.log(`  health:        /health`);
    console.log(`  stream:        /comp/stream?certs=1,2,3`);
    console.log(`  batch:         POST /comp/batch  { certs: [...] }`);
    console.log(`  flush a cert:  DELETE /cache/:cert`);
    console.log(`  flush all:     DELETE /cache`);
    console.log(`  concurrency:   ${CONCURRENCY}`);
    console.log(`  cache TTL:     ${Math.round(CACHE_TTL_MS / 3600000)}h`);
    console.log(`  cdp:           ${CDP_URL}`);
    // Warm up Brave immediately so it's ready before the first comp.
    ensureBrave()
      .then(r => console.log(r.alreadyUp ? '[browser] Brave already running.' : '[browser] Brave launched automatically.'))
      .catch(e => console.warn('[browser] could not auto-launch Brave:', e.message));
  }
  // Bind the port. The daily launcher just runs `node helper.js`, so if an old bot is
  // still holding 5050 the process would otherwise crash with EADDRINUSE and the window
  // would flash shut ("the bot doesn't open"). Instead, free the port and take over,
  // retrying once; if it still can't bind, another bot is fine to keep â€” just exit.
  function startListening(isRetry) {
    const server = app.listen(PORT, onListening);
    server.on('error', (e) => {
      if (e && e.code === 'EADDRINUSE' && !isRetry) {
        console.log(`\nPort ${PORT} is busy â€” an old bot is still running. Closing it and taking over...`);
        freePort(PORT).then(() => setTimeout(() => startListening(true), 1500));
      } else if (e && e.code === 'EADDRINUSE') {
        console.log(`\nA TCG Bot is already running â€” you only need one window open. This one will close.`);
        setTimeout(() => process.exit(0), 4000);
      } else {
        throw e;
      }
    });
  }
  startListening(false);
  keepAwake();            // hold the PC awake for as long as the bot runs (close the bot to let it sleep)
  startPhoneWatchdog();   // keep the wireless-ADB link alive + auto-reconnect if it drops mid-run

  process.on('SIGINT', async () => {
    if (cdpBrowser) await cdpBrowser.close().catch(() => {});
    persistCache();
    process.exit(0);
  });
}

function parseCerts(input) {
  let arr;
  if (Array.isArray(input)) arr = input;
  else if (typeof input === 'string') arr = input.split(',');
  else return [];
  return arr.map(c => String(c).trim()).filter(c => /^\d{6,15}$/.test(c));   // up to 15 digits â€” old CGC certs are 13-digit
}

startServer();


















