#!/usr/bin/env node
/**
 * scripts/ci/check-llms-pricing.js — assert the AI-facing files state current pricing.
 *
 * Run by .github/workflows/boot-smoke.yml on every push and PR.
 *
 * WHY: /llms.txt and /llms-full.txt are the files an LLM quotes back to a
 * prospect verbatim, and nothing fails when they go stale. On 2026-08-13 they
 * were found carrying per-seat fees that NO tier has charged since the
 * per-connection model shipped (CONN_PRICES is seat:0 throughout), legacy tier
 * names, "Enterprise — contact us" when Enterprise is self-serve checkout, and —
 * in the other file — a long-dead $99/$199 product split. The two files
 * contradicted each other AND both contradicted config/pricing.js. They had
 * drifted for months because a wrong price here breaks no test, no page and no
 * customer flow; it only misinforms every AI that reads the site.
 *
 * The invariant: config/pricing.js is the single source of truth, and every
 * price appearing in the AI files must be derivable from it.
 *
 * DESIGN NOTE — why this checks tokens, not prose. An assertion that pins prose
 * ("must contain the words 'annual, 20% off'") fails on innocent rewording, and
 * a check that cannot be satisfied gets switched off — the same lesson recorded
 * at the top of scripts/check-architecture-rules.sh. So this asserts only
 * format-stable money tokens ($49/conn, $210/mo) plus one whole-file sweep, and
 * says nothing about how the surrounding sentence is written.
 *
 * Usage: node scripts/ci/check-llms-pricing.js
 * Exit 0 = all assertions pass. Exit 1 = a stated price is wrong or stale.
 */

'use strict';

const path = require('path');
const express = require('express');

const ROOT = path.join(__dirname, '..', '..');
const { CONN_PRICES, PER_CONN_TIERS, ANNUAL_DISCOUNT } = require(path.join(ROOT, 'config', 'pricing.js'));

const RED = '\x1b[0;31m', GREEN = '\x1b[0;32m', YELLOW = '\x1b[1;33m', NC = '\x1b[0m', BOLD = '\x1b[1m';

const failures = [];
const warnings = [];
function fail(file, msg, fix) {
  failures.push({ file, msg, fix });
}

// ── Derive the canonical figures ────────────────────────────────────────────
// Rounding matches public/pricing.html, which rounds the annual rate in dollar
// space ($18 → $14.40 → "$14"). Doing it any other way here would fail the build
// against a page that is itself correct.
const money = n => '$' + n.toLocaleString('en-US');

const tiers = PER_CONN_TIERS.map(key => {
  const r = CONN_PRICES[key];
  return {
    key,
    monthly: Math.round(r.conn / 100),
    annual: Math.round(r.conn * ANNUAL_DISCOUNT / 100),
    minBill: Math.round(r.conn * r.min_conns / 100),
    minConns: r.min_conns,
    maxConns: r.max_conns,
    maxUsers: r.max_users,
  };
});

// Every per-connection rate that is legitimate to print. Used by the sweep below.
const validConnRates = new Set();
for (const t of tiers) { validConnRates.add(t.monthly); validConnRates.add(t.annual); }

// ── Fetch the two files from the live router ────────────────────────────────
// Deliberately served through express rather than regexed out of the source: the
// thing under test is what a crawler RECEIVES. A template literal that never
// reaches a response would otherwise pass.
async function fetchAiFiles() {
  const app = express();
  app.use('/', require(path.join(ROOT, 'routes', 'ai-discoverability.js')));

  return new Promise((resolve, reject) => {
    const server = app.listen(0, async () => {
      let result, error;
      try {
        const port = server.address().port;
        const out = {};
        for (const name of ['/llms.txt', '/llms-full.txt']) {
          const res = await fetch(`http://127.0.0.1:${port}${name}`);
          if (res.status !== 200) throw new Error(`${name} returned HTTP ${res.status}`);
          out[name] = await res.text();
        }
        result = out;
      } catch (err) {
        error = err;
      }
      // Settle only once the listener is fully closed. Resolving while the handle
      // is still closing and then calling process.exit() aborts the process with
      // a libuv assertion on Windows (exit 127) — a green check reported as a
      // build failure, which is the worst possible outcome for a gate.
      server.close(() => (error ? reject(error) : resolve(result)));
    });
    server.on('error', reject);
  });
}

// ── Rule 1: every tier's monthly, annual and minimum bill appears, in both files
function checkTierFigures(files) {
  for (const [name, body] of Object.entries(files)) {
    for (const t of tiers) {
      const monthlyToken = `$${t.monthly}/conn`;
      const annualToken = `$${t.annual}/conn`;
      const minBillToken = `${money(t.minBill)}/mo`;

      if (!body.includes(monthlyToken)) {
        fail(name, `${t.key}: monthly rate "${monthlyToken}" not found`,
          `config/pricing.js has CONN_PRICES.${t.key}.conn = ${CONN_PRICES[t.key].conn} cents. State it as "${monthlyToken}".`);
      }
      if (!body.includes(annualToken)) {
        fail(name, `${t.key}: annual rate "${annualToken}" not found`,
          `Annual is ${ANNUAL_DISCOUNT * 100}% of monthly, rounded in dollar space → "${annualToken}".`);
      }
      if (!body.includes(minBillToken)) {
        fail(name, `${t.key}: minimum bill "${minBillToken}" not found`,
          `min_conns (${t.minConns}) x $${t.monthly} = ${minBillToken}.`);
      }
    }
  }
}

// ── Rule 2: no per-connection rate appears that pricing.js does not authorise ──
// This is the check that would have caught the original bug on its own. The two
// files each carried an internally tidy price list; what made it a defect was
// that neither list came from CONN_PRICES. Sweeping every $N/conn token catches
// a stale figure ANYWHERE in the prose, including sentences this script has
// never heard of.
function checkNoUnauthorisedRates(files) {
  for (const [name, body] of Object.entries(files)) {
    const seen = new Set();
    for (const m of body.matchAll(/\$(\d+(?:,\d{3})*)\s*\/\s*conn/g)) {
      seen.add(Number(m[1].replace(/,/g, '')));
    }
    for (const rate of seen) {
      if (!validConnRates.has(rate)) {
        fail(name, `states a per-connection rate of $${rate}, which no tier charges`,
          `Authorised rates from config/pricing.js: ${[...validConnRates].sort((a, b) => b - a).map(r => '$' + r).join(', ')}.`);
      }
    }
    if (seen.size === 0) {
      fail(name, 'contains no per-connection rate at all',
        'Pricing must be stated in both AI files — that is the point of the file.');
    }
  }
}

// ── Rule 3: stale claims that were live in production and must not return ─────
const BANNED = [
  // Matches a seat PRICE ($29/seat, + $19 / seat), not the phrase "per-seat".
  // The first draft of this rule banned /per[- ]seat/ and immediately failed the
  // correct sentence "There are NO per-seat fees at any tier" — a rule that
  // cannot tell an assertion from its denial is the prose-matching trap written
  // up at the top of scripts/check-architecture-rules.sh. Saying seats are free
  // is the accurate thing to say; only quoting a seat rate is the defect.
  { re: /\$\s?\d[\d,]*\s*(?:\/|per\s)\s*seat/i,
    why: 'a per-seat rate — every tier is seat:0 in config/pricing.js and has been since the per-connection model shipped' },
  { re: /\bNeon\b/,
    why: 'Neon — TuneVault Cloud moved to Oracle ADB 26ai on OCI on 2026-07-26' },
  { re: /\bRender\b/,
    why: 'Render — hosting moved to OCI Compute on 2026-07-26' },
  { re: /Polsia/i,
    why: 'the Polsia AI proxy — AI calls go direct to OpenAI/Anthropic' },
  { re: /gpt-4o-mini/i,
    why: 'a pinned model name — model choice changes without anyone thinking about the AI files' },
];

function checkBannedClaims(files) {
  for (const [name, body] of Object.entries(files)) {
    for (const b of BANNED) {
      const hit = body.match(b.re);
      if (hit) {
        fail(name, `contains "${hit[0].trim()}" — ${b.why}`,
          'Remove it. This exact claim was live and wrong; that is why it is banned.');
      }
    }
  }
}

// ── Rule 4: the two free offers stay distinguishable ─────────────────────────
// public/pricing.html carries a comment warning that two unlabelled "Free"
// offers read as a contradiction. The same trap applies harder here, because an
// LLM will collapse them into one wrong answer ("free tier: unlimited checks").
function checkFreeOffersLabelled(files) {
  for (const [name, body] of Object.entries(files)) {
    if (!/Free\s*[—-]\s*Cloud/i.test(body)) {
      fail(name, 'does not name the "Free — Cloud" offer distinctly',
        'There are TWO free offers. Label the hosted one "Free — Cloud" (1 conn, 5 checks).');
    }
    if (!/self[- ]hosted/i.test(body) || !/30[- ]day/i.test(body)) {
      fail(name, 'does not describe the free 30-day self-hosted trial',
        'Label the other one as the free 30-day self-hosted license (1 conn, unlimited checks).');
    }
  }
}

// ── Rule 5: the MCP tool count is not overstated ─────────────────────────────
// Soft check. If the count cannot be read reliably it WARNS rather than fails —
// a build that breaks on an unrelated formatting change in mcp.js is a build
// people learn to ignore.
function checkMcpToolCount(files) {
  let declared;
  try {
    const src = require('fs').readFileSync(path.join(ROOT, 'routes', 'mcp.js'), 'utf8');
    const matches = src.match(/^\s{4}name:\s*'[a-z0-9_]+',/gm);
    declared = matches ? matches.length : 0;
  } catch (_) {
    declared = 0;
  }

  if (declared === 0) {
    warnings.push('could not count MCP tools in routes/mcp.js — skipping that assertion');
    return;
  }

  for (const [name, body] of Object.entries(files)) {
    const stated = body.match(/(\d+)\s+Oracle tools/);
    if (!stated) continue; // the files need not mention it
    if (Number(stated[1]) !== declared) {
      fail(name, `claims ${stated[1]} MCP Oracle tools; routes/mcp.js defines ${declared}`,
        `Update the number to ${declared} (and check CLAUDE.md, which has been wrong here before).`);
    }
  }
}

// ── Run ──────────────────────────────────────────────────────────────────────
(async () => {
  console.log(`${BOLD}llms pricing check${NC}`);
  console.log('  Source of truth: config/pricing.js\n');

  let files;
  try {
    files = await fetchAiFiles();
  } catch (err) {
    console.error(`${RED}Could not serve the AI files: ${err.message}${NC}`);
    process.exit(1);
  }

  checkTierFigures(files);
  checkNoUnauthorisedRates(files);
  checkBannedClaims(files);
  checkFreeOffersLabelled(files);
  checkMcpToolCount(files);

  for (const w of warnings) console.log(`${YELLOW}  warn: ${w}${NC}`);

  if (failures.length === 0) {
    const summary = tiers.map(t => `${t.key} $${t.monthly}/$${t.annual}`).join('  ');
    console.log(`${GREEN}✓ /llms.txt and /llms-full.txt match config/pricing.js${NC}`);
    console.log(`  ${summary}`);
    return; // exitCode stays 0; let the loop drain rather than forcing exit
  }

  console.error(`\n${RED}${BOLD}${failures.length} problem(s) in the AI-facing files:${NC}\n`);
  for (const f of failures) {
    console.error(`${RED}  ${f.file}${NC}: ${f.msg}`);
    console.error(`    fix: ${f.fix}`);
    console.error(`::error file=routes/ai-discoverability.js::${f.file} ${f.msg}`);
  }
  console.error(`\nThese files are what an LLM quotes to a prospect. A wrong number here`);
  console.error(`breaks no test and no page — which is exactly why it needs a gate.`);
  console.error(`Edit routes/ai-discoverability.js; pricing must appear once per file`);
  console.error(`and identically in both.\n`);
  process.exitCode = 1;
})().catch(err => {
  console.error(`${RED}check-llms-pricing crashed: ${err && err.stack || err}${NC}`);
  process.exitCode = 1;
});
