// LAV Pulse server — zero dependencies, Node 18+ only.
import './env.js';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
  loadSites, totals, timeseries, breakdown, filteredBreakdown, pageDetail, searchPages,
  portfolioTotals, portfolioTimeseries, portfolioBreakdown,
  resolveRange, previousRange, searchTerms, pageFlows, outboundClicks,
  channelMix, miscPages, pageTrafficSources, outboundJourneys,
} from './ga4.js';
import { trafficPeaks } from './peaks.js';
import { chat, aiSummary } from './chat.js';
import { computeInsights } from './insights.js';
import { releaseTracker, mediaRadar, matchCampaignToPath, reconReport } from './releases.js';
import { emailOverview, campaignDetail } from './email.js';
import { generateReport, pptxReport, listReports, REPORTS_DIR } from './report.js';
import { mailConfigured } from './mailer.js';
import { startScheduler, recentAlerts } from './scheduler.js';
import crypto from 'node:crypto';
import {
  loadUsers, createUser, verifyLogin, updateUser, deleteUser, publicUser,
  getSessionUser, setSessionHeader, clearSessionHeader, isAdmin, secret as sessionSecret,
} from './auth.js';
import { logActivity, logError, hookConsoleErrors, readLog } from './audit.js';
import { licenseStatus, activateKey, refreshLicense, startLicenseRefresh } from './license.js';
import { listSchedules, saveSchedule, deleteSchedule, reportIndex, forgetReport } from './repstore.js';
import { legacyOverview, legacyRelease, dbKind, releasesByTaxon, personActivity, publicationDetail, mediaDetail, releasesByDate, quietReleases, fullList, uploadStats, mediaPaths, searchMediaAssets, modelInterest, emailIntelligence, platformLocales, clearLocalesCache } from './legacy.js';
import { billingStatus, checkoutUrl, portalUrl, stripeConfigured } from './stripe.js';
import { monitoringOverview, monitoringInsights, monitoringArticles, monitoringList } from './monitoring.js';
import { testConnection, centralSites } from './db.js';
import { marketALS, parseMarketsParam, currentMarkets, textMatchesMarkets, langCode } from './market.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC = path.join(__dirname, '..', 'public');
const PASSWORD = process.env.DASHBOARD_PASSWORD || '';

// ── client-portal share tokens ──────────────────────────────────────────
const SHARE_SECRET = process.env.SHARE_SECRET || PASSWORD || sessionSecret();
const shareToken = (siteId) =>
  crypto.createHmac('sha256', SHARE_SECRET).update(String(siteId)).digest('hex').slice(0, 20);
const validShare = (siteId, token) => {
  try {
    return crypto.timingSafeEqual(Buffer.from(shareToken(siteId)), Buffer.from(String(token)));
  } catch { return false; }
};
// Optional per-site password on client links (Sites admin → "Client link
// password"). No password configured = links work as before.
const sharePwOk = (siteId, pw) => {
  const site = loadSites().find((s) => s.id === siteId);
  const want = String(site?.sharePassword || '');
  if (!want) return true;
  try {
    const a = crypto.createHash('sha256').update(String(pw || '')).digest();
    const b = crypto.createHash('sha256').update(want).digest();
    return crypto.timingSafeEqual(a, b);
  } catch { return false; }
};
// Endpoints a share token may reach — only that one site's data.
function shareAllowed(url, siteId) {
  if (url.pathname.startsWith(`/api/site/${siteId}`)) return true;
  if (url.pathname === '/api/insights' && url.searchParams.get('scope') === siteId) return true;
  if (url.pathname === '/api/portal') return true;
  return false;
}

const MIME = {
  '.html': 'text/html; charset=utf-8',
  '.js': 'text/javascript; charset=utf-8',
  '.css': 'text/css; charset=utf-8',
  '.svg': 'image/svg+xml',
  '.json': 'application/json',
  '.png': 'image/png',
  '.ico': 'image/x-icon',
  '.webmanifest': 'application/manifest+json; charset=utf-8',
};

function json(res, status, obj) {
  const body = JSON.stringify(obj);
  res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
  res.end(body);
}

function readBody(req) {
  return new Promise((resolve, reject) => {
    let data = '';
    req.on('data', (c) => {
      data += c;
      if (data.length > 1e6) { reject(new Error('body too large')); req.destroy(); }
    });
    req.on('end', () => {
      try { resolve(data ? JSON.parse(data) : {}); } catch (e) { reject(e); }
    });
    req.on('error', reject);
  });
}

async function api(req, res, url) {
  const range = resolveRange(url.searchParams.get('range'));
  const prev = previousRange(range);

  // ── auth routes (never gated) ─────────────────────────────────────────
  if (url.pathname.startsWith('/api/auth/')) {
    const action = url.pathname.slice('/api/auth/'.length);
    if (action === 'me') {
      const u = getSessionUser(req);
      if (u) return json(res, 200, { user: publicUser(u) });
      return json(res, 401, { needsSetup: loadUsers().length === 0 });
    }
    if (action === 'setup' && req.method === 'POST') {
      if (loadUsers().length > 0) return json(res, 403, { error: 'Setup already completed.' });
      const b = await readBody(req);
      const u = createUser({ name: b.name, email: b.email, password: b.password, role: 'superadmin', status: 'active' });
      setSessionHeader(res, loadUsers().find((x) => x.id === u.id));
      logActivity(u, 'account-setup', {}, req);
      return json(res, 200, { user: u });
    }
    if (action === 'login' && req.method === 'POST') {
      const b = await readBody(req);
      const r = verifyLogin(b.email, b.password);
      if (r.error) {
        logActivity(String(b.email || ''), 'login-failed', { reason: r.error }, req);
        return json(res, 401, { error: r.error });
      }
      setSessionHeader(res, r.user);
      logActivity(r.user, 'login', {}, req);
      return json(res, 200, { user: publicUser(r.user) });
    }
    if (action === 'register' && req.method === 'POST') {
      const b = await readBody(req);
      const u = createUser({ name: b.name, email: b.email, password: b.password, role: 'client', status: 'pending' });
      logActivity(u, 'register', {}, req);
      return json(res, 200, { user: u, message: 'Account created — an administrator will approve it shortly.' });
    }
    if (action === 'logout' && req.method === 'POST') {
      logActivity(getSessionUser(req), 'logout', {}, req);
      clearSessionHeader(res);
      return json(res, 200, { ok: true });
    }
    return json(res, 404, { error: 'not found' });
  }
  // ── licensing (Nova-style: the app requires a valid key after the trial) ─
  if (url.pathname === '/api/license') {
    const st = licenseStatus();
    return json(res, 200, { license: { ...st, key: undefined } });
  }
  if (url.pathname === '/api/license/activate' && req.method === 'POST') {
    const u0 = getSessionUser(req);
    // During a lock-out anyone who can reach the box may activate; once
    // running, only the superadmin can change the key.
    const st0 = licenseStatus();
    if (!st0.blocked && u0?.role !== 'superadmin') return json(res, 403, { error: 'Super admin only.' });
    const b = await readBody(req);
    try {
      const st = activateKey(String(b?.key || ''));
      logActivity(u0 || 'activation', 'license-activated', { plan: st.plan, licensee: st.licensee }, req);
      refreshLicense(true).catch(() => {});
      return json(res, 200, { license: st });
    } catch (err) {
      logActivity(u0 || 'activation', 'license-activation-failed', { error: err.message }, req);
      return json(res, 400, { error: err.message });
    }
  }
  // Everything except auth + licensing is blocked once the trial ends /
  // the licence lapses — the UI shows the activation screen.
  {
    const lic = licenseStatus();
    if (lic.blocked) {
      return json(res, 402, { licenseRequired: true, reason: lic.reason, license: lic });
    }
  }

  // Portal config: validates a share token and returns the site's identity.
  if (url.pathname === '/api/portal') {
    const siteId = url.searchParams.get('site') || '';
    const token = url.searchParams.get('token') || req.headers['x-share-token'] || '';
    if (!validShare(siteId, token)) return json(res, 401, { error: 'invalid share link' });
    const site = loadSites().find((s) => s.id === siteId);
    if (!site) return json(res, 404, { error: 'unknown site' });
    // Password-protected link? Say so (without leaking anything) until the
    // right password arrives.
    if (!sharePwOk(siteId, url.searchParams.get('pw') || req.headers['x-share-pw'])) {
      return json(res, 401, { needPassword: true, error: 'This client link is password-protected.' });
    }
    // Flags only — never credentials. `monitoring: true` lets the portal
    // render the coverage section; propertyId/db presence lets it detect
    // monitoring-only clients and skip the analytics dashboard.
    return json(res, 200, { ok: true, site: {
      id: site.id, name: site.name, url: site.url,
      monitoring: process.env.DEMO_MODE === '1' || site.propertyId === 'demo' ? true : site.monitoring ? true : undefined,
      monitoringOnly: Boolean(site.monitoringOnly
        || (site.monitoring?.host && (!site.propertyId || site.propertyId === 'none') && !site.db?.host)) || undefined,
      demo: site.propertyId === 'demo' || undefined,
      ci: site.ci || undefined,
    }, chatConfigured: Boolean(process.env.OPENAI_API_KEY) });
  }

  // ── main gate: logged-in user session OR a scoped client share token ──
  const user = getSessionUser(req);
  let allowedSites = null; // null = all sites; array = client scope
  if (!user) {
    const st = req.headers['x-share-token'], ss = req.headers['x-share-site'];
    const shared = st && ss && validShare(ss, st) && shareAllowed(url, ss)
      && sharePwOk(ss, req.headers['x-share-pw']);
    if (!shared) return json(res, 401, { error: 'unauthorised', needsSetup: loadUsers().length === 0 });
  } else if (user.role === 'client') {
    allowedSites = loadSites().filter((s) => (user.siteIds || []).includes(s.id));
    // Client scope enforcement for per-site endpoints
    const siteIdInPath = /^\/api\/site\/([\w-]+)/.exec(url.pathname)?.[1];
    if (siteIdInPath && !allowedSites.some((s) => s.id === siteIdInPath)) {
      return json(res, 403, { error: 'You do not have access to this site.' });
    }
    // Admin-only areas (chat + ai-summary are allowed for clients, scoped below)
    if (/^\/api\/(users|sites-config|reports|share-link|alerts|billing)/.test(url.pathname)) {
      return json(res, 403, { error: 'Not available for your role.' });
    }
    if (url.pathname === '/api/insights' && url.searchParams.get('scope') !== 'all') {
      const sc = url.searchParams.get('scope');
      if (!allowedSites.some((s) => s.id === sc)) return json(res, 403, { error: 'You do not have access to this site.' });
    }
  }

  // ── user management (admins; superadmin has full power) ───────────────
  if (url.pathname.startsWith('/api/users')) {
    if (!isAdmin(user)) return json(res, 403, { error: 'Admins only.' });
    const canTouch = (target) => user.role === 'superadmin' || target.role === 'client';
    if (url.pathname === '/api/users' && req.method === 'GET') {
      return json(res, 200, { users: loadUsers().map(publicUser), self: user.id });
    }
    if (url.pathname === '/api/users/update' && req.method === 'POST') {
      const b = await readBody(req);
      const target = loadUsers().find((u) => u.id === b.id);
      if (!target) return json(res, 404, { error: 'User not found.' });
      if (!canTouch(target)) return json(res, 403, { error: 'Admins can only manage client accounts.' });
      if (b.role && b.role !== 'client' && user.role !== 'superadmin')
        return json(res, 403, { error: 'Only the super admin can grant admin roles.' });
      if (target.id === user.id && b.status && b.status !== 'active')
        return json(res, 400, { error: "You can't disable your own account." });
      const updated = updateUser(b.id, b);
      logActivity(user, 'user-updated', { target: target.email, changes: Object.keys(b).filter((k) => k !== 'id').join(',') }, req);
      return json(res, 200, { user: updated });
    }
    if (url.pathname === '/api/users/create' && req.method === 'POST') {
      const b = await readBody(req);
      if (b.role && b.role !== 'client' && user.role !== 'superadmin')
        return json(res, 403, { error: 'Only the super admin can create admin accounts.' });
      const created = createUser({ ...b, status: 'active' });
      logActivity(user, 'user-created', { target: created.email, role: created.role }, req);
      return json(res, 200, { user: created });
    }
    if (url.pathname === '/api/users/delete' && req.method === 'POST') {
      const b = await readBody(req);
      const target = loadUsers().find((u) => u.id === b.id);
      if (!target) return json(res, 404, { error: 'User not found.' });
      if (target.id === user.id) return json(res, 400, { error: "You can't delete your own account." });
      if (!canTouch(target)) return json(res, 403, { error: 'Admins can only manage client accounts.' });
      deleteUser(b.id);
      logActivity(user, 'user-deleted', { target: target.email }, req);
      return json(res, 200, { ok: true });
    }
    return json(res, 404, { error: 'not found' });
  }

  // ── sites configuration management (admins) ───────────────────────────
  if (url.pathname.startsWith('/api/sites-config')) {
    if (!isAdmin(user)) return json(res, 403, { error: 'Admins only.' });
    const cfgPath = path.join(__dirname, '..', 'sites.config.json');
    if (req.method === 'GET') {
      return json(res, 200, JSON.parse(fs.readFileSync(cfgPath, 'utf8')));
    }
    // One-click import from the central reports DB's `sites` registry.
    // Assumes the per-site DBs were copied to the SAME MySQL host (e.g. RDS)
    // using the connection key as the schema name — editable per site after.
    if (url.pathname === '/api/sites-config/import-central' && req.method === 'POST') {
      const b = await readBody(req);
      const found = await centralSites(b);
      const current = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'sites.config.json'), 'utf8'));
      const existing = new Set(current.sites.map((s) => s.id));
      const slug = (n) => String(n).toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
      let imported = 0;
      for (const f of found) {
        const id = slug(f.name);
        if (!id || existing.has(id)) continue;
        existing.add(id);
        current.sites.push({
          id, name: f.name, url: '', propertyId: 'demo',
          db: {
            kind: f.kind, host: b.host, port: Number(b.port || 3306),
            database: f.connectionKey, user: b.user, password: b.password,
          },
        });
        imported++;
      }
      fs.writeFileSync(path.join(__dirname, '..', 'sites.config.json'), JSON.stringify(current, null, 2));
      return json(res, 200, { imported, found: found.length, skipped: found.length - imported });
    }
    if (url.pathname === '/api/sites-config/save' && req.method === 'POST') {
      // (audited below on success)
      const b = await readBody(req);
      const sites = Array.isArray(b?.sites) ? b.sites : null;
      if (!sites || !sites.length) return json(res, 400, { error: 'At least one site is required.' });
      // Grandfather ids that already exist in the config (legacy hand-edited
      // entries may have capitals); enforce the slug rule only on new ids.
      const existingIds = new Set(loadSites().map((s) => s.id));
      const ids = new Set();
      for (const s of sites) {
        if (!existingIds.has(s.id) && !/^[a-z0-9][a-z0-9-]*$/.test(s.id || '')) return json(res, 400, { error: `Invalid id "${s.id}" — use lowercase letters, numbers and hyphens.` });
        if (ids.has(s.id)) return json(res, 400, { error: `Duplicate id "${s.id}".` });
        ids.add(s.id);
        if (!s.name) return json(res, 400, { error: `Site "${s.id}" needs a name.` });
        if (!s.propertyId) return json(res, 400, { error: `Site "${s.id}" needs a propertyId (or "demo").` });
        s.url = String(s.url || '').replace(/\/+$/, '');
      }
      fs.writeFileSync(cfgPath, JSON.stringify({ sites }, null, 2));
      logActivity(user, 'sites-config-saved', { sites: sites.length }, req);
      return json(res, 200, { ok: true, count: sites.length });
    }
    return json(res, 404, { error: 'not found' });
  }

  if (url.pathname === '/api/config') {
    const visible = allowedSites || loadSites();
    return json(res, 200, {
      sites: visible.map((s2) => ({
        id: s2.id, name: s2.name, url: s2.url,
        demo: process.env.DEMO_MODE === '1' || s2.propertyId === 'demo',
        kind: dbKind(s2),
        monitoring: process.env.DEMO_MODE === '1' || s2.propertyId === 'demo' ? true : Boolean(s2.monitoring?.host),
        // Monitoring-ONLY clients (Ferrari): explicit admin flag, or
        // monitoring configured with no GA property and no reports DB.
        // Computed HERE — the client payload doesn't carry raw credentials.
        monitoringOnly: Boolean(s2.monitoringOnly
          || (s2.monitoring?.host && (!s2.propertyId || s2.propertyId === 'none') && !s2.db?.host)) || undefined,
        // Client CI — colours, font, logo. Drives the per-client dashboard
        // theme and is echoed into reports & decks.
        ci: s2.ci || undefined,
      })),
      user: user ? publicUser(user) : null,
      chatConfigured: Boolean(process.env.OPENAI_API_KEY),
      license: (() => { const l = licenseStatus(); return { mode: l.mode, daysLeft: l.daysLeft, plan: l.plan, licensee: l.licensee, expires: l.expires, warning: l.warning || null }; })(),
    });
  }

  if (url.pathname === '/api/portfolio') {
    const [cur, prv, trend, channels] = await Promise.all([
      portfolioTotals(range.startDate, range.endDate, allowedSites),
      portfolioTotals(prev.startDate, prev.endDate, allowedSites),
      portfolioTimeseries(range.startDate, range.endDate, allowedSites),
      portfolioBreakdown('sessionDefaultChannelGroup', range.startDate, range.endDate, 8, {}, allowedSites),
    ]);
    const prevById = Object.fromEntries(prv.perSite.map((s) => [s.id, s]));
    return json(res, 200, {
      range, totals: cur.totals, previous: prv.totals, trend, channels,
      sites: cur.perSite.map((s) => ({
        ...s,
        previousSessions: prevById[s.id]?.sessions ?? 0,
        previousConversions: prevById[s.id]?.conversions ?? 0,
      })),
    });
  }

  // Drill-down: pages under a path prefix, e.g. /api/site/kia/pages?prefix=/releases
  const pagesMatch = /^\/api\/site\/([\w-]+)\/pages$/.exec(url.pathname);
  if (pagesMatch) {
    const prefix = url.searchParams.get('prefix') || '/';
    const rows = await breakdown(pagesMatch[1], 'pagePath', range.startDate, range.endDate, 25, { pathPrefix: prefix });
    return json(res, 200, { range, prefix, rows });
  }

  // Channel drill-down: sources + landing pages within one channel
  const chMatch = /^\/api\/site\/([\w-]+)\/channel$/.exec(url.pathname);
  if (chMatch) {
    const channel = url.searchParams.get('channel') || 'Direct';
    const [sources, landing] = await Promise.all([
      filteredBreakdown(chMatch[1], { dimension: 'sessionSource', metric: 'sessions', filterField: 'sessionDefaultChannelGroup', filterValue: channel }, range.startDate, range.endDate, 8),
      filteredBreakdown(chMatch[1], { dimension: 'landingPage', metric: 'sessions', filterField: 'sessionDefaultChannelGroup', filterValue: channel }, range.startDate, range.endDate, 8),
    ]);
    return json(res, 200, { range, channel, sources, landing });
  }

  // Device drill-down: sources + top pages for one device category
  const dvMatch = /^\/api\/site\/([\w-]+)\/device$/.exec(url.pathname);
  if (dvMatch) {
    const device = url.searchParams.get('device') || 'desktop';
    const [sources, pages, channels] = await Promise.all([
      filteredBreakdown(dvMatch[1], { dimension: 'sessionSource', metric: 'sessions', filterField: 'deviceCategory', filterValue: device }, range.startDate, range.endDate, 8),
      filteredBreakdown(dvMatch[1], { dimension: 'pagePath', metric: 'screenPageViews', filterField: 'deviceCategory', filterValue: device }, range.startDate, range.endDate, 8),
      filteredBreakdown(dvMatch[1], { dimension: 'sessionDefaultChannelGroup', metric: 'sessions', filterField: 'deviceCategory', filterValue: device }, range.startDate, range.endDate, 8),
    ]);
    return json(res, 200, { range, device, sources, pages, channels });
  }

  // Country drill-down: cities within one country
  const ctryMatch = /^\/api\/site\/([\w-]+)\/cities$/.exec(url.pathname);
  if (ctryMatch) {
    const country = url.searchParams.get('country') || '';
    const rows = await filteredBreakdown(ctryMatch[1], { dimension: 'city', metric: 'sessions', filterField: 'country', filterValue: country }, range.startDate, range.endDate, 10);
    return json(res, 200, { range, country, rows });
  }

  // Single-page detail: daily views + sources + countries for one exact path,
  // enriched with the matched email campaign when one exists.
  const pdMatch = /^\/api\/site\/([\w-]+)\/page$/.exec(url.pathname);
  if (pdMatch) {
    const p = url.searchParams.get('path') || '/';
    const detail = await pageDetail(pdMatch[1], p, range.startDate, range.endDate);
    try {
      const ridM = /(\d{3,})\/?$/.exec(p);
      if (ridM) {
        const lr = await legacyRelease(pdMatch[1], ridM[1], url.searchParams.get('range'));
        if (lr) detail.legacy = lr;
      }
    } catch { /* legacy optional */ }
    try {
      const em = await emailOverview(pdMatch[1], url.searchParams.get('range'));
      if (em.enabled && em.campaigns?.length) {
        const c = matchCampaignToPath(p, em.campaigns);
        if (c) {
          detail.emailCampaign = {
            id: c.id, provider: c.provider, name: c.subject || c.name,
            date: c.date, sent: c.sent, opens: c.opens, openRate: c.openRate,
            clicks: c.clicks, clickRate: c.clickRate,
          };
        }
      }
    } catch { /* optional */ }
    return json(res, 200, { range, ...detail });
  }

  // Release performance tracker
  const relMatch = /^\/api\/site\/([\w-]+)\/releases$/.exec(url.pathname);
  if (relMatch) {
    return json(res, 200, await releaseTracker(relMatch[1], url.searchParams.get('range') || '90d'));
  }

  // Earned-media radar (new referring domains)
  const radarMatch = /^\/api\/site\/([\w-]+)\/radar$/.exec(url.pathname);
  if (radarMatch) {
    return json(res, 200, await mediaRadar(radarMatch[1], url.searchParams.get('range')));
  }

  // Email campaigns (SparkPost/Brevo by sender domain)
  const emMatch = /^\/api\/site\/([\w-]+)\/email$/.exec(url.pathname);
  if (emMatch) {
    const em = await emailOverview(emMatch[1], url.searchParams.get('range'));
    // Email platforms don't tag campaigns with markets — match the campaign
    // titles against the selected markets (US/USA, MX/Mexico/México, …).
    const mkts = currentMarkets();
    if (mkts && Array.isArray(em.campaigns) && em.campaigns.length) {
      const matched = em.campaigns.filter((c) => textMatchesMarkets(c.subject || c.name || c.title, mkts));
      em.marketFilter = { applied: matched.length > 0, matched: matched.length, total: em.campaigns.length };
      if (matched.length) em.campaigns = matched;
    }
    return json(res, 200, em);
  }

  // Single email campaign drill-down
  const ecMatch = /^\/api\/site\/([\w-]+)\/email\/campaign$/.exec(url.pathname);
  if (ecMatch) {
    return json(res, 200, await campaignDetail(
      ecMatch[1],
      url.searchParams.get('provider') || 'sparkpost',
      url.searchParams.get('cid') || '',
      url.searchParams.get('range')
    ));
  }

  // Share link for a site (dashboard users only — gated above)
  if (url.pathname === '/api/share-link') {
    const siteId = url.searchParams.get('site') || '';
    if (!loadSites().some((s) => s.id === siteId)) return json(res, 404, { error: 'unknown site' });
    logActivity(user, 'share-link-created', { siteId }, req);
    return json(res, 200, { url: `/share/${siteId}/${shareToken(siteId)}` });
  }

  // Reports: structured list (file + metadata for grouping/search) + delete
  if (url.pathname === '/api/reports') {
    const idx = reportIndex();
    const files = listReports().map((f) => {
      let st = null;
      try { st = fs.statSync(path.join(REPORTS_DIR, f)); } catch { /* raced delete */ }
      const meta = idx[f] || {};
      // Legacy files (before the index existed): best-effort client match
      // from the filename.
      let siteId = meta.siteId;
      if (!siteId) siteId = loadSites().find((s2) => f.includes(s2.id))?.id || null;
      return {
        file: f, at: meta.at || st?.mtimeMs || 0, size: st?.size || 0,
        siteId, title: meta.title || null, style: meta.style || null,
        format: meta.format || (f.endsWith('.pptx') ? 'pptx' : f.endsWith('.pdf') ? 'pdf' : 'html'),
        source: meta.source || null,
      };
    });
    return json(res, 200, { reports: files, sites: loadSites().map((s2) => ({ id: s2.id, name: s2.name })) });
  }
  if (url.pathname === '/api/reports/delete' && req.method === 'POST') {
    const body = await readBody(req);
    const file = path.basename(String(body?.file || ''));
    const full = path.join(REPORTS_DIR, file);
    if (!file || !full.startsWith(REPORTS_DIR) || !fs.existsSync(full)) return json(res, 404, { error: 'Report not found.' });
    fs.unlinkSync(full);
    forgetReport(file);
    logActivity(user, 'report-deleted', { file }, req);
    return json(res, 200, { ok: true });
  }
  if (url.pathname === '/api/reports/generate' && req.method === 'POST') {
    const body = await readBody(req);
    const scope = body?.scope || 'all';
    if (body?.format === 'pptx' && scope !== 'all') {
      const site2 = loadSites().find((s2) => s2.id === scope);
      const rp = await pptxReport({
        siteId: scope, range: body?.range || '28d',
        title: `${body?.style === 'monitoring' ? 'Media Monitoring' : 'Client report'} — ${site2?.name || scope}`,
        monitoring: body?.style === 'monitoring', market: body?.market || null, source: 'manual',
      });
      logActivity(user, 'report-generated', { siteId: scope, style: body?.style, format: 'pptx' }, req);
      return json(res, 200, { filename: rp.filename, pdfFilename: null, title: 'PowerPoint deck' });
    }
    const rep = await generateReport(scope, body?.range || '28d', body?.style || 'full', { market: body?.market || null, source: 'manual' });
    logActivity(user, 'report-generated', { siteId: scope, style: body?.style || 'full', range: body?.range }, req);
    return json(res, 200, { filename: rep.filename, pdfFilename: rep.pdfFilename || null, title: rep.title });
  }

  // Report schedules — recurring per-client reports (admins)
  if (url.pathname.startsWith('/api/report-schedules')) {
    if (!isAdmin(user)) return json(res, 403, { error: 'Admins only.' });
    if (req.method === 'GET') return json(res, 200, { schedules: listSchedules(), mailConfigured: mailConfigured() });
    if (url.pathname === '/api/report-schedules/save' && req.method === 'POST') {
      const b = await readBody(req);
      const saved = saveSchedule({ ...b, createdBy: user.email });
      logActivity(user, 'schedule-saved', { schedule: saved.id, siteId: saved.siteId, style: saved.style, format: saved.format, frequency: saved.frequency }, req);
      return json(res, 200, { schedule: saved });
    }
    if (url.pathname === '/api/report-schedules/delete' && req.method === 'POST') {
      const b = await readBody(req);
      const ok = deleteSchedule(String(b?.id || ''));
      if (ok) logActivity(user, 'schedule-deleted', { schedule: b?.id }, req);
      return json(res, 200, { ok });
    }
    return json(res, 404, { error: 'not found' });
  }

  // Superadmin: activity + error logs
  if (url.pathname === '/api/admin/logs') {
    if (user?.role !== 'superadmin') return json(res, 403, { error: 'Super admin only.' });
    const kind = url.searchParams.get('kind') === 'errors' ? 'errors' : 'activity';
    return json(res, 200, { entries: readLog(kind, { q: url.searchParams.get('q') || '', limit: Math.min(1000, Number(url.searchParams.get('limit')) || 400) }) });
  }

  // Recent anomaly alerts (for the dashboard banner)
  if (url.pathname === '/api/alerts') {
    return json(res, 200, { alerts: recentAlerts(7) });
  }

  // What drove the traffic peaks (annotated daily chart + card)
  const pkMatch = /^\/api\/site\/([\w-]+)\/peaks$/.exec(url.pathname);
  if (pkMatch) {
    try { return json(res, 200, { peaks: await trafficPeaks(pkMatch[1], url.searchParams.get('range')) }); }
    catch (err) { return json(res, 200, { peaks: [], error: String(err.message || err) }); }
  }
  // On-site search terms (GA4 searchTerm dimension MERGED with search-page
  // URL parameters, so under-instrumented properties still report fully)
  const stMatch = /^\/api\/site\/([\w-]+)\/searches$/.exec(url.pathname);
  if (stMatch) {
    try { return json(res, 200, { rows: await searchTerms(stMatch[1], range.startDate, range.endDate, 50) }); }
    catch (err) { return json(res, 200, { rows: [], error: String(err.message || err) }); }
  }
  // Channel mix — sessions per GA4 channel group, current vs previous period
  const chmixMatch = /^\/api\/site\/([\w-]+)\/channel-mix$/.exec(url.pathname);
  if (chmixMatch) {
    try { return json(res, 200, await channelMix(chmixMatch[1], url.searchParams.get('range'))); }
    catch (err) { return json(res, 200, { channels: [], error: String(err.message || err) }); }
  }
  // Rename a platform locale market (admins) — persists to sites.config.json
  const mkNameMatch = /^\/api\/site\/([\w-]+)\/market-name$/.exec(url.pathname);
  if (mkNameMatch && req.method === 'POST') {
    if (!isAdmin(user)) return json(res, 403, { error: 'Admins only.' });
    const b = await readBody(req);
    const locale = String(b?.locale || '').trim();
    const name = String(b?.name || '').trim().slice(0, 60);
    if (!locale || !name) return json(res, 400, { error: 'locale and name are required.' });
    const cfgPath = path.join(__dirname, '..', 'sites.config.json');
    const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
    const siteCfg = cfg.sites.find((s2) => s2.id === mkNameMatch[1]);
    if (!siteCfg) return json(res, 404, { error: 'Unknown site.' });
    siteCfg.marketNames = { ...(siteCfg.marketNames || {}), [locale]: name };
    fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
    clearLocalesCache();
    return json(res, 200, { ok: true, marketNames: siteCfg.marketNames });
  }
  // Market picker options: platform locales when the DB defines them
  // (auto-labelled via reader countries), else GA4 countries.
  const mkOptMatch = /^\/api\/site\/([\w-]+)\/markets$/.exec(url.pathname);
  if (mkOptMatch) {
    try {
      const locs = await platformLocales(mkOptMatch[1]).catch(() => []);
      if (locs.length >= 2) {
        return json(res, 200, { mode: 'locale', options: locs.map((l) => ({
          id: String(l.locale), label: l.label, sub: l.country ? `locale ${l.locale}` : null, count: l.releases,
          country: l.country || null, language: l.language || null })) });
      }
      const rows = await breakdown(mkOptMatch[1], 'country', range.startDate, range.endDate, 30).catch(() => []);
      return json(res, 200, { mode: 'country', options: rows
        .filter((x) => x.dimensionValue && x.dimensionValue !== '(not set)')
        .map((x) => ({ id: x.dimensionValue, label: x.dimensionValue, sub: null, count: x.sessions })) });
    } catch (err) { return json(res, 200, { mode: 'country', options: [], error: String(err.message || err) }); }
  }
  // Model interest — dated model-page views + asset downloads + release views
  const miMatch = /^\/api\/site\/([\w-]+)\/model-interest$/.exec(url.pathname);
  if (miMatch) {
    try { return json(res, 200, (await modelInterest(miMatch[1], url.searchParams.get('range'))) || { models: [] }); }
    catch (err) { return json(res, 200, { models: [], error: String(err.message || err) }); }
  }
  // Email intelligence — clicked links, best send times (platform event log)
  const eiMatch = /^\/api\/site\/([\w-]+)\/email-intel$/.exec(url.pathname);
  if (eiMatch) {
    try { return json(res, 200, (await emailIntelligence(eiMatch[1], url.searchParams.get('range'))) || { topClickedUrls: [] }); }
    catch (err) { return json(res, 200, { topClickedUrls: [], error: String(err.message || err) }); }
  }
  // Misc pages + Top executives (the old manual-report charts)
  const mpgMatch = /^\/api\/site\/([\w-]+)\/misc-pages$/.exec(url.pathname);
  if (mpgMatch) {
    try { return json(res, 200, await miscPages(mpgMatch[1], url.searchParams.get('range'))); }
    catch (err) { return json(res, 200, { sections: [], executives: [], error: String(err.message || err) }); }
  }
  // Quietest releases (bottom performers, live ≥30 days)
  const qrMatch = /^\/api\/site\/([\w-]+)\/quiet-releases$/.exec(url.pathname);
  if (qrMatch) {
    return json(res, 200, { rows: await quietReleases(qrMatch[1], url.searchParams.get('range'), 10) });
  }
  // Navigation journeys (pageReferrer → pagePath edges + entry sources)
  const flMatch2 = /^\/api\/site\/([\w-]+)\/flows$/.exec(url.pathname);
  if (flMatch2) {
    try { return json(res, 200, await pageFlows(flMatch2[1], range.startDate, range.endDate)); }
    catch (err) { return json(res, 200, { internal: [], entries: [], error: String(err.message || err) }); }
  }
  // Outbound clicks — where visitors go when they leave
  const obMatch = /^\/api\/site\/([\w-]+)\/outbound$/.exec(url.pathname);
  if (obMatch) {
    try { return json(res, 200, await outboundClicks(obMatch[1], range.startDate, range.endDate, 120)); }
    catch (err) { return json(res, 200, { domains: [], urls: [], collected: false, error: String(err.message || err) }); }
  }
  // Exit journeys — WHAT visitors were doing when they left (page × destination,
  // page kinds, previous-page arrival paths), release titles resolved.
  const ojMatch = /^\/api\/site\/([\w-]+)\/outbound-journeys$/.exec(url.pathname);
  if (ojMatch) {
    try {
      const j = await outboundJourneys(ojMatch[1], range.startDate, range.endDate);
      // Label /releases/<id> exit pages with their real titles
      try {
        const ids = [...new Set([...(j.byPage || []), ...(j.ownByPage || [])]
          .map((p) => /(\d{3,})\/?$/.exec(p.page)?.[1]).filter(Boolean))].slice(0, 60);
        if (ids.length) {
          const { releaseTitles } = await import('./legacy.js');
          const titles = await releaseTitles(ojMatch[1], ids).catch(() => ({}));
          const label = (p) => {
            const id = /(\d{3,})\/?$/.exec(p.page)?.[1];
            const t = id && titles[id];
            if (t) p.title = typeof t === 'string' ? t : (t.title || null);
            return p;
          };
          (j.byPage || []).forEach(label);
          (j.ownByPage || []).forEach(label);
        }
      } catch { /* titles optional */ }
      return json(res, 200, j);
    } catch (err) { return json(res, 200, { collected: false, error: String(err.message || err) }); }
  }
  // GA4 ↔ platform reconciliation (the consent-gap answer)
  const rcMatch = /^\/api\/site\/([\w-]+)\/recon$/.exec(url.pathname);
  if (rcMatch) {
    try { return json(res, 200, { recon: await reconReport(rcMatch[1], url.searchParams.get('range')) }); }
    catch (err) { return json(res, 200, { recon: null, error: String(err.message || err) }); }
  }
  // Publishing & upload volume, this year vs last year
  const upMatch = /^\/api\/site\/([\w-]+)\/upload-stats$/.exec(url.pathname);
  if (upMatch) {
    try { return json(res, 200, { stats: await uploadStats(upMatch[1], url.searchParams.get('range')) }); }
    catch (err) { return json(res, 200, { stats: null, error: String(err.message || err) }); }
  }
  // Media thumbnail paths (id → file path/url when the media table has one)
  const mpMatch = /^\/api\/site\/([\w-]+)\/media-paths$/.exec(url.pathname);
  if (mpMatch) {
    const ids = String(url.searchParams.get('ids') || '').split(',').filter(Boolean).slice(0, 30);
    return json(res, 200, { paths: await mediaPaths(mpMatch[1], ids).catch(() => ({})) });
  }
  // Full lists for the "View all" popups — DB rankings and GA4 breakdowns
  // with a big limit so the UI can paginate and search the whole thing.
  const flMatch = /^\/api\/site\/([\w-]+)\/list$/.exec(url.pathname);
  if (flMatch) {
    const what = url.searchParams.get('what') || '';
    const lim = Math.max(1, Math.min(500, Number(url.searchParams.get('limit') || 500)));
    const GA_DIMS = { countries: 'country', channels: 'sessionDefaultChannelGroup', sources: 'sessionSource', pages: 'pagePath', devices: 'deviceCategory' };
    try {
      if (what === 'searches') {
        return json(res, 200, { rows: await searchTerms(flMatch[1], range.startDate, range.endDate, lim) });
      }
      if (GA_DIMS[what]) {
        return json(res, 200, { rows: await breakdown(flMatch[1], GA_DIMS[what], range.startDate, range.endDate, lim) });
      }
      return json(res, 200, {
        rows: await fullList(flMatch[1], what, url.searchParams.get('range'), lim, url.searchParams.get('company') || null),
      });
    } catch (err) { return json(res, 200, { rows: [], error: String(err.message || err) }); }
  }

  // Media-monitoring overview (optional second DB per site) + computed intel
  const monMatch = /^\/api\/site\/([\w-]+)\/monitoring$/.exec(url.pathname);
  if (monMatch) {
    try {
      const ov = await monitoringOverview(monMatch[1], url.searchParams.get('range'), url.searchParams.get('market') || null);
      if (ov.enabled) ov.insights = monitoringInsights(ov);
      return json(res, 200, ov);
    } catch (err) { return json(res, 200, { enabled: true, error: String(err.message || err) }); }
  }
  // Full monitoring rankings for the "View all" popups
  const monList = /^\/api\/site\/([\w-]+)\/monitoring-list$/.exec(url.pathname);
  if (monList) {
    try {
      return json(res, 200, { rows: await monitoringList(monList[1], url.searchParams.get('range'), url.searchParams.get('what') || '', {
        marketId: url.searchParams.get('market') || null,
        limit: url.searchParams.get('limit') || 500,
      }) });
    } catch (err) { return json(res, 200, { rows: [], error: String(err.message || err) }); }
  }
  // Articles behind any monitoring graph segment (drill-down modal)
  const monArt = /^\/api\/site\/([\w-]+)\/monitoring-articles$/.exec(url.pathname);
  if (monArt) {
    try {
      return json(res, 200, await monitoringArticles(monArt[1], url.searchParams.get('range'), {
        marketId: url.searchParams.get('market') || null,
        kind: url.searchParams.get('kind') || null,
        value: url.searchParams.get('value') || null,
      }));
    } catch (err) { return json(res, 200, { articles: [], error: String(err.message || err) }); }
  }
  // AI insight note for ONE dashboard card (recon / heatmap / flows / …)
  const csMatch = /^\/api\/site\/([\w-]+)\/card-summary$/.exec(url.pathname);
  if (csMatch && req.method === 'POST') {
    const siteId = csMatch[1];
    const body = await readBody(req);
    const cardKey = String(body?.card || '');
    const rangeStr = body?.range || url.searchParams.get('range') || '28d';
    const site = loadSites().find((s) => s.id === siteId);
    let pack = null, label = '';
    if (cardKey === 'recon') {
      pack = {
        recon: await reconReport(siteId, rangeStr),
        guidance: 'Platform views are server-side and complete; GA4 only counts cookie-consented visitors minus ad-blockers, so GA4 normally shows a fraction. A release where GA4 is HIGHER than platform means its page also attracts consumer/repeat traffic the platform metric does not count — call those out as reach beyond the press audience, not an error. State the typical (median) visibility %, name the releases with the biggest gaps each way, and advise which number to quote where.',
      };
      label = `Platform vs GA4 reconciliation — ${site?.name || siteId}`;
    } else if (cardKey === 'heatmap') {
      const pages = await breakdown(siteId, 'pagePath', range.startDate, range.endDate, 500).catch(() => []);
      const secs = new Map();
      for (const p of pages) {
        const seg = '/' + (String(p.dimensionValue).split('/').filter(Boolean)[0] || '');
        const s = secs.get(seg) || { section: seg === '/' ? 'Homepage' : seg, views: 0, topPages: [] };
        s.views += p.screenPageViews || 0;
        if (s.topPages.length < 3) s.topPages.push({ path: p.dimensionValue, views: p.screenPageViews || 0 });
        secs.set(seg, s);
      }
      const sections = [...secs.values()].sort((a, b) => b.views - a.views).slice(0, 14);
      const tot = sections.reduce((a, s) => a + s.views, 0) || 1;
      pack = {
        sections: sections.map((s) => ({ ...s, share: Math.round((100 * s.views) / tot) + '%' })),
        guidance: 'This is where visitors actually spend their attention across the site. Point out the dominant sections, anything surprisingly weak (e.g. search or documents barely used), and what the balance says about what journalists come for. Recommend where to invest content or navigation changes.',
      };
      label = `Site heat map — ${site?.name || siteId}`;
    } else if (cardKey === 'flows') {
      pack = await pageFlows(siteId, range.startDate, range.endDate);
      label = `Visitor journeys — ${site?.name || siteId}`;
    } else if (cardKey === 'outbound') {
      const [ob, journeys] = await Promise.all([
        outboundClicks(siteId, range.startDate, range.endDate),
        outboundJourneys(siteId, range.startDate, range.endDate).catch(() => null),
      ]);
      pack = {
        destinations: ob,
        exitJourneys: journeys,
        guidance: 'exitJourneys shows the CONTEXT of each departure: byKind = what visitors were doing when they clicked away (press release / photo gallery / model page…); byPage = the exact pages (with release titles) and where each sends people; ownByPage = the handovers to the BRAND\'S OWN consumer site, including cameFrom (how visitors arrived at that page first). Explain in plain language AT WHAT STAGE of the visit people leave — especially the media-site → consumer-site handover: after reading which releases or galleries, and via which arrival paths. Quantify with the supplied numbers (ownShare = % of all outbound clicks that go to the consumer site). Finish with one concrete recommendation for strengthening the press-office → sales-funnel handover.',
      };
      label = `Exit journeys & outbound destinations — ${site?.name || siteId}`;
    } else if (cardKey === 'uploads') {
      pack = { stats: await uploadStats(siteId, rangeStr) };
      label = `Publishing volume YoY — ${site?.name || siteId}`;
    } else {
      return json(res, 400, { error: 'Unknown card.' });
    }
    const { aiSummary } = await import('./chat.js');
    return json(res, 200, { summary: await aiSummary(pack, label, 'card') });
  }
  // Per-section analyst narrative for the whole myNewspress dashboard —
  // ONE AI call returns bullets for every monitoring chart.
  const monNarr = /^\/api\/site\/([\w-]+)\/monitoring-narrative$/.exec(url.pathname);
  if (monNarr && req.method === 'POST') {
    const siteId = monNarr[1];
    const rangeStr = url.searchParams.get('range') || (await readBody(req))?.range || '28d';
    const ov = await monitoringOverview(siteId, rangeStr, url.searchParams.get('market') || null);
    if (!ov.enabled || ov.error) return json(res, 400, { error: ov.error || 'Monitoring is not enabled for this site.' });
    const site = loadSites().find((s) => s.id === siteId);
    const peakDays = [...(ov.daily || [])]
      .sort((a, b) => (Number(b.reach) || Number(b.articles) || 0) - (Number(a.reach) || Number(a.articles) || 0))
      .slice(0, 4);
    const { monitoringEvidence } = await import('./monitoring.js');
    const evidence = await monitoringEvidence(siteId, rangeStr, ov, url.searchParams.get('market') || null).catch(() => ({}));
    const pack = {
      period: rangeStr, totals: ov.totals, biggestDays: peakDays, evidence,
      sentiment: ov.sentiment, prevSentiment: ov.prevSentiment, sentimentByRegion: ov.sentimentByRegion,
      regions: ov.regions, topics: ov.topics, models: ov.models, mediaTypes: ov.mediaTypes,
      topPublications: ov.topPublications, topJournalists: ov.topJournalists,
      shareOfVoice: ov.shareOfVoice, topArticles: (ov.topArticles || []).slice(0, 10),
      releaseCoverage: ov.releaseCoverage, computedInsights: monitoringInsights(ov),
    };
    const { aiSummary } = await import('./chat.js');
    const raw = await aiSummary(pack, site?.name || siteId, 'mynews-sections');
    let sections = {};
    try {
      sections = JSON.parse(raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim());
    } catch {
      const m = /\{[\s\S]*\}/.exec(raw);
      if (m) { try { sections = JSON.parse(m[0]); } catch { /* give up */ } }
    }
    if (!sections || typeof sections !== 'object' || !Object.keys(sections).length) {
      return json(res, 502, { error: 'The AI narrative did not come back as valid JSON — try again.' });
    }
    return json(res, 200, { sections });
  }
  // AI-written KEY INSIGHT for the myNewspress monitoring section
  const monSum = /^\/api\/site\/([\w-]+)\/monitoring-summary$/.exec(url.pathname);
  if (monSum && req.method === 'POST') {
    const siteId = monSum[1];
    const rangeStr = url.searchParams.get('range') || (await readBody(req))?.range || '28d';
    const ov = await monitoringOverview(siteId, rangeStr);
    if (!ov.enabled) return json(res, 400, { error: 'Monitoring is not enabled for this site.' });
    const site = loadSites().find((s) => s.id === siteId);
    const pack = {
      period: rangeStr, totals: ov.totals, sentiment: ov.sentiment, prevSentiment: ov.prevSentiment,
      regions: ov.regions, topics: ov.topics, models: ov.models, mediaTypes: ov.mediaTypes,
      shareOfVoice: ov.shareOfVoice, topPublications: ov.topPublications, topJournalists: ov.topJournalists,
      topArticles: (ov.topArticles || []).slice(0, 10), releaseCoverage: ov.releaseCoverage,
      computedInsights: monitoringInsights(ov),
    };
    const { aiSummary } = await import('./chat.js');
    const summary = await aiSummary(pack, site?.name || siteId, 'monitoring');
    return json(res, 200, { summary });
  }

  // Legacy reports-DB overview (native stats, per-company on newspress markets)
  const lgMatch = /^\/api\/site\/([\w-]+)\/legacy$/.exec(url.pathname);
  if (lgMatch) {
    const lg = await legacyOverview(lgMatch[1], url.searchParams.get('range'), url.searchParams.get('company') || null, currentMarkets());
    // Platform emails aren't market-tagged — match campaign titles instead
    // ("MX Kona launch", "US: Palisade…"). Honest flag either way.
    const mkts = currentMarkets();
    if (mkts && Array.isArray(lg.emails) && lg.emails.length) {
      const matched = lg.emails.filter((e) => textMatchesMarkets(e.title || e.subject, mkts));
      lg.emailMarkets = { applied: matched.length > 0, matched: matched.length, total: lg.emails.length };
      if (matched.length) lg.emails = matched;
    }
    return json(res, 200, lg);
  }
  // Date-ordered platform release list (the content team's preferred view)
  const ldMatch = /^\/api\/site\/([\w-]+)\/legacy-releases$/.exec(url.pathname);
  if (ldMatch) {
    return json(res, 200, { rows: await releasesByDate(ldMatch[1], url.searchParams.get('range'), Number(url.searchParams.get('limit') || 150)) });
  }
  const txMatch = /^\/api\/site\/([\w-]+)\/legacy-taxon$/.exec(url.pathname);
  if (txMatch) {
    return json(res, 200, { rows: await releasesByTaxon(txMatch[1], url.searchParams.get('taxon'), url.searchParams.get('id'), url.searchParams.get('range')) });
  }
  const lgRel = /^\/api\/site\/([\w-]+)\/legacy-release$/.exec(url.pathname);
  if (lgRel) {
    return json(res, 200, { release: await legacyRelease(lgRel[1], url.searchParams.get('rid'), url.searchParams.get('range')) });
  }
  // Bulk release-title lookup (used to label GA paths with real titles)
  const rtMatch = /^\/api\/site\/([\w-]+)\/release-titles$/.exec(url.pathname);
  if (rtMatch) {
    const ids = String(url.searchParams.get('ids') || '').split(',').filter(Boolean).slice(0, 100);
    const { releaseTitles } = await import('./legacy.js');
    return json(res, 200, { titles: await releaseTitles(rtMatch[1], ids) });
  }
  const lgPerson = /^\/api\/site\/([\w-]+)\/legacy-person$/.exec(url.pathname);
  if (lgPerson) {
    return json(res, 200, { activity: await personActivity(lgPerson[1], url.searchParams.get('uid'), url.searchParams.get('email') || null) });
  }
  const lgPub = /^\/api\/site\/([\w-]+)\/legacy-publication$/.exec(url.pathname);
  if (lgPub) {
    return json(res, 200, { publication: await publicationDetail(lgPub[1], url.searchParams.get('pid'), url.searchParams.get('range')) });
  }
  const lgMedia = /^\/api\/site\/([\w-]+)\/legacy-media$/.exec(url.pathname);
  if (lgMedia) {
    return json(res, 200, { media: await mediaDetail(lgMedia[1], url.searchParams.get('mid')) });
  }
  // DB connection test (admins, from the Sites form)
  if (url.pathname === '/api/db-test' && req.method === 'POST') {
    if (!isAdmin(user)) return json(res, 403, { error: 'Admins only.' });
    return json(res, 200, await testConnection(await readBody(req)));
  }

  // GA4-backed page search (fallback when local filtering finds nothing)
  const searchMatch = /^\/api\/site\/([\w-]+)\/search-pages$/.exec(url.pathname);
  if (searchMatch) {
    const q = (url.searchParams.get('q') || '').trim();
    if (!q) return json(res, 200, { rows: [] });
    return json(res, 200, { q, rows: await searchPages(searchMatch[1], q, range.startDate, range.endDate) });
  }

  // Insights: computed facts + suggestions for a site or 'all'
  if (url.pathname === '/api/insights') {
    const scope = url.searchParams.get('scope') || 'all';
    return json(res, 200, await computeInsights(scope, url.searchParams.get('range'), allowedSites));
  }

  // AI executive summary
  if (url.pathname === '/api/ai-summary' && req.method === 'POST') {
    const body = await readBody(req);
    const scope = body?.scope || 'all';
    if (allowedSites && scope !== 'all' && !allowedSites.some((s) => s.id === scope))
      return json(res, 403, { error: 'You do not have access to this site.' });
    const rangeStr = body?.range;
    const rr = resolveRange(rangeStr);
    const prevR = previousRange(rr);
    const { insights } = await computeInsights(scope, rangeStr, allowedSites);
    let pack, label;
    if (scope === 'all') {
      const [cur, prv] = await Promise.all([
        portfolioTotals(rr.startDate, rr.endDate, allowedSites),
        portfolioTotals(prevR.startDate, prevR.endDate, allowedSites),
      ]);
      pack = { period: rr, totals: cur.totals, previous: prv.totals, perSite: cur.perSite, computedInsights: insights };
      label = 'the whole portfolio of client websites';
    } else {
      const site = loadSites().find((s) => s.id === scope);
      const [cur, prv, channels, pages] = await Promise.all([
        totals(scope, rr.startDate, rr.endDate),
        totals(scope, prevR.startDate, prevR.endDate),
        breakdown(scope, 'sessionDefaultChannelGroup', rr.startDate, rr.endDate, 10),
        breakdown(scope, 'pagePath', rr.startDate, rr.endDate, 15),
      ]);
      pack = { period: rr, totals: cur, previous: prv, channels, topPages: pages, computedInsights: insights };
      label = `the website "${site?.name || scope}"`;
    }
    const content = await aiSummary(pack, label);
    return json(res, 200, { content });
  }

  const siteMatch = /^\/api\/site\/([\w-]+)$/.exec(url.pathname);
  if (siteMatch) {
    const id = siteMatch[1];
    const [cur, prv, trend, channels, pages, devices, countries] = await Promise.all([
      totals(id, range.startDate, range.endDate),
      totals(id, prev.startDate, prev.endDate),
      timeseries(id, range.startDate, range.endDate),
      breakdown(id, 'sessionDefaultChannelGroup', range.startDate, range.endDate, 8),
      breakdown(id, 'pagePath', range.startDate, range.endDate, 10),
      breakdown(id, 'deviceCategory', range.startDate, range.endDate, 3),
      breakdown(id, 'country', range.startDate, range.endDate, 8),
    ]);
    return json(res, 200, { range, totals: cur, previous: prv, trend, channels, pages, devices, countries });
  }

  if (url.pathname === '/api/sparklines') {
    const sites = allowedSites || loadSites();
    const rows = await Promise.all(
      sites.map(async (s) => [s.id, (await timeseries(s.id, range.startDate, range.endDate)).map((r) => r.sessions)])
    );
    return json(res, 200, Object.fromEntries(rows));
  }

  // ── billing (superadmin) — plan, invoices, checkout, portal ──────────
  if (url.pathname.startsWith('/api/billing')) {
    if (!user || user.role !== 'superadmin') return json(res, 403, { error: 'Super admin only.' });
    const origin = `${req.headers['x-forwarded-proto'] || 'http'}://${req.headers.host}`;
    try {
      if (url.pathname === '/api/billing' && req.method === 'GET') {
        if (!stripeConfigured()) return json(res, 200, { configured: false });
        return json(res, 200, await billingStatus(user.email, user.name));
      }
      if (url.pathname === '/api/billing/checkout' && req.method === 'POST') {
        return json(res, 200, { url: await checkoutUrl(user.email, user.name, origin) });
      }
      if (url.pathname === '/api/billing/portal' && req.method === 'POST') {
        return json(res, 200, { url: await portalUrl(user.email, user.name, origin) });
      }
    } catch (err) {
      return json(res, 502, { error: String(err.message || err) });
    }
    return json(res, 404, { error: 'not found' });
  }

  if (url.pathname === '/api/chat' && req.method === 'POST') {
    const body = await readBody(req);
    const messages = Array.isArray(body?.messages) ? body.messages.slice(-24) : [];
    // Where the user actually IS in the UI — so "most popular release?" on a
    // site page never triggers a "which site?" counter-question.
    let chatCtx = null;
    if (body?.context && typeof body.context === 'object') {
      const ctxSite = body.context.siteId ? loadSites().find((s) => s.id === body.context.siteId) : null;
      chatCtx = {
        siteId: ctxSite ? ctxSite.id : null,
        siteName: ctxSite ? ctxSite.name : null,
        range: typeof body.context.range === 'string' ? body.context.range.slice(0, 20) : null,
        gm: parseMarketsParam(body.context.gm),
        gml: parseMarketsParam(body.context.gml).map(Number).filter(Boolean),
      };
    }
    // Streaming NDJSON: progress events while the AI works, then the reply.
    res.writeHead(200, { 'Content-Type': 'application/x-ndjson; charset=utf-8', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no' });
    let pct = 4;
    const send = (o) => { try { res.write(JSON.stringify(o) + '\n'); } catch { /* client gone */ } };
    send({ type: 'status', label: 'Starting…', pct });
    try {
      const lastQ = [...messages].reverse().find((m) => m.role === 'user');
      logActivity(user || 'client-link', 'ai-question', { siteId: chatCtx?.siteId, q: typeof lastQ?.content === 'string' ? lastQ.content.slice(0, 140) : '' }, req);
      // The chat's GA + platform tool calls inherit the dashboard's market
      // selection too (locale mode resolves GA countries like the wrapper).
      const chatStore = await resolveMarketStore(chatCtx?.siteId, chatCtx?.gml || [], chatCtx?.gm || []);
      const reply = await marketALS.run(chatStore, () =>
        chat(messages, allowedSites, (e) => {
          pct = Math.min(93, pct + (e.type === 'tool' ? 9 : 5));
          send({ ...e, pct });
        }, chatCtx));
      send({ type: 'done', pct: 100, reply });
    } catch (err) {
      logError('chat', err);
      send({ type: 'error', error: String(err.message || err) });
    }
    return res.end();
  }

  json(res, 404, { error: 'not found' });
}

// Generated reports — admin session required (the browser sends the
// session cookie automatically on link clicks).
function serveReport(req, res, url) {
  const u = getSessionUser(req);
  if (!isAdmin(u)) {
    res.writeHead(401, { 'Content-Type': 'text/plain' });
    return res.end('Sign in as an administrator to view reports.');
  }
  const file = path.basename(url.pathname.replace('/reports/', ''));
  const full = path.join(REPORTS_DIR, file);
  if (!full.startsWith(REPORTS_DIR) || !fs.existsSync(full)) {
    res.writeHead(404); return res.end();
  }
  const isPdf = full.endsWith('.pdf');
  const isPptx = full.endsWith('.pptx');
  res.writeHead(200, {
    'Content-Type': isPdf ? 'application/pdf'
      : isPptx ? 'application/vnd.openxmlformats-officedocument.presentationml.presentation'
      : 'text/html; charset=utf-8',
    ...(isPdf || isPptx ? { 'Content-Disposition': `attachment; filename="${file}"` } : {}),
  });
  fs.createReadStream(full).pipe(res);
}

function serveStatic(req, res, url) {
  let file = url.pathname === '/' ? '/index.html' : url.pathname;
  file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');
  let full = path.join(PUBLIC, file);
  if (!full.startsWith(PUBLIC)) { res.writeHead(403); return res.end(); }
  if (!fs.existsSync(full)) full = path.join(PUBLIC, 'index.html'); // SPA fallback
  const ext = path.extname(full);
  res.writeHead(200, { 'Content-Type': MIME[ext] || 'application/octet-stream' });
  fs.createReadStream(full).pipe(res);
}

// Build the market store for a locale selection: platform filters by locale
// ids; GA gets per-locale specs — country alone when unique, country+browser-
// language when two locales share a country (US English vs US Spanish).
async function resolveMarketStore(siteId, gmlIds, gmCountries) {
  const store = { countries: gmCountries || [], locales: gmlIds || [], gaFilters: [] };
  if (!gmlIds?.length || !siteId) return store;
  try {
    const locs = await platformLocales(siteId);
    const countryCount = new Map();
    for (const l of locs) if (l.country) countryCount.set(l.country, (countryCount.get(l.country) || 0) + 1);
    const chosen = gmlIds.map((id) => locs.find((l) => l.locale === id)).filter(Boolean);
    const resolved = [...new Set(chosen.map((l) => l.country).filter(Boolean))];
    if (resolved.length) store.countries = resolved;
    store.gaFilters = chosen.map((l) => {
      const shared = l.country && countryCount.get(l.country) > 1;
      const lp = langCode(l.language);
      const spec = {};
      if (l.country) spec.country = l.country;
      if (lp && (shared || !l.country)) spec.langPrefix = lp;
      return (spec.country || spec.langPrefix) ? spec : null;
    }).filter(Boolean);
  } catch { /* platform-only filtering */ }
  return store;
}

// Last line of defence: a stray rejection or exception anywhere (a driver
// timeout, a background refresh, a scheduler job) must NEVER kill the whole
// dashboard for everyone — log it loudly and keep serving.
process.on('unhandledRejection', (err) => {
  logError('unhandled-rejection', err);
  console.error('[unhandled rejection — server kept running]', err?.message || err);
});
process.on('uncaughtException', (err) => {
  logError('uncaught-exception', err);
  console.error('[uncaught exception — server kept running]', err?.stack || err);
});
// Mirror every console.error (scheduler jobs, DB failures…) into the
// superadmin error log.
hookConsoleErrors();
// Daily licence renewal check (silent; offline keeps working until the
// signed key's own expiry).
startLicenseRefresh();

const server = http.createServer((req, res) => {
  const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
  if (url.pathname.startsWith('/api/')) {
    // Analytics market filter — request-scoped, so every layer can apply it
    // without touching each route. ?gm=Country,Country filters GA by country;
    // ?gml=1,2 selects PLATFORM locales (the DB's own market column) — the
    // platform filters by locale directly and the GA country filter is
    // resolved from each locale's dominant reader country.
    (async () => {
      const gmCountries = parseMarketsParam(url.searchParams.get('gm'));
      const gmlIds = parseMarketsParam(url.searchParams.get('gml')).map(Number).filter(Boolean);
      const sid = /^\/api\/site\/([\w-]+)/.exec(url.pathname)?.[1];
      const store = await resolveMarketStore(sid, gmlIds, gmCountries);
      return marketALS.run(store, () => api(req, res, url));
    })().catch((err) => {
      logError('api', err, { path: url.pathname });
      console.error(err);
      if (!res.headersSent) json(res, 500, { error: String(err.message || err) });
    });
  } else if (url.pathname.startsWith('/reports/')) {
    serveReport(req, res, url);
  } else {
    serveStatic(req, res, url);
  }
});

// Make sure the myNewspress monitoring clients exist as sites — MG UK,
// Ferrari and KGM. Idempotent: only adds a client whose name isn't present
// yet (in any capitalisation); never touches existing sites. New entries
// start on demo data until GA4 / DBs are configured in Sites.
function ensureMyNewspressClients() {
  try {
    const cfgPath = path.join(__dirname, '..', 'sites.config.json');
    const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
    const names = cfg.sites.map((s) => String(s.name || '').toLowerCase());
    const ids = new Set(cfg.sites.map((s) => s.id));
    const wanted = [
      { id: 'mg-uk', name: 'MG UK' },
      { id: 'ferrari', name: 'Ferrari' },
      { id: 'kgm', name: 'KGM' },
    ];
    let added = 0;
    for (const w of wanted) {
      const match = names.some((n) => n === w.name.toLowerCase() || n.startsWith(w.name.toLowerCase() + ' ') || (w.id !== 'mg-uk' && n.includes(w.name.toLowerCase())));
      if (match || ids.has(w.id)) continue;
      cfg.sites.push({ id: w.id, name: w.name, url: '', propertyId: 'demo', monitoring: { demo: true } });
      added++;
    }
    if (added) {
      fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2));
      console.log(`myNewspress: added ${added} monitoring client site(s) (MG UK / Ferrari / KGM as needed).`);
    }
  } catch (e) { console.error('myNewspress client mapping skipped:', e.message); }
}

// Warm the market-label cache at boot (staggered, best-effort) so the
// picker is instant even on a fresh restart with a cold disk cache.
setTimeout(() => {
  (async () => {
    for (const s2 of loadSites()) {
      if (!s2.db?.host) continue;
      await platformLocales(s2.id).catch(() => {});
      await new Promise((r) => setTimeout(r, 1500)); // gentle on RDS
    }
  })().catch(() => {});
}, 8000);

const PORT = Number(process.env.PORT || 3000);
server.listen(PORT, () => {
  console.log(`LAV Pulse running on http://localhost:${PORT}`);
  if (process.env.DEMO_MODE === '1') console.log('Demo mode: serving generated sample data.');
  if (!process.env.OPENAI_API_KEY) console.log('Note: OPENAI_API_KEY not set — AI chat disabled until configured.');
  ensureMyNewspressClients();
  startScheduler();
});
