const express = require('express');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const mysql = require('mysql2/promise');
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { spawn, exec } = require('child_process');

// SINGLE SOURCE OF TRUTH for permission rules. Every endpoint that needs
// to know "what can this user see/do?" reads from this module. See the
// docstring at the top of the file for the full contract.
const perms = require('./lib/permissions.cjs');

const app = express();

// CORS configuration
// ── Lightweight .env loader ───────────────────────────────────────────────
// We don't depend on the `dotenv` package — instead we read `.env` once at
// boot. Loaded BEFORE any `process.env.*` access below so the URL/secret
// constants pick up the values. Existing env vars are NOT overridden, so
// shell exports still win (useful for one-off prod/staging swaps).
(() => {
  const fs = require('fs');
  const path = require('path');
  const candidates = [
    path.join(__dirname, '..', '.env'),     // project root
    path.join(__dirname, '.env'),           // server-local override
  ];
  for (const file of candidates) {
    if (!fs.existsSync(file)) continue;
    const txt = fs.readFileSync(file, 'utf8');
    for (const raw of txt.split(/\r?\n/)) {
      const line = raw.trim();
      if (!line || line.startsWith('#')) continue;
      const eq = line.indexOf('=');
      if (eq < 0) continue;
      const key = line.slice(0, eq).trim();
      let val = line.slice(eq + 1).trim();
      // Strip surrounding quotes (single or double).
      if ((val.startsWith('"') && val.endsWith('"')) ||
          (val.startsWith("'") && val.endsWith("'"))) {
        val = val.slice(1, -1);
      }
      if (process.env[key] == null) process.env[key] = val;
    }
  }
})();

const CORS_ORIGINS = process.env.CORS_ORIGINS
  ? process.env.CORS_ORIGINS.split(',').map(s => s.trim())
  : ['http://localhost:8080', 'http://localhost:3001'];
// Restrict CORS to the configured origins by default. CORS_ALLOW_ALL=1 in .env
// opens it up to all origins (useful for local dev with ad-hoc ports). The
// override is intentionally ignored in production for safety, regardless of
// what NODE_ENV is set to.
const corsAllowAll = process.env.CORS_ALLOW_ALL === '1' && process.env.NODE_ENV !== 'production';
app.use(cors({
  origin: corsAllowAll ? true : CORS_ORIGINS,
  credentials: true,
}));
if (corsAllowAll) {
  console.warn('CORS: CORS_ALLOW_ALL=1 — all origins permitted (dev only).');
}
app.use(express.json({ limit: '10mb' }));
app.use(cookieParser());
// CSRF guard: only takes effect for cookie-authed state-changing requests.
// Defined further down (csrfMiddleware) — registered here so it runs BEFORE
// any route handler.
app.use((req, res, next) => csrfMiddleware(req, res, next));

// ════════════════════════════════════════════
// EASYGDS CONFIG
// ════════════════════════════════════════════
const EASYGDS_IAM_URL = process.env.EASYGDS_IAM_URL || 'https://iam.stg-services.easygds.com/api';
const EASYGDS_CORE_URL = process.env.EASYGDS_CORE_URL || 'https://core.stg-services.easygds.com/api';
const EASYGDS_EMAIL = process.env.EASYGDS_EMAIL;
const EASYGDS_PASSWORD = process.env.EASYGDS_PASSWORD;
if (!EASYGDS_EMAIL || !EASYGDS_PASSWORD) {
  console.error('FATAL: EASYGDS_EMAIL and EASYGDS_PASSWORD environment variables are required.');
  console.error('Set them in your .env file using the service account credentials provided by the EasyGDS team.');
  process.exit(1);
}
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
  console.error('FATAL: JWT_SECRET environment variable is required and must be at least 32 characters long.');
  console.error('Generate one with: node -e "console.log(require(\'crypto\').randomBytes(48).toString(\'hex\'))"');
  process.exit(1);
}

// ── SMTP / IMAP credentials: PLAINTEXT storage ────────────────────────
// We previously encrypted these with AES-256-GCM keyed off
// SMTP_ENCRYPTION_KEY (or JWT_SECRET fallback). That added a sharp edge
// during DB transfer / backup-restore: a different key on the target
// instance left every credential unreadable, with confusing "535 auth"
// errors downstream. The operator's call: store plaintext and rely on
// MySQL access control + filesystem perms for protection at rest.
//
// `encryptSecret` is kept as a no-op pass-through so every existing call
// site stays correct. `decryptSecret` keeps working too — it transparently
// unwraps any leftover `enc:v1:` rows from the previous build until the
// boot-time migration rewrites them as plaintext (see the migration block
// further down). Once the migration completes once, the column contains
// only plaintext and decryptSecret is effectively a passthrough as well.
const _crypto_lib = require('crypto');
const SMTP_ENC_KEY = _crypto_lib.createHash('sha256')
  .update(String(process.env.SMTP_ENCRYPTION_KEY || JWT_SECRET))
  .digest();
const ENC_PREFIX = 'enc:v1:';

function encryptSecret(plaintext) {
  // No-op: store credentials in plaintext. Returning the input as-is
  // means every PATCH / INSERT writes the operator's value verbatim.
  return plaintext;
}

// Backwards-compat reader. Returns plaintext as-is. For legacy `enc:v1:`
// rows that the previous build wrote, attempts AES-256-GCM unwrap with
// the configured key. Falls back to returning the raw blob unchanged if
// the key doesn't match — the boot migration warns the operator about
// any such rows so they can re-enter the password through the UI.
function decryptSecret(value) {
  if (value == null || value === '') return value;
  if (typeof value !== 'string' || !value.startsWith(ENC_PREFIX)) return value;
  try {
    const buf = Buffer.from(value.slice(ENC_PREFIX.length), 'base64');
    const iv = buf.subarray(0, 12);
    const tag = buf.subarray(12, 28);
    const ct = buf.subarray(28);
    const decipher = _crypto_lib.createDecipheriv('aes-256-gcm', SMTP_ENC_KEY, iv);
    decipher.setAuthTag(tag);
    return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
  } catch (e) {
    console.warn('🔑 decryptSecret: legacy encrypted row could not be unwrapped — operator must re-enter:', e.message);
    return value; // fail-open: caller can detect by checking startsWith(ENC_PREFIX)
  }
}

// ── EasyGDS Token Manager ──
let easygdsToken = null;
let easygdsTokenExp = 0;
const easygdsUserTokens = new Map(); // per-user EasyGDS tokens
const MAX_USER_TOKENS = 1000;

// Periodic sweep of expired entries so the map doesn't grow indefinitely
// in long-running deploys. unref() lets the process exit if this is the
// only remaining work.
setInterval(() => {
  const nowSec = Date.now() / 1000;
  for (const [k, v] of easygdsUserTokens) {
    if (v?.exp == null || v.exp <= nowSec) easygdsUserTokens.delete(k);
  }
}, 10 * 60 * 1000).unref();

function setUserToken(email, tokenObj) {
  if (easygdsUserTokens.size >= MAX_USER_TOKENS) {
    const oldestKey = easygdsUserTokens.keys().next().value;
    if (oldestKey !== undefined) easygdsUserTokens.delete(oldestKey);
  }
  easygdsUserTokens.set(email, tokenObj);
}

async function getEasyGDSToken() {
  if (easygdsToken && Date.now() / 1000 < easygdsTokenExp - 300) return easygdsToken;
  try {
    const res = await fetch(`${EASYGDS_IAM_URL}/auth`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        scope_type: 'System', scope_id: '',
        email: EASYGDS_EMAIL, secret_key: EASYGDS_PASSWORD,
        flow_type: 'Basic'
      })
    });
    const data = await res.json();
    if (!data.access_token) throw new Error(data.message || 'Auth failed');
    easygdsToken = data.access_token;
    easygdsTokenExp = data.exp;
    console.log('✅ EasyGDS token refreshed, expires:', new Date(data.exp * 1000).toISOString());
    return easygdsToken;
  } catch (e) {
    console.error('❌ EasyGDS auth error:', e.message);
    throw e;
  }
}

// ── Apps-list cache ────────────────────────────────────────────────────
// `/apps/list?per_page=200` is the single biggest contributor to login
// latency: every App-scope login calls it to resolve the URL slug ("abc1")
// to the canonical app UUID, and that's a 2.2–2.8s round-trip on staging
// (network + EasyGDS). The list itself rarely changes (a new app is a
// monthly event), so caching it for 10 minutes saves ~2.5s on every login
// without changing any login/permission behavior — same data, just kept
// in memory instead of re-fetched.
//
// Race-safe: a single in-flight Promise is shared across concurrent
// callers, so 50 simultaneous logins trigger one upstream fetch, not 50.
// On error, the cache stays empty and the next call retries.
const APPS_LIST_TTL_MS = 10 * 60 * 1000;
let _appsListCache = null;        // { ts, value }
let _appsListInflight = null;     // shared Promise during a fetch
async function getAppsList({ force = false, perPage = 200 } = {}) {
  if (!force && _appsListCache && Date.now() - _appsListCache.ts < APPS_LIST_TTL_MS) {
    return _appsListCache.value;
  }
  if (_appsListInflight) return _appsListInflight;
  _appsListInflight = (async () => {
    try {
      const sysToken = await getEasyGDSToken();
      const res = await fetch(`${EASYGDS_CORE_URL}/apps/list?per_page=${perPage}`, {
        headers: { Authorization: `Bearer ${sysToken}` },
      });
      if (!res.ok) throw new Error(`apps/list ${res.status}`);
      const data = await res.json();
      const list = data?.result || [];
      _appsListCache = { ts: Date.now(), value: list };
      return list;
    } finally {
      _appsListInflight = null;
    }
  })();
  return _appsListInflight;
}
// Manual invalidation hook — call after admin actions that mutate apps.
function invalidateAppsListCache() { _appsListCache = null; }

// ── Enabled-apps cache ────────────────────────────────────────────────
// `app_settings.is_enabled` is the system-wide on/off switch for "does Ops
// handle this app?". When OFF, the app is filtered out of every list/dash
// in the platform — but the row stays in app_settings so the admin can
// re-enable it without losing BCC config etc.
//
// We cache the disabled set (smaller than the enabled set in practice)
// and the "is the table empty / migrating" flag. TTL is short (60s) so
// admin toggles propagate quickly without a server restart.
const ENABLED_APPS_TTL_MS = 60 * 1000;
let _disabledAppsCache = null; // { ts, set: Set<appId> }
async function getDisabledAppIds() {
  if (_disabledAppsCache && Date.now() - _disabledAppsCache.ts < ENABLED_APPS_TTL_MS) {
    return _disabledAppsCache.set;
  }
  let set = new Set();
  try {
    const [rows] = await pool.query(
      'SELECT app_id FROM app_settings WHERE is_enabled = 0'
    );
    set = new Set(rows.map(r => r.app_id));
  } catch (e) {
    // Column may not exist yet on a fresh install — treat all apps as enabled.
    if (!/Unknown column/i.test(e.message)) console.warn('disabled-apps query failed:', e.message);
  }
  _disabledAppsCache = { ts: Date.now(), set };
  return set;
}
function invalidateEnabledAppsCache() { _disabledAppsCache = null; }

// ── EasyGDS health tracker — populated by easygdsRequest() ──
const easygdsHealth = {
  last_call_at: null,
  last_call_endpoint: null,
  last_call_status: null,
  last_call_success: null,
  last_error: null,
  total_calls: 0,
  total_errors: 0,
  // Circuit-breaker fields. Trips OPEN after 5 consecutive failures, sits
  // there for `cooldown_ms`, then enters HALF_OPEN to let one probe through.
  circuit: 'closed',          // closed | open | half_open
  consecutive_failures: 0,
  open_since: null,
  cooldown_ms: 30 * 1000,     // 30s after first trip; doubles each re-trip up to 5min
  last_open_duration_ms: 0,
  last_outage_id: null,       // FK into system_health for the current open window
  force_readonly: false,      // admin override (Settings → System)
};

// Custom error so handlers can distinguish "circuit open" from real upstream
// failures and return a friendlier 503.
class UpstreamUnavailableError extends Error {
  constructor(message) { super(message); this.name = 'UpstreamUnavailableError'; this.status = 503; }
}

// Open / close the breaker. We track outages in system_health so we have a
// permanent log + the recovery flow can compute downtime.
async function _circuitOpen(reason) {
  if (easygdsHealth.circuit === 'open') return; // already tripped
  easygdsHealth.circuit = 'open';
  easygdsHealth.open_since = Date.now();
  // Exponential-ish backoff: 30s → 60s → 2m → 5m cap.
  const next = easygdsHealth.last_open_duration_ms
    ? Math.min(easygdsHealth.last_open_duration_ms * 2, 5 * 60 * 1000)
    : 30 * 1000;
  easygdsHealth.cooldown_ms = next;
  console.warn(`⚠️  EasyGDS circuit OPEN — ${reason} (cooldown ${next}ms)`);
  try {
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO system_health (id, service, status, message)
       VALUES (?, 'easygds_core', 'down', ?)`,
      [id, String(reason).slice(0, 1000)]
    );
    easygdsHealth.last_outage_id = id;
  } catch {}
}
async function _circuitClose() {
  if (easygdsHealth.circuit === 'closed' && !easygdsHealth.last_outage_id) return;
  const wasOpenMs = easygdsHealth.open_since ? Date.now() - easygdsHealth.open_since : 0;
  easygdsHealth.circuit = 'closed';
  easygdsHealth.consecutive_failures = 0;
  easygdsHealth.last_open_duration_ms = wasOpenMs;
  easygdsHealth.open_since = null;
  console.log(`✅ EasyGDS circuit CLOSED (was open ${wasOpenMs}ms)`);
  if (easygdsHealth.last_outage_id) {
    try {
      await pool.query(
        `UPDATE system_health SET status='ok', resolved_at=UTC_TIMESTAMP(), duration_ms=?
         WHERE id = ?`,
        [wasOpenMs, easygdsHealth.last_outage_id]
      );
    } catch {}
    easygdsHealth.last_outage_id = null;
    // Trigger an async catch-up sync of every active app so the cache picks
    // up bookings created during the outage. Late-bound to avoid an init
    // ordering issue (kickCatchUpSync is defined further down the file).
    setImmediate(() => { try { if (typeof kickCatchUpSync === 'function') kickCatchUpSync(); } catch {} });
  }
}

// ── EasyGDS Proxy Helper ──
// Wraps every upstream call with a 60s per-attempt AbortSignal and 3-attempt
// retry on network-level failures (terminated/EPIPE/ECONNRESET/AbortError).
// HTTP 4xx/5xx responses are NOT retried — those are deliberate answers from
// upstream. Circuit-breaker stats only tally the FINAL outcome of the retry
// loop, so a successful retry doesn't pollute consecutive_failures.
async function easygdsRequest(endpoint, options = {}) {
  // Force-readonly admin override OR open circuit → fail fast.
  if (easygdsHealth.force_readonly) {
    throw new UpstreamUnavailableError('Force-readonly mode is on — upstream calls disabled.');
  }
  if (easygdsHealth.circuit === 'open') {
    const elapsed = Date.now() - (easygdsHealth.open_since || 0);
    if (elapsed < easygdsHealth.cooldown_ms) {
      throw new UpstreamUnavailableError('EasyGDS upstream is currently unreachable. Retrying shortly.');
    }
    // Cooldown elapsed — let one probe through.
    easygdsHealth.circuit = 'half_open';
  }
  const token = await getEasyGDSToken();
  const url = `${EASYGDS_CORE_URL}${endpoint}`;
  easygdsHealth.total_calls += 1;
  easygdsHealth.last_call_at = new Date().toISOString();
  easygdsHealth.last_call_endpoint = endpoint;

  const TIMEOUT_MS = 60_000;
  const MAX_ATTEMPTS = 5;          // bumped from 3 — covers the ~10% of apps
                                   // that flake out for an extended burst.
  const MAX_BACKOFF_MS = 15_000;   // cap so total wait is bounded ~30s.
  let res = null;
  let data = null;
  let lastNetErr = null;

  for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
    const ctrl = new AbortController();
    const timer = setTimeout(() => ctrl.abort(new Error(`request timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS);
    try {
      res = await fetch(url, {
        ...options,
        headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers },
        signal: ctrl.signal,
      });
      data = await res.json();
      clearTimeout(timer);
      lastNetErr = null;
      break; // got a response (success or HTTP error) — exit retry loop
    } catch (e) {
      clearTimeout(timer);
      lastNetErr = e;
      if (attempt < MAX_ATTEMPTS) {
        // Exponential backoff capped at 15s: 1s → 2s → 4s → 8s.
        const backoff = Math.min(MAX_BACKOFF_MS, 1000 * Math.pow(2, attempt - 1));
        console.warn(`⚠️  ${endpoint} attempt ${attempt}/${MAX_ATTEMPTS} failed: ${e.message} — retrying in ${backoff}ms`);
        await new Promise(r => setTimeout(r, backoff));
      }
    }
  }

  if (lastNetErr) {
    // All retries exhausted — record and trip circuit if needed.
    easygdsHealth.last_call_success = false;
    easygdsHealth.total_errors += 1;
    easygdsHealth.last_error = lastNetErr.message;
    if (!(lastNetErr instanceof UpstreamUnavailableError)) {
      easygdsHealth.consecutive_failures += 1;
      if (easygdsHealth.consecutive_failures >= 5) await _circuitOpen(`${lastNetErr.message} (${endpoint})`);
      if (easygdsHealth.circuit === 'half_open') await _circuitOpen(`probe failed: ${lastNetErr.message}`);
    }
    throw lastNetErr;
  }

  easygdsHealth.last_call_status = res.status;
  if (res.status >= 400) {
    easygdsHealth.last_call_success = false;
    easygdsHealth.total_errors += 1;
    const msg = typeof data === 'string' ? data : data.message || JSON.stringify(data);
    easygdsHealth.last_error = msg;
    // 4xx is a "real" upstream answer — circuit stays closed. Only 5xx trips it.
    if (res.status >= 500) {
      easygdsHealth.consecutive_failures += 1;
      if (easygdsHealth.consecutive_failures >= 5) await _circuitOpen(`5xx from ${endpoint}: ${msg}`);
    }
    throw new Error(msg);
  }
  // Success — reset failure counter and close circuit if we were probing.
  easygdsHealth.last_call_success = true;
  easygdsHealth.last_error = null;
  easygdsHealth.consecutive_failures = 0;
  if (easygdsHealth.circuit === 'half_open') await _circuitClose();
  return data;
}

// ── Platform Auth Middleware ──
// Reads the JWT from the platform_token httpOnly cookie OR the Authorization
// header (legacy clients). Cookie takes precedence. For local users the JWT
// also carries `tv` (token_version); we re-check it against platform_users
// to enforce instant revocation — bumping the row's token_version on logout
// or deactivation invalidates every previously-issued JWT for that user.
//
// The DB row is cached in-process for ~30 s per user so the verification
// doesn't add a query on every request. The frontend hammers /api/auth/me
// + /api/easygds/* on every page load; without this cache that's two extra
// MySQL round-trips on the hot path. Cache is busted explicitly when we
// disable a user / change their password / they log out.
const userRowCache = new Map(); // id → { row, expires }
const USER_ROW_CACHE_TTL_MS = 30 * 1000;
function invalidateUserRowCache(userId) { if (userId) userRowCache.delete(userId); }
async function loadLiveUserRow(userId) {
  const cached = userRowCache.get(userId);
  if (cached && cached.expires > Date.now()) return cached.row;
  const [rows] = await pool.query(
    'SELECT id, token_version, is_active, role, allowed_app_ids FROM platform_users WHERE id = ? LIMIT 1',
    [userId]
  );
  const row = rows.length > 0 ? rows[0] : null;
  userRowCache.set(userId, { row, expires: Date.now() + USER_ROW_CACHE_TTL_MS });
  return row;
}

async function authMiddleware(req, res, next) {
  let token = null;
  if (req.cookies && req.cookies.platform_token) {
    token = req.cookies.platform_token;
    req._authVia = 'cookie';
  } else {
    const authHeader = req.headers.authorization;
    if (authHeader && authHeader.startsWith('Bearer ')) {
      token = authHeader.split(' ')[1];
      req._authVia = 'header';
    }
  }
  if (!token) return err(res, 'Unauthorized', 401);

  let decoded;
  try {
    decoded = jwt.verify(token, JWT_SECRET);
  } catch (e) {
    return err(res, 'Invalid token', 401);
  }

  // Local users carry `tv`. If the DB row's token_version has advanced past
  // the value baked into the JWT, the session was revoked — reject. We
  // intentionally do NOT block when the row is missing (e.g. an IAM-only
  // user accidentally has the same id), only when there's a clear mismatch.
  if (decoded && Number.isFinite(decoded.tv)) {
    try {
      const row = await loadLiveUserRow(decoded.id);
      if (row) {
        if (!row.is_active) return err(res, 'Account is deactivated', 401);
        if ((row.token_version | 0) !== (decoded.tv | 0)) {
          return err(res, 'Session revoked. Please sign in again.', 401);
        }
        // Pass the live row downstream so /api/auth/me doesn't re-query.
        req._liveUserRow = row;
      }
    } catch { /* DB hiccup — fall through, JWT signature already validated */ }
  }

  // Back-fill is_ops_team_member for JWTs minted before the flag was
  // baked in. Without this, an OPS team member who's still on a pre-fix
  // session (12h max) would silently fall back to the strict admin-only
  // gate in isOpsReadAccess. One DB query per request for the duration
  // it takes the JWT to expire — bounded and short-lived.
  if (decoded && decoded.is_ops_team_member === undefined && decoded.id) {
    try {
      decoded.is_ops_team_member = await checkOpsTeamMembership(decoded.id);
    } catch { decoded.is_ops_team_member = false; }
  }

  req.platformUser = decoded;
  next();
}

// CSRF protection (double-submit cookie). For state-changing requests
// authenticated via cookie, the X-CSRF-Token header MUST equal the
// csrf_token cookie value. Skipped for header-based auth (no cookie ⇒ no
// CSRF risk) and for safe methods (GET/HEAD/OPTIONS).
function csrfMiddleware(req, res, next) {
  if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
  // Only enforce CSRF when the caller is using cookie auth. Bearer-token
  // callers can't be CSRF'd because attackers can't forge the header.
  if (!req.cookies || !req.cookies.platform_token) return next();
  const cookieCsrf = req.cookies.csrf_token;
  const headerCsrf = req.get('x-csrf-token');
  if (!cookieCsrf || !headerCsrf || cookieCsrf !== headerCsrf) {
    return err(res, 'CSRF token mismatch', 403);
  }
  next();
}

// Admin-only endpoint guard. Two conditions, BOTH required:
//   1. role = admin or manager
//   2. login_context ≠ 'agent'   (an agent-context user is scoped to their
//      single agent for this session even if their org role is admin —
//      cross-tenant admin endpoints must NOT be reachable from an agent
//      portal session)
// See server/lib/permissions.cjs for the rule definition.
function adminMiddleware(req, res, next) {
  const u = req.platformUser;
  if (!u || !perms.isAdminRole(u) || perms.isAgentContext(u)) {
    return err(res, 'Admin access required', 403);
  }
  next();
}

// File upload setup
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });

// Whitelist of MIME types accepted by /rest/v1/upload. SVG is intentionally
// excluded — it can contain inline JavaScript that executes when rendered
// in a browser. If SVG support is needed later, sanitize first (e.g. DOMPurify).
const ALLOWED_UPLOAD_MIMES = new Set([
  'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp',
  'application/pdf',
  'application/msword',
  'application/vnd.ms-excel',
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
  'application/vnd.openxmlformats-officedocument.presentationml.presentation',
  'text/csv', 'text/plain',
  'application/json', 'application/zip',
]);
const upload = multer({
  dest: uploadDir,
  limits: { fileSize: 10 * 1024 * 1024, files: 1 },
  fileFilter: (req, file, cb) => {
    if (ALLOWED_UPLOAD_MIMES.has(file.mimetype)) return cb(null, true);
    cb(new Error(`Unsupported file type: ${file.mimetype}`));
  },
});

// Force browsers to download uploaded files instead of rendering them.
// Without this, an HTML or SVG file uploaded by an attacker could execute
// JavaScript in the user's browser context and steal session tokens.
app.use('/uploads', express.static(uploadDir, {
  setHeaders: (res) => res.setHeader('Content-Disposition', 'attachment'),
}));

// MySQL connection pool.
// `timezone: 'Z'` makes mysql2 treat DATETIME values as UTC on both reads
// and writes. Without this, Node assumes local TZ when constructing Dates
// from DATETIME strings while we INSERT values as UTC — the mismatch shows
// up as a freshly-created 2-hour deadline rendering as "0m overdue" when
// MySQL and Node are in different timezones (or when serialized via
// toISOString in the response).
const pool = mysql.createPool({
  host: process.env.DB_HOST || 'localhost',
  user: process.env.DB_USER || 'root',
  password: process.env.DB_PASSWORD || '',
  database: process.env.DB_NAME || 'tripovo_support',
  port: parseInt(process.env.DB_PORT || '3306'),
  waitForConnections: true,
  connectionLimit: 10,
  timezone: 'Z',
});

// ── Helpers ──
const ok = (res, data) => res.json({ data, error: null });
const err = (res, msg, status = 400) => res.status(status).json({ data: null, error: { message: msg } });

const maskEmail = (email) => {
  if (!email || typeof email !== 'string') return '<no-email>';
  const at = email.indexOf('@');
  if (at < 2) return '***@' + (email.slice(at + 1) || '?');
  return email[0] + '***' + email.slice(at);
};

// httpOnly cookie keeps the JWT out of reach of any XSS in the SPA. Paired
// with a non-httpOnly csrf_token cookie that the frontend echoes back as the
// X-CSRF-Token header on state-changing requests; mismatched values get a 403
// from the csrfMiddleware below. `Secure` is gated on NODE_ENV so local dev
// (HTTP) still works — production deploys MUST set NODE_ENV=production.
const COOKIE_MAX_AGE_MS = 12 * 60 * 60 * 1000;
function setAuthCookies(res, jwtToken, csrfToken) {
  const isProd = process.env.NODE_ENV === 'production';
  const csrf = csrfToken || require('crypto').randomBytes(24).toString('hex');
  const baseOpts = {
    httpOnly: true,
    secure: isProd,
    sameSite: 'lax',
    maxAge: COOKIE_MAX_AGE_MS,
    path: '/',
  };
  res.cookie('platform_token', jwtToken, baseOpts);
  res.cookie('csrf_token', csrf, { ...baseOpts, httpOnly: false });
}
function clearAuthCookies(res) {
  res.clearCookie('platform_token', { path: '/' });
  res.clearCookie('csrf_token', { path: '/' });
}

const toMysqlDatetime = (iso) => {
  if (!iso) return null;
  try { const d = new Date(iso); return isNaN(d.getTime()) ? null : d.toISOString().slice(0, 19).replace('T', ' '); }
  catch { return null; }
};

const parseJsonCols = (r) => {
  if (!r) return r;
  const jsonKeys = ['passenger_details', 'booking_details', 'field_changes', 'attached_documents', 'attachments', 'metadata'];
  const out = { ...r };
  for (const k of jsonKeys) {
    if (out[k] && typeof out[k] === 'string') { try { out[k] = JSON.parse(out[k]); } catch {} }
  }
  return out;
};

// ── Status Lifecycle ──
const VALID_STATUSES = [
  'open', 'assigned', 'in_progress', 'pending_supplier', 'escalated',
  'resolved', 'closed', 'rejected',
  // New triage statuses (see STATUS_TRANSITIONS below)
  'pending_app_review',
  'pending_app_response_review',
];

const STATUS_TRANSITIONS = {
  // Pre-forward triage by App user.
  // `in_progress` is allowed so the App user can "Handle" the ticket and
  // start working on it (triage_state will be set to 'handled_by_app').
  pending_app_review: ['open', 'in_progress', 'rejected', 'resolved', 'closed'],

  // Normal Ops flow (unchanged, but terminal targets may be intercepted for
  // agency-created tickets — see PATCH handler).
  open:              ['assigned', 'rejected', 'closed', 'pending_app_response_review'],
  assigned:          ['in_progress', 'rejected', 'closed', 'pending_app_response_review'],
  in_progress:       ['pending_supplier', 'escalated', 'resolved', 'rejected', 'pending_app_response_review'],
  pending_supplier:  ['in_progress', 'resolved', 'rejected', 'pending_app_response_review'],
  escalated:         ['in_progress', 'resolved', 'rejected', 'pending_app_response_review'],

  // Post-forward review by App user (Ops's proposed status/message sit in
  // ops_proposed_* until released here)
  pending_app_response_review: ['resolved', 'rejected', 'in_progress', 'open'],

  // Terminal + reopen/re-submit
  resolved: ['closed', 'in_progress'], // reopen
  closed:   ['in_progress'], // reopen
  // Creator re-submit from rejected adds 'pending_app_review' (gated at runtime
  // by triage_state + app_review_resubmit_count).
  rejected: ['in_progress', 'pending_app_review'],
};

const TERMINAL_STATUSES = ['resolved', 'closed', 'rejected'];
const REOPEN_STATUSES = ['resolved', 'closed', 'rejected'];

// Statuses that mean "Ops is actively working on this" — used to decide
// whether an incoming Ops action is "customer-facing" (and should be
// intercepted for App-user review) vs purely internal.
const OPS_WORKING_STATUSES = ['open', 'assigned', 'in_progress', 'pending_supplier', 'escalated'];

// DEADLINE POLICY (changed 2026-05-12):
//   The 2-hour SLA window is set ONCE when the ticket is created (see
//   the INSERTs in /rest/v1/support_requests and the internal create
//   helper, both of which stamp `now + 2h`). After that the deadline is
//   IMMUTABLE except via explicit user action — the "Extend" button
//   (useExtendDeadline → PATCH deadline + deadline_extended), the new
//   "Set Deadline" datetime picker (Ops + App users), and any direct
//   admin PATCH that includes a `deadline` field.
//
//   Previously this function reset the deadline to `now + 2h` on every
//   state-changing action (message sent, note added, status transition,
//   approval move, …). Operators reported that auto-refresh made the
//   timer essentially never expire, which defeated the SLA reporting and
//   surprised App users whose deadlines reset behind their backs.
//
//   The function is kept as a no-op so the 20+ call sites scattered
//   through this file don't all need to be touched. Future logic that
//   genuinely needs to reset the deadline should call a new explicit
//   helper (e.g. `setDeadlineHours(id, hours, reason)`), not this one.
async function refreshDeadline(/* requestId */) {
  /* intentionally no-op — see comment above */
}

// Classify the creator's login context at submit time so later handlers know
// whether a ticket needs the post-response review loop (only for 'agent'
// creators). Stored in created_by_context column.
function classifyCreatorContext(user) {
  const ctx = user?.login_context;
  if (ctx === 'agent') return 'agent';
  if (ctx === 'app')   return 'app';
  if (ctx === 'system') return 'system';
  return null;
}

// Scrub ops_proposed_message from responses that shouldn't leak it to the
// original agency creator (they must not see Ops's draft reply before the
// App user approves it). Also scrub the confidential supplier/provider
// identity — that's for Ops's eyes only, regardless of App/agency view.
function redactOpsProposal(row, viewerCtx) {
  if (!row) return row;
  const isOps = viewerCtx === 'system';
  const clone = { ...row };
  // Supplier fields: confidential to everyone except Ops.
  if (!isOps) {
    delete clone.supplier_name;
    delete clone.supplier_id;
    // booking_details may carry supplier_* via nested fields — strip them
    // from the JSON clone without mutating the cached row.
    if (clone.booking_details && typeof clone.booking_details === 'object') {
      const bd = { ...clone.booking_details };
      delete bd.supplier_name;
      delete bd.supplier_code;
      delete bd.supplier_source;
      delete bd.supplier_info;
      clone.booking_details = bd;
    }
  }
  // Ops-proposed draft reply: hidden from the agency creator until the App
  // user approves.
  if (viewerCtx !== 'app' && viewerCtx !== 'system') {
    delete clone.ops_proposed_message;
    delete clone.ops_proposed_status;
    delete clone.ops_proposed_at;
    delete clone.ops_proposed_by;
  }
  return clone;
}

// Friendly display name for an authenticated user. Used as the actor
// label in every support_activity_log row — earlier callers passed
// `user.email` here, which surfaced raw emails like
// "beshoy@tripovo.com" in the Activity History UI alongside proper
// names like "Beshoy R" from helpers that already concatenated
// first/last. Standardise on first+last, falling back to email then a
// generic placeholder so the column is never null.
function userDisplayName(user) {
  if (!user) return 'System';
  const full = `${user.first_name || ''} ${user.last_name || ''}`.trim();
  return full || user.email || 'User';
}

// Generate the next sequential request_number for support_requests.
// Format: SR000001, SR000002, ... — 6-digit zero-padded, prefix SR.
// Reads MAX(numeric suffix) from existing rows; UNIQUE constraint on
// the column catches concurrent races, the retry advances past the
// collision. Low-volume console — 3 attempts is plenty.
async function nextSupportRequestNumber(connOrPool = pool, offset = 0) {
  const [[r]] = await connOrPool.query(
    `SELECT MAX(CAST(SUBSTRING(request_number, 3) AS UNSIGNED)) AS max_seq
       FROM support_requests
      WHERE request_number REGEXP '^SR[0-9]+$'`
  );
  const next = (Number(r?.max_seq) || 0) + 1 + offset;
  return `SR${String(next).padStart(6, '0')}`;
}

// ── Activity Log Helper ──
async function logActivity(requestId, actionType, oldValue, newValue, performedBy, performedByName, metadata = null) {
  try {
    await pool.query(
      `INSERT INTO support_activity_log (id, request_id, action_type, old_value, new_value, performed_by, performed_by_name, metadata) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      [crypto.randomUUID(), requestId, actionType, oldValue, newValue, performedBy || 'system', performedByName || 'System', metadata ? JSON.stringify(metadata) : null]
    );
  } catch (e) { console.error('Activity log error:', e.message); }
}

// ── Notifications helpers ──────────────────────────────────────────────────
// createNotification fans a single event out to one or more target users.
// All event emitters call this; the bell + toasts read from it.
// Never throws — failing to notify should never block the primary action.
async function createNotification({
  user_ids, user_id, kind, title, body = null, priority = 'normal',
  link_path = null, actor_id = null, actor_name = null,
  entity_type = null, entity_id = null, metadata = null,
}) {
  try {
    const targets = (Array.isArray(user_ids) ? user_ids : [user_id]).filter(Boolean);
    if (targets.length === 0) return 0;
    // de-dupe and exclude the actor themselves from their own notification
    const deduped = [...new Set(targets)].filter(uid => uid !== actor_id);
    if (deduped.length === 0) return 0;

    // Generate ids up front so we can reference them in the delivery log
    // without a second round-trip for the inserted rows.
    const perRow = deduped.map(uid => ({
      id: crypto.randomUUID(), user_id: uid,
      kind, title, body, priority, link_path,
      actor_id, actor_name, entity_type, entity_id, metadata,
    }));
    const rows = perRow.map(r => [
      r.id, r.user_id, r.kind, r.title, r.body, r.priority,
      r.link_path, r.actor_id, r.actor_name, r.entity_type, r.entity_id,
      r.metadata ? JSON.stringify(r.metadata) : null,
    ]);
    await pool.query(
      `INSERT INTO notifications
         (id, user_id, kind, title, body, priority, link_path,
          actor_id, actor_name, entity_type, entity_id, metadata)
       VALUES ?`,
      [rows]
    );

    // Email fanout — only for support-request events for now. Runs
    // asynchronously via setImmediate so the HTTP request returns without
    // blocking on SMTP. Errors never propagate.
    if (EMAIL_WORTHY_KINDS.has(kind) && entity_type === 'support_request' && entity_id) {
      setImmediate(async () => {
        try {
          const [[request]] = await pool.query(
            'SELECT id, app_id, company_name, booking_code, request_number FROM support_requests WHERE id = ?',
            [entity_id]
          );
          if (!request) return;
          for (const n of perRow) {
            await sendNotificationEmail({ notification: n, recipientUserId: n.user_id, request });
          }
        } catch (e) { console.error('email fanout error:', e.message); }
      });
    }

    return deduped.length;
  } catch (e) { console.error('createNotification error:', e.message); return 0; }
}

// Resolve the set of platform_users ids that should be notified about an
// event on a support request, by audience tag.
//   'ops'       → every Ops user (login_context='system', is_active)
//   'app'       → every App user whose allowed_app_ids contains the request's app_id
//   'creator'   → the ticket creator
//   'assignee'  → the currently-assigned Ops/App agent
// Audiences combine freely (['ops','creator']) and are de-duped at insert.
async function resolveNotifyRecipients(request, audiences) {
  const ids = new Set();
  const want = new Set(Array.isArray(audiences) ? audiences : [audiences]);

  if (want.has('creator') && request?.created_by) ids.add(request.created_by);
  if (want.has('assignee') && request?.assigned_agent) ids.add(request.assigned_agent);

  if (want.has('ops')) {
    try {
      const [rows] = await pool.query(
        `SELECT id FROM platform_users WHERE login_context = 'system' AND is_active = 1`
      );
      for (const r of rows) ids.add(r.id);
    } catch (e) { /* platform_users may be missing columns in dev */ }
  }

  if (want.has('app') && request?.app_id) {
    try {
      const [rows] = await pool.query(
        `SELECT id, allowed_app_ids FROM platform_users
         WHERE login_context = 'app' AND is_active = 1`
      );
      for (const r of rows) {
        let list = r.allowed_app_ids;
        if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = []; } }
        if (Array.isArray(list) && list.includes(request.app_id)) ids.add(r.id);
      }
    } catch (e) { /* */ }
  }

  return [...ids];
}

// Shorthand: notify for a support-request event. Builds the link_path and
// forwards to createNotification with sensible defaults.
async function notifyForRequest({
  request, audiences, kind, title, body = null, priority = 'normal',
  actor, metadata = null,
}) {
  if (!request?.id) return 0;
  const targets = await resolveNotifyRecipients(request, audiences);
  const actorName = actor
    ? ((`${actor.first_name || ''} ${actor.last_name || ''}`.trim()) || actor.email || null)
    : null;
  return createNotification({
    user_ids: targets,
    kind, title, body, priority,
    link_path: `/support/${request.id}`,
    actor_id: actor?.id || null,
    actor_name: actorName,
    entity_type: 'support_request',
    entity_id: request.id,
    metadata,
  });
}

// ── Ops team rule engine ───────────────────────────────────────────────────
// V1 intentionally keeps the rule model simple and predictable:
//   - conditions are AND-only
//   - team rules affect members only
//   - direct user rules override team rules for the same action
// Actions the Ops rule engine knows how to gate. To add a new one:
//   1. Add it here.
//   2. Mirror it in src/integrations/easygds-api.ts OpsRuleAction union.
//   3. Add the human label to the Rule Builder action dropdown.
//   4. Call evaluateOpsRules(user, '<action>', subject) at the enforcement site.
//
// support_ticket.auto_claim: when a member of an allowed team opens a
// ticket's detail page (GET /rest/v1/support_requests/:id), and the
// ticket is currently unassigned and non-terminal, auto-assign it to
// them. Default is no-claim — only users explicitly matched by an
// `allow` rule trigger this behaviour. Stops two ops members from
// stepping on each other's work, while letting a "Frontline Ops" team
// (for example) own a triage queue.
const OPS_RULE_ACTIONS = new Set(['bookings.view', 'support_ticket.create', 'support_ticket.auto_claim']);
const OPS_RULE_EFFECTS = new Set(['allow', 'deny', 'require_approval']);

function parseMaybeJson(value, fallback = null) {
  if (value == null || value === '') return fallback;
  if (typeof value === 'object') return value;
  if (typeof value !== 'string') return fallback;
  try { return JSON.parse(value); } catch { return fallback; }
}

function coerceStringArray(value) {
  if (Array.isArray(value)) return value.map(v => String(v).trim()).filter(Boolean);
  if (typeof value === 'string') return value.split(',').map(v => v.trim()).filter(Boolean);
  return [];
}

function normalizeConditionList(value) {
  return coerceStringArray(value).map(v => v.toLowerCase());
}

function normalizeOpsRule(row) {
  if (!row) return row;
  const conditions = parseMaybeJson(row.conditions_json, {}) || {};
  return {
    ...row,
    is_active: row.is_active === true || Number(row.is_active) === 1,
    conditions: {
      app_ids: coerceStringArray(conditions.app_ids),
      booking_statuses: normalizeConditionList(conditions.booking_statuses),
      payment_statuses: normalizeConditionList(conditions.payment_statuses),
    },
  };
}

function normalizeBookingSubject(input) {
  const raw = input?.raw ? parseMaybeJson(input.raw, input.raw) : input;
  const payment = raw?.payment || input?.payment || {};
  return {
    id: input?.id || raw?.id || null,
    code: input?.code || input?.booking_code || raw?.code || raw?.booking_code || raw?.booking_ref || null,
    app_id: input?.app_id || raw?.app_id || raw?.app?.id || null,
    booking_status: String(input?.booking_status || raw?.booking_status || raw?.status || '').toLowerCase() || null,
    payment_status: String(input?.payment_status || raw?.payment_status || payment?.status || '').toLowerCase() || null,
  };
}

async function loadBookingRuleSubject(idOrCode, fallback = null) {
  if (fallback) {
    const subject = normalizeBookingSubject(fallback);
    if (subject.app_id || subject.booking_status || subject.payment_status) return subject;
  }
  if (!idOrCode) return normalizeBookingSubject({});
  try {
    const isUUID = UUID_RE.test(idOrCode);
    const [[cacheRow]] = await pool.query(
      isUUID
        ? `SELECT id, code, app_id, booking_status, payment_status, raw FROM bookings_cache WHERE id = ? LIMIT 1`
        : `SELECT id, code, app_id, booking_status, payment_status, raw FROM bookings_cache WHERE code = ? OR id = ? LIMIT 1`,
      isUUID ? [idOrCode] : [idOrCode, idOrCode]
    );
    if (cacheRow) return normalizeBookingSubject(cacheRow);
  } catch (e) {
    console.warn('Ops rule cache lookup failed:', e.message);
  }
  try {
    const cached = await readBookingDetailFromCache(idOrCode);
    if (cached?.raw) return normalizeBookingSubject(cached.raw);
  } catch {}
  try {
    const meta = await resolveBookingMeta(idOrCode);
    return normalizeBookingSubject({ id: idOrCode, code: meta?.code, app_id: meta?.app_id });
  } catch {
    return normalizeBookingSubject({ id: idOrCode });
  }
}

function opsRuleMatchesBooking(rule, subject) {
  const c = rule.conditions || {};
  if (c.app_ids?.length && !c.app_ids.includes(subject.app_id)) return false;
  if (c.booking_statuses?.length && !c.booking_statuses.includes(subject.booking_status)) return false;
  if (c.payment_statuses?.length && !c.payment_statuses.includes(subject.payment_status)) return false;
  return true;
}

// Returns true when the user is a member of at least one active Ops team.
// Used by login (to bake the flag into the JWT) and by authMiddleware (to
// back-fill the flag on legacy JWTs that pre-date the bake). The same
// query was previously inlined in two places — keeping a single helper so
// the SQL doesn't drift.
async function checkOpsTeamMembership(userId) {
  if (!userId) return false;
  try {
    const [[{ c }]] = await pool.query(
      `SELECT COUNT(*) AS c
         FROM ops_team_members m
         JOIN ops_teams t ON t.id = m.team_id AND t.is_active = 1
        WHERE m.user_id = ?`,
      [userId]
    );
    return Number(c) > 0;
  } catch { return false; /* legacy DB without ops_team_members */ }
}

async function getEffectiveOpsRules(user, action) {
  // Ops rules apply based on TEAM MEMBERSHIP and DIRECT user-target,
  // not on login_context. Earlier this required `login_context === 'system'`
  // which excluded users who were downgraded to 'app' context by the
  // auth flow (any user with role='agent' and a single allowed app is
  // auto-downgraded — see line ~3678 `isOpsRole`). That meant adding
  // someone to an Ops team had no enforcement effect for them. We now
  // gate purely on rule presence — the queries below return empty for
  // users with no targeted rules, which is the right default.
  if (!user || !OPS_RULE_ACTIONS.has(action)) {
    if (process.env.OPS_RULES_DEBUG === '1') {
      console.log(`[ops-rules] skip — user=${user?.id} action=${action}`);
    }
    return [];
  }

  // Direct user-targeted rules always apply. These take priority over team
  // rules because they're more specific.
  const [directRows] = await pool.query(
    `SELECT * FROM ops_permission_rules
     WHERE is_active = 1 AND action = ? AND target_type = 'user' AND target_id = ?
     ORDER BY created_at DESC`,
    [action, user.id]
  );
  if (directRows.length > 0) {
    if (process.env.OPS_RULES_DEBUG === '1') {
      console.log(`[ops-rules] user=${user.id} role=${user.role} action=${action} → ${directRows.length} direct rule(s)`);
    }
    return directRows.map(normalizeOpsRule);
  }

  // Team-targeted rules apply to ANY ops member of the team — including
  // admins and managers. Earlier we exempted admin/manager roles here,
  // but operators reported their rules looked broken because adding an
  // admin to a team didn't subject them to the team's deny rules.
  // Removing the exemption — admins keep access to Settings → Ops Teams
  // & Rules (gated separately by adminMiddleware) so they can lift any
  // rule that locks them out by mistake. Hard-deleting a rule remains
  // possible from that screen.
  const [teamRows] = await pool.query(
    `SELECT r.*
       FROM ops_permission_rules r
       JOIN ops_teams t ON t.id = r.target_id AND t.is_active = 1
       JOIN ops_team_members m
         ON m.team_id = r.target_id
        AND m.user_id = ?
      WHERE r.is_active = 1
        AND r.action = ?
        AND r.target_type = 'team'
      ORDER BY r.created_at DESC`,
    [user.id, action]
  );
  if (process.env.OPS_RULES_DEBUG === '1') {
    console.log(`[ops-rules] user=${user.id} role=${user.role} action=${action} → ${teamRows.length} team rule(s)`);
  }
  return teamRows.map(normalizeOpsRule);
}

function summarizeOpsDecision(rules, subject) {
  const matched = rules.filter(rule => opsRuleMatchesBooking(rule, subject));
  if (matched.some(rule => rule.effect === 'allow')) return { effect: 'allow', matched };
  if (matched.some(rule => rule.effect === 'deny')) return { effect: 'deny', matched };
  if (matched.some(rule => rule.effect === 'require_approval')) return { effect: 'require_approval', matched };
  return { effect: 'allow', matched };
}

async function evaluateOpsRules(user, action, subject) {
  const rules = await getEffectiveOpsRules(user, action);
  return summarizeOpsDecision(rules, subject);
}

function buildOpsBookingDenyFilter(rules) {
  const denyRules = rules.filter(rule => rule.effect === 'deny');
  if (denyRules.length === 0) return { sql: '', params: [] };
  const params = [];
  const clauses = denyRules.map(rule => {
    const c = rule.conditions || {};
    const parts = [];
    if (c.app_ids?.length) {
      parts.push(`app_id IN (${c.app_ids.map(() => '?').join(',')})`);
      params.push(...c.app_ids);
    }
    if (c.booking_statuses?.length) {
      parts.push(`LOWER(booking_status) IN (${c.booking_statuses.map(() => '?').join(',')})`);
      params.push(...c.booking_statuses);
    }
    if (c.payment_statuses?.length) {
      parts.push(`LOWER(payment_status) IN (${c.payment_statuses.map(() => '?').join(',')})`);
      params.push(...c.payment_statuses);
    }
    return parts.length > 0 ? `(${parts.join(' AND ')})` : '(1=1)';
  });
  return { sql: ` AND NOT (${clauses.join(' OR ')})`, params };
}

async function filterBookingsForOpsRules(user, bookings) {
  if (!Array.isArray(bookings) || bookings.length === 0) return bookings || [];
  const rules = await getEffectiveOpsRules(user, 'bookings.view');
  const denyRules = rules.filter(rule => rule.effect === 'deny');
  if (denyRules.length === 0) return bookings;
  return bookings.filter(booking => {
    const subject = normalizeBookingSubject(booking);
    return summarizeOpsDecision(denyRules, subject).effect !== 'deny';
  });
}

async function ensureBookingVisibleToUser(user, bookingLike, idOrCode = null) {
  // Drop the login_context gate — see getEffectiveOpsRules for rationale.
  // Rule presence (team membership / direct user target) is the source of
  // truth, not login_context. getEffectiveOpsRules returns [] for users
  // with no applicable rules, so this short-circuits cheaply for everyone.
  if (!user) return { allowed: true };
  const subject = bookingLike
    ? normalizeBookingSubject(bookingLike)
    : await loadBookingRuleSubject(idOrCode);
  const decision = await evaluateOpsRules(user, 'bookings.view', subject);
  if (decision.effect === 'deny') {
    return { allowed: false, reason: 'This booking is restricted by Ops team permissions' };
  }
  return { allowed: true };
}

async function bookingVisibilityMiddleware(req, res, next) {
  try {
    const guard = await ensureBookingVisibleToUser(req.platformUser, null, req.params.id);
    if (!guard.allowed) return err(res, guard.reason, 403);
    next();
  } catch (e) { err(res, e.message, 500); }
}

async function createSupportRequestCore(user, d) {
  const id = crypto.randomUUID();

  if (d.booking_code) {
    const [existing] = await pool.query(
      `SELECT id, request_number, status FROM support_requests
       WHERE booking_code = ?
         AND status IN ('pending_app_review','pending_app_response_review','open','assigned',
                        'in_progress','pending_supplier','escalated')
       LIMIT 1`,
      [d.booking_code]
    );
    if (existing.length > 0) {
      const message = `Booking ${d.booking_code} already has an active request (${existing[0].request_number} - ${existing[0].status}). Please resolve or close it before creating a new one.`;
      const error = new Error(message);
      error.status = 409;
      throw error;
    }
  }

  let app_id = null;
  const candidates = [d.booking_id, d.booking_code].filter(Boolean);
  for (const c of candidates) {
    try {
      const meta = await resolveBookingMeta(c);
      if (meta?.app_id) { app_id = meta.app_id; break; }
    } catch (e) { /* keep trying the next candidate */ }
  }

  const ctx = user.login_context;
  const createdByContext = classifyCreatorContext(user);
  let triageBound = (ctx === 'agent');
  if (triageBound && app_id) {
    try {
      const [[cfg]] = await pool.query(
        'SELECT skip_app_review FROM app_settings WHERE app_id = ?',
        [app_id]
      );
      if (cfg && Number(cfg.skip_app_review) === 1) triageBound = false;
    } catch (e) { /* table missing or DB hiccup - fall back to default */ }
  }
  const status = triageBound ? 'pending_app_review' : 'open';
  const triageState = triageBound ? 'pending_app_review' : 'bypassed';

  const originalMessage = triageBound ? (d.message ?? null) : null;
  const originalRequestType = triageBound ? (d.request_type ?? null) : null;
  const originalFieldChanges = triageBound ? (d.field_changes || []) : null;

  // Allocate request_number — retry on UNIQUE collision (concurrent inserts).
  let requestNumber = null;
  for (let attempt = 0; attempt < 3; attempt++) {
    requestNumber = await nextSupportRequestNumber(pool, attempt);
    try {
      await pool.query(
        `INSERT INTO support_requests (id, request_number, booking_code, company_name, booking_id, supplier_name, supplier_id,
          product_id, product_type, request_type, request_title, change_type, field_changes, message,
          status, assigned_agent, assigned_agent_name, assigned_at, deadline, currency, passenger_details,
          booking_details, attached_documents, created_by, created_by_type,
          app_id, triage_state, original_message, original_request_type, original_field_changes,
          created_by_context)
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
        [id, requestNumber, d.booking_code, d.company_name, d.booking_id, d.supplier_name, d.supplier_id,
         d.product_id, d.product_type, d.request_type, d.request_title, d.change_type,
         JSON.stringify(d.field_changes || []), d.message,
         status, d.assigned_agent || null, d.assigned_agent_name || null, toMysqlDatetime(d.assigned_at),
         new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' '),
         d.currency || 'USD',
         JSON.stringify(d.passenger_details), JSON.stringify(d.booking_details),
         JSON.stringify(d.attached_documents || []),
         user.id, d.created_by_type || 'client',
         app_id, triageState, originalMessage, originalRequestType,
         originalFieldChanges ? JSON.stringify(originalFieldChanges) : null,
         createdByContext]
      );
      break;
    } catch (e) {
      if (e?.code === 'ER_DUP_ENTRY' && attempt < 2) continue;
      throw e;
    }
  }

  await logActivity(id, 'status_change', null, status, user.id, userDisplayName(user), { action: 'created', triage_state: triageState });

  const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [id]);
  const created = rows[0];
  const reqTitle = created.request_title || created.request_type || 'New support request';
  if (triageState === 'pending_app_review') {
    await notifyForRequest({
      request: created,
      audiences: ['app'],
      kind: 'support_request_pending_review',
      title: 'New ticket to review',
      body: `${created.booking_code || ''} - ${reqTitle}`.trim(),
      priority: 'high',
      actor: user,
      metadata: { booking_code: created.booking_code, request_number: created.request_number },
    });
  } else {
    await notifyForRequest({
      request: created,
      audiences: ['ops'],
      kind: 'support_request_new',
      title: 'New ticket in Ops queue',
      body: `${created.booking_code || ''} - ${reqTitle}`.trim(),
      priority: 'normal',
      actor: user,
      metadata: { booking_code: created.booking_code, request_number: created.request_number },
    });
  }

  return parseJsonCols(created);
}

async function evaluateSupportTicketCreateRule(user, d) {
  // Drop login_context gate — see getEffectiveOpsRules. Team-targeted
  // rules now apply to any user added to that team, regardless of
  // whether they're system / app / agent context.
  if (!user) return { effect: 'allow', matched: [] };
  const subject = await loadBookingRuleSubject(d.booking_id || d.booking_code, {
    ...(d.booking_details || {}),
    id: d.booking_id,
    code: d.booking_code,
    app_id: d.app_id || d.booking_details?.app_id,
  });
  return evaluateOpsRules(user, 'support_ticket.create', subject);
}

async function createOpsActionApprovalRequest(user, action, d, decision) {
  const subject = await loadBookingRuleSubject(d.booking_id || d.booking_code, {
    ...(d.booking_details || {}),
    id: d.booking_id,
    code: d.booking_code,
    app_id: d.app_id || d.booking_details?.app_id,
  });
  const bookingCode = d.booking_code || subject.code || null;
  const bookingId = d.booking_id || subject.id || null;

  const [dupes] = await pool.query(
    `SELECT * FROM ops_action_approval_requests
     WHERE action = ? AND status = 'pending' AND requester_id = ?
       AND ((booking_code IS NULL AND ? IS NULL) OR booking_code = ?)
     ORDER BY created_at DESC LIMIT 1`,
    [action, user.id, bookingCode, bookingCode]
  );
  if (dupes.length > 0) return parseOpsActionApproval(dupes[0]);

  const id = crypto.randomUUID();
  const actorName = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || null;
  const rulesSnapshot = (decision.matched || []).map(rule => ({
    id: rule.id,
    name: rule.name,
    target_type: rule.target_type,
    target_id: rule.target_id,
    effect: rule.effect,
    conditions: rule.conditions,
  }));

  await pool.query(
    `INSERT INTO ops_action_approval_requests
       (id, action, status, requester_id, requester_name, requester_email,
        booking_id, booking_code, app_id, payload_json, conditions_snapshot)
     VALUES (?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?)`,
    [
      id, action, user.id, actorName, user.email || null,
      bookingId, bookingCode, subject.app_id || null,
      JSON.stringify(d || {}),
      JSON.stringify(rulesSnapshot),
    ]
  );

  const [[row]] = await pool.query('SELECT * FROM ops_action_approval_requests WHERE id = ?', [id]);
  return parseOpsActionApproval(row);
}

function parseOpsActionApproval(row) {
  if (!row) return row;
  return {
    ...row,
    payload_json: parseMaybeJson(row.payload_json, {}),
    conditions_snapshot: parseMaybeJson(row.conditions_snapshot, []),
  };
}

function opsActorName(user) {
  return `${user?.first_name || ''} ${user?.last_name || ''}`.trim() || user?.email || null;
}

async function canManageOpsApproval(user, approval) {
  if (!user || user.login_context !== 'system') return false;
  if (user.role === 'admin' || user.role === 'manager') return true;
  const [rows] = await pool.query(
    `SELECT 1
       FROM ops_team_members leader
       JOIN ops_team_members member ON member.team_id = leader.team_id
      WHERE leader.user_id = ?
        AND leader.role = 'leader'
        AND member.user_id = ?
        AND member.role = 'member'
      LIMIT 1`,
    [user.id, approval.requester_id]
  );
  return rows.length > 0;
}

// ── Email notification system ──────────────────────────────────────────────
//
// Phase-1 design (v1):
// · Only platform_users get emails (no external customer contacts yet).
// · Master on/off per user via platform_users.email_preferences.enabled.
// · Per-app BCC audit list lives on app_settings.bcc_emails.
// · Subject format: "{APP_NAME} - {AGENCY_NAME} - {BOOKING_NO}"
//   (each part is skipped when the source value is empty).
// · Rate limit: 60 s per (recipient_user_id, entity_id) across any kind.
// · Every attempt — success, failure, or skip — is logged to
//   notification_deliveries so it's auditable from the UI later.

// Notification kinds that are allowed to produce an email. Everything else
// stays silent (bell-only). Internal chatter and low-signal state flips
// are intentionally excluded.
const EMAIL_WORTHY_KINDS = new Set([
  // Support-request lifecycle
  'support_request_pending_review',
  'support_request_new',
  'support_request_forwarded',
  'support_request_assigned',
  'support_request_resolved',
  'support_request_closed',
  'support_request_rejected',
  'support_request_rejected_by_app',
  'support_response_pending_review',
  'support_response_released',
  'support_response_returned',
  // Approval proposals
  'approval_proposed_charge',
  'approval_proposed_refund',
  'approval_proposed_change',
  'approval_proposed_cancellation',
  'approval_approved',
  'approval_rejected',
  // Customer-facing thread messages (not internal notes)
  'support_message_new',
]);

const EMAIL_DEFAULT_PREFS = Object.freeze({
  enabled: true,
  categories: {
    tickets: true,
    messages: true,
    approvals: true,
    internal_notes: false,
  },
});

// Map a kind to a category so per-category toggles (Phase 2) can filter.
// For v1 only the master "enabled" flag is respected; categories are
// recorded in the DB and exposed via the API but not yet user-toggleable.
function kindCategory(kind) {
  if (!kind) return 'tickets';
  if (kind.startsWith('approval_')) return 'approvals';
  if (kind === 'support_message_new') return 'messages';
  if (kind === 'support_internal_note') return 'internal_notes';
  return 'tickets';
}

// Read the user's preferences; missing / malformed → defaults.
async function loadEmailPreferences(userId) {
  if (!userId) return null;
  try {
    const [[row]] = await pool.query(
      'SELECT email, first_name, last_name, email_preferences FROM platform_users WHERE id = ? AND is_active = 1',
      [userId]
    );
    if (!row) return null;
    let prefs = row.email_preferences;
    if (typeof prefs === 'string') { try { prefs = JSON.parse(prefs); } catch { prefs = null; } }
    const merged = {
      enabled: prefs?.enabled !== false, // null/undefined/true → true
      categories: { ...EMAIL_DEFAULT_PREFS.categories, ...(prefs?.categories || {}) },
    };
    return { ...row, email_preferences: merged };
  } catch (e) { return null; }
}

// Cache app metadata (name + bcc list + upstream BCC) so a flurry of fanouts
// to the same ticket doesn't hammer the DB or upstream. 60 s TTL is long
// enough for a burst, short enough that BCC edits show up quickly.
const _appMetaCache = new Map(); // app_id → { at: ms, meta }

// Pull the BCC lists configured upstream for this app (on booking_success /
// in_progress / cancel) and merge them into a single deduped array of valid
// email addresses. This gives us a sensible default BCC set to show / use
// when the admin hasn't configured a local override — matching the same
// audit recipients the app already uses on its booking emails.
function extractUpstreamBcc(upstreamApp) {
  if (!upstreamApp?.notification_settings) return [];
  const ns = upstreamApp.notification_settings;
  const merged = new Set();
  for (const k of ['booking_success_bcc', 'booking_in_progress_bcc', 'booking_cancel_bcc']) {
    const list = ns[k];
    if (!Array.isArray(list)) continue;
    for (const v of list) {
      if (typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())) {
        merged.add(v.trim());
      }
    }
  }
  return [...merged];
}

async function loadAppMeta(appId) {
  if (!appId) return { name: null, code: null, bcc_emails: [], upstream_bcc_emails: [] };
  const cached = _appMetaCache.get(appId);
  if (cached && Date.now() - cached.at < 60_000) return cached.meta;

  let name = null, code = null, bcc = [], upstreamBcc = [];
  try {
    const [[row]] = await pool.query(
      'SELECT app_name, app_code, bcc_emails FROM app_settings WHERE app_id = ?',
      [appId]
    );
    if (row) {
      name = row.app_name || null;
      code = row.app_code || null;
      if (row.bcc_emails) {
        try {
          const parsed = typeof row.bcc_emails === 'string' ? JSON.parse(row.bcc_emails) : row.bcc_emails;
          if (Array.isArray(parsed)) bcc = parsed.filter(e => typeof e === 'string' && e.includes('@'));
        } catch { /* ignore malformed JSON */ }
      }
    }
  } catch (e) { /* table may lag schema on first boot */ }

  // Fetch upstream to fill in: (a) the app name if missing locally, and
  // (b) upstream BCC defaults for apps that haven't been overridden yet.
  // One call for both — cheap and cache-friendly.
  try {
    const data = await easygdsRequest(`/apps?id=${encodeURIComponent(appId)}`);
    if (!name) name = data?.name || null;
    if (!code) code = data?.code || null;
    upstreamBcc = extractUpstreamBcc(data);
  } catch (e) { /* offline-friendly: still send, just no upstream defaults */ }

  const meta = { name, code, bcc_emails: bcc, upstream_bcc_emails: upstreamBcc };
  _appMetaCache.set(appId, { at: Date.now(), meta });
  return meta;
}

// Effective BCC for outbound mail: admin's local override if non-empty,
// otherwise fall back to the upstream app's configured BCC list. Keeps
// behaviour predictable — "empty local = inherit from upstream".
function effectiveBccList(appMeta) {
  if (appMeta.bcc_emails && appMeta.bcc_emails.length > 0) return appMeta.bcc_emails;
  return appMeta.upstream_bcc_emails || [];
}

// Invalidate the cache when an admin edits app_settings — called from the
// PATCH /api/app-settings/:id handler.
function invalidateAppMeta(appId) { _appMetaCache.delete(appId); }

// Assemble the subject per the user spec: APPNAME - AGENCY NAME - BOOKING NO.
// Skip any segment that's empty so we don't end up with "AppName -  - BKG-1".
function buildNotificationSubject({ appName, agencyName, bookingCode, requestNumber, title }) {
  const parts = [appName, agencyName, bookingCode].filter(Boolean);
  const head = parts.join(' - ');
  const trail = requestNumber ? ` · ${requestNumber}` : '';
  if (head) return `${head}${trail} — ${title}`;
  return `${title}${trail}`;
}

// 60 s per (recipient_user_id, entity_id) spam guard.
async function isRateLimited(userId, entityId) {
  if (!userId || !entityId) return false;
  const [[row]] = await pool.query(
    `SELECT COUNT(*) AS n FROM notification_deliveries
     WHERE recipient_user_id = ? AND entity_id = ?
       AND status = 'sent'
       AND sent_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 60 SECOND)`,
    [userId, entityId]
  );
  return (row?.n || 0) > 0;
}

async function logDelivery(fields) {
  try {
    await pool.query(
      `INSERT INTO notification_deliveries
         (id, notification_id, recipient_user_id, recipient_email, entity_type,
          entity_id, kind, subject, smtp_account_id, status, error)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [crypto.randomUUID(),
       fields.notification_id || null,
       fields.recipient_user_id || null,
       fields.recipient_email || null,
       fields.entity_type || null,
       fields.entity_id || null,
       fields.kind || null,
       (fields.subject || '').slice(0, 510),
       fields.smtp_account_id || null,
       fields.status,
       fields.error ? String(fields.error).slice(0, 1020) : null]
    );
  } catch (e) { console.error('logDelivery error:', e.message); }
}

// Build a minimal-but-branded HTML wrapper around the notification body.
// Deliberately inline-styles only — most webmail clients strip <style>.
function renderNotificationHtml({ title, body, linkUrl, footerNote }) {
  const safeBody = (body || '').replace(/[<>&]/g, c => ({'<':'&lt;','>':'&gt;','&':'&amp;'}[c]));
  const bodyHtml = safeBody.split('\n').map(l => `<p style="margin:0 0 12px;line-height:1.5;color:#0f172a">${l}</p>`).join('');
  const cta = linkUrl
    ? `<a href="${linkUrl}" style="display:inline-block;padding:10px 18px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-weight:600">View ticket</a>`
    : '';
  return `
<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:560px;margin:0 auto;padding:24px;background:#f8fafc">
  <div style="background:#fff;border:1px solid #e2e8f0;border-radius:8px;padding:24px">
    <h2 style="margin:0 0 16px;color:#0f172a;font-size:18px">${title}</h2>
    ${bodyHtml}
    <div style="margin-top:24px">${cta}</div>
  </div>
  <p style="margin:16px 0 0;font-size:11px;color:#64748b;text-align:center">
    ${footerNote || 'You\u2019re receiving this because you\u2019re a platform user on Tripovo.'}
    <br>
    <a href="${(process.env.FRONTEND_URL || 'http://localhost:8080').replace(/\/$/,'')}/settings" style="color:#64748b;text-decoration:underline">Manage notification preferences</a>
  </p>
</div>`.trim();
}

// Attempt to email a single recipient about a single notification. Swallows
// all errors — the audit log is the source of truth for what happened.
async function sendNotificationEmail({ notification, recipientUserId, request }) {
  const kind = notification?.kind;
  const entityId = notification?.entity_id || request?.id || null;

  // ── Filter layer (cheap) ─────────────────────────────────────────────
  if (!kind || !EMAIL_WORTHY_KINDS.has(kind)) return;
  if (!recipientUserId || !entityId) return;

  // Only ticket-entity events are supported in v1. Approvals carry
  // entity_type='support_request' too (we attached them that way).
  if ((notification?.entity_type || 'support_request') !== 'support_request') return;

  // ── Preferences + recipient lookup ───────────────────────────────────
  const user = await loadEmailPreferences(recipientUserId);
  if (!user || !user.email) {
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      entity_type: 'support_request', entity_id: entityId, kind, status: 'skipped_no_email' });
    return;
  }
  if (!user.email_preferences.enabled) {
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, status: 'skipped_pref' });
    return;
  }
  // Phase-2 hook: skip if the category is turned off. Already wired so we
  // can ship category toggles without another code change.
  const cat = kindCategory(kind);
  if (user.email_preferences.categories[cat] === false) {
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, status: 'skipped_pref' });
    return;
  }

  // ── Rate limit ───────────────────────────────────────────────────────
  if (await isRateLimited(recipientUserId, entityId)) {
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, status: 'skipped_rate' });
    return;
  }

  // ── Compose ──────────────────────────────────────────────────────────
  const appMeta = await loadAppMeta(request?.app_id);
  const subject = buildNotificationSubject({
    appName: appMeta.name,
    agencyName: request?.company_name || null,
    bookingCode: request?.booking_code || null,
    requestNumber: request?.request_number || null,
    title: notification.title,
  });
  const linkUrl = `${(process.env.FRONTEND_URL || 'http://localhost:8080').replace(/\/$/,'')}${notification.link_path || `/support/${entityId}`}`;
  const html = renderNotificationHtml({
    title: notification.title,
    body: notification.body || '',
    linkUrl,
    footerNote: `You're receiving this because you're a platform user on Tripovo${appMeta.name ? ` with access to ${appMeta.name}` : ''}.`,
  });
  const text = `${notification.title}\n\n${notification.body || ''}\n\n${linkUrl}`;

  // ── Send ─────────────────────────────────────────────────────────────
  let nodemailer;
  try { nodemailer = require('nodemailer'); } catch (_) {
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, subject, status: 'failed', error: 'nodemailer not installed' });
    return;
  }
  const account = await resolveSmtpAccount(null);
  if (!account) {
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, subject, status: 'failed', error: 'no active SMTP account' });
    return;
  }
  const fromAddress = account.from_name
    ? `"${account.from_name}" <${account.from_email}>`
    : account.from_email;
  // Threading header: synthetic message-id per ticket so multiple emails
  // about the same request collapse into one thread in the recipient's
  // mailbox. Must stay stable across sends.
  const threadMsgId = `<ticket-${entityId}@notifications.tripovo.local>`;

  try {
    const transporter = nodemailer.createTransport({
      host: account.host,
      port: account.port,
      secure: Number(account.secure) === 1,
      auth: { user: account.username, pass: decryptSecret(account.password) },
      tls: { rejectUnauthorized: false },
      connectionTimeout: 15_000,
    });
    const bccList = effectiveBccList(appMeta);
    await transporter.sendMail({
      from: fromAddress,
      to: user.email,
      bcc: bccList.length ? bccList : undefined,
      subject,
      text,
      html,
      inReplyTo: threadMsgId,
      references: threadMsgId,
    });
    await pool.query('UPDATE smtp_accounts SET last_used_at = UTC_TIMESTAMP(), last_error = NULL WHERE id = ?', [account.id]);
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, subject, smtp_account_id: account.id, status: 'sent' });
  } catch (sendErr) {
    const msg = String(sendErr?.message || sendErr).slice(0, 500);
    try {
      await pool.query('UPDATE smtp_accounts SET last_error = ? WHERE id = ?', [msg, account.id]);
    } catch {}
    await logDelivery({ notification_id: notification.id, recipient_user_id: recipientUserId,
      recipient_email: user.email, entity_type: 'support_request', entity_id: entityId,
      kind, subject, smtp_account_id: account.id, status: 'failed', error: msg });
  }
}

// ════════════════════════════════════════════
// STATUS TRANSITIONS
// ════════════════════════════════════════════

app.get('/rest/v1/support_status_transitions', authMiddleware, (req, res) => {
  const current = req.query.current_status;
  if (!current || !STATUS_TRANSITIONS[current]) return ok(res, []);
  ok(res, STATUS_TRANSITIONS[current]);
});

// ════════════════════════════════════════════
// SUPPORT REQUESTS  (with App-user triage flow)
// ════════════════════════════════════════════

// Helper: does the user have authority over this app?
function isAppUserForApp(user, appId) {
  if (!user || user.login_context !== 'app') return false;
  if (!appId) return false;
  if (!Array.isArray(user.allowed_app_ids)) return false;
  return user.allowed_app_ids.includes(appId);
}

// Helper: SQL fragment + params that scope the list query to what the
// caller is allowed to see.
function buildScopeClause(user, query) {
  const parts = [];
  const params = [];
  const ctx = user?.login_context;

  if (ctx === 'system') {
    // Ops hides in-flight triage states by default — opt-in via flags.
    // `handled_by_app` is included: the App user has taken responsibility for
    // the ticket, Ops should not touch it unless the App user forwards.
    const includes = [];
    if (query.include_pending_app_review !== 'true') includes.push("'pending_app_review'");
    if (query.include_rejected_by_app !== 'true') includes.push("'rejected_by_app'");
    if (query.include_pending_app_response_review !== 'true') includes.push("'pending_app_response_review'");
    if (query.include_handled_by_app !== 'true') includes.push("'handled_by_app'");
    if (includes.length > 0) {
      parts.push(`(triage_state IS NULL OR triage_state NOT IN (${includes.join(',')}))`);
    }
  } else if (ctx === 'app') {
    const apps = Array.isArray(user.allowed_app_ids) ? user.allowed_app_ids : [];
    if (apps.length === 0) {
      parts.push('1=0'); // no apps assigned → no results
    } else {
      const placeholders = apps.map(() => '?').join(',');
      // Apps see rows where app_id matches, OR legacy rows with NULL app_id
      // that match their booking scope (caller still passes booking_codes then).
      parts.push(`(app_id IN (${placeholders}) OR app_id IS NULL)`);
      params.push(...apps);
    }
  } else if (ctx === 'agent') {
    parts.push('(created_by = ? OR assigned_agent = ?)');
    params.push(user.id, user.id);
  }

  return { sql: parts.length ? ' AND ' + parts.join(' AND ') : '', params };
}

// List
app.get('/rest/v1/support_requests', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    let sql = 'SELECT * FROM support_requests WHERE 1=1';
    const params = [];

    if (req.query.booking_code) { sql += ' AND booking_code = ?'; params.push(req.query.booking_code); }
    if (req.query.booking_codes) {
      const codes = String(req.query.booking_codes).split(',').map(c => c.trim()).filter(Boolean);
      if (codes.length === 0) return ok(res, []);
      sql += ` AND booking_code IN (${codes.map(() => '?').join(',')})`;
      params.push(...codes);
    }
    if (req.query.status && req.query.status !== 'all') { sql += ' AND status = ?'; params.push(req.query.status); }
    if (req.query.triage_state) { sql += ' AND triage_state = ?'; params.push(req.query.triage_state); }
    if (req.query.assigned_agent) { sql += ' AND assigned_agent = ?'; params.push(req.query.assigned_agent); }
    if (req.query.not_status) { sql += ' AND status NOT IN (?)'; params.push(req.query.not_status.split(',')); }

    // Login-context scoping (triage visibility + app/agent restrictions).
    const scope = buildScopeClause(user, req.query);
    sql += scope.sql;
    params.push(...scope.params);

    // Last-activity first (newest update/message bumps a ticket to the
    // top), then created_at as a tiebreaker. Operators asked for this so
    // both brand-new tickets (updated_at = created_at on insert) AND
    // tickets that just got a customer reply or status change surface at
    // the top of the inbox. Deadline still drives the urgency-coloured
    // badge in the list UI; sort is a separate concern.
    sql += ' ORDER BY updated_at DESC, created_at DESC';
    if (req.query.limit) { sql += ' LIMIT ?'; params.push(parseInt(req.query.limit)); }

    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(parseJsonCols).map(r => redactOpsProposal(r, user.login_context)));
  } catch (e) { err(res, e.message, 500); }
});

// Get single
app.get('/rest/v1/support_requests/:id', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    if (rows.length === 0) return err(res, 'Not found', 404);
    let row = parseJsonCols(rows[0]);
    // Side-channel: the customer-facing view filters which messages it
    // shows based on whether App review is bypassed for this app. We
    // attach it to the request row here (instead of a separate fetch)
    // so the customer page can derive visibility without a second
    // round-trip. Two queries because tripovo_support tables span
    // utf8mb4_unicode_ci (Supabase migrations) and utf8mb4_general_ci
    // (local CREATE TABLE) — JOINing across the boundary errors out
    // with "Illegal mix of collations".
    try {
      if (row.app_id) {
        const [[cfg]] = await pool.query(
          'SELECT skip_app_review FROM app_settings WHERE app_id = ?',
          [row.app_id]
        );
        row.skip_app_review = cfg ? Number(cfg.skip_app_review) === 1 : false;
      } else {
        row.skip_app_review = false;
      }
    } catch (e) {
      // Non-fatal — fall back to the safe default (review required).
      row.skip_app_review = false;
    }
    // Enforce scope on the single-row fetch too.
    const ctx = user.login_context;
    if (ctx === 'agent' && row.created_by !== user.id && row.assigned_agent !== user.id) {
      return err(res, 'Forbidden', 403);
    }
    if (ctx === 'app') {
      const apps = Array.isArray(user.allowed_app_ids) ? user.allowed_app_ids : [];
      if (row.app_id && !apps.includes(row.app_id)) return err(res, 'Forbidden', 403);
    }

    // ── Auto-claim on view ───────────────────────────────────────────────
    // If an Ops rule allows `support_ticket.auto_claim` for this user on
    // this booking, and the ticket is currently unassigned + non-terminal,
    // stamp this user as the assignee. This is opt-in per team via the
    // Rule Builder.
    //
    // No login_context gate here — the rule eval itself is the authority.
    // An "agent"-role user who's been added to an Ops team is
    // auto-downgraded to login_context='app' by the auth flow (see CLAUDE.md
    // and the comment on getEffectiveOpsRules). Gating on `ctx === 'system'`
    // would make the rule silently no-op for exactly the people it's meant
    // for. evaluateOpsRules returns `allow` only for users whose team
    // membership / direct user rule actually matches, so this is safe.
    //
    // We still skip agency-context users (`ctx === 'agent'`): they
    // shouldn't be claiming tickets, and the assignee column is for Ops
    // staff. App users who are NOT in an Ops team won't match any rule
    // and so won't auto-claim — same outcome.
    const TERMINAL_STATUSES = new Set(['resolved', 'closed', 'rejected']);
    const eligibleForClaim =
      ctx !== 'agent' &&
      !row.assigned_agent &&
      !TERMINAL_STATUSES.has(row.status);
    if (eligibleForClaim) {
      try {
        const subject = await loadBookingRuleSubject(
          row.booking_id || row.booking_code,
          { id: row.booking_id, code: row.booking_code, app_id: row.app_id }
        );
        const decision = await evaluateOpsRules(user, 'support_ticket.auto_claim', subject);
        if (decision.effect === 'allow' && (decision.matched || []).length > 0) {
          const agentName = `${user.first_name || ''} ${user.last_name || ''}`.trim()
            || user.email
            || 'Ops Agent';
          const now = new Date();
          // Set status to 'assigned' only if it's currently 'open'; otherwise
          // preserve whatever working state Ops has moved it to.
          const newStatus = row.status === 'open' ? 'assigned' : row.status;
          await pool.query(
            `UPDATE support_requests
               SET assigned_agent = ?,
                   assigned_agent_name = ?,
                   assigned_at = ?,
                   status = ?
             WHERE id = ?
               AND assigned_agent IS NULL`,
            [user.id, agentName, toMysqlDatetime(now.toISOString()), newStatus, req.params.id]
          );
          // Re-read so we return the freshly-claimed row.
          const [reread] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
          if (reread.length > 0) row = parseJsonCols(reread[0]);
          await logActivity(
            req.params.id,
            'auto_claimed',
            null,
            user.id,
            user.id,
            agentName,
            {
              rule_ids: (decision.matched || []).map(r => r.id),
              previous_status: rows[0].status,
              new_status: newStatus,
            }
          );
        }
      } catch (claimErr) {
        // Auto-claim is a UX nicety, not a guarantee — log & continue so
        // a transient rule-eval error doesn't 500 the detail page.
        console.warn('Auto-claim skipped for request', req.params.id, claimErr.message);
      }
    }

    ok(res, redactOpsProposal(row, ctx));
  } catch (e) { err(res, e.message, 500); }
});

// Create — status is derived from the caller's login_context; the client
// cannot force its own initial status. Also enforces a hard cap of one
// active (non-terminal) request per booking.
app.post('/rest/v1/support_requests', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const d = req.body;
    const id = crypto.randomUUID();

    // ── Max-1 active ticket per booking ──────────────────────────────────
    // If there is already a non-terminal request on the same booking_code,
    // block the creation. The creator must resolve/close/reject the current
    // one first. This mirrors the UI guard but is authoritative here so API
    // callers can't bypass it.
    if (d.booking_code) {
      const [existing] = await pool.query(
        `SELECT id, request_number, status FROM support_requests
         WHERE booking_code = ?
           AND status IN ('pending_app_review','pending_app_response_review','open','assigned',
                          'in_progress','pending_supplier','escalated')
         LIMIT 1`,
        [d.booking_code]
      );
      if (existing.length > 0) {
        return err(
          res,
          `Booking ${d.booking_code} already has an active request (${existing[0].request_number} — ${existing[0].status}). Please resolve or close it before creating a new one.`,
          409
        );
      }
    }

    const opsDecision = await evaluateSupportTicketCreateRule(user, d);
    if (opsDecision.effect === 'deny') {
      return err(res, 'You are not allowed to create a ticket for this booking', 403);
    }
    if (opsDecision.effect === 'require_approval') {
      const approval = await createOpsActionApprovalRequest(user, 'support_ticket.create', d, opsDecision);
      return ok(res, {
        pending_approval: true,
        approval_request: approval,
        message: 'Ticket creation was sent for approval',
      });
    }

    // Resolve app_id from the booking. Try booking_id first (works for UUIDs
    // and, via our fallback, booking codes) — then fall back to booking_code
    // explicitly so we always get an app association when the booking exists.
    let app_id = null;
    const candidates = [d.booking_id, d.booking_code].filter(Boolean);
    for (const c of candidates) {
      try {
        const meta = await resolveBookingMeta(c);
        if (meta?.app_id) { app_id = meta.app_id; break; }
      } catch (e) { /* keep trying the next candidate */ }
    }

    // Initial state derived from login_context:
    //   - system (Ops)       → open / bypassed   (direct to Ops queue)
    //   - app (App user)     → open / bypassed   (App user is authority)
    //   - agent / other      → pending_app_review (triage)
    // …unless the App is configured as "no operation team" via
    // app_settings.skip_app_review, in which case agency-created requests
    // also bypass the review and land directly in the Ops queue.
    const ctx = user.login_context;
    const createdByContext = classifyCreatorContext(user);
    let triageBound = (ctx === 'agent');
    if (triageBound && app_id) {
      try {
        const [[cfg]] = await pool.query(
          'SELECT skip_app_review FROM app_settings WHERE app_id = ?',
          [app_id]
        );
        if (cfg && Number(cfg.skip_app_review) === 1) triageBound = false;
      } catch (e) { /* table missing or DB hiccup — fall back to default */ }
    }
    const status = triageBound ? 'pending_app_review' : 'open';
    const triageState = triageBound ? 'pending_app_review' : 'bypassed';

    // Snapshot creator-supplied content only when entering triage — the App
    // user may edit these later and we need an immutable original.
    const originalMessage = triageBound ? (d.message ?? null) : null;
    const originalRequestType = triageBound ? (d.request_type ?? null) : null;
    const originalFieldChanges = triageBound ? (d.field_changes || []) : null;

    // Allocate request_number — retry on UNIQUE collision (concurrent inserts).
    let requestNumber = null;
    for (let attempt = 0; attempt < 3; attempt++) {
      requestNumber = await nextSupportRequestNumber(pool, attempt);
      try {
        await pool.query(
          `INSERT INTO support_requests (id, request_number, booking_code, company_name, booking_id, supplier_name, supplier_id,
            product_id, product_type, request_type, request_title, change_type, field_changes, message,
            status, assigned_agent, assigned_agent_name, assigned_at, deadline, currency, passenger_details,
            booking_details, attached_documents, created_by, created_by_type,
            app_id, triage_state, original_message, original_request_type, original_field_changes,
            created_by_context)
           VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
          [id, requestNumber, d.booking_code, d.company_name, d.booking_id, d.supplier_name, d.supplier_id,
           d.product_id, d.product_type, d.request_type, d.request_title, d.change_type,
           JSON.stringify(d.field_changes || []), d.message,
           status, d.assigned_agent || null, d.assigned_agent_name || null, toMysqlDatetime(d.assigned_at),
           // Deadline = now + 2h (ignore client-supplied deadline for consistency)
           new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString().slice(0, 19).replace('T', ' '),
           d.currency || 'USD',
           JSON.stringify(d.passenger_details), JSON.stringify(d.booking_details),
           JSON.stringify(d.attached_documents || []),
           // created_by is the authenticated user id (ignore client-supplied value).
           user.id, d.created_by_type || 'client',
           app_id, triageState, originalMessage, originalRequestType,
           originalFieldChanges ? JSON.stringify(originalFieldChanges) : null,
           createdByContext]
        );
        break;
      } catch (e) {
        if (e?.code === 'ER_DUP_ENTRY' && attempt < 2) continue;
        throw e;
      }
    }

    await logActivity(id, 'status_change', null, status, user.id, userDisplayName(user), { action: 'created', triage_state: triageState });

    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [id]);
    const created = rows[0];

    // Fan out the notification:
    //  - pending_app_review → the App user(s) for this app need to triage
    //  - bypassed + agent creator (skip_app_review) → ops queue
    //  - bypassed + app/system creator → ops team
    const reqTitle = created.request_title || created.request_type || 'New support request';
    if (triageState === 'pending_app_review') {
      await notifyForRequest({
        request: created,
        audiences: ['app'],
        kind: 'support_request_pending_review',
        title: 'New ticket to review',
        body: `${created.booking_code || ''} — ${reqTitle}`.trim(),
        priority: 'high',
        actor: user,
        metadata: { booking_code: created.booking_code, request_number: created.request_number },
      });
    } else {
      await notifyForRequest({
        request: created,
        audiences: ['ops'],
        kind: 'support_request_new',
        title: 'New ticket in Ops queue',
        body: `${created.booking_code || ''} — ${reqTitle}`.trim(),
        priority: 'normal',
        actor: user,
        metadata: { booking_code: created.booking_code, request_number: created.request_number },
      });
    }

    ok(res, redactOpsProposal(parseJsonCols(created), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// Update — with triage permission + resubmit + Ops-customer-facing interception
app.patch('/rest/v1/support_requests/:id', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const d = { ...req.body };
    const requestId = req.params.id;

    const [currentRows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [requestId]);
    if (currentRows.length === 0) return err(res, 'Not found', 404);
    const current = currentRows[0];

    const isSystem = user.login_context === 'system';
    const isAppUser = isAppUserForApp(user, current.app_id);
    const isCreator = user.id === current.created_by;

    // ── Permission: triage states restrict who may touch the row ─────────
    if (current.triage_state === 'pending_app_review') {
      if (!isAppUser && !isCreator) return err(res, 'Forbidden while awaiting App review', 403);
    }
    if (current.triage_state === 'pending_app_response_review') {
      // Creator or Ops can't sneak past — only the App user releases it.
      if (!isAppUser) return err(res, 'Forbidden while awaiting App response review', 403);
    }
    if (current.triage_state === 'handled_by_app') {
      // The App user owns this ticket. Only they or the creator may update.
      if (!isAppUser && !isCreator) return err(res, 'Forbidden — App user is handling this request', 403);
    }

    // ── Creator resubmit: rejected_by_app → pending_app_review ───────────
    if (d.status === 'pending_app_review' && current.triage_state === 'rejected_by_app') {
      if (!isCreator) return err(res, 'Only the creator may resubmit', 403);
      if ((current.app_review_resubmit_count || 0) >= 1) {
        return err(res, 'This ticket has already been resubmitted once', 409);
      }
      // Replace the patch to perform the resubmit carefully.
      const sets = {
        status: 'pending_app_review',
        triage_state: 'pending_app_review',
        rejected_by_app_at: null,
        rejected_by_app_by: null,
        rejection_reason: null,
        resolved_at: null,
        app_review_resubmit_count: (current.app_review_resubmit_count || 0) + 1,
        previous_status: current.status,
      };
      const setClauses = Object.keys(sets).map(k => `${k} = ?`).join(', ');
      await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(sets), requestId]);
      await logActivity(requestId, 'app_resubmitted_by_creator', current.status, 'pending_app_review', user.id, userDisplayName(user));
      await refreshDeadline(requestId);
      const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [requestId]);
      return ok(res, redactOpsProposal(parseJsonCols(rows[0]), user.login_context));
    }

    // ── Ops customer-facing interception ─────────────────────────────────
    // When Ops tries to set a terminal status on a ticket whose creator was
    // an agency user, don't apply it yet — stash it in ops_proposed_* and
    // route the ticket to the App user's response-review queue.
    const opsIsAttemptingTerminal =
      isSystem &&
      d.status &&
      ['resolved', 'closed', 'rejected'].includes(d.status) &&
      current.created_by_context === 'agent' &&
      OPS_WORKING_STATUSES.includes(current.status) &&
      current.triage_state === 'approved_by_app';

    if (opsIsAttemptingTerminal) {
      const sets = {
        ops_proposed_status: d.status,
        ops_proposed_at: toMysqlDatetime(new Date().toISOString()),
        ops_proposed_by: user.id,
        triage_state: 'pending_app_response_review',
        previous_ops_status: current.status,
        // Keep support_requests.status at the current working value so the
        // App user can still see what Ops was actually doing.
      };
      const setClauses = Object.keys(sets).map(k => `${k} = ?`).join(', ');
      await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(sets), requestId]);
      await logActivity(requestId, 'ops_proposed_response', current.status, d.status, user.id, userDisplayName(user), { kind: 'status_change' });
      await refreshDeadline(requestId);
      const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [requestId]);
      // Notify App user(s): Ops has a proposed response waiting for their review.
      await notifyForRequest({
        request: rows[0],
        audiences: ['app'],
        kind: 'support_response_pending_review',
        title: 'Ops response waiting for your review',
        body: `${current.booking_code || ''} — ${current.request_title || current.request_type || ''}`.trim(),
        priority: 'high',
        actor: user,
        metadata: { proposed_status: d.status },
      });
      return ok(res, redactOpsProposal(parseJsonCols(rows[0]), user.login_context));
    }

    // ── Standard status transition validation ────────────────────────────
    if (d.status && d.status !== current.status) {
      const allowed = STATUS_TRANSITIONS[current.status] || [];
      if (!allowed.includes(d.status)) {
        return err(res, `Cannot transition from '${current.status}' to '${d.status}'. Allowed: ${allowed.join(', ')}`, 400);
      }

      // Block moving to a terminal status while any approval request is
      // still pending — we don't want open money on a closed ticket.
      if (['resolved', 'closed'].includes(d.status)) {
        const [[{ cnt }]] = await pool.query(
          `SELECT COUNT(*) AS cnt FROM support_approval_requests
           WHERE request_id = ? AND status = 'pending'`,
          [requestId]
        );
        if (cnt > 0) {
          return err(
            res,
            `Cannot mark this request ${d.status} while ${cnt} approval request(s) are still pending. Approve, reject, or withdraw them first.`,
            409
          );
        }
      }

      if (['resolved', 'closed', 'rejected'].includes(d.status) && !d.resolved_at) {
        d.resolved_at = new Date().toISOString();
      }
      if (REOPEN_STATUSES.includes(current.status) && d.status === 'in_progress') {
        d.reopened_count = (current.reopened_count || 0) + 1;
      }
      d.previous_status = current.status;

      if (d.status === 'escalated' && !d.escalated_at) {
        d.escalated_at = new Date().toISOString();
      }

      await logActivity(requestId, 'status_change', current.status, d.status, user.id, userDisplayName(user));
    }

    // Assignment change logging + open→assigned auto-transition
    if (d.assigned_agent && d.assigned_agent !== current.assigned_agent) {
      if (!d.assigned_at) d.assigned_at = new Date().toISOString();
      if (current.status === 'open' && !d.status) {
        d.status = 'assigned';
        d.previous_status = current.status;
        await logActivity(requestId, 'status_change', current.status, 'assigned', user.id, userDisplayName(user));
      }
      await logActivity(requestId, 'assignment', current.assigned_agent_name || 'Unassigned', d.assigned_agent_name || d.assigned_agent, user.id, userDisplayName(user));
    }

    // Strip client-supplied internal fields that we derive from the JWT now.
    delete d._performed_by;
    delete d._performed_by_name;
    // Don't let clients override audit / scoping columns.
    for (const banned of ['app_id', 'triage_state', 'original_message', 'original_request_type',
                          'original_field_changes', 'app_admin_edited_at', 'app_admin_edited_by',
                          'app_admin_edited_by_name', 'forwarded_to_ops_at', 'forwarded_to_ops_by',
                          'rejected_by_app_at', 'rejected_by_app_by', 'app_review_resubmit_count',
                          'ops_proposed_status', 'ops_proposed_message', 'ops_proposed_at',
                          'ops_proposed_by', 'app_response_edited_at', 'app_response_edited_by',
                          'app_response_edited_by_name', 'returned_to_ops_count',
                          'previous_ops_status', 'created_by_context', 'created_by']) {
      delete d[banned];
    }

    const sets = [];
    const params = [];
    const jsonCols = ['passenger_details', 'booking_details', 'field_changes', 'attached_documents'];
    const dateCols = ['deadline', 'resolved_at', 'assigned_at', 'first_response_at', 'escalated_at'];

    for (const [k, v] of Object.entries(d)) {
      sets.push(`${k} = ?`);
      if (jsonCols.includes(k)) params.push(JSON.stringify(v));
      else if (dateCols.includes(k)) params.push(toMysqlDatetime(v));
      else params.push(v);
    }

    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(requestId);
    await pool.query(`UPDATE support_requests SET ${sets.join(', ')} WHERE id = ?`, params);
    // Deadline policy: see refreshDeadline (now a no-op). The deadline is
    // set once on create and changes only via explicit user action. This
    // call is intentionally retained so the legacy behaviour can be
    // restored in one place if needed; today it does nothing.
    const callerSetDeadline = 'deadline' in (req.body || {}) || 'deadline_extended' in (req.body || {});
    if (!callerSetDeadline) await refreshDeadline(requestId);

    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [requestId]);
    const updated = rows[0];

    // Notify on assignment change (to the new assignee).
    if (d.assigned_agent && d.assigned_agent !== current.assigned_agent) {
      await createNotification({
        user_id: d.assigned_agent,
        kind: 'support_request_assigned',
        title: 'A ticket was assigned to you',
        body: `${updated.booking_code || ''} — ${updated.request_title || updated.request_type || ''}`.trim(),
        priority: 'high',
        link_path: `/support/${requestId}`,
        actor_id: user.id,
        actor_name: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email,
        entity_type: 'support_request',
        entity_id: requestId,
      });
    }

    // Notify on terminal status change (only when this is a real terminal,
    // not an Ops interception into pending_app_response_review handled above).
    if (d.status && d.status !== current.status &&
        ['resolved', 'closed', 'rejected'].includes(d.status)) {
      await notifyForRequest({
        request: updated,
        audiences: ['creator', 'assignee'],
        kind: `support_request_${d.status}`,
        title: d.status === 'resolved' ? 'Ticket resolved'
             : d.status === 'closed'   ? 'Ticket closed'
             : 'Ticket rejected',
        body: `${updated.booking_code || ''} — ${updated.request_title || updated.request_type || ''}`.trim(),
        priority: d.status === 'rejected' ? 'high' : 'normal',
        actor: user,
        metadata: { status: d.status },
      });
    }

    ok(res, redactOpsProposal(parseJsonCols(updated), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// SUPPORT REQUEST — TRIAGE ACTION ENDPOINTS
// ════════════════════════════════════════════

// App user: take ownership of a pending-review ticket and start handling it
// yourself. Moves to status='in_progress' + triage_state='handled_by_app'
// so the customer sees a working ticket and Ops still doesn't see it.
app.post('/rest/v1/support_requests/:id/handle-by-app', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    if (rows.length === 0) return err(res, 'Not found', 404);
    const current = rows[0];

    if (!isAppUserForApp(user, current.app_id)) return err(res, 'Only the App user can handle', 403);
    if (current.triage_state !== 'pending_app_review') return err(res, 'Not in pending_app_review', 400);

    const updates = {
      status: 'in_progress',
      triage_state: 'handled_by_app',
      previous_status: current.status,
      // Auto-assign to the acting App user so the "assigned" card is
      // populated and the App can track their own workload.
      assigned_agent: user.id,
      assigned_agent_name: `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || user.id,
      assigned_at: toMysqlDatetime(new Date().toISOString()),
    };
    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);
    await logActivity(req.params.id, 'handled_by_app', current.status, 'in_progress', user.id, userDisplayName(user));
    await refreshDeadline(req.params.id);

    const [r2] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    // Tell the creator their ticket is being handled.
    await notifyForRequest({
      request: r2[0],
      audiences: ['creator'],
      kind: 'support_request_handled_by_app',
      title: 'Your ticket is being handled',
      body: `${r2[0].booking_code || ''} — ${r2[0].request_title || r2[0].request_type || ''}`.trim(),
      priority: 'normal',
      actor: user,
    });
    ok(res, redactOpsProposal(parseJsonCols(r2[0]), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// App user: forward the request to Ops, optionally editing message/type/field_changes.
// Allowed from either pending_app_review (straight forward) OR handled_by_app
// (App user started working on it and later decided to escalate).
app.post('/rest/v1/support_requests/:id/forward-to-ops', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const { edited_message, edited_request_type, edited_field_changes } = req.body || {};
    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    if (rows.length === 0) return err(res, 'Not found', 404);
    const current = rows[0];

    if (!isAppUserForApp(user, current.app_id)) return err(res, 'Only the App user can forward', 403);
    if (!['pending_app_review', 'handled_by_app'].includes(current.triage_state)) {
      return err(res, 'Ticket is not in a forwardable state', 400);
    }

    const updates = {
      status: 'open',
      triage_state: 'approved_by_app',
      forwarded_to_ops_at: toMysqlDatetime(new Date().toISOString()),
      forwarded_to_ops_by: user.id,
      previous_status: current.status,
    };
    const diff = {};
    if (typeof edited_message === 'string' && edited_message !== current.message) {
      updates.message = edited_message; diff.message = true;
    }
    if (typeof edited_request_type === 'string' && edited_request_type !== current.request_type) {
      updates.request_type = edited_request_type; diff.request_type = true;
    }
    if (edited_field_changes !== undefined) {
      const oldStr = JSON.stringify(parseJsonCols(current).field_changes || []);
      const newStr = JSON.stringify(edited_field_changes || []);
      if (oldStr !== newStr) {
        updates.field_changes = JSON.stringify(edited_field_changes || []);
        diff.field_changes = true;
      }
    }
    if (Object.keys(diff).length > 0) {
      updates.app_admin_edited_at = toMysqlDatetime(new Date().toISOString());
      updates.app_admin_edited_by = user.id;
      updates.app_admin_edited_by_name = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || user.id;
    }

    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);
    await logActivity(req.params.id, 'forwarded_to_ops', current.status, 'open', user.id, userDisplayName(user), { edits: diff });
    await refreshDeadline(req.params.id);

    const [r2] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    // Alert Ops team that a new ticket has hit their queue.
    await notifyForRequest({
      request: r2[0],
      audiences: ['ops'],
      kind: 'support_request_forwarded',
      title: 'New ticket forwarded from App',
      body: `${r2[0].booking_code || ''} — ${r2[0].request_title || r2[0].request_type || ''}`.trim(),
      priority: 'normal',
      actor: user,
      metadata: { edited: Object.keys(diff) },
    });
    ok(res, redactOpsProposal(parseJsonCols(r2[0]), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// App user: reject the request without forwarding to Ops.
app.post('/rest/v1/support_requests/:id/reject-by-app', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const { rejection_reason } = req.body || {};
    if (!rejection_reason || !rejection_reason.trim()) return err(res, 'rejection_reason required', 400);

    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    if (rows.length === 0) return err(res, 'Not found', 404);
    const current = rows[0];

    if (!isAppUserForApp(user, current.app_id)) return err(res, 'Only the App user can reject', 403);
    if (current.triage_state !== 'pending_app_review') return err(res, 'Not in pending_app_review', 400);

    const updates = {
      status: 'rejected',
      triage_state: 'rejected_by_app',
      rejected_by_app_at: toMysqlDatetime(new Date().toISOString()),
      rejected_by_app_by: user.id,
      rejection_reason: rejection_reason.trim(),
      resolved_at: toMysqlDatetime(new Date().toISOString()),
      previous_status: current.status,
    };
    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);
    await logActivity(req.params.id, 'rejected_by_app', current.status, 'rejected', user.id, userDisplayName(user), { reason: rejection_reason.trim() });
    await refreshDeadline(req.params.id);

    const [r2] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    await notifyForRequest({
      request: r2[0],
      audiences: ['creator'],
      kind: 'support_request_rejected_by_app',
      title: 'Your ticket was rejected',
      body: rejection_reason.trim(),
      priority: 'high',
      actor: user,
    });
    ok(res, redactOpsProposal(parseJsonCols(r2[0]), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// App user: approve Ops's proposed response (with optional edits).
// Inserts the final (possibly edited) message into support_messages and
// applies the proposed status.
app.post('/rest/v1/support_requests/:id/approve-ops-response', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const { edited_message, edited_status } = req.body || {};
    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    if (rows.length === 0) return err(res, 'Not found', 404);
    const current = rows[0];

    if (!isAppUserForApp(user, current.app_id)) return err(res, 'Only the App user can approve', 403);
    if (current.triage_state !== 'pending_app_response_review') return err(res, 'Not in pending_app_response_review', 400);

    const finalStatus = edited_status || current.ops_proposed_status;
    const finalMessage = (typeof edited_message === 'string') ? edited_message : (current.ops_proposed_message || '');

    // Publish the message to the customer-facing thread.
    if (finalMessage && finalMessage.trim()) {
      const senderName = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || 'App';
      await pool.query(
        `INSERT INTO support_messages (id, request_id, sender_id, sender_type, sender_name, message, attachments)
         VALUES (?, ?, ?, ?, ?, ?, ?)`,
        [crypto.randomUUID(), req.params.id, user.id, 'agent', senderName, finalMessage, '[]']
      );
    }

    const updates = {
      status: finalStatus,
      triage_state: 'bypassed',
      ops_proposed_status: null,
      ops_proposed_message: null,
      ops_proposed_at: null,
      ops_proposed_by: null,
      previous_status: current.status,
    };
    if (['resolved', 'closed', 'rejected'].includes(finalStatus)) {
      updates.resolved_at = toMysqlDatetime(new Date().toISOString());
    }
    const edited = (typeof edited_message === 'string' && edited_message !== current.ops_proposed_message)
                   || (edited_status && edited_status !== current.ops_proposed_status);
    if (edited) {
      updates.app_response_edited_at = toMysqlDatetime(new Date().toISOString());
      updates.app_response_edited_by = user.id;
      updates.app_response_edited_by_name = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || user.id;
    }

    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);
    await logActivity(req.params.id, 'app_approved_ops_response', current.ops_proposed_status, finalStatus, user.id, userDisplayName(user), { edited });
    await refreshDeadline(req.params.id);

    const [r2] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    // Notify the creator — the response (maybe edited) is now visible.
    await notifyForRequest({
      request: r2[0],
      audiences: ['creator'],
      kind: finalStatus === 'resolved' ? 'support_request_resolved'
           : finalStatus === 'rejected' ? 'support_request_rejected'
           : 'support_response_released',
      title: finalStatus === 'resolved' ? 'Your ticket was resolved'
           : finalStatus === 'rejected' ? 'Your ticket was rejected'
           : 'You have a new reply',
      body: `${r2[0].booking_code || ''} — ${r2[0].request_title || r2[0].request_type || ''}`.trim(),
      priority: 'normal',
      actor: user,
      metadata: { status: finalStatus, edited },
    });
    ok(res, redactOpsProposal(parseJsonCols(r2[0]), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// App user: return Ops's proposal with a reason — bounces back to Ops queue.
app.post('/rest/v1/support_requests/:id/return-to-ops', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const { reason } = req.body || {};
    if (!reason || !reason.trim()) return err(res, 'reason required', 400);

    const [rows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    if (rows.length === 0) return err(res, 'Not found', 404);
    const current = rows[0];

    if (!isAppUserForApp(user, current.app_id)) return err(res, 'Only the App user can return', 403);
    if (current.triage_state !== 'pending_app_response_review') return err(res, 'Not in pending_app_response_review', 400);

    const restoreStatus = current.previous_ops_status && OPS_WORKING_STATUSES.includes(current.previous_ops_status)
      ? current.previous_ops_status
      : 'in_progress';

    const updates = {
      triage_state: 'approved_by_app',
      status: restoreStatus,
      ops_proposed_status: null,
      ops_proposed_message: null,
      ops_proposed_at: null,
      ops_proposed_by: null,
      returned_to_ops_count: (current.returned_to_ops_count || 0) + 1,
      previous_status: current.status,
    };
    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);
    // Internal note so Ops sees the reason in context. Landed in the App↔Ops
    // channel so Ops can read it — the Ops Internal Team Chat stays separate.
    await pool.query(
      `INSERT INTO support_internal_notes (id, request_id, agent_id, agent_name, task_type, comment, attachments, audience)
       VALUES (?, ?, ?, ?, ?, ?, ?, 'app_ops')`,
      [crypto.randomUUID(), req.params.id, user.id,
       `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || user.id,
       'return_to_ops', reason.trim(), '[]']
    );
    await logActivity(req.params.id, 'app_returned_to_ops', current.status, restoreStatus, user.id, userDisplayName(user), { reason: reason.trim() });
    await refreshDeadline(req.params.id);

    const [r2] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [req.params.id]);
    // Ops (+ the previously assigned agent, if any) need to know the response
    // was bounced back.
    await notifyForRequest({
      request: r2[0],
      audiences: ['ops', 'assignee'],
      kind: 'support_response_returned',
      title: 'App returned the response',
      body: reason.trim(),
      priority: 'high',
      actor: user,
    });
    ok(res, redactOpsProposal(parseJsonCols(r2[0]), user.login_context));
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// SUPPORT MESSAGES
// ════════════════════════════════════════════

app.get('/rest/v1/support_messages', authMiddleware, async (req, res) => {
  try {
    let sql = 'SELECT * FROM support_messages WHERE is_deleted = FALSE';
    const params = [];
    if (req.query.request_id) { sql += ' AND request_id = ?'; params.push(req.query.request_id); }
    sql += ' ORDER BY created_at ASC';
    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(parseJsonCols));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_messages', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const d = req.body;

    // Derive sender_type from the authenticated user's login_context so the
    // three-way throttle (client / app / agent) has a reliable signal. We do
    // NOT trust a client-supplied sender_type.
    //   agent  (agency/customer) → 'client'
    //   app    (App user/admin)  → 'app'
    //   system (Ops team)        → 'agent'
    const senderType =
      user.login_context === 'agent' ? 'client'
      : user.login_context === 'app'  ? 'app'
      :                                 'agent';

    // Fetch the owning request so we can decide whether to intercept.
    const [reqRows] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [d.request_id]);
    if (reqRows.length === 0) return err(res, 'Support request not found', 404);
    const current = reqRows[0];

    // ── Routing decision ─────────────────────────────────────────────────
    // Caller can explicitly pick the audience via `recipient`:
    //   - 'ops'       → store as internal note (app_ops audience), customer-invisible
    //   - 'customer'  → store as a normal support_messages row
    //   - undefined   → fall back to the legacy implicit rule below
    //
    // Implicit rule (back-compat): while Ops is handling the ticket
    // (triage_state='approved_by_app' or 'pending_app_response_review'),
    // App↔Ops messages stay internal. The customer's thread is untouched.
    // Must run BEFORE the throttle check so internal chatter doesn't count
    // toward the client-facing 2-in-a-row limit.
    const recipient = (d.recipient || '').toLowerCase();
    if (recipient && !['ops', 'customer'].includes(recipient)) {
      return err(res, "recipient must be 'ops' or 'customer'", 400);
    }
    // Customer (login_context='agent') can't choose a recipient — their
    // messages always go to the support thread.
    const effectiveRecipient = user.login_context === 'agent' ? '' : recipient;

    const opsHandlingStates = ['approved_by_app', 'pending_app_response_review'];
    const isAppOrOps = user.login_context === 'app' || user.login_context === 'system';
    const routeInternal = effectiveRecipient === 'ops'
      || (!effectiveRecipient && isAppOrOps && opsHandlingStates.includes(current.triage_state));
    if (routeInternal && isAppOrOps) {
      const derivedName =
        `${user.first_name || ''} ${user.last_name || ''}`.trim()
        || user.email
        || 'User';
      const noteId = crypto.randomUUID();
      await pool.query(
        `INSERT INTO support_internal_notes (id, request_id, agent_id, agent_name, task_type, comment, attachments, audience)
         VALUES (?, ?, ?, ?, ?, ?, ?, 'app_ops')`,
        [noteId, d.request_id, user.id, derivedName,
         user.login_context === 'app' ? 'app_to_ops' : 'ops_to_app',
         d.message || '', JSON.stringify(d.attachments || [])]
      );
      await logActivity(d.request_id, 'internal_message', null, user.login_context, user.id, derivedName, { redirected_from: 'support_messages' });
      await refreshDeadline(d.request_id);
      // Internal note: fan out to the opposite side of the app_ops channel.
      await notifyForRequest({
        request: current,
        audiences: user.login_context === 'app' ? ['ops', 'assignee'] : ['app'],
        kind: 'support_internal_note',
        title: user.login_context === 'app' ? 'Message from App' : 'Message from Ops',
        body: (d.message || '').slice(0, 200),
        priority: 'normal',
        actor: user,
        metadata: { audience: 'app_ops' },
      });
      return ok(res, { internal: true, request_id: d.request_id, note_id: noteId });
    }

    // ── Throttle: no one may post a 3rd consecutive message on the
    //    customer-visible thread. ───────────────────────────────────────
    const [tail] = await pool.query(
      `SELECT sender_type FROM support_messages
       WHERE request_id = ? AND is_deleted = FALSE
       ORDER BY created_at DESC LIMIT 2`,
      [d.request_id]
    );
    if (tail.length === 2 && tail[0].sender_type === senderType && tail[1].sender_type === senderType) {
      return err(res, 'Please wait for a reply before sending another message (limit: 2 in a row)', 409);
    }

    // ── Normal insert path ──────────────────────────────────────────────
    // sender_name always comes from the authenticated user — never trust the
    // client-supplied value (it would let a spoofed name reach the other side).
    const derivedSenderName =
      `${user.first_name || ''} ${user.last_name || ''}`.trim()
      || user.email
      || 'User';

    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO support_messages (id, request_id, sender_id, sender_type, sender_name, message, attachments) VALUES (?, ?, ?, ?, ?, ?, ?)`,
      [id, d.request_id, user.id, senderType, derivedSenderName, d.message, JSON.stringify(d.attachments || [])]
    );

    // Auto-transition guard: skip when ticket is in any pending-triage state.
    const inTriage = ['pending_app_review', 'pending_app_response_review'].includes(current.status);
    if ((senderType === 'agent') && !inTriage) {
      const updates = {};
      if (!current.first_response_at) {
        updates.first_response_at = toMysqlDatetime(new Date().toISOString());
      }
      if (current.status === 'assigned') {
        updates.status = 'in_progress';
        updates.previous_status = 'assigned';
        await logActivity(d.request_id, 'status_change', 'assigned', 'in_progress', d.sender_id || user.id, d.sender_name, { trigger: 'first_message' });
      }
      if (Object.keys(updates).length > 0) {
        const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
        await pool.query(`UPDATE support_requests SET ${setClauses} WHERE id = ?`, [...Object.values(updates), d.request_id]);
      }
    }

    await logActivity(d.request_id, 'message_sent', null, senderType, d.sender_id || user.id, d.sender_name);
    await refreshDeadline(d.request_id);

    const [rows] = await pool.query('SELECT * FROM support_messages WHERE id = ?', [id]);

    // Notify the other side(s) of the customer-facing thread. The sender is
    // auto-excluded inside createNotification.
    //   client (agency) → ops + app + assignee
    //   agent (ops)     → creator + app
    //   app             → creator + ops + assignee
    const audiencesByType = {
      client: ['ops', 'app', 'assignee'],
      agent:  ['creator', 'app'],
      app:    ['creator', 'ops', 'assignee'],
    };
    await notifyForRequest({
      request: current,
      audiences: audiencesByType[senderType] || ['assignee', 'creator'],
      kind: 'support_message_new',
      title: `New message on ${current.booking_code || 'ticket'}`,
      body: (d.message || '').slice(0, 200),
      priority: 'normal',
      actor: user,
      metadata: { sender_type: senderType, request_number: current.request_number },
    });

    ok(res, parseJsonCols(rows[0]));
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/rest/v1/support_messages/:id', authMiddleware, async (req, res) => {
  try {
    // Grab the request_id first so we can refresh the SLA deadline too.
    const [[row]] = await pool.query('SELECT request_id FROM support_messages WHERE id = ?', [req.params.id]);
    await pool.query('UPDATE support_messages SET is_deleted = ? WHERE id = ?', [req.body.is_deleted ?? true, req.params.id]);
    if (row?.request_id) await refreshDeadline(row.request_id);
    ok(res, { id: req.params.id });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// INTERNAL NOTES
// ════════════════════════════════════════════

app.get('/rest/v1/support_internal_notes', authMiddleware, async (req, res) => {
  try {
    // Audience gate:
    //   agent  → nothing (agency creator never sees internal content)
    //   app    → only audience='app_ops' (the App↔Ops channel)
    //   system → all (both 'ops_only' Internal Team Chat and 'app_ops')
    const ctx = req.platformUser.login_context;
    if (ctx === 'agent') return ok(res, []);

    let sql = 'SELECT * FROM support_internal_notes WHERE is_deleted = FALSE';
    const params = [];
    if (req.query.request_id) { sql += ' AND request_id = ?'; params.push(req.query.request_id); }
    if (ctx === 'app') { sql += " AND audience = 'app_ops'"; }
    else if (req.query.audience) { sql += ' AND audience = ?'; params.push(req.query.audience); }
    sql += ' ORDER BY created_at ASC';
    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(parseJsonCols));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_internal_notes', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    if (user.login_context === 'agent') return err(res, 'Forbidden', 403);
    const d = req.body;
    // Audience rules:
    //   App user → always 'app_ops' (they can't write into the Ops-only chat)
    //   Ops      → caller may request 'app_ops' to reply to the App user,
    //              otherwise default to 'ops_only' (Internal Team Chat).
    const audience =
      user.login_context === 'app'
        ? 'app_ops'
        : (d.audience === 'app_ops' ? 'app_ops' : 'ops_only');
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO support_internal_notes (id, request_id, agent_id, agent_name, task_type, comment, attachments, audience)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, d.request_id, d.agent_id || user.id, d.agent_name, d.task_type, d.comment,
       JSON.stringify(d.attachments || []), audience]
    );
    await logActivity(d.request_id, 'note_added', null, d.task_type || 'note', d.agent_id || user.id, d.agent_name, { audience });
    await refreshDeadline(d.request_id);
    const [rows] = await pool.query('SELECT * FROM support_internal_notes WHERE id = ?', [id]);

    // Notify participants based on the note's audience channel.
    //   ops_only → rest of the Ops team
    //   app_ops  → opposite side of the App↔Ops channel
    const [reqLookup] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [d.request_id]);
    const reqRow = reqLookup[0];
    if (reqRow) {
      let audiences;
      if (audience === 'ops_only') {
        audiences = ['ops'];
      } else {
        audiences = user.login_context === 'app' ? ['ops', 'assignee'] : ['app'];
      }
      await notifyForRequest({
        request: reqRow,
        audiences,
        kind: 'support_internal_note',
        title: audience === 'ops_only' ? 'Ops team note' : 'Internal note',
        body: (d.comment || '').slice(0, 200),
        priority: 'normal',
        actor: user,
        metadata: { audience, task_type: d.task_type || null },
      });
    }

    ok(res, parseJsonCols(rows[0]));
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/rest/v1/support_internal_notes/:id', authMiddleware, async (req, res) => {
  try {
    const [[row]] = await pool.query('SELECT request_id FROM support_internal_notes WHERE id = ?', [req.params.id]);
    await pool.query('UPDATE support_internal_notes SET is_deleted = ? WHERE id = ?', [req.body.is_deleted ?? true, req.params.id]);
    if (row?.request_id) await refreshDeadline(row.request_id);
    ok(res, { id: req.params.id });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// CHARGES
// ════════════════════════════════════════════

app.get('/rest/v1/support_charges', authMiddleware, async (req, res) => {
  try {
    // Charges are read-only to customers (they need to see what they owe /
    // what they've been refunded). Writing a charge is still Ops-only (POST
    // below). For agent viewers we scope by ticket ownership:
    //   - must supply ?request_id
    //   - they must be the creator of that request, or its assigned agent,
    //     otherwise return empty
    const user = req.platformUser;
    if (user.login_context === 'agent') {
      if (!req.query.request_id) return ok(res, []);
      const [[owningReq]] = await pool.query(
        'SELECT created_by, assigned_agent FROM support_requests WHERE id = ?',
        [req.query.request_id]
      );
      if (!owningReq) return ok(res, []);
      if (owningReq.created_by !== user.id && owningReq.assigned_agent !== user.id) {
        return ok(res, []);
      }
    }
    let sql = 'SELECT * FROM support_charges WHERE 1=1';
    const params = [];
    if (req.query.request_id) { sql += ' AND request_id = ?'; params.push(req.query.request_id); }
    sql += ' ORDER BY created_at DESC';
    const [rows] = await pool.query(sql, params);
    // DECIMAL columns come back as strings from mysql2. Coerce to Number so
    // downstream UI can call toFixed() without crashing.
    const numCols = ['supplier_cost', 'other_services', 'service_fee', 'net_amount', 'sale_amount', 'total'];
    const coerced = rows.map(r => {
      const out = { ...r };
      for (const k of numCols) if (out[k] != null) out[k] = Number(out[k]);
      return out;
    });
    ok(res, coerced);
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_charges', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    if (user.login_context !== 'system') return err(res, 'Ops only', 403);
    const d = req.body;
    const id = crypto.randomUUID();
    // net_amount / sale_amount are optional. When provided, the total still
    // wins as the source of truth for the client balance — net/sale are
    // captured separately for finance reporting.
    await pool.query(
      `INSERT INTO support_charges
        (id, request_id, charge_type, supplier_cost, other_services, service_fee, net_amount, sale_amount, total, currency, message, created_by)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [
        id, d.request_id, d.charge_type || 'charge',
        d.supplier_cost || 0, d.other_services || 0, d.service_fee || 0,
        d.net_amount != null ? Number(d.net_amount) : null,
        d.sale_amount != null ? Number(d.sale_amount) : null,
        d.total || 0, d.currency || 'USD', d.message,
        d.created_by || user.id,
      ]
    );
    await logActivity(d.request_id, 'charge_added', null, `${d.charge_type}: ${d.total} ${d.currency}`, d.created_by || user.id, null);
    await refreshDeadline(d.request_id);
    const [rows] = await pool.query('SELECT * FROM support_charges WHERE id = ?', [id]);
    const row = rows[0] || {};
    for (const k of ['supplier_cost', 'other_services', 'service_fee', 'net_amount', 'sale_amount', 'total']) {
      if (row[k] != null) row[k] = Number(row[k]);
    }
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// APPROVAL REQUESTS  (two-sided negotiation layer)
// ════════════════════════════════════════════

// Helper: normalise DECIMAL / JSON columns on read so the UI doesn't crash.
const APPROVAL_JSON_COLS = ['change_payload', 'metadata'];
const APPROVAL_NUM_COLS = ['amount'];
function parseApprovalRow(r) {
  if (!r) return r;
  const out = { ...r };
  for (const k of APPROVAL_NUM_COLS) if (out[k] != null) out[k] = Number(out[k]);
  for (const k of APPROVAL_JSON_COLS) {
    if (out[k] && typeof out[k] === 'string') { try { out[k] = JSON.parse(out[k]); } catch {} }
  }
  return out;
}

// Who can propose from→to?
function canPropose(fromParty, toParty, user) {
  const ctx = user?.login_context;
  if (fromParty === 'ops' && toParty === 'app' && ctx === 'system') return true;
  if (fromParty === 'app' && toParty === 'ops' && ctx === 'app') return true;
  if (fromParty === 'app' && toParty === 'customer' && ctx === 'app') return true;
  return false;
}

// Who is allowed to approve/reject an open proposal?
function canAnswer(row, user) {
  const ctx = user?.login_context;
  if (row.to_party === 'ops' && ctx === 'system') return true;
  if (row.to_party === 'app' && ctx === 'app') return true;
  // Customer answering: login_context='agent' AND they are the ticket's creator
  if (row.to_party === 'customer' && ctx === 'agent') return true;
  return false;
}

// Scope list results by viewer.
function approvalScopeWhere(ctx) {
  // system → see everything
  if (ctx === 'system') return { sql: '', params: [] };
  // app → see every proposal that touches the App user (any direction)
  if (ctx === 'app')    return { sql: " AND (from_party IN ('ops','app') OR to_party IN ('ops','app'))", params: [] };
  // agent/customer → only proposals addressed to them (from_party='app', to_party='customer')
  if (ctx === 'agent')  return { sql: " AND to_party = 'customer'", params: [] };
  return { sql: ' AND 1=0', params: [] };
}

app.get('/rest/v1/support_approval_requests', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    let sql = 'SELECT * FROM support_approval_requests WHERE 1=1';
    const params = [];
    if (req.query.request_id) { sql += ' AND request_id = ?'; params.push(req.query.request_id); }
    if (req.query.status) { sql += ' AND status = ?'; params.push(req.query.status); }
    const scope = approvalScopeWhere(user.login_context);
    sql += scope.sql;
    params.push(...scope.params);
    sql += ' ORDER BY created_at DESC';
    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(parseApprovalRow));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_approval_requests', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const d = req.body || {};
    const from = d.from_party;
    const to = d.to_party;
    const kind = d.kind;

    if (!d.request_id) return err(res, 'request_id required', 400);
    if (!['charge', 'refund', 'change', 'cancellation'].includes(kind)) {
      return err(res, 'kind must be charge|refund|change|cancellation', 400);
    }
    if (!canPropose(from, to, user)) {
      return err(res, `You cannot propose from '${from}' to '${to}' in this login context`, 403);
    }

    // App-user-created tickets put the App user in the *end-customer* role:
    // Ops is the support side, App is who has to approve/reject. So an App
    // user can't propose approvals against their own ticket — Ops is the
    // only party who can initiate ops→app proposals here. Customer-created
    // tickets keep the existing flow (App user can propose to customer or
    // forward proposals upstream to Ops).
    if (user.login_context === 'app') {
      try {
        const [[ticket]] = await pool.query(
          'SELECT created_by_type FROM support_requests WHERE id = ?',
          [d.request_id]
        );
        if (ticket && ticket.created_by_type !== 'client') {
          return err(res, 'App-created tickets do not accept App-side approval proposals — Ops will initiate any required approvals', 403);
        }
      } catch (e) {
        // Lookup failure shouldn't silently allow — fail closed.
        return err(res, 'Could not verify ticket ownership', 500);
      }
    }
    // charge/refund must carry an amount; change/cancellation may omit it
    if ((kind === 'charge' || kind === 'refund') && (d.amount == null || Number(d.amount) <= 0)) {
      return err(res, `${kind} requires a positive amount`, 400);
    }

    const senderName =
      `${user.first_name || ''} ${user.last_name || ''}`.trim()
      || user.email || 'User';

    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO support_approval_requests
         (id, request_id, parent_id, kind, from_party, to_party, amount, currency,
          description, change_payload, status, created_by, created_by_name, metadata)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
      [id, d.request_id, d.parent_id || null, kind, from, to,
       d.amount != null ? Number(d.amount) : null,
       d.currency || (d.amount != null ? 'USD' : null),
       d.description || null,
       d.change_payload ? JSON.stringify(d.change_payload) : null,
       user.id, senderName,
       d.metadata ? JSON.stringify(d.metadata) : null]
    );
    await logActivity(d.request_id, 'approval_proposed', null, `${from}->${to}:${kind}`, user.id, senderName,
      { amount: d.amount != null ? Number(d.amount) : null, currency: d.currency, kind });
    await refreshDeadline(d.request_id);

    const [rows] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [id]);

    // Notify the recipient party.
    const [reqLookup] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [d.request_id]);
    if (reqLookup[0]) {
      const audiencesByTo = {
        ops: ['ops', 'assignee'],
        app: ['app'],
        customer: ['creator'],
      };
      await notifyForRequest({
        request: reqLookup[0],
        audiences: audiencesByTo[to] || ['ops'],
        kind: `approval_proposed_${kind}`,
        title: `${kind.charAt(0).toUpperCase() + kind.slice(1)} proposal`,
        body: d.amount != null
          ? `${d.amount} ${d.currency || 'USD'} — ${d.description || ''}`.trim()
          : (d.description || kind),
        priority: 'high',
        actor: user,
        metadata: { kind, from_party: from, to_party: to, amount: d.amount != null ? Number(d.amount) : null, approval_id: id },
      });
    }

    ok(res, parseApprovalRow(rows[0]));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_approval_requests/:id/approve', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const [[row]] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    const current = parseApprovalRow(row);
    if (current.status !== 'pending') return err(res, `Already ${current.status}`, 409);
    if (!canAnswer(current, user)) return err(res, 'You are not the recipient of this proposal', 403);

    const now = new Date();
    const senderName =
      `${user.first_name || ''} ${user.last_name || ''}`.trim()
      || user.email || 'User';

    // 1. Record the approval
    await pool.query(
      `UPDATE support_approval_requests
       SET status = 'approved', responded_by = ?, responded_by_name = ?, responded_at = ?
       WHERE id = ?`,
      [user.id, senderName, toMysqlDatetime(now.toISOString()), req.params.id]
    );

    // 2. If the approved proposal carries an amount, write a settlement row
    //    into support_charges so accounting stays in one place.
    let chargeId = null;
    if (current.amount != null && ['charge', 'refund', 'change'].includes(current.kind) &&
        ['ops', 'app', 'customer'].includes(current.to_party)) {
      chargeId = crypto.randomUUID();
      const chargeType = current.kind === 'refund' ? 'refund' : 'charge';
      await pool.query(
        `INSERT INTO support_charges (id, request_id, charge_type, supplier_cost, other_services, service_fee, total, currency, message, created_by)
         VALUES (?, ?, ?, 0, 0, 0, ?, ?, ?, ?)`,
        [chargeId, current.request_id, chargeType,
         Number(current.amount), current.currency || 'USD',
         `Approval ${current.id}: ${current.description || current.kind}`,
         user.id]
      );
      await pool.query('UPDATE support_approval_requests SET resulting_charge_id = ? WHERE id = ?',
        [chargeId, current.id]);
    }

    // 3. Chain-to-customer: the App user can relay an Ops→App approval
    //    straight on to the customer in the same request. Only valid when
    //    the approved proposal is ops→app and the caller is App user.
    const pass = req.body || {};
    let childId = null;
    const wantsRelay = pass.pass_to_customer === true;
    if (wantsRelay && current.from_party === 'ops' && current.to_party === 'app' && user.login_context === 'app') {
      const passAmount = (pass.amount != null && Number(pass.amount) > 0)
        ? Number(pass.amount) : null;
      // Enforce: charge/refund must have an amount; change/cancellation may not.
      if ((current.kind === 'charge' || current.kind === 'refund') && !passAmount) {
        return err(res, 'pass_to_customer requires a positive amount for this kind', 400);
      }
      childId = crypto.randomUUID();
      await pool.query(
        `INSERT INTO support_approval_requests
           (id, request_id, parent_id, kind, from_party, to_party, amount, currency,
            description, change_payload, status, created_by, created_by_name, metadata)
         VALUES (?, ?, ?, ?, 'app', 'customer', ?, ?, ?, ?, 'pending', ?, ?, ?)`,
        [childId, current.request_id, current.id, current.kind,
         passAmount,
         pass.currency || current.currency || 'USD',
         pass.description || current.description || null,
         current.change_payload ? JSON.stringify(current.change_payload) : null,
         user.id, senderName,
         JSON.stringify({ relayed_from: current.id, ops_amount: current.amount, app_markup: passAmount != null && current.amount != null ? Number(passAmount) - Number(current.amount) : null })]
      );
      await logActivity(current.request_id, 'approval_relayed_to_customer', String(current.amount), String(passAmount), user.id, senderName,
        { parent_id: current.id, child_id: childId });
    }

    await logActivity(current.request_id, 'approval_approved',
      `${current.from_party}->${current.to_party}:${current.kind}`,
      current.amount != null ? `${current.amount} ${current.currency || ''}`.trim() : null,
      user.id, senderName, { chargeId, childId });
    await refreshDeadline(current.request_id);

    const [[updated]] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [req.params.id]);

    // Notify the original proposer (and their side) that the proposal was accepted.
    const [reqLookup] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [current.request_id]);
    if (reqLookup[0]) {
      const audiencesByFrom = {
        ops: ['ops', 'assignee'],
        app: ['app'],
        customer: ['creator'],
      };
      await notifyForRequest({
        request: reqLookup[0],
        audiences: audiencesByFrom[current.from_party] || ['creator'],
        kind: 'approval_approved',
        title: `${current.kind.charAt(0).toUpperCase() + current.kind.slice(1)} approved`,
        body: current.amount != null
          ? `${current.amount} ${current.currency || 'USD'}`.trim()
          : (current.description || current.kind),
        priority: 'normal',
        actor: user,
        metadata: { kind: current.kind, approval_id: current.id, charge_id: chargeId },
      });
    }

    ok(res, { approved: parseApprovalRow(updated), child_proposal_id: childId, resulting_charge_id: chargeId });
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_approval_requests/:id/reject', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const reason = (req.body || {}).reason || '';
    if (!reason.trim()) return err(res, 'reason required', 400);
    const [[row]] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    const current = parseApprovalRow(row);
    if (current.status !== 'pending') return err(res, `Already ${current.status}`, 409);
    if (!canAnswer(current, user)) return err(res, 'You are not the recipient of this proposal', 403);

    const senderName =
      `${user.first_name || ''} ${user.last_name || ''}`.trim()
      || user.email || 'User';
    await pool.query(
      `UPDATE support_approval_requests
       SET status = 'rejected', responded_by = ?, responded_by_name = ?, responded_at = ?, response_reason = ?
       WHERE id = ?`,
      [user.id, senderName, toMysqlDatetime(new Date().toISOString()), reason.trim(), req.params.id]
    );
    await logActivity(current.request_id, 'approval_rejected',
      `${current.from_party}->${current.to_party}:${current.kind}`, null,
      user.id, senderName, { reason: reason.trim() });
    await refreshDeadline(current.request_id);

    const [[updated]] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [req.params.id]);

    const [reqLookup] = await pool.query('SELECT * FROM support_requests WHERE id = ?', [current.request_id]);
    if (reqLookup[0]) {
      const audiencesByFrom = {
        ops: ['ops', 'assignee'],
        app: ['app'],
        customer: ['creator'],
      };
      await notifyForRequest({
        request: reqLookup[0],
        audiences: audiencesByFrom[current.from_party] || ['creator'],
        kind: 'approval_rejected',
        title: `${current.kind.charAt(0).toUpperCase() + current.kind.slice(1)} rejected`,
        body: reason.trim(),
        priority: 'high',
        actor: user,
        metadata: { kind: current.kind, approval_id: current.id },
      });
    }

    ok(res, parseApprovalRow(updated));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/rest/v1/support_approval_requests/:id/withdraw', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const [[row]] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    const current = parseApprovalRow(row);
    if (current.status !== 'pending') return err(res, `Already ${current.status}`, 409);
    // Only the creator may withdraw.
    if (current.created_by !== user.id) return err(res, 'Only the proposer can withdraw', 403);

    const senderName =
      `${user.first_name || ''} ${user.last_name || ''}`.trim()
      || user.email || 'User';
    await pool.query(
      `UPDATE support_approval_requests
       SET status = 'withdrawn', responded_by = ?, responded_by_name = ?, responded_at = ?
       WHERE id = ?`,
      [user.id, senderName, toMysqlDatetime(new Date().toISOString()), req.params.id]
    );
    await logActivity(current.request_id, 'approval_withdrawn',
      `${current.from_party}->${current.to_party}:${current.kind}`, null,
      user.id, senderName);
    await refreshDeadline(current.request_id);

    const [[updated]] = await pool.query('SELECT * FROM support_approval_requests WHERE id = ?', [req.params.id]);
    ok(res, parseApprovalRow(updated));
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// ACTIVITY LOG
// ════════════════════════════════════════════

app.get('/rest/v1/support_activity_log', authMiddleware, async (req, res) => {
  try {
    let sql = 'SELECT * FROM support_activity_log WHERE 1=1';
    const params = [];
    if (req.query.request_id) { sql += ' AND request_id = ?'; params.push(req.query.request_id); }
    sql += ' ORDER BY created_at DESC';
    if (req.query.limit) { sql += ' LIMIT ?'; params.push(parseInt(req.query.limit)); }
    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(parseJsonCols));
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// NOTIFICATION COUNTS
// ════════════════════════════════════════════

app.get('/rest/v1/support_notification_counts', authMiddleware, async (req, res) => {
  try {
    const userId = req.query.user_id;
    const role = req.query.role || 'agent';

    // Count open requests (visible to admin/manager)
    let openRequests = 0;
    if (role === 'admin' || role === 'manager') {
      const [rows] = await pool.query('SELECT COUNT(*) as cnt FROM support_requests WHERE status = ?', ['open']);
      openRequests = rows[0].cnt;
    }

    // Count assigned-to-me requests
    let assignedToMe = 0;
    if (userId) {
      const [rows] = await pool.query('SELECT COUNT(*) as cnt FROM support_requests WHERE assigned_agent = ? AND status IN (?, ?)', [userId, 'assigned', 'in_progress']);
      assignedToMe = rows[0].cnt;
    }

    // Count unread messages on assigned tickets
    let unreadMessages = 0;
    if (userId) {
      const [rows] = await pool.query(`
        SELECT COUNT(*) as cnt FROM support_messages m
        JOIN support_requests r ON r.id = m.request_id
        LEFT JOIN support_read_cursors c ON c.request_id = m.request_id AND c.user_id = ?
        WHERE r.assigned_agent = ?
          AND m.is_deleted = FALSE
          AND m.sender_id != ?
          AND (c.last_read_at IS NULL OR m.created_at > c.last_read_at)
      `, [userId, userId, userId]);
      unreadMessages = rows[0].cnt;
    }

    ok(res, {
      open_requests: openRequests,
      assigned_to_me: assignedToMe,
      unread_messages: unreadMessages,
      total: openRequests + assignedToMe + unreadMessages,
    });
  } catch (e) { err(res, e.message, 500); }
});

// Recent notifications feed
app.get('/rest/v1/support_notifications', authMiddleware, async (req, res) => {
  try {
    const userId = req.query.user_id;
    const limit = parseInt(req.query.limit) || 20;

    // Fetch recent activity relevant to this user
    const [rows] = await pool.query(`
      SELECT a.*, r.booking_code, r.request_number, r.request_title
      FROM support_activity_log a
      JOIN support_requests r ON r.id = a.request_id
      WHERE (r.assigned_agent = ? OR r.created_by = ? OR a.performed_by = ?)
      ORDER BY a.created_at DESC
      LIMIT ?
    `, [userId, userId, userId, limit]);

    ok(res, rows.map(parseJsonCols));
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// NOTIFICATIONS (per-user durable feed)
// ════════════════════════════════════════════

// List current user's notifications. Supports ?unread_only, ?limit, ?since.
app.get('/rest/v1/notifications', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const limit = Math.min(parseInt(req.query.limit) || 30, 200);
    const offset = Math.max(parseInt(req.query.offset) || 0, 0);
    const unreadOnly = req.query.unread_only === 'true';
    const readOnly = req.query.read_only === 'true';
    const since = req.query.since ? toMysqlDatetime(req.query.since) : null;
    const from = req.query.from ? toMysqlDatetime(req.query.from) : null;
    const to = req.query.to ? toMysqlDatetime(req.query.to) : null;
    const search = (req.query.q || '').toString().trim();
    const includeTotal = req.query.include_total === 'true';

    const csv = (val) => String(val || '')
      .split(',').map(s => s.trim()).filter(Boolean);
    const kinds = csv(req.query.kind);
    const entityTypes = csv(req.query.entity_type);
    const priorities = csv(req.query.priority);

    const where = ['user_id = ?'];
    const params = [userId];
    if (unreadOnly) where.push('is_read = 0');
    if (readOnly)   where.push('is_read = 1');
    if (since) { where.push('created_at > ?'); params.push(since); }
    if (from)  { where.push('created_at >= ?'); params.push(from); }
    if (to)    { where.push('created_at <= ?'); params.push(to); }
    if (kinds.length) {
      where.push(`kind IN (${kinds.map(() => '?').join(',')})`);
      params.push(...kinds);
    }
    if (entityTypes.length) {
      where.push(`entity_type IN (${entityTypes.map(() => '?').join(',')})`);
      params.push(...entityTypes);
    }
    if (priorities.length) {
      where.push(`priority IN (${priorities.map(() => '?').join(',')})`);
      params.push(...priorities);
    }
    if (search) {
      where.push('(title LIKE ? OR body LIKE ?)');
      const like = `%${search}%`;
      params.push(like, like);
    }

    const sqlParams = [...params, limit, offset];
    const [rows] = await pool.query(
      `SELECT * FROM notifications
       WHERE ${where.join(' AND ')}
       ORDER BY created_at DESC
       LIMIT ? OFFSET ?`,
      sqlParams
    );
    const parsed = rows.map(parseJsonCols);

    // Backwards-compatible behaviour: the bell calls this endpoint without
    // include_total and expects a bare array. The list page passes
    // include_total=true and gets a paginated envelope.
    if (!includeTotal) {
      ok(res, parsed);
      return;
    }
    const [[{ total = 0 } = {}]] = await pool.query(
      `SELECT COUNT(*) AS total FROM notifications WHERE ${where.join(' AND ')}`,
      params
    );
    ok(res, { rows: parsed, total: Number(total), limit, offset });
  } catch (e) { err(res, e.message, 500); }
});

// Unread count + a small breakdown by kind for the bell badge.
app.get('/rest/v1/notifications/counts', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const [[{ total = 0 } = {}]] = await pool.query(
      `SELECT COUNT(*) AS total FROM notifications WHERE user_id = ?`,
      [userId]
    );
    const [[{ unread = 0 } = {}]] = await pool.query(
      `SELECT COUNT(*) AS unread FROM notifications WHERE user_id = ? AND is_read = 0`,
      [userId]
    );
    const [byKindRows] = await pool.query(
      `SELECT kind, COUNT(*) AS cnt FROM notifications
       WHERE user_id = ? AND is_read = 0 GROUP BY kind`,
      [userId]
    );
    const byKind = {};
    for (const r of byKindRows) byKind[r.kind] = Number(r.cnt);
    ok(res, { total: Number(total), unread: Number(unread), by_kind: byKind });
  } catch (e) { err(res, e.message, 500); }
});

// Mark a single notification as read / unread.
app.patch('/rest/v1/notifications/:id', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const wantRead = req.body?.is_read !== false; // default: mark as read
    const readAt = wantRead ? toMysqlDatetime(new Date().toISOString()) : null;
    const [result] = await pool.query(
      `UPDATE notifications SET is_read = ?, read_at = ?
       WHERE id = ? AND user_id = ?`,
      [wantRead ? 1 : 0, readAt, req.params.id, userId]
    );
    if (result.affectedRows === 0) return err(res, 'Not found', 404);
    ok(res, { id: req.params.id, is_read: wantRead });
  } catch (e) { err(res, e.message, 500); }
});

// Mark ALL of the current user's notifications as read.
app.post('/rest/v1/notifications/read-all', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const [result] = await pool.query(
      `UPDATE notifications SET is_read = 1, read_at = ?
       WHERE user_id = ? AND is_read = 0`,
      [toMysqlDatetime(new Date().toISOString()), userId]
    );
    ok(res, { marked_read: result.affectedRows });
  } catch (e) { err(res, e.message, 500); }
});

// Mark all unread notifications matching a given entity as read for the
// current user. Called by pages (ticket detail, supplier reminders, emails)
// so that opening the page directly clears any related bell notifications —
// the user is now looking at the thing the notification was pointing at.
//   Body: { entity_type: string, entity_id?: string, kinds?: string[] }
//   entity_id is optional: when omitted, marks every notification of the
//   given entity_type (used by the supplier-reminders index page).
app.post('/rest/v1/notifications/mark-entity-read', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const { entity_type, entity_id, kinds } = req.body || {};
    if (!entity_type) return err(res, 'entity_type required', 400);
    const where = ['user_id = ?', 'is_read = 0', 'entity_type = ?'];
    const params = [userId, entity_type];
    if (entity_id) { where.push('entity_id = ?'); params.push(entity_id); }
    if (Array.isArray(kinds) && kinds.length > 0) {
      where.push(`kind IN (${kinds.map(() => '?').join(',')})`);
      params.push(...kinds);
    }
    const readAt = toMysqlDatetime(new Date().toISOString());
    const [result] = await pool.query(
      `UPDATE notifications SET is_read = 1, read_at = ?
       WHERE ${where.join(' AND ')}`,
      [readAt, ...params]
    );
    ok(res, { marked_read: result.affectedRows });
  } catch (e) { err(res, e.message, 500); }
});

// Bulk mark read/unread by id list. Used by the /notifications page
// table's bulk-action bar.
app.post('/rest/v1/notifications/bulk-mark', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(Boolean) : [];
    const wantRead = req.body?.is_read !== false;
    if (ids.length === 0) return ok(res, { marked: 0 });
    const readAt = wantRead ? toMysqlDatetime(new Date().toISOString()) : null;
    const [result] = await pool.query(
      `UPDATE notifications SET is_read = ?, read_at = ?
       WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
      [wantRead ? 1 : 0, readAt, userId, ...ids]
    );
    ok(res, { marked: result.affectedRows });
  } catch (e) { err(res, e.message, 500); }
});

// Bulk delete by id list.
app.post('/rest/v1/notifications/bulk-delete', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const ids = Array.isArray(req.body?.ids) ? req.body.ids.filter(Boolean) : [];
    if (ids.length === 0) return ok(res, { deleted: 0 });
    const [result] = await pool.query(
      `DELETE FROM notifications
       WHERE user_id = ? AND id IN (${ids.map(() => '?').join(',')})`,
      [userId, ...ids]
    );
    ok(res, { deleted: result.affectedRows });
  } catch (e) { err(res, e.message, 500); }
});

// Delete (user-dismissable).
app.delete('/rest/v1/notifications/:id', authMiddleware, async (req, res) => {
  try {
    const userId = req.platformUser.id;
    const [result] = await pool.query(
      `DELETE FROM notifications WHERE id = ? AND user_id = ?`,
      [req.params.id, userId]
    );
    if (result.affectedRows === 0) return err(res, 'Not found', 404);
    ok(res, { id: req.params.id });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// READ CURSORS
// ════════════════════════════════════════════

app.post('/rest/v1/support_read_cursors', authMiddleware, async (req, res) => {
  try {
    const { request_id, user_id } = req.body;
    const now = toMysqlDatetime(new Date().toISOString());
    await pool.query(
      `INSERT INTO support_read_cursors (id, request_id, user_id, last_read_at) VALUES (?, ?, ?, ?)
       ON DUPLICATE KEY UPDATE last_read_at = ?`,
      [crypto.randomUUID(), request_id, user_id, now, now]
    );
    ok(res, { request_id, user_id, last_read_at: now });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// KPI SUMMARY
// ════════════════════════════════════════════

app.get('/rest/v1/support_kpi', authMiddleware, async (req, res) => {
  try {
    const agentId = req.query.agent_id;
    const from = req.query.from;
    const to = req.query.to;

    let where = '1=1';
    const params = [];
    if (agentId) { where += ' AND assigned_agent = ?'; params.push(agentId); }
    if (from) { where += ' AND created_at >= ?'; params.push(from); }
    if (to) { where += ' AND created_at <= ?'; params.push(to); }

    const [rows] = await pool.query(`
      SELECT
        COUNT(*) as total_tickets,
        SUM(CASE WHEN status = 'resolved' OR status = 'closed' THEN 1 ELSE 0 END) as resolved_tickets,
        SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected_tickets,
        SUM(CASE WHEN reopened_count > 0 THEN 1 ELSE 0 END) as reopened_tickets,
        AVG(CASE WHEN first_response_at IS NOT NULL THEN TIMESTAMPDIFF(SECOND, created_at, first_response_at) END) as avg_first_response_seconds,
        AVG(CASE WHEN resolved_at IS NOT NULL THEN TIMESTAMPDIFF(SECOND, created_at, resolved_at) END) as avg_resolution_seconds,
        SUM(CASE WHEN resolved_at IS NOT NULL AND deadline IS NOT NULL AND resolved_at <= deadline THEN 1 ELSE 0 END) as sla_met,
        SUM(CASE WHEN resolved_at IS NOT NULL AND deadline IS NOT NULL AND resolved_at > deadline THEN 1 ELSE 0 END) as sla_breached
      FROM support_requests WHERE ${where}
    `, params);

    ok(res, rows[0]);
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// FILE UPLOAD
// ════════════════════════════════════════════

// Wrap multer so file-size / MIME rejections return clean JSON errors
// instead of triggering Express's default error handler.
const uploadSingle = (req, res, next) => {
  upload.single('file')(req, res, (uploadErr) => {
    if (uploadErr) {
      const status = uploadErr.code === 'LIMIT_FILE_SIZE' ? 413 : 400;
      return err(res, uploadErr.message || 'Upload failed', status);
    }
    next();
  });
};

app.post('/rest/v1/upload', authMiddleware, uploadSingle, (req, res) => {
  try {
    if (!req.file) return err(res, 'No file uploaded');
    const ext = path.extname(req.file.originalname);
    const newName = `${req.file.filename}${ext}`;
    const newPath = path.join(uploadDir, newName);
    fs.renameSync(req.file.path, newPath);
    ok(res, {
      name: req.file.originalname,
      url: `http://localhost:3001/uploads/${newName}`,
      size: req.file.size,
      type: req.file.mimetype,
      path: newName,
    });
  } catch (e) { err(res, e.message, 500); }
});

// Health check
app.get('/health', (req, res) => ok(res, { status: 'ok', db: 'mysql', version: '3.0' }));

// ════════════════════════════════════════════
// PLATFORM AUTH (via EasyGDS IAM)
// ════════════════════════════════════════════

// Brute-force protection: 5 failed attempts per (email, IP) per 15 minutes.
// Successful logins don't count against the limit, so a legitimate user who
// finally gets their password right isn't penalized for earlier typos.
// Keying by email AND IP means an attacker on one IP can't lock a real user
// out by repeatedly failing on their email from a different network.
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  standardHeaders: true,
  legacyHeaders: false,
  skipSuccessfulRequests: true,
  validate: { keyGeneratorIpFallback: false },
  keyGenerator: (req) => {
    const e = String(req.body?.email || '').toLowerCase().trim();
    return e ? `login:${e}:${req.ip}` : `login:ip:${req.ip}`;
  },
  handler: (req, res) => {
    res.status(429).json({
      data: null,
      error: { message: 'Too many failed login attempts. Try again in 15 minutes.' }
    });
  },
});

// Login — hybrid auth: check local DB first, then fallback to EasyGDS IAM.
// `auth_source` controls which paths run:
//   'local' → OPS-team path. Tries local DB. Skips IAM if no row exists.
//             If a row exists but the password is stale (e.g. operator
//             rotated their EasyGDS password without updating the local
//             hash), falls back to IAM and re-syncs the local hash so
//             the next login is fast again.
//   'iam'   → only EasyGDS IAM (for EasyGDS Admins / App / Agent users)
//   'auto'  → original hybrid behaviour (default for backward compat)
// The /auth and /auth/iam frontends pick the explicit source so the OPS
// login path doesn't pay the IAM network latency on every failed attempt.
app.post('/api/auth/login', loginLimiter, async (req, res) => {
  try {
    const { email, password, scope_type, scope_id } = req.body;
    const authSource = (req.body?.auth_source || 'auto').toLowerCase();
    if (!email || !password) return err(res, 'Email and password required');

    // Shared variables for scope resolution (used across steps)
    let resolvedScopeId = '';
    let resolvedAppId = '';

    // ── STEP 1: Check local platform_users database first (System Admin only) ──
    // For App/Agent logins, skip local DB auth and go straight to IAM API.
    // Also skipped when auth_source='iam' — the EasyGDS Admin login form
    // explicitly opts out of the local DB path for clarity.
    let localUser = null;
    let localRowFound = null; // captured even when password didn't match — see IAM fallback below
    const scopeTypeLower_check = (scope_type || 'System').toLowerCase();
    if (scopeTypeLower_check === 'system' && authSource !== 'iam') {
      try {
        const [rows] = await pool.query(
          'SELECT id, email, password_hash, first_name, last_name, role, is_active, allowed_app_ids, token_version FROM platform_users WHERE email = ? LIMIT 1',
          [email.toLowerCase()]
        );
        if (rows.length > 0) {
          const dbUser = rows[0];
          localRowFound = dbUser;
          const passwordMatch = await bcrypt.compare(password, dbUser.password_hash);
          if (passwordMatch && !dbUser.is_active) {
            console.warn(`Login attempt on deactivated account: id=${dbUser.id}`);
            return err(res, 'Account is deactivated. Contact your administrator.', 403);
          }
          if (passwordMatch) {
            localUser = {
              id: dbUser.id,
              email: dbUser.email,
              first_name: dbUser.first_name || email.split('@')[0],
              last_name: dbUser.last_name || '',
              role: dbUser.role || 'agent',
              is_active: true,
              allowed_app_ids: dbUser.allowed_app_ids ? (typeof dbUser.allowed_app_ids === 'string' ? JSON.parse(dbUser.allowed_app_ids) : dbUser.allowed_app_ids) : null,
              scope_type: scope_type || 'System',
              scope_id: null,
              _source: 'local',
              _token_version: dbUser.token_version | 0,
            };
            console.log(`✅ Local DB auth success (System Admin): ${maskEmail(email)} (role: ${localUser.role})`);

            // Update last_login
            await pool.query('UPDATE platform_users SET last_login = NOW() WHERE id = ?', [dbUser.id]).catch(() => {});
          }
        }
      } catch (dbErr) {
        console.warn('Local DB auth check failed (non-fatal):', dbErr.message);
      }
    }

    // ── STEP 2: If local auth failed (or App/Agent login), try EasyGDS IAM ──
    // For auth_source='local', we still fall back to IAM IF a local row
    // exists but the password didn't match — covers OPS users whose
    // EasyGDS password rotated without the local hash being updated. After
    // a successful IAM verify in that case we re-hash and store the new
    // password locally so the next /auth login is fast again. Without a
    // local row, auth_source='local' refuses cleanly (no IAM round-trip).
    let iamUser = null;
    let iamAccessToken = null;
    const tryIam = !localUser && (
      authSource !== 'local' ||
      localRowFound !== null
    );
    if (tryIam) {
      // For App/Agent scope, resolve app code (and agency name) to IDs
      resolvedScopeId = scope_id || '';
      if ((scope_type === 'App' || scope_type === 'Agent') && scope_id) {
        // scope_id might be "appCode" or "appCode/agencyName"
        const parts = scope_id.split('/');
        const appCode = parts[0];
        const agencySlug = parts[1] || null; // e.g., "admin-demo-5"

        // Skip resolution if it's already a UUID
        const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}/.test(appCode);
        if (!isUUID) {
          try {
            const sysToken = await getEasyGDSToken();

            // Step 1: Resolve app code to app ID.
            // Same slug-tolerance as `handleVerifyLogin` so URL-style slugs
            // like "test-app", "lp-luxury-vacations-llc" match against the
            // upstream's "TEST APP" / "LP Luxury Vacations LLC" names.
            // Without this, the login form rejects a slug that the verify
            // endpoint already accepted — leaking the URL slug into a
            // user-facing error.
            //
            // Cached helper — saves ~2.5s on every App-scope login. The
            // apps list barely changes; 10-min TTL is safe.
            const apps = await getAppsList({ perPage: 200 });
            const decodedAppParam = decodeURIComponent(appCode || '').toLowerCase().trim();
            const slug = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, '-');
            const matchedApp = apps.find(a => {
              const codeRaw = (a.code || '').toLowerCase();
              const nameRaw = (a.name || '').toLowerCase();
              return codeRaw === decodedAppParam
                  || nameRaw === decodedAppParam
                  || slug(a.code) === decodedAppParam
                  || slug(a.name) === decodedAppParam
                  || slug(a.name) === slug(decodedAppParam)
                  || slug(a.code) === slug(decodedAppParam);
            });
            if (!matchedApp) {
              return err(res, `App "${appCode}" not found. Check the app code and try again.`, 400);
            }
            resolvedAppId = matchedApp.id;

            if (scope_type === 'Agent' && agencySlug) {
              // Step 2: Resolve agency name to agent ID within the app
              const agentsRes = await fetch(`${EASYGDS_CORE_URL}/b2b/agents/list?app_ids=${matchedApp.id}&per_page=200`, {
                headers: { 'Authorization': `Bearer ${sysToken}` }
              });
              const agentsData = await agentsRes.json();
              const agents = agentsData.result || [];
              // Match by name (slug format: "admin-demo-5" matches "admin demo 5")
              const normalizedSlug = agencySlug.replace(/-/g, ' ').toLowerCase();
              const nameMatches = agents.filter(a =>
                a.name?.toLowerCase() === normalizedSlug
                || a.name?.toLowerCase().replace(/\s+/g, '-') === agencySlug.toLowerCase()
                || a.code?.toLowerCase() === agencySlug.toLowerCase()
              );
              // Prefer the root agent (parent_id is null) when multiple agents share the same name
              const matchedAgent = nameMatches.find(a => !a.parent_id) || nameMatches[0] || null;
              if (matchedAgent) {
                resolvedScopeId = matchedAgent.id;
                console.log(`✅ Resolved agency "${agencySlug}" → ${matchedAgent.name} (${matchedAgent.id})`);
              } else {
                console.warn(`Agency "${agencySlug}" not found in app ${appCode}, falling back to app scope`);
                // Fallback: use app ID as scope (will work for app-level users)
                resolvedScopeId = matchedApp.id;
              }
            } else {
              // App scope or no agency specified
              resolvedScopeId = matchedApp.id;
            }
          } catch (e) {
            console.warn('Could not resolve scope:', e.message);
          }
        }
      }

      try {
        // Send the RESOLVED canonical id to IAM, not the URL slug. Two URLs
        // pointing at the same app (e.g. /login/abc1 and /login/abc-solutions
        // both resolve to ABC Solutions) must hit IAM with the same scope_id
        // so they return the same auth result. Without this, IAM would see
        // different scope_ids per URL and could behave differently for each.
        //   - App scope:   send the resolved app UUID
        //   - Agent scope: resolvedScopeId is already the agent UUID (set
        //                  earlier when agency name was resolved)
        //   - System scope: resolvedScopeId stays as-is (usually empty)
        const iamScopeId = (scope_type === 'App' && resolvedAppId)
          ? resolvedAppId
          : resolvedScopeId;

        const iamRes = await fetch(`${EASYGDS_IAM_URL}/auth`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            scope_type: scope_type || 'System',
            scope_id: iamScopeId,
            email,
            secret_key: password,
            flow_type: 'Basic'
          })
        });

        const iamData = await iamRes.json();

        if (iamData.access_token) {
          iamAccessToken = iamData.access_token;

          // Decode IAM token
          const tokenParts = iamData.access_token.split('.');
          let payload = {};
          try { payload = JSON.parse(Buffer.from(tokenParts[1], 'base64').toString()); } catch {}

          // Determine role from scope_type
          const scopeType = (scope_type || 'System').toLowerCase();
          let role = 'agent';
          if (scopeType === 'system') role = 'admin';
          else if (scopeType === 'app') role = 'manager';

          // Resolve the FULL list of apps this user has access to.
          // Three-tier strategy:
          //   1. Read from app_members_cache (mirror, refreshed every 10 min).
          //      Fast, deterministic, no upstream dependency.
          //   2. Cache miss → live IAM fallback to /accounts/app-members/list.
          //      Covers: fresh boot before first sync; new user granted
          //      since last sync; cold cache after a wipe.
          //   3. Both empty → fall back to the URL-resolved app. Covers Ops
          //      accounts with system-scoped roles (no app-member rows) and
          //      true outages.
          //
          // Returns ALL the user's app memberships, so a user logging in via
          // /login/abc1 still gets every app they're enrolled in on the JWT —
          // the URL chooses the STARTING app, not the only one they can reach.
          let userAllowedAppIds = await getCachedAllowedAppIds(email);
          if (userAllowedAppIds.length > 0) {
            console.log(`✅ Cached app-members for ${maskEmail(email)}: ${userAllowedAppIds.length} app(s)`);
          } else {
            // Cache miss — try the live IAM lookup (covers fresh boot,
            // newly-added users, and post-cache-wipe situations).
            const live = await liveLookupUserApps(email);
            if (live.length > 0) {
              userAllowedAppIds = live;
              console.log(`✅ Live IAM lookup for ${maskEmail(email)}: ${live.length} app(s) (cache cold)`);
            } else {
              // Both cache and live lookup empty — last-resort: trigger
              // the bulk sync inline (iterates apps, fetches members per
              // app — same path as the periodic 10-min sync but right
              // now). Bounded by the single-flight guard so concurrent
              // logins don't double-run it. After it completes, re-query
              // the cache.
              //
              // This is the heaviest path but it's the only one that's
              // guaranteed to find the user's memberships if IAM's
              // search_texts/email filters don't surface them. Triggered
              // only on cold-cache login, so it pays for itself once and
              // every subsequent login is fast (cache hit).
              console.warn(`Cold cache + empty live lookup for ${maskEmail(email)} — triggering bulk sync`);
              try {
                await syncAppMembersCache();
                userAllowedAppIds = await getCachedAllowedAppIds(email);
                if (userAllowedAppIds.length > 0) {
                  console.log(`✅ Post-bulk-sync resolved ${userAllowedAppIds.length} app(s) for ${maskEmail(email)}`);
                }
              } catch (e) {
                console.warn('Inline bulk sync failed (non-fatal):', e.message);
              }
            }
          }
          if (userAllowedAppIds.length === 0) {
            userAllowedAppIds = resolvedAppId ? [resolvedAppId] : (resolvedScopeId ? [resolvedScopeId] : null);
          }

          iamUser = {
            id: payload.sub || payload.user_id || payload.id || email,
            email: payload.email || email,
            first_name: payload.first_name || payload.name || email.split('@')[0],
            last_name: payload.last_name || '',
            role,
            scope_type: scope_type || 'System',
            scope_id: resolvedScopeId || null,
            is_active: true,
            allowed_app_ids: userAllowedAppIds,
            _source: 'iam',
          };
          console.log(`✅ EasyGDS IAM auth success: ${maskEmail(email)} (scope: ${scope_type}, role: ${role})`);
        }
      } catch (iamErr) {
        console.warn('EasyGDS IAM auth failed:', iamErr.message);
      }
    }

    // ── STEP 3: Merge — local DB overrides IAM if user exists in both ──
    const finalUser = localUser || iamUser;
    if (!finalUser) {
      return err(res, 'Invalid email or password. Check your credentials and access level.', 401);
    }

    // If IAM succeeded, check if local DB has an override for this user
    if (iamUser && !localUser) {
      try {
        const [overrideRows] = await pool.query(
          'SELECT id, role, allowed_app_ids, first_name, last_name, is_active FROM platform_users WHERE email = ? LIMIT 1',
          [email.toLowerCase()]
        );
        if (overrideRows.length > 0 && overrideRows[0].is_active) {
          const override = overrideRows[0];
          const isAppOrAgentLogin = scopeTypeLower_check === 'app' || scopeTypeLower_check === 'agent';

          // Apply identity / profile fields from the local DB unconditionally
          // — name and last_login are local-DB territory and IAM doesn't own
          // them. The id swap also matters so audit-event and message rows
          // stay tied to the platform_users PK.
          finalUser.id = override.id;
          if (override.first_name) finalUser.first_name = override.first_name;
          if (override.last_name) finalUser.last_name = override.last_name;
          await pool.query('UPDATE platform_users SET last_login = NOW() WHERE id = ?', [override.id]).catch(() => {});

          // Role + allowed_app_ids: only apply the local override for SYSTEM-
          // scope logins. For App/Agent logins, IAM is the source of truth
          // for the user's app memberships — overwriting them with the local
          // DB's stale snapshot is exactly the bug that locked
          // abanoub.helmy@appsnow.io to one app instead of three.
          //
          // Why this happened: a `platform_users` row was created for an
          // App user (probably to grant them an Ops console role) with a
          // narrow `allowed_app_ids` set. On App-scope login the override
          // would replace IAM's complete list with the narrow local one and
          // any URL-resolved app outside that set would be 403'd.
          if (!isAppOrAgentLogin) {
            if (override.role) finalUser.role = override.role;
            if (override.allowed_app_ids) {
              finalUser.allowed_app_ids = typeof override.allowed_app_ids === 'string'
                ? JSON.parse(override.allowed_app_ids) : override.allowed_app_ids;
            }
            console.log(`✅ Local DB override applied for ${maskEmail(email)}: role=${finalUser.role}`);
          } else {
            console.log(`✅ App/Agent login for ${maskEmail(email)}: keeping IAM role + allowed_app_ids (${(finalUser.allowed_app_ids || []).length} app(s))`);
          }
        }
      } catch (e) {
        console.warn('Local DB override check failed (non-fatal):', e.message);
      }
    }

    // If we just authenticated via IAM but the user has a local row whose
    // password_hash didn't match what the user typed, the local hash drifted
    // (likely the operator rotated their EasyGDS password without updating
    // the local copy). Re-sync now so the next /auth login skips the IAM
    // round-trip. Best-effort — doesn't block the response if it fails.
    if (iamUser && localRowFound && !localUser) {
      try {
        const newHash = await bcrypt.hash(password, 12);
        await pool.query(
          'UPDATE platform_users SET password_hash = ? WHERE id = ?',
          [newHash, localRowFound.id]
        );
        invalidateUserRowCache(localRowFound.id);
        console.log(`Local password_hash re-synced from IAM for ${maskEmail(email)}`);
      } catch (e) {
        console.warn('Local password re-sync failed (non-fatal):', e.message);
      }
    }

    // Remove internal field
    delete finalUser._source;

    // Store the per-user EasyGDS IAM token for any per-user calls.
    // IMPORTANT: do NOT overwrite the global service-account token (`easygdsToken`)
    // with a user's personal IAM token — user tokens have narrower scope than the
    // service account (e.g. /b2b/agents/list returns 0 results), which would break
    // every server-side proxy call (agencies dropdown, etc.) for the rest of the session.
    if (iamAccessToken) {
      setUserToken(email, { token: iamAccessToken, exp: Date.now() / 1000 + 43200 });
    }

    // Determine login context for UI locking
    const scopeTypeLower = (scope_type || 'System').toLowerCase();
    finalUser.login_context = scopeTypeLower === 'system' ? 'system'
      : scopeTypeLower === 'app' ? 'app'
      : 'agent'; // Agent scope

    // Set locked IDs based on login context
    finalUser.locked_app_id = resolvedAppId || null;
    finalUser.locked_agent_id = (scopeTypeLower === 'agent' && resolvedScopeId) ? resolvedScopeId : null;

    // ENFORCE allowed_app_ids restrictions regardless of scope_type chosen.
    // Three branches based on the user's situation:
    //
    //   A) URL-scoped login (the user opened /login/<app> or /login/<app>/<agency>):
    //      - resolvedAppId is set to the app from the URL.
    //      - If that app is NOT in their allowed_app_ids, REJECT with 403.
    //        Critical fix: previously the override would silently rewrite
    //        locked_app_id to their first allowed app, so a user with
    //        allowed=[AppsNow] who hit /login/abc-solutions would log in but
    //        end up seeing AppsNow's data — a tenant-isolation breach.
    //      - If it IS in their list, lock them to that app (the URL choice).
    //
    //   B) System-scope login by a restricted user:
    //      - No specific app from the URL.
    //      - With 1 allowed app → lock to it.
    //      - With multiple → downgrade to 'app' context, default to first.
    //
    //   C) Admin / manager (Ops):
    //      - Bypass entirely. Ops legitimately needs cross-tenant System
    //        scope.
    const isOpsRole = finalUser.role === 'admin' || finalUser.role === 'manager';
    const allowed = Array.isArray(finalUser.allowed_app_ids) ? finalUser.allowed_app_ids : [];

    if (!isOpsRole && allowed.length > 0 && resolvedAppId) {
      // Branch A — user logged in via a specific app URL.
      if (!allowed.includes(resolvedAppId)) {
        console.warn(`🛑 Login denied for ${maskEmail(email)}: tried to access ${resolvedAppId} but allowed_app_ids = ${JSON.stringify(allowed)}`);
        return err(res, 'You do not have access to this application. Contact your administrator.', 403);
      }
      // The URL choice is one of the allowed apps — honour it.
      finalUser.login_context = 'app';
      finalUser.locked_app_id = resolvedAppId;
      console.log(`✅ Scoped login for ${maskEmail(email)}: ${resolvedAppId}`);
    } else if (!isOpsRole && allowed.length === 1) {
      // Branch B — single-app restriction, no URL.
      finalUser.login_context = 'app';
      finalUser.locked_app_id = allowed[0];
      console.log(`✅ Single-app override enforced for ${maskEmail(email)}: locked to ${allowed[0]}`);
    } else if (!isOpsRole && allowed.length > 1 && scopeTypeLower === 'system') {
      // Multi-app non-Ops user picked /auth (System scope).
      //
      // Downgrade context to 'app' (they're not Ops — no cross-tenant
      // authority, no Ops UI) but DON'T pin them to a single app: leave
      // locked_app_id null so the header dropdown lets them switch among
      // their N allowed apps. The frontend treats N>1 with no lock as
      // "All My Applications" mode by default.
      finalUser.login_context = 'app';
      finalUser.locked_app_id = null;
      console.log(`✅ Multi-app non-Ops user ${maskEmail(email)}: ${allowed.length} apps allowed, dropdown enabled`);
    }

    // Compute Ops team membership before signing so we can bake it into
    // the JWT — read gates downstream (emails inbox, system-owned
    // resources) can then check it for free without a DB round-trip.
    // Also returned on the user object so the SPA hook unlocks
    // Ops-team-only UI affordances (assign dropdown, charges/refunds,
    // apply-to-booking) immediately for users whose login_context isn't
    // 'system'. Mirrors /api/auth/me.
    finalUser.is_ops_team_member = await checkOpsTeamMembership(finalUser.id);

    // Issue platform JWT. `tv` (token_version) is only set for local users —
    // bumping platform_users.token_version invalidates every JWT issued before
    // the bump (used on logout + account deactivation). IAM users have no
    // server-side counterpart so we can't revoke their tokens this way; their
    // expiry is all we have.
    const jwtPayload = {
      id: finalUser.id, email: finalUser.email, role: finalUser.role,
      first_name: finalUser.first_name, last_name: finalUser.last_name,
      scope_type: finalUser.scope_type, scope_id: finalUser.scope_id,
      allowed_app_ids: finalUser.allowed_app_ids,
      login_context: finalUser.login_context,
      locked_app_id: finalUser.locked_app_id,
      locked_agent_id: finalUser.locked_agent_id,
      is_ops_team_member: finalUser.is_ops_team_member,
    };
    if (finalUser._source === 'local' && Number.isFinite(finalUser._token_version)) {
      jwtPayload.tv = finalUser._token_version;
    }
    const platformToken = jwt.sign(jwtPayload, JWT_SECRET, { expiresIn: '12h' });

    // Set token as httpOnly cookie so XSS in the SPA can't read it. We also
    // continue to return it in the response body during the migration so the
    // existing localStorage-based frontend keeps working until every call site
    // is switched to credentials: 'include'.
    setAuthCookies(res, platformToken);

    ok(res, { token: platformToken, user: finalUser });
  } catch (e) { err(res, e.message || 'Login failed', 500); }
});

// Logout — clears auth cookies and, for local users, bumps token_version so
// any other JWTs already issued for this account (e.g. on a stolen laptop)
// are immediately invalidated by authMiddleware on their next request.
app.post('/api/auth/logout', async (req, res) => {
  try {
    // Try to invalidate the user's other sessions if we can identify them.
    // Authentication is best-effort here — even if the JWT is missing or
    // expired we still clear the cookies so the browser's idea of "logged in"
    // matches reality.
    let userId = null;
    const token = (req.cookies && req.cookies.platform_token)
      || (req.headers.authorization && req.headers.authorization.startsWith('Bearer ')
            ? req.headers.authorization.split(' ')[1]
            : null);
    if (token) {
      try {
        const decoded = jwt.verify(token, JWT_SECRET);
        if (decoded && Number.isFinite(decoded.tv)) userId = decoded.id;
      } catch { /* expired/invalid — still clear cookies */ }
    }
    if (userId) {
      await pool.query(
        'UPDATE platform_users SET token_version = token_version + 1 WHERE id = ?',
        [userId]
      ).catch(() => {});
      invalidateUserRowCache(userId);
    }
    clearAuthCookies(res);
    ok(res, { ok: true });
  } catch (e) { err(res, e.message || 'Logout failed', 500); }
});

// Get current user — re-queries platform_users for live role/is_active/allowed_app_ids
// so that revoking access (is_active=false) or changing a role takes effect on the
// next /me call instead of waiting up to 12h for the JWT to expire. Falls back to
// JWT data for IAM-only users (App/Agent/EasyGDS Admin), who aren't in our DB.
app.get('/api/auth/me', authMiddleware, async (req, res) => {
  try {
    const u = req.platformUser;

    // authMiddleware already loaded (and cached) the live row when it
    // verified token_version. Re-use it instead of querying again.
    let live = req._liveUserRow || null;
    if (live === null && Number.isFinite(u.tv)) {
      try { live = await loadLiveUserRow(u.id); } catch { /* fall through to JWT */ }
    }

    if (live && !live.is_active) {
      return err(res, 'Account is deactivated. Contact your administrator.', 403);
    }

    const allowed_app_ids = live?.allowed_app_ids
      ? (typeof live.allowed_app_ids === 'string' ? JSON.parse(live.allowed_app_ids) : live.allowed_app_ids)
      : (u.allowed_app_ids || null);

    // Fetch the user's saved email signature so the profile editor can
    // pre-fill it without a second round trip. Best-effort — if the column
    // doesn't exist on legacy DBs we just return null.
    let email_signature = null;
    try {
      const [[sig]] = await pool.query(
        'SELECT email_signature FROM platform_users WHERE id = ?',
        [u.id]
      );
      email_signature = sig?.email_signature || null;
    } catch { /* legacy DB without the column */ }

    // is_ops_team_member: true when the user has at least one row in
    // ops_team_members for a still-active team. The UI uses this to show
    // Ops-team-only affordances (assign-to-other dropdown, charges,
    // apply-to-booking) for users who are NOT login_context='system'
    // admins — e.g., an agent-role user added to an "Ops Frontline" team
    // whom the auth flow auto-downgraded to login_context='app'. The
    // server-side rule engine already treats them as Ops; the UI was the
    // last place still gating purely on `isOps = system + admin`.
    const is_ops_team_member = await checkOpsTeamMembership(u.id);

    ok(res, {
      id: u.id,
      email: u.email,
      first_name: u.first_name,
      last_name: u.last_name,
      role: live?.role || u.role,
      scope_type: u.scope_type,
      scope_id: u.scope_id,
      is_active: true,
      allowed_app_ids,
      login_context: u.login_context || 'system',
      locked_app_id: u.locked_app_id || null,
      locked_agent_id: u.locked_agent_id || null,
      email_signature,
      is_ops_team_member,
    });
  } catch (e) { err(res, e.message, 500); }
});

// PATCH /api/users/me/profile — self-service profile editor (currently the
// email signature; first/last name will land here next). All authenticated
// users can update their own; no admin gate.
app.patch('/api/users/me/profile', authMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const sets = [];
    const params = [];
    if ('email_signature' in d) {
      // Empty string clears the signature → fall back to default-from-name.
      sets.push('email_signature = ?');
      params.push(typeof d.email_signature === 'string' ? d.email_signature : null);
    }
    if ('first_name' in d) {
      sets.push('first_name = ?');
      params.push(typeof d.first_name === 'string' ? d.first_name : null);
    }
    if ('last_name' in d) {
      sets.push('last_name = ?');
      params.push(typeof d.last_name === 'string' ? d.last_name : null);
    }
    if (sets.length === 0) return err(res, 'No fields to update', 400);
    params.push(req.platformUser.id);
    await pool.query(`UPDATE platform_users SET ${sets.join(', ')} WHERE id = ?`, params);
    invalidateUserRowCache(req.platformUser.id);
    const [[row]] = await pool.query(
      'SELECT id, email, first_name, last_name, email_signature FROM platform_users WHERE id = ?',
      [req.platformUser.id]
    );
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// ── Admin: app-members directory ───────────────────────────────────────
//
// Surfaces the local app_members_cache for the User Management page +
// support tools. Reads only — writes happen via syncAppMembersCache().
//
//   GET  /api/platform-users/lookup?email=<email>
//        → { account_id, email, first_name, last_name, apps: [...] }
//        Useful for "which apps does this user have access to?"
//
//   POST /api/admin/app-members/sync
//        → triggers an immediate re-sync; returns last run summary
//
//   GET  /api/admin/app-members/status
//        → diagnostic: when did sync last run, how many rows, etc.

app.get('/api/platform-users/lookup', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const email = String(req.query.email || '').toLowerCase().trim();
    if (!email) return err(res, 'email required', 400);

    const [rows] = await pool.query(
      `SELECT m.app_id, m.account_id, m.email, m.first_name, m.last_name,
              m.enabled, m.status, m.roles, m.last_synced_at
         FROM app_members_cache m
        WHERE m.email = ?`,
      [email]
    );

    // Resolve app names. Cheap because /apps/list is heavily cached upstream
    // (and only called once even when the user has many memberships).
    let appsById = new Map();
    if (rows.length > 0) {
      try {
        const data = await easygdsRequest('/apps/list?per_page=200');
        for (const a of (data.result || [])) appsById.set(a.id, a);
      } catch (e) { /* fall through with raw IDs */ }
    }

    const apps = rows.map(r => {
      const app = appsById.get(r.app_id) || {};
      return {
        app_id: r.app_id,
        app_name: app.name || null,
        app_code: app.code || null,
        app_status: app.status || null,
        member_status: r.status,
        enabled: !!r.enabled,
        roles: typeof r.roles === 'string' ? JSON.parse(r.roles || '[]') : (r.roles || []),
        last_synced_at: r.last_synced_at,
      };
    });

    ok(res, {
      email,
      account_id: rows[0]?.account_id || null,
      first_name: rows[0]?.first_name || null,
      last_name: rows[0]?.last_name || null,
      total_apps: apps.length,
      apps,
    });
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/admin/app-members/sync', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const result = await syncAppMembersCache();
    const [[count]] = await pool.query('SELECT COUNT(*) AS c FROM app_members_cache');
    ok(res, { ...result, total_rows: count.c });
  } catch (e) { err(res, e.message, 500); }
});

app.get('/api/admin/app-members/status', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [[count]] = await pool.query('SELECT COUNT(*) AS c FROM app_members_cache');
    const [[oldest]] = await pool.query('SELECT MIN(last_synced_at) AS t FROM app_members_cache');
    ok(res, {
      total_rows: count.c,
      oldest_synced_at: oldest.t,
      sync_interval_ms: APP_MEMBERS_SYNC_INTERVAL_MS,
      last_run: _appMembersSyncLast,
      currently_running: _appMembersSyncRunning,
    });
  } catch (e) { err(res, e.message, 500); }
});

// ── Agent (agency) IAM users + impersonation ───────────────────────────
//
// "Open as Agent" feature for Ops admins: when you've selected an agent
// in the header dropdown, you get a button that lists the agency's IAM
// users, then a per-user "Login as" that mints an impersonation token
// and redirects the operator to the b2b dashboard impersonating that
// user. Mirrors the native EasyGDS dashboard's IAM Management dialog.
//
// We try a few likely URL patterns for the IAM endpoints (since the
// docs aren't available on this side) and use the first that returns
// data. If they all fail, the API returns the error message verbatim
// so the operator can paste the right path back to me.

// Construct the b2b-auth callback URL. The b2b dashboard is served from
// the BookingApp domain (path: /b2b-auth/callback), so we use the app's
// `booking_app_base_domain` (custom vanity, e.g. "booking.emeco.com")
// when set, falling back to `booking_app_default_domain` (EasyGDS-managed
// default, e.g. "emeco.apps.easygds.com").
async function buildB2bCallbackUrl(appId, appCode, accessToken) {
  let host = null;
  try {
    const apps = await getAppsList({ perPage: 200 });
    const app = apps.find(a => (appId && a.id === appId) || (appCode && a.code === appCode));
    if (app) {
      host = app.booking_app_base_domain || app.booking_app_default_domain || null;
    }
  } catch (e) { /* fall through to manual guess */ }
  // Last-ditch fallback: derive the default host from the env URL.
  if (!host && appCode) {
    const isStaging = /stg-services|stg-apps/i.test(EASYGDS_CORE_URL);
    host = `${appCode.toLowerCase()}.${isStaging ? 'stg-apps' : 'apps'}.easygds.com`;
  }
  if (!host) return null;
  return `https://${host}/b2b-auth/callback?access_token=${encodeURIComponent(accessToken)}`;
}

// GET /api/admin/agent/:agent_id/users?app_id=<uuid>
//   Returns only the IAM users that are actual members of the given agent.
//
// EasyGDS IAM doesn't expose a single endpoint that lists "users of agent
// X" filtered by scope. So we use a two-step membership probe (validated
// against prod):
//
//   1. /accounts/app-members/list?app_id=X       — users with access to
//                                                   the parent app.
//   2. For each user, POST /auth/impersonate with {scope_type:'Agent',
//      scope_id, account_id} — 200 means they're a member of the agent,
//      403 means they're not.
//
// Probes are cached in-process for 10 min keyed by `<agent_id>:<account_id>`
// so the dialog only burns N impersonate calls the first time (and cache
// hits on every subsequent search). 403-probes don't generate b2b sessions
// but DO touch IAM, so caching is important to keep audit-log noise low.
const _agentMemberCache = new Map(); // 'agentId:accountId' → { isMember:boolean, ts:number }
const AGENT_MEMBER_TTL = 10 * 60 * 1000;
async function probeIsAgentMember(agentId, accountId, sysToken) {
  const key = `${agentId}:${accountId}`;
  const cached = _agentMemberCache.get(key);
  if (cached && Date.now() - cached.ts < AGENT_MEMBER_TTL) return cached.isMember;
  const r = await fetch(`${EASYGDS_IAM_URL}/auth/impersonate`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${sysToken}` },
    body: JSON.stringify({ scope_type: 'Agent', scope_id: agentId, account_id: accountId }),
  });
  // 200 → member, 403 → not, anything else → don't cache (treat as not member)
  const isMember = r.status === 200;
  if (r.status === 200 || r.status === 403) _agentMemberCache.set(key, { isMember, ts: Date.now() });
  return isMember;
}

app.get('/api/admin/agent/:agent_id/users', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const agentId = req.params.agent_id;
    const appId = String(req.query.app_id || '').trim();
    const q = String(req.query.q || '').toLowerCase().trim();
    if (!appId) return err(res, 'app_id query parameter is required', 400);
    const sysToken = await getEasyGDSToken();

    // 1. Pull app-members for the parent app.
    const memRes = await fetch(
      `${EASYGDS_IAM_URL}/accounts/app-members/list?app_id=${appId}&per_page=200`,
      { headers: { Authorization: `Bearer ${sysToken}` } },
    );
    if (!memRes.ok) {
      const txt = await memRes.text();
      return err(res, `IAM /accounts/app-members/list returned HTTP ${memRes.status}: ${txt.slice(0,200)}`, 502);
    }
    const memData = await memRes.json();
    const members = memData.result || [];

    // 2. Build the candidate pool — real users (no service-accounts) and
    //    deduped by account_id. Apply text filter early so the impersonate
    //    probe doesn't hit accounts the operator didn't ask about.
    const seen = new Set();
    const candidates = [];
    for (const m of members) {
      const acc = m.account || {};
      const id = m.account_id || acc.id;
      const email = acc.email || '';
      if (!id || !email) continue;
      if (/serviceaccount/i.test(email)) continue;
      if (seen.has(id)) continue;
      seen.add(id);
      const name = `${acc.first_name || ''} ${acc.last_name || ''}`.toLowerCase();
      if (q && !email.toLowerCase().includes(q) && !name.includes(q)) continue;
      candidates.push({
        account_id: id,
        email,
        first_name: acc.first_name || '',
        last_name:  acc.last_name || '',
        type:       acc.type || 'User',
        status:     m.status || acc.status || 'active',
        mfa_enabled: !!acc.mfa_enabled,
        created_at: acc.created_at || null,
      });
    }

    // 3. Membership probe — bounded concurrency to avoid bursting IAM.
    const CONCURRENCY = 4;
    const out = [];
    let i = 0;
    async function worker() {
      while (i < candidates.length) {
        const u = candidates[i++];
        const isMember = await probeIsAgentMember(agentId, u.account_id, sysToken);
        if (isMember) out.push(u);
      }
    }
    await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, worker));

    ok(res, {
      result: out,
      search_query: q,
      probed: candidates.length,
      members: out.length,
    });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/admin/impersonate-agent-user
//   Mint an impersonation access token for a specific IAM user under a
//   specific agent (agency), then return the redirect URL the operator
//   should hit to land on the b2b dashboard as that user.
//
// Request body: { account_id, scope_id (agent uuid), app_id (or app_code) }
// Response: { access_token, redirect_url, expires_at }
//
// Permissions: Ops admin/manager only — impersonation is a powerful
// operation and shouldn't be available to plain App users.
app.post('/api/admin/impersonate-agent-user', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { account_id, scope_id, app_id, app_code } = req.body || {};
    if (!account_id || !scope_id) {
      return err(res, 'account_id and scope_id (agent uuid) are required', 400);
    }
    const sysToken = await getEasyGDSToken();
    // Confirmed-good IAM endpoint via probing (returns proper validation
    // errors when the body is wrong, so we know it exists).
    const r = await fetch(`${EASYGDS_IAM_URL}/auth/impersonate`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${sysToken}` },
      body: JSON.stringify({ scope_type: 'Agent', scope_id, account_id }),
    });
    const text = await r.text();
    let data = null;
    try { data = JSON.parse(text); } catch {}
    if (!r.ok) {
      return err(res, `IAM /auth/impersonate ${r.status}: ${data?.message || text.slice(0, 200)}`, r.status);
    }
    const accessToken = data?.access_token;
    if (!accessToken) {
      return err(res, `IAM returned no access_token. Response: ${text.slice(0, 200)}`, 502);
    }

    // Resolve app_code if only app_id was given (cached lookup).
    let resolvedAppCode = app_code;
    if (!resolvedAppCode && app_id) {
      try {
        const apps = await getAppsList({ perPage: 200 });
        const app = apps.find(a => a.id === app_id);
        resolvedAppCode = (app?.code || '').toLowerCase() || null;
      } catch {}
    }

    // Decode the JWT for expiry surfacing (UI shows "session valid until").
    let expiresAt = null;
    try {
      const payloadB64 = accessToken.split('.')[1];
      const payload = JSON.parse(Buffer.from(payloadB64, 'base64').toString());
      if (payload?.exp) expiresAt = new Date(payload.exp * 1000).toISOString();
    } catch {}

    const redirectUrl = (resolvedAppCode || app_id)
      ? await buildB2bCallbackUrl(app_id, resolvedAppCode, accessToken)
      : null;

    // Audit — impersonation is a sensitive op, every session must trace
    // back to the operator that initiated it.
    await recordAuditEvent(req, {
      action: 'agent_user_impersonate',
      entityType: 'agent',
      entityId: scope_id,
      before: null,
      after: {
        target_account_id: account_id,
        app_id: app_id || null,
        app_code: resolvedAppCode || null,
        expires_at: expiresAt,
      },
      reason: req.body?.reason || null,
    });

    ok(res, {
      access_token: accessToken,
      redirect_url: redirectUrl,
      expires_at: expiresAt,
    });
  } catch (e) { err(res, e.message, 500); }
});

// ── Database schema health ─────────────────────────────────────────────
//
// GET  /api/admin/schema/check  → audit DB against SCHEMA_MANIFEST
// POST /api/admin/schema/repair → run ensureLocalSchema + ensureSupportTriageSchema,
//                                  return a fresh check
//
// Why expose this? Operators frequently restore an older DB snapshot then
// boot a newer build; missing tables/columns surface as runtime errors
// in random places. With this endpoint, the admin can open Settings →
// Database Health, see the gap clearly, and click "Repair" to run the
// same idempotent bootstrap that the server runs at startup.
app.get('/api/admin/schema/check', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const report = await checkSchemaHealth();
    ok(res, report);
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/admin/schema/repair', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Snapshot before so we can show the diff to the operator. Keeps the
    // response useful: "we created table X, added Y columns" instead of a
    // generic "done".
    const before = await checkSchemaHealth();
    // Running both bootstrap passes is safe — they're built around
    // CREATE TABLE IF NOT EXISTS and ensureColumn (which itself checks
    // INFORMATION_SCHEMA before adding). Repeated calls are no-ops.
    let bootstrapErr = null;
    try {
      await ensureLocalSchema();
    } catch (e) { bootstrapErr = e.message; }
    try {
      await ensureSupportTriageSchema();
    } catch (e) { bootstrapErr = bootstrapErr || e.message; }
    const after = await checkSchemaHealth();

    // Compute what changed. Keeps the response small even on big DBs.
    const beforeMap = new Map(before.tables.map(t => [t.name, t]));
    const changes = [];
    for (const t of after.tables) {
      const b = beforeMap.get(t.name);
      if (!b) continue;
      const created = !b.exists && t.exists;
      const cols = (b.missing_columns || []).filter(c => !(t.missing_columns || []).includes(c));
      if (created || cols.length > 0) {
        changes.push({
          table: t.name,
          created,
          columns_added: cols,
        });
      }
    }
    ok(res, {
      ok: after.ok,
      changes,
      bootstrap_error: bootstrapErr,
      before_summary: before.summary,
      after_summary: after.summary,
      report: after,
    });
  } catch (e) { err(res, e.message, 500); }
});

// ══════════════════════════════════════════════════════════════════════
// USER PREFERENCES (me)
// ══════════════════════════════════════════════════════════════════════
//
// Self-service endpoints — any logged-in user can read/write their own
// email-notification preferences. Uses the same shape as EMAIL_DEFAULT_PREFS
// so the UI doesn't need to branch on legacy vs default.

app.get('/api/users/me/prefs', authMiddleware, async (req, res) => {
  try {
    const me = await loadEmailPreferences(req.platformUser.id);
    if (!me) return err(res, 'User not found', 404);
    ok(res, { email_preferences: me.email_preferences });
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/users/me/prefs', authMiddleware, async (req, res) => {
  try {
    const d = req.body?.email_preferences;
    if (!d || typeof d !== 'object') return err(res, 'email_preferences object required', 400);

    // Merge over defaults so partial patches don't wipe categories.
    const merged = {
      enabled: d.enabled !== false,
      categories: { ...EMAIL_DEFAULT_PREFS.categories, ...(d.categories || {}) },
    };
    await pool.query(
      'UPDATE platform_users SET email_preferences = ? WHERE id = ?',
      [JSON.stringify(merged), req.platformUser.id]
    );
    ok(res, { email_preferences: merged });
  } catch (e) { err(res, e.message, 500); }
});

// Audit log — recent delivery attempts for admins. Anyone can read their
// own; admins can read everyone's via ?user_id=…
app.get('/api/notification-deliveries', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const isAdmin = ['admin', 'manager'].includes(user.role);
    const filterUser = req.query.user_id;
    const entityId = req.query.entity_id;
    const where = [];
    const params = [];
    if (filterUser) {
      if (!isAdmin && filterUser !== user.id) return err(res, 'Forbidden', 403);
      where.push('recipient_user_id = ?'); params.push(filterUser);
    } else if (!isAdmin) {
      where.push('recipient_user_id = ?'); params.push(user.id);
    }
    if (entityId) { where.push('entity_id = ?'); params.push(entityId); }
    const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
    const limit = Math.min(parseInt(req.query.limit) || 50, 200);
    params.push(limit);
    const [rows] = await pool.query(
      `SELECT * FROM notification_deliveries ${clause} ORDER BY sent_at DESC LIMIT ?`,
      params
    );
    ok(res, rows);
  } catch (e) { err(res, e.message, 500); }
});

// ══════════════════════════════════════════════════════════════════════
// EMAIL INGESTION  (IMAP poller + /emails listing + manual trigger)
// ══════════════════════════════════════════════════════════════════════

// Load IMAP libs lazily so the server still boots if they aren't installed.
let _imapflow = null;
let _mailparser = null;
function loadImapDeps() {
  if (!_imapflow) {
    try { _imapflow = require('imapflow'); } catch (_) { return null; }
  }
  if (!_mailparser) {
    try { _mailparser = require('mailparser'); } catch (_) { return null; }
  }
  return { ImapFlow: _imapflow.ImapFlow, simpleParser: _mailparser.simpleParser };
}

// Normalise one parsed email into our `emails` row. Runs the match engine
// (supplier classification + field extraction) and optionally links to a
// booking by the extracted reference.
async function ingestOneEmail(parsed, inboxRow) {
  // Skip if we've already stored it (idempotent on Message-ID).
  if (parsed.messageId) {
    const [[dup]] = await pool.query('SELECT id FROM emails WHERE message_id = ? LIMIT 1', [parsed.messageId]);
    if (dup) return { skipped: true, email_id: dup.id };
  }

  const fromEmail = parsed.from?.value?.[0]?.address || null;
  const fromName  = parsed.from?.value?.[0]?.name || null;
  const toEmails  = (parsed.to?.value || []).map(v => v.address).filter(Boolean);
  const ccEmails  = (parsed.cc?.value || []).map(v => v.address).filter(Boolean);

  const subject = parsed.subject || '';
  const bodyText = parsed.text || '';
  const bodyHtml = parsed.html || null;
  const snippet = bodyText.slice(0, 497).replace(/\s+/g, ' ').trim();

  // Match engine: which supplier? Which fields get extracted?
  const match = await runMatchEngine({
    from_email: fromEmail,
    subject,
    body_text: bodyText,
    headers: parsed.headers ? Object.fromEntries(parsed.headers) : {},
  }).catch(() => ({ supplier_id: null, supplier_name: null, matched_rule: null, extracted: {} }));

  // Classify.
  let category = 'unknown';
  if (match.supplier_id) category = 'supplier';
  else {
    // Is the sender one of our platform_users?
    if (fromEmail) {
      const [[u]] = await pool.query('SELECT id FROM platform_users WHERE email = ? LIMIT 1', [fromEmail]);
      if (u) category = 'internal';
    }
    // Is the sender a booking contact? (Customer.)
    if (category === 'unknown' && fromEmail) {
      const [[bc]] = await pool.query(
        `SELECT id, booking_id, booking_code FROM support_requests
         WHERE JSON_UNQUOTE(JSON_EXTRACT(booking_details, '$.email')) = ?
         LIMIT 1`,
        [fromEmail]
      ).catch(() => [[null]]);
      if (bc) category = 'customer';
    }
  }

  // ── Stage 1 — Deterministic ref/sender match (always runs, free) ─────
  // Catches the obvious case: email subject/body contains the booking
  // code, booking_ref, PNR, HCN, or confirmation_number — OR the sender
  // address matches a saved customer contact_email. This was the missing
  // piece that left emails unlinked even when the booking code was right
  // there in the subject line. Confidence 0.9–1.0 → auto-link directly.
  let bookingId = null, bookingCode = null;
  let detMatch = null;
  try {
    detMatch = await findBookingByDeterministicMatch({
      from_email: fromEmail,
      subject,
      body_text: bodyText,
    }, pool);
  } catch (e) { console.warn('Deterministic matcher failed (non-fatal):', e.message); }
  if (detMatch && detMatch.booking_id) {
    bookingId = detMatch.booking_id;
    bookingCode = detMatch.booking_code;
    // Promote category based on the matcher's classification — only if
    // the supplier-rules engine didn't already assign one above.
    if ((category === 'unknown' || !category) && detMatch.classification) {
      category = detMatch.classification;
    }
    console.log(`🔗 Deterministic match: "${subject?.slice(0, 40)}" → ${bookingCode} via ${detMatch.matched_via}=${detMatch.matched_value} (conf ${detMatch.confidence})`);
  }

  // ── Stage 2 — Configured supplier extraction rules (legacy) ──────────
  // Uses the user-defined regex patterns in supplier_extraction_rules.
  // Only runs if Stage 1 didn't already find a booking. Kept for backward
  // compatibility with existing rule sets.
  const extracted = match.extracted || {};
  if (!bookingId && (extracted.booking_ref || extracted.supplier_confirmation || extracted.pnr)) {
    const refs = [extracted.booking_ref, extracted.supplier_confirmation, extracted.pnr].filter(Boolean);
    for (const r of refs) {
      // Try upstream bookings/list search (reuses the helper we already have)
      try {
        const data = await easygdsRequest(`/bookings/list?per_page=2&search_text=${encodeURIComponent(r)}`);
        const hit = (data?.result || []).find(b => b.code === r) || (data?.result || [])[0];
        if (hit) { bookingId = hit.id; bookingCode = hit.code; break; }
      } catch (_) { /* continue */ }
    }
  }

  // ── Phase 4 — AI fallback when heuristic misses ──────────────────────
  // Heuristic above uses regex + upstream search. It works perfectly when
  // the email contains an explicit booking ref. It misses when:
  //   - The customer writes "regarding our trip last week"
  //   - The supplier replies with just "HCN attached" + a PDF
  //   - The thread is a forward / Re:Re:Re: with the original ref stripped
  //
  // For those cases we ask Claude Haiku to look at the email + a list of
  // recent candidate bookings and pick the most likely match. Cost-bounded
  // by the AI_DAILY_BUDGET env var; budget exhaustion silently disables
  // until UTC midnight resets the counter.
  let aiClassified = 0;
  let aiConfidence = null;
  let aiSuggestedBookingCode = null;
  let aiReason = null;
  let aiAutoLinkApplied = false;
  if (!bookingId && process.env.ANTHROPIC_API_KEY) {
    try {
      // Build a candidate list — recent bookings the operator might
      // recognise. 50 is generous; Haiku handles this in <1500 input
      // tokens with caching active.
      const [candidates] = await pool.query(
        `SELECT code, contact_name, contact_email, supplier_name,
                travel_date, booking_status
           FROM bookings_cache
          WHERE code IS NOT NULL
          ORDER BY upstream_updated DESC, synced_at DESC
          LIMIT 50`
      );

      const { classifyEmailWithAI } = require('./lib/ai-email-classifier.cjs');
      const aiResult = await classifyEmailWithAI({
        email: {
          subject,
          body_text: bodyText,
          snippet,
          from_email: fromEmail,
          from_name: fromName,
          to_emails: toEmails,
        },
        candidates,
        pool,
      });

      if (!aiResult.skipped) {
        aiClassified = 1;
        aiConfidence = aiResult.confidence;
        aiSuggestedBookingCode = aiResult.booking_code;
        aiReason = aiResult.reason;

        const autoThreshold = parseFloat(process.env.AI_AUTO_LINK_THRESHOLD || '0.85');
        const suggestThreshold = parseFloat(process.env.AI_SUGGEST_THRESHOLD || '0.5');

        if (aiResult.booking_code && aiResult.confidence >= autoThreshold) {
          // Confident enough to auto-link. Resolve the bookingId from the cache.
          const [[hit]] = await pool.query(
            `SELECT id, code FROM bookings_cache WHERE code = ? LIMIT 1`,
            [aiResult.booking_code]
          );
          if (hit) {
            bookingId = hit.id;
            bookingCode = hit.code;
            aiAutoLinkApplied = true;
            console.log(`🤖 AI auto-linked email "${subject?.slice(0, 40)}" → ${bookingCode} (conf ${aiResult.confidence})`);
          }
        } else if (aiResult.booking_code && aiResult.confidence >= suggestThreshold) {
          // Below auto threshold but above suggest — store as suggestion
          // for the operator to confirm in the inbox.
          console.log(`🤖 AI suggested ${aiResult.booking_code} (conf ${aiResult.confidence}) — awaiting confirmation`);
        }
        // Below suggest threshold — store the result anyway so the operator
        // can see the AI tried, but the suggestion isn't surfaced as an
        // actionable banner in the UI.

        // If AI returned a classification stronger than the heuristic,
        // upgrade the category. Only when we don't already have one.
        if (category === 'unknown' && aiResult.classification && aiResult.classification !== 'unknown') {
          category = aiResult.classification;
        }
      }
    } catch (aiErr) {
      console.warn('AI classifier failed (non-fatal):', aiErr.message);
    }
  }

  // Resolve app_id from the linked booking so the multi-tenancy gate can
  // hide this email from app users in other tenants. Cheap local lookup
  // (no upstream call). NULL when no booking match — Ops still sees it.
  let appId = null;
  if (bookingId || bookingCode) {
    try {
      const [[bk]] = await pool.query(
        `SELECT app_id FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
        [bookingId || '', bookingCode || '']
      );
      appId = bk?.app_id || null;
    } catch { /* leave null */ }
  }

  const id = crypto.randomUUID();
  const autoLinked = !!bookingId;
  const confidence = match.supplier_id ? 0.9 : bookingId ? 0.5 : 0.0;
  const attachmentCount = Array.isArray(parsed.attachments) ? parsed.attachments.length : 0;

  // Flatten parsed.references (string | array | undefined) into a single
  // space-separated chain with every id wrapped in <...>. Stored so outbound
  // replies can faithfully reproduce the full ancestor chain — required by
  // Outlook to thread multi-step conversations.
  const referencesArr = Array.isArray(parsed.references) ? parsed.references
    : parsed.references ? [parsed.references] : [];
  const referencesChain = referencesArr
    .map(wrapMsgId).filter(Boolean).join(' ') || null;

  // Thread grouping key = the ROOT of the conversation (first id in
  // References). If this email is a top-level post it has no references and
  // groups under its own Message-ID. Same rule on outbound, so every reply
  // we send lands in the same group as the email it replies to.
  const threadRoot =
    (referencesArr.length > 0 && wrapMsgId(referencesArr[0]))
    || wrapMsgId(parsed.inReplyTo || null)
    || wrapMsgId(parsed.messageId || null)
    || null;

  // `auto_linked = 1` whenever heuristic OR AI auto-link applied (UI uses
  // this for the "auto-linked" badge); the four ai_* columns let the UI
  // distinguish AI-classified rows + surface the suggestion banner.
  const autoLinkedFinal = autoLinked || aiAutoLinkApplied;
  await pool.query(
    `INSERT INTO emails
       (id, message_id, thread_id, in_reply_to, references_chain, direction, source,
        from_email, from_name, to_emails, cc_emails,
        subject, snippet, body_text, body_html,
        received_at, category, supplier_id, booking_id, booking_code, app_id,
        auto_linked, confidence, parsed_data, attachments_count, raw_headers,
        inbox_id,
        ai_classified, ai_confidence, ai_suggested_booking_code, ai_reason)
     VALUES (?, ?, ?, ?, ?, 'inbound', 'imap',
             ?, ?, ?, ?,
             ?, ?, ?, ?,
             ?, ?, ?, ?, ?, ?,
             ?, ?, ?, ?, ?,
             ?,
             ?, ?, ?, ?)`,
    [id, parsed.messageId || null,
     threadRoot,
     parsed.inReplyTo || null,
     referencesChain,
     fromEmail, fromName,
     JSON.stringify(toEmails), JSON.stringify(ccEmails),
     subject, snippet, bodyText, bodyHtml,
     toMysqlDatetime(parsed.date ? new Date(parsed.date).toISOString() : new Date().toISOString()),
     category, match.supplier_id, bookingId, bookingCode, appId,
     autoLinkedFinal ? 1 : 0, aiAutoLinkApplied ? aiConfidence : confidence,
     JSON.stringify(extracted),
     attachmentCount,
     parsed.headers ? JSON.stringify(Object.fromEntries(parsed.headers)) : null,
     // The inbox row is passed in by `pollInboxOnce()` → `ingestOneEmail(parsed, inboxRow)`.
     // Stamping inboxRow.id lets the UI tell the user which account the email came from.
     inboxRow?.id || null,
     aiClassified, aiConfidence, aiSuggestedBookingCode, aiReason]
  );
  // Audit-trail link row whenever a booking was auto-linked — heuristic
  // OR AI. `auto_linked = 1` flag means it wasn't a manual operator action.
  if (autoLinkedFinal) {
    await pool.query(
      `INSERT INTO email_booking_links (id, email_id, linked_type, linked_id, auto_linked)
       VALUES (?, ?, 'booking', ?, 1)`,
      [crypto.randomUUID(), id, bookingId]
    );
  }

  // Persist attachments to disk under server/uploads/emails/<email_id>/
  if (attachmentCount > 0) {
    const fs = require('fs');
    const path = require('path');
    const emailDir = path.join(uploadDir, 'emails', id);
    try { fs.mkdirSync(emailDir, { recursive: true }); } catch (_) {}
    for (const att of parsed.attachments) {
      try {
        const attId = crypto.randomUUID();
        const safe = String(att.filename || `attachment-${attId.slice(0, 8)}`).replace(/[^\w.\- ]+/g, '_').slice(0, 200);
        const target = path.join(emailDir, `${attId}-${safe}`);
        if (att.content) fs.writeFileSync(target, att.content);
        await pool.query(
          `INSERT INTO email_attachments (id, email_id, filename, mime_type, size_bytes, storage_path, content_id)
           VALUES (?, ?, ?, ?, ?, ?, ?)`,
          [attId, id, att.filename || safe, att.contentType || 'application/octet-stream',
           att.size || (att.content ? att.content.length : 0), target, att.contentId || att.cid || null]
        );
      } catch (e) { console.warn('attachment save failed:', e.message); }
    }
  }

  // Fan out a bell / toast notification for the new email. Recipients:
  //   - Always: the Ops team (they triage the inbox).
  //   - Additionally, App users for the booking's app_id, when the email
  //     is auto-linked to a booking (so the App sees what's flowing into
  //     their tickets).
  // We SKIP notifications for category='internal' (chatty, e.g. our own
  // outbound replies re-ingested) and category='unknown' below a confidence
  // bar — Ops can still find them via the inbox page. entity_type='email'
  // so the createNotification fanout doesn't try to send an outbound email
  // about receiving an email (which would be a recursion risk).
  try {
    if (category !== 'internal') {
      const senderLabel = fromName || fromEmail || 'Unknown sender';
      const title =
        category === 'customer' ? `New customer email from ${senderLabel}`
        : category === 'supplier' ? `New supplier email from ${senderLabel}`
        : `New email from ${senderLabel}`;
      const body = (subject || '').slice(0, 200) || '(no subject)';
      const priority = autoLinked ? 'high' : (category === 'unknown' ? 'low' : 'normal');

      // Who to notify — resolve recipients per audience.
      const targets = new Set();
      // Ops team always.
      try {
        const [opsRows] = await pool.query(
          `SELECT id FROM platform_users WHERE login_context = 'system' AND is_active = 1`
        );
        for (const r of opsRows) targets.add(r.id);
      } catch {}
      // App users — only when we know which app this belongs to (via linked booking).
      let app_id = null;
      if (bookingId) {
        try {
          const meta = await resolveBookingMeta(bookingId);
          app_id = meta?.app_id || null;
        } catch {}
      }
      if (app_id) {
        try {
          const [appRows] = await pool.query(
            `SELECT id, allowed_app_ids FROM platform_users
             WHERE login_context = 'app' AND is_active = 1`
          );
          for (const r of appRows) {
            let list = r.allowed_app_ids;
            if (typeof list === 'string') { try { list = JSON.parse(list); } catch { list = []; } }
            if (Array.isArray(list) && list.includes(app_id)) targets.add(r.id);
          }
        } catch {}
      }

      if (targets.size > 0) {
        await createNotification({
          user_ids: [...targets],
          kind: autoLinked ? 'email_received_linked' : `email_received_${category}`,
          title,
          body,
          priority,
          link_path: bookingCode ? `/bookings/${bookingCode}` : '/emails',
          actor_id: null,
          actor_name: senderLabel,
          entity_type: 'email',
          entity_id: id,
          metadata: { category, booking_code: bookingCode, supplier_id: match.supplier_id },
        });
      }
    }
  } catch (e) { console.warn('email notification fanout failed:', e.message); }

  return { skipped: false, email_id: id, category, supplier_id: match.supplier_id, booking_id: bookingId };
}

// Poll one inbox once (fetch new messages → ingest → log status). Returns a
// summary suitable for the admin UI's "last polled" display.
//
// Options:
//   full  — when true, ignore the last_polled_at watermark and search the
//           entire INBOX. Used by the "Fetch all history" button to backfill
//           messages older than the regular 7-day window. ingestOneEmail()
//           is idempotent (UNIQUE message_id), so re-running is safe — old
//           messages already in the DB are silently skipped.
async function pollInboxOnce(inboxId, opts = {}) {
  const { full = false } = opts;
  const [[inbox]] = await pool.query('SELECT * FROM email_inboxes WHERE id = ?', [inboxId]);
  if (!inbox) throw new Error('Inbox not found');
  const deps = loadImapDeps();
  if (!deps) throw new Error('imapflow / mailparser not available on the server');
  const { ImapFlow, simpleParser } = deps;
  const client = new ImapFlow({
    host: inbox.imap_host,
    port: inbox.imap_port,
    secure: Number(inbox.imap_use_ssl) === 1,
    auth: { user: inbox.imap_username, pass: decryptSecret(inbox.imap_password) },
    tls: { rejectUnauthorized: false },
    logger: false,
    disableAutoIdle: true,
  });
  // Prevent socket-level errors (ETIMEOUT, ECONNRESET, etc.) from
  // propagating as an unhandled 'error' event and crashing the process.
  client.on('error', (err) => {
    console.warn(`⚠️  IMAP socket error for inbox ${inboxId} (handled): ${err.code || err.message}`);
  });

  let fetched = 0, ingested = 0, skipped = 0, errors = 0;
  try {
    await client.connect();
    const lock = await client.getMailboxLock('INBOX');
    try {
      // Search window:
      //   - full=true     → entire INBOX (Date(0) = 1970, IMAP servers
      //                     happily return everything). Used for backfill.
      //   - watermark set → since last_polled_at (incremental).
      //   - first run     → last 7 days as a safety bound.
      const since = full
        ? new Date(0)
        : (inbox.last_polled_at ? new Date(inbox.last_polled_at) : new Date(Date.now() - 7 * 24 * 3600 * 1000));
      const uidList = await client.search({ since });
      fetched = uidList.length;
      for (const uid of uidList) {
        try {
          const msg = await client.fetchOne(uid, { source: true, envelope: true });
          if (!msg?.source) { errors++; continue; }
          const parsed = await simpleParser(msg.source);
          const r = await ingestOneEmail(parsed, inbox);
          if (r.skipped) skipped++; else ingested++;
        } catch (e) {
          console.error('ingest error for uid', uid, e.message);
          errors++;
        }
      }
    } finally {
      lock.release();
    }
    await client.logout().catch(() => {});
    await pool.query('UPDATE email_inboxes SET last_polled_at = NOW(), last_error = NULL WHERE id = ?', [inboxId]);
    return { fetched, ingested, skipped, errors, full };
  } catch (e) {
    await pool.query('UPDATE email_inboxes SET last_polled_at = NOW(), last_error = ? WHERE id = ?',
      [String(e.message || e).slice(0, 510), inboxId]);
    try { await client.logout().catch(() => {}); } catch (_) {}
    throw e;
  }
}

// Manual trigger for the admin UI.
//   POST /api/email-inboxes/:id/poll              → incremental (default)
//   POST /api/email-inboxes/:id/poll {full:true}  → full backfill (entire INBOX)
// The full=true path is exposed for the "Fetch all history" button — it can
// take a while on big mailboxes (one ingest per message, parser is the
// slow part) so the UI shows a confirmation before triggering it.
app.post('/api/email-inboxes/:id/poll', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const full = !!(req.body?.full || req.query?.full === 'true');
    const summary = await pollInboxOnce(req.params.id, { full });
    ok(res, summary);
  } catch (e) { err(res, e.message, 500); }
});

// Background poller: checks every active inbox on its own interval.
// Runs on startup — no external scheduler needed.
const _inboxPollState = new Map(); // inbox_id -> timestamp of next run
async function pollerTick() {
  try {
    const [rows] = await pool.query('SELECT id, check_interval, last_polled_at, is_active FROM email_inboxes WHERE is_active = 1');
    const now = Date.now();
    for (const r of rows) {
      const intervalMs = Math.max(1, Number(r.check_interval) || 5) * 60_000;
      const lastMs = r.last_polled_at ? new Date(r.last_polled_at).getTime() : 0;
      const nextAt = Math.max(lastMs + intervalMs, _inboxPollState.get(r.id) || 0);
      if (now >= nextAt) {
        _inboxPollState.set(r.id, now + intervalMs);
        pollInboxOnce(r.id).catch(e => console.warn('poller failed for', r.id, e.message));
      }
    }
  } catch (e) {
    console.warn('pollerTick failed:', e.message);
  }
}
setInterval(pollerTick, 30_000);
// Kick off one tick a few seconds after boot (gives schema bootstrap time).
setTimeout(() => pollerTick().catch(() => {}), 10_000);

// ════════════════════════════════════════════════════════════════════════
// APP-MEMBER SYNC
// ════════════════════════════════════════════════════════════════════════
//
// The login flow needs to know which apps each user has access to. Calling
// IAM at every login is fragile (network blips, rate limits, weird scope
// behavior). Instead, we keep a local mirror of the IAM app-members
// registry in `app_members_cache` and refresh it every 10 min.
//
// Read path (login + admin lookup): a single SQL query against the cache.
// Write path (this sync): iterates apps, fetches their members, upserts.
//
// Sync runs at boot (after a delay to let schema bootstrap finish) and
// every APP_MEMBERS_SYNC_INTERVAL_MS ms — defaults to 10 min, overridable
// via env. The manual trigger endpoint at `/api/admin/app-members/sync`
// lets an admin force a refresh after granting/revoking access in IAM.
const APP_MEMBERS_SYNC_INTERVAL_MS = parseInt(process.env.APP_MEMBERS_SYNC_INTERVAL_MS || '600000'); // 10 min default

let _appMembersSyncRunning = false;
let _appMembersSyncLast = { startedAt: null, finishedAt: null, ok: false, apps: 0, members: 0, error: null };

async function syncAppMembersCache() {
  // Single-flight guard — back-to-back calls (boot + interval racing) won't
  // double-run.
  if (_appMembersSyncRunning) return _appMembersSyncLast;
  _appMembersSyncRunning = true;
  const startedAt = new Date();
  let totalApps = 0;
  let totalMembers = 0;
  let errMsg = null;
  try {
    const sysToken = await getEasyGDSToken();

    // Fetch the full apps list once. The IAM app-members endpoint accepts
    // `app_id` so we iterate per-app to get clean per-app member rows.
    // Bulk sync runs frequently (every 10 min) — sharing the cached apps
    // list with the login flow avoids redundant 2-3s upstream calls.
    const allApps = await getAppsList({ perPage: 200 });
    // Skip apps the admin disabled in Settings → Apps. We still keep the
    // CACHE rows for those apps (so a re-enable doesn't lose history) but
    // stop spending IAM round-trips refreshing them.
    const disabled = await getDisabledAppIds();
    const apps = allApps.filter(a => !disabled.has(a.id));
    totalApps = apps.length;

    // Cut-off used to clean up rows that didn't reappear in this sync —
    // those memberships were revoked upstream and should drop out of the
    // cache. Using "before this sync's start time" rather than NOW() so a
    // member-row touched mid-sync isn't accidentally pruned.
    const cutoff = new Date(startedAt.getTime() - 1000); // 1s buffer

    for (const app of apps) {
      try {
        let page = 1;
        while (true) {
          const memUrl = `${EASYGDS_IAM_URL}/accounts/app-members/list?app_id=${app.id}&page=${page}&per_page=200`;
          const memRes = await fetch(memUrl, { headers: { 'Authorization': `Bearer ${sysToken}` } });
          if (!memRes.ok) {
            console.warn(`app-members fetch ${app.code || app.id}: HTTP ${memRes.status}`);
            break;
          }
          const memData = await memRes.json();
          const members = memData.result || [];
          if (members.length === 0) break;

          // Upsert in a single batched query for speed. mysql2 bulk
          // INSERT ... ON DUPLICATE KEY UPDATE is the cheapest way to keep
          // the cache hot without a transaction round-trip per row.
          const rows = [];
          for (const m of members) {
            const acct = m.account || {};
            rows.push([
              m.app_id,
              m.account_id,
              (acct.email || '').toLowerCase(),
              acct.first_name || null,
              acct.last_name || null,
              m.enabled ? 1 : 0,
              m.status || 'active',
              JSON.stringify((m.roles || []).map(r => ({ id: r.id, name: r.name }))),
              startedAt,
            ]);
          }
          if (rows.length > 0) {
            await pool.query(
              `INSERT INTO app_members_cache
                 (app_id, account_id, email, first_name, last_name, enabled, status, roles, last_synced_at)
               VALUES ?
               ON DUPLICATE KEY UPDATE
                 email = VALUES(email),
                 first_name = VALUES(first_name),
                 last_name = VALUES(last_name),
                 enabled = VALUES(enabled),
                 status = VALUES(status),
                 roles = VALUES(roles),
                 last_synced_at = VALUES(last_synced_at)`,
              [rows]
            );
          }
          totalMembers += members.length;

          if (members.length < 200) break;
          page++;
          if (page > 20) { // safety bound (4 000 members per app)
            console.warn(`app-members ${app.code || app.id}: paged out at ${page}`);
            break;
          }
        }
      } catch (appErr) {
        console.warn(`app-members sync failed for ${app.code || app.id}:`, appErr.message);
      }
    }

    // Prune memberships that didn't show up in this sync (they were
    // removed upstream). Bounded to last_synced_at < cutoff so we don't
    // touch rows updated within the current run.
    await pool.query(
      `DELETE FROM app_members_cache WHERE last_synced_at < ?`,
      [cutoff]
    );

    console.log(`✅ App-members sync: ${totalApps} apps, ${totalMembers} memberships`);
  } catch (e) {
    errMsg = e.message;
    console.warn('App-members sync FAILED:', e.message);
  } finally {
    _appMembersSyncRunning = false;
    _appMembersSyncLast = {
      startedAt,
      finishedAt: new Date(),
      ok: !errMsg,
      apps: totalApps,
      members: totalMembers,
      error: errMsg,
    };
  }
  return _appMembersSyncLast;
}

// Schedule: 15s after boot (after schema bootstrap), then every 10 min.
setTimeout(() => syncAppMembersCache().catch(() => {}), 15_000);
setInterval(() => syncAppMembersCache().catch(() => {}), APP_MEMBERS_SYNC_INTERVAL_MS);

// Supplier-reminder checker — fires overdue reminders to Ops. Boots 30s
// after startup so the schema is settled, then every 5 min.
const SUPPLIER_REMINDER_INTERVAL_MS = parseInt(process.env.SUPPLIER_REMINDER_INTERVAL_MS || '300000'); // 5 min default
setTimeout(() => checkSupplierReminders().catch(() => {}), 30_000);
setInterval(() => checkSupplierReminders().catch(() => {}), SUPPLIER_REMINDER_INTERVAL_MS);

/**
 * Read a user's app memberships from the cache. Returns a list of app_id
 * strings. Empty list if the user has no memberships (or the sync hasn't
 * run yet — call ensure-fresh from the caller if you need a hard guarantee).
 */
async function getCachedAllowedAppIds(email) {
  if (!email) return [];
  try {
    const [rows] = await pool.query(
      `SELECT DISTINCT app_id FROM app_members_cache
       WHERE email = ? AND enabled = 1 AND status = 'active'`,
      [email.toLowerCase()]
    );
    return rows.map(r => r.app_id);
  } catch (e) {
    console.warn('app_members_cache lookup failed:', e.message);
    return [];
  }
}

/**
 * Live lookup of a user's app memberships from IAM. Used at login time
 * when the cache is empty/cold. Robust replacement for the inline
 * single-page `search_texts` lookup that was missing memberships:
 *
 *   - Tries multiple query parameters (`search_texts`, `email`) because
 *     IAM's filtering semantics for these keys aren't documented and
 *     they each catch a different slice of records.
 *   - Pages through results (up to 5 pages = 1000 rows) instead of
 *     stopping at 200.
 *   - Lenient on filter fields:
 *       enabled → only excluded if explicitly false / 0
 *       status  → only excluded if present AND not 'active' (missing
 *                 means the IAM record predates the status field, treat
 *                 as active to match what the bulk sync stores)
 *   - Dedupes by (app_id, account_id) so the same membership returned
 *     by both URL variants only counts once.
 *
 * Falls back to bulk sync if both URL variants return zero — that's
 * the heaviest-but-most-reliable path (iterates apps, fetches members
 * per app, just like the periodic sync).
 */
async function liveLookupUserApps(email) {
  if (!email) return [];
  const lc = email.toLowerCase();
  let sysToken;
  try { sysToken = await getEasyGDSToken(); }
  catch (e) { console.warn('liveLookupUserApps: token fetch failed:', e.message); return []; }

  // 200 results per page is enough for any single user's memberships
  // (no human user belongs to >200 apps). Page 2 was defensive; in
  // practice never returned anything new and added 1.5s × 4 = 6s of
  // worst-case latency to login. Capping at 1 page keeps the same outcome
  // and bounds the call to 2 IAM round-trips max.
  const variants = [
    `${EASYGDS_IAM_URL}/accounts/app-members/list?search_texts=${encodeURIComponent(email)}&per_page=200`,
    `${EASYGDS_IAM_URL}/accounts/app-members/list?email=${encodeURIComponent(email)}&per_page=200`,
  ];
  const seen = new Set();
  const apps = new Set();

  // Per-call timeout — 5s ceiling per IAM page so a hanging upstream
  // never freezes login indefinitely. 5s is generous (staging averages
  // ~1.6s) but tight enough that a real outage bubbles up fast.
  const FETCH_TIMEOUT_MS = 5000;

  for (const baseUrl of variants) {
    try {
      const ctrl = new AbortController();
      const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
      let res;
      try {
        res = await fetch(`${baseUrl}&page=1`, {
          headers: { 'Authorization': `Bearer ${sysToken}` },
          signal: ctrl.signal,
        });
      } finally {
        clearTimeout(timer);
      }
      if (!res.ok) {
        console.warn(`liveLookupUserApps: ${baseUrl} → HTTP ${res.status}`);
        continue;
      }
      const data = await res.json();
      const members = data.result || [];
      for (const m of members) {
        const memEmail = (m.account?.email || m.email || '').toLowerCase();
        if (memEmail !== lc) continue;            // strict email match (key check)
        if (m.deleted) continue;
        if (m.enabled === false || m.enabled === 0) continue; // lenient on missing
        if (m.status && String(m.status).toLowerCase() !== 'active') continue;
        if (!m.app_id) continue;
        const key = `${m.app_id}:${m.account_id || ''}`;
        if (seen.has(key)) continue;
        seen.add(key);
        apps.add(m.app_id);
      }
    } catch (e) {
      const why = e.name === 'AbortError' ? `timed out after ${FETCH_TIMEOUT_MS}ms` : e.message;
      console.warn(`liveLookupUserApps: ${baseUrl} failed: ${why}`);
      // continue to next variant
    }
  }
  return [...apps];
}

// ════════════════════════════════════════════════════════════════════════
// SUPPLIER BALANCE SYNC
// ════════════════════════════════════════════════════════════════════════
//
// EasyGDS doesn't expose supplier balances. We compute them here by
// walking bookings_cache, attributing per-product amounts to the
// canonical supplier (resolving merged_into_id chains), and recording
// each as a row in supplier_transactions_cache.
//
// Booking attribution rules (per user spec):
//   - Booking debit:  booking_status='success' AND payment_status='success'
//   - Refund credit:  payment_status='refunded' (any booking_status)
//   - Manual top-up:  rows from supplier_topups (operator-recorded payments)
//
// Multi-product attribution (per user spec — "each supplier his service"):
//   - Try to extract a per-product amount from rate.amount / data.total_amount
//   - For products with no extractable amount: split the residual evenly
//   - If 0 products have known amounts: split booking total evenly across all
//
// Sync runs at boot (45s delay — bookings_cache must be populated first)
// then every SUPPLIER_BALANCE_SYNC_INTERVAL_MS ms (default 10 min).

const SUPPLIER_BALANCE_SYNC_INTERVAL_MS = parseInt(process.env.SUPPLIER_BALANCE_SYNC_INTERVAL_MS || '600000');

let _supplierSyncRunning = false;
let _supplierSyncLast = { startedAt: null, finishedAt: null, ok: false, txns: 0, error: null };

// Same supplier-resolution priority chain as src/utils/productUtils.ts.
// Returns the supplier code FROM A PRODUCT, or null. Filters out junk
// values like UUID-looking opaque ids.
function looksLikeRealSupplierCode(s) {
  if (!s || typeof s !== 'string') return false;
  if (s.length > 24) return false;                                  // too long
  if (/^[0-9a-f]{32,}$/i.test(s)) return false;                     // hex blob
  if (/^[0-9a-f]{8}-[0-9a-f]{4}/.test(s)) return false;             // UUID
  return true;
}

function extractProductSupplier(p) {
  const sr = p?.data?.selected_rooms || [];
  // 1. rate.source_id — canonical supplier id used in vouchers
  for (const r of sr) {
    const sid = r?.rate?.source_id;
    if (looksLikeRealSupplierCode(sid)) return sid;
  }
  // 2. rate.supplier_code
  for (const r of sr) {
    const sc = r?.rate?.supplier_code;
    if (looksLikeRealSupplierCode(sc)) return sc;
  }
  // 3. product.data.supplier_code / supplier_source / provider
  const d = p?.data || {};
  for (const k of ['supplier_code', 'supplier_source', 'provider_code', 'provider', 'source']) {
    if (looksLikeRealSupplierCode(d[k])) return d[k];
  }
  // 4. room-level
  for (const r of sr) {
    if (looksLikeRealSupplierCode(r?.supplier_code)) return r.supplier_code;
    if (looksLikeRealSupplierCode(r?.supplier_source)) return r.supplier_source;
  }
  return null;
}

function extractProductAmount(p) {
  const sr = p?.data?.selected_rooms || [];
  // Sum rate.amount across selected_rooms[*]
  let sum = 0, found = false;
  for (const r of sr) {
    const a = Number(r?.rate?.amount ?? r?.rate?.total ?? r?.rate?.price);
    if (Number.isFinite(a) && a > 0) { sum += a; found = true; }
  }
  if (found) return sum;
  // Fallbacks
  const d = p?.data || {};
  for (const k of ['total_amount', 'price', 'amount', 'sale_price', 'net_price']) {
    const a = Number(d[k]);
    if (Number.isFinite(a) && a > 0) return a;
  }
  const a2 = Number(p?.total_amount);
  return Number.isFinite(a2) && a2 > 0 ? a2 : null;
}

/**
 * Allocate a booking's total across its products by supplier.
 * Returns: [{ supplierCode, amount, productIndex }] — one entry per product
 * that has a resolvable supplier code. Products without a supplier code
 * are dropped (we can't attribute them).
 *
 * Handles missing per-product amounts:
 *   - Products with known amounts use those.
 *   - Products without known amounts split (booking_total - sum_of_known)
 *     evenly.
 *   - If no product has a known amount, split booking_total evenly.
 */
function allocateBookingToSuppliers(bookingTotal, products) {
  if (!Array.isArray(products) || products.length === 0) return [];
  const items = products.map((p, i) => ({
    productIndex: i,
    supplierCode: extractProductSupplier(p),
    amount: extractProductAmount(p),
  })).filter(it => it.supplierCode);
  if (items.length === 0) return [];

  const known = items.filter(it => it.amount != null);
  const unknown = items.filter(it => it.amount == null);
  const knownSum = known.reduce((s, it) => s + it.amount, 0);

  if (unknown.length === 0) {
    return items;  // every product has a per-product amount
  }
  // Distribute residual (or full total if nothing known) evenly across unknowns.
  const residual = Math.max(0, bookingTotal - knownSum);
  const perUnknown = unknown.length > 0 ? residual / unknown.length : 0;
  for (const u of unknown) u.amount = perUnknown;
  return items;
}

// ── Currency routing & FX helpers (used by syncSupplierTransactionsCache) ─

// Try to extract an FX rate FROM the booking's own raw payload. Travel/IBE
// engines lock the rate at booking-creation, and we'd rather use that lock
// than today's market rate. Format we've observed in EasyGDS payloads:
//   products[i].data.currency_info.rate     // "1 USD = X originalCcy"
//   products[i].data.selected_rooms[0].rate.price_from_USD
// Returns { rate, source } where rate is "1 USD = X originalCcy", or null.
function extractEmbeddedUsdRate(rawProduct, originalCcy) {
  if (!rawProduct || originalCcy === 'USD') return null;
  const r = Number(rawProduct?.data?.currency_info?.rate);
  if (Number.isFinite(r) && r > 0) return { rate: r, source: 'booking_embedded' };
  const sr = rawProduct?.data?.selected_rooms?.[0];
  const usdPrice = Number(sr?.rate?.price_from_USD);
  const localPrice = Number(sr?.rate?.amount ?? sr?.rate?.total ?? sr?.rate?.price);
  if (Number.isFinite(usdPrice) && Number.isFinite(localPrice) && usdPrice > 0 && localPrice > 0) {
    return { rate: localPrice / usdPrice, source: 'booking_embedded' };
  }
  return null;
}

// Frankfurter historical rates by date (1 USD = X CCY for a given date).
// Cached per-date in memory for the lifetime of the sync run.
const _histFxCache = new Map();
async function fetchHistoricalFxRates(dateStr) {
  if (!dateStr) return null;
  if (_histFxCache.has(dateStr)) return _histFxCache.get(dateStr);
  try {
    const r = await fetch(`https://api.frankfurter.app/${dateStr}?from=USD`);
    if (!r.ok) { _histFxCache.set(dateStr, null); return null; }
    const j = await r.json();
    if (!j?.rates) { _histFxCache.set(dateStr, null); return null; }
    const rates = { USD: 1, ...j.rates };
    _histFxCache.set(dateStr, rates);
    return rates;
  } catch {
    _histFxCache.set(dateStr, null);
    return null;
  }
}

// Convert amount in fromCcy → toCcy. Resolution order:
//   1. booking-embedded rate (locked at booking time — most accurate)
//   2. Frankfurter historical (booking_creation_date)
//   3. Frankfurter today (cached)
//   4. identity (preserve amount, mark fx_source='identity' for audit)
// Returns { amount, rate, source }.
async function convertCurrencyForSettlement(amount, fromCcy, toCcy, bookingDate, rawProduct) {
  if (fromCcy === toCcy) return { amount, rate: 1, source: 'identity' };

  // 1) Booking-embedded: the rate is "1 USD = R fromCcy". To get toCcy,
  //    cross via USD using today's rate (fromCcy → USD locked, USD → toCcy live).
  const emb = extractEmbeddedUsdRate(rawProduct, fromCcy);
  if (emb) {
    const usdAmount = amount / emb.rate;          // exact, locked at booking
    if (toCcy === 'USD') return { amount: usdAmount, rate: 1 / emb.rate, source: 'booking_embedded' };
    const fxToday = await fetchFxRates();
    const usdToTarget = Number(fxToday?.rates?.[toCcy]);
    if (Number.isFinite(usdToTarget) && usdToTarget > 0) {
      return { amount: usdAmount * usdToTarget, rate: usdToTarget / emb.rate, source: 'booking_embedded' };
    }
  }

  // 2) Historical Frankfurter (1 USD = X CCY on the booking date).
  const dateStr = bookingDate ? String(bookingDate instanceof Date ? bookingDate.toISOString() : bookingDate).slice(0, 10) : null;
  const hist = await fetchHistoricalFxRates(dateStr);
  if (hist) {
    const rFrom = Number(hist[fromCcy]);
    const rTo   = Number(hist[toCcy]);
    if (Number.isFinite(rFrom) && Number.isFinite(rTo) && rFrom > 0 && rTo > 0) {
      const cross = rTo / rFrom;
      return { amount: amount * cross, rate: cross, source: 'frankfurter_historical' };
    }
  }

  // 3) Today's rate.
  const fxToday = await fetchFxRates();
  if (fxToday?.rates) {
    const rFrom = Number(fxToday.rates[fromCcy]);
    const rTo   = Number(fxToday.rates[toCcy]);
    if (Number.isFinite(rFrom) && Number.isFinite(rTo) && rFrom > 0 && rTo > 0) {
      const cross = rTo / rFrom;
      return { amount: amount * cross, rate: cross, source: 'frankfurter_today' };
    }
  }

  // 4) Last resort.
  return { amount, rate: 1, source: 'identity' };
}

// Pick the settlement currency for this booking allocation given the
// supplier's policy. Rules:
//   - settlement_currencies NULL/empty: keep booking ccy (no policy → no change)
//   - booking ccy ∈ settlement_currencies: keep booking ccy (no conversion)
//   - else: route to default_settlement_currency (defaults to 'USD')
function pickSettlementCurrency(originalCcy, settlementCurrencies, defaultCcy) {
  if (!Array.isArray(settlementCurrencies) || settlementCurrencies.length === 0) {
    return originalCcy;
  }
  if (settlementCurrencies.includes(originalCcy)) return originalCcy;
  return defaultCcy || 'USD';
}

async function syncSupplierTransactionsCache() {
  if (_supplierSyncRunning) return _supplierSyncLast;
  _supplierSyncRunning = true;
  const startedAt = new Date();
  let totalTxns = 0;
  let errMsg = null;
  try {
    // Suppliers with their settlement policy. We always attribute to the
    // canonical (merged_into_id) so multi-code suppliers (Hotelbeds = BH+TPBH;
    // Nuitee = NT+APItude+Nuitee) roll up. Aggregate the policy from the
    // canonical row.
    const [supRows] = await pool.query(
      `SELECT s.id, s.code,
              COALESCE(s.merged_into_id, s.id) AS canonical_id,
              c.settlement_currencies          AS settlement_currencies,
              c.default_settlement_currency    AS default_settlement_currency
       FROM suppliers s
       LEFT JOIN suppliers c ON c.id = COALESCE(s.merged_into_id, s.id)
       WHERE s.is_active = 1`
    );
    const codeToCanonical = new Map();
    const policyByCanonical = new Map(); // canonical_id → { settlement_currencies, default_settlement_currency }
    for (const r of supRows) {
      if (r.code) codeToCanonical.set(String(r.code).toLowerCase(), r.canonical_id);
      if (!policyByCanonical.has(r.canonical_id)) {
        let sc = r.settlement_currencies;
        if (typeof sc === 'string') { try { sc = JSON.parse(sc); } catch { sc = null; } }
        policyByCanonical.set(r.canonical_id, {
          settlement_currencies: Array.isArray(sc) ? sc : null,
          default_settlement_currency: r.default_settlement_currency || 'USD',
        });
      }
    }

    // Walk relevant bookings in batches.
    //   booking_status='success' + payment_status='success'  → booking debit
    //   payment_status='refunded' (any booking_status)       → booking debit + refund credit
    // The booking debit is ALWAYS emitted so a fully-refunded booking nets
    // to zero (booking − refund), no spurious "overpaid" balance.
    const cutoff = new Date(startedAt.getTime() - 1000);
    // Globally disabled apps — exclude their bookings from supplier balance
    // calculations so the dashboard doesn't show liabilities for apps we
    // don't operate. Checked once before the loop.
    const disabledAppIds = Array.from(await getDisabledAppIds());
    const disabledClause = disabledAppIds.length
      ? ` AND (app_id IS NULL OR app_id NOT IN (${disabledAppIds.map(() => '?').join(',')}))`
      : '';
    const BATCH = 500;
    // Load all active supplier_contracts up front so the classification
    // lookup is O(1) per booking instead of O(rows*contracts). Grouped by
    // supplier_id, then a small array per supplier (typically 1-5 rows:
    // us + a handful of client apps). For each booking we pick the first
    // row matching booking.app_id whose effective_from/_to window contains
    // the booking's upstream_created. NULL window endpoints = open-ended.
    // We also carry the contract's currency policy so the sync can route
    // a transaction to the contract's preferred settlement currency
    // (falling back to the supplier-level policy when the contract row
    // leaves the field null = "inherit").
    const [contractRows] = await pool.query(
      `SELECT supplier_id, owner_app_id, effective_from, effective_to,
              settlement_currency, settlement_currencies
         FROM supplier_contracts WHERE is_active = 1`
    );
    const contractsBySupplier = new Map();
    for (const row of contractRows) {
      let sc = row.settlement_currencies;
      if (typeof sc === 'string') { try { sc = JSON.parse(sc); } catch { sc = null; } }
      const arr = contractsBySupplier.get(row.supplier_id) || [];
      arr.push({
        ...row,
        settlement_currencies: Array.isArray(sc) ? sc : null,
      });
      contractsBySupplier.set(row.supplier_id, arr);
    }
    // Resolve to {ownerAppId, contractPolicy}. Returns null owner = no
    // matching contract row (transaction falls into Ops). When the
    // contract has its own currency policy, contractPolicy carries it;
    // the caller falls back to supplier-level fields otherwise.
    const classifyOwner = (supplierId, appId, txnDate) => {
      if (!supplierId || !appId) return { ownerAppId: null, contractPolicy: null };
      const candidates = contractsBySupplier.get(supplierId);
      if (!candidates) return { ownerAppId: null, contractPolicy: null };
      const date = txnDate ? new Date(txnDate) : null;
      const dateMs = date && !Number.isNaN(date.getTime()) ? date.getTime() : null;
      for (const row of candidates) {
        if (row.owner_app_id !== appId) continue;
        const fromMs = row.effective_from ? new Date(row.effective_from).getTime() : null;
        const toMs = row.effective_to ? new Date(row.effective_to).getTime() : null;
        if (fromMs !== null && dateMs !== null && dateMs < fromMs) continue;
        if (toMs !== null && dateMs !== null && dateMs > toMs) continue;
        return {
          ownerAppId: appId,
          contractPolicy: (row.settlement_currency || row.settlement_currencies) ? {
            settlement_currencies: row.settlement_currencies,
            default_settlement_currency: row.settlement_currency,
          } : null,
        };
      }
      return { ownerAppId: null, contractPolicy: null };
    };

    let offset = 0;
    while (true) {
      const [batch] = await pool.query(
        `SELECT id, code, app_id, total_amount, currency, booking_status, payment_status,
                upstream_created, raw
         FROM bookings_cache
         WHERE ((booking_status = 'success' AND payment_status = 'success')
            OR payment_status = 'refunded')
         ${disabledClause}
         ORDER BY id
         LIMIT ${BATCH} OFFSET ${offset}`,
        disabledAppIds
      );
      if (batch.length === 0) break;
      offset += batch.length;

      const txnRows = [];
      for (const b of batch) {
        let raw;
        try { raw = typeof b.raw === 'string' ? JSON.parse(b.raw) : b.raw; } catch { continue; }
        const products = raw?.products || [];
        const bookingTotal = Number(b.total_amount) || 0;
        if (bookingTotal === 0 || products.length === 0) continue;

        const allocations = allocateBookingToSuppliers(bookingTotal, products);
        const isRefund = b.payment_status === 'refunded';
        const txnDate = b.upstream_created || startedAt;
        const originalCcy = b.currency || 'USD';

        for (const alloc of allocations) {
          const canonical = codeToCanonical.get(String(alloc.supplierCode).toLowerCase());
          if (!canonical) continue;
          if (Math.abs(alloc.amount) < 0.01) continue;

          // Statement classification + currency policy. The contract
          // lookup returns BOTH the owner and (if the contract overrides
          // currencies) a per-contract currency policy. When the contract
          // carries its own settlement_currency / settlement_currencies
          // we honour that — e.g. an app whose contract with Amadeus
          // settles in EUR while our Amadeus contract settles in USD.
          const { ownerAppId, contractPolicy } = classifyOwner(canonical, b.app_id, txnDate);
          const supplierPolicy = policyByCanonical.get(canonical)
            || { settlement_currencies: null, default_settlement_currency: 'USD' };
          const policy = contractPolicy || supplierPolicy;
          const targetCcy = pickSettlementCurrency(
            originalCcy,
            policy.settlement_currencies,
            policy.default_settlement_currency,
          );
          const rawProduct = products[alloc.productIndex];
          const conv = await convertCurrencyForSettlement(alloc.amount, originalCcy, targetCcy, txnDate, rawProduct);
          const settledAmount = Math.round(conv.amount * 100) / 100;

          const meta = {
            supplier_code: alloc.supplierCode,
            product_index: alloc.productIndex,
            booking_payment_status: b.payment_status,
            booking_booking_status: b.booking_status,
            booking_app_id: b.app_id || null,
          };

          // Always emit a booking debit. Even for refunded bookings — the
          // original charge happened and must be on the books.
          txnRows.push([
            `booking:${b.id}:${alloc.productIndex}`,
            canonical,
            'booking',
            b.id, b.code,
            settledAmount,
            targetCcy,
            txnDate,
            `Booking ${b.code}`,
            JSON.stringify(meta),
            startedAt,
            originalCcy,
            Math.round(alloc.amount * 100) / 100,
            conv.rate,
            conv.source,
            ownerAppId,
          ]);

          // For refunded bookings, also emit a matching refund credit so
          // the pair nets to zero in the balance calc.
          if (isRefund) {
            txnRows.push([
              `refund:${b.id}:${alloc.productIndex}`,
              canonical,
              'refund',
              b.id, b.code,
              settledAmount,
              targetCcy,
              txnDate,
              `Refund for booking ${b.code}`,
              JSON.stringify(meta),
              startedAt,
              originalCcy,
              Math.round(alloc.amount * 100) / 100,
              conv.rate,
              conv.source,
              ownerAppId,
            ]);
          }
        }
      }

      if (txnRows.length > 0) {
        // Chunk to stay under max_allowed_packet (default 1MB on stock
        // MariaDB; we bumped to 64M but be defensive for portability).
        const CHUNK = 100;
        for (let i = 0; i < txnRows.length; i += CHUNK) {
          await pool.query(
            `INSERT INTO supplier_transactions_cache
               (id, supplier_id, type, booking_id, booking_code, amount, currency,
                txn_date, description, metadata, last_synced_at,
                original_currency, original_amount, fx_rate_used, fx_source,
                owner_app_id)
             VALUES ?
             ON DUPLICATE KEY UPDATE
               supplier_id = VALUES(supplier_id),
               type        = VALUES(type),
               amount      = VALUES(amount),
               currency    = VALUES(currency),
               txn_date    = VALUES(txn_date),
               description = VALUES(description),
               metadata    = VALUES(metadata),
               last_synced_at = VALUES(last_synced_at),
               original_currency = VALUES(original_currency),
               original_amount   = VALUES(original_amount),
               fx_rate_used      = VALUES(fx_rate_used),
               fx_source         = VALUES(fx_source),
               owner_app_id      = VALUES(owner_app_id)`,
            [txnRows.slice(i, i + CHUNK)]
          );
        }
        totalTxns += txnRows.length;
      }
    }

    // Top-ups — one row per active supplier_topup. Soft-deleted top-ups are
    // dropped from the cache so they disappear from the ledger.
    // (Note: alias is `description_text`, not `desc` — `desc` is a reserved
    // word in MariaDB and breaks the parser without backticks.)
    // owner_app_id propagates from the topup row directly (no lookup needed)
    // — the operator picked "Paid by" at create time and we stored that.
    const [topupRows] = await pool.query(
      `SELECT id, supplier_id, amount, currency, paid_on, owner_app_id,
              CONCAT('Top-Up',
                CASE WHEN reference IS NOT NULL THEN CONCAT(' · ', reference) ELSE '' END,
                CASE WHEN notes IS NOT NULL THEN CONCAT(' (', notes, ')') ELSE '' END
              ) AS description_text
       FROM supplier_topups WHERE deleted = 0`
    );
    if (topupRows.length > 0) {
      // Top-ups are recorded in whatever currency the operator entered.
      // No FX conversion — what they paid in cash IS the canonical record.
      const topupTxnRows = topupRows.map(t => [
        `topup:${t.id}`,
        t.supplier_id,
        'topup',
        null, null,
        Number(t.amount),
        t.currency,
        t.paid_on,
        t.description_text,
        JSON.stringify({ topup_id: t.id }),
        startedAt,
        t.currency,           // original_currency = same
        Number(t.amount),     // original_amount   = same
        1,                    // fx_rate_used      = 1
        'identity',           // fx_source
        t.owner_app_id || null, // owner_app_id  (NULL = Ops top-up)
      ]);
      await pool.query(
        `INSERT INTO supplier_transactions_cache
           (id, supplier_id, type, booking_id, booking_code, amount, currency,
            txn_date, description, metadata, last_synced_at,
            original_currency, original_amount, fx_rate_used, fx_source,
            owner_app_id)
         VALUES ?
         ON DUPLICATE KEY UPDATE
           amount = VALUES(amount),
           currency = VALUES(currency),
           txn_date = VALUES(txn_date),
           description = VALUES(description),
           last_synced_at = VALUES(last_synced_at),
           original_currency = VALUES(original_currency),
           original_amount   = VALUES(original_amount),
           fx_rate_used      = VALUES(fx_rate_used),
           fx_source         = VALUES(fx_source),
           owner_app_id      = VALUES(owner_app_id)`,
        [topupTxnRows]
      );
      totalTxns += topupTxnRows.length;
    }

    // Prune stale rows (bookings/topups that disappeared from source — e.g.
    // soft-deleted top-up, or booking removed from cache).
    await pool.query(
      `DELETE FROM supplier_transactions_cache WHERE last_synced_at < ?`,
      [cutoff]
    );

    console.log(`✅ Supplier-balance sync: ${totalTxns} transactions across ${supRows.length} suppliers`);

    // After-sync risk check. Cheap if no thresholds are configured.
    try {
      await checkSupplierCriticalThresholds();
    } catch (e) {
      console.warn('checkSupplierCriticalThresholds failed (non-fatal):', e.message);
    }
  } catch (e) {
    errMsg = e.message;
    console.warn('Supplier-balance sync FAILED:', e.message);
  } finally {
    _supplierSyncRunning = false;
    _supplierSyncLast = {
      startedAt, finishedAt: new Date(), ok: !errMsg, txns: totalTxns, error: errMsg,
    };
  }
  return _supplierSyncLast;
}

// Schedule: 45s after boot (gives bookings_cache time to be ready), then
// every 10 min. Manual trigger via POST /api/suppliers/sync-balances.
setTimeout(() => syncSupplierTransactionsCache().catch(() => {}), 45_000);
setInterval(() => syncSupplierTransactionsCache().catch(() => {}), SUPPLIER_BALANCE_SYNC_INTERVAL_MS);

// ── Supplier critical-threshold monitor ──────────────────────────────────
// Runs after each supplier sync. For every supplier with `critical_threshold`
// set, computes the live balance in their default settlement currency and
// emails the finance team if the threshold is breached. 24h anti-spam guard
// on `last_critical_alert_at` so a supplier sitting under threshold doesn't
// page every 10 minutes.
//
// "Breached" depends on account type:
//   deposit: remaining_deposit  = topups − (bookings − refunds)  ≤ threshold
//            (we're running OUT of pre-paid balance)
//   credit : outstanding_debt   = (bookings − refunds) − topups  ≥ threshold
//            (our debt is approaching their credit limit)
// Audit log helper — write a row to `audit_events` for any money-mutating
// action so accounting has a who/what/when trail. Failures are
// non-blocking: a slow audit insert must never break the main operation.
//
//   action       — short verb_noun (e.g. 'supplier_topup_create')
//   entityType   — domain: 'supplier' | 'booking' | 'email_template' | …
//   entityId     — id of the affected row, when applicable
//   before/after — JSON snapshots; pass null for create/delete-only
//   reason       — optional free-text reason from the operator
async function recordAuditEvent(req, { action, entityType, entityId, before, after, reason }) {
  try {
    const u = req?.platformUser || {};
    const id = require('crypto').randomBytes(16).toString('hex');
    const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim() || null;
    const ip = (req?.headers?.['x-forwarded-for'] || req?.ip || '').split(',')[0].trim() || null;
    await pool.query(
      `INSERT INTO audit_events
         (id, actor_user_id, actor_email, actor_name, action, entity_type, entity_id,
          before_json, after_json, reason, ip_address)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, u.id || null, u.email || null, fullName, action, entityType, entityId || null,
       before == null ? null : JSON.stringify(before),
       after  == null ? null : JSON.stringify(after),
       reason || null, ip]
    );
  } catch (e) {
    console.warn('⚠️  audit insert failed (non-fatal):', e.message);
  }
}

async function loadFinanceTeamEmails() {
  try {
    const [[row]] = await pool.query(
      "SELECT value FROM system_settings WHERE `key` = 'finance_team_emails'"
    );
    if (!row) return [];
    const v = typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
    return Array.isArray(v) ? v.filter(Boolean) : [];
  } catch { return []; }
}

// ── Supplier reminder SLA config ──────────────────────────────────────
// Per-request-type SLA hours + escalation cadence. Stored in system_settings
// under `supplier_reminder_sla` so admins can edit through the UI without a
// deploy. Sensible defaults applied when nothing is saved yet.
const SUPPLIER_REMINDER_SLA_KEY = 'supplier_reminder_sla';
const DEFAULT_SUPPLIER_REMINDER_SLA = Object.freeze({
  hcn:             { sla_hours: 48, reminder_every_hours: 12, max_escalation_level: 5 },
  cancellation:    { sla_hours: 24, reminder_every_hours: 6,  max_escalation_level: 5 },
  change_request:  { sla_hours: 24, reminder_every_hours: 12, max_escalation_level: 5 },
  refund:          { sla_hours: 72, reminder_every_hours: 24, max_escalation_level: 5 },
  other:           { sla_hours: 48, reminder_every_hours: 12, max_escalation_level: 5 },
});
const SUPPLIER_REMINDER_TYPES = Object.keys(DEFAULT_SUPPLIER_REMINDER_SLA);

async function getSupplierReminderSlaConfig() {
  try {
    const [[row]] = await pool.query(
      "SELECT value FROM system_settings WHERE `key` = ?",
      [SUPPLIER_REMINDER_SLA_KEY]
    );
    if (!row) return { ...DEFAULT_SUPPLIER_REMINDER_SLA };
    const v = typeof row.value === 'string' ? JSON.parse(row.value) : row.value;
    // Merge over defaults so a partially-saved config still has fallbacks
    // for new request types we add later.
    const merged = { ...DEFAULT_SUPPLIER_REMINDER_SLA };
    if (v && typeof v === 'object') {
      for (const [k, conf] of Object.entries(v)) {
        if (conf && typeof conf === 'object') merged[k] = { ...merged[k], ...conf };
      }
    }
    return merged;
  } catch { return { ...DEFAULT_SUPPLIER_REMINDER_SLA }; }
}

async function setSupplierReminderSlaConfig(value, updatedBy) {
  await pool.query(
    `INSERT INTO system_settings (\`key\`, value, updated_by)
       VALUES (?, ?, ?)
     ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by)`,
    [SUPPLIER_REMINDER_SLA_KEY, JSON.stringify(value), updatedBy || null]
  );
}

// Pick the SLA config for one request type, falling back to 'other'.
function pickSupplierReminderSla(config, type) {
  return config[type] || config.other || DEFAULT_SUPPLIER_REMINDER_SLA.other;
}

// Resolve who should be reminded about a supplier reminder. Includes:
//  - The reminder's creator (so they're aware their request is overdue)
//  - The assigned agent on the linked support_request (if any)
//  - All active Ops users (login_context='system') as catch-all
// We dedupe before notifying.
async function resolveSupplierReminderRecipients(reminder) {
  const ids = new Set();
  if (reminder.created_by) ids.add(reminder.created_by);
  if (reminder.request_id) {
    try {
      const [[r]] = await pool.query(
        'SELECT assigned_agent FROM support_requests WHERE id = ?',
        [reminder.request_id]
      );
      if (r?.assigned_agent) ids.add(r.assigned_agent);
    } catch { /* ignore */ }
  }
  // All active Ops admins/managers/agents.
  try {
    const [rows] = await pool.query(
      `SELECT id FROM platform_users
       WHERE login_context = 'system' AND is_active = 1
         AND role IN ('admin', 'manager', 'agent', 'customer_service')`
    );
    for (const r of rows) ids.add(r.id);
  } catch { /* platform_users may not have login_context column in dev */ }
  return Array.from(ids);
}

// Friendly label for a request_type — used in notification titles + emails.
function supplierReminderTypeLabel(type) {
  switch (type) {
    case 'hcn': return 'HCN (Hotel Confirmation Number)';
    case 'cancellation': return 'Cancellation';
    case 'change_request': return 'Change request';
    case 'refund': return 'Refund';
    default: return 'Supplier request';
  }
}

// Background checker — runs every 5 min. Picks up open reminders whose
// next_reminder_at has passed, fans out an in-app notification + email
// to the assigned agent and Ops admins, then bumps escalation_level and
// schedules the next ping. Stops escalating at max_escalation_level.
let _supplierReminderTickRunning = false;
async function checkSupplierReminders() {
  if (_supplierReminderTickRunning) return; // single-flight
  _supplierReminderTickRunning = true;
  try {
    const [rows] = await pool.query(
      `SELECT * FROM supplier_reminders
       WHERE status = 'open'
         AND next_reminder_at <= UTC_TIMESTAMP()
       ORDER BY next_reminder_at ASC
       LIMIT 100`
    );
    if (rows.length === 0) return;
    for (const r of rows) {
      try {
        const recipients = await resolveSupplierReminderRecipients(r);
        const overdueHours = Math.round((Date.now() - new Date(r.created_at).getTime()) / (1000 * 60 * 60));
        const typeLabel = supplierReminderTypeLabel(r.request_type);
        const supplierLabel = r.supplier_name || r.supplier_email || 'supplier';
        const bookingTag = r.booking_code ? ` · booking ${r.booking_code}` : '';
        const title = `Supplier hasn't replied: ${typeLabel}${bookingTag}`;
        const body = `${supplierLabel} hasn't replied to our ${typeLabel.toLowerCase()} request yet (${overdueHours}h elapsed). Reminder #${r.escalation_level + 1}.`;

        await createNotification({
          user_ids: recipients,
          kind: 'supplier_reminder_overdue',
          title,
          body,
          priority: r.escalation_level >= 2 ? 'high' : 'normal',
          link_path: '/supplier-reminders',
          entity_type: 'supplier_reminder',
          entity_id: r.id,
          metadata: {
            request_type: r.request_type,
            supplier_email: r.supplier_email,
            supplier_name: r.supplier_name,
            booking_code: r.booking_code,
            escalation_level: r.escalation_level + 1,
          },
        });

        // Send a separate email blast (createNotification only auto-emails
        // for support_request entities). We use sendFinanceAlertEmail's
        // SMTP path with a small custom body.
        try {
          const [users] = await pool.query(
            `SELECT email FROM platform_users WHERE id IN (${recipients.map(() => '?').join(',')})`,
            recipients
          );
          const emails = users.map(u => u.email).filter(Boolean);
          if (emails.length > 0) {
            const html = `
              <div style="font-family:Arial,sans-serif;color:#111;max-width:560px;margin:0 auto;padding:16px;">
                <h2 style="margin:0 0 8px 0;color:#dc2626;">Reminder: Supplier hasn't replied</h2>
                <p>${body}</p>
                <table style="border-collapse:collapse;width:100%;margin:12px 0;">
                  <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;width:40%;">Request type</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${typeLabel}</td></tr>
                  <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Supplier</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${(r.supplier_name || '').replace(/</g, '&lt;')}${r.supplier_email ? ` &lt;${r.supplier_email.replace(/</g, '&lt;')}&gt;` : ''}</td></tr>
                  ${r.booking_code ? `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Booking</td><td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;">${r.booking_code}</td></tr>` : ''}
                  ${r.subject ? `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Subject</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${r.subject.replace(/</g, '&lt;')}</td></tr>` : ''}
                  <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Elapsed</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${overdueHours} hours</td></tr>
                  <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Reminder #</td><td style="padding:6px 8px;border:1px solid #e5e7eb;">${r.escalation_level + 1}</td></tr>
                </table>
                <p>Open the <a href="${process.env.PUBLIC_URL || 'http://localhost:8080'}/supplier-reminders">Supplier Reminders</a> page to chase or mark as replied.</p>
              </div>`;
            await sendFinanceAlertEmail(emails, `[Reminder] ${supplierLabel} hasn't replied — ${typeLabel}`, html);
          }
        } catch (e) {
          console.warn('supplier reminder email failed:', e.message);
        }

        // Schedule the next reminder (or stop escalating).
        const newLevel = r.escalation_level + 1;
        const stopEscalating = newLevel >= (r.max_escalation_level || 5);
        const nextAt = stopEscalating
          ? null
          : new Date(Date.now() + (Number(r.reminder_every_hours) || 12) * 60 * 60 * 1000);
        if (stopEscalating) {
          // Don't auto-close — leave open so an Ops user can flip it manually.
          // But push next_reminder_at far into the future so we stop emailing.
          await pool.query(
            `UPDATE supplier_reminders
             SET escalation_level = ?, last_reminder_at = UTC_TIMESTAMP(),
                 next_reminder_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 DAY)
             WHERE id = ?`,
            [newLevel, r.id]
          );
        } else {
          await pool.query(
            `UPDATE supplier_reminders
             SET escalation_level = ?, last_reminder_at = UTC_TIMESTAMP(),
                 next_reminder_at = ?
             WHERE id = ?`,
            [newLevel, toMysqlDatetime(nextAt.toISOString()), r.id]
          );
        }
      } catch (e) {
        console.warn(`reminder ${r.id} fan-out failed:`, e.message);
      }
    }
  } catch (e) {
    console.warn('checkSupplierReminders failed:', e.message);
  } finally {
    _supplierReminderTickRunning = false;
  }
}

async function sendFinanceAlertEmail(toList, subject, html) {
  if (!toList || toList.length === 0) {
    console.warn('🔕 supplier alert skipped — no finance recipients configured');
    return false;
  }
  let nodemailer;
  try { nodemailer = require('nodemailer'); }
  catch { console.warn('nodemailer not installed'); return false; }
  const account = await resolveSmtpAccount(null);
  if (!account) { console.warn('no SMTP account configured for finance alert'); return false; }
  try {
    const transporter = nodemailer.createTransport({
      host: account.host, port: account.port, secure: account.secure,
      auth: { user: account.username, pass: decryptSecret(account.password) },
    });
    await transporter.sendMail({
      from: `"${account.from_name || 'EasyGDS Ops'}" <${account.from_email || account.username}>`,
      to: toList.join(', '),
      subject, html,
    });
    return true;
  } catch (e) {
    console.warn('finance alert send failed:', e.message);
    return false;
  }
}

/**
 * Render the default supplier topup email.
 *  supplier   { name, code }
 *  topup      { amount, currency, paid_on, reference, payment_method_name, notes }
 *  hasAttachment  whether an attachment will be included (changes wording)
 *
 * Pure — no I/O. Returns { subject, html } so the same template can render
 * for the preview endpoint AND the send endpoint, ensuring what the operator
 * previews is what the supplier receives.
 */
function renderSupplierTopupEmail({ supplier, topup, hasAttachment }) {
  const fmtAmt = `${Number(topup.amount).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} ${topup.currency}`;
  const supplierLabel = supplier.name || supplier.code || 'Supplier';
  const subject = `Payment confirmation: ${fmtAmt} on ${topup.paid_on}`;

  // Plain inline-styled HTML — keeps the email readable in clients that
  // strip <style>. wrapInEmailShell() exists but adds Ops-portal branding
  // we don't want for a supplier-facing message.
  const safeRef = (topup.reference || '').replace(/</g, '&lt;');
  const safeNotes = (topup.notes || '').replace(/</g, '&lt;');
  const html = `
    <div style="font-family:Arial,sans-serif;color:#111;max-width:560px;margin:0 auto;padding:20px;">
      <h2 style="margin:0 0 12px 0;color:#0d6efd;">Payment confirmation</h2>
      <p>Hello ${supplierLabel},</p>
      <p>This is to confirm a payment we made to your account. Details:</p>
      <table style="border-collapse:collapse;width:100%;margin:12px 0;">
        <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;width:40%;">Amount</td>
            <td style="padding:6px 8px;border:1px solid #e5e7eb;"><strong>${fmtAmt}</strong></td></tr>
        <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Payment date</td>
            <td style="padding:6px 8px;border:1px solid #e5e7eb;">${topup.paid_on}</td></tr>
        <tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Method</td>
            <td style="padding:6px 8px;border:1px solid #e5e7eb;">${topup.payment_method_name || '—'}</td></tr>
        ${safeRef ? `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Reference</td>
            <td style="padding:6px 8px;border:1px solid #e5e7eb;font-family:monospace;">${safeRef}</td></tr>` : ''}
        ${safeNotes ? `<tr><td style="padding:6px 8px;border:1px solid #e5e7eb;background:#f9fafb;">Notes</td>
            <td style="padding:6px 8px;border:1px solid #e5e7eb;">${safeNotes}</td></tr>` : ''}
      </table>
      ${hasAttachment ? `<p style="color:#555;">A proof of payment is attached to this email.</p>` : ''}
      <p>If anything looks off, please reply to this email so we can reconcile.</p>
      <p style="color:#888;font-size:12px;margin-top:24px;">— EasyGDS Operations</p>
    </div>`;
  return { subject, html };
}

/**
 * Send a payment-confirmation email to a supplier after a top-up is recorded.
 * Optionally attaches the bank slip / proof of payment the operator uploaded.
 *
 *  to             recipient email (typically supplier.primary_email)
 *  cc             optional array of cc addresses
 *  subject        rendered or operator-edited subject
 *  html           rendered or operator-edited body
 *  attachmentAbs  absolute path on disk to the proof-of-payment file (or null)
 *
 * Returns { ok: boolean, error?: string } so callers can log the outcome
 * without breaking the topup creation if mail fails.
 */
async function sendSupplierTopupEmail({ to, cc, subject, html, attachmentAbs, actingUser }) {
  if (!to) return { ok: false, error: 'No recipient' };
  if (!subject || !html) return { ok: false, error: 'Subject and body are required' };
  let nodemailer;
  try { nodemailer = require('nodemailer'); }
  catch { return { ok: false, error: 'nodemailer not installed' }; }
  // Acting user is the operator who recorded the top-up. We send "from"
  // their preferred / scope-default SMTP so the supplier sees the human
  // reaching out. Falls back to system default if they have nothing
  // configured. This is Ops/Admin only per the agreed scope.
  let account = null;
  if (actingUser) {
    try { account = await resolveSmtpForSend(actingUser, null); } catch { /* fall through */ }
  }
  if (!account) account = await resolveSmtpAccount(null);
  if (!account) return { ok: false, error: 'No SMTP account configured' };

  const attachments = [];
  if (attachmentAbs) {
    try {
      // sanity-check the path exists before handing to nodemailer.
      if (fs.existsSync(attachmentAbs)) {
        attachments.push({ filename: path.basename(attachmentAbs), path: attachmentAbs });
      }
    } catch { /* best-effort */ }
  }

  try {
    const transporter = nodemailer.createTransport({
      host: account.host, port: account.port, secure: account.secure,
      auth: { user: account.username, pass: decryptSecret(account.password) },
    });
    await transporter.sendMail({
      from: `"${account.from_name || 'EasyGDS Ops'}" <${account.from_email || account.username}>`,
      to,
      cc: Array.isArray(cc) && cc.length ? cc.join(', ') : undefined,
      subject,
      html,
      attachments,
    });
    return { ok: true };
  } catch (e) {
    console.warn('supplier topup email failed:', e.message);
    return { ok: false, error: e.message };
  }
}

async function checkSupplierCriticalThresholds() {
  const [supps] = await pool.query(
    `SELECT id, name, code,
            account_type, critical_threshold, credit_limit,
            default_settlement_currency, last_critical_alert_at,
            critical_alert_cooldown_minutes
       FROM suppliers
      WHERE critical_threshold IS NOT NULL
        AND merged_into_id IS NULL
        AND is_active = 1`
  );
  if (supps.length === 0) return { checked: 0, alerted: 0 };

  // Pull all transactions in one shot, group in memory.
  const supIds = supps.map(s => s.id);
  const placeholders = supIds.map(() => '?').join(',');
  const [txns] = await pool.query(
    `SELECT supplier_id, type, currency, amount
       FROM supplier_transactions_cache
      WHERE supplier_id IN (${placeholders})`,
    supIds
  );
  // Aggregate per (supplier_id, currency) → {bookings, refunds, topups}.
  const agg = new Map();
  for (const t of txns) {
    const key = `${t.supplier_id}|${t.currency}`;
    const a = agg.get(key) || { bookings: 0, refunds: 0, topups: 0 };
    a[`${t.type}s`] = (a[`${t.type}s`] || 0) + Number(t.amount);
    agg.set(key, a);
  }

  const fxToday = await fetchFxRates(); // for cross-currency rollup
  const recipients = await loadFinanceTeamEmails();
  const now = new Date();
  const DEFAULT_COOLDOWN_MIN = 24 * 60; // 24h matches old hardcoded behaviour
  let alerted = 0;

  for (const s of supps) {
    const targetCcy = s.default_settlement_currency || 'USD';
    // Sum each currency component into target ccy via today's FX. Fine for
    // an alert (we don't store rates per-currency-bucket, just want a
    // headline number).
    let bookings = 0, refunds = 0, topups = 0;
    for (const [key, a] of agg.entries()) {
      const [supId, ccy] = key.split('|');
      if (supId !== s.id) continue;
      let mul = 1;
      if (ccy !== targetCcy) {
        const rFrom = Number(fxToday?.rates?.[ccy]);
        const rTo   = Number(fxToday?.rates?.[targetCcy]);
        if (Number.isFinite(rFrom) && Number.isFinite(rTo) && rFrom > 0 && rTo > 0) {
          mul = rTo / rFrom;
        }
      }
      bookings += a.bookings * mul;
      refunds  += a.refunds  * mul;
      topups   += a.topups   * mul;
    }
    const netActivity = bookings - refunds;       // gross spend with this supplier
    const remainingDeposit = topups - netActivity; // for deposit accounts
    const outstandingDebt  = netActivity - topups; // for credit  accounts
    const threshold = Number(s.critical_threshold);
    const accountType = s.account_type || 'deposit';

    let isCritical = false;
    let headline = '';
    if (accountType === 'deposit') {
      isCritical = remainingDeposit <= threshold;
      headline = `${targetCcy} ${remainingDeposit.toFixed(2)} remaining (threshold ${threshold.toFixed(2)})`;
    } else {
      isCritical = outstandingDebt >= threshold;
      headline = `${targetCcy} ${outstandingDebt.toFixed(2)} outstanding (threshold ${threshold.toFixed(2)}${
        s.credit_limit ? `, credit limit ${Number(s.credit_limit).toFixed(2)}` : ''})`;
    }

    if (!isCritical) continue;

    // Per-supplier cooldown — finance can dial high-volume suppliers down
    // to e.g. 60 min during peak season, or up to 1 week (10080 min) for
    // long-tail vendors. Falls back to 24h when unset.
    const cooldownMin = s.critical_alert_cooldown_minutes != null
      ? Math.max(15, Number(s.critical_alert_cooldown_minutes) || DEFAULT_COOLDOWN_MIN)
      : DEFAULT_COOLDOWN_MIN;
    const cooldownMs = cooldownMin * 60 * 1000;
    const lastAlert = s.last_critical_alert_at ? new Date(s.last_critical_alert_at).getTime() : 0;
    if (now.getTime() - lastAlert < cooldownMs) continue;

    const supplierLabel = s.name || s.code || s.id;
    const subject = `[Supplier balance alert] ${supplierLabel} — critical threshold reached`;
    const html = `
      <h3>Supplier balance alert</h3>
      <p><strong>${supplierLabel}</strong> (${s.code || '—'}) is at a critical balance.</p>
      <ul>
        <li>Account type: <strong>${accountType}</strong></li>
        <li>Balance: <strong>${headline}</strong></li>
        <li>Bookings: ${targetCcy} ${bookings.toFixed(2)}</li>
        <li>Refunds: ${targetCcy} ${refunds.toFixed(2)}</li>
        <li>Topups: ${targetCcy} ${topups.toFixed(2)}</li>
      </ul>
      <p>Open the supplier's ledger in the Ops console to take action.</p>
    `;
    const sent = await sendFinanceAlertEmail(recipients, subject, html);
    if (sent) {
      await pool.query(
        `UPDATE suppliers SET last_critical_alert_at = ? WHERE id = ?`,
        [now, s.id]
      );
      alerted += 1;
      console.log(`📨 supplier critical alert sent: ${supplierLabel} (${headline})`);
    }
  }
  return { checked: supps.length, alerted };
}

// ── Emails — listing + single fetch ────────────────────────────────────

app.get('/rest/v1/emails', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    // Agency staff (agent context) only ever see emails linked to their own
    // tickets — surfaced via /support_requests/:id/emails, not this list
    // endpoint.
    if (perms.isAgentContext(user)) return ok(res, []);

    // ── Multi-tenancy gate ──────────────────────────────────────────────
    // App users see only emails for their allowed apps. Ops sees
    // everything — including NULL app_id rows, which are either
    // pre-backfill leftovers or emails ingested on a system-owned IMAP
    // account that isn't tied to a specific tenant ("main inbox").
    //
    // `isOpsReadAccess` matches `isOps` PLUS any active Ops team
    // member (mirrors the team-membership pattern documented in
    // getEffectiveOpsRules). Without this, OPS team members with roles
    // like 'agent'/'customer_service' — whom the auth flow auto-
    // downgrades to login_context='app' — would never see the main
    // inbox even though they're functionally Ops.
    const isOps = perms.isOpsReadAccess(user);
    let appIdGate = '';
    const appIdGateParams = [];
    if (!isOps) {
      const allowed = Array.isArray(user.allowed_app_ids) ? user.allowed_app_ids : [];
      if (allowed.length === 0) return ok(res, []);  // app user with no apps → empty
      appIdGate = ` AND e.app_id IN (${allowed.map(() => '?').join(',')})`;
      appIdGateParams.push(...allowed);
    }

    // LEFT JOIN email_inboxes so each row carries the inbox name — the
    // frontend uses it to show "from <Account>" badges and to populate the
    // inbox filter dropdown without a second round-trip.
    let sql = `SELECT e.id, e.message_id, e.thread_id, e.in_reply_to, e.references_chain,
                      e.direction, e.source,
                      e.from_email, e.from_name, e.to_emails, e.cc_emails, e.subject, e.snippet,
                      e.received_at, e.fetched_at, e.category, e.supplier_id, e.booking_id,
                      e.booking_code, e.support_request_id, e.auto_linked, e.confidence,
                      e.parsed_data, e.attachments_count, e.is_read, e.is_starred, e.is_archived,
                      e.inbox_id, ib.name AS inbox_name,
                      e.ai_classified, e.ai_confidence,
                      e.ai_suggested_booking_code, e.ai_reason
               FROM emails e
               LEFT JOIN email_inboxes ib ON e.inbox_id = ib.id
               WHERE 1=1`;
    const params = [];
    if (req.query.category)          { sql += ' AND e.category = ?'; params.push(req.query.category); }
    if (req.query.supplier_id)       { sql += ' AND e.supplier_id = ?'; params.push(req.query.supplier_id); }
    if (req.query.booking_id)        { sql += ' AND e.booking_id = ?'; params.push(req.query.booking_id); }
    if (req.query.booking_code)      { sql += ' AND e.booking_code = ?'; params.push(req.query.booking_code); }
    if (req.query.support_request_id){ sql += ' AND e.support_request_id = ?'; params.push(req.query.support_request_id); }
    if (req.query.from)              { sql += ' AND e.from_email = ?'; params.push(req.query.from); }
    // Filter by inbox account — used when the user wants to see only emails
    // from one of their configured accounts. The literal string "unknown"
    // matches legacy rows where inbox_id is NULL.
    if (req.query.inbox_id === 'unknown') sql += ' AND e.inbox_id IS NULL';
    else if (req.query.inbox_id)     { sql += ' AND e.inbox_id = ?'; params.push(req.query.inbox_id); }
    if (req.query.unlinked === 'true') sql += ' AND e.booking_id IS NULL AND e.support_request_id IS NULL';
    // Archive filter — by default the inbox view hides archived emails so
    // clicking Archive visibly removes the row from the list. Pass
    // `archived=true` to see ONLY archived (the "Archived" tab),
    // `archived=all` to see both. Without this filter the Archive button
    // looked like it did nothing.
    const archivedParam = String(req.query.archived || 'false').toLowerCase();
    if (archivedParam === 'true') sql += ' AND e.is_archived = 1';
    else if (archivedParam !== 'all') sql += ' AND e.is_archived = 0';
    if (req.query.q) {
      sql += ' AND (e.subject LIKE ? OR e.snippet LIKE ? OR e.from_email LIKE ?)';
      const like = `%${req.query.q}%`;
      params.push(like, like, like);
    }
    // ── Participant filter (booking-scoped supplier/customer threads) ────
    // Used by the booking-detail page's "Supplier Thread" / "Customer
    // Thread" deep-link buttons.
    //
    //   participant=customer → category='customer' (set by Email Customer /
    //                          Send Voucher dialogs) OR the booking's
    //                          contact email appears in from/to/cc.
    //   participant=supplier → category='supplier' OR e.supplier_id set OR
    //                          a known supplier_contacts email appears in
    //                          from/to/cc. Excludes anything that ALSO
    //                          looks customer-bound to avoid double-counting.
    //
    // Both require booking_code (or booking_id) to be in scope, otherwise
    // there's no anchor and we just skip the participant filter.
    //
    // Why category-first? Our send dialogs explicitly tag every outbound
    // email with the right category — that's the authoritative signal.
    // Falling back to email-address matching catches inbound replies AND
    // legacy rows where category is still 'unknown'.
    if (req.query.participant && (req.query.booking_code || req.query.booking_id)) {
      const part = String(req.query.participant).toLowerCase();
      // Resolve customer email from bookings_cache (contact_email column,
      // then fallbacks in raw → contact / customer / agent_user).
      const codeOrId = req.query.booking_code || req.query.booking_id;
      const [[bk]] = await pool.query(
        `SELECT id, contact_email, raw FROM bookings_cache
           WHERE code = ? OR id = ?
           LIMIT 1`,
        [codeOrId, codeOrId]
      );
      let customerEmail = bk?.contact_email || null;
      if (!customerEmail && bk?.raw) {
        try {
          const r = typeof bk.raw === 'string' ? JSON.parse(bk.raw) : bk.raw;
          customerEmail =
            r?.contact?.email ||
            r?.customer?.email ||
            r?.agent_user?.email ||
            null;
        } catch {}
      }
      if (part === 'customer') {
        // Match if EITHER the row was tagged customer at send time, OR the
        // booking's customer email appears in any address field.
        const conds = ["e.category = 'customer'"];
        if (customerEmail) {
          conds.push('(e.from_email = ? OR e.to_emails LIKE ? OR e.cc_emails LIKE ?)');
          params.push(customerEmail, `%${customerEmail}%`, `%${customerEmail}%`);
        }
        sql += ` AND (${conds.join(' OR ')})`;
      } else if (part === 'supplier') {
        // Match supplier-tagged rows OR rows linked to a supplier OR rows
        // touching a known supplier contact email. Then subtract anything
        // that also looks customer-bound (to keep the buckets disjoint).
        sql += ` AND (
            e.category = 'supplier'
            OR e.supplier_id IS NOT NULL
            OR e.from_email IN (SELECT value FROM supplier_contacts WHERE type='email')
            OR EXISTS (
                 SELECT 1 FROM supplier_contacts sc
                  WHERE sc.type='email' AND (
                    e.to_emails LIKE CONCAT('%', sc.value, '%')
                    OR e.cc_emails LIKE CONCAT('%', sc.value, '%')
                  )
               )
          )`;
        // Exclude customer-tagged rows.
        sql += ` AND e.category <> 'customer'`;
        if (customerEmail) {
          // Also exclude anything addressed to the customer email — covers
          // legacy/unknown-category rows.
          sql += ' AND e.from_email <> ? AND (e.to_emails IS NULL OR e.to_emails NOT LIKE ?) AND (e.cc_emails IS NULL OR e.cc_emails NOT LIKE ?)';
          params.push(customerEmail, `%${customerEmail}%`, `%${customerEmail}%`);
        }
      }
      // participant=all → no extra filter
    }
    if (appIdGate) {
      sql += appIdGate;
      params.push(...appIdGateParams);
    }
    sql += ' ORDER BY e.received_at DESC LIMIT ?';
    params.push(Math.min(parseInt(req.query.limit) || 100, 500));

    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(r => {
      const out = { ...r };
      for (const k of ['to_emails','cc_emails','parsed_data']) {
        if (out[k] && typeof out[k] === 'string') { try { out[k] = JSON.parse(out[k]); } catch {} }
      }
      return out;
    }));
  } catch (e) { err(res, e.message, 500); }
});

// Return every message in the same conversation as the given email, in
// chronological order. Used by the inbox detail pane so the stacked
// conversation view stays complete even when the list is filtered by
// category/search (e.g. viewing only "Customer" emails shouldn't hide the
// "Internal" reply that belongs to the same thread). Grouping rule
// mirrors the client — normalized subject + thread_id as tiebreaker.
app.get('/rest/v1/emails/:id/thread', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    if (user.login_context === 'agent') return ok(res, []);
    const [[origin]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    if (!origin) return err(res, 'Not found', 404);

    // Normalized subject: strip leading Re:/Fw:/Fwd: prefixes (case-insensitive,
    // can chain like "Re: Fwd: Subject") so the group key is stable across
    // replies and forwards. Matches the client's normalizeSubject exactly.
    const normalize = (s) =>
      String(s || '').replace(/^(?:\s*(?:re|fw|fwd)\s*:\s*)+/i, '').trim().toLowerCase();
    const subjKey = normalize(origin.subject);

    // Inline attachments for a batch of rows. Declared up front so either
    // branch of the thread walk can use it (hoisting across the early
    // return below would otherwise risk a TDZ error in strict mode).
    const inlineAttachments = async (rs) => {
      const ids = rs.map(m => m.id);
      let attsByEmail = new Map();
      if (ids.length > 0) {
        const ph = ids.map(() => '?').join(',');
        const [atts] = await pool.query(
          `SELECT email_id, id, filename, mime_type, size_bytes, content_id, created_at
           FROM email_attachments WHERE email_id IN (${ph}) ORDER BY created_at ASC`,
          ids
        );
        for (const a of atts) {
          if (!attsByEmail.has(a.email_id)) attsByEmail.set(a.email_id, []);
          attsByEmail.get(a.email_id).push(a);
        }
      }
      return rs.map(r => {
        const row = { ...r };
        for (const k of ['to_emails', 'cc_emails', 'parsed_data', 'raw_headers']) {
          if (row[k] && typeof row[k] === 'string') { try { row[k] = JSON.parse(row[k]); } catch {} }
        }
        row.attachments = attsByEmail.get(r.id) || [];
        return row;
      });
    };

    // Grouping policy — RFC 5322 Message-ID ancestry. Two emails are in
    // the same thread if and only if one's Message-ID appears in the
    // other's In-Reply-To / References chain. Subject is kept as a safety
    // filter so upstream header weirdness can't fuse unrelated threads.
    //
    // We do NOT rely on thread_id here: Gmail/IMAP synthesize a shared
    // thread_id for unrelated emails that happen to share a subject
    // (Google's "Security alert" x4), which would group them incorrectly.
    //
    // Algorithm — iterative expansion:
    //   1. Seed the set with the origin's Message-ID and every ancestor
    //      id listed in its References / In-Reply-To.
    //   2. Walk outward: add any email whose Message-ID matches an id in
    //      our set, or whose References/In-Reply-To references an id we
    //      already have. Repeat until nothing new is discovered.
    //   3. Subject-filter at the end.
    //
    // For a typical support thread (< 20 messages) this finishes in 2-3
    // iterations and a handful of cheap queries.

    const splitRefs = (s) =>
      String(s || '').trim().split(/\s+/).filter(Boolean);

    // Seed set of Message-IDs connected to the origin.
    const linkedIds = new Set();
    if (origin.message_id) linkedIds.add(origin.message_id);
    if (origin.in_reply_to) linkedIds.add(origin.in_reply_to);
    for (const r of splitRefs(origin.references_chain)) linkedIds.add(r);

    if (linkedIds.size === 0) {
      // No linkage possible — email stands alone. (Happens for forwards /
      // fresh sends that carry no References.)
      return ok(res, await inlineAttachments([origin]));
    }

    // Collected rows keyed by id so we don't double-add.
    const collected = new Map([[origin.id, origin]]);

    // Walk outward, max 5 iterations as a safety rail.
    for (let iter = 0; iter < 5; iter++) {
      const pendingIds = [...linkedIds];
      const placeholders = pendingIds.map(() => '?').join(',');
      const likeClauses = pendingIds.map(() => 'references_chain LIKE ?').join(' OR ');
      // One query: rows whose message_id / in_reply_to is in the set, or
      // whose references_chain textually contains any id in the set. LIKE
      // is fine here because Message-IDs are `<something@domain>` and
      // appear uniquely in the chain.
      const [rows] = await pool.query(
        `SELECT * FROM emails
         WHERE message_id IN (${placeholders})
            OR in_reply_to IN (${placeholders})
            OR (${likeClauses})
         LIMIT 100`,
        [...pendingIds, ...pendingIds, ...pendingIds.map(x => `%${x}%`)]
      );

      let addedSomething = false;
      for (const r of rows) {
        if (!collected.has(r.id)) { collected.set(r.id, r); addedSomething = true; }
        // Extend the linked set with this email's own ancestry for next
        // iteration. Only add if it's a genuine graph neighbour (already
        // collected) so we don't leak into unrelated threads.
        if (r.message_id && !linkedIds.has(r.message_id)) {
          linkedIds.add(r.message_id); addedSomething = true;
        }
        if (r.in_reply_to && !linkedIds.has(r.in_reply_to)) {
          linkedIds.add(r.in_reply_to); addedSomething = true;
        }
        for (const id of splitRefs(r.references_chain)) {
          if (!linkedIds.has(id)) { linkedIds.add(id); addedSomething = true; }
        }
      }
      if (!addedSomething) break;
    }

    // Final safety filter: drop anything whose normalized subject diverges.
    // This guards against rare cases where two unrelated conversations
    // happen to reference a shared utility Message-ID.
    const matches = [...collected.values()].filter(
      r => normalize(r.subject) === subjKey
    );

    // Sort chronologically for the stacked view.
    matches.sort((a, b) =>
      new Date(a.received_at || 0).getTime() - new Date(b.received_at || 0).getTime()
    );

    ok(res, await inlineAttachments(matches));
  } catch (e) { err(res, e.message, 500); }
});

// One-shot thread_id backfill — recomputes thread_id from references_chain,
// in_reply_to, and message_id so older rows (ingested before the grouping
// fix) can now cluster correctly. Admin only; safe to run repeatedly.
app.post('/rest/v1/emails/rethread', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [rows] = await pool.query(
      'SELECT id, message_id, in_reply_to, references_chain FROM emails'
    );
    let updated = 0;
    for (const r of rows) {
      const refs = r.references_chain ? String(r.references_chain).trim().split(/\s+/) : [];
      const root = wrapMsgId(refs[0]) || wrapMsgId(r.in_reply_to) || wrapMsgId(r.message_id) || null;
      if (root) {
        await pool.query('UPDATE emails SET thread_id = ? WHERE id = ?', [root, r.id]);
        updated += 1;
      }
    }
    ok(res, { total: rows.length, updated });
  } catch (e) { err(res, e.message, 500); }
});

// Stats for the inbox header (mirrors what the old Supabase dashboard showed).
app.get('/rest/v1/emails/stats', authMiddleware, async (req, res) => {
  try {
    const [[total]]    = await pool.query('SELECT COUNT(*) AS c FROM emails');
    const [[today]]    = await pool.query('SELECT COUNT(*) AS c FROM emails WHERE received_at >= CURDATE()');
    const [[unread]]   = await pool.query('SELECT COUNT(*) AS c FROM emails WHERE is_read = 0');
    const [[unlinked]] = await pool.query('SELECT COUNT(*) AS c FROM emails WHERE booking_id IS NULL AND support_request_id IS NULL');
    const [[starred]]  = await pool.query('SELECT COUNT(*) AS c FROM emails WHERE is_starred = 1');
    const [cats]       = await pool.query('SELECT category, COUNT(*) AS c FROM emails GROUP BY category');
    const byCategory = { customer: 0, supplier: 0, internal: 0, unknown: 0 };
    for (const r of cats) byCategory[r.category] = Number(r.c);
    ok(res, {
      total: Number(total.c),
      today: Number(today.c),
      unread: Number(unread.c),
      unlinked: Number(unlinked.c),
      starred: Number(starred.c),
      by_category: byCategory,
    });
  } catch (e) { err(res, e.message, 500); }
});

app.get('/rest/v1/emails/:id', authMiddleware, async (req, res) => {
  try {
    const [[row]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    for (const k of ['to_emails','cc_emails','parsed_data','raw_headers']) {
      if (row[k] && typeof row[k] === 'string') { try { row[k] = JSON.parse(row[k]); } catch {} }
    }
    // Inline attachments list so the UI doesn't need a second round-trip.
    const [atts] = await pool.query(
      'SELECT id, filename, mime_type, size_bytes, content_id, created_at FROM email_attachments WHERE email_id = ? ORDER BY created_at ASC',
      [req.params.id]
    );
    row.attachments = atts;
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// Serve one attachment as a binary stream.
app.get('/rest/v1/email-attachments/:id/download', authMiddleware, async (req, res) => {
  const fs = require('fs');
  try {
    const [[row]] = await pool.query('SELECT * FROM email_attachments WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    if (!row.storage_path || !fs.existsSync(row.storage_path)) {
      return err(res, 'File not stored on disk', 404);
    }
    res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
    res.setHeader('Content-Length', row.size_bytes || fs.statSync(row.storage_path).size);
    // Inline for images / PDFs so they preview; attachment otherwise.
    const inline = /^(image\/|application\/pdf|text\/plain)/.test(row.mime_type || '');
    const dispo = inline ? 'inline' : 'attachment';
    const safeName = String(row.filename || 'file').replace(/"/g, '');
    res.setHeader('Content-Disposition', `${dispo}; filename="${safeName}"`);
    fs.createReadStream(row.storage_path).pipe(res);
  } catch (e) {
    try { err(res, e.message, 500); } catch (_) { /* headers may already be sent */ }
  }
});

// Confirm an AI-suggested booking link. One-click action from the inbox
// banner — applies the AI's suggested booking_code as if the operator
// had typed it manually into Link to booking. Differs from /link in that:
//   - It's a single-purpose endpoint (only the booking_code field).
//   - It clears the ai_suggested_booking_code so the banner disappears.
//   - It bumps confidence to 1.0 (operator confirmed it).
//   - It writes an audit_events row noting AI was the source.
app.post('/rest/v1/emails/:id/confirm-ai-suggestion', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const [[email]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    if (!email) return err(res, 'Email not found', 404);
    if (!email.ai_suggested_booking_code) {
      return err(res, 'No AI suggestion on this email to confirm', 400);
    }

    // Resolve the suggested code → booking row in the cache to get id + app_id.
    const [[bk]] = await pool.query(
      `SELECT id, code, app_id FROM bookings_cache WHERE code = ? LIMIT 1`,
      [email.ai_suggested_booking_code]
    );
    if (!bk) {
      // The suggested booking has since been deleted/un-cached. Surface a
      // useful error rather than a silent no-op.
      return err(res, `AI-suggested booking ${email.ai_suggested_booking_code} no longer in cache. Re-sync bookings or link manually.`, 400);
    }

    await pool.query(
      `UPDATE emails
          SET booking_id = ?, booking_code = ?, app_id = ?,
              auto_linked = 1, confidence = 1.00,
              ai_suggested_booking_code = NULL
        WHERE id = ?`,
      [bk.id, bk.code, bk.app_id, req.params.id]
    );

    // Mirror the link in email_booking_links for the audit timeline.
    await pool.query(
      `INSERT INTO email_booking_links (id, email_id, linked_type, linked_id, auto_linked, linked_by)
       VALUES (?, ?, 'booking', ?, 0, ?)`,
      [crypto.randomUUID(), req.params.id, bk.id, req.platformUser.id]
    );

    // Audit — distinct action so finance can filter "AI-confirmed links"
    // separately from "manually linked emails".
    await recordAuditEvent(req, {
      action: 'email_ai_suggestion_confirmed',
      entityType: 'email',
      entityId: req.params.id,
      before: { booking_code: email.booking_code || null, ai_confidence: email.ai_confidence },
      after: { booking_code: bk.code, app_id: bk.app_id },
      reason: email.ai_reason || null,
    });

    const [[row]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    for (const k of ['to_emails','cc_emails','parsed_data','raw_headers']) {
      if (row[k] && typeof row[k] === 'string') { try { row[k] = JSON.parse(row[k]); } catch {} }
    }
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// Dismiss an AI suggestion without applying it. Sets ai_suggested_booking_code
// to NULL so the banner disappears, leaving ai_classified/ai_confidence
// intact for analytics.
app.post('/rest/v1/emails/:id/dismiss-ai-suggestion', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    await pool.query(
      `UPDATE emails SET ai_suggested_booking_code = NULL WHERE id = ?`,
      [req.params.id]
    );
    ok(res, { dismissed: true });
  } catch (e) { err(res, e.message, 500); }
});

// AI quota status — for an admin dashboard. Today's usage + limit.
app.get('/api/ai/quota', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const today = new Date().toISOString().slice(0, 10);
    const [[row]] = await pool.query(
      `SELECT calls, input_tokens, output_tokens, cache_read_tokens, last_call_at
         FROM ai_classification_log WHERE day = ?`,
      [today]
    );
    const dailyBudget = parseInt(process.env.AI_DAILY_BUDGET || '500', 10);
    ok(res, {
      day: today,
      enabled: !!process.env.ANTHROPIC_API_KEY && dailyBudget > 0,
      daily_budget: dailyBudget,
      calls_today: row?.calls || 0,
      remaining: Math.max(0, dailyBudget - (row?.calls || 0)),
      input_tokens: row?.input_tokens || 0,
      output_tokens: row?.output_tokens || 0,
      cache_read_tokens: row?.cache_read_tokens || 0,
      last_call_at: row?.last_call_at || null,
    });
  } catch (e) { err(res, e.message, 500); }
});

// Link an email to a booking, support request, and/or supplier. Overwrites
// the current link. Also writes an audit row into email_booking_links.
app.post('/rest/v1/emails/:id/link', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const d = req.body || {};
    const [[email]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    if (!email) return err(res, 'Not found', 404);

    const updates = {};
    if ('booking_id' in d)        updates.booking_id = d.booking_id || null;
    if ('booking_code' in d)      updates.booking_code = d.booking_code || null;
    if ('support_request_id' in d) updates.support_request_id = d.support_request_id || null;
    if ('supplier_id' in d)        updates.supplier_id = d.supplier_id || null;
    if ('category' in d && ['customer','supplier','internal','unknown'].includes(d.category)) {
      updates.category = d.category;
    }
    // Manual link forces auto_linked=false so we don't overwrite it later.
    updates.auto_linked = 0;

    // When the operator links to a booking, also stamp app_id so the
    // multi-tenancy gate routes the email to the right tenant. (Without
    // this, App users in the booking's tenant would still not see the
    // email until the next reclassify pass.)
    if (updates.booking_id || updates.booking_code) {
      try {
        const [[bk]] = await pool.query(
          `SELECT app_id FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
          [updates.booking_id || '', updates.booking_code || '']
        );
        if (bk?.app_id) updates.app_id = bk.app_id;
      } catch { /* leave app_id alone */ }
    }

    if (Object.keys(updates).length === 0) return err(res, 'Nothing to update', 400);
    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE emails SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);

    // Audit rows — one per linked entity.
    const auditRows = [];
    if (updates.booking_id || updates.booking_code) {
      auditRows.push(['booking', updates.booking_id || updates.booking_code]);
    }
    if (updates.support_request_id) auditRows.push(['support_request', updates.support_request_id]);
    if (updates.supplier_id)        auditRows.push(['supplier', updates.supplier_id]);
    for (const [type, id] of auditRows) {
      if (!id) continue;
      await pool.query(
        `INSERT INTO email_booking_links (id, email_id, linked_type, linked_id, auto_linked, linked_by)
         VALUES (?, ?, ?, ?, 0, ?)`,
        [crypto.randomUUID(), req.params.id, type, id, user.id]
      );
    }

    const [[row]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    for (const k of ['to_emails','cc_emails','parsed_data','raw_headers']) {
      if (row[k] && typeof row[k] === 'string') { try { row[k] = JSON.parse(row[k]); } catch {} }
    }
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// Remove one or all of this email's links. Body: { type?: 'booking' | 'support_request' | 'supplier' }.
// Omitting type clears all three.
app.post('/rest/v1/emails/:id/unlink', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const type = (req.body || {}).type; // optional
    const updates = {};
    if (!type || type === 'booking')         { updates.booking_id = null; updates.booking_code = null; }
    if (!type || type === 'support_request') { updates.support_request_id = null; }
    if (!type || type === 'supplier')        { updates.supplier_id = null; }
    if (Object.keys(updates).length === 0) return err(res, 'Invalid type', 400);
    const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
    await pool.query(`UPDATE emails SET ${setClauses} WHERE id = ?`, [...Object.values(updates), req.params.id]);
    await pool.query(
      `UPDATE email_booking_links SET unlinked_at = NOW()
       WHERE email_id = ? AND unlinked_at IS NULL
         ${type ? 'AND linked_type = ?' : ''}`,
      type ? [req.params.id, type] : [req.params.id]
    );
    const [[row]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// Re-run the match engine on one email using the current supplier rules +
// extraction rules. Use this after adding/editing rules to retro-classify.
async function reclassifyOne(emailId) {
  const [[e]] = await pool.query('SELECT * FROM emails WHERE id = ?', [emailId]);
  if (!e) throw new Error('Not found');
  const headers = (() => { try { return typeof e.raw_headers === 'string' ? JSON.parse(e.raw_headers) : (e.raw_headers || {}); } catch { return {}; } })();
  const match = await runMatchEngine({
    from_email: e.from_email,
    subject: e.subject || '',
    body_text: e.body_text || '',
    headers,
  });

  // Decide category. If still unknown, try booking-contact and internal checks
  // the same way the ingestion path does.
  let category = 'unknown';
  if (match.supplier_id) category = 'supplier';
  else if (e.from_email) {
    const [[u]] = await pool.query('SELECT id FROM platform_users WHERE email = ? LIMIT 1', [e.from_email]);
    if (u) category = 'internal';
  }

  let bookingId = e.booking_id, bookingCode = e.booking_code;

  // Stage 1 — deterministic ref/sender match (same path as live ingest).
  // Re-running this on legacy emails is the fastest way to backfill links
  // for the existing inbox after the matcher rolled out.
  if (!bookingId) {
    try {
      const det = await findBookingByDeterministicMatch({
        from_email: e.from_email,
        subject: e.subject || '',
        body_text: e.body_text || '',
      }, pool);
      if (det && det.booking_id) {
        bookingId = det.booking_id;
        bookingCode = det.booking_code;
        if (category === 'unknown' && det.classification) category = det.classification;
      }
    } catch (_) { /* fall through */ }
  }

  // Stage 2 — legacy extraction-rule fallback.
  if (!bookingId) {
    const refs = [match.extracted?.booking_ref, match.extracted?.supplier_confirmation, match.extracted?.pnr].filter(Boolean);
    for (const r of refs) {
      try {
        const data = await easygdsRequest(`/bookings/list?per_page=2&search_text=${encodeURIComponent(r)}`);
        const hit = (data?.result || []).find(b => b.code === r) || (data?.result || [])[0];
        if (hit) { bookingId = hit.id; bookingCode = hit.code; break; }
      } catch (_) { /* continue */ }
    }
  }

  const updates = {
    category,
    supplier_id: match.supplier_id || null,
    parsed_data: JSON.stringify(match.extracted || {}),
    confidence: match.supplier_id ? 0.9 : bookingId ? 0.95 : 0.0,
  };
  if (bookingId && !e.booking_id) {
    updates.booking_id = bookingId;
    updates.booking_code = bookingCode;
    updates.auto_linked = 1;
    // Resolve app_id from cache so the multi-tenancy gate routes the row
    // to the right tenant.
    try {
      const [[bk]] = await pool.query(
        `SELECT app_id FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
        [bookingId, bookingCode]
      );
      if (bk?.app_id) updates.app_id = bk.app_id;
    } catch { /* leave alone */ }
  }
  const setClauses = Object.keys(updates).map(k => `${k} = ?`).join(', ');
  await pool.query(`UPDATE emails SET ${setClauses} WHERE id = ?`, [...Object.values(updates), emailId]);

  return { email_id: emailId, ...match, booking_id: bookingId };
}

app.post('/rest/v1/emails/:id/reclassify', authMiddleware, async (req, res) => {
  try {
    const r = await reclassifyOne(req.params.id);
    ok(res, r);
  } catch (e) { err(res, e.message, 500); }
});

// Bulk re-classify. Admin-only. Processes every email (or only unknowns via
// ?only=unknown). Useful after you add a new supplier rule.
app.post('/rest/v1/emails/reclassify-all', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const only = (req.query.only === 'unknown') ? 'WHERE category = "unknown"' : '';
    const [rows] = await pool.query(`SELECT id FROM emails ${only} ORDER BY received_at DESC LIMIT 2000`);
    let ok_ = 0, failed = 0;
    for (const r of rows) {
      try { await reclassifyOne(r.id); ok_++; } catch (_) { failed++; }
    }
    ok(res, { processed: rows.length, updated: ok_, failed });
  } catch (e) { err(res, e.message, 500); }
});

// Targeted retro-classify — only emails currently UNLINKED. After
// rolling out the deterministic matcher this is the right way to
// backfill links on legacy rows: every match is high-confidence (1.0)
// and the matcher is cheap (in-memory scan, no upstream calls).
//
// Returns counts so the admin can see how many rows got linked.
app.post('/rest/v1/emails/relink-unlinked', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const limit = Math.min(parseInt(req.query.limit) || 1000, 5000);
    const [rows] = await pool.query(
      `SELECT id FROM emails
        WHERE booking_id IS NULL
          AND support_request_id IS NULL
          AND is_archived = 0
        ORDER BY received_at DESC
        LIMIT ?`,
      [limit]
    );
    let scanned = 0, linked = 0, stillUnlinked = 0, failed = 0;
    for (const r of rows) {
      scanned++;
      try {
        const result = await reclassifyOne(r.id);
        if (result.booking_id) linked++; else stillUnlinked++;
      } catch (_) { failed++; }
    }
    ok(res, { scanned, linked, still_unlinked: stillUnlinked, failed });
  } catch (e) { err(res, e.message, 500); }
});

// Mark read / starred / archived.
app.patch('/rest/v1/emails/:id', authMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const allowed = ['is_read', 'is_starred', 'is_archived'];
    const sets = [], params = [];
    for (const k of allowed) if (k in d) { sets.push(`${k} = ?`); params.push(d[k] ? 1 : 0); }
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE emails SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM emails WHERE id = ?', [req.params.id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});


// ══════════════════════════════════════════════════════════════════════
// SMTP ACCOUNTS + OUTBOUND SEND  (admin-managed credentials; auth'd send)
// ══════════════════════════════════════════════════════════════════════

const smtpToApi = (r) => r && ({
  id: r.id,
  name: r.name,
  host: r.host,
  port: r.port,
  secure: Number(r.secure) === 1,
  username: r.username,
  password: r.password ? '********' : '',
  from_email: r.from_email,
  from_name: r.from_name || '',
  owner_type: r.owner_type || 'system',
  owner_id: r.owner_id || null,
  display_label: r.display_label || null,
  is_default_for_scope: Number(r.is_default_for_scope) === 1,
  is_default: Number(r.is_default) === 1,
  is_active: Number(r.is_active) === 1,
  last_used_at: r.last_used_at,
  last_error: r.last_error,
  created_at: r.created_at,
  updated_at: r.updated_at,
});

// Scope-filtered list. Admins see everything; non-admins get system + own
// + apps in their scope (per inboxOrSmtpScopeForUser).
app.get('/api/smtp-accounts', authMiddleware, async (req, res) => {
  try {
    const rows = await listSmtpAccountsForUser(req.platformUser, { activeOnly: false });
    ok(res, rows.map(smtpToApi));
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/auth/me/smtp-options — compact picker payload for the email
// composer. Just the fields we need to render the From dropdown.
app.get('/api/auth/me/smtp-options', authMiddleware, async (req, res) => {
  try {
    const rows = await listSmtpAccountsForUser(req.platformUser, { activeOnly: true });
    const ctx = req.platformUser?.login_context || 'system';
    // Group hint for the UI — own user accounts first, then apps, then system.
    ok(res, {
      preferred_smtp_account_id: req.platformUser?.preferred_smtp_account_id || null,
      login_context: ctx,
      options: rows.map(r => ({
        id: r.id,
        label: r.display_label || r.name,
        from_name: r.from_name,
        from_email: r.from_email,
        owner_type: r.owner_type,
        owner_id: r.owner_id,
        is_default_for_scope: Number(r.is_default_for_scope) === 1,
      })),
    });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// EMAIL TEMPLATES
// ════════════════════════════════════════════
//
// Simple admin-managed store of subject + body snippets with {{variable}}
// placeholders. Substitution happens on the client when the user picks a
// template in the Reply dialog — the server just stores the raw text.

const TEMPLATE_FIELDS = ['name', 'subject', 'body', 'template_type', 'reminder_request_type', 'ref_display', 'is_active'];
// Allowed values for the reminder_request_type column. Mirrors
// SUPPLIER_REMINDER_TYPES so a template can pre-select what kind of
// reminder gets created when the email is sent. NULL is allowed and
// means "no auto-default — operator picks at send time".
const ALLOWED_REMINDER_REQUEST_TYPES = new Set(['hcn', 'cancellation', 'change_request', 'refund', 'other']);
// What reference shows in the email shell header subtitle. Supplier
// templates default to 'supplier' (supplier ref), customer to 'booking'.
const ALLOWED_REF_DISPLAYS = new Set(['booking', 'supplier', 'both', 'none']);

function templateToApi(r) {
  if (!r) return r;
  return {
    id: r.id,
    name: r.name,
    subject: r.subject,
    body: r.body,
    template_type: r.template_type,
    reminder_request_type: r.reminder_request_type || null,
    ref_display: r.ref_display || null,
    is_active: Number(r.is_active) === 1,
    created_by: r.created_by,
    created_by_name: r.created_by_name,
    app_id: r.app_id,
    created_at: r.created_at,
    updated_at: r.updated_at,
  };
}

app.get('/api/email-templates', authMiddleware, async (req, res) => {
  try {
    const where = ['1=1'];
    const params = [];
    if (req.query.template_type) {
      where.push('template_type = ?');
      params.push(req.query.template_type);
    }
    if (req.query.active_only === 'true') {
      where.push('is_active = 1');
    }
    const [rows] = await pool.query(
      `SELECT * FROM email_templates WHERE ${where.join(' AND ')} ORDER BY template_type, name`,
      params
    );
    ok(res, rows.map(templateToApi));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/email-templates', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const d = req.body || {};
    if (!d.name || !d.subject || !d.body) {
      return err(res, 'name, subject, and body are required', 400);
    }
    const id = crypto.randomUUID();
    const createdByName =
      `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || null;
    // Validate reminder_request_type if provided; null means "no default".
    let reminderType = null;
    if (d.reminder_request_type) {
      const t = String(d.reminder_request_type).toLowerCase();
      if (!ALLOWED_REMINDER_REQUEST_TYPES.has(t)) {
        return err(res, `reminder_request_type must be one of: ${[...ALLOWED_REMINDER_REQUEST_TYPES].join(', ')}`, 400);
      }
      reminderType = t;
    }
    let refDisplay = null;
    if (d.ref_display) {
      const t = String(d.ref_display).toLowerCase();
      if (!ALLOWED_REF_DISPLAYS.has(t)) {
        return err(res, `ref_display must be one of: ${[...ALLOWED_REF_DISPLAYS].join(', ')}`, 400);
      }
      refDisplay = t;
    }
    await pool.query(
      `INSERT INTO email_templates
         (id, name, subject, body, template_type, reminder_request_type, ref_display, is_active, created_by, created_by_name, app_id)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, d.name, d.subject, d.body,
       d.template_type || 'customer',
       reminderType,
       refDisplay,
       d.is_active === false ? 0 : 1,
       user.id, createdByName, d.app_id || null]
    );
    const [[row]] = await pool.query('SELECT * FROM email_templates WHERE id = ?', [id]);
    ok(res, templateToApi(row));
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/email-templates/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    // Validate reminder_request_type early — null/'' is fine (clears).
    if ('reminder_request_type' in d && d.reminder_request_type) {
      const t = String(d.reminder_request_type).toLowerCase();
      if (!ALLOWED_REMINDER_REQUEST_TYPES.has(t)) {
        return err(res, `reminder_request_type must be one of: ${[...ALLOWED_REMINDER_REQUEST_TYPES].join(', ')}`, 400);
      }
      d.reminder_request_type = t;
    }
    if ('ref_display' in d && d.ref_display) {
      const t = String(d.ref_display).toLowerCase();
      if (!ALLOWED_REF_DISPLAYS.has(t)) {
        return err(res, `ref_display must be one of: ${[...ALLOWED_REF_DISPLAYS].join(', ')}`, 400);
      }
      d.ref_display = t;
    }
    const sets = [];
    const params = [];
    for (const k of TEMPLATE_FIELDS) {
      if (k in d) {
        sets.push(`${k} = ?`);
        // Treat empty string as NULL for the optional fields.
        if (k === 'is_active') params.push(d[k] ? 1 : 0);
        else if (k === 'reminder_request_type' || k === 'ref_display') params.push(d[k] === '' ? null : d[k]);
        else params.push(d[k]);
      }
    }
    if (sets.length === 0) return err(res, 'Nothing to update', 400);
    params.push(req.params.id);
    const [result] = await pool.query(
      `UPDATE email_templates SET ${sets.join(', ')} WHERE id = ?`,
      params
    );
    if (result.affectedRows === 0) return err(res, 'Not found', 404);
    const [[row]] = await pool.query('SELECT * FROM email_templates WHERE id = ?', [req.params.id]);
    ok(res, templateToApi(row));
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/email-templates/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Soft-delete: flip is_active to 0 so historical references keep rendering.
    const [result] = await pool.query(
      'UPDATE email_templates SET is_active = 0 WHERE id = ?',
      [req.params.id]
    );
    if (result.affectedRows === 0) return err(res, 'Not found', 404);
    ok(res, { id: req.params.id });
  } catch (e) { err(res, e.message, 500); }
});

// ── Email template renderer ──────────────────────────────────────────────
//
// POST /api/email-templates/:id/render
//   body: { booking_id?, booking_code?, supplier_id?, product_indexes?: [int],
//           include_voucher_link?: bool }
//
// Pure render. Resolves the booking, the chosen supplier, the chosen
// product subset, fills the template's `{{placeholder}}` vocabulary, and
// returns rendered subject + HTML/text bodies + suggested To/CC.
// The actual send happens via /rest/v1/emails/send (already exists).
//
// Placeholder vocabulary:
//   Booking:  {{booking_ref}} {{booking_status}} {{travel_date}}
//             {{currency}} {{customer_first_name}} {{customer_last_name}}
//             {{customer_email}} {{customer_phone}} {{passenger_name}}
//             {{passenger_count}} {{ops_user_name}} {{ops_user_signature}}
//             {{app_name}}
//   Supplier: {{supplier_name}} {{supplier_code}} {{supplier_ref}}
//   Hotel:    {{hcn}} {{confirmation_number}} {{provider_booking_ref}}
//             {{room_name}} {{board_basis}}
//             {{check_in_date}} {{check_out_date}}
//             — pulled from products[].data.selected_rooms[]; first
//             non-empty match across the picked products.
//   Flight:   {{pnr}}
//   Services: {{services_table}}  ← auto-rendered HTML block. Lists the
//             chosen products with type, ref, dates, and amounts.
//   Optional: {{notes}}           ← pass-through for free-form add-ons
//             (the dialog's "Additional notes" textarea)
//
// Unknown placeholders are LEFT in place so the operator notices and can
// edit them before sending.
async function renderEmailTemplate({ template, booking, supplier, products, opsUserName, opsUserSignature, notes, appName }) {
  // Build a flat key→value scope. Anything not here stays as-is (visible
  // double-curly so the operator notices and can patch).
  const contact = booking?.contact || {};

  // Walk products → look for `getter(p, data, room, rate)` returning a truthy
  // value. Tries product-level first, then per-room and per-room.rate.
  // Returns the first non-empty match across all selected products. This
  // is the central routine for per-room hotel fields (hcn / confirmation
  // number / bk_ref) which live at `products[i].data.selected_rooms[j].*`,
  // not the product top level. See trip-glide-control/CLAUDE.md "Per-room
  // vs per-voucher" section.
  const pickFromProducts = (getter) => {
    for (const raw of (products || [])) {
      const p = raw || {};
      const data = p.data || p;
      const v0 = getter(p, data, null, null);
      if (v0) return String(v0);
      const rooms = Array.isArray(data.selected_rooms) ? data.selected_rooms : [];
      for (const room of rooms) {
        const rate = (room && room.rate) || {};
        const v = getter(p, data, room || {}, rate);
        if (v) return String(v);
      }
    }
    return '';
  };

  // Supplier reference — the booking id the SUPPLIER issues, i.e. what they
  // want quoted on customer enquiries (e.g. Hotelbeds "61J3ReR5V", Viator
  // "102-20378672"). NOT the upstream EasyGDS-internal `data.booking_ref`
  // (e.g. "gq-qrfopqdgiyxp5bg"), which is a wholesaler id meaningless to the
  // supplier — operators were emailing suppliers their own internal id by
  // mistake. Priority:
  //   1. per-room hotel ref (`bk_ref` / `booking_ref` / `rate.booking_reference`)
  //   2. tour `provider_reference`
  //   3. explicit `supplier_reference` if the product carries one
  //   4. confirmation number / PNR / generic reference
  // `data.booking_ref` is intentionally omitted — see above.
  const productSupplierRef = pickFromProducts((p, data, room, rate) =>
    (room == null
      ? (p.supplier_reference
        || data.supplier_reference
        || data.provider_reference
        || p.confirmation_number
        || data.confirmation_number
        || data.pnr
        || data.segments?.[0]?.pnr
        || data.flight_segments?.[0]?.pnr
        || data.reference)
      : (room.bk_ref
        || room.booking_ref
        || rate.booking_reference
        || rate.bk_ref))
  );

  // Hotel Confirmation Number — strictly per-room. Most suppliers also
  // call this the "confirmation number"; we expose both placeholders.
  const hcn = pickFromProducts((p, data, room, rate) =>
    room == null ? '' : (room.hcn || room.confirmation_number || rate.hcn)
  );

  // Generic confirmation number — for hotels this is the per-room field;
  // for flights/tours/etc. it's product-level.
  const confirmationNumber = pickFromProducts((p, data, room, rate) =>
    room == null
      ? (data.confirmation_number || p.confirmation_number)
      : (room.confirmation_number || room.hcn || room.bk_ref || rate.booking_reference)
  );

  // PNR — flights only. Walk product or its segments.
  const pnr = pickFromProducts((p, data) =>
    data.pnr || data.segments?.[0]?.pnr || data.flight_segments?.[0]?.pnr || ''
  );

  // Provider (inventory partner) booking reference — useful for upstream
  // support tickets. Per-room only.
  const providerBookingRef = pickFromProducts((p, data, room) =>
    room == null ? '' : (room.pvd_bk_ref || '')
  );

  // First-room display fields. We pick from the first selected room of the
  // first hotel product because templates target a single supplier per
  // send (operators pick one supplier in the dialog); the {{services_table}}
  // already lists each product/room individually for multi-room cases.
  const firstRoomName = pickFromProducts((p, data, room) =>
    room == null ? '' : (room.name || room.room_name || room.room_type)
  );
  const firstBoardBasis = pickFromProducts((p, data, room) =>
    room == null ? '' : (room.board_basis || room.meal_plan || '')
  );
  const checkInDate = pickFromProducts((p, data, room) =>
    room == null
      ? (data.check_in_date || data.checkin_date || data.check_in || '')
      : (room.check_in_date || room.checkin_date || room.check_in || '')
  );
  const checkOutDate = pickFromProducts((p, data, room) =>
    room == null
      ? (data.check_out_date || data.checkout_date || data.check_out || '')
      : (room.check_out_date || room.checkout_date || room.check_out || '')
  );

  // Lead passenger — "Mr John Doe" style, used in {{passenger_name}}.
  const passengerName = [contact.title, contact.first_name, contact.last_name]
    .filter(Boolean).map(s => String(s).trim()).join(' ');

  // Passenger count — `bookings_cache.passenger_count` is often NULL on
  // Supabase-imported rows, which left `{{passenger_count}}` rendering as
  // a literal placeholder in supplier emails. Compute from the products:
  // hotels carry per-room `paxes` (sum across rooms), tours/transfers
  // expose a flat `pax_count` at the data level.
  const computedPaxCount = (() => {
    let total = 0;
    for (const raw of (products || [])) {
      const data = (raw || {}).data || raw || {};
      const rooms = Array.isArray(data.selected_rooms) ? data.selected_rooms : [];
      if (rooms.length > 0) {
        for (const room of rooms) {
          const paxes = Array.isArray(room.paxes) ? room.paxes : (room.paxes ? [room.paxes] : []);
          total += paxes.length;
        }
        continue;
      }
      if (typeof data.pax_count === 'number') total += data.pax_count;
      else if (Array.isArray(data.paxes)) total += data.paxes.length;
    }
    return total;
  })();
  const paxCountValue = (booking?.passenger_count != null && booking.passenger_count !== '')
    ? booking.passenger_count
    : (computedPaxCount > 0 ? computedPaxCount : '');

  // Default signature if the user hasn't set one — keeps legacy templates
  // rendering as before (name on its own line). Operators with a custom
  // signature in their profile get that block instead.
  const defaultSig = opsUserName ? `${escapeHtml(opsUserName)}` : '';
  const signature = opsUserSignature && String(opsUserSignature).trim()
    ? String(opsUserSignature)
    : defaultSig;

  const scope = {
    booking_ref:           booking?.code || '',
    booking_status:        booking?.status || '',
    travel_date:           booking?.travel_date ? String(booking.travel_date).slice(0, 10) : '',
    currency:              booking?.currency || '',
    customer_first_name:   contact.first_name || '',
    customer_last_name:    contact.last_name || '',
    customer_email:        contact.email || '',
    customer_phone:        contact.phone || '',
    // Convenience composite — "{{customer_first_name}} {{customer_last_name}}"
    // is the common case in templates, but {{passenger_name}} is shorter and
    // includes the title prefix when present ("Mr John Doe").
    passenger_name:        passengerName,
    passenger_count:       paxCountValue === '' ? '' : String(paxCountValue),
    ops_user_name:         opsUserName || '',
    ops_user_signature:    signature,
    app_name:              appName || '',
    supplier_name:         supplier?.name || supplier?.code || '',
    supplier_code:         supplier?.code || '',
    // For template substitution, fall back to the internal booking code when
    // the supplier hasn't issued a reference yet (e.g. brand-new request).
    // The shell header (shell_meta.supplier_ref) intentionally does NOT use
    // this fallback — it should stay empty so the header doesn't claim a
    // "supplier ref" that's really our internal id.
    supplier_ref:          productSupplierRef || booking?.code || '',
    // Hotel per-room identifiers. {{hcn}} and {{confirmation_number}} are
    // the most-requested fields and now resolve from selected_rooms[] —
    // previously the merge couldn't see per-room data at all, so these
    // placeholders rendered as literal {{hcn}} in sent emails.
    hcn:                   hcn,
    confirmation_number:   confirmationNumber,
    provider_booking_ref:  providerBookingRef,
    // Flight identifier.
    pnr:                   pnr,
    // First-room display attributes (hotel emails almost always need at
    // least one of these). For multi-room bookings the full breakdown is
    // already in {{services_table}}.
    room_name:             firstRoomName,
    board_basis:           firstBoardBasis,
    check_in_date:         checkInDate ? String(checkInDate).slice(0, 10) : '',
    check_out_date:        checkOutDate ? String(checkOutDate).slice(0, 10) : '',
    services_table:        renderServicesTable(products || [], booking?.currency),
    notes:                 notes ? `<p>${escapeHtml(notes).replace(/\n/g, '<br/>')}</p>` : '',
  };

  const fill = (s) => String(s || '').replace(/\{\{\s*([a-zA-Z_]+)\s*\}\}/g, (m, key) =>
    scope[key] != null && scope[key] !== '' ? scope[key] : m
  );

  // Find any placeholders that didn't resolve so the dialog can warn.
  const subjectFilled = fill(template.subject);
  const bodyFilled = fill(template.body || '');
  const missing = [];
  const re = /\{\{\s*([a-zA-Z_]+)\s*\}\}/g;
  let m;
  while ((m = re.exec(subjectFilled + ' ' + bodyFilled)) !== null) {
    if (!missing.includes(m[1])) missing.push(m[1]);
  }
  return { subject: subjectFilled, body_html: bodyFilled, missing_placeholders: missing };
}

function escapeHtml(s) {
  return String(s == null ? '' : s)
    .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

// Render the chosen subset of products as a structured HTML block.
//
// We use <ul><li>...</li></ul> rather than a <table> because the rich-text
// editor on the frontend (ReactQuill) doesn't understand tables and will
// silently strip them on round-trip — losing the most important content
// of a supplier email. Lists round-trip cleanly through Quill, and they
// still render well in every modern mail client (Outlook, Gmail, Apple).
//
// Each list item packs: type chip, supplier reference (mono), human detail,
// amount. Inline styles only — Outlook drops <style> blocks.
// Render a per-product line. Each item is shaped to be human-readable in
// the customer's inbox — friendly category label ("Flight ticket" not
// "SERVICE"), one identifier per line (PNR for flights, booking ref for
// hotels — never both when they're the same value), and a short detail
// block (route / hotel name + room / pickup → dropoff).
//
// Why NOT "Ref X — PNR X"? Because the operator pointed out — correctly —
// that for a flight the PNR *is* the reference. Showing "Ref D2VLAS — PNR
// D2VLAS" on the same line is just noise. We pick whichever identifier
// actually matters for that product type, and only show a second one if
// it's genuinely different (e.g. some hotels have both a supplier ref and
// a confirmation number).
function renderServicesTable(products, fallbackCurrency) {
  if (!products || products.length === 0) return '<p><em>No services selected.</em></p>';

  // Detect what kind of product this is regardless of how the upstream
  // labels it. Some bookings come back with product_type="service" even
  // though they're flights — we want to display "Flight ticket" anyway.
  const detectKind = (p, data) => {
    const raw = String(p.product_type || data.product_type || data.type || '').toLowerCase();
    if (raw === 'hotel' || data.hotel || data.hotels) return 'hotel';
    if (raw === 'flight' || raw === 'flight_ticket' || data.segments || data.flight_segments || data.pnr) return 'flight';
    if (raw === 'transfer' || data.transfer || data.transfers) return 'transfer';
    if (raw === 'tour' || raw === 'activity' || data.tour || data.activity) return 'tour';
    if (raw === 'visa') return 'visa';
    if (raw === 'insurance') return 'insurance';
    if (raw === 'package' || data.package) return 'package';
    return 'service';
  };

  const KIND_LABELS = {
    hotel:     'Hotel',
    flight:    'Flight ticket',
    transfer:  'Transfer',
    tour:      'Tour',
    visa:      'Visa',
    insurance: 'Insurance',
    package:   'Package',
    service:   'Service',
  };

  const items = products.map((p) => {
    const data = p.data || p;
    const kind = detectKind(p, data);

    // Pick identifiers per kind. Never duplicate if two fields hold the
    // same value (operator complaint: "Ref D2VLAS — PNR D2VLAS" was the
    // same code shown twice).
    //
    // Hotels carry their key identifiers PER ROOM (hcn / confirmation_number
    // / bk_ref in selected_rooms[]) — the product top level is often empty.
    // Fall back through the per-room fields so a booking with HCN-only set
    // (the common Hotelbeds case) still renders an identifier.
    const firstRoom = (data.selected_rooms && data.selected_rooms[0]) || {};
    const firstRoomRate = firstRoom.rate || {};
    // Supplier reference for the services-table line. Match the priority
    // used by {{supplier_ref}} above — exclude `data.booking_ref` (upstream
    // wholesaler id, not what the supplier needs) and include the tour
    // `provider_reference` so non-hotel products get a meaningful ref.
    const supplierRef = p.supplier_reference
      || data.supplier_reference
      || data.provider_reference
      || firstRoom.bk_ref
      || firstRoomRate.booking_reference
      || null;
    const confirmation = p.confirmation_number
      || data.confirmation_number
      || firstRoom.confirmation_number
      || firstRoom.hcn
      || null;
    const pnr = data.pnr || (data.segments?.[0]?.pnr) || (data.flight_segments?.[0]?.pnr) || null;
    const generic = data.reference || null;

    const identifiers = [];
    const seen = new Set();
    const pushId = (label, value) => {
      if (!value) return;
      const key = String(value).trim().toUpperCase();
      if (!key || seen.has(key)) return;
      seen.add(key);
      identifiers.push({ label, value });
    };

    if (kind === 'flight') {
      // PNR is the canonical flight identifier — show it FIRST. Only show
      // the supplier ref if it's a *different* code.
      pushId('PNR', pnr);
      pushId('Booking ref', supplierRef || confirmation);
    } else if (kind === 'hotel') {
      // Hotel: prefer supplier ref (the supplier's own booking code that
      // the customer should quote at check-in). Fall back to confirmation
      // number if no supplier ref.
      pushId('Booking ref', supplierRef || confirmation);
      // If both exist AND they're different, show the second too.
      if (supplierRef && confirmation && supplierRef !== confirmation) {
        pushId('Confirmation #', confirmation);
      }
    } else {
      // Transfer/tour/etc — just one identifier, whatever's available.
      pushId('Ref', supplierRef || confirmation || generic);
    }

    // Build the detail block — short narrative description of the service.
    let detail = '';
    if (kind === 'hotel') {
      const h = data.hotel || data.hotels?.[0] || {};
      const room = data.selected_rooms?.[0]?.name || data.selected_rooms?.[0]?.rate?.room_type || '';
      const ci = data.check_in || h.check_in || data.checkin || '';
      const co = data.check_out || h.check_out || data.checkout || '';
      const nm = h.name || data.hotel_name || data.name || '';
      detail = [nm, room, ci && co ? `${String(ci).slice(0,10)} → ${String(co).slice(0,10)}` : ''].filter(Boolean).join(' · ');
    } else if (kind === 'flight') {
      const segs = data.segments || data.flight_segments || [];
      const summary = segs.slice(0, 3)
        .map(s => {
          const from = s.from || s.origin || s.departure_airport || '';
          const to   = s.to   || s.destination || s.arrival_airport || '';
          // Drop empty segments — otherwise we emit a meaningless "→".
          return (from || to) ? `${from}→${to}` : '';
        })
        .filter(Boolean)
        .join(', ');
      // Airline name + route. If no segments and no airline, leave detail
      // blank — the PNR alone is enough on its own line.
      detail = [data.airline_name || data.carrier || '', summary].filter(Boolean).join(' · ');
    } else if (kind === 'transfer') {
      const x = data.transfer || data.transfers?.[0] || {};
      detail = [x.from || x.pickup || '', x.to || x.dropoff || '', x.pickup_time || ''].filter(Boolean).join(' · ');
    } else {
      detail = data.name || data.product_name || data.description || '';
    }

    // Money line — always show currency + amount when available.
    const amt =
      p.supplier_total_amount || p.total_amount || p.amount ||
      data.supplier_total_amount || data.total_amount || data.amount;
    const ccy = p.currency || data.currency || fallbackCurrency || '';
    const amountStr = amt != null ? `${ccy} ${Number(amt).toFixed(2)}` : '';

    // Compose the line. "Hotel — Booking ref ABC — Marriott · Deluxe King · 2026-06-22 → 2026-06-23 — USD 155.00"
    const parts = [];
    parts.push(`<strong>${escapeHtml(KIND_LABELS[kind] || 'Service')}</strong>`);
    for (const id of identifiers) {
      parts.push(`${escapeHtml(id.label)} <code>${escapeHtml(id.value)}</code>`);
    }
    if (detail) parts.push(escapeHtml(detail));
    if (amountStr) parts.push(`<strong>${escapeHtml(amountStr)}</strong>`);
    return `<li>${parts.join(' — ')}</li>`;
  }).join('');
  return `<ul>${items}</ul>`;
}

// Strip HTML to a clean text alternative for the body_text MIME part.
// Renders headings as their own lines with surrounding blank lines, tables
// as `Header: value` rows (one per cell), lists with `• ` bullets and
// nested-list indentation, blockquotes prefixed `> `, and horizontal rules
// as `------`. Used for the text/plain MIME part — recipients with text-only
// clients (or screen readers) get something readable instead of stripped
// markup soup.
function htmlToTextSimple(html) {
  if (!html) return '';
  let s = String(html);

  // 1. Drop everything that has no text equivalent.
  s = s.replace(/<style[\s\S]*?<\/style>/gi, '');
  s = s.replace(/<script[\s\S]*?<\/script>/gi, '');
  s = s.replace(/<head[\s\S]*?<\/head>/gi, '');
  s = s.replace(/<!--[\s\S]*?-->/g, '');

  // 2. Block elements → newlines / framing.
  s = s.replace(/<br\s*\/?>/gi, '\n');
  s = s.replace(/<hr\s*\/?>/gi, '\n------\n');
  s = s.replace(/<\/(p|div)>/gi, '\n\n');
  s = s.replace(/<(p|div)[^>]*>/gi, '');
  s = s.replace(/<\/h[1-6]>/gi, '\n');
  s = s.replace(/<h[1-6][^>]*>/gi, '\n');
  s = s.replace(/<\/blockquote>/gi, '\n');
  s = s.replace(/<blockquote[^>]*>/gi, '\n> ');

  // 3. Lists — keep them readable. We don't parse depth, but indenting
  // nested <li> by 2 spaces per surrounding <ul>/<ol> covers common cases.
  s = s.replace(/<\/(ul|ol)>/gi, '\n');
  s = s.replace(/<(ul|ol)[^>]*>/gi, '\n');
  s = s.replace(/<li[^>]*>/gi, '• ');
  s = s.replace(/<\/li>/gi, '\n');

  // 4. Tables — emit as `Cell | Cell | Cell` per row. Header cells get
  // a trailing `:` so they read more naturally. We're intentionally simple
  // here — colspan/rowspan aren't preserved.
  s = s.replace(/<\/table>/gi, '\n');
  s = s.replace(/<table[^>]*>/gi, '\n');
  s = s.replace(/<\/tr>/gi, '\n');
  s = s.replace(/<tr[^>]*>/gi, '');
  s = s.replace(/<th[^>]*>/gi, '');
  s = s.replace(/<\/th>/gi, ': ');
  s = s.replace(/<td[^>]*>/gi, '');
  s = s.replace(/<\/td>/gi, ' | ');

  // 5. Strip remaining tags + decode entities.
  s = s.replace(/<[^>]+>/g, '');
  s = s.replace(/&nbsp;/g, ' ');
  s = s.replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
  s = s.replace(/&quot;/g, '"').replace(/&#39;/g, "'");
  s = s.replace(/&[a-z]+;/gi, '');  // any leftover named entity → drop

  // 6. Whitespace cleanup — collapse runs of blank lines, trim trailing
  // " | " from the last cell of each table row, strip lonely bullets.
  s = s.replace(/[ \t]+\n/g, '\n');
  s = s.replace(/ \| \n/g, '\n');
  s = s.replace(/\n{3,}/g, '\n\n');
  s = s.replace(/^•\s*$/gm, '');

  return s.trim();
}

// Wrap any HTML body in a polished, mobile-friendly email shell. Called
// at SEND time so the operator's edits in Quill stay simple — no header
// banners or footer markup to wrestle with in the editor — but the
// recipient sees a properly branded, scannable email.
//
// Why a table-based shell? Outlook desktop strips most modern CSS; only
// nested <table> with inline styles renders consistently across Outlook,
// Gmail web, Apple Mail, and Gmail mobile. This is the standard pattern
// every transactional email service (Postmark, SendGrid templates, etc.)
// uses. Stays under 8KB so Gmail's clipping threshold isn't an issue.
//
// `meta`: optional decorations to surface in the shell.
//   appName        → header pill (e.g. "MHholidays", "Tripovo")
//   bookingRef     → small subhead under the brand
//   accentColor    → brand color for the header bar (defaults to orange #FF6B35)
//   footerNote     → short text in the muted footer (defaults to a generic line)
function wrapInEmailShell(innerHtml, meta = {}) {
  // Validate accent — must be a hex colour to be safely interpolated into
  // CSS / HTML attributes. Anything else falls back to the brand orange.
  const accentRaw = meta.accentColor && /^#?[0-9a-fA-F]{3,8}$/.test(String(meta.accentColor).trim())
    ? String(meta.accentColor).trim()
    : '#FF6B35';
  const accent = accentRaw.startsWith('#') ? accentRaw : `#${accentRaw}`;
  const appName    = meta.appName || 'Travel Operations';
  const bookingRef = meta.bookingRef ? String(meta.bookingRef) : '';
  const supplierRef = meta.supplierRef ? String(meta.supplierRef) : '';
  // What to show in the header subtitle. 'booking' (default), 'supplier',
  // 'both' (supplier ref FIRST then booking — operator-friendly when the
  // supplier needs to look up their own record before ours), 'none'.
  const refDisplay = ['booking', 'supplier', 'both', 'none'].includes(meta.refDisplay)
    ? meta.refDisplay
    : 'booking';
  // Optional logo URL from app_settings — rendered as an <img> in place of
  // the text-only brand pill. Only http(s) URLs accepted (no data: or
  // protocol-relative — too easy to abuse).
  const logoUrl = (meta.logoUrl && /^https?:\/\//i.test(String(meta.logoUrl)))
    ? String(meta.logoUrl)
    : '';
  // Build the subtitle line(s). When 'both' we put supplier ref FIRST so a
  // supplier reading the email sees their own reference at the top of the
  // header. Falls back gracefully if one of the refs is missing.
  let subtitleHtml = '';
  if (refDisplay !== 'none') {
    const lines = [];
    if (refDisplay === 'supplier' || refDisplay === 'both') {
      if (supplierRef) lines.push(`Supplier ref ${escapeHtml(supplierRef)}`);
    }
    if (refDisplay === 'booking' || refDisplay === 'both') {
      if (bookingRef) lines.push(`Booking ${escapeHtml(bookingRef)}`);
    }
    if (lines.length > 0) {
      subtitleHtml = `<div style="font-size:13px;color:#ffffff;opacity:0.92;margin-top:${logoUrl ? '8px' : '4px'};font-family:'SFMono-Regular',Consolas,'Liberation Mono',Menlo,monospace;line-height:1.4;mso-line-height-rule:exactly;">${lines.join('<br/>')}</div>`;
    }
  }
  const preheader  = meta.preheader ? String(meta.preheader) :
    (supplierRef && (refDisplay === 'supplier' || refDisplay === 'both')
      ? `Supplier ref ${supplierRef}`
      : bookingRef ? `Booking ${bookingRef}` : '');
  const footerNote = meta.footerNote ||
    'This email was sent automatically from our operations console. Just hit reply if you need anything.';

  // Idempotency guard: if the body has already been wrapped (e.g. in a
  // prior render call that called this helper), don't double-wrap.
  if (typeof innerHtml === 'string' && /<!--\s*tripovo-shell-v\d+\s*-->/.test(innerHtml)) {
    return innerHtml;
  }

  // Inline-style every body element so the recipient's client can't override
  // our intent. (Editor-produced markup is bare — <p>, <h1>, <ul> with no
  // inline styles. We add them here at send time.)
  const inlineHtml = inlineEmailSafeStyles(innerHtml || '');

  // Preheader — hidden in the body but Gmail/Apple Mail show it as inbox
  // preview. Helps recipient know what's inside before opening.
  const preheaderHtml = preheader
    ? `<div style="display:none;font-size:1px;color:#f3f4f6;line-height:1px;max-height:0;max-width:0;opacity:0;overflow:hidden;mso-hide:all;">${escapeHtml(preheader)}</div>`
    : '';

  return `<!-- tripovo-shell-v2 -->
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office" lang="en">
<head>
  <meta charset="utf-8" />
  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
  <meta http-equiv="X-UA-Compatible" content="IE=edge" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no" />
  <meta name="x-apple-disable-message-reformatting" />
  <meta name="color-scheme" content="light only" />
  <meta name="supported-color-schemes" content="light only" />
  <title>${escapeHtml(preheader || appName)}</title>
  <!--[if mso]>
  <xml>
    <o:OfficeDocumentSettings>
      <o:AllowPNG/>
      <o:PixelsPerInch>96</o:PixelsPerInch>
    </o:OfficeDocumentSettings>
  </xml>
  <style type="text/css">
    body, table, td { font-family: 'Segoe UI', Arial, sans-serif !important; }
    table { border-collapse: collapse !important; }
  </style>
  <![endif]-->
  <style type="text/css">
    /* CLIENT FIX-UPS — these are the rules that *most* clients honour
       (Gmail web, Apple Mail, Yahoo, Outlook.com). Older Outlook desktop
       ignores <style> entirely; that's why we ALSO inline-style every
       element below. */
    html, body { margin:0 !important; padding:0 !important; height:100% !important; width:100% !important; }
    * { -ms-text-size-adjust:100%; -webkit-text-size-adjust:100%; }
    img { -ms-interpolation-mode:bicubic; border:0; outline:none; text-decoration:none; height:auto; line-height:100%; }
    table, td { mso-table-lspace:0pt !important; mso-table-rspace:0pt !important; }
    table { border-spacing:0 !important; border-collapse:collapse !important; }
    .ExternalClass, .ExternalClass p, .ExternalClass span,
    .ExternalClass font, .ExternalClass td, .ExternalClass div { line-height:100%; }
    a { text-decoration:none; }
    a[x-apple-data-detectors],
    .unstyle-auto-detected-links a,
    .aBn { border-bottom:0 !important; cursor:default !important; color:inherit !important; text-decoration:none !important; font-size:inherit !important; font-family:inherit !important; font-weight:inherit !important; line-height:inherit !important; }
    /* Mobile */
    @media only screen and (max-width:620px) {
      .email-card { width:100% !important; max-width:100% !important; border-radius:0 !important; }
      .email-pad { padding:20px !important; }
      .email-h-pad { padding:18px 20px !important; }
      .email-brand { font-size:18px !important; }
    }
    /* Light mode lock — disables Gmail/Apple Mail dark-mode color inversion
       which was rewriting our orange header to a desaturated red on iOS. */
    [data-ogsc] body, [data-ogsb] body { background:#f3f4f6 !important; color:#111827 !important; }
  </style>
</head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#111827;-webkit-font-smoothing:antialiased;">
${preheaderHtml}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" bgcolor="#f3f4f6" style="background-color:#f3f4f6;padding:24px 12px;">
  <tr><td align="center" valign="top">
    <table role="presentation" cellpadding="0" cellspacing="0" border="0" width="600" class="email-card" style="max-width:600px;width:100%;background:#ffffff;border-radius:12px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.05);">
      <!-- HEADER -->
      <tr><td class="email-h-pad" bgcolor="${accent}" style="background-color:${accent};padding:22px 28px;color:#ffffff;">
        ${logoUrl
          ? `<img src="${escapeHtml(logoUrl)}" alt="${escapeHtml(appName)}" height="36" style="display:block;max-height:36px;width:auto;border:0;outline:none;text-decoration:none;line-height:100%;" />`
          : `<div class="email-brand" style="font-size:20px;font-weight:700;letter-spacing:0.2px;color:#ffffff;line-height:1.2;mso-line-height-rule:exactly;">${escapeHtml(appName)}</div>`
        }
        ${subtitleHtml}
      </td></tr>
      <!-- BODY -->
      <tr><td class="email-pad" style="padding:28px;font-size:15px;line-height:1.6;color:#111827;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
${inlineHtml}
      </td></tr>
      <!-- FOOTER -->
      <tr><td style="padding:18px 28px 24px 28px;border-top:1px solid #e5e7eb;background:#f9fafb;color:#6b7280;font-size:12px;line-height:1.5;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
        ${escapeHtml(footerNote)}
      </td></tr>
    </table>
    <div style="font-size:11px;color:#9ca3af;margin-top:12px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">© ${new Date().getFullYear()} ${escapeHtml(appName)}</div>
  </td></tr>
</table>
</body></html>`;
}

// Inject email-safe inline styles into the operator-edited HTML so that
// every <p>/<h1-3>/<ul>/<ol>/<li>/<a>/<table> renders consistently across
// clients — even those that strip <style> tags (Outlook 2013/2016 desktop,
// some Gmail iOS scenarios). We don't run a full HTML parser; we use
// targeted regex on opening tags and append `style=` only when the editor
// hasn't already set one. This is intentionally conservative.
function inlineEmailSafeStyles(html) {
  if (!html || typeof html !== 'string') return '';
  // Helper: add `style="..."` to an opening tag if it doesn't already have one.
  // Otherwise leave the existing style intact (the user / TipTap put it there
  // for a reason and we don't want to clobber color/highlight choices).
  const ensureStyle = (tagName, defaultStyle) => {
    const re = new RegExp(`<${tagName}\\b([^>]*)>`, 'gi');
    return (h) => h.replace(re, (m, attrs) => {
      if (/\bstyle\s*=/.test(attrs)) {
        // Tag already has a style — only inject what's missing. We do this
        // by prepending defaults so an explicit user style still wins (later
        // in the cascade).
        return m.replace(/style\s*=\s*"([^"]*)"/i, (_full, existing) => {
          return `style="${defaultStyle};${existing}"`;
        });
      }
      return `<${tagName}${attrs} style="${defaultStyle}">`;
    });
  };

  let out = html;

  // Block-level
  out = ensureStyle('p',          'margin:0 0 12px 0;font-size:15px;line-height:1.6;color:#111827;')(out);
  out = ensureStyle('h1',         'margin:24px 0 12px 0;font-size:24px;line-height:1.3;font-weight:700;color:#111827;')(out);
  out = ensureStyle('h2',         'margin:20px 0 10px 0;font-size:20px;line-height:1.3;font-weight:700;color:#111827;')(out);
  out = ensureStyle('h3',         'margin:16px 0 8px 0;font-size:17px;line-height:1.35;font-weight:600;color:#111827;')(out);
  out = ensureStyle('blockquote', 'border-left:4px solid #e5e7eb;padding:8px 16px;margin:12px 0;color:#4b5563;background:#f9fafb;font-style:normal;')(out);
  out = ensureStyle('hr',         'border:0;border-top:1px solid #e5e7eb;margin:18px 0;height:1px;')(out);

  // Lists — modest left padding survives Outlook
  out = ensureStyle('ul',         'margin:8px 0 12px 0;padding-left:24px;font-size:15px;line-height:1.6;color:#111827;')(out);
  out = ensureStyle('ol',         'margin:8px 0 12px 0;padding-left:24px;font-size:15px;line-height:1.6;color:#111827;')(out);
  out = ensureStyle('li',         'margin:0 0 4px 0;')(out);

  // Inline
  out = ensureStyle('a',          'color:#2563eb;text-decoration:underline;')(out);
  out = ensureStyle('strong',     'font-weight:700;color:#111827;')(out);
  out = ensureStyle('b',          'font-weight:700;color:#111827;')(out);
  out = ensureStyle('em',         'font-style:italic;')(out);
  out = ensureStyle('code',       'background:#f3f4f6;border-radius:3px;padding:1px 4px;font-family:SFMono-Regular,Consolas,Menlo,monospace;font-size:0.92em;')(out);

  // Tables (operator-inserted via TipTap)
  out = ensureStyle('table',      'border-collapse:collapse;width:100%;margin:12px 0;')(out);
  out = ensureStyle('th',         'background:#f3f4f6;border:1px solid #e5e7eb;padding:8px 12px;text-align:left;font-weight:600;font-size:14px;')(out);
  out = ensureStyle('td',         'border:1px solid #e5e7eb;padding:8px 12px;vertical-align:top;font-size:14px;line-height:1.5;')(out);

  // Images — make sure they shrink on small screens
  out = ensureStyle('img',        'max-width:100%;height:auto;display:inline-block;border:0;')(out);

  return out;
}

// Resolve default to/cc recipients for a (supplier, template) pair. Logic:
//   - To: every contact marked is_primary (so operators can flag multiple
//         "main" addresses — operations@ + reservations@, etc.). When no
//         contact is flagged, the first one becomes the To.
//   - Cc: every OTHER email contact, so a contact added in
//         Settings → Suppliers always appears somewhere in the dialog
//         (previous behaviour silently dropped non-primary contacts whose
//         label didn't match a hardcoded specialty regex — the most
//         common cause of "I added a contact and it doesn't show up").
//   - Specialty hint (Cancellation, Refund, …) PROMOTES any matching
//         contact from Cc to To so the right person gets addressed first.
//   - Duplicates dropped while preserving order.
async function resolveSupplierContactsForTemplate(supplierId, templateName) {
  const [contacts] = await pool.query(
    `SELECT value, label, is_primary
       FROM supplier_contacts
      WHERE supplier_id = ? AND type = 'email'
      ORDER BY is_primary DESC, created_at ASC`,
    [supplierId]
  );
  if (contacts.length === 0) return { to: [], cc: [] };

  const tname = String(templateName || '').toLowerCase();
  const labelHint =
    /cancel/.test(tname) ? /cancel/i :
    /refund|credit/.test(tname) ? /refund|credit|finance|account/i :
    /reconfirm|confirm/.test(tname) ? /reconfirm|confirm/i :
    /change|special|name|date/.test(tname) ? /change|reservation|operation/i :
    null;

  const to = [];
  const cc = [];
  const seen = new Set();
  const push = (arr, v) => {
    if (!v) return;
    const k = String(v).toLowerCase();
    if (seen.has(k)) return;
    seen.add(k);
    arr.push(v);
  };

  // First pass — anything marked is_primary OR matching the specialty
  // hint goes straight to To. Specialty match wins over not-primary so
  // the cancellation contact is addressed, not bcc'd.
  for (const c of contacts) {
    const matchesSpecialty = labelHint && c.label && labelHint.test(c.label);
    if (c.is_primary || matchesSpecialty) push(to, c.value);
  }
  // Second pass — everyone else into Cc, preserving order.
  for (const c of contacts) push(cc, c.value);

  // If absolutely nothing landed in To (no is_primary flag, no specialty
  // match), promote the first contact so the operator never sees a blank
  // recipient when at least one contact exists.
  if (to.length === 0 && contacts[0]) {
    push(to, contacts[0].value);
  }
  // Anything we just put in To shouldn't also sit in Cc.
  const toSet = new Set(to.map(v => String(v).toLowerCase()));
  const ccFinal = cc.filter(v => !toSet.has(String(v).toLowerCase()));

  return { to, cc: ccFinal };
}

app.post('/api/email-templates/:id/render', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { booking_id, booking_code, supplier_id, product_indexes, notes } = req.body || {};
    if (!booking_id && !booking_code) return err(res, 'booking_id or booking_code required', 400);

    const [[template]] = await pool.query(
      'SELECT id, name, subject, body, template_type, ref_display FROM email_templates WHERE id = ?',
      [req.params.id]
    );
    if (!template) return err(res, 'Template not found', 404);

    const [[bk]] = await pool.query(
      `SELECT id, code, app_id, currency, total_amount, contact_email, contact_name,
              passenger_count, travel_date, booking_status AS status, raw
         FROM bookings_cache
        WHERE id = ? OR code = ?
        LIMIT 1`,
      [booking_id || '', booking_code || '']
    );
    if (!bk) return err(res, 'Booking not found in cache', 404);
    let raw = {};
    try { raw = typeof bk.raw === 'string' ? JSON.parse(bk.raw) : (bk.raw || {}); } catch {}
    const contact = {
      first_name: raw?.contact?.first_name || (bk.contact_name || '').split(' ')[0] || '',
      last_name:  raw?.contact?.last_name  || (bk.contact_name || '').split(' ').slice(1).join(' ') || '',
      email:      bk.contact_email || raw?.contact?.email || '',
      phone:      raw?.contact?.phone || '',
    };
    const allProducts = Array.isArray(raw?.products) ? raw.products : [];
    const indexes = Array.isArray(product_indexes) && product_indexes.length > 0
      ? product_indexes.filter(i => i >= 0 && i < allProducts.length)
      : allProducts.map((_, i) => i);
    const chosenProducts = indexes.map(i => allProducts[i]);

    let supplier = null;
    if (supplier_id) {
      const [[s]] = await pool.query(
        'SELECT id, name, code FROM suppliers WHERE id = ?',
        [supplier_id]
      );
      supplier = s || null;
    }

    // App display name (for the shell title). Per the operator's request,
    // the title now comes from the active SMTP account's `from_name` (i.e.
    // who is actually sending) rather than the booking's app_name. The
    // {{app_name}} placeholder still uses the booking's app_settings name
    // so existing templates that render "© ABC Solutions" in the body keep
    // working.
    let appNameForBody = '';
    if (bk.app_id) {
      const [[ar]] = await pool.query(
        'SELECT app_name FROM app_settings WHERE app_id = ?',
        [bk.app_id]
      );
      appNameForBody = ar?.app_name || '';
    }
    let smtpFromName = '';
    try {
      const account = await resolveSmtpAccount(null);
      smtpFromName = account?.from_name || '';
    } catch { /* preview still works without an SMTP account configured */ }

    const opsUser = req.platformUser;
    const opsName = opsUser?.first_name || opsUser?.email || 'Ops';
    // Per-user email signature. Newest column on platform_users — may be
    // missing on a non-bootstrapped DB; null/empty falls back to a default
    // built from the user's name inside renderEmailTemplate.
    const opsUserSignature = opsUser?.email_signature || null;

    const rendered = await renderEmailTemplate({
      template,
      booking: { ...bk, contact },
      supplier,
      products: chosenProducts,
      opsUserName: opsName,
      opsUserSignature,
      notes,
      appName: appNameForBody,
    });
    const body_text = htmlToTextSimple(rendered.body_html);
    const recipients = supplier_id
      ? await resolveSupplierContactsForTemplate(supplier_id, template.name)
      : { to: [], cc: [] };

    // Effective ref_display — fall back to a sensible default when the
    // template doesn't have one set yet: 'supplier' for supplier templates
    // (they want their own ref shown FIRST), 'booking' for everything else.
    const effectiveRefDisplay = template.ref_display ||
      (template.template_type === 'supplier' ? 'supplier' : 'booking');

    // Best-available supplier ref for the header subtitle.
    const headerSupplierRef = (() => {
      for (const raw of (chosenProducts || [])) {
        const p = raw || {};
        const data = p.data || p;
        const v = p.supplier_reference
          || data.supplier_reference
          || p.confirmation_number
          || data.confirmation_number
          || data.pnr
          || data.segments?.[0]?.pnr
          || data.flight_segments?.[0]?.pnr
          || data.reference;
        if (v) return String(v);
      }
      return '';
    })();

    ok(res, {
      template_id: template.id,
      template_name: template.name,
      template_type: template.template_type,
      subject: rendered.subject,
      body_html: rendered.body_html,
      body_text,
      to_suggestions: recipients.to,
      cc_suggestions: recipients.cc,
      missing_placeholders: rendered.missing_placeholders,
      // Brand metadata for the preview iframe + send pipeline. The composer
      // forwards these to /api/emails/preview and /rest/v1/emails/send so
      // what the operator previews matches what gets sent.
      shell_meta: {
        app_name: smtpFromName || appNameForBody || null,
        booking_ref: bk.code || null,
        supplier_ref: headerSupplierRef || null,
        ref_display: effectiveRefDisplay,
      },
    });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/emails/preview
//   Returns the editor body wrapped in the same shell that we use at send
//   time — so the operator can see EXACTLY what the recipient will see
//   while they're composing. Mounted before the auth middleware list above
//   ran them through requireOps; this is a safe operation (nothing is
//   persisted, and we only echo back HTML the user already has).
//
// Request:  { body_html: string, app_id?: string|number, booking_code?: string,
//             skip_shell?: boolean }
// Response: { preview_html: string, used_shell: boolean }
//
// We keep the response a JSON envelope (rather than serving raw HTML) so
// the front-end can render it inside an <iframe srcdoc=...> without
// needing a same-origin XHR-with-credentials roundtrip.
app.post('/api/emails/preview', authMiddleware, async (req, res) => {
  try {
    const {
      body_html, app_id, booking_code, skip_shell,
      // Optional pass-through from the render step. When the composer
      // already has these (it just called /render), forward them so the
      // preview iframe matches what we'll actually send.
      supplier_ref, ref_display,
      // Operator-supplied override for the header title (rare). Otherwise
      // we read from SMTP from_name → app_settings.app_name.
      app_name_override,
    } = req.body || {};
    if (typeof body_html !== 'string') {
      return err(res, 'body_html (string) is required', 400);
    }
    if (skip_shell) {
      return ok(res, { preview_html: body_html, used_shell: false });
    }
    // Resolve brand metadata. Title priority: explicit override > SMTP
    // from_name > app_settings.app_name > shell default.
    let appName = null, accentColor = null, logoUrl = null;
    let resolvedAppId = app_id || null;
    if (!resolvedAppId && booking_code) {
      try {
        const [[bk]] = await pool.query(
          'SELECT app_id FROM bookings_cache WHERE code = ? LIMIT 1', [booking_code]
        );
        resolvedAppId = bk?.app_id || null;
      } catch {}
    }
    if (resolvedAppId) {
      try {
        const [[ar]] = await pool.query(
          'SELECT app_name, accent_color, logo_url FROM app_settings WHERE app_id = ?',
          [resolvedAppId]
        );
        appName = ar?.app_name || null;
        accentColor = ar?.accent_color || null;
        logoUrl = ar?.logo_url || null;
      } catch {}
    }
    // SMTP from_name takes precedence over app_settings for the header
    // title — the operator wanted the title to reflect "who's sending".
    let smtpFromName = null;
    try {
      const account = await resolveSmtpAccount(null);
      smtpFromName = account?.from_name || null;
    } catch { /* fall back to app_settings */ }
    const titleAppName = app_name_override || smtpFromName || appName;
    const wrapped = wrapInEmailShell(body_html, {
      appName: titleAppName || undefined,
      accentColor: accentColor || undefined,
      logoUrl: logoUrl || undefined,
      bookingRef: booking_code || undefined,
      supplierRef: supplier_ref || undefined,
      refDisplay: ref_display || undefined,
    });
    ok(res, { preview_html: wrapped, used_shell: true });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/bookings/:codeOrId/vouchers
//   Returns one row per product that has a downloadable voucher PDF, in
//   the shape the SendVoucherDialog expects (checkbox list with a default
//   filename per product).
//
//   IMPORTANT: the `pdf_url` does NOT live in `bookings_cache.raw` — that's
//   the listings payload, which omits per-product voucher links. The
//   per-product `pdf_url` is only present on the live `/bookings/products/list`
//   upstream response (each URL is signed with a token that EasyGDS rotates).
//   So we call upstream here and merge with the cached booking just to
//   resolve the booking id from a code.
// Per-booking voucher cache. The upstream `/bookings/products` call returns
// signed pdf_url tokens that EasyGDS rotates roughly hourly — caching for
// 60 seconds is well within the rotation window and stops the SendVoucher
// dialog from re-hitting upstream every time the operator opens it. Keyed
// by booking id (not code) since a single booking can have multiple aliases.
//
// Map shape: { ts: epoch_ms, payload: <api response> }
// Eviction: lazy on read (entries older than TTL are recomputed). Cap at
// 500 entries — once hit, oldest are dropped to keep memory bounded.
const VOUCHER_CACHE_TTL_MS = 60_000;
const VOUCHER_CACHE_MAX = 500;
const _voucherCache = new Map();
function _voucherCacheGet(key) {
  const e = _voucherCache.get(key);
  if (!e) return null;
  if (Date.now() - e.ts > VOUCHER_CACHE_TTL_MS) {
    _voucherCache.delete(key);
    return null;
  }
  return e.payload;
}
function _voucherCacheSet(key, payload) {
  if (_voucherCache.size >= VOUCHER_CACHE_MAX) {
    // Drop the oldest entry. Map preserves insertion order so the first
    // key from .keys() is the LRU candidate (we don't move-on-access for
    // simplicity — a 60s TTL means thrash matters less than memory cap).
    const oldest = _voucherCache.keys().next().value;
    if (oldest) _voucherCache.delete(oldest);
  }
  _voucherCache.set(key, { ts: Date.now(), payload });
}

app.get('/api/bookings/:codeOrId/vouchers', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const [[bk]] = await pool.query(
      `SELECT id, code FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache', 404);

    // Cache hit — return the same payload we built last time within TTL.
    const cached = _voucherCacheGet(bk.id);
    if (cached) return ok(res, cached);

    // Fresh products list from upstream — has the signed pdf_url per product.
    // The upstream path is `/bookings/products` (singular path segment),
    // not `/bookings/products/list`. Returns `{result: [...]}` typically.
    const upstream = await easygdsRequest(`/bookings/products?booking_id=${bk.id}`);
    const products = upstream?.result || upstream?.data || (Array.isArray(upstream) ? upstream : []);
    const result = products
      .map((p, idx) => {
        const pdf = p.pdf_url || p.data?.pdf_url || null;
        if (!pdf) return null;
        const t = String(
          p.product_type || p.product?.code || p.data?.product_type || p.data?.type || 'service'
        ).toLowerCase();
        const ref =
          p.product_ref || p.supplier_reference || p.confirmation_number ||
          p.data?.supplier_reference || p.data?.confirmation_number ||
          p.data?.pnr || null;
        const filename = `voucher-${bk.code || bk.id}-${t}-${idx + 1}.pdf`;
        return {
          product_index: idx,
          product_id: p.id,
          product_type: t,
          reference: ref,
          pdf_url: pdf,
          filename,
        };
      })
      .filter(Boolean);
    const payload = { result };
    _voucherCacheSet(bk.id, payload);
    ok(res, payload);
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/bookings/:codeOrId/confirmation-numbers
//   Returns one entry per product with the current HCN / PNR / confirmation
//   info, in the shape the UpdateConfirmationNumbers dialog needs.
//
//   Source of truth is the live `/bookings/products?booking_id=...` upstream
//   response (NOT bookings_cache.raw — the listings payload omits the
//   per-product `data.selected_rooms[].hcn` field that we want to surface
//   and edit). Cheap call with no cache layer; matches the voucher endpoint
//   pattern for consistency.
app.get('/api/bookings/:codeOrId/confirmation-numbers', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const [[bk]] = await pool.query(
      `SELECT id, code FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache', 404);

    const upstream = await easygdsRequest(`/bookings/products?booking_id=${bk.id}`);
    const products = upstream?.result || upstream?.data || (Array.isArray(upstream) ? upstream : []);

    const result = products.map((p, idx) => {
      const data = p.data || {};
      const rawType = String(p.product_type || p.product?.code || data.product_type || data.type || '').toLowerCase();
      // Detect kind even when product_type is a generic "service" (some
      // bookings have flights labelled "service" in EasyGDS — same fix as
      // renderServicesTable).
      let kind = 'service';
      if (rawType === 'hotel' || data.hotel || data.hotels) kind = 'hotel';
      else if (rawType === 'flight' || rawType === 'flight_ticket' || data.segments || data.flight_segments || data.pnr) kind = 'flight';
      else if (rawType === 'transfer' || data.transfer || data.transfers) kind = 'transfer';
      else if (rawType === 'tour' || rawType === 'activity') kind = 'tour';
      else if (rawType === 'visa') kind = 'visa';
      else if (rawType === 'insurance') kind = 'insurance';

      // Per-room HCN snapshots for hotels. We pre-fill the input with
      // whatever identifier the booking-detail page shows for the room —
      // operators were confused when the dialog came back blank even
      // though the detail page clearly displayed VNR…/HCN/booking-ref.
      // Fallback chain mirrors BookingDetailsPage exactly:
      //   room.confirmation_number → room.hcn → room.bk_ref →
      //   room.rate?.booking_reference → product-level confirmation_number.
      // The product-level value is the safety net for older bookings
      // where nothing room-side was set.
      const productConf = p.confirmation_number || data.confirmation_number || '';
      const rooms = (kind === 'hotel' && Array.isArray(data.selected_rooms))
        ? data.selected_rooms.map((r, ri) => ({
            index: ri,
            label: r.name || r.rate?.room_type || r.rate?.name || `Room ${ri + 1}`,
            hcn: r.hcn
              || r.confirmation_number
              || r.bk_ref
              || r.rate?.booking_reference
              || productConf
              || '',
          }))
        : [];

      // Top-level identifiers used by non-hotel products.
      const pnr = data.pnr || (data.segments?.[0]?.pnr) || (data.flight_segments?.[0]?.pnr) || '';
      const supplierRef = p.supplier_reference || data.supplier_reference || '';
      const confirmationNumber = p.confirmation_number || data.confirmation_number || '';

      // Human-readable label for the dialog row.
      let title = '';
      if (kind === 'hotel') {
        const h = data.hotel || data.hotels?.[0] || {};
        title = h.name || data.hotel_name || data.name || 'Hotel';
      } else if (kind === 'flight') {
        const segs = data.segments || data.flight_segments || [];
        const route = segs.slice(0, 3)
          .map(s => `${s.from || s.origin || s.departure_airport || ''}→${s.to || s.destination || s.arrival_airport || ''}`)
          .filter(s => s !== '→')
          .join(', ');
        title = [data.airline_name || data.carrier, route].filter(Boolean).join(' — ') || 'Flight';
      } else if (kind === 'transfer') {
        const x = data.transfer || data.transfers?.[0] || {};
        title = `${x.from || x.pickup || 'Transfer'} → ${x.to || x.dropoff || ''}`.trim();
      } else {
        title = data.name || data.product_name || data.description || `${kind.charAt(0).toUpperCase()}${kind.slice(1)}`;
      }

      return {
        index: idx,
        product_id: p.id,
        kind,
        title: title || kind,
        rooms,
        pnr,
        supplier_reference: supplierRef,
        confirmation_number: confirmationNumber,
      };
    });

    ok(res, { booking_id: bk.id, booking_code: bk.code, result });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/bookings/:codeOrId/confirmation-numbers
//   Update HCN / PNR / confirmation number on one or more products of a
//   booking. Each upstream PATCH is independent — partial success is the
//   common case (e.g. EasyGDS accepts the hotel HCN but rejects the flight
//   PNR). We report per-update results so the UI can show ✓ next to the
//   ones that worked and ✗ + reason next to the ones that didn't.
//
// Body shape:
//   { updates: [
//       // Hotel — per-room HCN. Index matches data.selected_rooms[].
//       { product_id, kind: 'hotel', rooms: [ { index: 0, hcn: 'ABC123' }, ... ] },
//       // Flight — top-level PNR.
//       { product_id, kind: 'flight', pnr: 'XYZ789' },
//       // Transfer / tour / other — supplier_reference or confirmation_number.
//       { product_id, kind: 'transfer', confirmation_number: 'TR-001' },
//   ] }
//
// Implementation: GET the product once, mutate the matching field in `data`,
// then PATCH back the FULL data object (EasyGDS expects the complete blob,
// not a partial diff — same as the dry-run script verified).
app.post('/api/bookings/:codeOrId/confirmation-numbers', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const updates = Array.isArray(req.body?.updates) ? req.body.updates : [];
    if (updates.length === 0) return err(res, 'updates[] is required', 400);
    // Body-level reason — applies to every update unless a per-update one
    // overrides it. The dialog uses a single reason field for the whole
    // batch (rare for the operator to want different reasons per row).
    const bodyReason = typeof req.body?.reason === 'string' && req.body.reason.trim() !== ''
      ? req.body.reason.trim()
      : null;

    const [[bk]] = await pool.query(
      `SELECT id, code, app_id FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache', 404);

    // One round-trip to load all current product data in one shot.
    const upstream = await easygdsRequest(`/bookings/products?booking_id=${bk.id}`);
    const products = upstream?.result || upstream?.data || (Array.isArray(upstream) ? upstream : []);
    const productById = new Map(products.map(p => [p.id, p]));

    const succeeded = [];
    const failed = [];

    for (const u of updates) {
      const p = productById.get(u.product_id);
      if (!p) {
        failed.push({ product_id: u.product_id, error: 'Product not found on this booking' });
        continue;
      }
      const data = { ...(p.data || {}) };
      const before = {};   // captured for audit
      const after  = {};

      try {
        if (u.kind === 'hotel') {
          if (!Array.isArray(data.selected_rooms)) {
            throw new Error('Hotel product has no selected_rooms');
          }
          const newRooms = data.selected_rooms.map((r, ri) => {
            const update = (u.rooms || []).find(x => Number(x.index) === ri);
            if (!update) return r;
            before[`room_${ri}_hcn`] = r.hcn || '';
            after[`room_${ri}_hcn`]  = String(update.hcn || '');
            return { ...r, hcn: String(update.hcn || '') };
          });
          data.selected_rooms = newRooms;
        } else if (u.kind === 'flight') {
          before.pnr = data.pnr || '';
          after.pnr  = String(u.pnr || '');
          data.pnr = String(u.pnr || '');
        } else {
          // transfer / tour / generic: write supplier_reference and/or
          // confirmation_number — both fields are read by the renderer so
          // setting whichever is provided keeps the UI honest.
          if ('confirmation_number' in u) {
            before.confirmation_number = data.confirmation_number || '';
            after.confirmation_number  = String(u.confirmation_number || '');
            data.confirmation_number = String(u.confirmation_number || '');
          }
          if ('supplier_reference' in u) {
            before.supplier_reference = data.supplier_reference || '';
            after.supplier_reference  = String(u.supplier_reference || '');
            data.supplier_reference = String(u.supplier_reference || '');
          }
          if (!('confirmation_number' in u) && !('supplier_reference' in u)) {
            throw new Error('Non-hotel/flight update needs confirmation_number or supplier_reference');
          }
        }

        // Send the PATCH — EasyGDS expects { id, data } with the full data blob.
        await easygdsRequest('/bookings/products', {
          method: 'PATCH',
          body: JSON.stringify({ id: u.product_id, data }),
          headers: { 'Content-Type': 'application/json' },
        });

        // Audit each successful update so finance / customer support can
        // trace who changed which identifier when.
        await recordAuditEvent(req, {
          action: 'booking_confirmation_update',
          entityType: 'booking',
          entityId: bk.code,
          before,
          after: { ...after, product_id: u.product_id, kind: u.kind, app_id: bk.app_id },
          reason: u.reason || bodyReason || null,
        });

        succeeded.push({ product_id: u.product_id, kind: u.kind });
      } catch (e) {
        failed.push({ product_id: u.product_id, kind: u.kind, error: e.message || String(e) });
      }
    }

    // Re-sync this booking so bookings_cache mirrors the upstream change.
    // Don't await — the UI re-fetches /confirmation-numbers anyway, and we
    // don't want a slow sync to hold the response.
    if (succeeded.length > 0 && bk.app_id) {
      setImmediate(() => {
        syncBookingsForApp(bk.app_id, { full: false }).catch(err => {
          console.warn('Post-HCN-update sync failed (non-fatal):', err.message);
        });
      });
    }

    ok(res, { booking_id: bk.id, booking_code: bk.code, succeeded, failed });
  } catch (e) { err(res, e.message, 500); }
});

// Helper for the dialog's first step: enumerate suppliers represented in
// this booking's products. Returns one row per distinct supplier with its
// canonical id (so an emailed thread links to the merged supplier, not
// per-source-code splits) and the indexes of products attributed to it.
//
// ── Booking review history ────────────────────────────────────────────
// Append-only timeline of operator review actions on a booking. Each row
// is one transition: status (reviewed | flagged | in_progress) + notes +
// who + when. The "current status" is the latest row's status; the full
// list is the timeline shown under the Review dialog.

// 'new' means the operator has explicitly chosen to keep the booking in the
// new/unreviewed bucket (a deliberate decision, not the implicit absence of
// any review row). The display layer treats an empty review history as
// "new" too, so users see all unreviewed bookings even before anyone has
// opened them.
const ALLOWED_REVIEW_STATUSES = new Set(['new', 'reviewed', 'flagged', 'in_progress']);

// Resolve a booking by either UUID or human code. Returns null when the
// booking isn't in the cache yet (we still allow review on it — the cache
// can populate later, the review references booking_code so it's durable).
async function resolveBookingForReview(codeOrId) {
  const [[row]] = await pool.query(
    `SELECT id, code FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
    [codeOrId, codeOrId]
  );
  return row || null;
}

// Classify each booking by supplier-contract ownership at booking time.
//   'our'      — at least one product's supplier has NO matching customer
//                contract for the booking's app (falls to Ops).
//   'customer' — every resolvable product's supplier has an active
//                customer-owned contract (owner_app_id = booking.app_id)
//                whose effective window contains the booking date.
//
// Bookings with no resolvable product suppliers default to 'our' — Ops is
// the conservative default so a booking can't disappear from their queue
// just because its supplier code didn't match the canonical table yet.
//
// Mirrors the logic of `classifyOwner` used by the supplier-balance sync
// (server/index.cjs:5653). Loads suppliers + active contracts once per
// invocation; safe to call with hundreds of rows.
async function classifyBookingsSupplierOwnership(rows) {
  const out = new Map();
  if (!rows || rows.length === 0) return out;

  // Build: supplier code (lower) → canonical supplier_id, honouring
  // merge graph (merged suppliers point at their replacement).
  const [supRows] = await pool.query(
    `SELECT id, code, COALESCE(merged_into_id, id) AS canonical_id
       FROM suppliers WHERE is_active = 1`
  );
  const codeToCanonical = new Map();
  for (const s of supRows) {
    if (s.code) codeToCanonical.set(String(s.code).toLowerCase(), s.canonical_id);
  }

  // Build: supplier_id → list of active contracts.
  const [contractRows] = await pool.query(
    `SELECT supplier_id, owner_app_id, effective_from, effective_to
       FROM supplier_contracts WHERE is_active = 1`
  );
  const contractsBySupplier = new Map();
  for (const c of contractRows) {
    const arr = contractsBySupplier.get(c.supplier_id) || [];
    arr.push(c);
    contractsBySupplier.set(c.supplier_id, arr);
  }

  const hasCustomerContract = (supplierId, appId, dateMs) => {
    if (!supplierId || !appId) return false;
    const candidates = contractsBySupplier.get(supplierId);
    if (!candidates) return false;
    for (const row of candidates) {
      if (row.owner_app_id !== appId) continue;
      const fromMs = row.effective_from ? new Date(row.effective_from).getTime() : null;
      const toMs   = row.effective_to   ? new Date(row.effective_to).getTime()   : null;
      if (fromMs !== null && dateMs !== null && dateMs < fromMs) continue;
      if (toMs   !== null && dateMs !== null && dateMs > toMs)   continue;
      return true;
    }
    return false;
  };

  for (const r of rows) {
    let raw = r.raw;
    if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch { raw = null; } }
    const products = Array.isArray(raw?.products) ? raw.products : [];
    const txnDate = r.upstream_created || r.created_at || null;
    const dateMs = txnDate ? new Date(txnDate).getTime() : null;
    const appId = r.app_id || null;

    let anyOurs = false;
    let anyResolvable = false;
    for (const p of products) {
      const code = extractProductSupplier(p);
      if (!code) continue;
      const canonical = codeToCanonical.get(String(code).toLowerCase());
      if (!canonical) continue;
      anyResolvable = true;
      if (!hasCustomerContract(canonical, appId, dateMs)) {
        anyOurs = true;
        break;
      }
    }
    // No resolvable suppliers → default to 'our' (Ops queue, conservative).
    if (!anyResolvable) { out.set(r.code, 'our'); continue; }
    out.set(r.code, anyOurs ? 'our' : 'customer');
  }
  return out;
}

// Bulk lookup: for each booking_code, return the latest review status.
// Uses the per-code latest-row pattern (MAX(created_at) per code). Two-step
// rather than a JOIN on bookings_cache to avoid the cross-collation
// (utf8mb4_unicode_ci vs utf8mb4_general_ci) issue documented in
// trip-glide-control/CLAUDE.md.
async function fetchLatestReviewStatusByCode(codes) {
  const out = new Map();
  if (!codes || codes.length === 0) return out;
  const uniq = Array.from(new Set(codes.filter(Boolean)));
  if (uniq.length === 0) return out;
  const placeholders = uniq.map(() => '?').join(',');
  const [rows] = await pool.query(
    `SELECT br.booking_code, br.status
       FROM booking_reviews br
       JOIN (
         SELECT booking_code, MAX(created_at) AS max_at
           FROM booking_reviews
          WHERE booking_code IN (${placeholders})
          GROUP BY booking_code
       ) latest ON latest.booking_code = br.booking_code AND latest.max_at = br.created_at`,
    uniq
  );
  for (const r of rows) {
    if (!out.has(r.booking_code)) out.set(r.booking_code, r.status);
  }
  return out;
}

// GET /api/bookings/:codeOrId/reviews — full review history (newest first)
app.get('/api/bookings/:codeOrId/reviews', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const bk = await resolveBookingForReview(codeOrId);
    // Even when not in cache, query by raw param — booking_code might still match.
    const code = bk?.code || codeOrId;
    const [rows] = await pool.query(
      `SELECT id, booking_id, booking_code, status, notes,
              created_by, created_by_name, created_by_email, created_at
         FROM booking_reviews
        WHERE booking_code = ? OR booking_id = ?
        ORDER BY created_at DESC`,
      [code, bk?.id || codeOrId]
    );
    ok(res, {
      booking_id: bk?.id || null,
      booking_code: code,
      // Convenience field — current/latest status. UI badges off this so
      // it doesn't have to peek at result[0].
      current_status: rows[0]?.status || null,
      result: rows,
    });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/bookings/:codeOrId/reviews — record a new review action
//   body: { status: 'reviewed' | 'flagged' | 'in_progress', notes?: string }
app.post('/api/bookings/:codeOrId/reviews', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const d = req.body || {};
    const status = String(d.status || '').toLowerCase();
    if (!ALLOWED_REVIEW_STATUSES.has(status)) {
      return err(res, `status must be one of: ${[...ALLOWED_REVIEW_STATUSES].join(', ')}`, 400);
    }
    const bk = await resolveBookingForReview(codeOrId);
    const bookingCode = bk?.code || codeOrId;
    const bookingId = bk?.id || null;

    const id = crypto.randomUUID();
    const user = req.platformUser;
    const fullName = `${user.first_name || ''} ${user.last_name || ''}`.trim() || null;
    await pool.query(
      `INSERT INTO booking_reviews
         (id, booking_id, booking_code, status, notes,
          created_by, created_by_name, created_by_email)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, bookingId, bookingCode, status, d.notes || null,
       user.id, fullName, user.email || null]
    );
    // Audit — keeps a separate trail in audit_events alongside the
    // booking_reviews timeline (the audit log is global; the review
    // history is per-booking).
    await recordAuditEvent(req, {
      action: 'booking_review_create',
      entityType: 'booking',
      entityId: bookingId || bookingCode,
      before: null,
      after: { id, status, notes: d.notes || null, booking_code: bookingCode },
    });

    const [[row]] = await pool.query('SELECT * FROM booking_reviews WHERE id = ?', [id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/bookings/:codeOrId/suppliers
app.get('/api/bookings/:codeOrId/suppliers', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const [[bk]] = await pool.query(
      `SELECT id, code, raw FROM bookings_cache
        WHERE id = ? OR code = ? LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache', 404);
    let raw = {};
    try { raw = typeof bk.raw === 'string' ? JSON.parse(bk.raw) : (bk.raw || {}); } catch {}
    const products = Array.isArray(raw.products) ? raw.products : [];
    if (products.length === 0) return ok(res, { result: [] });

    // Re-use the same allocation rules as the supplier-balance sync so
    // multi-product bookings attribute to the same supplier IDs the user
    // sees in the Suppliers Balance page.
    const allocs = allocateBookingToSuppliers(Number(bk.total_amount) || 0, products);
    // Map source code → canonical supplier row (as in the balance sync).
    const [supRows] = await pool.query(
      `SELECT id, code, name,
              COALESCE(merged_into_id, id) AS canonical_id
         FROM suppliers WHERE is_active = 1`
    );
    const codeToCanonical = new Map();
    const byId = new Map();
    for (const s of supRows) {
      if (s.code) codeToCanonical.set(String(s.code).toLowerCase(), s.canonical_id);
      byId.set(s.id, s);
    }

    // Helper to extract a friendly supplier name from a product blob —
    // used for the stub label when the supplier hasn't been synced into
    // our `suppliers` table yet (typical after a deep DB clean OR a brand
    // new staging environment).
    const extractSupplierName = (p) => {
      const d = p?.data || p || {};
      return d.supplier_name || d.provider_name || d.supplier_code || null;
    };

    const grouped = new Map(); // key -> { supplier, productIndexes, productSummaries }
    let autoDiscoveredCount = 0;
    for (const a of allocs) {
      const canon = codeToCanonical.get(String(a.supplierCode).toLowerCase());
      let groupKey;
      let supplier;
      if (canon) {
        groupKey = canon;
        supplier = byId.get(canon) || { id: canon, code: a.supplierCode, name: null };
      } else {
        // Stub — supplier referenced by the booking but not yet present
        // in our `suppliers` table. The dialog still shows it; the
        // operator types the email address manually (or we trigger an
        // auto-discover sync in the background to populate the row).
        // Use `stub:<code>` as the key so multi-product allocations to
        // the same un-synced supplier collapse into one row.
        groupKey = `stub:${a.supplierCode}`;
        const productSample = products[a.productIndex] || {};
        supplier = {
          id: groupKey,
          code: a.supplierCode,
          name: extractSupplierName(productSample) || a.supplierCode,
          auto_discovered: true,
        };
        autoDiscoveredCount++;
      }
      if (!grouped.has(groupKey)) {
        grouped.set(groupKey, { supplier, productIndexes: [], productSummaries: [] });
      }
      const slot = grouped.get(groupKey);
      if (!slot.productIndexes.includes(a.productIndex)) slot.productIndexes.push(a.productIndex);
      // Compact summary used by the dialog's product-checkbox list.
      const p = products[a.productIndex] || {};
      const d = p.data || p;
      const t = String(p.product_type || d.product_type || d.type || 'service').toLowerCase();
      const ref = p.supplier_reference || p.confirmation_number ||
                  d.supplier_reference || d.confirmation_number ||
                  d.pnr || d.reference || null;
      slot.productSummaries.push({
        index: a.productIndex,
        type: t,
        ref,
        label: d.name || d.product_name || d.hotel?.name || (t === 'flight' ? (d.airline_name || d.carrier || 'Flight') : t),
      });
    }

    // If we surfaced any stubs, trigger a background discover-from-bookings
    // pass so the suppliers table self-heals. Single-flight via a module
    // flag — concurrent calls don't pile up.
    if (autoDiscoveredCount > 0 && !_supplierAutoDiscoveryRunning) {
      _supplierAutoDiscoveryRunning = true;
      setImmediate(async () => {
        try {
          await runSupplierAutoDiscovery();
          console.log(`✅ Supplier auto-discovery: scanned bookings_cache, populated missing supplier rows`);
        } catch (e) {
          console.warn('Supplier auto-discovery failed:', e.message);
        } finally {
          _supplierAutoDiscoveryRunning = false;
        }
      });
    }

    const result = Array.from(grouped.values()).map(g => ({
      supplier_id: g.supplier.id,
      supplier_name: g.supplier.name || g.supplier.code,
      supplier_code: g.supplier.code,
      auto_discovered: g.supplier.auto_discovered === true,
      product_indexes: g.productIndexes,
      products: g.productSummaries,
    }));
    ok(res, { result });
  } catch (e) { err(res, e.message, 500); }
});

// Validate the requested ownership tier against the caller. Ops admins can
// create any tier; non-admins can only create owner_type='user' rows for
// themselves. App tier is admin-only (per the agreed plan: "App admin role
// → Ops admins only for now"). Returns either { owner_type, owner_id } or
// throws.
function resolveOwnership(user, body) {
  const isAdmin = user?.login_context === 'system' && user?.role === 'admin';
  const ot = String(body.owner_type || 'user').toLowerCase();
  if (!['system', 'app', 'user'].includes(ot)) throw new Error('Invalid owner_type');
  if (ot === 'system') {
    if (!isAdmin) throw Object.assign(new Error('Only Ops admins can manage system accounts'), { status: 403 });
    return { owner_type: 'system', owner_id: null };
  }
  if (ot === 'app') {
    if (!isAdmin) throw Object.assign(new Error('Only Ops admins can manage app-scoped accounts'), { status: 403 });
    if (!body.owner_id) throw new Error('owner_id (app_id) is required for owner_type=app');
    return { owner_type: 'app', owner_id: String(body.owner_id) };
  }
  // owner_type=user — non-admins forced to themselves; admins can target
  // any user (e.g. helping a user reset their personal SMTP).
  const targetId = isAdmin && body.owner_id ? String(body.owner_id) : user.id;
  return { owner_type: 'user', owner_id: targetId };
}

app.post('/api/smtp-accounts', authMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    if (!d.name || !d.host || !d.username || !d.password || !d.from_email) {
      return err(res, 'name, host, username, password, from_email are required', 400);
    }
    let ownership;
    try { ownership = resolveOwnership(req.platformUser, d); }
    catch (e) { return err(res, e.message, e.status || 400); }
    // Per-scope default management — when the new row claims default, clear
    // any sibling row in the same scope first.
    if (d.is_default_for_scope || d.is_default) {
      await pool.query(
        `UPDATE smtp_accounts
            SET is_default_for_scope = 0
          WHERE owner_type = ? AND ${ownership.owner_id == null ? 'owner_id IS NULL' : 'owner_id = ?'}
            AND is_default_for_scope = 1`,
        ownership.owner_id == null ? [ownership.owner_type] : [ownership.owner_type, ownership.owner_id]
      );
    }
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO smtp_accounts
         (id, name, host, port, secure, username, password, from_email, from_name,
          owner_type, owner_id, display_label, is_default_for_scope, is_default, is_active)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, d.name, d.host,
       d.port != null ? Number(d.port) : 587,
       d.secure ? 1 : 0,
       d.username, encryptSecret(d.password), d.from_email, d.from_name || null,
       ownership.owner_type, ownership.owner_id,
       d.display_label || null,
       (d.is_default_for_scope || d.is_default) ? 1 : 0,
       (ownership.owner_type === 'system' && d.is_default) ? 1 : 0,
       d.is_active === false ? 0 : 1]
    );
    const [[row]] = await pool.query('SELECT * FROM smtp_accounts WHERE id = ?', [id]);
    ok(res, smtpToApi(row));
  } catch (e) { err(res, e.message, e.status || 500); }
});

app.patch('/api/smtp-accounts/:id', authMiddleware, async (req, res) => {
  try {
    if (!await canUserManageOwnedRow(req.platformUser, 'smtp_accounts', req.params.id)) {
      return err(res, 'Forbidden — you can only edit your own SMTP accounts', 403);
    }
    const d = req.body || {};
    const allowed = ['name','host','port','secure','username','password','from_email','from_name','display_label','is_default_for_scope','is_default','is_active'];
    const sets = [], params = [];
    for (const k of allowed) {
      if (!(k in d)) continue;
      if (k === 'password') {
        if (d[k] === '********' || d[k] === '' || d[k] == null) continue;
        let cleaned = String(d[k]).trim();
        if (cleaned.startsWith('********')) {
          console.warn('⚠️  SMTP password update arrived with a leading `********` mask — stripping.');
          cleaned = cleaned.replace(/^\*+/, '').trim();
        }
        if (cleaned === '') continue;
        sets.push('password = ?');
        params.push(encryptSecret(cleaned));
        continue;
      }
      sets.push(`${k} = ?`);
      params.push(['secure','is_default_for_scope','is_default','is_active'].includes(k) ? (d[k] ? 1 : 0) : d[k]);
    }
    if (sets.length === 0) return err(res, 'No fields to update');
    // Per-scope default reset: if this row is being promoted to scope
    // default, demote its siblings in the same (owner_type, owner_id).
    if (d.is_default_for_scope) {
      const [[me]] = await pool.query('SELECT owner_type, owner_id FROM smtp_accounts WHERE id = ?', [req.params.id]);
      if (me) {
        await pool.query(
          `UPDATE smtp_accounts SET is_default_for_scope = 0
            WHERE owner_type = ?
              AND ${me.owner_id == null ? 'owner_id IS NULL' : 'owner_id = ?'}
              AND id != ?`,
          me.owner_id == null ? [me.owner_type, req.params.id] : [me.owner_type, me.owner_id, req.params.id]
        );
      }
    }
    params.push(req.params.id);
    await pool.query(`UPDATE smtp_accounts SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM smtp_accounts WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    ok(res, smtpToApi(row));
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/smtp-accounts/:id', authMiddleware, async (req, res) => {
  try {
    if (!await canUserManageOwnedRow(req.platformUser, 'smtp_accounts', req.params.id)) {
      return err(res, 'Forbidden — you can only delete your own SMTP accounts', 403);
    }
    // If this row was someone's preferred, NULL it out so we don't carry a
    // dangling reference forward.
    await pool.query('UPDATE platform_users SET preferred_smtp_account_id = NULL WHERE preferred_smtp_account_id = ?', [req.params.id]);
    await pool.query('DELETE FROM smtp_accounts WHERE id = ?', [req.params.id]);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// Helper — pick the SMTP account to use for a send. Prefers the flagged
// default, falls back to the first active row. Used by background jobs
// (notifications, finance alerts, supplier reminders) — they always send
// from the SYSTEM scope. User-driven sends use resolveSmtpForSend below
// instead.
async function resolveSmtpAccount(preferredId) {
  if (preferredId) {
    const [[row]] = await pool.query('SELECT * FROM smtp_accounts WHERE id = ? AND is_active = 1', [preferredId]);
    if (row) return row;
  }
  // System scope first: flagged default → legacy default → first active.
  const [[row]] = await pool.query(
    `SELECT * FROM smtp_accounts
     WHERE is_active = 1 AND owner_type = 'system'
     ORDER BY is_default_for_scope DESC, is_default DESC, created_at ASC
     LIMIT 1`
  );
  if (row) return row;
  // No system row at all — fall back to ANY active account so an empty
  // system tier doesn't kill background jobs on a fresh install.
  const [[any]] = await pool.query(
    'SELECT * FROM smtp_accounts WHERE is_active = 1 ORDER BY is_default DESC, created_at ASC LIMIT 1'
  );
  return any || null;
}

// ── Inbox + SMTP visibility scoping ──────────────────────────────────
// Three ownership tiers: system, app, user. The helpers below answer:
// "what can THIS user see and pick from?" and are used everywhere we
// list or resolve mailbox-related rows.

function inboxOrSmtpScopeForUser(user) {
  // Returns { systemAllowed, appIds: string[]|null, userId: string }.
  // appIds === null = ALL apps (admin); [] = none; [..] = specific.
  //
  // `isAdmin` is the WRITE gate (canUserManageOwnedRow gates create/edit/
  // delete on it). Setting it true beyond actual admins would let
  // non-admins mutate system-owned IMAP/SMTP rows — never do that here.
  //
  // `systemAllowed` is the READ gate (do we include `owner_type='system'`
  // rows in the listing?). Widening this for OPS team members is safe:
  // they see the system inbox account in dropdowns and can read its
  // emails via the emails endpoint, but can't reconfigure it.
  const ctx = user?.login_context || 'system';
  const role = user?.role || '';
  const isAdmin = ctx === 'system' && role === 'admin';
  // OPS team membership grants READ access to system-owned resources
  // even when the auth flow downgraded the user to login_context='app'.
  // Mirrors perms.isOpsReadAccess — see comment there for rationale.
  const isOpsTeamMember = user?.is_ops_team_member === true;
  if (isAdmin) {
    return { systemAllowed: true, appIds: null, userId: user.id, isAdmin: true };
  }
  if (ctx === 'system') {
    // Ops agent / manager / customer_service: system + own + apps in scope.
    const apps = Array.isArray(user.allowed_app_ids) && user.allowed_app_ids.length > 0
      ? user.allowed_app_ids
      : null; // null here = unrestricted (sees every app)
    return { systemAllowed: true, appIds: apps, userId: user.id, isAdmin: false };
  }
  if (ctx === 'app') {
    const apps = Array.isArray(user.allowed_app_ids) ? user.allowed_app_ids : [];
    // Read-only widening — OPS team members in 'app' context still see
    // system-owned inboxes/SMTPs in the dropdown. Writes stay
    // admin-only (isAdmin stays false).
    return { systemAllowed: isOpsTeamMember, appIds: apps, userId: user.id, isAdmin: false };
  }
  // 'agent' context — locked to a single app. Agent staff never see
  // system-owned resources, even if (somehow) flagged as Ops team.
  const lockedApps = user?.locked_app_id ? [user.locked_app_id] : [];
  return { systemAllowed: false, appIds: lockedApps, userId: user.id, isAdmin: false };
}

function buildOwnerWhere(scope) {
  // Builds a `(owner_type, owner_id)` WHERE fragment for the given scope.
  // Returns { sql, params } that you AND into your existing query.
  const clauses = [];
  const params = [];
  if (scope.systemAllowed) clauses.push("(owner_type = 'system')");
  if (scope.userId) {
    clauses.push("(owner_type = 'user' AND owner_id = ?)");
    params.push(scope.userId);
  }
  if (scope.appIds === null) {
    // Unrestricted → all app rows visible.
    clauses.push("(owner_type = 'app')");
  } else if (scope.appIds.length > 0) {
    clauses.push(`(owner_type = 'app' AND owner_id IN (${scope.appIds.map(() => '?').join(',')}))`);
    params.push(...scope.appIds);
  }
  // No matches → return a falsy clause so the query yields zero rows.
  if (clauses.length === 0) return { sql: '0=1', params: [] };
  return { sql: `(${clauses.join(' OR ')})`, params };
}

async function listSmtpAccountsForUser(user, { activeOnly = true } = {}) {
  const scope = inboxOrSmtpScopeForUser(user);
  const where = buildOwnerWhere(scope);
  const sql = `
    SELECT id, name, host, port, secure, username, from_email, from_name,
           owner_type, owner_id, display_label,
           is_default_for_scope, is_default, is_active, last_used_at, last_error,
           created_at, updated_at
    FROM smtp_accounts
    WHERE ${where.sql}
    ${activeOnly ? 'AND is_active = 1' : ''}
    ORDER BY
      CASE owner_type WHEN 'user' THEN 0 WHEN 'app' THEN 1 ELSE 2 END,
      is_default_for_scope DESC,
      created_at ASC`;
  const [rows] = await pool.query(sql, where.params);
  return rows;
}

async function listInboxesForUser(user, { activeOnly = false } = {}) {
  const scope = inboxOrSmtpScopeForUser(user);
  const where = buildOwnerWhere(scope);
  const sql = `
    SELECT * FROM email_inboxes
    WHERE ${where.sql}
    ${activeOnly ? 'AND is_active = 1' : ''}
    ORDER BY
      CASE owner_type WHEN 'user' THEN 0 WHEN 'app' THEN 1 ELSE 2 END,
      created_at ASC`;
  const [rows] = await pool.query(sql, where.params);
  return rows;
}

async function canUserAccessSmtpAccount(user, accountId) {
  if (!accountId) return false;
  const scope = inboxOrSmtpScopeForUser(user);
  const where = buildOwnerWhere(scope);
  const [[row]] = await pool.query(
    `SELECT id FROM smtp_accounts WHERE id = ? AND ${where.sql} LIMIT 1`,
    [accountId, ...where.params]
  );
  return !!row;
}

async function canUserAccessInbox(user, inboxId) {
  if (!inboxId) return false;
  const scope = inboxOrSmtpScopeForUser(user);
  const where = buildOwnerWhere(scope);
  const [[row]] = await pool.query(
    `SELECT id FROM email_inboxes WHERE id = ? AND ${where.sql} LIMIT 1`,
    [inboxId, ...where.params]
  );
  return !!row;
}

// canUserManageSmtpAccount / canUserManageInbox — write-side scope check.
// Ops admins can manage anything. Non-admins can only manage their own
// owner_type='user' rows (their personal inbox/SMTP).
async function canUserManageOwnedRow(user, table, rowId) {
  if (!rowId) return false;
  const scope = inboxOrSmtpScopeForUser(user);
  if (scope.isAdmin) return true;
  const [[row]] = await pool.query(
    `SELECT owner_type, owner_id FROM ${table} WHERE id = ? LIMIT 1`,
    [rowId]
  );
  if (!row) return false;
  return row.owner_type === 'user' && row.owner_id === user.id;
}

// resolveSmtpForSend — the user-driven send pipeline. Picks the right
// SMTP account in this order:
//   1. Caller passed a `requestedId` AND it's in the user's allowed list
//   2. The user's `preferred_smtp_account_id` (if still allowed)
//   3. Their scope default (own user → app → system, in that order)
//
// Returns the row plus a `_resolution` tag explaining which path won so
// the composer can show "Sent from your personal Gmail" / "system default".
// Returns null when nothing's configured at all.
async function resolveSmtpForSend(user, requestedId) {
  if (requestedId) {
    if (await canUserAccessSmtpAccount(user, requestedId)) {
      const [[row]] = await pool.query(
        'SELECT * FROM smtp_accounts WHERE id = ? AND is_active = 1', [requestedId]
      );
      if (row) return Object.assign(row, { _resolution: 'requested' });
    } else {
      // Operator picked something they don't own — refuse so we don't
      // leak credentials cross-tenant.
      const e = new Error('Forbidden: that SMTP account is not in your scope');
      e.status = 403;
      throw e;
    }
  }
  // Step 2 — user's saved preference.
  if (user?.preferred_smtp_account_id) {
    if (await canUserAccessSmtpAccount(user, user.preferred_smtp_account_id)) {
      const [[row]] = await pool.query(
        'SELECT * FROM smtp_accounts WHERE id = ? AND is_active = 1',
        [user.preferred_smtp_account_id]
      );
      if (row) return Object.assign(row, { _resolution: 'preferred' });
    }
  }
  // Step 3 — scope defaults, in order: own user → first allowed app → system.
  const scope = inboxOrSmtpScopeForUser(user);
  // Own personal default
  const [[ownRow]] = await pool.query(
    `SELECT * FROM smtp_accounts
     WHERE is_active = 1 AND owner_type = 'user' AND owner_id = ?
     ORDER BY is_default_for_scope DESC, created_at ASC LIMIT 1`,
    [user.id]
  );
  if (ownRow) return Object.assign(ownRow, { _resolution: 'own_default' });
  // App default — first match across all apps the user is scoped to.
  if (scope.appIds === null || scope.appIds.length > 0) {
    const where = scope.appIds === null
      ? "owner_type = 'app'"
      : `owner_type = 'app' AND owner_id IN (${scope.appIds.map(() => '?').join(',')})`;
    const params = scope.appIds === null ? [] : scope.appIds;
    const [[appRow]] = await pool.query(
      `SELECT * FROM smtp_accounts
       WHERE is_active = 1 AND ${where}
       ORDER BY is_default_for_scope DESC, created_at ASC LIMIT 1`,
      params
    );
    if (appRow) return Object.assign(appRow, { _resolution: 'app_default' });
  }
  // System default
  if (scope.systemAllowed) {
    const sysRow = await resolveSmtpAccount(null);
    if (sysRow) return Object.assign(sysRow, { _resolution: 'system_default' });
  }
  return null;
}

// ── Email-threading helpers ────────────────────────────────────────────────
//
// mailparser strips the angle brackets from `messageId` and `references`
// when parsing inbound email. RFC 5322 requires those brackets on outgoing
// `In-Reply-To` / `References` headers — without them Outlook (and some
// Apple Mail setups) won't thread replies with the original. These helpers
// round-trip the bracket form consistently.

// Render a plain-text email body as a minimal HTML multipart counterpart.
// We always ship BOTH text and HTML on outbound — clients that prefer HTML
// (Outlook, Gmail, Apple Mail) get a nicer-looking reply, and plain-text
// clients still see the original body. Handles quoted ("> ") reply blocks
// and paragraph-per-line output.
function textToHtml(text) {
  if (!text) return '';
  const escape = (s) => s.replace(/[<>&]/g, c => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
  // Auto-linkify URLs so the HTML view is actually clickable; the plain-text
  // part stays untouched so mail clients that prefer text aren't affected.
  const linkify = (s) => s.replace(/\b(https?:\/\/[^\s<>"']+)/g, '<a href="$1" style="color:#2563eb">$1</a>');

  const lines = text.split('\n');
  const html = [];
  let inQuote = false;
  for (const raw of lines) {
    const isQuote = raw.startsWith('>');
    if (isQuote && !inQuote) {
      html.push('<blockquote style="border-left:3px solid #cbd5e1;margin:8px 0;padding-left:12px;color:#475569">');
      inQuote = true;
    }
    if (!isQuote && inQuote) {
      html.push('</blockquote>');
      inQuote = false;
    }
    const line = isQuote ? raw.replace(/^>\s?/, '') : raw;
    if (line.trim() === '') {
      html.push('<br>');
    } else {
      html.push(`<p style="margin:0 0 8px;line-height:1.5">${linkify(escape(line))}</p>`);
    }
  }
  if (inQuote) html.push('</blockquote>');

  return `<div style="font-family:system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:14px;color:#0f172a;max-width:640px">${html.join('\n')}</div>`;
}

// Wrap a single Message-ID in <...> form if it isn't already. Null/empty
// stays null so callers can just spread the result into the headers map.
function wrapMsgId(id) {
  if (!id) return null;
  const trimmed = String(id).trim();
  if (!trimmed) return null;
  return /^<.+>$/.test(trimmed) ? trimmed : `<${trimmed}>`;
}

// Given a chain string (space-separated Message-IDs) and one extra parent,
// build the References header value: parent appended at the end, each id
// wrapped, duplicates preserved (clients use order as the tree walk).
function buildReferencesChain(existingChain, parentMsgId) {
  const ids = [];
  if (existingChain) {
    // Split by whitespace and wrap each — handles values that were stored
    // without brackets just fine.
    for (const raw of String(existingChain).split(/\s+/)) {
      const w = wrapMsgId(raw);
      if (w) ids.push(w);
    }
  }
  const parent = wrapMsgId(parentMsgId);
  if (parent && !ids.includes(parent)) ids.push(parent);
  return ids.length ? ids.join(' ') : null;
}

// Admin test: opens an SMTP connection and verifies auth.
// GET /api/smtp-accounts/:id/inspect — DEBUG-ONLY endpoint that returns
// the actual stored SMTP password so an admin can verify exactly what's
// in the DB. This bypasses the `********` masking applied to /api/smtp-
// accounts list responses, so use it only when troubleshooting auth
// failures (e.g. SMTP returning 535 — knowing the stored value lets you
// confirm whether it's the right password vs. a copy/paste glitch).
//
// Admin-only. The response includes a `_warning` field; the route is
// expected to be removed or further locked down once the SMTP issue the
// operator was debugging is resolved.
app.get('/api/smtp-accounts/:id/inspect', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [[row]] = await pool.query('SELECT * FROM smtp_accounts WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'SMTP account not found', 404);
    const stored = row.password || '';
    const isLegacyEncrypted = typeof stored === 'string' && stored.startsWith(ENC_PREFIX);
    const decrypted = isLegacyEncrypted ? decryptSecret(stored) : stored;
    const stillEncrypted = typeof decrypted === 'string' && decrypted.startsWith(ENC_PREFIX);
    console.warn(`🔓 SMTP /inspect requested by ${req.platformUser?.email || '?'} for account ${row.name || row.id}`);
    ok(res, {
      _warning: 'This response contains the cleartext SMTP password. Do not share or log it.',
      id: row.id,
      name: row.name,
      host: row.host,
      port: row.port,
      username: row.username,
      from_email: row.from_email,
      stored_format: isLegacyEncrypted ? 'legacy_encrypted' : 'plaintext',
      can_decrypt: !stillEncrypted,
      password_length: stillEncrypted ? null : String(decrypted || '').length,
      password: stillEncrypted ? null : decrypted,
      // Defensive shape diagnostics so you can spot copy/paste glitches
      // even just by looking at the response without revealing the value.
      diagnostics: stillEncrypted ? null : {
        starts_with_mask: String(decrypted || '').startsWith('********'),
        has_leading_space: String(decrypted || '') !== String(decrypted || '').trimStart(),
        has_trailing_space: String(decrypted || '') !== String(decrypted || '').trimEnd(),
        has_newline: /[\r\n]/.test(String(decrypted || '')),
        ascii_only: /^[\x20-\x7e]*$/.test(String(decrypted || '')),
      },
    });
  } catch (e) { err(res, e.message, 500); }
});

// Same idea for IMAP — returns the stored password so you can verify the
// inbox credential matches what your provider issued.
app.get('/api/email-inboxes/:id/inspect', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [[row]] = await pool.query('SELECT * FROM email_inboxes WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Inbox not found', 404);
    const stored = row.imap_password || '';
    const isLegacyEncrypted = typeof stored === 'string' && stored.startsWith(ENC_PREFIX);
    const decrypted = isLegacyEncrypted ? decryptSecret(stored) : stored;
    const stillEncrypted = typeof decrypted === 'string' && decrypted.startsWith(ENC_PREFIX);
    console.warn(`🔓 IMAP /inspect requested by ${req.platformUser?.email || '?'} for inbox ${row.name || row.id}`);
    ok(res, {
      _warning: 'This response contains the cleartext IMAP password. Do not share or log it.',
      id: row.id,
      name: row.name,
      imap_host: row.imap_host,
      imap_port: row.imap_port,
      imap_username: row.imap_username,
      stored_format: isLegacyEncrypted ? 'legacy_encrypted' : 'plaintext',
      can_decrypt: !stillEncrypted,
      password_length: stillEncrypted ? null : String(decrypted || '').length,
      password: stillEncrypted ? null : decrypted,
      diagnostics: stillEncrypted ? null : {
        starts_with_mask: String(decrypted || '').startsWith('********'),
        has_leading_space: String(decrypted || '') !== String(decrypted || '').trimStart(),
        has_trailing_space: String(decrypted || '') !== String(decrypted || '').trimEnd(),
        has_newline: /[\r\n]/.test(String(decrypted || '')),
        ascii_only: /^[\x20-\x7e]*$/.test(String(decrypted || '')),
      },
    });
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/smtp-accounts/test', authMiddleware, async (req, res) => {
  let nodemailer;
  try { nodemailer = require('nodemailer'); } catch (_) { return err(res, 'nodemailer not installed', 500); }
  let resolvedPassword = null;          // captured for diagnostic on failure
  let resolvedPasswordSource = null;    // 'typed' | 'stored'
  try {
    let d = req.body || {};
    if (d.id) {
      const [[row]] = await pool.query('SELECT * FROM smtp_accounts WHERE id = ?', [d.id]);
      if (!row) return err(res, 'SMTP account not found', 404);
      // If the operator typed a fresh value, use it. Otherwise read the
      // stored row — decryptSecret() unwraps any leftover legacy
      // encrypted blobs and returns plaintext as-is.
      const userTypedPassword = d.password && d.password !== '********';
      if (userTypedPassword) {
        resolvedPassword = d.password;
        resolvedPasswordSource = 'typed';
      } else {
        resolvedPassword = decryptSecret(row.password);
        resolvedPasswordSource = 'stored';
        // If the row is still wrapped in `enc:v1:` after decryptSecret
        // (i.e. key mismatch), surface that as a clear actionable error.
        if (typeof resolvedPassword === 'string' && resolvedPassword.startsWith(ENC_PREFIX)) {
          return err(res, 'Stored credential is encrypted with a key this server can\'t read. Re-enter the password under Settings → SMTP and Save.', 400);
        }
      }
      d = {
        host: d.host || row.host,
        port: d.port || row.port,
        secure: d.secure != null ? d.secure : Number(row.secure) === 1,
        username: d.username || row.username,
        password: resolvedPassword,
      };
    } else {
      resolvedPassword = d.password;
      resolvedPasswordSource = 'typed-new-account';
    }
    const transporter = nodemailer.createTransport({
      host: d.host,
      port: Number(d.port) || 587,
      secure: d.secure === true,
      auth: { user: d.username, pass: d.password },
      tls: { rejectUnauthorized: false },
      connectionTimeout: 10_000,
    });
    await transporter.verify();
    ok(res, { success: true, message: 'SMTP connection verified' });
  } catch (e) {
    // Build a diagnostic envelope when SMTP rejects auth. We do NOT echo
    // the password — only its shape — so an admin can spot the common
    // failure modes (wrong length, leading mask leftover from an old UI
    // build, accidental whitespace, suspicious chars) without any
    // cleartext leaking into responses or logs.
    const pw = String(resolvedPassword || '');
    const masked = pw.length > 8 ? pw.slice(0, 2) + '…' + pw.slice(-2) : '***';
    const diag = {
      source: resolvedPasswordSource,
      length: pw.length,
      starts_with_mask: pw.startsWith('********'),
      has_leading_space: pw !== pw.trimStart(),
      has_trailing_space: pw !== pw.trimEnd(),
      has_newline: /[\r\n]/.test(pw),
      ascii_only: /^[\x20-\x7e]*$/.test(pw),
      first_last: masked,
    };
    console.warn('🔐 SMTP test failed — diagnostic:', diag, '— smtp error:', String(e.message || '').slice(0, 120));
    err(res, `${e.message || 'SMTP verify failed'} — server-side password diagnostic: ${JSON.stringify(diag)}`, 400);
  }
});

// Send an outbound email. Used both from the email detail pane (reply) and
// from the support ticket (customer / supplier outreach). Writes the sent
// message into `emails` with direction='outbound' so it threads with the
// inbox history.
//
// Rate limit: 30 sends per minute per user, 200 per hour. Both are
// generous enough that no human operator will hit them, but a buggy retry
// loop on the frontend (or a compromised account) can't blast the SMTP
// account into a blocklist. Standard headers expose the limit so the UI
// can show "slow down" toasts if needed.
// We key the limiter by authenticated user id (with IP as fallback). The
// `validate.keyGeneratorIpFallback: false` opt-out tells express-rate-limit
// not to scream about IP-based fallback paths — we know what we're doing
// and the warning was filling stderr at every login.
const _emailLimiterCommon = {
  standardHeaders: true,
  legacyHeaders: false,
  validate: { keyGeneratorIpFallback: false },
  keyGenerator: (req) => (req.platformUser?.id ? `u:${req.platformUser.id}` : `ip:${req.ip}`),
};
const emailSendLimiterMinute = rateLimit({
  ..._emailLimiterCommon,
  windowMs: 60 * 1000,
  max: 30,
  handler: (req, res) => {
    res.status(429).json({
      data: null,
      error: { message: 'Too many emails sent in the last minute. Wait ~30 seconds and try again.' }
    });
  },
});
const emailSendLimiterHour = rateLimit({
  ..._emailLimiterCommon,
  windowMs: 60 * 60 * 1000,
  max: 200,
  handler: (req, res) => {
    res.status(429).json({
      data: null,
      error: { message: 'Hourly send limit reached for this account. Contact an admin if this is a real bulk send.' }
    });
  },
});

app.post('/rest/v1/emails/send', authMiddleware, emailSendLimiterMinute, emailSendLimiterHour, async (req, res) => {
  let nodemailer;
  try { nodemailer = require('nodemailer'); } catch (_) { return err(res, 'nodemailer not installed', 500); }
  try {
    const user = req.platformUser;
    const d = req.body || {};
    if (!d.to || (Array.isArray(d.to) && d.to.length === 0)) return err(res, 'to is required', 400);
    const subject = d.subject || '(no subject)';
    // Always produce BOTH text and HTML. Most clients prefer HTML but every
    // serious MTA still wants the text/plain alternative for accessibility
    // and spam-scoring reasons. If the caller only supplied text, we derive
    // HTML from it; if they only supplied HTML, we derive plain text.
    let htmlBody = d.body_html || null;
    let textBody = d.body_text || (htmlBody ? htmlBody.replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>') : '');
    if (!textBody && !htmlBody) return err(res, 'body_text or body_html is required', 400);
    if (!htmlBody && textBody) htmlBody = textToHtml(textBody);

    // Scope-aware SMTP resolution. The composer has a From dropdown; the
    // user's pick (smtp_account_id) is validated against their allowed
    // list. If they didn't pick one, we fall back to their preferred and
    // then the scope chain (own → app → system).
    let account;
    try {
      account = await resolveSmtpForSend(user, d.smtp_account_id);
    } catch (e) {
      return err(res, e.message, e.status || 500);
    }
    if (!account) return err(res, 'No active SMTP account available. Add one in Settings → SMTP or Settings → Profile.', 400);

    // Persist the user's last pick as their preferred so the next send
    // defaults to the same account without them having to re-pick.
    if (d.smtp_account_id && account.id === d.smtp_account_id && account.id !== user.preferred_smtp_account_id) {
      await pool.query('UPDATE platform_users SET preferred_smtp_account_id = ? WHERE id = ?', [account.id, user.id]).catch(() => {});
    }

    const toList = Array.isArray(d.to) ? d.to : [d.to];
    const ccList = Array.isArray(d.cc) ? d.cc : d.cc ? [d.cc] : [];
    const fromAddress = account.from_name
      ? `"${account.from_name}" <${account.from_email}>`
      : account.from_email;

    // Threading: if replying to an existing email, pull the full ancestor
    // chain (not just the immediate parent) so the receiver's client can
    // walk it correctly. We also wrap every Message-ID in <...> form —
    // mailparser stores them bare, but RFC 5322 + Outlook need brackets.
    let inReplyTo = wrapMsgId(d.in_reply_to || null);
    let references = d.references || null;
    let replyToSubject = subject;
    // Outlook keys threading primarily off Thread-Topic (original subject
    // with Re:/Fwd: stripped). Without it, Outlook often splits the reply
    // into its own conversation. Derived from the parent when available.
    let threadTopic = null;
    let replyToBookingCode = d.booking_code || null;
    let replyToBookingId = d.booking_id || null;
    let replyToSupportId = d.support_request_id || null;
    if (d.reply_to_email_id) {
      const [[orig]] = await pool.query(
        `SELECT message_id, subject, booking_id, booking_code, support_request_id,
                thread_id, references_chain
         FROM emails WHERE id = ?`,
        [d.reply_to_email_id]
      );
      if (orig) {
        const origMsgId = wrapMsgId(orig.message_id);
        // In-Reply-To = the immediate parent's Message-ID.
        inReplyTo = inReplyTo || origMsgId || null;
        // References = parent's own References + parent's Message-ID.
        // This is what lets clients walk the full thread tree — not just
        // the direct parent-child link.
        references = references || buildReferencesChain(orig.references_chain, orig.message_id);
        if (!d.subject) {
          replyToSubject = orig.subject && !/^re:/i.test(orig.subject) ? `Re: ${orig.subject}` : (orig.subject || subject);
        }
        // Strip every Re:/Fwd: (any language prefix could be added later)
        // so Outlook sees a stable topic across the whole thread.
        threadTopic = (orig.subject || '').replace(/^(?:\s*(?:re|fw|fwd)\s*:\s*)+/i, '').trim() || null;
        replyToBookingCode = replyToBookingCode || orig.booking_code || null;
        replyToBookingId   = replyToBookingId   || orig.booking_id   || null;
        replyToSupportId   = replyToSupportId   || orig.support_request_id || null;
      }
    }

    // ── Attachments ────────────────────────────────────────────────────
    // Caller passes `attachments: [{filename, url|content, content_type}]`.
    //   - `url`     → server-side fetch (e.g. EasyGDS signed PDF). Must
    //                 be reachable from the box; usually the booking
    //                 product's `pdf_url`.
    //   - `content` → base64 from the client (e.g. file picker, future).
    // We resolve them in parallel before SMTP send so we either send ALL
    // attachments or none — no half-attached emails.
    const reqAttachments = Array.isArray(d.attachments) ? d.attachments : [];
    const resolvedAttachments = [];
    for (const a of reqAttachments) {
      if (!a || !a.filename) continue;
      try {
        if (a.content) {
          resolvedAttachments.push({
            filename: a.filename,
            content: Buffer.from(String(a.content), 'base64'),
            contentType: a.content_type || undefined,
          });
        } else if (a.url) {
          const r = await fetch(a.url);
          if (!r.ok) throw new Error(`HTTP ${r.status} fetching ${a.url}`);
          const buf = Buffer.from(await r.arrayBuffer());
          resolvedAttachments.push({
            filename: a.filename,
            content: buf,
            contentType: a.content_type || r.headers.get('content-type') || undefined,
          });
        }
      } catch (attErr) {
        return err(res, `Failed to load attachment "${a.filename}": ${attErr.message}`, 400);
      }
    }

    // Wrap the operator-edited body in our branded shell so the recipient
    // gets a proper transactional email, not a bare paragraph. The shell
    // is rendered server-side (not in the editor) so the user-facing
    // editor stays simple. `skip_shell: true` from the caller bypasses
    // wrapping (e.g. for replies that need to look like a clean reply).
    let finalHtml = htmlBody || undefined;
    // Resolve the booking's app_id once — used both for the shell brand
    // and for stamping the saved emails row so the multi-tenancy gate
    // works without requiring downstream joins.
    let bookingAppId = null;
    if (replyToBookingId || replyToBookingCode) {
      try {
        const [[bk]] = await pool.query(
          `SELECT app_id FROM bookings_cache
            WHERE id = ? OR code = ? LIMIT 1`,
          [replyToBookingId || '', replyToBookingCode || '']
        );
        bookingAppId = bk?.app_id || null;
      } catch { /* leave null, gate will treat as cross-tenant */ }
    }
    if (finalHtml && !d.skip_shell) {
      // Resolve brand details for the shell — name, accent colour, logo.
      // Title source priority: caller override (rare) > SMTP from_name
      // (= "who's actually sending") > app_settings.app_name > shell default.
      // The accent color + logo still come from the booking's app_settings.
      let appName = null;
      let accentColor = null;
      let logoUrl = null;
      if (bookingAppId) {
        try {
          const [[meta]] = await pool.query(
            'SELECT app_name, accent_color, logo_url FROM app_settings WHERE app_id = ?',
            [bookingAppId]
          );
          appName = meta?.app_name || null;
          accentColor = meta?.accent_color || null;
          logoUrl = meta?.logo_url || null;
        } catch { /* shell falls back to defaults */ }
      }
      const titleAppName = d.app_name_override || account.from_name || appName;
      finalHtml = wrapInEmailShell(finalHtml, {
        appName: titleAppName || undefined,
        accentColor: accentColor || undefined,
        logoUrl: logoUrl || undefined,
        bookingRef: replyToBookingCode || undefined,
        // Optional supplier-side reference and which ref(s) to render in
        // the header subtitle. Set by the booking-detail email composer
        // after the /render call returns shell_meta. Falls back to booking
        // when not supplied so direct sends still work.
        supplierRef: d.supplier_ref || undefined,
        refDisplay: d.ref_display || undefined,
      });
    }

    // Resolve the SMTP password. decryptSecret() returns plaintext as-is,
    // and unwraps any leftover legacy `enc:v1:` blob if a current key can
    // read it. If neither is true (an undecryptable legacy row), bail out
    // with an actionable message — the operator has to re-enter via the
    // SMTP settings page.
    const smtpPass = decryptSecret(account.password);
    if (typeof smtpPass === 'string' && smtpPass.startsWith(ENC_PREFIX)) {
      return err(res, 'Stored SMTP credential is encrypted with a key this server can\'t read. Re-enter the password under Settings → SMTP and Save.', 500);
    }
    const transporter = nodemailer.createTransport({
      host: account.host,
      port: account.port,
      secure: Number(account.secure) === 1,
      auth: { user: account.username, pass: smtpPass },
      tls: { rejectUnauthorized: false },
      connectionTimeout: 15_000,
    });

    const mail = {
      from: fromAddress,
      to: toList,
      cc: ccList.length ? ccList : undefined,
      subject: replyToSubject,
      text: textBody,
      html: finalHtml,
      inReplyTo: inReplyTo || undefined,
      references: references || undefined,
      // Thread-Topic is an Outlook/Exchange extension. Safe to set on any
      // send — other clients just ignore it.
      headers: threadTopic ? { 'Thread-Topic': threadTopic } : undefined,
      attachments: resolvedAttachments.length > 0 ? resolvedAttachments : undefined,
    };

    let info;
    try {
      info = await transporter.sendMail(mail);
      await pool.query('UPDATE smtp_accounts SET last_used_at = NOW(), last_error = NULL WHERE id = ?', [account.id]);
    } catch (sendErr) {
      await pool.query('UPDATE smtp_accounts SET last_used_at = NOW(), last_error = ? WHERE id = ?',
        [String(sendErr.message || sendErr).slice(0, 510), account.id]);
      throw sendErr;
    }

    // Persist the sent message to the emails table so the conversation shows
    // both sides in one thread. Save the exact References chain we sent so
    // if someone later replies to this reply, we can keep extending the
    // ancestry.
    // thread_id = the ROOT of References (first id), so every message in the
    // conversation shares the same grouping key. Falls back to the immediate
    // parent if no References were present, then to the email's own
    // Message-ID for brand-new conversations.
    const threadRoot = references
      ? (references.split(/\s+/).find(Boolean) || null)
      : (inReplyTo || info.messageId || null);
    // Pick a sensible category for the saved row. Caller can pass an
    // explicit one (the dialog flags supplier/customer); otherwise infer
    // from the linked supplier/booking.
    let category = (d.category && /^(customer|supplier|internal|unknown)$/.test(d.category)) ? d.category : null;
    if (!category) category = d.supplier_id ? 'supplier' : (replyToSupportId ? 'internal' : 'internal');

    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO emails
         (id, message_id, thread_id, in_reply_to, references_chain, direction, source,
          from_email, from_name, to_emails, cc_emails,
          subject, snippet, body_text, body_html,
          received_at, fetched_at, category,
          booking_id, booking_code, support_request_id, supplier_id, template_id, app_id,
          auto_linked, confidence, attachments_count, is_read)
       VALUES (?, ?, ?, ?, ?, 'outbound', 'smtp',
               ?, ?, ?, ?,
               ?, ?, ?, ?,
               NOW(), NOW(), ?,
               ?, ?, ?, ?, ?, ?,
               1, 1.0, ?, 1)`,
      [id, info.messageId || null, threadRoot, inReplyTo || null,
       references || null,
       account.from_email, account.from_name || null,
       JSON.stringify(toList), JSON.stringify(ccList),
       replyToSubject, textBody.slice(0, 497).replace(/\s+/g, ' ').trim(),
       textBody, finalHtml || htmlBody,   // store the shell-wrapped HTML so
                                          // the in-app thread viewer renders
                                          // the same as the recipient sees
       category,
       replyToBookingId, replyToBookingCode, replyToSupportId,
       d.supplier_id || null, d.template_id || null, bookingAppId || null,
       resolvedAttachments.length]
    );

    // Audit row per attachment so the inbox detail pane can show what
    // went out (e.g. paperclip count badge already in the UI). We only
    // store metadata — the binary lives in the recipient's mailbox.
    for (const a of resolvedAttachments) {
      try {
        await pool.query(
          `INSERT INTO email_attachments (id, email_id, filename, mime_type, size_bytes)
           VALUES (?, ?, ?, ?, ?)`,
          [crypto.randomUUID(), id, a.filename,
           a.contentType || null,
           a.content ? a.content.length : null]
        );
      } catch { /* non-fatal — the email is sent regardless */ }
    }

    ok(res, { email_id: id, message_id: info.messageId, accepted: info.accepted, rejected: info.rejected });
  } catch (e) { err(res, e.message || 'Send failed', 500); }
});


// ══════════════════════════════════════════════════════════════════════
// EMAIL INBOXES  (admin only — IMAP credentials the poller uses)
// ══════════════════════════════════════════════════════════════════════

const emailInboxToApi = (r) => r && ({
  id: r.id,
  name: r.name,
  description: r.description || '',
  imap_host: r.imap_host,
  imap_port: r.imap_port,
  imap_username: r.imap_username,
  // Never send the password back to the client.
  imap_password: r.imap_password ? '********' : '',
  imap_use_ssl: Number(r.imap_use_ssl) === 1,
  auto_process: Number(r.auto_process) === 1,
  check_interval: r.check_interval,
  is_active: Number(r.is_active) === 1,
  owner_type: r.owner_type || 'system',
  owner_id: r.owner_id || null,
  display_label: r.display_label || null,
  last_polled_at: r.last_polled_at,
  last_error: r.last_error,
  created_at: r.created_at,
  updated_at: r.updated_at,
});

app.get('/api/email-inboxes', authMiddleware, async (req, res) => {
  try {
    const rows = await listInboxesForUser(req.platformUser, { activeOnly: false });
    ok(res, rows.map(emailInboxToApi));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/email-inboxes', authMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    if (!d.name || !d.imap_host || !d.imap_username || !d.imap_password) {
      return err(res, 'name, imap_host, imap_username, imap_password are required', 400);
    }
    let ownership;
    try { ownership = resolveOwnership(req.platformUser, d); }
    catch (e) { return err(res, e.message, e.status || 400); }
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO email_inboxes (id, name, description, imap_host, imap_port, imap_username, imap_password,
         imap_use_ssl, auto_process, check_interval, is_active,
         owner_type, owner_id, display_label)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, d.name, d.description || null, d.imap_host,
       d.imap_port != null ? Number(d.imap_port) : 993,
       d.imap_username, encryptSecret(d.imap_password),
       d.imap_use_ssl === false ? 0 : 1,
       d.auto_process === false ? 0 : 1,
       d.check_interval != null ? Number(d.check_interval) : 5,
       d.is_active ? 1 : 0,
       ownership.owner_type, ownership.owner_id,
       d.display_label || null]
    );
    const [[row]] = await pool.query('SELECT * FROM email_inboxes WHERE id = ?', [id]);
    ok(res, emailInboxToApi(row));
  } catch (e) {
    if (e.code === 'ER_DUP_ENTRY') {
      return err(res, 'An inbox with this host + username already exists', 409);
    }
    err(res, e.message, 500);
  }
});

app.patch('/api/email-inboxes/:id', authMiddleware, async (req, res) => {
  try {
    if (!await canUserManageOwnedRow(req.platformUser, 'email_inboxes', req.params.id)) {
      return err(res, 'Forbidden — you can only edit your own inboxes', 403);
    }
    const d = req.body || {};
    const allowed = ['name', 'description', 'imap_host', 'imap_port', 'imap_username',
                     'imap_password', 'imap_use_ssl', 'auto_process', 'check_interval', 'is_active', 'display_label'];
    const sets = [];
    const params = [];
    for (const k of allowed) {
      if (!(k in d)) continue;
      // Empty / legacy `********` / null = "keep current password". We
      // also strip a leading `********` mask defensively — an old frontend
      // build that still pre-fills the input would otherwise save
      // `********RealPassword` and fail IMAP auth on every poll forever.
      if (k === 'imap_password') {
        if (d[k] === '********' || d[k] === '' || d[k] == null) continue;
        let cleaned = String(d[k]).trim();
        if (cleaned.startsWith('********')) {
          console.warn('⚠️  IMAP password update arrived with a leading `********` mask — stripping. Old UI build cached on client?');
          cleaned = cleaned.replace(/^\*+/, '').trim();
        }
        if (cleaned === '') continue;
        sets.push('imap_password = ?');
        params.push(encryptSecret(cleaned));
        continue;
      }
      sets.push(`${k} = ?`);
      params.push(['imap_use_ssl', 'auto_process', 'is_active'].includes(k) ? (d[k] ? 1 : 0) : d[k]);
    }
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE email_inboxes SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM email_inboxes WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Not found', 404);
    ok(res, emailInboxToApi(row));
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/email-inboxes/:id', authMiddleware, async (req, res) => {
  try {
    if (!await canUserManageOwnedRow(req.platformUser, 'email_inboxes', req.params.id)) {
      return err(res, 'Forbidden — you can only delete your own inboxes', 403);
    }
    const id = req.params.id;
    // Order of operations:
    //   1. Null-out inbox_id on emails so the LEFT JOIN doesn't surface the
    //      deleted account name; the emails themselves stay (they're still
    //      part of conversations and ticket history).
    //   2. Delete the inbox row.
    //   3. Drop the in-memory next-run timestamp so a freshly added inbox
    //      with the same id wouldn't inherit a stale "wait until X" entry.
    await pool.query('UPDATE emails SET inbox_id = NULL WHERE inbox_id = ?', [id]);
    await pool.query('DELETE FROM email_inboxes WHERE id = ?', [id]);
    _inboxPollState.delete(id);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// Test-connection: minimal IMAP LOGIN handshake using Node's built-in `tls`
// and `net` modules — no extra deps. Good enough to validate host + creds.
// If `id` is provided we load the stored password from the row so the admin
// doesn't have to re-type it.
app.post('/api/email-inboxes/test', authMiddleware, async (req, res) => {
  const tls = require('tls');
  const net = require('net');
  try {
    let d = req.body || {};
    if (d.id) {
      // Test against an existing row — caller must be allowed to manage it.
      if (!await canUserManageOwnedRow(req.platformUser, 'email_inboxes', d.id)) {
        return err(res, 'Forbidden — you can only test your own inboxes', 403);
      }
      const [[row]] = await pool.query('SELECT * FROM email_inboxes WHERE id = ?', [d.id]);
      if (!row) return err(res, 'Inbox not found', 404);
      const userTypedPassword = d.imap_password && d.imap_password !== '********';
      const resolvedPassword = userTypedPassword
        ? d.imap_password
        : decryptSecret(row.imap_password);
      // Catch leftover encrypted blob from a key-mismatched legacy row.
      if (!userTypedPassword && typeof resolvedPassword === 'string' && resolvedPassword.startsWith(ENC_PREFIX)) {
        return err(res, 'Stored credential is encrypted with a key this server can\'t read. Re-enter the password under Settings → Inbox and Save.', 400);
      }
      d = { ...d,
        imap_host: d.imap_host || row.imap_host,
        imap_port: d.imap_port || row.imap_port,
        imap_username: d.imap_username || row.imap_username,
        imap_password: resolvedPassword,
        imap_use_ssl: d.imap_use_ssl != null ? d.imap_use_ssl : Number(row.imap_use_ssl) === 1,
      };
    }
    const { imap_host, imap_port, imap_username, imap_password, imap_use_ssl } = d;
    if (!imap_host || !imap_username || !imap_password) {
      return err(res, 'imap_host, imap_username and imap_password are required', 400);
    }
    const port = Number(imap_port) || (imap_use_ssl ? 993 : 143);
    const useSsl = imap_use_ssl !== false;

    const result = await new Promise((resolve) => {
      let settled = false;
      const done = (ok, message) => { if (!settled) { settled = true; try { socket.destroy(); } catch (_) {} resolve({ ok, message }); } };
      const timeout = setTimeout(() => done(false, 'Timed out connecting to IMAP server'), 10000);
      const socket = useSsl
        ? tls.connect({ host: imap_host, port, servername: imap_host, rejectUnauthorized: false })
        : net.createConnection({ host: imap_host, port });
      let buf = '';
      let tag = 0;
      let state = 'wait_banner';
      const nextTag = () => `a${++tag}`;
      const send = (line) => { try { socket.write(line + '\r\n'); } catch (_) {} };
      const onData = (chunk) => {
        buf += chunk.toString('utf8');
        if (state === 'wait_banner' && buf.includes('\r\n')) {
          if (!/^\*\s+(OK|PREAUTH)/i.test(buf)) return done(false, 'Server did not send an IMAP OK banner: ' + buf.slice(0, 200));
          state = 'wait_login';
          buf = '';
          const user = String(imap_username).replace(/"/g, '\\"');
          const pass = String(imap_password).replace(/"/g, '\\"');
          send(`${nextTag()} LOGIN "${user}" "${pass}"`);
          return;
        }
        if (state === 'wait_login') {
          if (/^a\d+\s+OK/im.test(buf)) return done(true, 'IMAP LOGIN succeeded');
          if (/^a\d+\s+(NO|BAD)/im.test(buf)) return done(false, 'IMAP LOGIN rejected: ' + buf.split('\r\n').find(l => /^a\d+/.test(l)));
        }
      };
      socket.on('data', onData);
      socket.on('error', (e) => done(false, e.message));
      socket.on('close', () => done(false, 'Connection closed before login completed'));
      socket.once(useSsl ? 'secureConnect' : 'connect', () => { clearTimeout(timeout); });
    });

    if (result.ok) ok(res, { success: true, message: result.message });
    else err(res, result.message, 400);
  } catch (e) { err(res, e.message, 500); }
});


// ══════════════════════════════════════════════════════════════════════
// SUPPLIER SETUP  (admin only — runs the email routing + extraction brain)
// ══════════════════════════════════════════════════════════════════════

// NOTE: The previous `POST /api/suppliers/sync` endpoint (which pulled the
// upstream EasyGDS supplier catalogue) was removed at the user's request —
// the catalogue contained generic provider IDs that didn't match the supplier
// codes booking products actually carry. Suppliers are now sourced exclusively
// from `POST /api/suppliers/discover-from-bookings` (below) plus manual edits.

// Single-flight flag for runSupplierAutoDiscovery — set when a background
// auto-discovery is already in progress (e.g. triggered by an Email
// Supplier dialog hitting an empty suppliers table). Prevents pile-up
// when a second dialog opens before the first scan completes.
let _supplierAutoDiscoveryRunning = false;

// Pure logic — extracted so both the admin endpoint and the auto-trigger
// in /api/bookings/:codeOrId/suppliers can share the same scanner. Returns
// `{ scanned, products_scanned, distinct_codes_found, inserted, already_existed }`.
async function runSupplierAutoDiscovery() {
  const [rows] = await pool.query('SELECT raw FROM bookings_cache WHERE raw IS NOT NULL');

  const looksCanonical = (s) => {
    if (!s) return false;
    if (s.length <= 8) return true;
    return /[A-Z]/.test(s);
  };

  const seen = new Set();
  const discovered = [];
  let scanned = 0;
  let productsScanned = 0;
  for (const row of rows) {
    scanned++;
    let raw = row.raw;
    if (typeof raw === 'string') {
      try { raw = JSON.parse(raw); } catch { continue; }
    }
    const products = Array.isArray(raw?.products) ? raw.products : [];
    for (const p of products) {
      productsScanned++;
      const d = p?.data || {};
      const candidates = [];
      const rooms = Array.isArray(d.selected_rooms) ? d.selected_rooms : [];
      for (const room of rooms) {
        if (room?.rate?.source_id) candidates.push(room.rate.source_id);
        if (room?.rate?.supplier_code) candidates.push(room.rate.supplier_code);
        if (room?.supplier_code) candidates.push(room.supplier_code);
        if (room?.supplier_source) candidates.push(room.supplier_source);
      }
      if (d.supplier_code) candidates.push(d.supplier_code);
      if (d.supplier_source) candidates.push(d.supplier_source);
      if (d.provider_code) candidates.push(d.provider_code);

      for (const c of candidates) {
        if (typeof c !== 'string') continue;
        const key = c.trim();
        if (!key) continue;
        if (key === 'MGJarvis') continue;
        if (!looksCanonical(key)) continue;
        if (seen.has(key)) continue;
        seen.add(key);
        discovered.push(key);
      }
    }
  }

  if (discovered.length === 0) {
    return { scanned, products_scanned: productsScanned, distinct_codes_found: 0, inserted: 0, already_existed: 0 };
  }

  const placeholders = discovered.map(() => '?').join(',');
  const [existingRows] = await pool.query(
    `SELECT id, code FROM suppliers WHERE code IN (${placeholders}) OR id IN (${placeholders})`,
    [...discovered, ...discovered]
  );
  const existingKeys = new Set();
  for (const r of existingRows) {
    if (r.code) existingKeys.add(r.code);
    if (r.id)   existingKeys.add(r.id);
  }

  let inserted = 0;
  for (const code of discovered) {
    if (existingKeys.has(code)) continue;
    const id = `discovered:${code}`;
    try {
      await pool.query(
        `INSERT INTO suppliers (id, source_id, name, code, is_active, source, notes, synced_at)
         VALUES (?, NULL, NULL, ?, 1, 'from_bookings', ?, NOW())`,
        [id, code, `Auto-discovered from bookings on ${new Date().toISOString().slice(0, 10)}. Rename and add contacts to complete setup.`]
      );
      inserted++;
    } catch (e) {
      if (!/Duplicate/i.test(e.message)) throw e;
    }
  }

  return {
    scanned,
    products_scanned: productsScanned,
    distinct_codes_found: discovered.length,
    inserted,
    already_existed: discovered.length - inserted,
  };
}

// Discover suppliers from cached bookings.
//   Walks every row in `bookings_cache`, parses `raw.products[].data.{supplier_code |
//   supplier_source | provider_code}`, and inserts any code that doesn't already
//   exist in `suppliers` as a NEW row with `source='from_bookings'`, `name=NULL`
//   (UI falls back to showing the code). Existing rows are NEVER touched — so
//   you can safely re-run this any time without losing edited names / contacts.
//
//   Why local-only: each cached booking already carries the full upstream JSON
//   (including the products array) in `bookings_cache.raw`, so this scan needs
//   ZERO upstream calls. Even with 17 000+ bookings it's fast (a single SELECT
//   + in-process JSON parse).
app.post('/api/suppliers/discover-from-bookings', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const summary = await runSupplierAutoDiscovery();
    ok(res, summary);
  } catch (e) { err(res, e.message, 500); }
});

// List — admin only. LEFT JOIN against the merge target so each row also
// carries the canonical name + code without an extra round-trip; the UI
// uses these to show "→ Hotelbeds" indicators on linked variants and to
// resolve booking-supplier names through the link.
//
// Also exposes `resolved_primary_email` / `resolved_primary_phone` —
// pulled from `supplier_contacts` of THIS row's effective canonical
// (`merged_into_id` if linked, else self). Lets the Bookings detail page
// display the right contact info regardless of which variant code a
// booking happens to carry.
app.get('/api/suppliers', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Sort order:
    //   1. Named suppliers first (those that have a friendly name set), A-Z
    //      by name. Operators set up these rows and will look for them by
    //      name, so they get top billing.
    //   2. Then unnamed (just-discovered, no name yet), A-Z by code so the
    //      "from bookings" stragglers are still browsable in alphabetical
    //      order.
    //
    // The CASE expression turns the named/unnamed split into a 0/1 group
    // key — sorted ascending puts named (0) first.
    const [rows] = await pool.query(`
      SELECT s.*,
        m.name AS merged_into_name,
        m.code AS merged_into_code,
        (SELECT value FROM supplier_contacts c
         WHERE c.supplier_id = COALESCE(s.merged_into_id, s.id)
           AND c.type = 'email'
         ORDER BY c.is_primary DESC, c.created_at ASC LIMIT 1) AS resolved_primary_email,
        (SELECT value FROM supplier_contacts c
         WHERE c.supplier_id = COALESCE(s.merged_into_id, s.id)
           AND c.type = 'phone'
         ORDER BY c.is_primary DESC, c.created_at ASC LIMIT 1) AS resolved_primary_phone,
        (SELECT COUNT(*) FROM supplier_contacts       WHERE supplier_id = s.id) AS contacts_count,
        (SELECT COUNT(*) FROM supplier_email_rules    WHERE supplier_id = s.id) AS email_rules_count,
        (SELECT COUNT(*) FROM supplier_extraction_rules WHERE supplier_id = s.id) AS extraction_rules_count,
        (SELECT COUNT(*) FROM suppliers v WHERE v.merged_into_id = s.id) AS variants_count
      FROM suppliers s
      LEFT JOIN suppliers m ON s.merged_into_id = m.id
      ORDER BY
        CASE WHEN s.name IS NULL OR TRIM(s.name) = '' THEN 1 ELSE 0 END ASC,
        s.name ASC,
        s.code ASC
    `);
    ok(res, rows);
  } catch (e) { err(res, e.message, 500); }
});

app.get('/api/suppliers/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Same LEFT JOIN as the list, so the detail view knows whether this row
    // is a linked variant and can show the canonical name in a banner. The
    // resolved_* fields pull primary contacts from the effective canonical
    // (this row, or the merge target if linked).
    const [[s]] = await pool.query(`
      SELECT s.*,
        m.name AS merged_into_name,
        m.code AS merged_into_code,
        (SELECT value FROM supplier_contacts c
         WHERE c.supplier_id = COALESCE(s.merged_into_id, s.id)
           AND c.type = 'email'
         ORDER BY c.is_primary DESC, c.created_at ASC LIMIT 1) AS resolved_primary_email,
        (SELECT value FROM supplier_contacts c
         WHERE c.supplier_id = COALESCE(s.merged_into_id, s.id)
           AND c.type = 'phone'
         ORDER BY c.is_primary DESC, c.created_at ASC LIMIT 1) AS resolved_primary_phone
      FROM suppliers s
      LEFT JOIN suppliers m ON s.merged_into_id = m.id
      WHERE s.id = ?
    `, [req.params.id]);
    if (!s) return err(res, 'Not found', 404);
    const [contacts] = await pool.query('SELECT * FROM supplier_contacts WHERE supplier_id = ? ORDER BY is_primary DESC, type ASC, label ASC', [req.params.id]);
    const [emailRules] = await pool.query('SELECT * FROM supplier_email_rules WHERE supplier_id = ? ORDER BY priority ASC, id', [req.params.id]);
    const [extractionRules] = await pool.query('SELECT * FROM supplier_extraction_rules WHERE supplier_id = ? ORDER BY field_name ASC, priority ASC', [req.params.id]);
    // Variants — every other supplier that points at this row as its canonical.
    // Only meaningful for canonicals; an empty array on linked variants is
    // fine (a variant can never have variants of its own per server guards).
    // Includes the same convenience fields the list endpoint uses so the UI
    // can render them with one query result.
    const [variants] = await pool.query(`
      SELECT s.id, s.code, s.name, s.source, s.is_active,
        (SELECT COUNT(*) FROM supplier_contacts WHERE supplier_id = s.id) AS contacts_count
      FROM suppliers s
      WHERE s.merged_into_id = ?
      ORDER BY s.code ASC
    `, [req.params.id]);
    ok(res, { ...s, contacts, email_rules: emailRules, extraction_rules: extractionRules, variants });
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/suppliers/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const allowed = ['name', 'code', 'is_active', 'primary_email', 'primary_phone', 'notes'];
    const sets = [];
    const params = [];
    for (const k of allowed) if (k in d) { sets.push(`${k} = ?`); params.push(d[k]); }
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE suppliers SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[s]] = await pool.query('SELECT * FROM suppliers WHERE id = ?', [req.params.id]);
    ok(res, s);
  } catch (e) { err(res, e.message, 500); }
});

// Link this supplier as a variant of another canonical supplier.
//   target_id (body) — the canonical row this becomes a variant of.
// Refuses:
//   - linking to self
//   - linking to a target that doesn't exist
//   - linking to a target that is itself already linked (no chains/cycles —
//     keeps display resolution simple: one hop, no recursion)
//   - linking a supplier that already has variants pointing at it (would
//     orphan those variants by making this row a non-canonical pointer)
app.post('/api/suppliers/:id/link', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const sourceId = req.params.id;
    const targetId = (req.body || {}).target_id;
    if (!targetId) return err(res, 'target_id required', 400);
    if (targetId === sourceId) return err(res, "A supplier can't be linked to itself", 400);

    const [[source]] = await pool.query('SELECT id, name, code FROM suppliers WHERE id = ?', [sourceId]);
    if (!source) return err(res, 'Supplier not found', 404);

    const [[target]] = await pool.query('SELECT id, name, code, merged_into_id FROM suppliers WHERE id = ?', [targetId]);
    if (!target) return err(res, 'Target supplier not found', 404);
    if (target.merged_into_id) {
      return err(res, 'Target is itself a linked variant — pick its canonical instead', 400);
    }

    const [[varCount]] = await pool.query(
      'SELECT COUNT(*) AS n FROM suppliers WHERE merged_into_id = ?', [sourceId]
    );
    if (varCount.n > 0) {
      return err(res, `Can't link this supplier — ${varCount.n} other supplier(s) point at it as their canonical. Unlink them first.`, 400);
    }

    await pool.query('UPDATE suppliers SET merged_into_id = ? WHERE id = ?', [targetId, sourceId]);
    const [[updated]] = await pool.query(`
      SELECT s.*, m.name AS merged_into_name, m.code AS merged_into_code
      FROM suppliers s LEFT JOIN suppliers m ON s.merged_into_id = m.id
      WHERE s.id = ?
    `, [sourceId]);
    ok(res, updated);
  } catch (e) { err(res, e.message, 500); }
});

// Clear the merge link — this supplier becomes standalone again. Its own
// name (if it had one) re-asserts itself in the UI.
app.post('/api/suppliers/:id/unlink', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [[s]] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [req.params.id]);
    if (!s) return err(res, 'Supplier not found', 404);
    await pool.query('UPDATE suppliers SET merged_into_id = NULL WHERE id = ?', [req.params.id]);
    const [[updated]] = await pool.query('SELECT * FROM suppliers WHERE id = ?', [req.params.id]);
    ok(res, updated);
  } catch (e) { err(res, e.message, 500); }
});

// ── Contacts (emails + phones) ─────────────────────────────────────────

app.post('/api/suppliers/:id/contacts', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    if (!['email', 'phone'].includes(d.type)) return err(res, 'type must be email or phone', 400);
    if (!d.value || !d.value.trim()) return err(res, 'value required', 400);
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO supplier_contacts (id, supplier_id, type, value, label, is_primary)
       VALUES (?, ?, ?, ?, ?, ?)`,
      [id, req.params.id, d.type, d.value.trim(), d.label || null, d.is_primary ? 1 : 0]
    );
    const [[row]] = await pool.query('SELECT * FROM supplier_contacts WHERE id = ?', [id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/supplier-contacts/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const allowed = ['value', 'label', 'is_primary'];
    const sets = [];
    const params = [];
    for (const k of allowed) if (k in d) { sets.push(`${k} = ?`); params.push(k === 'is_primary' ? (d[k] ? 1 : 0) : d[k]); }
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE supplier_contacts SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM supplier_contacts WHERE id = ?', [req.params.id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/supplier-contacts/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    await pool.query('DELETE FROM supplier_contacts WHERE id = ?', [req.params.id]);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// ── Email routing rules ────────────────────────────────────────────────

app.post('/api/suppliers/:id/email-rules', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    if (!['exact_email', 'domain_wildcard', 'subject_regex', 'body_regex'].includes(d.rule_type)) {
      return err(res, 'rule_type must be exact_email | domain_wildcard | subject_regex | body_regex', 400);
    }
    if (!d.pattern || !d.pattern.trim()) return err(res, 'pattern required', 400);
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO supplier_email_rules (id, supplier_id, rule_type, pattern, priority, is_active, notes)
       VALUES (?, ?, ?, ?, ?, ?, ?)`,
      [id, req.params.id, d.rule_type, d.pattern.trim(),
       d.priority != null ? Number(d.priority) : 100,
       d.is_active === false ? 0 : 1,
       d.notes || null]
    );
    const [[row]] = await pool.query('SELECT * FROM supplier_email_rules WHERE id = ?', [id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/supplier-email-rules/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const allowed = ['rule_type', 'pattern', 'priority', 'is_active', 'notes'];
    const sets = [];
    const params = [];
    for (const k of allowed) if (k in d) {
      sets.push(`${k} = ?`);
      params.push(k === 'is_active' ? (d[k] ? 1 : 0) : d[k]);
    }
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE supplier_email_rules SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM supplier_email_rules WHERE id = ?', [req.params.id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/supplier-email-rules/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    await pool.query('DELETE FROM supplier_email_rules WHERE id = ?', [req.params.id]);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// ── Extraction rules ───────────────────────────────────────────────────

app.post('/api/suppliers/:id/extraction-rules', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    if (!d.field_name) return err(res, 'field_name required', 400);
    if (!d.pattern || !d.pattern.trim()) return err(res, 'pattern required', 400);
    if (!['subject', 'body', 'headers'].includes(d.source || 'body')) {
      return err(res, 'source must be subject | body | headers', 400);
    }
    const id = crypto.randomUUID();
    // Allow supplier_id = null for global defaults, controlled by the route;
    // this endpoint is per-supplier so we always stamp with req.params.id.
    await pool.query(
      `INSERT INTO supplier_extraction_rules
         (id, supplier_id, field_name, source, pattern, fallback_value, priority, is_active, notes)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, req.params.id, d.field_name, d.source || 'body', d.pattern.trim(),
       d.fallback_value || null,
       d.priority != null ? Number(d.priority) : 100,
       d.is_active === false ? 0 : 1,
       d.notes || null]
    );
    const [[row]] = await pool.query('SELECT * FROM supplier_extraction_rules WHERE id = ?', [id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/supplier-extraction-rules/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const allowed = ['field_name', 'source', 'pattern', 'fallback_value', 'priority', 'is_active', 'notes'];
    const sets = [];
    const params = [];
    for (const k of allowed) if (k in d) {
      sets.push(`${k} = ?`);
      params.push(k === 'is_active' ? (d[k] ? 1 : 0) : d[k]);
    }
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE supplier_extraction_rules SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM supplier_extraction_rules WHERE id = ?', [req.params.id]);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/supplier-extraction-rules/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    await pool.query('DELETE FROM supplier_extraction_rules WHERE id = ?', [req.params.id]);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════════════════════════════════
// SUPPLIER BALANCE — endpoints (Ops-only)
// ════════════════════════════════════════════════════════════════════════
//
// All read endpoints gated by `requireOps`. The supplier ledger is
// platform-wide finance data — App and Agency users have no business
// seeing it. The frontend hook also short-circuits via canSeeAdminTabs
// so the page never renders for non-Ops, but the server-side gate is the
// authoritative one.
//
// The cache (supplier_transactions_cache) refreshes every 10 min; reads
// here aggregate it on the fly. Add Top-Up triggers a foreground re-sync
// so the row appears immediately without waiting for the next tick.

// NOTE: routes live at `/api/supplier-balances` (not `/api/suppliers/balances`)
// because Express matches `/api/suppliers/:id` (registered earlier) first
// and would 404 with id='balances'. Hyphenated path side-steps the
// collision without restructuring the existing supplier endpoints.

// GET /api/supplier-balances?currency=USD&owner=ops|<app_id>|all&breakdown=owner
//   Lists every supplier with at least one transaction. Returns one row
//   per (supplier × currency), or one row per (supplier × currency × owner)
//   when `breakdown=owner` is set. Ops dashboard.
//
//   owner filter (mutually exclusive with breakdown=owner):
//     - undefined / 'all'  → no filter, aggregate across all owners (legacy
//                            behavior — keeps existing UI working)
//     - 'ops'              → only rows where owner_app_id IS NULL
//     - '<app_id>'         → only rows where owner_app_id = <app_id>
//
//   breakdown=owner:
//     Adds owner_app_id to GROUP BY and the response. The "Statement view"
//     selector on the Suppliers Balance page uses this to render one row
//     per (supplier, currency, owner) so each statement is visible
//     standalone. Per-contract overrides (credit_limit / threshold /
//     settlement_currency / account_type) are joined in via supplier_contracts
//     and reported alongside the supplier-level defaults — `effective_*`
//     fields contain the resolved value the UI should display.
app.get('/api/supplier-balances', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const where = [];
    const params = [];
    if (req.query.currency) { where.push('t.currency = ?'); params.push(req.query.currency); }
    // Baseline filter: when a supplier has `balance_baseline_date` set, only
    // count transactions on/after that date. Lets ops "reset" a supplier's
    // ledger to zero after a manual reconciliation without losing history.
    where.push('(s.balance_baseline_date IS NULL OR t.txn_date >= s.balance_baseline_date)');

    // Owner filtering (phase 3 of per-app statements). 'ops' → NULL only;
    // 'all' or absent → no filter; otherwise treat as an app id.
    const ownerFilter = typeof req.query.owner === 'string' ? req.query.owner : null;
    if (ownerFilter === 'ops') {
      where.push('t.owner_app_id IS NULL');
    } else if (ownerFilter && ownerFilter !== 'all') {
      where.push('t.owner_app_id = ?');
      params.push(ownerFilter);
    }

    const breakdown = req.query.breakdown === 'owner';
    const whereSql = `WHERE ${where.join(' AND ')}`;

    // When breakdown=owner is on we also LEFT JOIN supplier_contracts to
    // pick up per-contract financial overrides (credit_limit, threshold,
    // settlement currency, account type). The JOIN uses owner_app_id
    // matching (NULL-safe via the special predicate) and the latest
    // contract whose effective_from is ≤ today (or NULL = open-ended).
    const breakdownSelect = breakdown ? `, t.owner_app_id,
         (SELECT id FROM supplier_contracts c
           WHERE c.is_active = 1
             AND c.supplier_id = t.supplier_id
             AND ((c.owner_app_id IS NULL AND t.owner_app_id IS NULL) OR c.owner_app_id = t.owner_app_id)
             AND (c.effective_from IS NULL OR c.effective_from <= CURDATE())
             AND (c.effective_to   IS NULL OR c.effective_to   >= CURDATE())
           ORDER BY effective_from DESC LIMIT 1) AS contract_id` : '';
    const breakdownGroup = breakdown ? ', t.owner_app_id' : '';

    // Aggregate per (supplier_id, currency [, owner_app_id]). Bookings
    // contribute as a debit (we owe), refunds and topups as credits.
    //   net_owed = bookings − refunds − topups
    //     > 0 → we still owe supplier
    //     0   → settled
    //     < 0 → we overpaid / supplier holds our deposit
    const [rows] = await pool.query(
      `SELECT
         t.supplier_id,
         t.currency,
         COALESCE(s.name, s.code, t.supplier_id) AS supplier_name,
         s.code AS supplier_code,
         s.account_type,
         s.critical_threshold,
         s.credit_limit,
         s.default_settlement_currency,
         s.settlement_currencies,
         s.balance_baseline_date,
         (SELECT GROUP_CONCAT(DISTINCT v.code ORDER BY v.code SEPARATOR ',')
            FROM suppliers v
            WHERE v.merged_into_id = t.supplier_id
               OR v.id = t.supplier_id
         ) AS all_codes,
         SUM(CASE WHEN t.type = 'booking' THEN t.amount ELSE 0 END) AS bookings_amount,
         SUM(CASE WHEN t.type = 'booking' THEN 1 ELSE 0 END) AS bookings_count,
         SUM(CASE WHEN t.type = 'refund'  THEN t.amount ELSE 0 END) AS refunds_amount,
         SUM(CASE WHEN t.type = 'refund'  THEN 1 ELSE 0 END) AS refunds_count,
         SUM(CASE WHEN t.type = 'topup'   THEN t.amount ELSE 0 END) AS topups_amount,
         SUM(CASE WHEN t.type = 'topup'   THEN 1 ELSE 0 END) AS topups_count,
         (SUM(CASE WHEN t.type = 'booking' THEN t.amount ELSE 0 END)
          - SUM(CASE WHEN t.type = 'refund' THEN t.amount ELSE 0 END)
          - SUM(CASE WHEN t.type = 'topup'  THEN t.amount ELSE 0 END)
         ) AS net_owed,
         MAX(t.txn_date) AS last_activity
         ${breakdownSelect}
       FROM supplier_transactions_cache t
       LEFT JOIN suppliers s ON s.id = t.supplier_id
       ${whereSql}
       GROUP BY t.supplier_id, t.currency, s.name, s.code, s.account_type,
                s.critical_threshold, s.credit_limit, s.default_settlement_currency,
                s.settlement_currencies, s.balance_baseline_date
                ${breakdownGroup}
       ORDER BY supplier_name ASC, t.currency ASC ${breakdown ? ', t.owner_app_id IS NULL DESC, t.owner_app_id ASC' : ''}`,
      params
    );

    // When breakdown=owner is on, load the matched contracts in a second
    // batch (we already have the ids) so each row's effective limits and
    // currency reflect the per-contract override when present, and the
    // supplier-level defaults otherwise.
    const contractIds = breakdown ? rows.map(r => r.contract_id).filter(Boolean) : [];
    const contractsById = new Map();
    if (contractIds.length > 0) {
      const placeholders = contractIds.map(() => '?').join(',');
      const [contractRows] = await pool.query(
        `SELECT id, account_type, credit_limit, critical_threshold, settlement_currency, contract_label
           FROM supplier_contracts WHERE id IN (${placeholders})`,
        contractIds
      );
      for (const c of contractRows) contractsById.set(c.id, c);
    }

    ok(res, {
      total: rows.length,
      breakdown: breakdown ? 'owner' : null,
      result: rows.map(r => {
        let sc = r.settlement_currencies;
        if (typeof sc === 'string') { try { sc = JSON.parse(sc); } catch { sc = null; } }

        // Resolve effective financials. Contract override > supplier
        // default. NULL on the contract = inherit the supplier-level
        // value. Only meaningful when breakdown=owner is on.
        const contract = breakdown && r.contract_id ? contractsById.get(r.contract_id) : null;
        const accountType = (contract && contract.account_type) || r.account_type || 'deposit';
        const threshold = contract && contract.critical_threshold != null
          ? Number(contract.critical_threshold)
          : (r.critical_threshold != null ? Number(r.critical_threshold) : null);
        const creditLimit = contract && contract.credit_limit != null
          ? Number(contract.credit_limit)
          : (r.credit_limit != null ? Number(r.credit_limit) : null);
        const effectiveCurrency = (contract && contract.settlement_currency)
          || r.default_settlement_currency
          || 'USD';

        const netOwed = Number(r.net_owed) || 0;
        // is_critical is computed only for THIS currency row. The cross-currency
        // aggregate (used for the email alert) lives in checkSupplierCriticalThresholds.
        let isCritical = false;
        if (threshold != null) {
          if (accountType === 'deposit') {
            // remaining_deposit = -net_owed (topups - bookings + refunds in this ccy)
            isCritical = (-netOwed) <= threshold;
          } else {
            // credit: outstanding_debt = net_owed
            isCritical = netOwed >= threshold;
          }
        }
        return {
          supplier_id: r.supplier_id,
          supplier_name: r.supplier_name,
          supplier_code: r.supplier_code,
          all_codes: r.all_codes ? r.all_codes.split(',') : [],
          currency: r.currency,
          bookings_count: Number(r.bookings_count) || 0,
          bookings_amount: Number(r.bookings_amount) || 0,
          refunds_count: Number(r.refunds_count) || 0,
          refunds_amount: Number(r.refunds_amount) || 0,
          topups_count: Number(r.topups_count) || 0,
          topups_amount: Number(r.topups_amount) || 0,
          net_owed: netOwed,
          last_activity: r.last_activity,
          // ── v2 fields (now respecting contract overrides when breakdown=owner) ──
          account_type: accountType,
          critical_threshold: threshold,
          credit_limit: creditLimit,
          default_settlement_currency: effectiveCurrency,
          settlement_currencies: Array.isArray(sc) ? sc : null,
          balance_baseline_date: r.balance_baseline_date,
          is_critical: isCritical,
          // ── Phase 3 fields ──
          owner_app_id: breakdown ? (r.owner_app_id || null) : undefined,
          contract_id: breakdown ? (r.contract_id || null) : undefined,
          contract_label: contract ? (contract.contract_label || null) : undefined,
        };
      }),
    });
  } catch (e) { err(res, e.message, 500); }
});

// ─────────────────────────────────────────────────────────────────────
// Supplier-contracts CRUD (per-app statement ownership). The semantics:
//   - One row per (supplier, owner). owner_app_id IS NULL = our contract.
//   - When a transaction's booking.app_id has a matching active contract
//     row for this supplier, the transaction is classified into that
//     app's statement; otherwise it falls through to ours.
//   - effective_from gates "future-only" classification: a contract that
//     starts 2026-05-01 catches bookings created on/after that date.
//     The standalone backfill tool (phase 6) covers the case where the
//     operator wants to re-classify older bookings — that's intentionally
//     a separate, audited action because it rewrites historical statements.
//
// classifyContractForBooking(supplierId, appId, txnDate):
//   Returns the matching contract row or null. txnDate should be the
//   booking.upstream_created (or topup.paid_on) so effective_from/_to
//   honor the right window. Used by:
//     - the ledger sync to populate supplier_transactions_cache.owner_app_id
//     - the top-up POST to default the contract-owner currency / threshold
//     - the booking-detail "contract badge" lookup (phase 3)
//   The function is intentionally cheap (single indexed query); callers
//   that classify hundreds of rows should still memoize per (supplier, app).
async function classifyContractForBooking(supplierId, appId, txnDate) {
  if (!supplierId || !appId) return null;
  const date = txnDate ? new Date(txnDate) : new Date();
  const isoDate = Number.isNaN(date.getTime())
    ? null
    : date.toISOString().slice(0, 10);
  const [rows] = await pool.query(
    `SELECT *
       FROM supplier_contracts
      WHERE is_active = 1
        AND supplier_id = ?
        AND owner_app_id = ?
        AND (effective_from IS NULL OR ? IS NULL OR effective_from <= ?)
        AND (effective_to   IS NULL OR ? IS NULL OR effective_to   >= ?)
      ORDER BY effective_from DESC
      LIMIT 1`,
    [supplierId, appId, isoDate, isoDate, isoDate, isoDate]
  );
  return rows.length > 0 ? rows[0] : null;
}

function normalizeContractRow(row) {
  if (!row) return row;
  // settlement_currencies is stored as JSON; pull it back to an array.
  // null = no per-contract list (sync falls back to supplier policy).
  let settlementCurrencies = row.settlement_currencies;
  if (typeof settlementCurrencies === 'string') {
    try { settlementCurrencies = JSON.parse(settlementCurrencies); }
    catch { settlementCurrencies = null; }
  }
  return {
    ...row,
    is_active: row.is_active === true || Number(row.is_active) === 1,
    credit_limit: row.credit_limit == null ? null : Number(row.credit_limit),
    critical_threshold: row.critical_threshold == null ? null : Number(row.critical_threshold),
    settlement_currencies: Array.isArray(settlementCurrencies) ? settlementCurrencies : null,
  };
}

// Coerce a date-ish input into a 'YYYY-MM-DD' string MySQL will accept
// in a DATE column. Handles three shapes:
//   1. '2026-05-14'                          → unchanged
//   2. '2026-05-14T00:00:00.000Z' (ISO)      → '2026-05-14'  (the client
//      receives DATE columns as ISO datetimes because mysql2 promotes
//      them to JS Date objects which JSON-serialize with the time
//      portion; without this slice the round-trip would 1500 the next
//      PATCH.)
//   3. Date object                           → toISOString().slice(0,10)
// Returns null for empty / null / invalid input so callers can pass it
// straight to `effective_from = ?` / `effective_to = ?`.
function normalizeDateOnly(value) {
  if (value == null || value === '') return null;
  if (value instanceof Date) {
    if (Number.isNaN(value.getTime())) return null;
    return value.toISOString().slice(0, 10);
  }
  const s = String(value).trim();
  if (!s) return null;
  // Already date-only or date-with-T separator — slice the first 10 chars.
  // Validate the shape with a simple YYYY-MM-DD regex so we don't pass
  // garbage to MySQL.
  const ymd = s.slice(0, 10);
  if (!/^\d{4}-\d{2}-\d{2}$/.test(ymd)) return null;
  return ymd;
}

// Coerce a settlement_currencies payload into a clean string[] of upper-cased
// codes, or null. Accepts arrays of strings, comma-separated strings, or
// null/undefined.
function normalizeSettlementCurrencies(value) {
  if (value == null || value === '') return null;
  let list;
  if (Array.isArray(value)) list = value;
  else if (typeof value === 'string') list = value.split(',');
  else return null;
  const cleaned = Array.from(new Set(
    list.map(v => String(v || '').trim().toUpperCase()).filter(Boolean).filter(v => v.length <= 8)
  ));
  return cleaned.length === 0 ? null : cleaned;
}

// GET /api/suppliers/:id/contracts
//   Lists every contract row for one supplier (ours + per-app). Active +
//   inactive both included; the UI surfaces the toggle so admins can see
//   the historical setup. Ops-admin gated.
app.get('/api/suppliers/:id/contracts', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [rows] = await pool.query(
      'SELECT * FROM supplier_contracts WHERE supplier_id = ? ORDER BY is_active DESC, owner_app_id IS NOT NULL, created_at ASC',
      [req.params.id]
    );
    ok(res, rows.map(normalizeContractRow));
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/suppliers/:id/contracts
//   Body: { owner_app_id?, contract_label?, account_type?, credit_limit?,
//           critical_threshold?, settlement_currency?, effective_from?,
//           effective_to?, is_active?, notes? }
//   owner_app_id absent / null / "" → Ops contract. Otherwise must be an
//   existing app id (string passthrough; no upstream FK).
//   Returns the inserted row.
app.post('/api/suppliers/:id/contracts', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const supplierId = req.params.id;
    const [[s]] = await pool.query('SELECT id FROM suppliers WHERE id = ?', [supplierId]);
    if (!s) return err(res, 'Supplier not found', 404);

    const d = req.body || {};
    const id = require('crypto').randomBytes(16).toString('hex');
    const ownerAppId = d.owner_app_id ? String(d.owner_app_id) : null;
    const settlementCurrencies = normalizeSettlementCurrencies(d.settlement_currencies);
    const params = [
      id,
      supplierId,
      ownerAppId,
      d.contract_label ? String(d.contract_label).slice(0, 128) : null,
      d.account_type === 'deposit' || d.account_type === 'credit' ? d.account_type : null,
      d.credit_limit == null || d.credit_limit === '' ? null : Number(d.credit_limit),
      d.critical_threshold == null || d.critical_threshold === '' ? null : Number(d.critical_threshold),
      d.settlement_currency ? String(d.settlement_currency).toUpperCase().slice(0, 8) : null,
      settlementCurrencies ? JSON.stringify(settlementCurrencies) : null,
      normalizeDateOnly(d.effective_from),
      normalizeDateOnly(d.effective_to),
      d.is_active === false ? 0 : 1,
      d.notes ? String(d.notes) : null,
      req.platformUser.id,
    ];
    await pool.query(
      `INSERT INTO supplier_contracts
         (id, supplier_id, owner_app_id, contract_label, account_type,
          credit_limit, critical_threshold, settlement_currency, settlement_currencies,
          effective_from, effective_to, is_active, notes, created_by)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      params
    );

    await recordAuditEvent(req, {
      action: 'supplier_contract_create',
      entityType: 'supplier_contract',
      entityId: id,
      before: null,
      after: { supplier_id: supplierId, owner_app_id: ownerAppId, contract_label: d.contract_label || null },
    });

    // Force a foreground re-sync so the new contract takes effect
    // immediately on the Suppliers Balance page + per-booking badges.
    // Without this the operator would have to wait up to 10 min for the
    // next scheduled tick — which feels broken when they just clicked Save.
    await syncSupplierTransactionsCache().catch(() => {});

    const [[row]] = await pool.query('SELECT * FROM supplier_contracts WHERE id = ?', [id]);
    ok(res, normalizeContractRow(row));
  } catch (e) { err(res, e.message, 500); }
});

// PATCH /api/suppliers/:id/contracts/:contractId
//   Same body shape as POST. Any field omitted is left alone.
app.patch('/api/suppliers/:id/contracts/:contractId', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { id: supplierId, contractId } = req.params;
    const [[existing]] = await pool.query(
      'SELECT * FROM supplier_contracts WHERE id = ? AND supplier_id = ?',
      [contractId, supplierId]
    );
    if (!existing) return err(res, 'Contract not found', 404);

    const d = req.body || {};
    const sets = [];
    const params = [];
    const setField = (key, value) => { sets.push(`${key} = ?`); params.push(value); };

    if ('owner_app_id' in d)        setField('owner_app_id', d.owner_app_id ? String(d.owner_app_id) : null);
    if ('contract_label' in d)      setField('contract_label', d.contract_label ? String(d.contract_label).slice(0, 128) : null);
    if ('account_type' in d) {
      const v = d.account_type === 'deposit' || d.account_type === 'credit' ? d.account_type : null;
      setField('account_type', v);
    }
    if ('credit_limit' in d)        setField('credit_limit', d.credit_limit == null || d.credit_limit === '' ? null : Number(d.credit_limit));
    if ('critical_threshold' in d)  setField('critical_threshold', d.critical_threshold == null || d.critical_threshold === '' ? null : Number(d.critical_threshold));
    if ('settlement_currency' in d) setField('settlement_currency', d.settlement_currency ? String(d.settlement_currency).toUpperCase().slice(0, 8) : null);
    if ('settlement_currencies' in d) {
      const v = normalizeSettlementCurrencies(d.settlement_currencies);
      setField('settlement_currencies', v ? JSON.stringify(v) : null);
    }
    if ('effective_from' in d)      setField('effective_from', normalizeDateOnly(d.effective_from));
    if ('effective_to' in d)        setField('effective_to', normalizeDateOnly(d.effective_to));
    if ('is_active' in d)           setField('is_active', d.is_active === false ? 0 : 1);
    if ('notes' in d)               setField('notes', d.notes ? String(d.notes) : null);

    if (sets.length === 0) return err(res, 'No fields to update', 400);
    params.push(contractId);
    await pool.query(`UPDATE supplier_contracts SET ${sets.join(', ')} WHERE id = ?`, params);

    const [[after]] = await pool.query('SELECT * FROM supplier_contracts WHERE id = ?', [contractId]);
    await recordAuditEvent(req, {
      action: 'supplier_contract_update',
      entityType: 'supplier_contract',
      entityId: contractId,
      before: normalizeContractRow(existing),
      after: normalizeContractRow(after),
    });
    // Force-sync so the edited contract re-classifies on next read.
    await syncSupplierTransactionsCache().catch(() => {});
    ok(res, normalizeContractRow(after));
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/suppliers/:id/contracts/:contractId/backfill-preview
//   Body: { from_date: 'YYYY-MM-DD' }
//   Counts how many `supplier_transactions_cache` rows would move into
//   this contract's statement if we re-classified historical bookings.
//   Dry-run only — no writes. The operator sees the count before
//   committing via the actual backfill endpoint. Required by the
//   CLAUDE.md data-mutation protocol: never change history without a
//   preview + explicit confirmation.
app.post('/api/suppliers/:id/contracts/:contractId/backfill-preview', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { id: supplierId, contractId } = req.params;
    const fromDate = req.body?.from_date;
    if (!fromDate || !/^\d{4}-\d{2}-\d{2}$/.test(fromDate)) {
      return err(res, 'from_date (YYYY-MM-DD) is required', 400);
    }
    const [[contract]] = await pool.query(
      'SELECT * FROM supplier_contracts WHERE id = ? AND supplier_id = ?',
      [contractId, supplierId]
    );
    if (!contract) return err(res, 'Contract not found', 404);
    if (contract.owner_app_id == null) {
      return err(res, 'Backfill only applies to client-owned contracts (Ops contracts are the default).', 400);
    }

    // Disambiguate the three possible states a 0-rows-to-move result
    // can be in, so the UI shows the right message:
    //   - rows_to_move          → bookings that would be reclassified
    //   - rows_already_owned    → bookings already on this owner's statement
    //                             (so backfill is unnecessary — done)
    //   - rows_total_for_pair   → total bookings that match (supplier, owner_app)
    //                             regardless of classification.
    // 0 + 0 + 0 = no qualifying bookings exist at all
    // 0 + N + N = already classified, nothing to do
    // N + ?     = something to move
    const baseWhere = `
      t.supplier_id = ?
      AND b.app_id = ?
      AND t.type IN ('booking', 'refund')
      AND t.txn_date >= ?`;
    const baseParams = [supplierId, contract.owner_app_id, fromDate];
    const [[{ rows_to_move }]] = await pool.query(
      `SELECT COUNT(*) AS rows_to_move
         FROM supplier_transactions_cache t
         JOIN bookings_cache b ON b.id = t.booking_id
        WHERE ${baseWhere}
          AND (t.owner_app_id IS NULL OR t.owner_app_id != ?)`,
      [...baseParams, contract.owner_app_id]
    );
    const [[{ rows_already_owned }]] = await pool.query(
      `SELECT COUNT(*) AS rows_already_owned
         FROM supplier_transactions_cache t
         JOIN bookings_cache b ON b.id = t.booking_id
        WHERE ${baseWhere}
          AND t.owner_app_id = ?`,
      [...baseParams, contract.owner_app_id]
    );
    const [[range]] = await pool.query(
      `SELECT MIN(t.txn_date) AS first_txn, MAX(t.txn_date) AS last_txn
         FROM supplier_transactions_cache t
         JOIN bookings_cache b ON b.id = t.booking_id
        WHERE ${baseWhere}
          AND (t.owner_app_id IS NULL OR t.owner_app_id != ?)`,
      [...baseParams, contract.owner_app_id]
    );

    ok(res, {
      contract_id: contractId,
      owner_app_id: contract.owner_app_id,
      from_date: fromDate,
      rows_to_move: Number(rows_to_move) || 0,
      rows_already_owned: Number(rows_already_owned) || 0,
      first_txn: range?.first_txn || null,
      last_txn: range?.last_txn || null,
    });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/suppliers/:id/contracts/:contractId/backfill
//   Body: { from_date: 'YYYY-MM-DD', confirm: true }
//   Re-classifies historical transactions for (supplier_id,
//   contract.owner_app_id) into this contract's statement, ignoring
//   the contract's effective_from for this one pass. The dry-run
//   preview MUST be called first; this endpoint refuses without
//   confirm:true. Audit-logged with row counts + actor + reason.
app.post('/api/suppliers/:id/contracts/:contractId/backfill', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { id: supplierId, contractId } = req.params;
    const fromDate = req.body?.from_date;
    const confirm = req.body?.confirm === true;
    const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : null;

    if (!fromDate || !/^\d{4}-\d{2}-\d{2}$/.test(fromDate)) {
      return err(res, 'from_date (YYYY-MM-DD) is required', 400);
    }
    if (!confirm) return err(res, 'confirm:true required — call backfill-preview first to see the impact', 400);

    const [[contract]] = await pool.query(
      'SELECT * FROM supplier_contracts WHERE id = ? AND supplier_id = ?',
      [contractId, supplierId]
    );
    if (!contract) return err(res, 'Contract not found', 404);
    if (contract.owner_app_id == null) {
      return err(res, 'Backfill only applies to client-owned contracts (Ops contracts are the default).', 400);
    }

    // The UPDATE. Touches rows whose booking.app_id matches the
    // contract owner. We don't move topup rows here — operator-logged
    // top-ups already carry their explicit owner_app_id and we never
    // want to silently re-attribute money the operator stamped.
    const [updateResult] = await pool.query(
      `UPDATE supplier_transactions_cache t
          JOIN bookings_cache b ON b.id = t.booking_id
          SET t.owner_app_id = ?
        WHERE t.supplier_id = ?
          AND b.app_id = ?
          AND t.type IN ('booking', 'refund')
          AND t.txn_date >= ?
          AND (t.owner_app_id IS NULL OR t.owner_app_id != ?)`,
      [contract.owner_app_id, supplierId, contract.owner_app_id, fromDate, contract.owner_app_id]
    );
    const affected = Number(updateResult.affectedRows) || 0;

    await recordAuditEvent(req, {
      action: 'supplier_contract_backfill',
      entityType: 'supplier_contract',
      entityId: contractId,
      before: null,
      after: {
        supplier_id: supplierId,
        owner_app_id: contract.owner_app_id,
        from_date: fromDate,
        rows_moved: affected,
      },
      reason,
    });

    ok(res, {
      contract_id: contractId,
      owner_app_id: contract.owner_app_id,
      from_date: fromDate,
      rows_moved: affected,
    });
  } catch (e) { err(res, e.message, 500); }
});

// DELETE /api/suppliers/:id/contracts/:contractId
//   Hard delete. Contracts have no booking-level FK so removing one is
//   safe — the next ledger sync simply stops classifying matching bookings
//   into this owner's statement (they flow back to ours).
app.delete('/api/suppliers/:id/contracts/:contractId', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { id: supplierId, contractId } = req.params;
    const [[existing]] = await pool.query(
      'SELECT * FROM supplier_contracts WHERE id = ? AND supplier_id = ?',
      [contractId, supplierId]
    );
    if (!existing) return err(res, 'Contract not found', 404);
    await pool.query('DELETE FROM supplier_contracts WHERE id = ?', [contractId]);
    await recordAuditEvent(req, {
      action: 'supplier_contract_delete',
      entityType: 'supplier_contract',
      entityId: contractId,
      before: normalizeContractRow(existing),
      after: null,
    });
    // Force-sync so transactions previously classified into this owner's
    // statement immediately fall back to ours (or to a matching surviving
    // contract, if any).
    await syncSupplierTransactionsCache().catch(() => {});
    ok(res, { deleted: true, id: contractId });
  } catch (e) { err(res, e.message, 500); }
});

// ─────────────────────────────────────────────────────────────────────
// App-user "Finances" surface (phase 5 of per-app supplier statements).
//   Routes under /api/me/finances/* are scoped to the caller's app(s) —
//   they read profile.allowed_app_ids (set on the JWT at login) and
//   refuse any read or write outside that set. Ops admins (login_context
//   = 'system') can pass an app_id explicitly; otherwise the caller's
//   first allowed app is the default.
//
// What an App user can do here:
//   - List their own contracts (per-supplier + per-app).
//   - List the balance per supplier for their app.
//   - List their own top-ups + supporting transactions.
//   - Create a new top-up against any supplier that has a contract row
//     for their app.
//
// What they CANNOT do:
//   - See other apps' top-ups, balances, or contracts.
//   - Pick "Ops" as the Paid-by owner (top-ups always stamp their app_id).
//   - Edit / delete contracts (admin-only via the existing CRUD).
// ─────────────────────────────────────────────────────────────────────
function resolveAppForFinances(user, requestedAppId) {
  const allowed = Array.isArray(user.allowed_app_ids) ? user.allowed_app_ids : [];
  if (user.login_context === 'system') {
    // Ops admins can scope to any app explicitly. Without an app_id
    // hint we have no statement to show (Ops should use /api/supplier-balances
    // instead). Returning null signals the caller to 400.
    return requestedAppId || null;
  }
  // Non-system callers: they must request an app they own, or fall back
  // to their first allowed app (the common case — single-app App users).
  if (requestedAppId) {
    return allowed.includes(requestedAppId) ? requestedAppId : null;
  }
  return allowed[0] || null;
}

// GET /api/me/has-suppliers
//   Cheap yes/no the Sidebar uses to decide whether to render the
//   "Suppliers" (formerly "Finances") entry for the calling user.
//     Ops → always true (they need scope-switching even without contracts).
//     App user → true iff ≥1 active supplier_contracts row exists for any
//                of their allowed_app_ids.
app.get('/api/me/has-suppliers', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    if (user.login_context === 'system') return ok(res, { has: true });
    const allowed = Array.isArray(user.allowed_app_ids) ? user.allowed_app_ids : [];
    if (allowed.length === 0) return ok(res, { has: false });
    const placeholders = allowed.map(() => '?').join(',');
    const [[row]] = await pool.query(
      `SELECT COUNT(*) AS c FROM supplier_contracts
        WHERE owner_app_id IN (${placeholders}) AND is_active = 1`,
      allowed
    );
    ok(res, { has: Number(row.c) > 0 });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/me/finances/contracts?app_id=<optional>
//   Lists every active contract row owned by the user's app (NULL contracts
//   = Ops are excluded; this surface is the client side, not Ops).
app.get('/api/me/finances/contracts', authMiddleware, async (req, res) => {
  try {
    const appId = resolveAppForFinances(req.platformUser, req.query.app_id);
    if (!appId) return err(res, 'No accessible app — finances scope is empty', 400);
    const [rows] = await pool.query(
      `SELECT c.*, s.name AS supplier_name, s.code AS supplier_code
         FROM supplier_contracts c
         LEFT JOIN suppliers s ON s.id = c.supplier_id
        WHERE c.owner_app_id = ? AND c.is_active = 1
        ORDER BY s.name ASC, c.effective_from DESC`,
      [appId]
    );
    ok(res, rows.map(normalizeContractRow));
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/me/finances/balances?app_id=<optional>&currency=<optional>
//   Per-supplier balance for the user's app, computed from the existing
//   supplier_transactions_cache. Same shape as /api/supplier-balances with
//   owner=<app_id>&breakdown=owner, but scoped server-side.
// Transactions for one supplier, scoped to the caller's app statement.
// Mirrors the Ops `GET /api/suppliers/:id/transactions` endpoint but is
// hard-gated by `owner_app_id = <my app>` so a client can only ever see
// their own bookings/refunds/top-ups against the supplier.
app.get('/api/me/finances/suppliers/:supplierId/transactions', authMiddleware, async (req, res) => {
  try {
    const appId = resolveAppForFinances(req.platformUser, req.query.app_id);
    if (!appId) return err(res, 'No accessible app — finances scope is empty', 400);
    const supplierId = req.params.supplierId;
    const where = ['t.supplier_id = ?', 't.owner_app_id = ?'];
    const params = [supplierId, appId];
    if (req.query.currency) { where.push('t.currency = ?'); params.push(req.query.currency); }
    if (req.query.type) { where.push('t.type = ?'); params.push(req.query.type); }
    const whereSql = `WHERE ${where.join(' AND ')}`;

    const [[totalRow]] = await pool.query(
      `SELECT COUNT(*) AS c FROM supplier_transactions_cache t ${whereSql}`,
      params
    );
    const total = Number(totalRow.c) || 0;

    const page = Math.max(1, parseInt(req.query.page) || 1);
    const perPage = Math.min(200, Math.max(1, parseInt(req.query.per_page) || 50));
    const offset = (page - 1) * perPage;

    // Same join pattern as the Ops endpoint so the topup metadata
    // (payment method, reference, attachment) is included when the row
    // is a top-up. Booking/refund rows just get the base columns.
    const [rows] = await pool.query(
      `SELECT t.*,
              CASE WHEN t.type = 'topup' THEN tu.payment_method_id ELSE NULL END AS payment_method_id,
              CASE WHEN t.type = 'topup' THEN pm.name ELSE NULL END AS payment_method_name,
              CASE WHEN t.type = 'topup' THEN tu.attachment_path ELSE NULL END AS attachment_path,
              CASE WHEN t.type = 'topup' THEN tu.reference ELSE NULL END AS topup_reference
         FROM supplier_transactions_cache t
         LEFT JOIN supplier_topups tu
                ON tu.id = JSON_UNQUOTE(JSON_EXTRACT(t.metadata, '$.topup_id'))
               AND t.type = 'topup'
         LEFT JOIN payment_methods pm
                ON pm.id = tu.payment_method_id
         ${whereSql}
         ORDER BY t.txn_date DESC, t.id DESC
         LIMIT ? OFFSET ?`,
      [...params, perPage, offset]
    );

    ok(res, {
      app_id: appId,
      total,
      page,
      per_page: perPage,
      result: rows.map(r => ({
        ...r,
        amount: Number(r.amount),
        metadata: typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata,
      })),
    });
  } catch (e) { err(res, e.message, 500); }
});

app.get('/api/me/finances/balances', authMiddleware, async (req, res) => {
  try {
    const appId = resolveAppForFinances(req.platformUser, req.query.app_id);
    if (!appId) return err(res, 'No accessible app — finances scope is empty', 400);
    const where = ['t.owner_app_id = ?'];
    const params = [appId];
    if (req.query.currency) { where.push('t.currency = ?'); params.push(req.query.currency); }
    where.push('(s.balance_baseline_date IS NULL OR t.txn_date >= s.balance_baseline_date)');
    const [rows] = await pool.query(
      `SELECT
         t.supplier_id, t.currency,
         COALESCE(s.name, s.code, t.supplier_id) AS supplier_name,
         s.code AS supplier_code,
         (SELECT id FROM supplier_contracts c
           WHERE c.is_active = 1 AND c.supplier_id = t.supplier_id AND c.owner_app_id = ?
             AND (c.effective_from IS NULL OR c.effective_from <= CURDATE())
             AND (c.effective_to   IS NULL OR c.effective_to   >= CURDATE())
           ORDER BY effective_from DESC LIMIT 1) AS contract_id,
         SUM(CASE WHEN t.type = 'booking' THEN t.amount ELSE 0 END) AS bookings_amount,
         SUM(CASE WHEN t.type = 'booking' THEN 1 ELSE 0 END) AS bookings_count,
         SUM(CASE WHEN t.type = 'refund'  THEN t.amount ELSE 0 END) AS refunds_amount,
         SUM(CASE WHEN t.type = 'refund'  THEN 1 ELSE 0 END) AS refunds_count,
         SUM(CASE WHEN t.type = 'topup'   THEN t.amount ELSE 0 END) AS topups_amount,
         SUM(CASE WHEN t.type = 'topup'   THEN 1 ELSE 0 END) AS topups_count,
         (SUM(CASE WHEN t.type = 'booking' THEN t.amount ELSE 0 END)
          - SUM(CASE WHEN t.type = 'refund' THEN t.amount ELSE 0 END)
          - SUM(CASE WHEN t.type = 'topup'  THEN t.amount ELSE 0 END)
         ) AS net_owed,
         MAX(t.txn_date) AS last_activity
       FROM supplier_transactions_cache t
       LEFT JOIN suppliers s ON s.id = t.supplier_id
       WHERE ${where.join(' AND ')}
       GROUP BY t.supplier_id, t.currency, s.name, s.code
       ORDER BY supplier_name ASC, t.currency ASC`,
      [appId, ...params]
    );
    ok(res, {
      app_id: appId,
      result: rows.map(r => ({
        supplier_id: r.supplier_id,
        supplier_name: r.supplier_name,
        supplier_code: r.supplier_code,
        currency: r.currency,
        bookings_count: Number(r.bookings_count) || 0,
        bookings_amount: Number(r.bookings_amount) || 0,
        refunds_count: Number(r.refunds_count) || 0,
        refunds_amount: Number(r.refunds_amount) || 0,
        topups_count: Number(r.topups_count) || 0,
        topups_amount: Number(r.topups_amount) || 0,
        net_owed: Number(r.net_owed) || 0,
        last_activity: r.last_activity,
        contract_id: r.contract_id || null,
      })),
    });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/me/finances/topups?app_id=<optional>&supplier_id=<optional>
//   Lists the top-up rows the user's app has logged. Includes the
//   payment method name + the supplier name for display.
app.get('/api/me/finances/topups', authMiddleware, async (req, res) => {
  try {
    const appId = resolveAppForFinances(req.platformUser, req.query.app_id);
    if (!appId) return err(res, 'No accessible app — finances scope is empty', 400);
    const where = ['t.owner_app_id = ?', 't.deleted = 0'];
    const params = [appId];
    if (req.query.supplier_id) { where.push('t.supplier_id = ?'); params.push(req.query.supplier_id); }
    const [rows] = await pool.query(
      `SELECT t.*, pm.name AS payment_method_name,
              COALESCE(s.name, s.code, t.supplier_id) AS supplier_name
         FROM supplier_topups t
         LEFT JOIN payment_methods pm ON pm.id = t.payment_method_id
         LEFT JOIN suppliers s ON s.id = t.supplier_id
        WHERE ${where.join(' AND ')}
        ORDER BY t.paid_on DESC, t.created_at DESC`,
      params
    );
    ok(res, rows.map(r => ({ ...r, amount: Number(r.amount) })));
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/me/finances/topups
//   Multipart form. App user logs a top-up against one of their contracted
//   suppliers. owner_app_id is always stamped to the caller's app — the
//   field cannot be overridden client-side. Server validates a contract
//   exists for (supplier_id, app_id) before accepting. Same downstream
//   flow as the Ops endpoint: insert + force-sync the ledger.
app.post('/api/me/finances/topups', authMiddleware, upload.single('attachment'), async (req, res) => {
  try {
    const appId = resolveAppForFinances(req.platformUser, req.body.app_id);
    if (!appId) return err(res, 'No accessible app — finances scope is empty', 400);

    const { supplier_id, amount, currency, paid_on, payment_method_id, reference, notes } = req.body;
    if (!supplier_id) return err(res, 'supplier_id required', 400);
    if (!amount || Number(amount) <= 0) return err(res, 'amount must be > 0', 400);
    if (!currency) return err(res, 'currency required', 400);
    if (!paid_on) return err(res, 'paid_on required', 400);
    if (!payment_method_id) return err(res, 'payment_method_id required', 400);

    // Contract check: the user can only top-up a supplier they have a
    // contract with. Prevents an app user from injecting a top-up into
    // another tenant's statement or onto a supplier they don't own.
    const [[contract]] = await pool.query(
      `SELECT id FROM supplier_contracts
        WHERE is_active = 1 AND supplier_id = ? AND owner_app_id = ?
          AND (effective_from IS NULL OR effective_from <= CURDATE())
          AND (effective_to   IS NULL OR effective_to   >= CURDATE())
        LIMIT 1`,
      [supplier_id, appId]
    );
    if (!contract) return err(res, 'No active contract for this supplier under your app — ask Ops to add one in Settings → Suppliers → Contracts.', 403);

    // Validate payment method exists + active (same rule as Ops endpoint).
    const [[pm]] = await pool.query(
      'SELECT id FROM payment_methods WHERE id = ? AND is_active = 1',
      [payment_method_id]
    );
    if (!pm) return err(res, 'Payment method not found or inactive', 400);

    const id = require('crypto').randomBytes(16).toString('hex');
    const attachmentPath = req.file ? `/uploads/${path.basename(req.file.path)}` : null;
    await pool.query(
      `INSERT INTO supplier_topups
         (id, supplier_id, amount, currency, paid_on, payment_method_id,
          reference, attachment_path, notes, created_by, owner_app_id)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, supplier_id, Number(amount), currency, paid_on, payment_method_id,
       reference || null, attachmentPath, notes || null, req.platformUser.id, appId]
    );

    await recordAuditEvent(req, {
      action: 'finances_topup_create',
      entityType: 'supplier_topup',
      entityId: id,
      before: null,
      after: { supplier_id, amount: Number(amount), currency, owner_app_id: appId },
    });

    // Foreground re-sync so the new top-up appears in the user's balance
    // page immediately (same UX as the Ops endpoint).
    await syncSupplierTransactionsCache().catch(() => {});

    const [[created]] = await pool.query(
      `SELECT t.*, pm.name AS payment_method_name
         FROM supplier_topups t LEFT JOIN payment_methods pm ON pm.id = t.payment_method_id
        WHERE t.id = ?`,
      [id]
    );
    ok(res, { ...created, amount: Number(created.amount) });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/contracts/debug/booking?code=<booking_code>
//   Walks the whole classification pipeline for one booking and returns
//   a step-by-step trace. Used when "I added a contract but the booking
//   isn't on the right statement" — the response tells the operator
//   exactly which step fails: missing app_id on bookings_cache, supplier
//   code doesn't resolve, no contract matches, contract effective_from
//   too late, etc. Admin-gated; no writes.
app.get('/api/contracts/debug/booking', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const code = typeof req.query.code === 'string' ? req.query.code.trim() : '';
    if (!code) return err(res, 'code (booking code) required', 400);

    // Step 1: locate the booking in bookings_cache.
    const [bookingRows] = await pool.query(
      `SELECT id, code, app_id, total_amount, currency, booking_status, payment_status,
              upstream_created, raw
         FROM bookings_cache
        WHERE code = ?
        LIMIT 1`,
      [code]
    );
    if (bookingRows.length === 0) {
      return ok(res, {
        step: 'booking_lookup',
        ok: false,
        message: `No booking with code "${code}" in bookings_cache. Either the code is wrong or the booking hasn't been synced yet — trigger a full sync from the booking list page.`,
      });
    }
    const booking = bookingRows[0];

    // Step 2: confirm the booking qualifies for the supplier ledger.
    // Same predicate as syncSupplierTransactionsCache:
    //   ((booking_status='success' AND payment_status='success') OR payment_status='refunded')
    const qualifies = (booking.booking_status === 'success' && booking.payment_status === 'success')
      || booking.payment_status === 'refunded';
    if (!qualifies) {
      return ok(res, {
        step: 'qualification',
        ok: false,
        booking: { id: booking.id, code: booking.code, app_id: booking.app_id, booking_status: booking.booking_status, payment_status: booking.payment_status },
        message: `Booking is in status ${booking.booking_status}/${booking.payment_status} — the ledger sync only ingests success/success or any-status/refunded. Bookings in other states never appear on supplier statements (matches Suppliers Balance behaviour).`,
      });
    }

    // Step 3: confirm bookings_cache has an app_id — otherwise classifyOwner
    // returns null even with a perfect contract. This is the #1 cause of
    // "contract not reflecting" complaints in practice.
    if (!booking.app_id) {
      return ok(res, {
        step: 'app_id_missing',
        ok: false,
        booking: { id: booking.id, code: booking.code, app_id: booking.app_id },
        message: `bookings_cache.app_id is NULL for this booking. Without an app_id the sync can't match it to any contract. Trigger a fresh sync from the Bookings page (or wait for the next periodic sync) — the app_id is set when the row is upserted from upstream EasyGDS.`,
      });
    }

    // Step 4: extract supplier codes from the product list.
    let raw;
    try { raw = typeof booking.raw === 'string' ? JSON.parse(booking.raw) : booking.raw; }
    catch { raw = null; }
    const products = raw?.products || [];
    if (products.length === 0) {
      return ok(res, {
        step: 'no_products',
        ok: false,
        booking: { id: booking.id, code: booking.code, app_id: booking.app_id },
        message: `Booking has no products in raw payload — nothing to allocate to a supplier.`,
      });
    }
    // Re-use the SAME extractor the sync runs (`extractProductSupplier`)
    // so the trace and the ledger never disagree. The sync's picker walks
    // these fields in priority order (first hit wins per product):
    //   1. selected_rooms[].rate.source_id     ← canonical supplier id for hotels
    //   2. selected_rooms[].rate.supplier_code
    //   3. data.supplier_code | supplier_source | provider_code | provider | source
    //   4. selected_rooms[].supplier_code | supplier_source
    // For each product we record the winning code as `primary: true`, then
    // collect every other plausible supplier code we saw as additional
    // candidates so the operator can see why we picked what we picked.
    const productSupplierCodes = new Set();
    const primaryCodes = new Set();
    const otherFields = ['supplier_code', 'supplier_source', 'provider_code', 'provider', 'source', 'source_id'];
    for (const p of products) {
      const primary = extractProductSupplier(p);
      if (primary) {
        productSupplierCodes.add(primary);
        primaryCodes.add(primary.toLowerCase());
      }
      const d = p?.data || {};
      const rooms = Array.isArray(d.selected_rooms) ? d.selected_rooms : [];
      for (const room of rooms) {
        for (const f of otherFields) {
          const v = (room?.rate?.[f] || room?.[f] || '').toString().trim();
          if (v && looksLikeRealSupplierCode(v)) productSupplierCodes.add(v);
        }
      }
      for (const f of otherFields) {
        const v = (d?.[f] || d?.rate?.[f] || p?.[f] || '').toString().trim();
        if (v && looksLikeRealSupplierCode(v)) productSupplierCodes.add(v);
      }
    }
    if (productSupplierCodes.size === 0) {
      return ok(res, {
        step: 'no_supplier_codes',
        ok: false,
        booking: { id: booking.id, code: booking.code, app_id: booking.app_id },
        message: `Found ${products.length} product(s) but none carry a recognisable supplier code in the usual fields (supplier_code / source_id / etc.). The sync wouldn't classify these to any supplier either.`,
      });
    }

    // Step 5: resolve each code to a canonical supplier id.
    const codes = Array.from(productSupplierCodes);
    const lowerCodes = codes.map(c => c.toLowerCase());
    const placeholders = lowerCodes.map(() => '?').join(',');
    const [supRows] = await pool.query(
      `SELECT id, code, name, source, merged_into_id,
              COALESCE(merged_into_id, id) AS canonical_id
         FROM suppliers
        WHERE LOWER(code) IN (${placeholders})`,
      lowerCodes
    );
    const codeToSupplier = new Map();
    for (const r of supRows) codeToSupplier.set(String(r.code).toLowerCase(), r);
    const supplierTrace = codes.map(c => ({
      code: c,
      // `primary` flags the code that the sync's classifier (extractProductSupplier)
      // actually chose for one of the booking's products — i.e. the code
      // the ledger row would be tagged with. Everything else is shown for
      // debugging so operators can see which fields hold which values.
      primary: primaryCodes.has(c.toLowerCase()),
      resolved: codeToSupplier.has(c.toLowerCase()),
      supplier: codeToSupplier.get(c.toLowerCase()) || null,
    }));
    const unresolved = supplierTrace.filter(s => !s.resolved);
    if (unresolved.length === codes.length) {
      return ok(res, {
        step: 'codes_unresolved',
        ok: false,
        booking: { id: booking.id, code: booking.code, app_id: booking.app_id },
        codes: supplierTrace,
        message: `None of the booking's supplier codes match a row in the suppliers table. Run "Discover suppliers from bookings" in Settings → Suppliers to populate them, then try again.`,
      });
    }

    // Step 6: for each resolved canonical, look up the contract for (canonical, booking.app_id).
    const canonicalIds = supplierTrace
      .filter(s => s.supplier)
      .map(s => s.supplier.canonical_id);
    const distinctCanonicalIds = Array.from(new Set(canonicalIds));
    const today = booking.upstream_created
      ? new Date(booking.upstream_created).toISOString().slice(0, 10)
      : new Date().toISOString().slice(0, 10);
    const contractRows = distinctCanonicalIds.length === 0 ? [] : (await pool.query(
      `SELECT * FROM supplier_contracts
        WHERE supplier_id IN (${distinctCanonicalIds.map(() => '?').join(',')})
          AND owner_app_id = ?`,
      [...distinctCanonicalIds, booking.app_id]
    ))[0];
    const contractsByCanonical = new Map();
    for (const c of contractRows) contractsByCanonical.set(c.supplier_id, c);
    const contractTrace = supplierTrace.map(s => {
      if (!s.supplier) return { code: s.code, reason: 'unresolved supplier code' };
      const canonical = s.supplier.canonical_id;
      const contract = contractsByCanonical.get(canonical) || null;
      if (!contract) {
        return {
          code: s.code,
          supplier_id: canonical,
          contract_found: false,
          reason: `No contract row for (supplier ${canonical}, app ${booking.app_id}). Either the contract owner_app_id doesn't match the booking's app_id, or no contract exists for this supplier+app.`,
        };
      }
      // Check effective window against booking.upstream_created.
      const txnDate = booking.upstream_created;
      const fromOK = !contract.effective_from || new Date(txnDate) >= new Date(contract.effective_from);
      const toOK = !contract.effective_to || new Date(txnDate) <= new Date(contract.effective_to);
      const activeOK = contract.is_active === true || Number(contract.is_active) === 1;
      const inWindow = fromOK && toOK && activeOK;
      return {
        code: s.code,
        supplier_id: canonical,
        contract_found: true,
        contract: normalizeContractRow(contract),
        booking_date: txnDate,
        is_active: activeOK,
        in_effective_window: inWindow,
        reason: inWindow
          ? 'Contract matches — sync should classify this product to the contract owner on next run.'
          : !activeOK
            ? 'Contract is not active.'
            : !fromOK
              ? `Booking date ${txnDate} is BEFORE the contract's effective_from ${contract.effective_from}. Use the backfill tool (History icon on the contract row) to pick this booking up.`
              : `Booking date ${txnDate} is AFTER the contract's effective_to ${contract.effective_to}.`,
      };
    });

    // Step 7: report the current classification in supplier_transactions_cache.
    const [txnRows] = await pool.query(
      `SELECT supplier_id, type, amount, currency, owner_app_id, txn_date
         FROM supplier_transactions_cache
        WHERE booking_id = ?`,
      [booking.id]
    );

    ok(res, {
      step: 'complete',
      ok: contractTrace.some(c => c.in_effective_window),
      booking: {
        id: booking.id,
        code: booking.code,
        app_id: booking.app_id,
        booking_status: booking.booking_status,
        payment_status: booking.payment_status,
        total_amount: Number(booking.total_amount) || 0,
        currency: booking.currency,
        upstream_created: booking.upstream_created,
      },
      supplier_codes: supplierTrace,
      contracts: contractTrace,
      current_ledger_rows: txnRows,
      hint: contractTrace.some(c => c.in_effective_window)
        ? 'Contracts match. If the row still shows owner_app_id = NULL in current_ledger_rows, trigger a re-sync — it should reclassify on next pass.'
        : 'No contract matches. See the "reason" on each contract trace above.',
    });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/contract-lookup?app_id=<id>&supplier_ids=<id1,id2,...>
//   Bulk classifier — returns, for each supplier_id passed in, the matching
//   contract row for the given app_id (or null when no contract applies =
//   the transaction would fall into Ops). Used by the Booking detail
//   "Direct partner" badge so one round-trip resolves N supplier cards.
//
//   Auth: any authenticated user (the badge renders inside Ops + App
//   views; both should see whether the relationship is theirs or ours).
//   No new admin gate — the response is bounded to the (app, supplier)
//   pair the caller specified, no global enumeration.
app.get('/api/contract-lookup', authMiddleware, async (req, res) => {
  try {
    const appId = typeof req.query.app_id === 'string' ? req.query.app_id : '';
    const ids = typeof req.query.supplier_ids === 'string'
      ? req.query.supplier_ids.split(',').map(s => s.trim()).filter(Boolean)
      : [];
    if (!appId || ids.length === 0) return ok(res, {});
    const placeholders = ids.map(() => '?').join(',');
    const today = new Date().toISOString().slice(0, 10);
    // For each supplier, prefer the active contract row that matches the
    // booking's app and whose effective window contains today. If none
    // matches the app, fall through to NULL (= Ops). We return only the
    // app-matched rows here; the absence of a row in the response means
    // "no client contract, falls to Ops" — the client picks that up.
    const [rows] = await pool.query(
      `SELECT c.*
         FROM supplier_contracts c
         JOIN (
           SELECT supplier_id, MAX(effective_from IS NULL) AS has_open,
                  MAX(effective_from) AS latest_from
             FROM supplier_contracts
            WHERE is_active = 1 AND owner_app_id = ? AND supplier_id IN (${placeholders})
              AND (effective_from IS NULL OR effective_from <= ?)
              AND (effective_to IS NULL OR effective_to >= ?)
            GROUP BY supplier_id
         ) latest
           ON latest.supplier_id = c.supplier_id
        WHERE c.is_active = 1
          AND c.owner_app_id = ?
          AND (c.effective_from <=> latest.latest_from OR (latest.has_open = 1 AND c.effective_from IS NULL))
          AND (c.effective_to IS NULL OR c.effective_to >= ?)`,
      [appId, ...ids, today, today, appId, today]
    );
    const bySupplier = {};
    for (const r of rows) bySupplier[r.supplier_id] = normalizeContractRow(r);
    ok(res, bySupplier);
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/suppliers/:id/transactions?currency=&type=&owner=&page=&per_page=
//   Drill-down: paginated transactions for one supplier (canonical id).
//   owner filter (added phase 3): 'ops' (NULL only) or '<app_id>'. Absent
//   or 'all' = no filter (pre-phase-3 behavior).
app.get('/api/suppliers/:id/transactions', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const supplierId = req.params.id;
    const where = ['t.supplier_id = ?'];
    const params = [supplierId];
    if (req.query.currency) { where.push('t.currency = ?'); params.push(req.query.currency); }
    if (req.query.type) { where.push('t.type = ?'); params.push(req.query.type); }
    const owner = typeof req.query.owner === 'string' ? req.query.owner : null;
    if (owner === 'ops') {
      where.push('t.owner_app_id IS NULL');
    } else if (owner && owner !== 'all') {
      where.push('t.owner_app_id = ?');
      params.push(owner);
    }
    const whereSql = `WHERE ${where.join(' AND ')}`;

    const [[totalRow]] = await pool.query(
      `SELECT COUNT(*) AS c FROM supplier_transactions_cache t ${whereSql}`,
      params
    );
    const total = Number(totalRow.c) || 0;

    const page = Math.max(1, parseInt(req.query.page) || 1);
    const perPage = Math.min(200, Math.max(1, parseInt(req.query.per_page) || 50));
    const offset = (page - 1) * perPage;

    const [rows] = await pool.query(
      `SELECT t.*,
              CASE WHEN t.type = 'topup' THEN tu.payment_method_id ELSE NULL END AS payment_method_id,
              CASE WHEN t.type = 'topup' THEN pm.name ELSE NULL END AS payment_method_name,
              CASE WHEN t.type = 'topup' THEN tu.attachment_path ELSE NULL END AS attachment_path,
              CASE WHEN t.type = 'topup' THEN tu.reference ELSE NULL END AS topup_reference
         FROM supplier_transactions_cache t
         LEFT JOIN supplier_topups tu
                ON tu.id = JSON_UNQUOTE(JSON_EXTRACT(t.metadata, '$.topup_id'))
               AND t.type = 'topup'
         LEFT JOIN payment_methods pm
                ON pm.id = tu.payment_method_id
         ${whereSql}
         ORDER BY t.txn_date DESC, t.id DESC
         LIMIT ? OFFSET ?`,
      [...params, perPage, offset]
    );

    ok(res, {
      total,
      page,
      per_page: perPage,
      result: rows.map(r => ({
        ...r,
        amount: Number(r.amount),
        metadata: typeof r.metadata === 'string' ? JSON.parse(r.metadata) : r.metadata,
      })),
    });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/suppliers/:id/topups   (multipart for optional bank slip)
//   Records that the operator paid this supplier. Triggers a foreground
//   sync so the row appears in the ledger immediately.
// POST /api/suppliers/:id/topups/preview-email
//   Returns the rendered supplier-topup email (subject + html) WITHOUT
//   sending or saving anything. Used by the topup dialog so the operator
//   can preview/edit before commit. Same template as the actual send so
//   the preview is faithful.
app.post('/api/suppliers/:id/topups/preview-email', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const supplierId = req.params.id;
    const { amount, currency, paid_on, payment_method_id, reference, notes, has_attachment } = req.body || {};

    if (!amount || Number(amount) <= 0) return err(res, 'amount must be > 0', 400);
    if (!currency) return err(res, 'currency is required', 400);
    if (!paid_on) return err(res, 'paid_on is required', 400);

    const [[s]] = await pool.query(
      'SELECT id, name, code, primary_email FROM suppliers WHERE id = ?',
      [supplierId]
    );
    if (!s) return err(res, 'Supplier not found', 404);

    let paymentMethodName = null;
    if (payment_method_id) {
      const [[pm]] = await pool.query(
        'SELECT name FROM payment_methods WHERE id = ?',
        [payment_method_id]
      );
      paymentMethodName = pm?.name || null;
    }

    const { subject, html } = renderSupplierTopupEmail({
      supplier: { name: s.name, code: s.code },
      topup: {
        amount, currency, paid_on,
        reference: reference || null,
        payment_method_name: paymentMethodName,
        notes: notes || null,
      },
      hasAttachment: !!has_attachment,
    });
    ok(res, {
      subject, html,
      default_to: s.primary_email || null,
      supplier_name: s.name || s.code || null,
    });
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/suppliers/:id/topups', authMiddleware, upload.single('attachment'), async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const supplierId = req.params.id;
    const {
      amount, currency, paid_on, payment_method_id, reference, notes,
      // owner_app_id: which statement this top-up belongs to. Absent /
      // empty / "null" → Ops top-up (NULL). A string value → must be
      // one of the user's known app ids (we trust the picker; server
      // could validate against the user's allowed_app_ids list once
      // phase 5 ships the App-user self-service flow).
      owner_app_id,
      // New (optional): email the supplier with the proof of payment.
      // Sent as form fields on the multipart request.
      //   notify_supplier   "true" / "1" / "on" → opt in
      //   notify_email      override recipient (defaults to suppliers.primary_email)
      //   notify_cc         comma-separated cc list (e.g. "ops@…,finance@…")
      //   notify_subject    operator-edited subject from the preview dialog
      //   notify_html       operator-edited HTML body from the preview dialog
      notify_supplier, notify_email, notify_cc,
      notify_subject, notify_html,
    } = req.body;
    const ownerAppId = (owner_app_id && owner_app_id !== 'null' && owner_app_id !== '')
      ? String(owner_app_id)
      : null;

    if (!amount || Number(amount) <= 0) return err(res, 'amount must be > 0', 400);
    if (!currency) return err(res, 'currency is required', 400);
    if (!paid_on) return err(res, 'paid_on is required', 400);
    if (!payment_method_id) return err(res, 'payment_method_id is required', 400);

    // Validate supplier exists. Pull primary_email so we can default the
    // notify recipient when the operator opts in but doesn't override.
    const [[s]] = await pool.query(
      'SELECT id, name, code, primary_email FROM suppliers WHERE id = ?',
      [supplierId]
    );
    if (!s) return err(res, 'Supplier not found', 404);

    // Validate payment method exists + active
    const [[pm]] = await pool.query(
      'SELECT id, name FROM payment_methods WHERE id = ? AND is_active = 1',
      [payment_method_id]
    );
    if (!pm) return err(res, 'Payment method not found or inactive', 400);

    const id = require('crypto').randomBytes(16).toString('hex');
    const attachmentPath = req.file ? `/uploads/${path.basename(req.file.path)}` : null;
    const attachmentAbs = req.file ? req.file.path : null;

    await pool.query(
      `INSERT INTO supplier_topups
         (id, supplier_id, amount, currency, paid_on, payment_method_id,
          reference, attachment_path, notes, created_by, owner_app_id)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, supplierId, Number(amount), currency, paid_on, payment_method_id,
       reference || null, attachmentPath, notes || null, req.platformUser.id,
       ownerAppId]
    );

    // Foreground re-sync so the new top-up appears in the ledger right away
    // (otherwise the user would have to wait up to 10 min for the next tick).
    await syncSupplierTransactionsCache().catch(() => {});

    const [[created]] = await pool.query(
      `SELECT t.*, pm.name AS payment_method_name
         FROM supplier_topups t
         LEFT JOIN payment_methods pm ON pm.id = t.payment_method_id
        WHERE t.id = ?`,
      [id]
    );

    // Optional supplier notification. Doesn't block the topup if mail fails —
    // the row is already saved and the operator can resend via the audit
    // log if needed.
    let emailResult = null;
    const wantNotify = ['true', '1', 'on', 'yes'].includes(String(notify_supplier || '').toLowerCase());
    if (wantNotify) {
      const recipient = String(notify_email || '').trim() || s.primary_email || null;
      const ccArr = String(notify_cc || '')
        .split(/[,;\n]+/).map(x => x.trim()).filter(Boolean);
      if (!recipient) {
        emailResult = { ok: false, error: 'No supplier email on file. Add primary_email to the supplier or supply notify_email.' };
      } else {
        // Use the operator's edited subject/body from the preview dialog if
        // present; otherwise re-render the default. Mirroring the same
        // template guarantees what they previewed = what gets sent.
        let subject = typeof notify_subject === 'string' && notify_subject.trim()
          ? notify_subject.trim() : null;
        let html = typeof notify_html === 'string' && notify_html.trim()
          ? notify_html : null;
        if (!subject || !html) {
          const rendered = renderSupplierTopupEmail({
            supplier: { name: s.name, code: s.code },
            topup: {
              amount, currency, paid_on,
              reference: reference || null,
              payment_method_name: pm.name,
              notes: notes || null,
            },
            hasAttachment: !!attachmentAbs,
          });
          subject = subject || rendered.subject;
          html = html || rendered.html;
        }
        emailResult = await sendSupplierTopupEmail({
          to: recipient,
          cc: ccArr,
          subject,
          html,
          attachmentAbs,
          actingUser: req.platformUser,
        });
      }
    }

    // Audit — finance needs to know who paid which supplier when, and
    // whether a confirmation email went out.
    await recordAuditEvent(req, {
      action: 'supplier_topup_create',
      entityType: 'supplier',
      entityId: supplierId,
      before: null,
      after: {
        topup_id: id, amount: Number(amount), currency, paid_on,
        payment_method: pm.name, reference: reference || null,
        supplier_name: s.name, supplier_code: s.code,
        notify_supplier: !!wantNotify,
        notify_recipient: emailResult ? (notify_email || s.primary_email || null) : null,
        notify_status: emailResult ? (emailResult.ok ? 'sent' : 'failed') : 'skipped',
        notify_error: emailResult && !emailResult.ok ? emailResult.error : null,
      },
      reason: notes || null,
    });
    // Surface email outcome to the client so the toast can say
    // "Top-up saved. Email sent / failed".
    ok(res, { ...created, _email: emailResult });
  } catch (e) { err(res, e.message, 500); }
});

// DELETE /api/suppliers/:id/topups/:topupId  — soft-delete + re-sync
app.delete('/api/suppliers/:id/topups/:topupId', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    // Snapshot the row BEFORE deletion so the audit log captures what was
    // there. Skipped if the row is already gone (idempotent delete).
    const [[before]] = await pool.query(
      `SELECT id, supplier_id, amount, currency, paid_on, reference, notes
         FROM supplier_topups WHERE id = ? AND supplier_id = ? AND deleted = 0`,
      [req.params.topupId, req.params.id]
    );
    await pool.query(
      'UPDATE supplier_topups SET deleted = 1 WHERE id = ? AND supplier_id = ?',
      [req.params.topupId, req.params.id]
    );
    await syncSupplierTransactionsCache().catch(() => {});
    if (before) {
      await recordAuditEvent(req, {
        action: 'supplier_topup_delete',
        entityType: 'supplier',
        entityId: req.params.id,
        before,
        after: null,
        reason: req.body?.reason || null,
      });
    }
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/supplier-balances/sync — manual re-sync trigger (Ops only)
app.post('/api/supplier-balances/sync', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const result = await syncSupplierTransactionsCache();
    ok(res, result);
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/supplier-balances/sync/status — diagnostic
app.get('/api/supplier-balances/sync/status', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const [[count]] = await pool.query('SELECT COUNT(*) AS c FROM supplier_transactions_cache');
    ok(res, {
      total_rows: count.c,
      sync_interval_ms: SUPPLIER_BALANCE_SYNC_INTERVAL_MS,
      last_run: _supplierSyncLast,
      currently_running: _supplierSyncRunning,
    });
  } catch (e) { err(res, e.message, 500); }
});

// ── Supplier reminders ──────────────────────────────────────────────────
// Track open requests we've sent to suppliers (HCN, cancellation, change,
// refund, …) and chase them when the supplier doesn't reply in time.
// Background job `checkSupplierReminders()` fans out notifications + email
// to Ops when next_reminder_at passes.

// GET /api/supplier-reminders?status=open|replied|resolved|dismissed|all
//   &supplier_id=...&request_type=...&search=...&limit=N&offset=N
app.get('/api/supplier-reminders', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const where = ['1=1'];
    const params = [];
    const status = String(req.query.status || 'open');
    if (status !== 'all') {
      const allowed = ['open', 'replied', 'resolved', 'dismissed'];
      const list = status.split(',').filter(s => allowed.includes(s));
      if (list.length === 0) return err(res, 'Invalid status', 400);
      where.push(`status IN (${list.map(() => '?').join(',')})`);
      params.push(...list);
    }
    if (req.query.supplier_id) { where.push('supplier_id = ?'); params.push(req.query.supplier_id); }
    if (req.query.request_type) { where.push('request_type = ?'); params.push(req.query.request_type); }
    if (req.query.booking_code) { where.push('booking_code = ?'); params.push(req.query.booking_code); }
    if (req.query.search) {
      const like = `%${req.query.search}%`;
      where.push('(supplier_name LIKE ? OR supplier_email LIKE ? OR booking_code LIKE ? OR subject LIKE ?)');
      params.push(like, like, like, like);
    }
    const limit = Math.min(500, Math.max(1, parseInt(req.query.limit) || 100));
    const offset = Math.max(0, parseInt(req.query.offset) || 0);
    const [rows] = await pool.query(
      `SELECT * FROM supplier_reminders
       WHERE ${where.join(' AND ')}
       ORDER BY
         CASE status
           WHEN 'open' THEN 0
           WHEN 'replied' THEN 1
           WHEN 'resolved' THEN 2
           WHEN 'dismissed' THEN 3
           ELSE 4
         END ASC,
         next_reminder_at ASC,
         created_at DESC
       LIMIT ? OFFSET ?`,
      [...params, limit, offset]
    );
    const [[count]] = await pool.query(
      `SELECT COUNT(*) AS c FROM supplier_reminders WHERE ${where.join(' AND ')}`,
      params
    );
    ok(res, { total: count.c, result: rows });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/supplier-reminders/:id — single
app.get('/api/supplier-reminders/:id', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const [[row]] = await pool.query(
      'SELECT * FROM supplier_reminders WHERE id = ?', [req.params.id]
    );
    if (!row) return err(res, 'Reminder not found', 404);
    ok(res, row);
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/supplier-reminders — create
//   Required: request_type, supplier_email or supplier_id+supplier_name
//   Optional: request_id, booking_id, booking_code, subject, message,
//             sla_hours, reminder_every_hours (override config defaults),
//             notes
app.post('/api/supplier-reminders', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const d = req.body || {};
    const request_type = String(d.request_type || '').toLowerCase();
    if (!SUPPLIER_REMINDER_TYPES.includes(request_type)) {
      return err(res, `request_type must be one of: ${SUPPLIER_REMINDER_TYPES.join(', ')}`, 400);
    }
    if (!d.supplier_email && !d.supplier_id && !d.supplier_name) {
      return err(res, 'supplier_email or supplier_id (or supplier_name) is required', 400);
    }

    // Pull SLA defaults from config; allow per-row overrides.
    const config = await getSupplierReminderSlaConfig();
    const conf = pickSupplierReminderSla(config, request_type);
    const slaHours = Number(d.sla_hours) > 0 ? Number(d.sla_hours) : Number(conf.sla_hours);
    const everyHours = Number(d.reminder_every_hours) > 0
      ? Number(d.reminder_every_hours) : Number(conf.reminder_every_hours);
    const maxLevel = Number(d.max_escalation_level) > 0
      ? Number(d.max_escalation_level) : Number(conf.max_escalation_level || 5);

    const id = crypto.randomUUID();
    const nextAt = new Date(Date.now() + slaHours * 60 * 60 * 1000);
    await pool.query(
      `INSERT INTO supplier_reminders
        (id, request_id, booking_id, booking_code, supplier_id, supplier_name, supplier_email,
         request_type, subject, message, sla_hours, reminder_every_hours, max_escalation_level,
         next_reminder_at, notes, created_by)
       VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
      [id, d.request_id || null, d.booking_id || null, d.booking_code || null,
       d.supplier_id || null, d.supplier_name || null, d.supplier_email || null,
       request_type, d.subject || null, d.message || null,
       slaHours, everyHours, maxLevel,
       toMysqlDatetime(nextAt.toISOString()),
       d.notes || null, req.platformUser.id]
    );
    const [[created]] = await pool.query('SELECT * FROM supplier_reminders WHERE id = ?', [id]);
    await recordAuditEvent(req, {
      action: 'supplier_reminder_create',
      entityType: 'supplier_reminder',
      entityId: id,
      before: null,
      after: created,
    });
    ok(res, created);
  } catch (e) { err(res, e.message, 500); }
});

// PATCH /api/supplier-reminders/:id — update status / SLA fields
//   status: 'replied' | 'resolved' | 'dismissed' | 'open' (re-open)
//   Editing: subject, message, supplier_email, supplier_name, sla_hours,
//   reminder_every_hours, max_escalation_level, notes
const REMINDER_EDITABLE = [
  'subject', 'message', 'supplier_email', 'supplier_name', 'notes',
  'sla_hours', 'reminder_every_hours', 'max_escalation_level',
];
app.patch('/api/supplier-reminders/:id', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const id = req.params.id;
    const [[before]] = await pool.query('SELECT * FROM supplier_reminders WHERE id = ?', [id]);
    if (!before) return err(res, 'Reminder not found', 404);
    const d = req.body || {};
    const sets = [];
    const params = [];
    for (const k of REMINDER_EDITABLE) {
      if (k in d) { sets.push(`${k} = ?`); params.push(d[k] === '' ? null : d[k]); }
    }
    if ('status' in d) {
      const s = String(d.status).toLowerCase();
      if (!['open', 'replied', 'resolved', 'dismissed'].includes(s)) {
        return err(res, 'Invalid status', 400);
      }
      sets.push('status = ?'); params.push(s);
      if (s === 'replied') {
        sets.push('replied_at = UTC_TIMESTAMP()');
        // Pause future reminders by pushing next_reminder_at far out.
        sets.push('next_reminder_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 DAY)');
      } else if (s === 'resolved' || s === 'dismissed') {
        sets.push('resolved_at = UTC_TIMESTAMP()');
        sets.push('resolved_by = ?'); params.push(req.platformUser.id);
        sets.push('next_reminder_at = DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 DAY)');
      } else if (s === 'open') {
        // Re-opening: schedule the next reminder using the row's cadence.
        const everyHours = Number(before.reminder_every_hours) || 12;
        const nextAt = new Date(Date.now() + everyHours * 60 * 60 * 1000);
        sets.push('next_reminder_at = ?'); params.push(toMysqlDatetime(nextAt.toISOString()));
        sets.push('replied_at = NULL'); sets.push('resolved_at = NULL');
      }
    }
    if (sets.length === 0) return err(res, 'No fields to update', 400);
    params.push(id);
    await pool.query(`UPDATE supplier_reminders SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[after]] = await pool.query('SELECT * FROM supplier_reminders WHERE id = ?', [id]);

    // When the reminder is no longer "open", remove any lingering bell
    // notifications attached to it for every recipient. Without this step
    // the `supplier_reminder_overdue` items keep showing in users' bells
    // even though there is nothing left to chase. We delete rather than
    // mark-read so the row also drops out of the "All" tab.
    if ('status' in d) {
      const newStatus = String(d.status).toLowerCase();
      if (['replied', 'resolved', 'dismissed'].includes(newStatus)) {
        try {
          await pool.query(
            `DELETE FROM notifications
             WHERE entity_type = 'supplier_reminder' AND entity_id = ?`,
            [id]
          );
        } catch (e) {
          console.warn('supplier_reminder notification cleanup failed:', e.message);
        }
      }
    }

    await recordAuditEvent(req, {
      action: 'supplier_reminder_update',
      entityType: 'supplier_reminder',
      entityId: id,
      before, after,
    });
    ok(res, after);
  } catch (e) { err(res, e.message, 500); }
});

// DELETE /api/supplier-reminders/:id — admin only
app.delete('/api/supplier-reminders/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [[before]] = await pool.query('SELECT * FROM supplier_reminders WHERE id = ?', [req.params.id]);
    if (!before) return err(res, 'Reminder not found', 404);
    await pool.query('DELETE FROM supplier_reminders WHERE id = ?', [req.params.id]);
    // Drop any bell notifications that referenced this reminder, otherwise
    // clicking them later would 404 the user on /supplier-reminders.
    try {
      await pool.query(
        `DELETE FROM notifications
         WHERE entity_type = 'supplier_reminder' AND entity_id = ?`,
        [req.params.id]
      );
    } catch (e) {
      console.warn('supplier_reminder notification cleanup failed:', e.message);
    }
    await recordAuditEvent(req, {
      action: 'supplier_reminder_delete',
      entityType: 'supplier_reminder',
      entityId: req.params.id,
      before, after: null,
    });
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/supplier-reminders/sla-config — current per-type config
app.get('/api/supplier-reminders-sla-config', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const config = await getSupplierReminderSlaConfig();
    ok(res, { types: SUPPLIER_REMINDER_TYPES, defaults: DEFAULT_SUPPLIER_REMINDER_SLA, config });
  } catch (e) { err(res, e.message, 500); }
});

// PATCH /api/supplier-reminders/sla-config — admin updates the per-type
//   config. Body shape: { hcn: { sla_hours, reminder_every_hours, max_escalation_level }, ... }
app.patch('/api/supplier-reminders-sla-config', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const body = req.body || {};
    const next = {};
    for (const t of SUPPLIER_REMINDER_TYPES) {
      const incoming = body[t];
      if (!incoming || typeof incoming !== 'object') continue;
      const sla = Number(incoming.sla_hours);
      const every = Number(incoming.reminder_every_hours);
      const max = Number(incoming.max_escalation_level);
      next[t] = {
        sla_hours: Number.isFinite(sla) && sla > 0 ? sla : DEFAULT_SUPPLIER_REMINDER_SLA[t].sla_hours,
        reminder_every_hours: Number.isFinite(every) && every > 0 ? every : DEFAULT_SUPPLIER_REMINDER_SLA[t].reminder_every_hours,
        max_escalation_level: Number.isFinite(max) && max > 0 ? Math.floor(max) : DEFAULT_SUPPLIER_REMINDER_SLA[t].max_escalation_level,
      };
    }
    if (Object.keys(next).length === 0) return err(res, 'No valid types in body', 400);
    await setSupplierReminderSlaConfig(next, req.platformUser.id);
    const config = await getSupplierReminderSlaConfig();
    ok(res, config);
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/supplier-reminders/run — admin: run the checker now (manual trigger)
app.post('/api/supplier-reminders-run', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    await checkSupplierReminders();
    ok(res, { ran: true });
  } catch (e) { err(res, e.message, 500); }
});

// ── Supplier balance v2 config endpoints ────────────────────────────────
//
// PATCH /api/suppliers/:id/balance-config
//   Set settlement policy + risk thresholds in one shot. All fields optional;
//   omitting a field leaves it unchanged. Pass `null` to clear (e.g. to
//   un-set a threshold). Triggers a foreground sync so the policy takes
//   effect immediately on the next /api/supplier-balances read.
const ALLOWED_BALANCE_FIELDS = [
  'settlement_currencies',
  'default_settlement_currency',
  'account_type',
  'critical_threshold',
  'credit_limit',
  'balance_baseline_date',
  // Per-supplier override for the alert cadence (minutes). NULL → use the
  // global 24h default. Min 15 min; max ~1 week.
  'critical_alert_cooldown_minutes',
];
app.patch('/api/suppliers/:id/balance-config', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const supplierId = req.params.id;
    // Snapshot the BEFORE state so the audit log can show the diff. Cheap
    // single-row read; one query before mutation keeps the trail honest.
    const [[beforeRow]] = await pool.query(
      `SELECT settlement_currencies, default_settlement_currency, account_type,
              critical_threshold, credit_limit, balance_baseline_date,
              critical_alert_cooldown_minutes
         FROM suppliers WHERE id = ?`,
      [supplierId]
    );
    const updates = [];
    const values = [];
    for (const field of ALLOWED_BALANCE_FIELDS) {
      if (!(field in req.body)) continue;
      let v = req.body[field];
      // Validation per field
      if (field === 'settlement_currencies') {
        if (v !== null && !Array.isArray(v)) return err(res, 'settlement_currencies must be array or null', 400);
        if (Array.isArray(v)) v = JSON.stringify(v.map(s => String(s).toUpperCase()));
      } else if (field === 'default_settlement_currency') {
        if (v != null) v = String(v).toUpperCase();
      } else if (field === 'account_type') {
        if (v !== 'deposit' && v !== 'credit') return err(res, "account_type must be 'deposit' or 'credit'", 400);
      } else if (field === 'critical_threshold' || field === 'credit_limit') {
        if (v != null) {
          v = Number(v);
          if (!Number.isFinite(v) || v < 0) return err(res, `${field} must be a non-negative number or null`, 400);
        }
      } else if (field === 'balance_baseline_date') {
        if (v != null) {
          // Accept "YYYY-MM-DD" or full ISO date strings
          const d = new Date(v);
          if (Number.isNaN(d.getTime())) return err(res, 'balance_baseline_date must be a valid date or null', 400);
          v = d.toISOString().slice(0, 10);
        }
      } else if (field === 'critical_alert_cooldown_minutes') {
        if (v != null) {
          v = Number(v);
          if (!Number.isFinite(v) || v < 15 || v > 10080) {
            return err(res, 'critical_alert_cooldown_minutes must be between 15 and 10080 (1 week) or null', 400);
          }
          v = Math.round(v);
        }
      }
      updates.push(`${field} = ?`);
      values.push(v);
    }
    if (updates.length === 0) return err(res, 'No allowed fields in request body', 400);
    values.push(supplierId);
    const [r] = await pool.query(
      `UPDATE suppliers SET ${updates.join(', ')} WHERE id = ?`,
      values
    );
    if (r.affectedRows === 0) return err(res, 'Supplier not found', 404);
    // Re-sync immediately so changes apply (currency routing changes need a
    // fresh ledger). Non-blocking: don't make the caller wait minutes.
    setImmediate(() => syncSupplierTransactionsCache().catch(() => {}));
    const [[supplier]] = await pool.query(
      `SELECT id, name, code, settlement_currencies, default_settlement_currency,
              account_type, critical_threshold, credit_limit, balance_baseline_date
         FROM suppliers WHERE id = ?`,
      [supplierId]
    );
    if (typeof supplier.settlement_currencies === 'string') {
      try { supplier.settlement_currencies = JSON.parse(supplier.settlement_currencies); }
      catch { supplier.settlement_currencies = null; }
    }
    // Audit — capturing the diff lets accounting see e.g. "threshold went
    // from 5000 USD to 1000 USD on 2026-05-03 by Sarah".
    await recordAuditEvent(req, {
      action: 'supplier_balance_config_update',
      entityType: 'supplier',
      entityId: supplierId,
      before: beforeRow || null,
      after: supplier,
      reason: req.body?.reason || null,
    });
    ok(res, supplier);
  } catch (e) { err(res, e.message, 500); }
});

// POST /api/suppliers/:id/baseline-reset
//   Sets balance_baseline_date = today (in UTC). After this, the supplier's
//   balance row only reflects activity from this point forward. Use this
//   after a manual end-of-month / end-of-quarter reconciliation when the
//   historical totals are no longer trusted.
app.post('/api/suppliers/:id/baseline-reset', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const today = new Date().toISOString().slice(0, 10);
    const [[before]] = await pool.query(
      `SELECT balance_baseline_date FROM suppliers WHERE id = ?`,
      [req.params.id]
    );
    const [r] = await pool.query(
      `UPDATE suppliers SET balance_baseline_date = ? WHERE id = ?`,
      [today, req.params.id]
    );
    if (r.affectedRows === 0) return err(res, 'Supplier not found', 404);
    // Audit — baseline reset zeros out historical activity for this
    // supplier in the dashboard view; finance must be able to trace it.
    await recordAuditEvent(req, {
      action: 'supplier_baseline_reset',
      entityType: 'supplier',
      entityId: req.params.id,
      before: { balance_baseline_date: before?.balance_baseline_date || null },
      after:  { balance_baseline_date: today },
      reason: req.body?.reason || null,
    });
    ok(res, { supplier_id: req.params.id, balance_baseline_date: today });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/audit-events — query the audit log. Ops only. Filters:
//   ?entity_type=supplier&entity_id=<id>  → ledger view (e.g. supplier page)
//   ?actor_user_id=<id>                   → user activity view
//   ?action=supplier_topup_create         → all events of one type
//   ?since=2026-01-01                     → date range
//   ?limit=200 (max 1000)
app.get('/api/audit-events', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const conds = ['1=1'];
    const params = [];
    if (req.query.entity_type) { conds.push('entity_type = ?'); params.push(req.query.entity_type); }
    if (req.query.entity_id)   { conds.push('entity_id = ?');   params.push(req.query.entity_id); }
    if (req.query.actor_user_id) { conds.push('actor_user_id = ?'); params.push(req.query.actor_user_id); }
    if (req.query.action)      { conds.push('action = ?');      params.push(req.query.action); }
    if (req.query.since) {
      const d = new Date(req.query.since);
      if (!Number.isNaN(d.getTime())) { conds.push('created_at >= ?'); params.push(d); }
    }
    const limit = Math.min(parseInt(req.query.limit) || 200, 1000);
    const [rows] = await pool.query(
      `SELECT id, actor_user_id, actor_email, actor_name, action,
              entity_type, entity_id, before_json, after_json, reason,
              ip_address, created_at
         FROM audit_events
        WHERE ${conds.join(' AND ')}
        ORDER BY created_at DESC
        LIMIT ?`,
      [...params, limit]
    );
    // Parse JSON columns for the client.
    for (const r of rows) {
      for (const k of ['before_json', 'after_json']) {
        if (r[k] && typeof r[k] === 'string') { try { r[k] = JSON.parse(r[k]); } catch {} }
      }
    }
    ok(res, rows);
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/bookings/:codeOrId/timeline — unified, time-sorted activity log
//   for one booking. Aggregates everything that happens to a booking from
//   creation through close: audit events (edits / price changes / etc.),
//   support requests (created, status changes, charges), support messages
//   (chat), emails (sent + received), and reviews. Ops-only.
//
//   The shape every event lands in: { id, kind, timestamp, title,
//   description, actor_name, actor_email, metadata }. Frontend just sorts +
//   renders without per-source branching.
//
//   Each source query is capped (`?limit=...` per-source, default 200) so a
//   booking with 5000 messages doesn't blow up the response. Total events
//   are returned sorted DESC.
app.get('/api/bookings/:codeOrId/timeline', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { codeOrId } = req.params;
    const perSource = Math.min(parseInt(req.query.limit) || 200, 1000);

    // Resolve booking — accept either id or code, then we scope every
    // sub-query by the right column. Both ids matter because legacy audits
    // sometimes wrote one and not the other.
    const [[bk]] = await pool.query(
      `SELECT id, code, app_id, upstream_created, synced_at, raw
         FROM bookings_cache
        WHERE id = ? OR code = ?
        LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache', 404);

    const code = bk.code;
    const bookingId = bk.id;

    // ── 1) Audit events (booking edits, confirmation updates, price
    //    changes, reviews, etc.). Already entity-scoped to entity_id=code.
    const [auditRows] = await pool.query(
      `SELECT id, actor_user_id, actor_email, actor_name, action,
              before_json, after_json, reason, ip_address, created_at
         FROM audit_events
        WHERE entity_type = 'booking' AND (entity_id = ? OR entity_id = ?)
        ORDER BY created_at DESC
        LIMIT ?`,
      [code, bookingId, perSource]
    );

    // ── 2) Support requests created (one event per request).
    //    We fetch this first because the related tables (activity log,
    //    messages, charges, approvals) all key off `request_id`. By
    //    pre-fetching the request IDs and using `IN (?,?,...)` instead of
    //    JOINs, we sidestep the database's mixed-collation issue
    //    (legacy Supabase-migrated tables use utf8mb4_unicode_ci while
    //    newer ones default to utf8mb4_general_ci — JOINing across them
    //    fails with an "Illegal mix of collations" error).
    const [requestRows] = await pool.query(
      `SELECT id, request_number, request_type, request_title, change_type,
              status, triage_state, created_by, message, created_at
         FROM support_requests
        WHERE booking_code = ? OR booking_id = ?
        ORDER BY created_at DESC
        LIMIT ?`,
      [code, bookingId, perSource]
    );

    const requestIds = requestRows.map(r => r.id);
    const requestMeta = new Map(requestRows.map(r => [r.id, { request_number: r.request_number, request_title: r.request_title }]));

    // Helper: build a `request_id IN (?,?,...)` clause for a parameterized
    // query. Returns null when there are no IDs (caller should skip).
    const inClause = requestIds.length > 0
      ? `request_id IN (${requestIds.map(() => '?').join(',')})`
      : null;

    // ── 3) Support activity log — status changes, agent assignments,
    //    charge additions, etc. Surfaces request_number from the lookup
    //    map built above instead of JOINing the table.
    const [activityRows] = inClause ? await pool.query(
      `SELECT id, request_id, action_type, old_value, new_value,
              performed_by, performed_by_name, metadata, created_at
         FROM support_activity_log
        WHERE ${inClause}
        ORDER BY created_at DESC
        LIMIT ?`,
      [...requestIds, perSource]
    ) : [[]];

    // ── 4) Support messages (customer ↔ ops/app chat).
    const [messageRows] = inClause ? await pool.query(
      `SELECT id, request_id, sender_type, sender_name, message, created_at
         FROM support_messages
        WHERE ${inClause} AND is_deleted = FALSE
        ORDER BY created_at DESC
        LIMIT ?`,
      [...requestIds, perSource]
    ) : [[]];

    // ── 5) Charges / refunds.
    const [chargeRows] = inClause ? await pool.query(
      `SELECT id, request_id, charge_type, total, net_amount,
              sale_amount, currency, message, created_by, created_at
         FROM support_charges
        WHERE ${inClause}
        ORDER BY created_at DESC
        LIMIT ?`,
      [...requestIds, perSource]
    ) : [[]];

    // ── 6) Approval requests (proposed charges/changes).
    const [approvalRows] = inClause ? await pool.query(
      `SELECT id, request_id, kind, from_party, to_party,
              amount, currency, description, status,
              created_by_name, responded_by_name, responded_at,
              response_reason, created_at
         FROM support_approval_requests
        WHERE ${inClause}
        ORDER BY created_at DESC
        LIMIT ?`,
      [...requestIds, perSource]
    ) : [[]];

    // ── 7) Emails — both sent + received, scoped to this booking.
    //    Subjects already capture the gist; we don't include the body.
    const [emailRows] = await pool.query(
      `SELECT id, direction, source, from_email, from_name, to_emails,
              subject, snippet, received_at, supplier_id, support_request_id
         FROM emails
        WHERE (booking_code = ? OR booking_id = ?)
        ORDER BY received_at DESC
        LIMIT ?`,
      [code, bookingId, perSource]
    );

    // ── 8) Booking review (created/approved). One per booking.
    const [reviewRows] = await pool.query(
      `SELECT id, status, created_by, created_by_name, created_by_email, notes, created_at
         FROM booking_reviews
        WHERE booking_code = ? OR booking_id = ?
        ORDER BY created_at DESC`,
      [code, bookingId]
    );

    // ── Normalize into a single event shape ────────────────────────────
    const events = [];

    // Booking created — synthetic event from bookings_cache.
    if (bk.upstream_created || bk.synced_at) {
      events.push({
        id: `created_${bookingId}`,
        kind: 'booking_created',
        timestamp: bk.upstream_created || bk.synced_at,
        title: 'Booking created',
        description: `Booking ${code} entered the system`,
        actor_name: null,
        actor_email: null,
        metadata: { booking_id: bookingId, code, app_id: bk.app_id },
      });
    }

    for (const r of auditRows) {
      let before = r.before_json, after = r.after_json;
      try { if (typeof before === 'string') before = JSON.parse(before); } catch {}
      try { if (typeof after  === 'string') after  = JSON.parse(after);  } catch {}
      events.push({
        id: `audit_${r.id}`,
        kind: 'audit',
        action: r.action,
        timestamp: r.created_at,
        title: prettifyAuditAction(r.action),
        description: r.reason || null,
        actor_name: r.actor_name,
        actor_email: r.actor_email,
        metadata: { before, after, ip_address: r.ip_address },
      });
    }

    for (const r of requestRows) {
      events.push({
        id: `support_req_${r.id}`,
        kind: 'support_request_created',
        timestamp: r.created_at,
        title: `Support request opened: ${r.request_title || r.request_type || 'Untitled'}`,
        description: r.message ? truncate(r.message, 240) : null,
        actor_name: null,
        actor_email: null,
        metadata: {
          request_id: r.id,
          request_number: r.request_number,
          request_type: r.request_type,
          change_type: r.change_type,
          status: r.status,
          triage_state: r.triage_state,
        },
      });
    }

    for (const r of activityRows) {
      let metadata = r.metadata;
      try { if (typeof metadata === 'string') metadata = JSON.parse(metadata); } catch {}
      const m = requestMeta.get(r.request_id) || {};
      events.push({
        id: `activity_${r.id}`,
        kind: 'support_activity',
        timestamp: r.created_at,
        action: r.action_type,
        title: `Support: ${prettifyAuditAction(r.action_type)}`,
        description: r.old_value && r.new_value
          ? `${truncate(String(r.old_value), 60)} → ${truncate(String(r.new_value), 60)}`
          : (r.new_value ? truncate(String(r.new_value), 120) : null),
        actor_name: r.performed_by_name,
        actor_email: null,
        metadata: {
          request_id: r.request_id,
          request_number: m.request_number,
          old_value: r.old_value,
          new_value: r.new_value,
          extra: metadata,
        },
      });
    }

    for (const r of messageRows) {
      const m = requestMeta.get(r.request_id) || {};
      events.push({
        id: `msg_${r.id}`,
        kind: 'support_message',
        timestamp: r.created_at,
        title: `Message from ${r.sender_type === 'client' ? 'Customer' : r.sender_type === 'app' ? 'App' : 'Support'}`,
        description: truncate(r.message || '', 240),
        actor_name: r.sender_name,
        actor_email: null,
        metadata: { request_id: r.request_id, request_number: m.request_number, sender_type: r.sender_type },
      });
    }

    for (const r of chargeRows) {
      const num = (v) => v == null ? null : Number(v);
      const m = requestMeta.get(r.request_id) || {};
      events.push({
        id: `charge_${r.id}`,
        kind: r.charge_type === 'refund' ? 'refund' : 'charge',
        timestamp: r.created_at,
        title: r.charge_type === 'refund'
          ? `Refund: ${num(r.total)?.toFixed(2)} ${r.currency}`
          : `Charge: ${num(r.total)?.toFixed(2)} ${r.currency}`,
        description: r.message || null,
        actor_name: null,
        actor_email: null,
        metadata: {
          request_id: r.request_id,
          request_number: m.request_number,
          total: num(r.total),
          net_amount: num(r.net_amount),
          sale_amount: num(r.sale_amount),
          currency: r.currency,
        },
      });
    }

    for (const r of approvalRows) {
      const num = (v) => v == null ? null : Number(v);
      const m = requestMeta.get(r.request_id) || {};
      events.push({
        id: `approval_${r.id}`,
        kind: 'approval_request',
        timestamp: r.created_at,
        title: `Approval ${r.kind} from ${r.from_party} → ${r.to_party}${r.amount ? ` (${num(r.amount)?.toFixed(2)} ${r.currency || ''})` : ''}`,
        description: r.description || null,
        actor_name: r.created_by_name,
        actor_email: null,
        metadata: {
          request_id: r.request_id,
          request_number: m.request_number,
          status: r.status,
          responded_by_name: r.responded_by_name,
          responded_at: r.responded_at,
          response_reason: r.response_reason,
          amount: num(r.amount),
          currency: r.currency,
        },
      });
      if (r.responded_at) {
        events.push({
          id: `approval_response_${r.id}`,
          kind: 'approval_response',
          timestamp: r.responded_at,
          title: `Approval ${r.status} (${r.kind})`,
          description: r.response_reason || null,
          actor_name: r.responded_by_name,
          actor_email: null,
          metadata: { request_id: r.request_id, request_number: m.request_number, status: r.status },
        });
      }
    }

    for (const r of emailRows) {
      let toList = r.to_emails;
      try { if (typeof toList === 'string') toList = JSON.parse(toList); } catch {}
      events.push({
        id: `email_${r.id}`,
        kind: r.direction === 'outbound' ? 'email_sent' : 'email_received',
        timestamp: r.received_at,
        title: r.direction === 'outbound'
          ? `Email sent: ${truncate(r.subject || '(no subject)', 80)}`
          : `Email received: ${truncate(r.subject || '(no subject)', 80)}`,
        description: r.snippet ? truncate(r.snippet, 200) : null,
        actor_name: r.from_name || r.from_email,
        actor_email: r.from_email,
        metadata: {
          email_id: r.id,
          direction: r.direction,
          source: r.source,
          to: toList,
          support_request_id: r.support_request_id,
          supplier_id: r.supplier_id,
        },
      });
    }

    for (const r of reviewRows) {
      events.push({
        id: `review_${r.id}`,
        kind: 'review',
        timestamp: r.created_at,
        title: `Booking review: ${r.status}`,
        description: r.notes || null,
        actor_name: r.created_by_name,
        actor_email: r.created_by_email,
        metadata: { review_id: r.id, status: r.status },
      });
    }

    // Sort newest-first. Stable across same-timestamp by event id.
    events.sort((a, b) => {
      const ta = new Date(a.timestamp).getTime();
      const tb = new Date(b.timestamp).getTime();
      if (tb !== ta) return tb - ta;
      return String(b.id).localeCompare(String(a.id));
    });

    ok(res, {
      booking_code: code,
      booking_id: bookingId,
      total: events.length,
      events,
    });
  } catch (e) { err(res, e.message, 500); }
});

// Pretty-print a snake_case audit/activity action code for the timeline UI.
function prettifyAuditAction(action) {
  if (!action) return 'event';
  return String(action).replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

// Truncate a long string to N chars + ellipsis. Defensive against null.
function truncate(s, n) {
  if (s == null) return '';
  const str = String(s);
  return str.length > n ? str.slice(0, n - 1) + '…' : str;
}

// POST /api/suppliers/check-critical — manual trigger of the alert check
//   (also runs automatically after every supplier sync). Useful for testing.
app.post('/api/suppliers/check-critical', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const result = await checkSupplierCriticalThresholds();
    ok(res, result);
  } catch (e) { err(res, e.message, 500); }
});

// ── Finance team alert recipients (system-wide) ─────────────────────────
//
// GET /api/system-settings/finance-team-emails  → ["a@x.com", ...]
// PUT /api/system-settings/finance-team-emails  body: { emails: [...] }
//
// One canonical list used by the supplier critical-threshold alerts.
app.get('/api/system-settings/finance-team-emails', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const emails = await loadFinanceTeamEmails();
    ok(res, { emails });
  } catch (e) { err(res, e.message, 500); }
});

app.put('/api/system-settings/finance-team-emails', authMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const emails = Array.isArray(req.body?.emails) ? req.body.emails : null;
    if (!emails) return err(res, 'emails must be an array', 400);
    // Light validation — we don't bounce-check, just shape-check.
    const cleaned = emails.map(s => String(s).trim()).filter(s => s.includes('@'));
    await pool.query(
      `INSERT INTO system_settings (\`key\`, value, updated_by)
       VALUES ('finance_team_emails', ?, ?)
       ON DUPLICATE KEY UPDATE value = VALUES(value), updated_by = VALUES(updated_by)`,
      [JSON.stringify(cleaned), req.platformUser?.id || null]
    );
    ok(res, { emails: cleaned });
  } catch (e) { err(res, e.message, 500); }
});

// ── Payment methods (admin-managed lookup) ─────────────────────────────
//
// Powers the payment_method_id dropdown on the supplier top-up form, plus
// the Settings → Payment Methods CRUD tab. Operators upload their own
// list (Wise, Bank Transfer, Crypto, etc.) so the form stays useful as
// they add channels without redeploying.

app.get('/api/payment-methods', authMiddleware, async (req, res) => {
  try {
    // Show all (active + inactive) to admins; only active to others.
    const isAdmin = perms.isAdminRole(req.platformUser);
    const where = isAdmin ? '' : 'WHERE is_active = 1';
    const [rows] = await pool.query(
      `SELECT id, name, code, description, is_active, sort_order, created_at, updated_at
         FROM payment_methods ${where}
         ORDER BY is_active DESC, sort_order ASC, name ASC`
    );
    ok(res, rows);
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/payment-methods', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { name, code, description, sort_order, is_active } = req.body;
    if (!name || !code) return err(res, 'name and code are required', 400);

    const id = require('crypto').randomBytes(16).toString('hex');
    await pool.query(
      `INSERT INTO payment_methods (id, name, code, description, sort_order, is_active)
       VALUES (?, ?, ?, ?, ?, ?)`,
      [id, name, code, description || null, Number(sort_order) || 0, is_active === false ? 0 : 1]
    );
    const [[created]] = await pool.query('SELECT * FROM payment_methods WHERE id = ?', [id]);
    ok(res, created);
  } catch (e) {
    if (e.code === 'ER_DUP_ENTRY') return err(res, 'A payment method with this code already exists', 409);
    err(res, e.message, 500);
  }
});

app.patch('/api/payment-methods/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const fields = ['name', 'code', 'description', 'sort_order', 'is_active'];
    const sets = [];
    const params = [];
    for (const f of fields) {
      if (req.body[f] !== undefined) {
        sets.push(`${f} = ?`);
        params.push(f === 'is_active' ? (req.body[f] ? 1 : 0) : req.body[f]);
      }
    }
    if (sets.length === 0) return err(res, 'No fields to update', 400);
    params.push(req.params.id);
    await pool.query(`UPDATE payment_methods SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM payment_methods WHERE id = ?', [req.params.id]);
    ok(res, row);
  } catch (e) {
    if (e.code === 'ER_DUP_ENTRY') return err(res, 'A payment method with this code already exists', 409);
    err(res, e.message, 500);
  }
});

app.delete('/api/payment-methods/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Soft-deactivate so historical top-ups still resolve their payment
    // method name. A hard delete would leave dangling FKs in supplier_topups.
    await pool.query('UPDATE payment_methods SET is_active = 0 WHERE id = ?', [req.params.id]);
    ok(res, { deactivated: true });
  } catch (e) { err(res, e.message, 500); }
});

// ── Match engine (shared + /test endpoint) ─────────────────────────────

// Run a normalized email object through every active rule and return which
// supplier matched + which field extractions fired. Used by both ingestion
// and the admin's "Test match" panel.
async function runMatchEngine({ from_email, subject, body_text, headers }) {
  const fromEmail = (from_email || '').toLowerCase();
  const fromDomain = fromEmail.includes('@') ? fromEmail.split('@')[1] : '';
  subject = subject || '';
  body_text = body_text || '';

  // 1. Find the supplier.
  const [rules] = await pool.query(
    `SELECT r.*, s.name AS supplier_name
     FROM supplier_email_rules r
     JOIN suppliers s ON s.id = r.supplier_id
     WHERE r.is_active = 1 AND s.is_active = 1
     ORDER BY r.priority ASC, r.id ASC`
  );
  let matchedSupplierId = null;
  let matchedSupplierName = null;
  let matchedRule = null;
  for (const r of rules) {
    let hit = false;
    try {
      if (r.rule_type === 'exact_email') {
        hit = fromEmail === r.pattern.toLowerCase();
      } else if (r.rule_type === 'domain_wildcard') {
        // support '*@foo.com' or plain 'foo.com'
        const dom = r.pattern.replace(/^\*@/, '').toLowerCase();
        hit = fromDomain === dom;
      } else if (r.rule_type === 'subject_regex') {
        hit = new RegExp(r.pattern, 'i').test(subject);
      } else if (r.rule_type === 'body_regex') {
        hit = new RegExp(r.pattern, 'i').test(body_text);
      }
    } catch (_) { /* bad regex in pattern → skip */ }
    if (hit) {
      matchedSupplierId = r.supplier_id;
      matchedSupplierName = r.supplier_name;
      matchedRule = r;
      break;
    }
  }

  // 2. Run extraction rules: supplier-specific first, then global fallbacks.
  const extracts = [];
  if (matchedSupplierId) {
    const [sr] = await pool.query(
      'SELECT * FROM supplier_extraction_rules WHERE supplier_id = ? AND is_active = 1 ORDER BY field_name ASC, priority ASC',
      [matchedSupplierId]
    );
    extracts.push(...sr);
  }
  const [global] = await pool.query(
    'SELECT * FROM supplier_extraction_rules WHERE supplier_id IS NULL AND is_active = 1 ORDER BY field_name ASC, priority ASC'
  );
  extracts.push(...global);

  const parsed = {};
  for (const r of extracts) {
    if (parsed[r.field_name]) continue; // first capture wins per field
    const src = r.source === 'subject' ? subject
              : r.source === 'headers' ? JSON.stringify(headers || {})
              : body_text;
    try {
      const re = new RegExp(r.pattern, 'i');
      const m = src.match(re);
      if (m) parsed[r.field_name] = (m[1] || m[0] || '').trim();
      else if (r.fallback_value) parsed[r.field_name] = r.fallback_value;
    } catch (_) { /* bad regex → skip */ }
  }

  return {
    supplier_id: matchedSupplierId,
    supplier_name: matchedSupplierName,
    matched_rule: matchedRule ? { id: matchedRule.id, rule_type: matchedRule.rule_type, pattern: matchedRule.pattern } : null,
    extracted: parsed,
  };
}

// ── Deterministic booking matcher ───────────────────────────────────────
// Runs BEFORE the configured-rule engine and BEFORE the AI fallback.
// Catches the obvious case where an email contains an exact reference
// match (booking code, booking_ref, PNR, HCN, confirmation_number,
// supplier_reference) OR comes from a customer's saved contact email.
//
// Fields scanned per booking:
//   Top-level     : code, pnr, contact_email
//   Per-product   : data.booking_ref, data.pnr, data.confirmation_number,
//                   data.supplier_reference,
//                   data.selected_rooms[].hcn,
//                   data.flight_segments[].pnr / data.segments[].pnr
//
// Sender-email match:
//   - If `from_email` exists in supplier_contacts (any supplier) →
//     classification 'supplier', skip the customer-email match.
//   - Else if `from_email` matches exactly one booking's contact_email →
//     auto-link as 'customer'.
//   - Else if matches multiple → pick the most recent booking.
//
// Returns { booking_id, booking_code, classification, matched_via,
// matched_value, confidence } or null.
//
// Performance: in-memory scan of bookings_cache. Fine for a few thousand
// bookings (we have ~75 on staging right now). For 50k+ bookings we'd
// move to a `booking_refs` index table — see TODO at end of file.

// Tokenize an email (subject + body + snippet) into candidate
// reference strings. Conservative regex: identifiers are at least 4
// chars, mostly uppercase/digits, no spaces. Trims leading/trailing
// punctuation. Case preserved — booking codes are case-sensitive.
function extractRefTokens(text) {
  if (!text || typeof text !== 'string') return [];
  // Strip HTML tags (in case body_html slipped through) and decode
  // common entities so e.g. "DEMOS202302331" inside <span> still hits.
  const stripped = text
    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
    .replace(/<[^>]+>/g, ' ')
    .replace(/&nbsp;|&amp;|&lt;|&gt;|&quot;|&#39;/g, ' ');
  const tokens = new Set();
  // Match anything that looks like a reference. Order matters — wider
  // pattern first to catch hyphenated refs like "320-5293886" before the
  // narrower one.
  const re = /\b([A-Za-z0-9][A-Za-z0-9_/.\-]{2,30}[A-Za-z0-9])\b/g;
  let m;
  while ((m = re.exec(stripped)) !== null) {
    const t = m[1].trim();
    if (t.length < 4) continue;
    // Skip pure-numeric tokens shorter than 6 (years, ages, port numbers)
    if (/^\d+$/.test(t) && t.length < 6) continue;
    tokens.add(t);
  }
  return [...tokens];
}

async function findBookingByDeterministicMatch({ from_email, subject, body_text }, pool) {
  const tokens = extractRefTokens(`${subject || ''}\n${body_text || ''}`);
  if (tokens.length === 0 && !from_email) return null;
  const tokenSet = new Set(tokens);

  // Determine if sender is a known supplier first — used to suppress
  // customer-email matching when a supplier replies from a personal
  // address (e.g. their gmail).
  const fromEmailLc = (from_email || '').toLowerCase();
  let senderIsSupplier = false;
  if (fromEmailLc) {
    try {
      const [supRows] = await pool.query(
        `SELECT 1 FROM supplier_contacts WHERE type = 'email' AND LOWER(value) = ? LIMIT 1`,
        [fromEmailLc]
      );
      if (supRows.length > 0) senderIsSupplier = true;
      else {
        // Domain-wildcard rule check
        const fromDomain = fromEmailLc.split('@')[1] || '';
        if (fromDomain) {
          const [domRows] = await pool.query(
            `SELECT 1 FROM supplier_email_rules
              WHERE is_active = 1
                AND ((rule_type = 'domain_wildcard' AND LOWER(REPLACE(pattern, '*@', '')) = ?)
                  OR (rule_type = 'exact_email' AND LOWER(pattern) = ?))
              LIMIT 1`,
            [fromDomain, fromEmailLc]
          );
          if (domRows.length > 0) senderIsSupplier = true;
        }
      }
    } catch (e) { /* fall through */ }
  }

  // Pull every booking — we walk the cache to look for ref matches and
  // contact-email matches. Limited to columns we need + raw JSON.
  // For installations with >>5k bookings we can switch to a `booking_refs`
  // index table; for now an in-memory scan is fast enough.
  const [rows] = await pool.query(
    `SELECT id, code, pnr, contact_email, contact_name, raw, upstream_updated, synced_at
       FROM bookings_cache`
  );

  // Try ref tokens first — highest confidence, fewest false positives.
  // We rank by field type: code > booking_ref > pnr > hcn > confirmation_number
  // > supplier_reference. First strongest match wins.
  const FIELD_RANK = {
    code: { conf: 1.00, label: 'booking_code' },
    booking_ref: { conf: 1.00, label: 'booking_ref' },
    pnr: { conf: 0.95, label: 'pnr' },
    hcn: { conf: 0.95, label: 'hcn' },
    confirmation_number: { conf: 0.90, label: 'confirmation_number' },
    supplier_reference: { conf: 0.90, label: 'supplier_reference' },
  };

  let bestRefMatch = null;   // { booking, conf, label, value }

  for (const b of rows) {
    let raw = {};
    try { raw = typeof b.raw === 'string' ? JSON.parse(b.raw) : (b.raw || {}); } catch { continue; }

    const checks = [];
    // Top-level fields
    if (b.code) checks.push({ key: 'code', value: String(b.code) });
    if (b.pnr)  checks.push({ key: 'pnr',  value: String(b.pnr)  });

    // Per-product nested fields
    const products = Array.isArray(raw.products) ? raw.products : [];
    for (const p of products) {
      const d = p?.data || {};
      if (d.booking_ref)        checks.push({ key: 'booking_ref',        value: String(d.booking_ref) });
      if (d.pnr)                checks.push({ key: 'pnr',                value: String(d.pnr) });
      if (d.confirmation_number) checks.push({ key: 'confirmation_number', value: String(d.confirmation_number) });
      if (d.supplier_reference) checks.push({ key: 'supplier_reference', value: String(d.supplier_reference) });
      const rooms = Array.isArray(d.selected_rooms) ? d.selected_rooms : [];
      for (const r of rooms) {
        if (r?.hcn) checks.push({ key: 'hcn', value: String(r.hcn) });
      }
      const segs = Array.isArray(d.flight_segments || d.segments) ? (d.flight_segments || d.segments) : [];
      for (const s of segs) {
        if (s?.pnr) checks.push({ key: 'pnr', value: String(s.pnr) });
      }
    }

    for (const c of checks) {
      if (!tokenSet.has(c.value)) continue;
      const r = FIELD_RANK[c.key];
      if (!r) continue;
      if (!bestRefMatch || r.conf > bestRefMatch.conf) {
        bestRefMatch = { booking: b, conf: r.conf, label: r.label, value: c.value };
      }
    }
  }

  if (bestRefMatch) {
    return {
      booking_id: bestRefMatch.booking.id,
      booking_code: bestRefMatch.booking.code,
      classification: senderIsSupplier ? 'supplier' : 'customer',
      matched_via: bestRefMatch.label,
      matched_value: bestRefMatch.value,
      confidence: bestRefMatch.conf,
    };
  }

  // No token match. Try sender-email → customer match. Only when sender
  // is NOT a supplier (otherwise it's a supplier replying to a customer
  // chain and we shouldn't link as customer).
  if (fromEmailLc && !senderIsSupplier) {
    const customerHits = rows.filter(b => (b.contact_email || '').toLowerCase() === fromEmailLc);
    if (customerHits.length > 0) {
      // Multiple bookings for the same customer? Pick the most recently
      // updated upstream — most likely to be the one this email is about.
      customerHits.sort((a, b) => {
        const ad = a.upstream_updated || a.synced_at || '';
        const bd = b.upstream_updated || b.synced_at || '';
        return String(bd).localeCompare(String(ad));
      });
      const winner = customerHits[0];
      return {
        booking_id: winner.id,
        booking_code: winner.code,
        classification: 'customer',
        matched_via: 'customer_email',
        matched_value: fromEmailLc,
        confidence: customerHits.length === 1 ? 0.90 : 0.75,
      };
    }
  }

  return null;
}

// Dry-run endpoint for the Supplier Setup UI's "Test match" panel.
// Returns BOTH the deterministic-match result (booking-code / sender-email
// scan) AND the legacy supplier-rule engine result so the operator can
// see which layer of the linker is producing what.
app.post('/api/email-match/test', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const body = req.body || {};
    const probe = {
      from_email: body.from_email || '',
      subject:    body.subject || '',
      body_text:  body.body || body.body_text || '',
      headers:    body.headers || {},
    };
    const [detMatch, ruleMatch] = await Promise.all([
      findBookingByDeterministicMatch(probe, pool).catch(e => ({ _error: e.message })),
      runMatchEngine(probe).catch(e => ({ _error: e.message })),
    ]);
    ok(res, {
      tokens_extracted: extractRefTokens(`${probe.subject}\n${probe.body_text}`),
      deterministic_match: detMatch,                       // null if no hit
      supplier_rule_match: ruleMatch,                      // existing engine
      // Convenience flag — would the live ingest auto-link this email?
      would_auto_link: !!(detMatch && detMatch.booking_id),
    });
  } catch (e) { err(res, e.message, 500); }
});

// ── Per-App settings (Admin only) ─────────────────────────────────────
// Lets system admins flag Apps that don't have their own operation team, so
// agency/customer tickets bypass App review and go straight to Ops.

// GET — list all apps with their current setting (merging upstream apps list
// with whatever rows exist in app_settings). Admin-only.
// Parse a JSON-array value that may come back as string or native array.
function parseJsonArray(v) {
  if (!v) return [];
  if (Array.isArray(v)) return v;
  if (typeof v === 'string') { try { const p = JSON.parse(v); return Array.isArray(p) ? p : []; } catch { return []; } }
  return [];
}

app.get('/api/app-settings', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Pull all apps from upstream so the admin sees the full catalogue.
    let upstream = [];
    try {
      const data = await easygdsRequest('/apps/list?per_page=200');
      upstream = data?.result || data || [];
    } catch (e) { /* if upstream fails, fall back to whatever is in app_settings */ }
    const [rows] = await pool.query('SELECT * FROM app_settings');
    const settingsByApp = new Map(rows.map(r => [r.app_id, r]));
    const merged = upstream.map(a => {
      const s = settingsByApp.get(a.id);
      return {
        app_id: a.id,
        app_code: a.code || null,
        app_name: a.name || a.code || a.id,
        // is_enabled defaults to TRUE when no row exists yet — apps are
        // handled by default; admin opts out per-app.
        is_enabled: s ? Number(s.is_enabled) !== 0 : true,
        skip_app_review: s ? Number(s.skip_app_review) === 1 : false,
        notes: s?.notes || '',
        bcc_emails: parseJsonArray(s?.bcc_emails),
        updated_at: s?.updated_at || null,
        updated_by: s?.updated_by || null,
      };
    });
    // Also include any orphan settings rows whose app isn't in the upstream list.
    for (const r of rows) {
      if (!merged.find(m => m.app_id === r.app_id)) {
        merged.push({
          app_id: r.app_id,
          app_code: r.app_code || null,
          app_name: r.app_name || `(unknown: ${r.app_id.slice(0, 8)}…)`,
          is_enabled: Number(r.is_enabled) !== 0,
          skip_app_review: Number(r.skip_app_review) === 1,
          notes: r.notes || '',
          bcc_emails: parseJsonArray(r.bcc_emails),
          updated_at: r.updated_at,
          updated_by: r.updated_by,
        });
      }
    }
    merged.sort((a, b) => (a.app_name || '').localeCompare(b.app_name || ''));
    ok(res, merged);
  } catch (e) { err(res, e.message, 500); }
});

// GET upstream BCC for a single app — lazy-loaded by the AppReviewSettings
// drawer when an admin expands a row. We don't include this in the list
// response because it would need a /apps?id=... round trip per app (43+
// calls for a full list). Using loadAppMeta's cache so repeated expansions
// hit upstream at most once per 60 s.
app.get('/api/app-settings/:appId/upstream-bcc', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const meta = await loadAppMeta(req.params.appId);
    ok(res, {
      app_id: req.params.appId,
      upstream_bcc_emails: meta.upstream_bcc_emails || [],
      app_name: meta.name || null,
    });
  } catch (e) { err(res, e.message, 500); }
});

// PATCH — upsert a single App's settings. Admin-only.
// Accepts: skip_app_review, notes, bcc_emails (array of strings).
app.patch('/api/app-settings/:appId', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const appId = req.params.appId;
    const d = req.body || {};

    // Normalise BCC list: strings, trimmed, non-empty, basic email shape check.
    let bccList = null;
    if ('bcc_emails' in d) {
      const raw = Array.isArray(d.bcc_emails) ? d.bcc_emails
                : typeof d.bcc_emails === 'string' ? d.bcc_emails.split(/[,;]+/)
                : [];
      bccList = raw.map(s => String(s || '').trim()).filter(s => s && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
    }

    // Try to capture name/code from upstream so outbound email subjects can
    // include the app name without a second round trip.
    let appName = null, appCode = null;
    try {
      const data = await easygdsRequest(`/apps?id=${encodeURIComponent(appId)}`);
      appName = data?.name || null;
      appCode = data?.code || null;
    } catch { /* upstream hiccup — don't block the save */ }

    // Build the UPDATE set dynamically so callers can patch partial fields.
    const sets = { updated_by: user.id };
    if ('skip_app_review' in d) sets.skip_app_review = (d.skip_app_review === true || d.skip_app_review === 1) ? 1 : 0;
    if ('is_enabled' in d) sets.is_enabled = (d.is_enabled === true || d.is_enabled === 1) ? 1 : 0;
    if ('notes' in d) sets.notes = typeof d.notes === 'string' ? d.notes : null;
    if (bccList !== null) sets.bcc_emails = JSON.stringify(bccList);
    if (appName) sets.app_name = appName;
    if (appCode) sets.app_code = appCode;

    // UPSERT — INSERT fields include required columns; update mirrors sets.
    const cols = ['app_id', 'skip_app_review', 'is_enabled', 'notes', 'bcc_emails', 'app_name', 'app_code', 'updated_by'];
    const vals = [appId,
                  sets.skip_app_review ?? 0,
                  sets.is_enabled ?? 1,
                  sets.notes ?? null,
                  sets.bcc_emails ?? null,
                  sets.app_name ?? null,
                  sets.app_code ?? null,
                  user.id];
    const updateClauses = Object.keys(sets).map(k => `${k} = VALUES(${k})`).join(', ');
    await pool.query(
      `INSERT INTO app_settings (${cols.join(', ')})
       VALUES (${cols.map(() => '?').join(', ')})
       ON DUPLICATE KEY UPDATE ${updateClauses}`,
      vals
    );

    invalidateAppMeta(appId); // email helper caches this — force a refresh.
    invalidateEnabledAppsCache(); // is_enabled may have flipped — refresh the filter cache too.

    const [[row]] = await pool.query('SELECT * FROM app_settings WHERE app_id = ?', [appId]);
    ok(res, {
      app_id: row.app_id,
      app_code: row.app_code,
      app_name: row.app_name,
      is_enabled: Number(row.is_enabled) !== 0,
      skip_app_review: Number(row.skip_app_review) === 1,
      notes: row.notes || '',
      bcc_emails: parseJsonArray(row.bcc_emails),
      updated_at: row.updated_at,
      updated_by: row.updated_by,
    });
  } catch (e) { err(res, e.message, 500); }
});

// ── Platform User Management (Admin only) ──

app.get('/api/users', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [rows] = await pool.query('SELECT id, email, first_name, last_name, role, is_active, allowed_app_ids, created_at, last_login FROM platform_users ORDER BY created_at DESC');
    ok(res, rows.map(r => {
      if (r.allowed_app_ids && typeof r.allowed_app_ids === 'string') r.allowed_app_ids = JSON.parse(r.allowed_app_ids);
      return r;
    }));
  } catch (e) { err(res, e.message, 500); }
});

// ── Ops Teams, Rules, and Action Approvals ────────────────────────────────

app.get('/api/ops/teams', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [teams] = await pool.query(
      `SELECT * FROM ops_teams
       WHERE is_active = 1
       ORDER BY name ASC`
    );
    const [members] = await pool.query(
      `SELECT m.team_id, m.user_id, m.role, m.created_at,
              u.email, u.first_name, u.last_name, u.role AS user_role
         FROM ops_team_members m
         JOIN ops_teams t ON t.id = m.team_id AND t.is_active = 1
         LEFT JOIN platform_users u ON u.id = m.user_id
        ORDER BY m.role DESC, u.first_name ASC, u.last_name ASC`
    );
    const byTeam = new Map();
    for (const m of members) {
      if (!byTeam.has(m.team_id)) byTeam.set(m.team_id, []);
      byTeam.get(m.team_id).push(m);
    }
    ok(res, teams.map(t => ({ ...t, members: byTeam.get(t.id) || [] })));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/ops/teams', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const name = String(req.body?.name || '').trim();
    if (!name) return err(res, 'Team name is required', 400);
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO ops_teams (id, name, created_by)
       VALUES (?, ?, ?)`,
      [id, name, req.platformUser.id]
    );
    const [[team]] = await pool.query('SELECT * FROM ops_teams WHERE id = ?', [id]);
    ok(res, { ...team, members: [] });
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/ops/teams/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const sets = [];
    const params = [];
    if ('name' in req.body) {
      const name = String(req.body.name || '').trim();
      if (!name) return err(res, 'Team name is required', 400);
      sets.push('name = ?'); params.push(name);
    }
    if ('is_active' in req.body) {
      sets.push('is_active = ?'); params.push(req.body.is_active ? 1 : 0);
    }
    if (sets.length === 0) return err(res, 'No fields to update', 400);
    params.push(req.params.id);
    await pool.query(`UPDATE ops_teams SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[team]] = await pool.query('SELECT * FROM ops_teams WHERE id = ?', [req.params.id]);
    ok(res, team);
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/ops/teams/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    await pool.query('UPDATE ops_teams SET is_active = 0 WHERE id = ?', [req.params.id]);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/ops/teams/:id/members', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const userId = String(req.body?.user_id || '').trim();
    const role = req.body?.role === 'leader' ? 'leader' : 'member';
    if (!userId) return err(res, 'user_id is required', 400);
    await pool.query(
      `INSERT INTO ops_team_members (team_id, user_id, role)
       VALUES (?, ?, ?)
       ON DUPLICATE KEY UPDATE role = VALUES(role)`,
      [req.params.id, userId, role]
    );
    ok(res, { team_id: req.params.id, user_id: userId, role });
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/ops/teams/:id/members/:userId', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    await pool.query(
      'DELETE FROM ops_team_members WHERE team_id = ? AND user_id = ?',
      [req.params.id, req.params.userId]
    );
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

app.get('/api/ops/permission-rules', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [rows] = await pool.query(
      `SELECT * FROM ops_permission_rules
       ORDER BY is_active DESC, created_at DESC`
    );
    ok(res, rows.map(normalizeOpsRule));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/ops/permission-rules', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const name = String(d.name || '').trim();
    const targetType = d.target_type === 'user' ? 'user' : 'team';
    const targetId = String(d.target_id || '').trim();
    const action = String(d.action || '');
    const effect = String(d.effect || '');
    if (!name || !targetId) return err(res, 'Rule name and target are required', 400);
    if (!OPS_RULE_ACTIONS.has(action)) return err(res, 'Unsupported rule action', 400);
    if (!OPS_RULE_EFFECTS.has(effect)) return err(res, 'Unsupported rule effect', 400);
    const conditions = {
      app_ids: coerceStringArray(d.conditions?.app_ids),
      booking_statuses: normalizeConditionList(d.conditions?.booking_statuses),
      payment_statuses: normalizeConditionList(d.conditions?.payment_statuses),
    };
    const id = crypto.randomUUID();
    await pool.query(
      `INSERT INTO ops_permission_rules
         (id, name, target_type, target_id, action, effect, conditions_json, is_active, created_by)
       VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?)`,
      [id, name, targetType, targetId, action, effect, JSON.stringify(conditions), req.platformUser.id]
    );
    const [[row]] = await pool.query('SELECT * FROM ops_permission_rules WHERE id = ?', [id]);
    ok(res, normalizeOpsRule(row));
  } catch (e) { err(res, e.message, 500); }
});

app.patch('/api/ops/permission-rules/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body || {};
    const sets = [];
    const params = [];
    if ('name' in d) { sets.push('name = ?'); params.push(String(d.name || '').trim()); }
    if ('target_type' in d) { sets.push('target_type = ?'); params.push(d.target_type === 'user' ? 'user' : 'team'); }
    if ('target_id' in d) { sets.push('target_id = ?'); params.push(String(d.target_id || '').trim()); }
    if ('action' in d) {
      if (!OPS_RULE_ACTIONS.has(String(d.action))) return err(res, 'Unsupported rule action', 400);
      sets.push('action = ?'); params.push(String(d.action));
    }
    if ('effect' in d) {
      if (!OPS_RULE_EFFECTS.has(String(d.effect))) return err(res, 'Unsupported rule effect', 400);
      sets.push('effect = ?'); params.push(String(d.effect));
    }
    if ('conditions' in d) {
      const conditions = {
        app_ids: coerceStringArray(d.conditions?.app_ids),
        booking_statuses: normalizeConditionList(d.conditions?.booking_statuses),
        payment_statuses: normalizeConditionList(d.conditions?.payment_statuses),
      };
      sets.push('conditions_json = ?'); params.push(JSON.stringify(conditions));
    }
    if ('is_active' in d) { sets.push('is_active = ?'); params.push(d.is_active ? 1 : 0); }
    if (sets.length === 0) return err(res, 'No fields to update', 400);
    params.push(req.params.id);
    await pool.query(`UPDATE ops_permission_rules SET ${sets.join(', ')} WHERE id = ?`, params);
    const [[row]] = await pool.query('SELECT * FROM ops_permission_rules WHERE id = ?', [req.params.id]);
    ok(res, normalizeOpsRule(row));
  } catch (e) { err(res, e.message, 500); }
});

// Hard delete a permission rule. Earlier this endpoint only flipped
// `is_active = 0` (soft-disable), but the UI's trash icon led operators to
// think the rule was gone — and the GET endpoint still returned disabled
// rules so they kept showing up. We now remove the row outright and write
// an audit_events entry so finance/security can trace who removed which
// rule. There's no undo; the operator must re-create if they misclick.
app.delete('/api/ops/permission-rules/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Snapshot the rule first so the audit log has a before-image.
    const [[existing]] = await pool.query(
      'SELECT * FROM ops_permission_rules WHERE id = ? LIMIT 1',
      [req.params.id]
    );
    if (!existing) return err(res, 'Rule not found', 404);

    const [result] = await pool.query(
      'DELETE FROM ops_permission_rules WHERE id = ?',
      [req.params.id]
    );
    if (result.affectedRows === 0) return err(res, 'Rule not found', 404);

    await recordAuditEvent(req, {
      action: 'ops_permission_rule_delete',
      entityType: 'ops_permission_rule',
      entityId: req.params.id,
      before: normalizeOpsRule(existing),
      after: null,
      reason: typeof req.body?.reason === 'string' ? req.body.reason.trim() : null,
    });

    ok(res, { deleted: true, id: req.params.id });
  } catch (e) { err(res, e.message, 500); }
});

// GET /api/ops/can — "can the current user perform <action> on this <booking>?"
//   Query params:
//     action  — required, one of OPS_RULE_ACTIONS (e.g. support_ticket.create)
//     booking — optional booking id or code. When omitted, conditions
//               that depend on a booking subject (booking_status,
//               payment_status, app_id) are evaluated against an empty
//               subject, which means deny rules with those conditions
//               won't match — useful for blanket "can this user create
//               any support ticket at all" checks.
//   Returns: { effect: 'allow'|'deny'|'require_approval', matched: Rule[] }
//   The client uses this to gate UI affordances (disable buttons, swap
//   labels) so non-admins don't see options the server will refuse.
//   Admins/managers always get `allow` because team rules don't apply to
//   them (see getEffectiveOpsRules).
app.get('/api/ops/can', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const action = String(req.query.action || '');
    if (!OPS_RULE_ACTIONS.has(action)) {
      return err(res, `Unsupported action. Allowed: ${Array.from(OPS_RULE_ACTIONS).join(', ')}`, 400);
    }
    // Evaluate Ops rules for any authenticated user — team membership
    // and direct user-targeted rules are the source of truth, not
    // login_context. evaluateOpsRules returns allow when no rules
    // apply, so this is safe for users outside the Ops permission
    // system entirely.
    const subject = await loadBookingRuleSubject(req.query.booking || null, null);
    const decision = await evaluateOpsRules(user, action, subject);
    ok(res, { effect: decision.effect, matched: decision.matched });
  } catch (e) { err(res, e.message, 500); }
});

app.get('/api/ops/action-approvals', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    if (user.login_context !== 'system') return err(res, 'Ops only', 403);
    const status = String(req.query.status || 'pending');
    const params = [];
    let sql = `SELECT DISTINCT a.* FROM ops_action_approval_requests a`;
    if (!(user.role === 'admin' || user.role === 'manager')) {
      sql += `
        JOIN ops_team_members member ON member.user_id = a.requester_id AND member.role = 'member'
        JOIN ops_team_members leader ON leader.team_id = member.team_id AND leader.user_id = ? AND leader.role = 'leader'`;
      params.push(user.id);
    }
    sql += ' WHERE 1=1';
    if (status !== 'all') { sql += ' AND a.status = ?'; params.push(status); }
    sql += ' ORDER BY a.created_at DESC';
    const [rows] = await pool.query(sql, params);
    ok(res, rows.map(parseOpsActionApproval));
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/ops/action-approvals/:id/approve', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const [[row]] = await pool.query('SELECT * FROM ops_action_approval_requests WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Approval request not found', 404);
    const approval = parseOpsActionApproval(row);
    if (!(await canManageOpsApproval(user, approval))) return err(res, 'Forbidden', 403);
    if (approval.status !== 'pending') return err(res, 'Approval request is no longer pending', 409);

    const [claimed] = await pool.query(
      `UPDATE ops_action_approval_requests
          SET status = 'approved',
              responded_by = ?,
              responded_by_name = ?,
              responded_at = UTC_TIMESTAMP()
        WHERE id = ? AND status = 'pending'`,
      [user.id, opsActorName(user), req.params.id]
    );
    if (claimed.affectedRows !== 1) return err(res, 'Approval request is no longer pending', 409);

    const [[requesterRow]] = await pool.query(
      'SELECT id, email, first_name, last_name, role FROM platform_users WHERE id = ?',
      [approval.requester_id]
    );
    const requester = requesterRow
      ? { ...requesterRow, login_context: 'system' }
      : {
          id: approval.requester_id,
          email: approval.requester_email,
          first_name: approval.requester_name,
          last_name: '',
          role: 'agent',
          login_context: 'system',
        };

    let created;
    try {
      created = await createSupportRequestCore(requester, approval.payload_json || {});
    } catch (e) {
      await pool.query(
        `UPDATE ops_action_approval_requests
            SET status = 'rejected',
                response_reason = ?,
                responded_at = UTC_TIMESTAMP()
          WHERE id = ?`,
        [`Approval failed while creating the ticket: ${e.message}`, req.params.id]
      );
      return err(res, e.message, e.status || 500);
    }

    await pool.query(
      `UPDATE ops_action_approval_requests
          SET resulting_entity_type = 'support_request',
              resulting_entity_id = ?
        WHERE id = ?`,
      [created.id, req.params.id]
    );
    const [[updated]] = await pool.query('SELECT * FROM ops_action_approval_requests WHERE id = ?', [req.params.id]);
    ok(res, { approval: parseOpsActionApproval(updated), support_request: created });
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/ops/action-approvals/:id/reject', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const [[row]] = await pool.query('SELECT * FROM ops_action_approval_requests WHERE id = ?', [req.params.id]);
    if (!row) return err(res, 'Approval request not found', 404);
    const approval = parseOpsActionApproval(row);
    if (!(await canManageOpsApproval(user, approval))) return err(res, 'Forbidden', 403);
    if (approval.status !== 'pending') return err(res, 'Approval request is no longer pending', 409);
    await pool.query(
      `UPDATE ops_action_approval_requests
          SET status = 'rejected',
              responded_by = ?,
              responded_by_name = ?,
              responded_at = UTC_TIMESTAMP(),
              response_reason = ?
        WHERE id = ? AND status = 'pending'`,
      [user.id, opsActorName(user), req.body?.reason || null, req.params.id]
    );
    const [[updated]] = await pool.query('SELECT * FROM ops_action_approval_requests WHERE id = ?', [req.params.id]);
    ok(res, parseOpsActionApproval(updated));
  } catch (e) { err(res, e.message, 500); }
});

// ── Operators list — any authenticated user can read ──
// Used by the support ticket UI to populate the "Assign agent" dropdown,
// the agent filter on the tickets list, and the activity log lookups.
// Returns only the minimum fields needed to render a name+role — no emails,
// no allowed_app_ids, no last_login. Agency users see it too.
app.get('/api/operators', authMiddleware, async (req, res) => {
  try {
    const [rows] = await pool.query(
      `SELECT id, first_name, last_name, email, role
       FROM platform_users
       WHERE is_active = TRUE AND role IN ('admin', 'manager', 'agent', 'customer_service')
       ORDER BY first_name, last_name`
    );
    ok(res, rows);
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/users', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const { email, password, first_name, last_name, role, allowed_app_ids } = req.body;
    if (!email || !password) return err(res, 'Email and password required');

    const hash = await bcrypt.hash(password, 12);
    const id = crypto.randomUUID();
    await pool.query(
      'INSERT INTO platform_users (id, email, password_hash, first_name, last_name, role, allowed_app_ids) VALUES (?, ?, ?, ?, ?, ?, ?)',
      [id, email, hash, first_name, last_name, role || 'agent', allowed_app_ids ? JSON.stringify(allowed_app_ids) : null]
    );
    const [rows] = await pool.query('SELECT id, email, first_name, last_name, role, is_active, allowed_app_ids, created_at FROM platform_users WHERE id = ?', [id]);
    ok(res, rows[0]);
  } catch (e) {
    if (e.code === 'ER_DUP_ENTRY') return err(res, 'Email already exists');
    err(res, e.message, 500);
  }
});

app.patch('/api/users/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const d = req.body;
    const sets = [];
    const params = [];
    // Bump token_version when admin disables the account or rotates the
    // password, so any active sessions for that user get rejected by
    // authMiddleware on their next request instead of staying alive until
    // JWT expiry (up to 12h).
    let bumpTokenVersion = false;
    for (const [k, v] of Object.entries(d)) {
      if (['email', 'first_name', 'last_name', 'role', 'is_active'].includes(k)) {
        sets.push(`${k} = ?`); params.push(v);
        if (k === 'is_active' && (v === false || v === 0 || v === '0')) bumpTokenVersion = true;
      }
      if (k === 'allowed_app_ids') { sets.push('allowed_app_ids = ?'); params.push(JSON.stringify(v)); }
      if (k === 'password') {
        sets.push('password_hash = ?');
        params.push(await bcrypt.hash(v, 12));
        bumpTokenVersion = true;
      }
    }
    if (bumpTokenVersion) sets.push('token_version = token_version + 1');
    if (sets.length === 0) return err(res, 'No fields to update');
    params.push(req.params.id);
    await pool.query(`UPDATE platform_users SET ${sets.join(', ')} WHERE id = ?`, params);
    invalidateUserRowCache(req.params.id);
    const [rows] = await pool.query('SELECT id, email, first_name, last_name, role, is_active, allowed_app_ids, created_at FROM platform_users WHERE id = ?', [req.params.id]);
    ok(res, rows[0]);
  } catch (e) { err(res, e.message, 500); }
});

app.delete('/api/users/:id', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    if (req.params.id === req.platformUser.id) return err(res, 'Cannot delete yourself');
    // Soft-delete + revoke all active sessions for this account.
    await pool.query(
      'UPDATE platform_users SET is_active = FALSE, token_version = token_version + 1 WHERE id = ?',
      [req.params.id]
    );
    invalidateUserRowCache(req.params.id);
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// BRANDING (public — no auth needed)
// ════════════════════════════════════════════

const brandingPath = path.join(__dirname, 'branding.json');
let brandingConfig = {};
try { brandingConfig = JSON.parse(fs.readFileSync(brandingPath, 'utf-8')); } catch {}

// ─── Dynamic branding helpers ───
// Cache upstream lookups for 5 minutes to avoid hammering the EasyGDS API on every page load.
const brandingCache = new Map(); // key -> { value, exp }
const BRANDING_TTL_MS = 24 * 60 * 60 * 1000; // 1 day

function parsePrimaryColorFromCss(css) {
  if (!css || typeof css !== 'string') return null;
  // Try several common variable names in priority order
  const patterns = [
    /--q-color-primary\s*:\s*([^;]+)/i,
    /--q-primary\s*:\s*([^;]+)/i,
    /--primary-color\s*:\s*([^;]+)/i,
    /--color-primary\s*:\s*([^;]+)/i,
    /--brand-color\s*:\s*([^;]+)/i,
  ];
  for (const re of patterns) {
    const m = css.match(re);
    if (m) {
      const v = m[1].trim();
      if (v) return v;
    }
  }
  return null;
}

async function fetchUpstreamBranding(appCode, agencyName) {
  // Cache key uses the slugified canonical so /api/branding/abc1 and
  // /api/branding/abc-solutions share one cache entry — the upstream
  // result is identical for both forms.
  const slug = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, '-');
  const decoded = decodeURIComponent(appCode || '').toLowerCase().trim();
  const cacheKey = agencyName ? `${decoded}/${agencyName}` : decoded;
  const cached = brandingCache.get(cacheKey);
  if (cached && cached.exp > Date.now()) return cached.value;

  let branding = null;
  try {
    // Resolve app by code OR slugified name — same tolerance the verify
    // and login endpoints use, so the URL form ("abc1" vs "abc-solutions")
    // doesn't change which app's branding gets returned.
    const appsData = await easygdsRequest('/apps/list?per_page=200');
    const apps = appsData.result || appsData || [];
    const app = apps.find(a =>
      (a.code || '').toLowerCase() === decoded
      || (a.name || '').toLowerCase() === decoded
      || slug(a.code) === decoded
      || slug(a.name) === decoded
      || slug(a.name) === slug(decoded)
      || slug(a.code) === slug(decoded)
    );
    if (!app) return null;

    // Fetch full app (for cms_settings.template + custom_css)
    let fullApp = app;
    try {
      const single = await easygdsRequest(`/apps?id=${app.id}`);
      fullApp = single.result || single || app;
    } catch {}

    const appCss = fullApp?.cms_settings?.custom_css;
    const primary_color = parsePrimaryColorFromCss(appCss) || null;
    const appLogoUrl = fullApp?.cms_settings?.template?.avatar?.url || null;

    branding = {
      company_name: fullApp.name || appCode,
      primary_color: primary_color || null,
      logo_url: appLogoUrl,
      background: null,
    };

    // If an agency is specified, try to overlay agent-specific branding
    if (agencyName) {
      try {
        const agentsData = await easygdsRequest(`/b2b/agents/list?app_ids=${app.id}&per_page=500`);
        const agents = agentsData.result || agentsData || [];
        const normalized = decodeURIComponent(agencyName).replace(/-/g, ' ').toLowerCase();
        const agent = agents.find(a =>
          a.name?.toLowerCase() === normalized
          || a.company_name?.toLowerCase() === normalized
          || a.name?.replace(/\s+/g, '-').toLowerCase() === agencyName.replace(/\s+/g, '-').toLowerCase()
          || a.code?.toLowerCase() === agencyName.toLowerCase()
        );
        if (agent) {
          const agentCms = agent.settings?.cms_settings || {};
          const agentLogoUrl = agentCms?.logo?.url || agentCms?.favicon?.url || null;
          branding = {
            company_name: agent.name || agent.company_name || branding.company_name,
            primary_color: branding.primary_color, // colors stay app-level (no per-agent color in API)
            logo_url: agentLogoUrl || branding.logo_url,
            background: branding.background,
          };
        }
      } catch (e) {
        console.warn('Branding agent lookup failed:', e.message);
      }
    }
  } catch (e) {
    console.warn('Branding upstream lookup failed:', e.message);
  }

  brandingCache.set(cacheKey, { value: branding, exp: Date.now() + BRANDING_TTL_MS });
  return branding;
}

// GET /api/branding/:appCode or /api/branding/:appCode/:agencyName
async function handleBrandingGet(req, res) {
  const { appCode, agencyName } = req.params;

  // Resolve the URL segment to the canonical app so /api/branding/abc1 and
  // /api/branding/abc-solutions return the SAME record (admin only saved
  // branding under one of those keys; the other URL form would otherwise
  // fall through to default styling). We try the request slug first
  // (preserves an exact-match override if it exists), then the resolved
  // canonical code and name-slug. The first non-empty match wins.
  const slug = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, '-');
  let resolvedApp = null;
  try {
    const apps = await getAppsList({ perPage: 200 });
    const decoded = decodeURIComponent(appCode || '').toLowerCase().trim();
    resolvedApp = apps.find(a =>
      (a.code || '').toLowerCase() === decoded ||
      (a.name || '').toLowerCase() === decoded ||
      slug(a.code) === decoded ||
      slug(a.name) === decoded ||
      slug(a.name) === slug(decoded) ||
      slug(a.code) === slug(decoded)
    ) || null;
  } catch (e) { /* fall through — branding is best-effort */ }

  // List of candidate "app keys" to try in priority order. Same key wins
  // for both URL forms because the canonical code+name-slug are the same.
  const appCandidates = [
    appCode,
    resolvedApp?.code,
    resolvedApp?.name ? slug(resolvedApp.name) : null,
  ].filter((k, i, arr) => k && arr.indexOf(k) === i); // dedupe + drop empty

  // App-level override — first non-empty match.
  let overrideApp = null;
  for (const k of appCandidates) {
    if (brandingConfig[k]) { overrideApp = brandingConfig[k]; break; }
  }

  // Agency-level override — same fallback chain, with the agency segment
  // appended to each candidate.
  let overrideExact = null;
  if (agencyName) {
    for (const k of appCandidates) {
      const composed = `${k}/${agencyName}`;
      if (brandingConfig[composed]) { overrideExact = brandingConfig[composed]; break; }
    }
  }

  // 2. Pull dynamic branding from upstream EasyGDS API
  const upstream = await fetchUpstreamBranding(appCode, agencyName);

  // 3. Merge: upstream as base, local overrides on top, with sane defaults
  const merged = {
    company_name: null,
    primary_color: null,
    logo_url: null,
    background: null,
    ...(upstream || {}),
    ...(agencyName && overrideApp
      ? (() => { const { company_name, ...rest } = overrideApp; return rest; })()
      : (overrideApp || {})),
    ...(overrideExact || {}),
  };

  // Drop nulls so the frontend's defaults (e.g. orange #f97316) only kick in
  // when nothing was found anywhere.
  for (const k of Object.keys(merged)) if (merged[k] == null) delete merged[k];

  ok(res, Object.keys(merged).length > 0 ? merged : null);
}
app.get('/api/branding/:appCode/:agencyName', handleBrandingGet);
app.get('/api/branding/:appCode', handleBrandingGet);

// Clear the dynamic branding cache (admin only). Optional ?key=appCode or ?key=appCode/agencyName
// to clear a single entry; otherwise clears all.
app.post('/api/branding/cache/clear', authMiddleware, adminMiddleware, (req, res) => {
  const targetKey = req.query.key;
  let cleared = 0;
  if (targetKey) {
    if (brandingCache.delete(targetKey)) cleared = 1;
  } else {
    cleared = brandingCache.size;
    brandingCache.clear();
  }
  ok(res, { cleared, ttl_ms: BRANDING_TTL_MS });
});

// ════════════════════════════════════════════
// SYSTEM RESTART (admin only)
// ════════════════════════════════════════════
// Restart the backend by spawning a fresh detached `node server/index.cjs` and exiting.
// The new process inherits the parent's CWD/env so it picks up code changes immediately.
app.post('/api/system/restart-backend', authMiddleware, adminMiddleware, (req, res) => {
  console.log('🔁 Backend restart requested by', req.platformUser?.email || 'admin');
  ok(res, { restarting: 'backend' });

  // Defer the respawn so the response can flush before we exit.
  setTimeout(() => {
    try {
      const child = spawn(process.execPath, [__filename], {
        cwd: path.dirname(__filename),
        env: process.env,
        detached: true,
        stdio: 'ignore',
      });
      child.unref();
      console.log('✅ Spawned new backend pid', child.pid, '— exiting old process');
      process.exit(0);
    } catch (e) {
      console.error('❌ Backend respawn failed:', e.message);
    }
  }, 250);
});

// Restart the frontend (Vite dev server). Kills whoever is listening on port 8080
// then spawns `npm run dev` from the trip-glide-control directory.
app.post('/api/system/restart-frontend', authMiddleware, adminMiddleware, (req, res) => {
  console.log('🔁 Frontend restart requested by', req.platformUser?.email || 'admin');
  // Resolve the frontend project root (server lives at <root>/server/index.cjs)
  const frontendCwd = path.resolve(__dirname, '..');

  exec("lsof -tiTCP:8080 -sTCP:LISTEN", (err, stdout) => {
    const pids = (stdout || '').split('\n').map(s => s.trim()).filter(Boolean);
    // Kill existing vite process(es) on 8080
    for (const pid of pids) {
      try { process.kill(parseInt(pid, 10), 'SIGTERM'); } catch {}
    }

    // Give the port a moment to free, then spawn a fresh Vite
    setTimeout(() => {
      try {
        const child = spawn('npm', ['run', 'dev'], {
          cwd: frontendCwd,
          env: process.env,
          detached: true,
          stdio: 'ignore',
        });
        child.unref();
        console.log('✅ Spawned new frontend pid', child.pid, 'in', frontendCwd);
      } catch (e) {
        console.error('❌ Frontend respawn failed:', e.message);
      }
    }, 500);

    ok(res, { restarting: 'frontend', killed_pids: pids });
  });
});

// ════════════════════════════════════════════
// SYSTEM HEALTH & DIAGNOSTICS (admin only)
// ════════════════════════════════════════════

// MySQL ping — returns whether the platform DB pool is alive
app.get('/api/system/health/db', authMiddleware, async (req, res) => {
  try {
    const start = Date.now();
    const [rows] = await pool.query('SELECT 1 as ok');
    const latency_ms = Date.now() - start;
    ok(res, { ok: rows?.[0]?.ok === 1, latency_ms });
  } catch (e) {
    err(res, e.message, 500);
  }
});

// Per-table row counts for the platform's MySQL tables — admin only.
app.get('/api/system/db-counts', authMiddleware, adminMiddleware, async (req, res) => {
  const tables = [
    'platform_users',
    'support_requests',
    'support_messages',
    'support_internal_notes',
    'support_charges',
    'support_activity_log',
    'support_read_cursors',
  ];
  const counts = {};
  for (const t of tables) {
    try {
      const [rows] = await pool.query(`SELECT COUNT(*) AS c FROM \`${t}\``);
      counts[t] = { ok: true, count: Number(rows?.[0]?.c || 0) };
    } catch (e) {
      counts[t] = { ok: false, error: e.message };
    }
  }
  ok(res, { tables: counts });
});

// EasyGDS upstream status — token expiry, last call info, error counters.
app.get('/api/system/easygds-status', authMiddleware, adminMiddleware, async (req, res) => {
  const now = Date.now() / 1000;
  const expiresInSec = easygdsTokenExp ? Math.max(0, Math.round(easygdsTokenExp - now)) : null;
  ok(res, {
    token: {
      present: !!easygdsToken,
      expires_at: easygdsTokenExp ? new Date(easygdsTokenExp * 1000).toISOString() : null,
      expires_in_sec: expiresInSec,
      iam_url: EASYGDS_IAM_URL,
      core_url: EASYGDS_CORE_URL,
    },
    last_call_at: easygdsHealth.last_call_at,
    last_call_endpoint: easygdsHealth.last_call_endpoint,
    last_call_status: easygdsHealth.last_call_status,
    last_call_success: easygdsHealth.last_call_success,
    last_error: easygdsHealth.last_error,
    total_calls: easygdsHealth.total_calls,
    total_errors: easygdsHealth.total_errors,
    circuit: easygdsHealth.circuit,
    consecutive_failures: easygdsHealth.consecutive_failures,
    open_since: easygdsHealth.open_since,
    cooldown_ms: easygdsHealth.cooldown_ms,
    force_readonly: easygdsHealth.force_readonly,
  });
});

// Lightweight health endpoint for the Ops-only degraded-mode banner.
// Polled every 30 s by the frontend. Unauthenticated path so it never gets
// caught up in token expiry — but we still gate the *banner* visibility
// to login_context='system' on the client.
app.get('/api/health/easygds', async (req, res) => {
  const ok_ = easygdsHealth.circuit === 'closed' && !easygdsHealth.force_readonly;
  ok(res, {
    ok: ok_,
    state: easygdsHealth.force_readonly ? 'force_readonly' : easygdsHealth.circuit,
    last_call_at: easygdsHealth.last_call_at,
    last_call_success: easygdsHealth.last_call_success,
    last_error: easygdsHealth.last_error,
    open_since: easygdsHealth.open_since,
    cooldown_ms: easygdsHealth.cooldown_ms,
  });
});

// Admin: force read-only mode (e.g., upstream maintenance window).
// When ON, every easygdsRequest fails fast with a 503, and the global
// banner shows "Read-only mode (manually enabled)".
app.post('/api/system/easygds-force-readonly', authMiddleware, adminMiddleware, async (req, res) => {
  const enable = req.body?.enabled === true || req.body?.enabled === 'true';
  easygdsHealth.force_readonly = !!enable;
  if (enable && easygdsHealth.circuit === 'closed') {
    // Log the manual outage so audit history matches reality.
    try {
      const id = crypto.randomUUID();
      await pool.query(
        `INSERT INTO system_health (id, service, status, message)
         VALUES (?, 'easygds_core', 'down', 'Manual force-readonly')`,
        [id]
      );
      easygdsHealth.last_outage_id = id;
    } catch {}
  }
  if (!enable && easygdsHealth.last_outage_id) {
    try {
      await pool.query(
        `UPDATE system_health SET status='ok', resolved_at=UTC_TIMESTAMP()
         WHERE id = ?`,
        [easygdsHealth.last_outage_id]
      );
    } catch {}
    easygdsHealth.last_outage_id = null;
  }
  ok(res, { force_readonly: easygdsHealth.force_readonly });
});

// Outage history — last N entries from system_health for the admin panel.
app.get('/api/system/easygds-outages', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const [rows] = await pool.query(
      `SELECT id, status, message, noticed_at, resolved_at, duration_ms
       FROM system_health
       WHERE service = 'easygds_core'
       ORDER BY noticed_at DESC
       LIMIT ?`,
      [Math.min(parseInt(req.query.limit) || 20, 200)]
    );
    ok(res, rows);
  } catch (e) { err(res, e.message, 500); }
});

// Force EasyGDS token refresh — admin only.
app.post('/api/system/easygds-refresh-token', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    easygdsToken = null;
    easygdsTokenExp = 0;
    const token = await getEasyGDSToken();
    ok(res, {
      refreshed: true,
      expires_at: easygdsTokenExp ? new Date(easygdsTokenExp * 1000).toISOString() : null,
    });
  } catch (e) {
    err(res, e.message, 500);
  }
});

// ════════════════════════════════════════════
// Reports & Analytics — server-side aggregation + CSV export
// ════════════════════════════════════════════

function csvEscape(v) {
  if (v == null) return '';
  const s = String(v);
  if (s.includes(',') || s.includes('"') || s.includes('\n')) {
    return `"${s.replace(/"/g, '""')}"`;
  }
  return s;
}

function rowsToCsv(rows) {
  if (!rows.length) return '';
  const headers = Object.keys(rows[0]);
  const lines = [headers.join(',')];
  for (const r of rows) lines.push(headers.map((h) => csvEscape(r[h])).join(','));
  return lines.join('\n');
}

// ── FX Rates Manager (USD base) ──
// Uses Frankfurter API (https://www.frankfurter.app) — free, no key required, ECB rates.
// Rates are cached in-memory for 6 hours; falls back to a hardcoded snapshot if the API fails.
let fxRatesCache = null;
let fxRatesFetchedAt = 0;
const FX_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours

// Hardcoded fallback rates (rates per 1 USD) — used only if Frankfurter is unreachable.
// These are approximate ECB rates used as a safety net.
const FX_FALLBACK = {
  base: 'USD',
  rates: {
    USD: 1, EUR: 0.92, GBP: 0.79, MYR: 4.71, SGD: 1.35, AED: 3.67,
    SAR: 3.75, EGP: 49.0, JPY: 152.0, CNY: 7.24, INR: 83.5, AUD: 1.52,
    CAD: 1.36, CHF: 0.88, HKD: 7.81, IDR: 15800, THB: 35.5, KRW: 1370,
    NZD: 1.66, ZAR: 18.5, TRY: 32.5, PHP: 56.5, VND: 25000, BRL: 5.05,
    MXN: 17.0, RUB: 92.0, NOK: 10.7, SEK: 10.5, DKK: 6.85, PLN: 3.95,
  },
};

async function fetchFxRates() {
  // Return cached if still fresh
  if (fxRatesCache && Date.now() - fxRatesFetchedAt < FX_TTL_MS) return fxRatesCache;
  try {
    const r = await fetch('https://api.frankfurter.app/latest?from=USD');
    if (!r.ok) throw new Error(`Frankfurter HTTP ${r.status}`);
    const j = await r.json();
    if (!j || !j.rates || typeof j.rates !== 'object') throw new Error('Invalid Frankfurter response');
    // Frankfurter returns rates relative to USD, but does not include USD=1 in the rates map
    fxRatesCache = {
      base: 'USD',
      date: j.date,
      rates: { USD: 1, ...j.rates },
      source: 'frankfurter',
    };
    fxRatesFetchedAt = Date.now();
    console.log('✅ FX rates refreshed from Frankfurter:', j.date);
    return fxRatesCache;
  } catch (e) {
    console.error('❌ FX fetch failed, using fallback:', e.message);
    // Cache the fallback briefly so we don't hammer the API on every request
    fxRatesCache = { ...FX_FALLBACK, source: 'fallback', date: new Date().toISOString().slice(0, 10) };
    fxRatesFetchedAt = Date.now() - (FX_TTL_MS - 30 * 60 * 1000); // retry in 30 min
    return fxRatesCache;
  }
}

// Convert an amount in any currency to USD using cached rates.
// Frankfurter rates are "1 USD = X CCY", so USD = amount / rates[ccy].
function convertToUsd(amount, fromCcy, fxRates) {
  const cur = String(fromCcy || 'USD').toUpperCase();
  if (!Number.isFinite(Number(amount))) return 0;
  if (cur === 'USD') return Number(amount);
  const rate = fxRates?.rates?.[cur];
  if (!rate || rate <= 0) return Number(amount); // unknown currency — don't lie, return raw
  return Number(amount) / rate;
}

// GET /api/reports/fx-rates  → { base, date, rates, source }
app.get('/api/reports/fx-rates', authMiddleware, async (req, res) => {
  try {
    const rates = await fetchFxRates();
    ok(res, rates);
  } catch (e) {
    err(res, e.message, 500);
  }
});

// Aggregate bookings on the server (avoids pulling 200+ rows to the client)
// GET /api/reports/bookings/aggregate?group_by=app|agency|status|product_type&date_from=...&date_to=...
app.get('/api/reports/bookings/aggregate', authMiddleware, async (req, res) => {
  try {
    const groupBy = req.query.group_by || 'status';
    const allowedGroups = ['app', 'agency', 'status', 'product_type', 'currency'];
    if (!allowedGroups.includes(groupBy)) {
      return err(res, `group_by must be one of: ${allowedGroups.join(', ')}`);
    }

    const loginContext = req.platformUser?.login_context || 'system';
    const lockedAppId = req.platformUser?.locked_app_id || null;
    const lockedAgentId = req.platformUser?.locked_agent_id || null;

    const params = new URLSearchParams();
    params.set('per_page', '500');
    params.set('page', '1');
    if (req.query.date_from) params.set('created_after', req.query.date_from);
    if (req.query.date_to) params.set('created_before', req.query.date_to);
    if (req.query.app_id) params.set('app_id', req.query.app_id);
    if (req.query.agent_id) params.set('agent_id', req.query.agent_id);

    // Enforce scope from token
    if (loginContext === 'agent') {
      if (lockedAppId) params.set('app_id', lockedAppId);
      if (lockedAgentId) params.set('agent_id', lockedAgentId);
      params.set('b2b_only', 'true');
    } else if (loginContext === 'app' && lockedAppId) {
      params.set('app_id', lockedAppId);
    }

    const [data, fx, disabledSet] = await Promise.all([
      easygdsRequest(`/bookings/list?${params.toString()}`),
      fetchFxRates(),
      getDisabledAppIds(),
    ]);
    // Drop bookings whose app is globally disabled. The aggregate would
    // otherwise report bookings the rest of the platform refuses to show.
    const bookings = (data.result || []).filter(b => {
      const id = b.app_id || b.app?.id;
      return !id || !disabledSet.has(id);
    });

    const buckets = new Map();
    for (const b of bookings) {
      let key;
      switch (groupBy) {
        case 'app':
          key = b.app?.name || b.app_id || 'Unknown';
          break;
        case 'agency':
          key = b.agent?.name || (b.agent_id ? `Agency ${String(b.agent_id).slice(0, 8)}` : 'Direct');
          break;
        case 'status':
          key = b.status || 'unknown';
          break;
        case 'product_type': {
          const types = (b.products || []).map((p) => p.product?.code || p.product_type || p.type || 'other');
          key = types.length ? types[0] : 'booking';
          break;
        }
        case 'currency':
          key = b.currency_code || b.currency || 'USD';
          break;
      }
      const existing = buckets.get(key) || { key, count: 0, value: 0, value_usd: 0 };
      existing.count += 1;
      const amt = Number(b.total_amount || 0);
      existing.value += amt;
      existing.value_usd += convertToUsd(amt, b.currency_code || b.currency, fx);
      buckets.set(key, existing);
    }

    const result = Array.from(buckets.values()).sort((a, b) => b.count - a.count);
    ok(res, {
      group_by: groupBy,
      total_bookings: bookings.length,
      buckets: result,
      currency: 'USD',
      fx_source: fx.source,
      fx_date: fx.date,
    });
  } catch (e) {
    err(res, e.message, 500);
  }
});

// Provider report — admin only. Walks bookings + products and groups by provider/supplier.
// GET /api/reports/providers?date_from=&date_to=&product_type=&status=
app.get('/api/reports/providers', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    const params = new URLSearchParams();
    params.set('per_page', '500');
    params.set('page', '1');
    if (req.query.date_from) params.set('created_after', req.query.date_from);
    if (req.query.date_to) params.set('created_before', req.query.date_to);
    if (req.query.app_id) params.set('app_id', req.query.app_id);
    if (req.query.status) params.set('booking_statuses', req.query.status);

    const [data, fx, disabledSet] = await Promise.all([
      easygdsRequest(`/bookings/list?${params.toString()}`),
      fetchFxRates(),
      getDisabledAppIds(),
    ]);
    // Drop bookings from globally-disabled apps so the providers report
    // matches what the rest of the platform shows.
    const bookings = (data.result || []).filter(b => {
      const id = b.app_id || b.app?.id;
      return !id || !disabledSet.has(id);
    });

    const productTypeFilter = req.query.product_type && req.query.product_type !== 'all'
      ? String(req.query.product_type).toLowerCase()
      : null;
    const providerFilter = req.query.provider && req.query.provider !== 'all'
      ? String(req.query.provider)
      : null;

    const rows = [];
    for (const b of bookings) {
      const products = b.products || [];
      for (const p of products) {
        const productType = String(p.product?.code || p.product_type || p.type || 'other').toLowerCase();
        if (productTypeFilter && productType !== productTypeFilter) continue;

        const data = p.data || {};
        const provider =
          data.provider_name ||
          data.supplier_name ||
          data.source ||
          p.product?.name ||
          'Unknown provider';
        if (providerFilter && provider !== providerFilter) continue;

        const totalRaw = Number(
          data.total_amount || data.gross_amount || data.price?.total || p.total_amount || 0
        );
        const supplierBaseRaw = Number(
          data.supplier_amount || data.net_amount || data.cost || data.price?.net || 0
        );
        const sourceCurrency = data.currency || b.currency_code || 'USD';
        const total = convertToUsd(totalRaw, sourceCurrency, fx);
        const supplierBase = convertToUsd(supplierBaseRaw, sourceCurrency, fx);
        const markup = Math.max(total - supplierBase, 0);

        rows.push({
          booking_code: b.code,
          booking_date: b.created_at,
          travel_date:
            data.check_in ||
            data.travel_date ||
            data.departure_datetime ||
            b.departure_date ||
            b.travel_start_date ||
            null,
          product_type: productType,
          status: (b.status || 'unknown').toUpperCase(),
          supplier_ref: data.supplier_ref || data.confirmation_number || data.pnr || p.product_ref || '',
          provider_name: provider,
          total_booking_value: Number(total.toFixed(2)),
          supplier_base_price: Number(supplierBase.toFixed(2)),
          markup: Number(markup.toFixed(2)),
          currency: 'USD',
          original_currency: sourceCurrency,
          source_id: provider,
        });
      }
    }

    ok(res, { total: rows.length, rows, currency: 'USD', fx_source: fx.source, fx_date: fx.date });
  } catch (e) {
    err(res, e.message, 500);
  }
});

// CSV export of filtered bookings
// GET /api/reports/bookings/export.csv?date_from=&date_to=&app_id=&agent_id=&status=
app.get('/api/reports/bookings/export.csv', authMiddleware, async (req, res) => {
  try {
    const loginContext = req.platformUser?.login_context || 'system';
    const lockedAppId = req.platformUser?.locked_app_id || null;
    const lockedAgentId = req.platformUser?.locked_agent_id || null;

    const params = new URLSearchParams();
    params.set('per_page', '1000');
    params.set('page', '1');
    if (req.query.date_from) params.set('created_after', req.query.date_from);
    if (req.query.date_to) params.set('created_before', req.query.date_to);
    if (req.query.status) params.set('booking_statuses', req.query.status);
    if (req.query.app_id) params.set('app_id', req.query.app_id);
    if (req.query.agent_id) params.set('agent_id', req.query.agent_id);

    if (loginContext === 'agent') {
      if (lockedAppId) params.set('app_id', lockedAppId);
      if (lockedAgentId) params.set('agent_id', lockedAgentId);
      params.set('b2b_only', 'true');
    } else if (loginContext === 'app' && lockedAppId) {
      params.set('app_id', lockedAppId);
    }

    const [data, fx, disabledSet] = await Promise.all([
      easygdsRequest(`/bookings/list?${params.toString()}`),
      fetchFxRates(),
      getDisabledAppIds(),
    ]);
    // Drop bookings from globally-disabled apps from the export.
    const bookings = (data.result || []).filter(b => {
      const id = b.app_id || b.app?.id;
      return !id || !disabledSet.has(id);
    });

    const rows = bookings.map((b) => {
      const amt = Number(b.total_amount || 0);
      const ccy = b.currency_code || '';
      const usd = convertToUsd(amt, ccy, fx);
      return {
        code: b.code,
        created_at: b.created_at,
        status: b.status,
        app: b.app?.name || b.app_id || '',
        agency: b.agent?.name || b.agent_id || 'Direct',
        customer:
          [b.contact?.first_name, b.contact?.last_name].filter(Boolean).join(' ') ||
          b.contact?.email ||
          '',
        email: b.contact?.email || '',
        total: amt,
        currency: ccy,
        total_usd: Number(usd.toFixed(2)),
        payment_status: b.payment?.status || '',
        departure_date: b.departure_date || b.travel_start_date || '',
      };
    });

    const csv = rowsToCsv(rows);
    res.setHeader('Content-Type', 'text/csv; charset=utf-8');
    res.setHeader(
      'Content-Disposition',
      `attachment; filename="bookings-${new Date().toISOString().slice(0, 10)}.csv"`
    );
    res.send(csv);
  } catch (e) {
    err(res, e.message, 500);
  }
});

// GET all branding entries (admin only)
app.get('/api/branding', authMiddleware, adminMiddleware, (req, res) => {
  ok(res, brandingConfig);
});

// PUT /api/branding/:appCode or /api/branding/:appCode/:agencyName (admin only)
function handleBrandingPut(req, res) {
  const { appCode, agencyName } = req.params;
  const key = agencyName ? `${appCode}/${agencyName}` : appCode;
  brandingConfig[key] = { ...brandingConfig[key], ...req.body };
  try {
    fs.writeFileSync(brandingPath, JSON.stringify(brandingConfig, null, 2));
    ok(res, brandingConfig[key]);
  } catch (e) { err(res, e.message, 500); }
}
app.put('/api/branding/:appCode/:agencyName', authMiddleware, adminMiddleware, handleBrandingPut);
app.put('/api/branding/:appCode', authMiddleware, adminMiddleware, handleBrandingPut);

// DELETE branding entry (admin only)
function handleBrandingDelete(req, res) {
  const { appCode, agencyName } = req.params;
  const key = agencyName ? `${appCode}/${agencyName}` : appCode;
  if (!brandingConfig[key]) return err(res, 'Branding entry not found', 404);
  delete brandingConfig[key];
  try {
    fs.writeFileSync(brandingPath, JSON.stringify(brandingConfig, null, 2));
    ok(res, { deleted: true });
  } catch (e) { err(res, e.message, 500); }
}
app.delete('/api/branding/:appCode/:agencyName', authMiddleware, adminMiddleware, handleBrandingDelete);
app.delete('/api/branding/:appCode', authMiddleware, adminMiddleware, handleBrandingDelete);

// Upload logo for branding (admin only)
const logoDir = path.join(__dirname, 'uploads', 'logos');
if (!fs.existsSync(logoDir)) fs.mkdirSync(logoDir, { recursive: true });

app.post('/api/branding/upload-logo', authMiddleware, adminMiddleware, upload.single('logo'), (req, res) => {
  if (!req.file) return err(res, 'No file uploaded');
  const ext = path.extname(req.file.originalname) || '.png';
  const newName = `${req.file.filename}${ext}`;
  const newPath = path.join(logoDir, newName);
  fs.renameSync(req.file.path, newPath);
  const logoUrl = `http://localhost:3001/uploads/logos/${newName}`;
  ok(res, { logo_url: logoUrl });
});

// ════════════════════════════════════════════
// LOGIN VERIFICATION (public — no auth needed)
// ════════════════════════════════════════════

// Verify app code and optionally agency name before showing login page
async function handleVerifyLogin(req, res) {
  const { appCode, agencyName } = req.params;
  try {
    const sysToken = await getEasyGDSToken();

    // 1. Resolve app code to app object.
    // Match against name OR code, with hyphen↔space tolerance so URL slugs
    // like "test-app" or "lp-luxury-vacations-llc" resolve to apps named
    // "TEST APP" or "LP Luxury Vacations LLC". URL-decode first in case the
    // segment was percent-encoded ("test%20app" → "test app").
    // Cached helper — sub-millisecond on cache hit.
    const apps = await getAppsList({ perPage: 200 });
    const decodedAppParam = decodeURIComponent(appCode || '').toLowerCase().trim();
    // Slug form: collapse runs of whitespace into a single hyphen.
    const slug = (s) => (s || '').toLowerCase().trim().replace(/\s+/g, '-');
    const matchedApp = apps.find(a => {
      const codeRaw = (a.code || '').toLowerCase();
      const nameRaw = (a.name || '').toLowerCase();
      return codeRaw === decodedAppParam
          || nameRaw === decodedAppParam
          || slug(a.code) === decodedAppParam
          || slug(a.name) === decodedAppParam
          // Reverse direction: caller might send "test app" with space,
          // match against the app's slugified name. Comparing both sides
          // slug-to-slug normalises every shape.
          || slug(a.name) === slug(decodedAppParam)
          || slug(a.code) === slug(decodedAppParam);
    });

    if (!matchedApp) {
      return ok(res, { valid: false, reason: 'app_not_found', message: 'This application does not exist.' });
    }

    // Check if app is active — use is_active flag if present
    if (matchedApp.is_active === false) {
      return ok(res, { valid: false, reason: 'app_inactive', message: 'This application is currently suspended.' });
    }

    // 2. If agency name given, verify it exists in this app
    if (agencyName) {
      const agentsRes = await fetch(`${EASYGDS_CORE_URL}/b2b/agents/list?app_ids=${matchedApp.id}&per_page=500`, {
        headers: { 'Authorization': `Bearer ${sysToken}` }
      });
      const agentsData = await agentsRes.json();
      const agents = agentsData.result || agentsData || [];
      console.log(`Verify login: searching ${agents.length} agents for "${agencyName}" in app ${matchedApp.code}`);

      // Match by name — supports both URL-encoded spaces and hyphens
      const normalizedName = decodeURIComponent(agencyName).replace(/-/g, ' ').toLowerCase();
      const matchedAgent = agents.find(a =>
        a.name?.toLowerCase() === normalizedName ||
        a.company_name?.toLowerCase() === normalizedName ||
        a.name?.replace(/\s+/g, '-').toLowerCase() === agencyName.replace(/\s+/g, '-').toLowerCase() ||
        a.code?.toLowerCase() === agencyName.toLowerCase()
      );

      if (!matchedAgent) {
        console.warn(`Verify login: agency "${agencyName}" (normalized: "${normalizedName}") not found among ${agents.length} agents`);
        return ok(res, { valid: false, reason: 'agency_not_found', message: 'This agency does not exist.' });
      }

      // Check if agent is active — use is_active flag if present
      if (matchedAgent.is_active === false) {
        return ok(res, { valid: false, reason: 'agency_inactive', message: 'This agency account is currently suspended.' });
      }

      return ok(res, {
        valid: true,
        app: { id: matchedApp.id, name: matchedApp.name, code: matchedApp.code },
        agency: { id: matchedAgent.id, name: matchedAgent.name || matchedAgent.company_name }
      });
    }

    // App-only login
    return ok(res, {
      valid: true,
      app: { id: matchedApp.id, name: matchedApp.name, code: matchedApp.code }
    });

  } catch (e) {
    console.error('Login verification error:', e.message);
    return ok(res, { valid: false, reason: 'error', message: 'Unable to verify login link. Please try again.' });
  }
}

app.get('/api/verify-login/:appCode/:agencyName', handleVerifyLogin);
app.get('/api/verify-login/:appCode', handleVerifyLogin);


// ════════════════════════════════════════════
// EASYGDS API PROXY
// ════════════════════════════════════════════

// Apps list — filtered to the user's app scope.
//
// scopedAppIds() encapsulates the rule: null = unrestricted (Ops), [] = no
// apps (restricted user with empty allowed list), [..] = restricted to
// these specific app IDs. We use filterByAppScope() rather than the
// previous inline `allowed_app_ids?.length > 0` check so this endpoint
// behaves identically to /api/easygds/bookings et al.
app.get('/api/easygds/apps', authMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest('/apps/list?per_page=200');
    const allApps = data.result || [];
    // Filter out apps the admin has globally disabled in Settings → Apps.
    // Admins can still see disabled apps via `?include_disabled=true` (used
    // by the Settings page itself to render the toggle list). This is the
    // "first option" the user asked for: stop the system fetching data
    // for apps that aren't ours to handle.
    const disabled = await getDisabledAppIds();
    const includeDisabled = req.query.include_disabled === 'true' && req.platformUser.role === 'admin';
    let apps = perms.filterByAppScope(req.platformUser, allApps);
    if (!includeDisabled) apps = apps.filter(a => !disabled.has(a.id));
    ok(res, { total: apps.length, result: apps });
  } catch (e) { err(res, e.message, 500); }
});

// Single app — same scope rules as the list endpoint above. Without this,
// a logged-in user could read any app's metadata by guessing UUIDs, which
// breaks the tenant isolation that filterByAppScope enforces on the list.
app.get('/api/easygds/apps/:id', authMiddleware, async (req, res) => {
  try {
    if (!perms.canAccessApp(req.platformUser, req.params.id)) {
      return err(res, 'Forbidden', 403);
    }
    // Disabled apps are not handled by this Ops platform — block reads so
    // no UI accidentally shows their data. Admins on the Settings page get
    // a bypass since they need to re-enable from this very screen.
    const disabled = await getDisabledAppIds();
    if (disabled.has(req.params.id) && req.platformUser.role !== 'admin') {
      return err(res, 'This application is not handled by this Ops platform', 403);
    }
    const data = await easygdsRequest(`/apps?id=${req.params.id}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Bookings list — enforces permissions server-side based on JWT token
// ══════════════════════════════════════════════════════════════════════
// BOOKINGS CACHE  (lazy sync — reduces upstream load by ~30×)
// ══════════════════════════════════════════════════════════════════════
//
// The list endpoints below (Bookings page, Monitor) read from this local
// MySQL cache. Detail page still hits upstream live so users always see
// fresh data when they open a record. Sync is triggered by user activity
// (lazy + stale-while-revalidate) — there is intentionally no always-on
// background poller.

// Normalise an upstream booking record into our cache columns. Keeps the
// raw payload in `raw` so the UI can pull whatever else it needs without
// having to re-shape the projection.
function mapUpstreamBookingToCacheRow(b) {
  if (!b || !b.id) return null;
  // Upstream field names vary across endpoints / booking types. Coalesce
  // here once so every read site stays simple. Verified against a real
  // production booking row (TPV000546):
  //   - status (not booking_status)
  //   - payment.status (not payment_status)
  //   - currency_code (not currency)
  //   - travel_start_date / departure_date (not travel_date)
  //   - agent.b2b_only when channel_type is missing
  //   - app.name for company_name fallback
  const code = b.code || b.booking_ref || null;
  const contact = b.contact || {};
  const contactName = [contact.first_name, contact.last_name].filter(Boolean).join(' ').trim() || null;
  const totalAmount = b.total_amount ?? b.price_info?.total_amount ?? null;
  const travelDateRaw = b.travel_date || b.travel_start_date || b.departure_date || null;
  return {
    id: b.id,
    code,
    app_id: b.app_id || b.app?.id || null,
    agent_id: b.agent_id || b.agent?.id || null,
    booking_status: b.booking_status || b.status || null,
    payment_status: b.payment_status || b.payment?.status || null,
    channel_type: b.channel_type || b.agent?.channel_type || null,
    b2b_only: (b.b2b_only ?? b.agent?.b2b_only) ? 1 : 0,
    contact_email: contact.email || b.agent_user?.email || null,
    contact_name: contactName,
    contact_phone: contact.phone || b.agent_user?.phone || null,
    pnr: b.pnr || null,
    travel_date: travelDateRaw,
    total_amount: totalAmount != null ? Number(totalAmount) : null,
    currency: b.currency || b.currency_code || null,
    passenger_count: b.passenger_count ?? null,
    company_name: b.company_name || b.company?.name || b.app?.name || null,
    supplier_name: b.supplier_name || b.supplier?.name || null,
    upstream_created: b.created_at || null,
    upstream_updated: b.updated_at || null,
    raw: b,
  };
}

// Cached session packet limit. Read once on first upsert and shared across
// all subsequent calls. We aim for chunks at ~60% of the limit so headers,
// SQL boilerplate, and the prepared-statement framing all fit comfortably.
let _cachedMaxPacket = null;
async function getMaxAllowedPacket() {
  if (_cachedMaxPacket != null) return _cachedMaxPacket;
  try {
    const [[r]] = await pool.query("SELECT @@session.max_allowed_packet AS p");
    _cachedMaxPacket = Number(r.p) || 1_048_576;
  } catch {
    _cachedMaxPacket = 1_048_576;
  }
  return _cachedMaxPacket;
}

async function upsertBookingsCache(rows) {
  if (!rows || rows.length === 0) return 0;
  const cols = ['id', 'code', 'app_id', 'agent_id', 'booking_status', 'payment_status',
                'channel_type', 'b2b_only', 'contact_email', 'contact_name', 'contact_phone',
                'pnr', 'travel_date', 'total_amount', 'currency', 'passenger_count',
                'company_name', 'supplier_name', 'upstream_created', 'upstream_updated', 'raw'];
  // Build (tuple, byteSize) pairs so we can pack chunks by SIZE rather than
  // a fixed ROW count. A booking with a huge `raw` payload could otherwise
  // explode a 25-row chunk past max_allowed_packet, while small bookings
  // would waste connections on lots of tiny inserts.
  const sized = rows.map(r => {
    const tuple = cols.map(c => {
      const v = r[c];
      if (c === 'raw') return JSON.stringify(v || {});
      if (c === 'upstream_created' || c === 'upstream_updated') return toMysqlDatetime(v);
      if (c === 'travel_date' && v) return String(v).slice(0, 10);
      return v == null ? null : v;
    });
    // Approximate the wire size: sum of stringified scalar lengths +
    // ~50B per cell for protocol framing.
    let bytes = 50;
    for (const v of tuple) bytes += (v == null ? 0 : String(v).length) + 8;
    return { tuple, bytes };
  });

  const sql = `INSERT INTO bookings_cache (${cols.join(', ')}, synced_at)
     VALUES ?
     ON DUPLICATE KEY UPDATE
       code = VALUES(code),
       app_id = VALUES(app_id),
       agent_id = VALUES(agent_id),
       booking_status = VALUES(booking_status),
       payment_status = VALUES(payment_status),
       channel_type = VALUES(channel_type),
       b2b_only = VALUES(b2b_only),
       contact_email = VALUES(contact_email),
       contact_name = VALUES(contact_name),
       contact_phone = VALUES(contact_phone),
       pnr = VALUES(pnr),
       travel_date = VALUES(travel_date),
       total_amount = VALUES(total_amount),
       currency = VALUES(currency),
       passenger_count = VALUES(passenger_count),
       company_name = VALUES(company_name),
       supplier_name = VALUES(supplier_name),
       upstream_created = VALUES(upstream_created),
       upstream_updated = VALUES(upstream_updated),
       raw = VALUES(raw),
       synced_at = UTC_TIMESTAMP()`;
  const nowMysql = new Date().toISOString().slice(0, 19).replace('T', ' ');
  const limit = await getMaxAllowedPacket();
  // 60% of the packet, capped at 8MB (avoids huge single statements even
  // when MariaDB allows it). Floor at 256KB so a comically tight server
  // setting doesn't degrade us into 1-row-per-insert pathological mode
  // for typical 30KB payloads.
  const targetBytes = Math.min(8 * 1024 * 1024, Math.max(256 * 1024, Math.floor(limit * 0.6)));

  let i = 0;
  while (i < sized.length) {
    let j = i;
    let pending = 200; // base SQL statement headroom
    while (j < sized.length && (j === i || pending + sized[j].bytes < targetBytes)) {
      pending += sized[j].bytes;
      j += 1;
    }
    const chunk = sized.slice(i, j).map(s => [...s.tuple, nowMysql]);
    await pool.query(sql, [chunk]);
    i = j;
  }
  return rows.length;
}

// Sync state per scope. Keyed by app_id ("app:abc-123") or "all" if upstream
// somehow returns global rows. Tracks the last successful sync time + the
// max upstream `updated_at` we've seen, so the next sync can pass
// `?modified_after=<watermark>` and only fetch the delta.
async function readSyncState(scopeKey) {
  const [[row]] = await pool.query(
    `SELECT * FROM bookings_sync_state WHERE scope_key = ?`,
    [scopeKey]
  );
  return row || null;
}
async function writeSyncState(scopeKey, fields) {
  const cols = Object.keys(fields);
  const values = cols.map(c => fields[c]);
  const updateClauses = cols.map(c => `${c} = VALUES(${c})`).join(', ');
  await pool.query(
    `INSERT INTO bookings_sync_state (scope_key, ${cols.join(', ')})
     VALUES (?, ${cols.map(() => '?').join(', ')})
     ON DUPLICATE KEY UPDATE ${updateClauses}`,
    [scopeKey, ...values]
  );
}

// Per-scope in-memory mutex so two concurrent sync calls for the same app
// coalesce into one upstream round-trip. Returned promise resolves with
// the sync summary so all callers see the same result.
const _syncInflight = new Map();

// Pull bookings for one app from upstream and upsert into cache. Uses
// incremental delta when we have a watermark; falls back to full pull on
// first run or when full=true. Throws on upstream failure (caller decides
// whether to surface).
async function syncBookingsForApp(appId, { full = false, perPage = 100 } = {}) {
  // perPage default lowered from 200 → 100 so each upstream page stays well
  // under the 30s undici body-timeout window. The total number of upstream
  // calls doubles, but each is faster and resilient (easygdsRequest now has
  // 60s timeout + 3-attempt retry on socket failures).
  if (!appId) throw new Error('app_id required');
  // Disabled in Settings → Apps. Bail out early so we don't pull bookings
  // for apps another team owns. Returns the same shape as a successful sync
  // so callers (full-sync workers, lazy fetchers) don't need to special-case.
  const disabled = await getDisabledAppIds();
  if (disabled.has(appId)) {
    return { ok: true, scope: `app:${appId}`, synced: 0, ms: 0, skipped: 'app_disabled' };
  }
  const scopeKey = `app:${appId}`;
  if (_syncInflight.has(scopeKey)) return _syncInflight.get(scopeKey);

  const work = (async () => {
    const t0 = Date.now();
    await writeSyncState(scopeKey, { status: 'syncing', last_error: null, synced_count: 0 });

    const state = await readSyncState(scopeKey);
    const since = !full && state?.last_modified_upstream
      ? new Date(state.last_modified_upstream).toISOString()
      : null;

    let totalSeen = 0, totalFetched = 0, page = 1, maxModified = since;
    try {
      while (true) {
        const params = new URLSearchParams();
        params.set('app_id', appId);
        params.set('page', String(page));
        params.set('per_page', String(perPage));
        // Sort newest-first so we can short-circuit incremental syncs once
        // we hit a row older than our watermark.
        params.set('sorts', '-created_at');
        if (since) params.set('updated_after', since);
        const data = await easygdsRequest(`/bookings/list?${params.toString()}`);
        const items = data?.result || [];
        if (items.length === 0) break;
        totalFetched = data?.total ?? totalFetched;

        const mapped = items.map(mapUpstreamBookingToCacheRow).filter(Boolean);
        if (mapped.length > 0) await upsertBookingsCache(mapped);
        totalSeen += mapped.length;
        for (const r of mapped) {
          if (r.upstream_updated && (!maxModified || r.upstream_updated > maxModified)) {
            maxModified = r.upstream_updated;
          }
        }

        // Persist progress after each successful page. If a later page fails,
        // the rows already upserted into bookings_cache stay valid and the
        // sync_state row reflects partial progress instead of "0 synced".
        await writeSyncState(scopeKey, {
          status: 'syncing',
          synced_count: totalSeen,
          last_modified_upstream: maxModified ? toMysqlDatetime(maxModified) : null,
        });

        if (items.length < perPage) break;       // last page
        if (totalFetched && totalSeen >= totalFetched) break;
        page += 1;
        // Hard cap. Incremental sync stops at 100 pages (= 10 000 rows at
        // perPage=100) so a runaway loop doesn't pin upstream forever. Full
        // sync raises the cap to 1000 pages (= 100 000 rows) — enough for
        // the largest apps we've seen, still bounded.
        const pageCap = full ? 1000 : 100;
        if (page > pageCap) break;
      }

      await writeSyncState(scopeKey, {
        status: 'idle',
        last_synced_at: toMysqlDatetime(new Date().toISOString()),
        last_modified_upstream: maxModified ? toMysqlDatetime(maxModified) : null,
        synced_count: totalSeen,
        duration_ms: Date.now() - t0,
        last_error: null,
      });
      return { ok: true, scope: scopeKey, synced: totalSeen, ms: Date.now() - t0 };
    } catch (e) {
      // Preserve totalSeen so the UI shows partial progress (e.g. "1500/3623
      // synced before error"). The bookings_cache rows themselves are durable
      // — only the sync_state record gets the error stamped.
      console.warn(`[sync][${scopeKey}] CATCH: ${e.message}; totalSeen=${totalSeen}; will write error state`);
      try {
        await writeSyncState(scopeKey, {
          status: 'error',
          last_error: String(e.message).slice(0, 1000),
          synced_count: totalSeen,
          duration_ms: Date.now() - t0,
        });
        console.warn(`[sync][${scopeKey}] CATCH wrote error state OK`);
      } catch (wErr) {
        console.error(`[sync][${scopeKey}] CATCH writeSyncState FAILED: ${wErr.message}`);
      }
      throw e;
    }
  })();

  _syncInflight.set(scopeKey, work);
  try { return await work; } finally { _syncInflight.delete(scopeKey); }
}

// Catch-up sync after the circuit closes — fires every running app's last-
// viewed scope so the cache picks up bookings created during the outage.
// Errors are swallowed per-app so one slow app can't block the others.
async function kickCatchUpSync() {
  try {
    // Only refresh apps users actually look at (within last 24h).
    const [rows] = await pool.query(
      `SELECT scope_key FROM bookings_sync_state
       WHERE scope_key LIKE 'app:%'
         AND last_viewed_at IS NOT NULL
         AND last_viewed_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)`
    );
    for (const r of rows) {
      const appId = String(r.scope_key).slice(4); // strip "app:"
      try { await syncBookingsForApp(appId); }
      catch (e) { console.warn(`catch-up sync for ${appId} failed:`, e.message); }
    }
  } catch (e) { console.warn('catch-up sync failed:', e.message); }
}

// Build a SQL WHERE clause from the same filters the upstream proxy
// accepts. Returns { sql, params }. Same query-string contract → frontend
// swap is one URL change.
//
// `disabledAppIds` (optional Array of app_ids) is appended as a NOT IN
// clause so disabled apps disappear from every list/report regardless of
// caller scope. Pass an empty array to disable the filter.
function buildBookingsCacheWhere(q, scope, disabledAppIds = []) {
  const where = ['1=1'];
  const params = [];
  // Permission scoping (mirrors the upstream proxy's enforcement).
  if (scope.appIds && scope.appIds.length > 0) {
    where.push(`app_id IN (${scope.appIds.map(() => '?').join(',')})`);
    params.push(...scope.appIds);
  }
  if (scope.agentId) { where.push('agent_id = ?'); params.push(scope.agentId); }
  if (scope.b2bOnly) { where.push('b2b_only = 1'); }
  // Globally disabled apps — admin opted out of handling these in
  // Settings → Apps. Hide their bookings from EVERY response (lists,
  // reports, dashboards) until the admin re-enables.
  if (disabledAppIds && disabledAppIds.length > 0) {
    where.push(`app_id NOT IN (${disabledAppIds.map(() => '?').join(',')})`);
    params.push(...disabledAppIds);
  }
  // User-supplied filters
  if (q.app_id)            { where.push('app_id = ?');         params.push(q.app_id); }
  if (q.agent_id)          { where.push('agent_id = ?');       params.push(q.agent_id); }
  if (q.booking_statuses)  {
    const s = String(q.booking_statuses).split(',').filter(Boolean);
    if (s.length) { where.push(`booking_status IN (${s.map(() => '?').join(',')})`); params.push(...s); }
  }
  if (q.payment_statuses)  {
    const s = String(q.payment_statuses).split(',').filter(Boolean);
    if (s.length) { where.push(`payment_status IN (${s.map(() => '?').join(',')})`); params.push(...s); }
  }
  if (q.channel_type)      { where.push('channel_type = ?');   params.push(q.channel_type); }
  if (q.email)             { where.push('contact_email = ?');  params.push(q.email); }
  if (q.pnr)               { where.push('pnr = ?');            params.push(q.pnr); }
  if (q.created_after)     { where.push('upstream_created >= ?'); params.push(toMysqlDatetime(q.created_after)); }
  if (q.created_before)    { where.push('upstream_created < ?');  params.push(toMysqlDatetime(q.created_before)); }
  if (q.travel_after)      { where.push('travel_date >= ?');   params.push(String(q.travel_after).slice(0, 10)); }
  if (q.travel_before)     { where.push('travel_date < ?');    params.push(String(q.travel_before).slice(0, 10)); }
  if (q.search_text) {
    // Search across the flat indexed columns first AND fall back to a
    // substring match against the raw JSON blob. The JSON match catches
    // PNR/HCN/Confirmation No/Supplier Ref values that live inside the
    // products[] / selected_rooms[] sub-objects and aren't promoted to
    // their own indexed columns. Slower (no index) but acceptable for the
    // per-app cache sizes we see in practice.
    where.push('(code LIKE ? OR contact_email LIKE ? OR contact_name LIKE ? OR pnr LIKE ? OR raw LIKE ?)');
    const like = `%${q.search_text}%`;
    params.push(like, like, like, like, like);
  }
  return { sql: where.join(' AND '), params };
}

// Read from cache. Mimics upstream's `{ total, result }` shape.
app.get('/rest/v1/bookings_cache', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const ctx = user.login_context || 'system';

    // Permission scoping — must match the upstream proxy's intent so
    // switching the frontend over doesn't expose new data.
    const scope = { appIds: null, agentId: null, b2bOnly: false };
    if (ctx === 'agent') {
      if (user.locked_app_id) scope.appIds = [user.locked_app_id];
      if (user.locked_agent_id) scope.agentId = user.locked_agent_id;
      scope.b2bOnly = true;
    } else if (ctx === 'app') {
      if (req.query.app_id) {
        if (user.allowed_app_ids?.length && !user.allowed_app_ids.includes(req.query.app_id)) {
          return err(res, 'You do not have access to this app', 403);
        }
        scope.appIds = [req.query.app_id];
      } else if (user.allowed_app_ids?.length) {
        scope.appIds = user.allowed_app_ids;
      }
      if (user.locked_agent_id && !req.query.agent_id) scope.agentId = user.locked_agent_id;
    } else {
      // system/admin — only constrain if allowed_app_ids is explicitly set
      if (user.allowed_app_ids?.length) {
        if (req.query.app_id && !user.allowed_app_ids.includes(req.query.app_id)) {
          return err(res, 'You do not have access to this app', 403);
        }
        scope.appIds = req.query.app_id ? [req.query.app_id] : null;
      }
    }

    // Disabled apps — admins can opt to view them by passing
    // ?include_disabled=true (used by the Settings page). Everyone else
    // gets the standard "hidden" treatment.
    const disabledSet = await getDisabledAppIds();
    const includeDisabled = req.query.include_disabled === 'true' && req.platformUser.role === 'admin';
    const disabledAppIds = includeDisabled ? [] : Array.from(disabledSet);

    const { sql: baseSql, params: baseParams } = buildBookingsCacheWhere(req.query, scope, disabledAppIds);
    const opsViewRules = await getEffectiveOpsRules(user, 'bookings.view');
    const opsDeny = buildOpsBookingDenyFilter(opsViewRules);
    const sql = `${baseSql}${opsDeny.sql}`;
    const params = [...baseParams, ...opsDeny.params];
    const page = Math.max(1, parseInt(req.query.page) || 1);
    const perPage = Math.min(200, Math.max(1, parseInt(req.query.per_page) || 20));

    // Sort — supports `-created_at` (default) and a couple of common
    // alternatives the upstream proxy accepts.
    const sortMap = {
      '-created_at': 'upstream_created DESC',
      'created_at':  'upstream_created ASC',
      '-travel_date': 'travel_date DESC',
      'travel_date':  'travel_date ASC',
    };
    const orderBy = sortMap[req.query.sorts] || 'upstream_created DESC';

    // Optional post-fetch filters:
    //   review_status      — synthetic 'new' bucket included; needs the
    //                        per-booking latest review row.
    //   supplier_ownership — 'our' (Ops handles settlement) vs 'customer'
    //                        (booking's app owns the supplier contract).
    // Both apply server-side AFTER the SQL fetch because they depend on
    // joined data the codebase forbids JOINing directly (collation
    // mismatch on booking_reviews) or that requires walking JSON
    // (products[].supplier_code for ownership).
    const reviewStatusFilterRaw = String(req.query.review_status || '').toLowerCase();
    const VALID_REVIEW_FILTERS = new Set(['new', 'reviewed', 'flagged', 'in_progress']);
    const reviewStatusFilter = VALID_REVIEW_FILTERS.has(reviewStatusFilterRaw) ? reviewStatusFilterRaw : null;

    const supplierOwnershipRaw = String(req.query.supplier_ownership || '').toLowerCase();
    const VALID_SUPPLIER_OWNERSHIP = new Set(['our', 'customer']);
    const supplierOwnershipFilter = VALID_SUPPLIER_OWNERSHIP.has(supplierOwnershipRaw) ? supplierOwnershipRaw : null;

    const needsPostFilter = !!(reviewStatusFilter || supplierOwnershipFilter);

    let allRows;
    let countValue;
    if (needsPostFilter) {
      // Fetch ALL matches for the base filter, attach computed columns,
      // filter, then paginate in memory. Per-app booking sets are small
      // enough that this is fine; the alternative would be either a
      // cross-collation JOIN (forbidden) or pushing the JSON walk into
      // SQL (slower).
      const [matched] = await pool.query(
        `SELECT * FROM bookings_cache WHERE ${sql}
         ORDER BY ${orderBy}`,
        params
      );
      const codes = matched.map(r => r.code).filter(Boolean);
      const reviewMap = await fetchLatestReviewStatusByCode(codes);
      const ownershipMap = supplierOwnershipFilter
        ? await classifyBookingsSupplierOwnership(matched)
        : new Map();

      let enriched = matched.map(r => ({
        ...r,
        _review_status: reviewMap.get(r.code) || 'new',
        _supplier_ownership: ownershipMap.get(r.code) || 'our',
      }));
      if (reviewStatusFilter)      enriched = enriched.filter(r => r._review_status === reviewStatusFilter);
      if (supplierOwnershipFilter) enriched = enriched.filter(r => r._supplier_ownership === supplierOwnershipFilter);

      countValue = enriched.length;
      allRows = enriched.slice((page - 1) * perPage, (page - 1) * perPage + perPage);
    } else {
      const [[count]] = await pool.query(
        `SELECT COUNT(*) AS c FROM bookings_cache WHERE ${sql}`,
        params
      );
      const [rows] = await pool.query(
        `SELECT * FROM bookings_cache WHERE ${sql}
         ORDER BY ${orderBy}
         LIMIT ? OFFSET ?`,
        [...params, perPage, (page - 1) * perPage]
      );
      countValue = count.c;
      const codes = rows.map(r => r.code).filter(Boolean);
      const reviewMap = await fetchLatestReviewStatusByCode(codes);
      allRows = rows.map(r => ({
        ...r,
        _review_status: reviewMap.get(r.code) || 'new',
      }));
    }

    // Bookkeeping: remember the last-viewed scope so kickCatchUpSync knows
    // which apps to refresh after an outage. Only mark when we have a
    // single-app scope (avoids polluting state with "all").
    const targetAppId = req.query.app_id || (scope.appIds?.length === 1 ? scope.appIds[0] : null);
    if (targetAppId) {
      pool.query(
        `INSERT INTO bookings_sync_state (scope_key, last_viewed_at)
         VALUES (?, UTC_TIMESTAMP())
         ON DUPLICATE KEY UPDATE last_viewed_at = UTC_TIMESTAMP()`,
        [`app:${targetAppId}`]
      ).catch(() => {});
    }

    // Re-hydrate the JSON `raw` column on each row so the frontend can read
    // the full upstream payload (consistent with what /api/easygds/bookings
    // returned). The shape is the upstream object plus a couple of cache
    // metadata fields tucked into the same row.
    const result = allRows.map(r => {
      let raw = r.raw;
      if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch {} }
      return {
        ...(raw || {}),
        id: r.id,
        code: r.code,
        app_id: r.app_id,
        agent_id: r.agent_id,
        booking_status: r.booking_status,
        payment_status: r.payment_status,
        channel_type: r.channel_type,
        contact_email: r.contact_email,
        contact_name: r.contact_name,
        pnr: r.pnr,
        travel_date: r.travel_date,
        total_amount: r.total_amount != null ? Number(r.total_amount) : null,
        currency: r.currency,
        passenger_count: r.passenger_count,
        company_name: r.company_name,
        supplier_name: r.supplier_name,
        created_at: r.upstream_created,
        updated_at: r.upstream_updated,
        // Operator review status — 'new' when no review row exists yet OR
        // when the latest row is explicitly 'new'. Otherwise reflects the
        // latest review (reviewed | flagged | in_progress).
        review_status: r._review_status || 'new',
        _cache: { synced_at: r.synced_at },
      };
    });
    ok(res, { total: countValue, result });
  } catch (e) { err(res, e.message, 500); }
});

// Stats — returns counts grouped by booking_status AND review_status for
// the BookingsPage status-card row. Replaces 8 separate /bookings_cache
// calls (per_page=1, one per bucket) with a single round-trip. The caller
// is expected to STRIP its dimension's filter on the way in (e.g. the
// booking_status cards call this without booking_statuses set, so the
// returned bucket counts cover all four values).
app.get('/rest/v1/bookings_cache/stats', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const ctx = user.login_context || 'system';

    const scope = { appIds: null, agentId: null, b2bOnly: false };
    if (ctx === 'agent') {
      if (user.locked_app_id) scope.appIds = [user.locked_app_id];
      if (user.locked_agent_id) scope.agentId = user.locked_agent_id;
      scope.b2bOnly = true;
    } else if (ctx === 'app') {
      if (req.query.app_id) {
        if (user.allowed_app_ids?.length && !user.allowed_app_ids.includes(req.query.app_id)) {
          return err(res, 'You do not have access to this app', 403);
        }
        scope.appIds = [req.query.app_id];
      } else if (user.allowed_app_ids?.length) {
        scope.appIds = user.allowed_app_ids;
      }
      if (user.locked_agent_id && !req.query.agent_id) scope.agentId = user.locked_agent_id;
    } else {
      if (user.allowed_app_ids?.length) {
        if (req.query.app_id && !user.allowed_app_ids.includes(req.query.app_id)) {
          return err(res, 'You do not have access to this app', 403);
        }
        scope.appIds = req.query.app_id ? [req.query.app_id] : null;
      }
    }

    const disabledSet = await getDisabledAppIds();
    const includeDisabled = req.query.include_disabled === 'true' && req.platformUser.role === 'admin';
    const disabledAppIds = includeDisabled ? [] : Array.from(disabledSet);

    const { sql: baseSql, params: baseParams } = buildBookingsCacheWhere(req.query, scope, disabledAppIds);
    const opsViewRules = await getEffectiveOpsRules(user, 'bookings.view');
    const opsDeny = buildOpsBookingDenyFilter(opsViewRules);
    const sql = `${baseSql}${opsDeny.sql}`;
    const params = [...baseParams, ...opsDeny.params];

    const supplierOwnershipRaw = String(req.query.supplier_ownership || '').toLowerCase();
    const VALID_SUPPLIER_OWNERSHIP = new Set(['our', 'customer']);
    const supplierOwnershipFilter = VALID_SUPPLIER_OWNERSHIP.has(supplierOwnershipRaw) ? supplierOwnershipRaw : null;

    const reviewStatusFilterRaw = String(req.query.review_status || '').toLowerCase();
    const VALID_REVIEW_FILTERS = new Set(['new', 'reviewed', 'flagged', 'in_progress']);
    const reviewStatusFilter = VALID_REVIEW_FILTERS.has(reviewStatusFilterRaw) ? reviewStatusFilterRaw : null;

    // Fetch only the lightweight columns we need — never parse the JSON
    // `raw` payload here. If supplier_ownership is filtered, we DO need
    // `raw` because classification walks `products[].supplier_code` AND
    // `app_id` + `upstream_created` because contract matching keys on
    // (app_id, transaction_date). Omitting those two silently classifies
    // every row as 'our' (appId=null short-circuits hasCustomerContract).
    // `bookings_cache` has no `created_at` column — the canonical date is
    // `upstream_created`. classifyBookingsSupplierOwnership reads
    // `r.upstream_created || r.created_at`, so listing only `upstream_created`
    // is sufficient.
    const selectCols = supplierOwnershipFilter
      ? 'id, code, booking_status, app_id, upstream_created, raw'
      : 'id, code, booking_status';
    const [rows] = await pool.query(
      `SELECT ${selectCols} FROM bookings_cache WHERE ${sql}`,
      params
    );

    // Need review map BEFORE supplier_ownership filter when review_status
    // post-filter is active, because we filter on review status too.
    const allCodes = rows.map(r => r.code).filter(Boolean);
    const reviewMap = (reviewStatusFilter || !supplierOwnershipFilter)
      ? await fetchLatestReviewStatusByCode(allCodes)
      : null;

    let working = rows;
    if (supplierOwnershipFilter) {
      const ownershipMap = await classifyBookingsSupplierOwnership(rows);
      working = working.filter(r => (ownershipMap.get(r.code) || 'our') === supplierOwnershipFilter);
    }
    if (reviewStatusFilter) {
      working = working.filter(r => (reviewMap.get(r.code) || 'new') === reviewStatusFilter);
    }

    // Re-resolve the review map for the in-memory bucket counting below
    // (may have been skipped above if only supplier_ownership was active).
    const workingCodes = working.map(r => r.code).filter(Boolean);
    const reviewMapForCounting = reviewMap || await fetchLatestReviewStatusByCode(workingCodes);

    const bookingBuckets = { success: 0, processing: 0, failed: 0, cancelled: 0 };
    const reviewBuckets  = { new: 0, in_progress: 0, flagged: 0, reviewed: 0 };
    for (const r of working) {
      // Upstream emits both spellings — `canceled` (1 l) historically and
      // `cancelled` (2 l) in some flows. Bucket key uses British spelling
      // to match the front-end card label; map American spelling onto it.
      let bs = String(r.booking_status || '').toLowerCase();
      if (bs === 'canceled') bs = 'cancelled';
      if (bs in bookingBuckets) bookingBuckets[bs]++;
      const rs = reviewMapForCounting.get(r.code) || 'new';
      if (rs in reviewBuckets) reviewBuckets[rs]++;
    }

    ok(res, {
      total: working.length,
      booking_status: bookingBuckets,
      review_status: reviewBuckets,
    });
  } catch (e) { err(res, e.message, 500); }
});

// Freshness — what's the cache age for a given scope. Used by the
// frontend to render the green/amber/red pill + decide whether to fire
// an async background sync on page load.
app.get('/rest/v1/bookings_cache/freshness', authMiddleware, async (req, res) => {
  try {
    const appId = req.query.app_id;
    const scopeKey = appId ? `app:${appId}` : null;
    if (scopeKey) {
      const state = await readSyncState(scopeKey);
      if (state && state.last_synced_at) {
        const ageMs = Date.now() - new Date(state.last_synced_at).getTime();
        return ok(res, {
          scope_key: scopeKey,
          last_synced_at: state.last_synced_at,
          age_seconds: Math.round(ageMs / 1000),
          status: state.status,
          last_error: state.last_error,
          synced_count: state.synced_count,
        });
      }
    }
    ok(res, { scope_key: scopeKey, last_synced_at: null, age_seconds: null, status: 'never' });
  } catch (e) { err(res, e.message, 500); }
});

// Manual refresh. Rate-limited per scope (in-memory mutex above) so 10
// users hitting Refresh at once = 1 upstream round-trip.
app.post('/rest/v1/bookings_cache/sync', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const ctx = user.login_context || 'system';
    const requested = req.body?.app_id || req.query.app_id;

    // Resolve which app(s) to sync, with the same scope rules as the read.
    let appIds = [];
    if (requested) {
      if (ctx === 'agent') return err(res, 'Agency users cannot trigger sync', 403);
      if (ctx === 'app' && user.allowed_app_ids?.length && !user.allowed_app_ids.includes(requested)) {
        return err(res, 'You do not have access to this app', 403);
      }
      appIds = [requested];
    } else if (ctx === 'app' && user.allowed_app_ids?.length) {
      appIds = user.allowed_app_ids;
    } else if (ctx === 'system') {
      // System admin with no app specified. Pick a sensible scope:
      //   1. apps the user (or anyone) has looked at in the last 24h, OR
      //   2. allowed_app_ids if explicitly set on the JWT, OR
      //   3. top 10 running apps from upstream (first-time bootstrap)
      const [rows] = await pool.query(
        `SELECT scope_key FROM bookings_sync_state
         WHERE scope_key LIKE 'app:%'
           AND last_viewed_at IS NOT NULL
           AND last_viewed_at >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 24 HOUR)
         LIMIT 20`
      );
      appIds = rows.map(r => String(r.scope_key).slice(4));
      if (appIds.length === 0 && user.allowed_app_ids?.length) {
        appIds = user.allowed_app_ids.slice(0, 10);
      }
      if (appIds.length === 0) {
        try {
          const list = await easygdsRequest('/apps/list?per_page=200');
          const running = (list?.result || []).filter(a => a.status === 'running');
          // Drop apps we don't operate before slicing — otherwise the first
          // 10 spots can fill up with disabled apps and we waste a round trip.
          const disabled = await getDisabledAppIds();
          appIds = running.filter(a => !disabled.has(a.id)).slice(0, 10).map(a => a.id);
        } catch (e) {
          return err(res, `No app scope to sync and upstream lookup failed: ${e.message}`, 503);
        }
      }
    } else {
      return err(res, 'Specify app_id', 400);
    }

    // Strip disabled apps from explicit lists too — syncBookingsForApp
    // would short-circuit anyway, but failing fast here gives the caller
    // a clearer response.
    const disabledForSync = await getDisabledAppIds();
    appIds = appIds.filter(id => !disabledForSync.has(id));
    if (appIds.length === 0) return ok(res, { synced: [] });

    // Run in parallel but bounded — Promise.allSettled so one bad app
    // doesn't take the whole batch down.
    const results = await Promise.allSettled(appIds.map(id => syncBookingsForApp(id, { full: !!req.body?.full })));
    ok(res, {
      synced: results.map((r, i) => r.status === 'fulfilled'
        ? { app_id: appIds[i], ...r.value }
        : { app_id: appIds[i], ok: false, error: r.reason?.message }),
    });
  } catch (e) { err(res, e.message, 500); }
});

// ──────────────────────────────────────────────────────────────────────
// Full sync — ALL running apps (admin only). Long-running, so it executes
// in the background and the client polls a status endpoint for live
// progress instead of holding the HTTP request open.
// ──────────────────────────────────────────────────────────────────────

// In-memory job registry. We don't persist these — restart loses progress,
// which is fine because the job state is just a UI affordance; the cache
// itself is durable. Keeps the last 5 completed jobs around so a user who
// closes the dialog can still pull up the result. Older jobs evict naturally.
const fullSyncJobs = new Map();
let activeFullSyncJobId = null;

function summarizeJob(job) {
  if (!job) return null;
  const now = Date.now();
  const elapsedMs = (job.completed_at || now) - job.started_at;
  const percent = job.total_apps > 0
    ? Math.round((job.apps_completed / job.total_apps) * 100)
    : 0;
  // ETA: (elapsed / completed) * remaining. Already correct for parallel
  // workers — with N workers and equal app sizes, completing K apps in
  // elapsed seconds means avg-time-per-completion is elapsed/K, and the
  // remaining apps will complete at the same throughput. No extra factor
  // needed.
  const estimatedRemainingMs = job.apps_completed > 0 && job.status === 'running'
    ? Math.round((elapsedMs / job.apps_completed) * (job.total_apps - job.apps_completed))
    : null;
  return {
    id: job.id,
    status: job.status,
    started_at: job.started_at,
    completed_at: job.completed_at || null,
    elapsed_ms: elapsedMs,
    estimated_remaining_ms: estimatedRemainingMs,
    percent,
    concurrency: job.concurrency,
    total_apps: job.total_apps,
    apps_completed: job.apps_completed,
    apps_failed: job.apps_failed,
    total_bookings_synced: job.total_bookings_synced,
    // Snapshot the in-flight apps array so a callback that finishes mid-poll
    // doesn't yield an inconsistent slice.
    current_apps: [...(job.current_apps || [])],
    last_error: job.last_error,
    apps: job.apps, // per-app log
  };
}

app.post('/api/bookings_cache/full-sync-all-apps', authMiddleware, adminMiddleware, async (req, res) => {
  try {
    // Reject overlapping jobs — running multiple full-syncs in parallel would
    // pin upstream and confuse the progress UI.
    if (activeFullSyncJobId && fullSyncJobs.get(activeFullSyncJobId)?.status === 'running') {
      return err(res, 'A full sync is already running. Wait for it to finish or view its progress.', 409);
    }

    // Concurrency: how many apps to sync in parallel. Default 5 — fast
    // enough that 43 apps usually finish in well under 5 minutes, gentle
    // enough that upstream's circuit breaker doesn't trip. Capped at 10 so
    // accidental large values can't take upstream down.
    const requested = Number((req.body || {}).concurrency);
    const concurrency = Number.isFinite(requested) && requested >= 1
      ? Math.min(10, Math.floor(requested))
      : 5;

    // Pull the running-app list FIRST and synchronously, so the user gets an
    // immediate error if upstream is unreachable instead of a job that fails
    // at second 0.
    let appsList;
    try {
      const data = await easygdsRequest('/apps/list?per_page=200');
      appsList = (data?.result || []).filter(a => a.status === 'running');
    } catch (e) {
      return err(res, `Could not list apps from upstream: ${e.message}`, 502);
    }
    // Drop apps the admin has globally disabled in Settings → Apps. We
    // shouldn't waste bandwidth or cache rows on apps this Ops platform
    // isn't responsible for.
    const disabled = await getDisabledAppIds();
    appsList = appsList.filter(a => !disabled.has(a.id));
    if (appsList.length === 0) {
      return err(res, 'No active apps to sync. Enable at least one app in Settings → Apps.', 400);
    }

    const jobId = crypto.randomUUID();
    const job = {
      id: jobId,
      status: 'running',
      started_at: Date.now(),
      completed_at: null,
      concurrency,
      total_apps: appsList.length,
      apps_completed: 0,
      apps_failed: 0,
      // List of apps currently being synced (up to `concurrency` entries).
      // Workers add themselves on entry and remove on completion, so the UI
      // can show "Running 5 apps in parallel: …" with live names.
      current_apps: [],
      total_bookings_synced: 0,
      last_error: null,
      // Per-app log so the UI can show a scrollable list. Status starts at
      // 'queued' and transitions queued → running → done|error.
      apps: appsList.map(a => ({
        app_id: a.id,
        name: a.name || a.id,
        status: 'queued',
        synced_count: null,
        ms: null,
        error: null,
      })),
    };
    fullSyncJobs.set(jobId, job);
    activeFullSyncJobId = jobId;

    // Trim to last 6 jobs (1 active + 5 historic) so memory stays bounded.
    if (fullSyncJobs.size > 6) {
      const oldest = [...fullSyncJobs.entries()]
        .filter(([k]) => k !== jobId)
        .sort((a, b) => a[1].started_at - b[1].started_at)[0];
      if (oldest) fullSyncJobs.delete(oldest[0]);
    }

    // Kick off the worker pool WITHOUT awaiting — return the job_id immediately.
    // Each worker pulls from a shared queue (the apps array indices) until
    // empty. As soon as one finishes, it grabs the next queued app — so an
    // app that completes quickly doesn't block the slot for slower ones.
    (async () => {
      let nextIndex = 0;
      const workers = Array(Math.min(concurrency, job.apps.length)).fill(0).map(async () => {
        while (true) {
          const i = nextIndex++;
          if (i >= job.apps.length) break;
          const entry = job.apps[i];
          entry.status = 'running';
          // Track in current_apps array so the UI can name what's running.
          const inFlight = { id: entry.app_id, name: entry.name };
          job.current_apps.push(inFlight);
          try {
            const result = await syncBookingsForApp(entry.app_id, { full: true });
            entry.status = 'done';
            entry.synced_count = result.synced;
            entry.ms = result.ms;
            job.total_bookings_synced += result.synced || 0;
          } catch (e) {
            entry.status = 'error';
            entry.error = String(e.message).slice(0, 500);
            job.apps_failed += 1;
          } finally {
            // Remove from current_apps regardless of outcome.
            const idx = job.current_apps.indexOf(inFlight);
            if (idx >= 0) job.current_apps.splice(idx, 1);
            job.apps_completed += 1;
          }
        }
      });
      await Promise.all(workers);
      job.current_apps = [];
      job.status = job.apps_failed === job.total_apps ? 'failed' : 'completed';
      job.completed_at = Date.now();
      activeFullSyncJobId = null;
    })().catch(e => {
      // Should never fire (errors caught per-app inside the worker), but
      // belt-and-braces in case something throws outside the try/catch.
      job.status = 'failed';
      job.last_error = String(e.message).slice(0, 500);
      job.completed_at = Date.now();
      activeFullSyncJobId = null;
    });

    ok(res, summarizeJob(job));
  } catch (e) { err(res, e.message, 500); }
});

// Pickup endpoint: lets a returning admin (e.g. after a refresh) find the
// in-flight job without remembering its id. Returns null when nothing's
// running, so the UI can render "Start full sync" instead.
//
// MUST be declared BEFORE the `/:id` route — Express matches in registration
// order, so a `/:id` first would swallow `/active` as `id='active'` → 404.
app.get('/api/bookings_cache/sync-jobs/active', authMiddleware, adminMiddleware, async (req, res) => {
  if (!activeFullSyncJobId) return ok(res, null);
  const job = fullSyncJobs.get(activeFullSyncJobId);
  if (!job) { activeFullSyncJobId = null; return ok(res, null); }
  ok(res, summarizeJob(job));
});

// Poll endpoint: cheap, returns the same shape as the start endpoint.
app.get('/api/bookings_cache/sync-jobs/:id', authMiddleware, adminMiddleware, async (req, res) => {
  const job = fullSyncJobs.get(req.params.id);
  if (!job) return err(res, 'Job not found', 404);
  ok(res, summarizeJob(job));
});

app.get('/api/easygds/bookings', authMiddleware, async (req, res) => {
  try {
    const user = req.platformUser;
    const userAllowedApps = user.allowed_app_ids;
    const loginContext = user.login_context || 'system';
    const lockedAppId = user.locked_app_id || null;
    const lockedAgentId = user.locked_agent_id || null;

    // Globally disabled apps are hidden from this endpoint regardless of
    // the user's scope — this Ops platform isn't operating those apps and
    // their bookings must not appear anywhere.
    const disabledSet = await getDisabledAppIds();
    if (req.query.app_id && disabledSet.has(req.query.app_id)) {
      return ok(res, { total: 0, result: [] });
    }

    // ── Server-side permission enforcement ──
    // These checks ensure the user can NEVER see data outside their scope,
    // regardless of what the frontend sends.

    if (loginContext === 'agent') {
      // Agent: locked to specific app + agent — override any frontend values
      if (lockedAppId) req.query.app_id = lockedAppId;
      if (lockedAgentId) req.query.agent_id = lockedAgentId;
      req.query.b2b_only = 'true';
    } else if (loginContext === 'app') {
      // App user: must stay within their allowed apps
      if (req.query.app_id) {
        // Validate the requested app_id is in user's allowed list
        if (userAllowedApps && userAllowedApps.length > 0 && !userAllowedApps.includes(req.query.app_id)) {
          return err(res, 'You do not have access to this app', 403);
        }
      } else if (userAllowedApps && userAllowedApps.length > 0) {
        // No app_id specified — if single app, force it; if multiple, use locked_app_id or first
        if (userAllowedApps.length === 1) {
          req.query.app_id = userAllowedApps[0];
        } else if (lockedAppId) {
          // Multi-app "All Apps" mode: fetch per-app and merge below
          // EasyGDS API only accepts a single app_id, so we need multiple calls
        } else {
          req.query.app_id = userAllowedApps[0];
        }
      }
      // Enforce agent_id if locked
      if (lockedAgentId && !req.query.agent_id) {
        req.query.agent_id = lockedAgentId;
      }
    } else {
      // System admin: if allowed_app_ids is set, enforce it
      if (userAllowedApps && userAllowedApps.length > 0) {
        if (req.query.app_id) {
          if (!userAllowedApps.includes(req.query.app_id)) {
            return err(res, 'You do not have access to this app', 403);
          }
        }
        // System admin without app_id: no restriction (they see all)
      }
    }

    const params = new URLSearchParams();
    const allowed = ['page', 'per_page', 'app_id', 'booking_statuses', 'payment_statuses',
      'search_text', 'created_before', 'created_after', 'agent_id', 'email',
      'first_name', 'last_name', 'pnr', 'channel_type', 'b2b_only',
      'travel_before', 'travel_after', 'payment_methods', 'sorts'];

    for (const key of allowed) {
      if (req.query[key]) params.set(key, req.query[key]);
    }
    if (!params.has('per_page')) params.set('per_page', '20');
    if (!params.has('page')) params.set('page', '1');

    // For multi-app APP users in "All Apps" mode, fetch from each allowed app and merge
    if (loginContext === 'app' && !params.has('app_id') && userAllowedApps && userAllowedApps.length > 1) {
      const perPage = parseInt(params.get('per_page') || '20');
      const page = parseInt(params.get('page') || '1');

      // Skip globally disabled apps in the fan-out so their bookings don't
      // pollute the merged response.
      const eligibleApps = userAllowedApps.filter(id => !disabledSet.has(id));
      if (eligibleApps.length === 0) return ok(res, { total: 0, result: [] });

      // Fetch from all allowed apps in parallel
      const appFetches = eligibleApps.map(async (appId) => {
        const appParams = new URLSearchParams(params);
        appParams.set('app_id', appId);
        appParams.set('per_page', '200'); // fetch more to merge
        appParams.set('page', '1');
        try {
          return await easygdsRequest(`/bookings/list?${appParams.toString()}`);
        } catch (e) {
          console.warn(`Failed to fetch bookings for app ${appId}:`, e.message);
          return { result: [], total: 0 };
        }
      });

      const results = await Promise.all(appFetches);
      // Merge all bookings, sort by created_at desc, and paginate
      let allBookings = [];
      let totalCount = 0;
      for (const r of results) {
        allBookings = allBookings.concat(r.result || []);
        totalCount += (r.total || 0);
      }
      allBookings = await filterBookingsForOpsRules(user, allBookings);
      totalCount = allBookings.length;
      // Sort merged results by created_at descending (default)
      allBookings.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime());
      // Apply client-side pagination
      const start = (page - 1) * perPage;
      const paged = allBookings.slice(start, start + perPage);
      return ok(res, { total: totalCount, result: paged });
    }

    const data = await easygdsRequest(`/bookings/list?${params.toString()}`);
    // Strip out bookings for disabled apps. Upstream returns everything when
    // app_id is absent (admin "all apps" view); we hide the disabled-app
    // rows so the system stays consistent with Settings → Apps.
    if (disabledSet.size > 0 && Array.isArray(data?.result)) {
      const before = data.result.length;
      data.result = data.result.filter(b => !disabledSet.has(b.app_id));
      const removed = before - data.result.length;
      if (removed > 0 && typeof data.total === 'number') {
        data.total = Math.max(0, data.total - removed);
      }
    }
    if (Array.isArray(data?.result)) {
      const before = data.result.length;
      data.result = await filterBookingsForOpsRules(user, data.result);
      const removed = before - data.result.length;
      if (removed > 0 && typeof data.total === 'number') {
        data.total = Math.max(0, data.total - removed);
      }
    }
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Helpers for the detail-cache fallback. We OPPORTUNISTICALLY save the full
// upstream payload after a successful fetch, so a later request can fall
// back to it when upstream is unreachable.
async function saveBookingDetailToCache(idOrCode, raw) {
  if (!raw || (!raw.id && !raw.code)) return;
  try {
    await pool.query(
      `INSERT INTO bookings_detail_cache (booking_id, booking_code, raw, fetched_at)
       VALUES (?, ?, ?, UTC_TIMESTAMP())
       ON DUPLICATE KEY UPDATE
         booking_code = VALUES(booking_code),
         raw = VALUES(raw),
         fetched_at = VALUES(fetched_at)`,
      [raw.id || idOrCode, raw.code || null, JSON.stringify(raw)]
    );
  } catch {}
}
async function readBookingDetailFromCache(idOrCode) {
  if (!idOrCode) return null;
  try {
    const [[row]] = await pool.query(
      `SELECT * FROM bookings_detail_cache
       WHERE booking_id = ? OR booking_code = ?
       ORDER BY fetched_at DESC LIMIT 1`,
      [idOrCode, idOrCode]
    );
    if (!row) return null;
    let raw = row.raw;
    if (typeof raw === 'string') { try { raw = JSON.parse(raw); } catch {} }
    return { raw, fetched_at: row.fetched_at };
  } catch { return null; }
}

// Single booking — live from upstream when reachable, cached fallback when not.
// The frontend treats a `_degraded: true` response as "show banner + disable
// upstream-mutation buttons" so users can still inspect the booking offline.
app.get('/api/easygds/bookings/:id', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  const param = req.params.id;
  const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(param);

  try {
    let data;
    if (isUUID) {
      data = await easygdsRequest(`/bookings?id=${param}`);
    } else {
      const listData = await easygdsRequest(`/bookings/list?per_page=1&search_text=${encodeURIComponent(param)}`);
      const results = listData.result || [];
      const match = results.find(b => b.code === param);
      if (!match) return err(res, 'Booking not found', 404);
      data = match;
    }
    // Block bookings whose app is globally disabled — the system isn't
    // operating those apps anymore. Admins viewing the Settings tab get
    // a bypass since the only place to re-enable from is Settings → Apps.
    const disabledSet = await getDisabledAppIds();
    const bookingAppId = data?.app_id || data?.app?.id || null;
    if (bookingAppId && disabledSet.has(bookingAppId) && req.platformUser.role !== 'admin') {
      return err(res, 'This booking belongs to an application not handled by this Ops platform', 403);
    }
    const guard = await ensureBookingVisibleToUser(req.platformUser, data, param);
    if (!guard.allowed) return err(res, guard.reason, 403);
    // Stash the full payload so a future outage can serve a fallback.
    saveBookingDetailToCache(param, data);
    return ok(res, data);
  } catch (e) {
    // Upstream unreachable (or 5xx). Try the detail cache before failing.
    const cached = await readBookingDetailFromCache(param);
    if (cached) {
      const guard = await ensureBookingVisibleToUser(req.platformUser, cached.raw, param);
      if (!guard.allowed) return err(res, guard.reason, 403);
      return ok(res, {
        ...cached.raw,
        _degraded: true,
        _last_fetched_at: cached.fetched_at,
        _degraded_reason: e.message,
      });
    }
    return err(res, e.message, e.status || 500);
  }
});

// Booking products
app.get('/api/easygds/bookings/:id/products', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/products?booking_id=${req.params.id}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// In-memory id→meta cache for upstream endpoints that need booking_code or
// app_id (System scope). One lookup of /bookings?id=... gives us both. 10-min TTL.
// Accepts either a UUID (used as ?id=...) or a booking code (falls back to
// /bookings/list?search_text=...) so callers can pass whichever they have.
const bookingMetaCache = new Map(); // key -> { code, app_id, exp }
const BOOKING_META_TTL_MS = 10 * 60 * 1000;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

async function resolveBookingMeta(idOrCode) {
  if (!idOrCode) return { code: null, app_id: null };
  const now = Date.now();
  const cached = bookingMetaCache.get(idOrCode);
  if (cached && cached.exp > now) return cached;

  // Cache-first: check our local mirror tables before hitting upstream.
  // bookings_cache covers list-page rows; bookings_detail_cache covers
  // anything a user has previously opened. Both let us answer
  // resolveBookingMeta in <5ms even if upstream is down.
  try {
    const isUUID = UUID_RE.test(idOrCode);
    const [[localRow]] = await pool.query(
      isUUID
        ? `SELECT id, code, app_id FROM bookings_cache WHERE id = ? LIMIT 1`
        : `SELECT id, code, app_id FROM bookings_cache WHERE code = ? OR id = ? LIMIT 1`,
      isUUID ? [idOrCode] : [idOrCode, idOrCode]
    );
    if (localRow && (localRow.code || localRow.app_id)) {
      const meta = {
        code: localRow.code || null,
        app_id: localRow.app_id || null,
        exp: now + BOOKING_META_TTL_MS,
      };
      bookingMetaCache.set(idOrCode, meta);
      return meta;
    }
    // Detail cache as a second-tier fallback.
    const detail = await readBookingDetailFromCache(idOrCode);
    if (detail?.raw) {
      const meta = {
        code: detail.raw.code || null,
        app_id: detail.raw.app_id || detail.raw.app?.id || null,
        exp: now + BOOKING_META_TTL_MS,
      };
      if (meta.code || meta.app_id) {
        bookingMetaCache.set(idOrCode, meta);
        return meta;
      }
    }
  } catch (e) {
    // Cache lookup failure shouldn't break the upstream path.
    console.warn('resolveBookingMeta cache lookup failed:', e.message);
  }

  // Cache miss — fall through to upstream. May throw if upstream is down,
  // in which case the caller is expected to handle the failure (most
  // ingest paths just continue without an app_id).
  let root = null;
  if (UUID_RE.test(idOrCode)) {
    const booking = await easygdsRequest(`/bookings?id=${idOrCode}`);
    root = booking?.result || booking || null;
  } else {
    const list = await easygdsRequest(`/bookings/list?per_page=2&search_text=${encodeURIComponent(idOrCode)}`);
    const rows = list?.result || [];
    root = rows.find(r => r.code === idOrCode) || rows[0] || null;
  }

  const meta = {
    code: root?.code || null,
    app_id: root?.app_id || root?.app?.id || null,
    exp: now + BOOKING_META_TTL_MS,
  };
  if (meta.code || meta.app_id) bookingMetaCache.set(idOrCode, meta);
  // Opportunistically warm both caches with the upstream result.
  if (root?.id) {
    await saveBookingDetailToCache(idOrCode, root);
    const mapped = mapUpstreamBookingToCacheRow(root);
    if (mapped) await upsertBookingsCache([mapped]).catch(() => {});
  }
  return meta;
}

// Booking travelers
// Upstream now requires booking_code (booking_id alone returns 400). The
// frontend already calls this with the booking id, so we transparently
// resolve the code first.
app.get('/api/easygds/bookings/:id/travelers', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const code = req.query.code || (await resolveBookingMeta(req.params.id)).code;
    if (!code) return err(res, 'Booking code not found for id', 404);
    const data = await easygdsRequest(`/bookings/travelers?booking_code=${encodeURIComponent(code)}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Booking prices
app.get('/api/easygds/bookings/:id/prices', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/prices?booking_id=${req.params.id}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Booking contact
// Upstream returns { contact: {...}, fields: [...] } — unwrap so the
// frontend always sees a flat contact object (matches the inline
// booking.contact shape).
app.get('/api/easygds/bookings/:id/contact', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/contact?booking_id=${req.params.id}`);
    ok(res, data?.contact || data);
  } catch (e) { err(res, e.message, 500); }
});

// Update booking contact
app.patch('/api/easygds/bookings/:id/contact', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/contact`, {
      method: 'PATCH',
      body: JSON.stringify({ booking_id: req.params.id, ...req.body })
    });
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Update travelers
app.patch('/api/easygds/bookings/:id/travelers', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/travelers`, {
      method: 'PATCH',
      body: JSON.stringify({ booking_id: req.params.id, ...req.body })
    });
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Send booking email
app.post('/api/easygds/bookings/:id/send-mail', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/send-mail`, {
      method: 'POST',
      body: JSON.stringify({ booking_id: req.params.id, ...req.body })
    });
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Cancel booking product
app.post('/api/easygds/bookings/:id/products/cancel', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/bookings/products/cancel`, {
      method: 'POST',
      body: JSON.stringify({ booking_id: req.params.id, ...req.body })
    });
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Update a single booking product — generic edit endpoint.
//
// Body shape: { data: { ...partialFields }, reason?: string }
// We GET the current product, deep-merge the partial `data`, then PATCH the
// FULL data blob upstream (EasyGDS expects the complete object, not a diff —
// same pattern as confirmation-numbers).
//
// Ops-only: editing live booking data is destructive enough that App users
// must go through the support-request approval flow.
app.patch('/api/easygds/bookings/:id/products/:productId', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { id: codeOrId, productId } = req.params;
    const partial = req.body?.data;
    const reason = typeof req.body?.reason === 'string' ? req.body.reason.trim() : null;
    // target_room_index: when set (number ≥ 0), hotel per-room field
    // mirroring is scoped to that room only — used by the support-request
    // flow to make per-room edits independent. Omit / null = mirror to
    // every room (legacy behavior, kept for backward compat).
    const rawTarget = req.body?.target_room_index;
    const targetRoomIndex = (typeof rawTarget === 'number' && Number.isFinite(rawTarget) && rawTarget >= 0)
      ? Math.floor(rawTarget)
      : null;
    if (!partial || typeof partial !== 'object') {
      return err(res, 'data object is required', 400);
    }

    // Resolve booking — accept either UUID or human code (e.g.
    // DEMOS202302340), then use the UUID for upstream calls. The
    // EasyGDS /bookings/products endpoint only accepts the UUID;
    // earlier this endpoint passed `:id` straight through, so when the
    // ApplyChangesDialog fell back to `request.booking_code` (a code
    // string) the upstream returned no products and we 404'd with
    // "Product not found on this booking".
    const [[bk]] = await pool.query(
      `SELECT id, code FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache (could not resolve id or code)', 404);
    const bookingId = bk.id;

    // Load current products in one shot.
    const upstream = await easygdsRequest(`/bookings/products?booking_id=${bookingId}`);
    const products = upstream?.result || upstream?.data || (Array.isArray(upstream) ? upstream : []);
    const product = products.find(p => p.id === productId);
    if (!product) return err(res, 'Product not found on this booking', 404);

    const original = product.data || {};
    const merged = { ...original, ...partial };

    // Capture only the keys we're changing, for a tighter audit trail.
    const before = {};
    for (const k of Object.keys(partial)) before[k] = original[k];

    // ── Hotel per-room field mirroring ────────────────────────────────────
    // Several "support request" field keys (check_in, check_out, room_name,
    // board_basis, special_requests, hcn, free_cancellation) look like
    // top-level data fields but actually live nested on each room (and on
    // the room's rate). When the partial includes one of those, we mirror
    // it to every room in selected_rooms so the booking page (and every
    // other reader of the same path) sees the change. The strict per-room
    // keys are also stripped from the top-level merged data afterwards so
    // we don't leave behind cruft fields nothing reads.
    const productKind = String(product.product_type || product.product?.code || '').toLowerCase();
    const isHotelLike = productKind === 'hotel' || productKind === 'stopover'
      || (Array.isArray(merged.selected_rooms) && merged.selected_rooms.length > 0);

    if (isHotelLike && Array.isArray(merged.selected_rooms)) {
      const roomPatch = {};
      const ratePatch = {};
      const stripFromTop = [];

      // check_in / check_out (support-request keys) → per-room dates,
      // mirrored into both spellings, plus the booking-level summary.
      if ('check_in' in partial) {
        const v = partial.check_in;
        roomPatch.check_in_date = v;
        roomPatch.checkin_date = v;
        ratePatch.check_in_date = v;
        ratePatch.checkin_date = v;
        merged.checkin_date = v;
        merged.check_in_date = v;
        stripFromTop.push('check_in');
      }
      if ('check_out' in partial) {
        const v = partial.check_out;
        roomPatch.check_out_date = v;
        roomPatch.checkout_date = v;
        ratePatch.check_out_date = v;
        ratePatch.checkout_date = v;
        merged.checkout_date = v;
        merged.check_out_date = v;
        stripFromTop.push('check_out');
      }

      // Room name → per-room only (no booking-level equivalent).
      if ('room_name' in partial) {
        roomPatch.name = partial.room_name;
        roomPatch.room_name = partial.room_name;
        stripFromTop.push('room_name');
      }

      // Board basis → per-room AND room.rate.board_basis (booking page reads
      // `room.board_basis || room.rate.board_basis`).
      if ('board_basis' in partial) {
        roomPatch.board_basis = partial.board_basis;
        ratePatch.board_basis = partial.board_basis;
        stripFromTop.push('board_basis');
      }

      // Special requests live per-room; we keep the top-level write so
      // bookings that store them at booking level also get updated, but we
      // don't strip from top.
      if ('special_requests' in partial) {
        roomPatch.special_requests = partial.special_requests;
      }

      // Free cancellation → boolean per-room.
      if ('free_cancellation' in partial) {
        roomPatch.free_cancellation = !!partial.free_cancellation;
        stripFromTop.push('free_cancellation');
      }

      // HCN / confirmation_number / bk_ref → per-room. These are room-level
      // identifiers, not booking-level. When a support request sets `hcn`,
      // we mirror it into all three room-level paths so each reader is
      // satisfied (booking page renders the first non-empty of these).
      if ('hcn' in partial) {
        roomPatch.hcn = partial.hcn;
        roomPatch.confirmation_number = roomPatch.confirmation_number || partial.hcn;
        roomPatch.bk_ref = roomPatch.bk_ref || partial.hcn;
        stripFromTop.push('hcn');
      }
      if ('confirmation_number' in partial && !('hcn' in partial)) {
        // Only strip if hcn wasn't explicitly set (otherwise hcn handler
        // already wrote a value derived from hcn).
        roomPatch.confirmation_number = partial.confirmation_number;
        stripFromTop.push('confirmation_number');
      }

      const hasRoomChanges = Object.keys(roomPatch).length > 0 || Object.keys(ratePatch).length > 0;
      if (hasRoomChanges) {
        // When targetRoomIndex is set, only that room is mutated. Other
        // rooms pass through untouched. When unset, every room gets the
        // same patch (legacy mirror-to-all).
        merged.selected_rooms = merged.selected_rooms.map((r, idx) => {
          if (targetRoomIndex != null && idx !== targetRoomIndex) return r;
          const updatedRate = Object.keys(ratePatch).length > 0
            ? { ...(r.rate || {}), ...ratePatch }
            : r.rate;
          return { ...r, ...roomPatch, rate: updatedRate };
        });

        // Booking-level date summary — when targeting one room, derive
        // the booking-level checkin/checkout from the earliest checkin
        // and latest checkout across ALL rooms (not just the patched
        // one) so the booking detail card stays consistent.
        if (targetRoomIndex != null && (roomPatch.check_in_date || roomPatch.check_out_date)) {
          const allCheckins = merged.selected_rooms
            .map((r) => r.check_in_date || r.checkin_date)
            .filter(Boolean)
            .sort();
          const allCheckouts = merged.selected_rooms
            .map((r) => r.check_out_date || r.checkout_date)
            .filter(Boolean)
            .sort();
          if (allCheckins.length > 0) {
            merged.checkin_date = allCheckins[0];
            merged.check_in_date = allCheckins[0];
          }
          if (allCheckouts.length > 0) {
            merged.checkout_date = allCheckouts[allCheckouts.length - 1];
            merged.check_out_date = allCheckouts[allCheckouts.length - 1];
          }
        }
      }
      // Strip fields that don't belong at the data top level — we wrote
      // them into the rooms, leaving them at top would be cruft.
      for (const k of stripFromTop) {
        if (k in merged) delete merged[k];
      }
    }

    const result = await easygdsRequest('/bookings/products', {
      method: 'PATCH',
      body: JSON.stringify({ id: productId, data: merged }),
      headers: { 'Content-Type': 'application/json' },
    });

    // Audit against the booking code (matches the legacy confirmation-numbers
    // endpoint pattern so the history tab sees both old + new events under
    // a single entity id). Reuses the booking we already resolved at the
    // top of the handler — no second DB hit.
    await recordAuditEvent(req, {
      action: 'booking_product_update',
      entityType: 'booking',
      entityId: bk.code || bookingId,
      before,
      after: { ...partial, product_id: productId, kind: product.product_type || product.type },
      reason,
    });

    ok(res, result);
  } catch (e) { err(res, e.message, 500); }
});

// Update product price (net / sale) on a booking. Net = supplier cost,
// sale = what the client pays. Stored on the product's `data.price_info`
// blob alongside the existing fields. Ops-only.
app.patch('/api/easygds/bookings/:id/products/:productId/price', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  if (perms.requireOps(req, res)) return;
  try {
    const { id: codeOrId, productId } = req.params;
    const { net_price, sale_price, currency, reason } = req.body || {};
    if (net_price == null && sale_price == null) {
      return err(res, 'net_price or sale_price is required', 400);
    }

    // Resolve booking by id OR code — same fix as the products PATCH.
    const [[bk]] = await pool.query(
      `SELECT id, code FROM bookings_cache WHERE id = ? OR code = ? LIMIT 1`,
      [codeOrId, codeOrId]
    );
    if (!bk) return err(res, 'Booking not found in cache (could not resolve id or code)', 404);
    const bookingId = bk.id;

    const upstream = await easygdsRequest(`/bookings/products?booking_id=${bookingId}`);
    const products = upstream?.result || upstream?.data || (Array.isArray(upstream) ? upstream : []);
    const product = products.find(p => p.id === productId);
    if (!product) return err(res, 'Product not found on this booking', 404);

    const data = { ...(product.data || {}) };
    const priceInfo = { ...(data.price_info || {}) };
    const before = { net_price: priceInfo.net_price, sale_price: priceInfo.sale_price, currency: priceInfo.currency };

    if (net_price != null) priceInfo.net_price = Number(net_price);
    if (sale_price != null) priceInfo.sale_price = Number(sale_price);
    if (currency) priceInfo.currency = currency;
    data.price_info = priceInfo;

    const result = await easygdsRequest('/bookings/products', {
      method: 'PATCH',
      body: JSON.stringify({ id: productId, data }),
      headers: { 'Content-Type': 'application/json' },
    });

    // Audit — reuse the booking already resolved at the top.
    await recordAuditEvent(req, {
      action: 'booking_product_price_update',
      entityType: 'booking',
      entityId: bk.code || bookingId,
      before,
      after: { net_price, sale_price, currency, product_id: productId },
      reason: typeof reason === 'string' ? reason.trim() : null,
    });

    ok(res, result);
  } catch (e) { err(res, e.message, 500); }
});

// PNRs
// Upstream requires app_id when called under the System scope, otherwise it
// returns 400 "Missing app_id for System scope". Resolve it from the booking.
app.get('/api/easygds/bookings/:id/pnrs', authMiddleware, bookingVisibilityMiddleware, async (req, res) => {
  try {
    const meta = await resolveBookingMeta(req.params.id);
    if (!meta.app_id) return err(res, 'Booking app_id not found for id', 404);
    const data = await easygdsRequest(
      `/bookings/products/pnrs/list?booking_id=${req.params.id}&app_id=${meta.app_id}`
    );
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Comments
app.get('/api/easygds/comments', authMiddleware, async (req, res) => {
  try {
    const params = new URLSearchParams();
    // EasyGDS API uses object_ids (plural) to filter comments by booking
    if (req.query.booking_id) params.set('object_ids', req.query.booking_id);
    if (req.query.object_id) params.set('object_ids', req.query.object_id);
    if (req.query.object_ids) params.set('object_ids', req.query.object_ids);
    if (req.query.per_page) params.set('per_page', req.query.per_page);
    else params.set('per_page', '100');
    const data = await easygdsRequest(`/comments/list?${params.toString()}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

app.post('/api/easygds/comments', authMiddleware, async (req, res) => {
  try {
    // Map booking_id to object_id/object_type for EasyGDS API
    const body = { ...req.body };
    if (body.booking_id && !body.object_id) {
      body.object_id = body.booking_id;
      body.object_type = 'Booking';
    }
    const data = await easygdsRequest(`/comments`, { method: 'POST', body: JSON.stringify(body) });
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// B2B Agents — scoped to the user's allowed apps.
//
// Audit gap fix: previously this endpoint had ZERO server-side filtering,
// it just passed `app_id` / `app_ids` through to upstream and relied on the
// EasyGDS user token's narrower scope to do the filtering. If that token
// scope ever loosened, this would leak cross-tenant agents.
//
// New rules:
//   - Ops (unrestricted): pass through anything the caller asks for.
//   - Restricted user: every requested app_id MUST be in their scope, else
//     403. If they ask for nothing, force the filter to their scope so
//     agency dropdowns can't accidentally show other apps' agents.
app.get('/api/easygds/agents', authMiddleware, async (req, res) => {
  try {
    const params = new URLSearchParams();
    for (const k of ['page', 'per_page', 'app_ids', 'status', 'search_text']) {
      if (req.query[k]) params.set(k, req.query[k]);
    }
    // Support app_id as alias for app_ids
    if (req.query.app_id && !params.has('app_ids')) params.set('app_ids', req.query.app_id);

    // Validate / inject app scope.
    const allowed = perms.scopedAppIds(req.platformUser);
    if (allowed !== null) {
      // Restricted caller. Inspect any app_ids they passed.
      const requested = (params.get('app_ids') || '').split(',').filter(Boolean);
      if (requested.length > 0) {
        const outOfScope = requested.filter(id => !allowed.includes(id));
        if (outOfScope.length > 0) {
          return err(res, `Access denied for app(s): ${outOfScope.join(',')}`, 403);
        }
      } else {
        // No app_ids provided — force the filter so we don't accidentally
        // return all tenants' agents.
        if (allowed.length === 0) return ok(res, { total: 0, result: [] });
        params.set('app_ids', allowed.join(','));
      }
    }

    if (!params.has('per_page')) params.set('per_page', '50');
    const data = await easygdsRequest(`/b2b/agents/list?${params.toString()}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Payments
app.get('/api/easygds/payments', authMiddleware, async (req, res) => {
  try {
    const params = new URLSearchParams();
    for (const k of ['page', 'per_page', 'status', 'agent_id']) {
      if (req.query[k]) params.set(k, req.query[k]);
    }
    const data = await easygdsRequest(`/payments/list?${params.toString()}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Single payment
app.get('/api/easygds/payments/:id', authMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest(`/payments?id=${req.params.id}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// ── Balance + transaction adjustments ──────────────────────────────────
//
// Two endpoints, both proxy `core.services.easygds.com`:
//
//   GET /api/easygds/balance?scope_type=App|Agent&scope_id=<id>
//     → current balance snapshot { id, amount, currency_code, ... }
//
//   GET /api/easygds/balance/adjustments?scope_type=App|Agent&scope_id=<id>
//                                        &page=&per_page=&type=increase|decrease
//     → paginated transaction ledger { total, result: [{ description, type,
//       amount, balance_amount, currency_info, ... }] }
//
// The frontend never needs to know the upstream `balance_id` — we resolve
// it from (scope_type, scope_id) here in one extra call. Caching helps on
// repeat — `/balances` for the same scope returns the same id.
//
// Permission gating uses the same scopedAppIds/canAccessApp helpers as the
// rest of the codebase:
//   - Ops (system context): can query any app/agent
//   - App user: scope_type=App → must be in their allowed_app_ids; for
//     scope_type=Agent we trust upstream to filter (Agent is a child of
//     an App; App users can drill into agencies under their apps)
//   - Agent user: locked_agent_id wins; scope_type=Agent must match;
//     scope_type=App must be their locked_app_id

const _balanceIdCache = new Map(); // key=`${scope_type}:${scope_id}` → balance_id, ~5 min TTL
const _BALANCE_ID_TTL_MS = 5 * 60 * 1000;

async function resolveBalanceId(scopeType, scopeId) {
  const key = `${scopeType}:${scopeId}`;
  const cached = _balanceIdCache.get(key);
  if (cached && cached.exp > Date.now()) return cached.id;
  const data = await easygdsRequest(`/balances?scope_type=${encodeURIComponent(scopeType)}&scope_id=${encodeURIComponent(scopeId)}`);
  if (!data?.id) throw new Error(`No balance found for ${scopeType}:${scopeId}`);
  _balanceIdCache.set(key, { id: data.id, exp: Date.now() + _BALANCE_ID_TTL_MS });
  return data.id;
}

// Validate the caller can access this scope. Returns null if OK; error
// message string if forbidden.
function validateBalanceScope(user, scopeType, scopeId) {
  if (!scopeType || !scopeId) return 'scope_type and scope_id are required';
  const ctx = user?.login_context;
  // Agent context is locked
  if (ctx === 'agent') {
    if (scopeType === 'Agent' && scopeId !== user.locked_agent_id) return 'Forbidden';
    if (scopeType === 'App' && scopeId !== user.locked_app_id) return 'Forbidden';
  }
  // App-level scope: validate against allowed_app_ids unless Ops
  if (scopeType === 'App' && !perms.isOps(user)) {
    if (!perms.canAccessApp(user, scopeId)) return 'Access denied for this application';
  }
  // Agent scope for App context: allow the user to query any agent under
  // their allowed apps (we trust upstream to enforce — agents are children
  // of apps and EasyGDS scopes the read).
  return null;
}

// Reject balance lookups for apps the admin has globally disabled. We
// only block App-scope (not Agent) — agents live under apps but the upstream
// id used here is the agent's own and isn't easily mapped to a parent app
// without an extra round trip. App-scope is the common case and covers the
// "we don't operate this app" intent.
async function validateBalanceAppEnabled(scopeType, scopeId, user) {
  if (scopeType !== 'App') return null;
  const disabled = await getDisabledAppIds();
  if (disabled.has(scopeId) && user?.role !== 'admin') {
    return 'This application is not handled by this Ops platform';
  }
  return null;
}

app.get('/api/easygds/balance', authMiddleware, async (req, res) => {
  try {
    const { scope_type, scope_id, force } = req.query;
    const denial = validateBalanceScope(req.platformUser, scope_type, scope_id);
    if (denial) return err(res, denial, 403);
    const disabledDenial = await validateBalanceAppEnabled(scope_type, scope_id, req.platformUser);
    if (disabledDenial) return err(res, disabledDenial, 403);
    // ?force=1 from the "Sync now" button busts our 5-min balance-id
    // cache so we re-resolve from upstream — useful when EasyGDS rotates
    // an internal id, or when the operator wants a guaranteed fresh read.
    if (force === '1' || force === 'true') {
      _balanceIdCache.delete(`${scope_type}:${scope_id}`);
    }
    const data = await easygdsRequest(`/balances?scope_type=${encodeURIComponent(scope_type)}&scope_id=${encodeURIComponent(scope_id)}`);
    ok(res, { ...data, _synced_at: new Date().toISOString() });
  } catch (e) { err(res, e.message, 500); }
});

app.get('/api/easygds/balance/adjustments', authMiddleware, async (req, res) => {
  try {
    const { scope_type, scope_id, force } = req.query;
    const denial = validateBalanceScope(req.platformUser, scope_type, scope_id);
    if (denial) return err(res, denial, 403);
    const disabledDenial = await validateBalanceAppEnabled(scope_type, scope_id, req.platformUser);
    if (disabledDenial) return err(res, disabledDenial, 403);
    if (force === '1' || force === 'true') {
      _balanceIdCache.delete(`${scope_type}:${scope_id}`);
    }
    const balanceId = await resolveBalanceId(scope_type, scope_id);

    const params = new URLSearchParams();
    params.set('balance_id', balanceId);
    for (const k of ['page', 'per_page', 'type']) {
      if (req.query[k]) params.set(k, req.query[k]);
    }
    if (!params.has('per_page')) params.set('per_page', '50');
    if (!params.has('page')) params.set('page', '1');

    const data = await easygdsRequest(`/balances/adjustments?${params.toString()}`);
    ok(res, { ...data, _synced_at: new Date().toISOString() });
  } catch (e) { err(res, e.message, 500); }
});

// Currencies
app.get('/api/easygds/currencies', authMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest('/currencies/list');
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Suppliers — agency staff have no business viewing the supplier catalogue.
// Audit gap fix: previously open to every authenticated user including
// agency-context. Ops + App-context users still see the upstream list.
app.get('/api/easygds/suppliers', authMiddleware, async (req, res) => {
  try {
    if (perms.requireNotAgent(req, res)) return;
    const data = await easygdsRequest('/suppliers');
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Charts
app.post('/api/easygds/charts/bookings', authMiddleware, async (req, res) => {
  try {
    const data = await easygdsRequest('/charts/bookings', { method: 'POST', body: JSON.stringify(req.body) });
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Promotions
app.get('/api/easygds/promotions', authMiddleware, async (req, res) => {
  try {
    const params = new URLSearchParams();
    for (const k of ['page', 'per_page', 'app_id']) {
      if (req.query[k]) params.set(k, req.query[k]);
    }
    const data = await easygdsRequest(`/promotions?${params.toString()}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// B2B Markets
app.get('/api/easygds/markets', authMiddleware, async (req, res) => {
  try {
    if (!req.query.app_id) return err(res, 'app_id required');
    const data = await easygdsRequest(`/b2b/markets/list?app_id=${req.query.app_id}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// Static content
app.get('/api/easygds/static/airlines', authMiddleware, async (req, res) => {
  try {
    const search = req.query.search ? `?search=${encodeURIComponent(req.query.search)}` : '';
    const data = await easygdsRequest(`/static-contents/airlines${search}`);
    ok(res, data);
  } catch (e) { err(res, e.message, 500); }
});

// ════════════════════════════════════════════
// PRODUCTION: Serve static frontend
// ════════════════════════════════════════════

if (process.env.NODE_ENV === 'production') {
  const distPath = require('path').join(__dirname, '..', 'dist');
  app.use(require('express').static(distPath));
  // SPA fallback - serve index.html for all non-API routes
  app.get('*path', (req, res) => {
    if (!req.path.startsWith('/api') && !req.path.startsWith('/rest') && !req.path.startsWith('/uploads')) {
      res.sendFile(require('path').join(distPath, 'index.html'));
    }
  });
}

// ════════════════════════════════════════════
// START SERVER
// ════════════════════════════════════════════

// Export app for Passenger (cPanel)
module.exports = app;

// Idempotent schema bootstrap — creates the local platform_users table if it
// doesn't exist yet. This is the table used by the System Admin local-auth
// path; without it admin login falls through to EasyGDS IAM only.
async function ensureLocalSchema() {
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS platform_users (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        email           VARCHAR(255) NOT NULL UNIQUE,
        password_hash   VARCHAR(255) NOT NULL,
        first_name      VARCHAR(128) DEFAULT NULL,
        last_name       VARCHAR(128) DEFAULT NULL,
        role            VARCHAR(32)  NOT NULL DEFAULT 'agent',
        is_active       TINYINT(1)   NOT NULL DEFAULT 1,
        allowed_app_ids JSON         DEFAULT NULL,
        token_version   INT UNSIGNED NOT NULL DEFAULT 0,
        last_login      DATETIME     DEFAULT NULL,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_role (role),
        INDEX idx_is_active (is_active)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    // Idempotent migration for installs that pre-date the token_version column.
    // Bumping token_version invalidates every previously-issued JWT for this
    // user — used on logout, password change, and account deactivation.
    try {
      await pool.query(`ALTER TABLE platform_users ADD COLUMN token_version INT UNSIGNED NOT NULL DEFAULT 0`);
    } catch (e) { if (!/Duplicate column/i.test(e.message)) throw e; }
    console.log('✅ Schema bootstrap: platform_users ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (platform_users) failed:', e.message);
  }

  // App-membership cache. Mirrors EasyGDS IAM's `/accounts/app-members/list`
  // and is refreshed every 10 min by syncAppMembersCache(). Login reads this
  // table to know which apps a user has access to — no per-login IAM call,
  // no flakiness when IAM is briefly unreachable, and we get a queryable
  // "who has access to what" admin tool for free.
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS app_members_cache (
        app_id          VARCHAR(64)  NOT NULL,
        account_id      VARCHAR(64)  NOT NULL,
        email           VARCHAR(255) NOT NULL,
        first_name      VARCHAR(128) DEFAULT NULL,
        last_name       VARCHAR(128) DEFAULT NULL,
        enabled         TINYINT(1)   NOT NULL DEFAULT 1,
        status          VARCHAR(32)  NOT NULL DEFAULT 'active',
        roles           JSON         DEFAULT NULL,
        last_synced_at  DATETIME     NOT NULL,
        PRIMARY KEY (app_id, account_id),
        INDEX idx_email (email),
        INDEX idx_app (app_id),
        INDEX idx_synced (last_synced_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: app_members_cache ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (app_members_cache) failed:', e.message);
  }

  // ── Supplier balance machinery (3 tables) ─────────────────────────────
  // Drives the Ops-only "Suppliers Balance" page. EasyGDS doesn't expose
  // supplier balances via API, so we calculate them here:
  //   payment_methods            — admin-managed lookup for top-up form
  //   supplier_topups            — manual entries: operator paid supplier
  //   supplier_transactions_cache — derived ledger (bookings + refunds + topups)
  //                                 refreshed every 10 min by the sync job
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS payment_methods (
        id           VARCHAR(64)  NOT NULL PRIMARY KEY,
        name         VARCHAR(128) NOT NULL,
        code         VARCHAR(64)  NOT NULL UNIQUE,
        description  TEXT         DEFAULT NULL,
        is_active    TINYINT(1)   NOT NULL DEFAULT 1,
        sort_order   INT          NOT NULL DEFAULT 0,
        created_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at   DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_active_sort (is_active, sort_order)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: payment_methods ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (payment_methods) failed:', e.message);
  }

  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_topups (
        id                 VARCHAR(64)   NOT NULL PRIMARY KEY,
        supplier_id        VARCHAR(64)   NOT NULL,
        amount             DECIMAL(15,2) NOT NULL,
        currency           VARCHAR(8)    NOT NULL DEFAULT 'USD',
        paid_on            DATE          NOT NULL,
        payment_method_id  VARCHAR(64)   DEFAULT NULL,
        reference          VARCHAR(255)  DEFAULT NULL,
        attachment_path    VARCHAR(512)  DEFAULT NULL,
        notes              TEXT          DEFAULT NULL,
        created_by         VARCHAR(64)   DEFAULT NULL,
        created_at         DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
        deleted            TINYINT(1)    NOT NULL DEFAULT 0,
        INDEX idx_supplier (supplier_id),
        INDEX idx_supplier_currency (supplier_id, currency),
        INDEX idx_paid_on (paid_on),
        INDEX idx_deleted (deleted)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: supplier_topups ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (supplier_topups) failed:', e.message);
  }

  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_transactions_cache (
        id              VARCHAR(96)   NOT NULL PRIMARY KEY,
        supplier_id     VARCHAR(64)   NOT NULL,
        type            VARCHAR(16)   NOT NULL,
        booking_id      VARCHAR(64)   DEFAULT NULL,
        booking_code    VARCHAR(64)   DEFAULT NULL,
        amount          DECIMAL(15,2) NOT NULL,
        currency        VARCHAR(8)    NOT NULL,
        txn_date        DATETIME      NOT NULL,
        description     VARCHAR(500)  DEFAULT NULL,
        metadata        JSON          DEFAULT NULL,
        last_synced_at  DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_supplier_date (supplier_id, txn_date),
        INDEX idx_supplier_currency (supplier_id, currency),
        INDEX idx_type (type),
        INDEX idx_booking (booking_id),
        INDEX idx_synced (last_synced_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: supplier_transactions_cache ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (supplier_transactions_cache) failed:', e.message);
  }

  // Seed common payment methods on first boot — operator can edit/disable
  // via the Settings → Payment Methods tab.
  try {
    const [[{ c }]] = await pool.query('SELECT COUNT(*) AS c FROM payment_methods');
    if (c === 0) {
      const seed = [
        ['bank-transfer', 'Bank Transfer', 1],
        ['wire-transfer', 'Wire Transfer', 2],
        ['cash', 'Cash', 3],
        ['cheque', 'Cheque', 4],
      ];
      for (const [code, name, order] of seed) {
        await pool.query(
          'INSERT INTO payment_methods (id, code, name, sort_order, is_active) VALUES (?, ?, ?, ?, 1)',
          [require('crypto').randomBytes(16).toString('hex'), code, name, order]
        );
      }
      console.log('✅ Seeded payment_methods with 4 defaults');
    }
  } catch (e) { /* non-fatal seed */ }

  // ── Supplier balance v2: settlement currencies + risk thresholds ─────
  // Each column is idempotent (ensureColumn checks INFORMATION_SCHEMA first).
  try {
    // ensureColumn expects "<col> <full DDL>" — column name is splatted
    // verbatim into the ALTER, not just the type.
    await ensureColumn('suppliers', 'settlement_currencies',       'settlement_currencies JSON DEFAULT NULL');
    await ensureColumn('suppliers', 'default_settlement_currency', "default_settlement_currency VARCHAR(8) NOT NULL DEFAULT 'USD'");
    await ensureColumn('suppliers', 'balance_baseline_date',       'balance_baseline_date DATE DEFAULT NULL');
    await ensureColumn('suppliers', 'account_type',                "account_type VARCHAR(8) NOT NULL DEFAULT 'deposit'");
    await ensureColumn('suppliers', 'critical_threshold',          'critical_threshold DECIMAL(15,2) DEFAULT NULL');
    await ensureColumn('suppliers', 'credit_limit',                'credit_limit DECIMAL(15,2) DEFAULT NULL');
    await ensureColumn('suppliers', 'last_critical_alert_at',      'last_critical_alert_at DATETIME DEFAULT NULL');
    // Per-supplier cooldown override — minutes between critical-balance
    // alert emails. NULL = use the global default (24 h). Min 15 min.
    await ensureColumn('suppliers', 'critical_alert_cooldown_minutes', 'critical_alert_cooldown_minutes INT DEFAULT NULL');
    await ensureColumn('supplier_transactions_cache', 'original_currency', 'original_currency VARCHAR(8) DEFAULT NULL');
    await ensureColumn('supplier_transactions_cache', 'original_amount',   'original_amount DECIMAL(15,2) DEFAULT NULL');
    await ensureColumn('supplier_transactions_cache', 'fx_rate_used',      'fx_rate_used DECIMAL(18,8) DEFAULT NULL');
    await ensureColumn('supplier_transactions_cache', 'fx_source',         'fx_source VARCHAR(32) DEFAULT NULL');
    console.log('✅ Schema bootstrap: supplier balance v2 columns ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (supplier balance v2) failed:', e.message);
  }

  // ── Supplier contracts (per-app statement ownership) ─────────────────
  // One row per (supplier, owner) pair. owner_app_id IS NULL means the
  // contract belongs to us (Ops). When a client app has its own direct
  // contract with the supplier, a row is added with that app's id. The
  // sync job in syncSupplierTransactionsCache uses these rows to stamp
  // owner_app_id on each transaction so per-app statements split cleanly.
  // See AGENT_HANDOFF.md ("Per-app supplier statements") for the full design.
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_contracts (
        id                  VARCHAR(64)   NOT NULL PRIMARY KEY,
        supplier_id         VARCHAR(64)   NOT NULL,
        owner_app_id        VARCHAR(64)   NULL,                    -- NULL = Ops/us
        contract_label      VARCHAR(128)  NULL,
        account_type        VARCHAR(8)    NULL,                    -- 'deposit' | 'credit' | NULL = inherit
        credit_limit        DECIMAL(15,2) NULL,                    -- NULL = inherit
        critical_threshold  DECIMAL(15,2) NULL,                    -- NULL = inherit
        settlement_currency VARCHAR(8)    NULL,                    -- NULL = inherit
        effective_from      DATE          NULL,
        effective_to        DATE          NULL,
        is_active           TINYINT(1)    NOT NULL DEFAULT 1,
        notes               TEXT          NULL,
        created_by          VARCHAR(64)   NULL,
        created_at          DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at          DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_supplier (supplier_id),
        INDEX idx_owner_app (owner_app_id),
        INDEX idx_active (is_active),
        INDEX idx_supplier_owner_active (supplier_id, owner_app_id, is_active)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    // owner_app_id columns on the existing ledger tables. NULL keeps current
    // behaviour exactly — "everything belongs to us" — until a contract row
    // is created. Indexed for the statement split query (supplier + owner).
    await ensureColumn('supplier_topups', 'owner_app_id', 'owner_app_id VARCHAR(64) NULL');
    await ensureIndex('supplier_topups', 'idx_owner_app',
      'INDEX idx_owner_app (owner_app_id)');
    await ensureColumn('supplier_transactions_cache', 'owner_app_id', 'owner_app_id VARCHAR(64) NULL');
    await ensureIndex('supplier_transactions_cache', 'idx_supplier_owner_ccy',
      'INDEX idx_supplier_owner_ccy (supplier_id, owner_app_id, currency)');
    // Multi-currency per contract. The existing `settlement_currency`
    // column stays as the primary / default. `settlement_currencies` is
    // an optional JSON array of "also accepted" currencies — when the
    // sync classifies a transaction onto this contract, the currency
    // routing prefers the contract's primary if the booking matches;
    // otherwise it picks from the allowed list; otherwise it falls back
    // to the supplier-level policy. Mirrors `suppliers.settlement_currencies`.
    await ensureColumn('supplier_contracts', 'settlement_currencies',
      'settlement_currencies JSON DEFAULT NULL');
    console.log('✅ Schema bootstrap: supplier_contracts + ownership columns ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (supplier_contracts) failed:', e.message);
  }

  // Generic key/value table for system-wide settings (finance-alert
  // recipient list, etc.). Single-row-per-key, JSON value.
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS system_settings (
        \`key\`      VARCHAR(64) NOT NULL PRIMARY KEY,
        value      JSON         NOT NULL,
        updated_by VARCHAR(64)  DEFAULT NULL,
        updated_at DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: system_settings ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (system_settings) failed:', e.message);
  }

  // supplier_reminders — Ops sends a request to a supplier (HCN, cancellation,
  // change, refund, …) and we need to chase if they don't reply. Each row
  // represents one open expectation. A background job (checkSupplierReminders)
  // ticks through `next_reminder_at` and fans out notifications + emails to
  // the assigned agent + Ops admins. Operators flip status to 'replied' when
  // the supplier responds, or 'resolved'/'dismissed' to close out.
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_reminders (
        id                 VARCHAR(64)  NOT NULL PRIMARY KEY,
        request_id         VARCHAR(64)  NULL,            -- support_requests.id (optional)
        booking_id         VARCHAR(64)  NULL,            -- bookings_cache.id (optional)
        booking_code       VARCHAR(64)  NULL,
        supplier_id        VARCHAR(64)  NULL,            -- suppliers.id (optional)
        supplier_name      VARCHAR(255) NULL,
        supplier_email     VARCHAR(255) NULL,
        request_type       VARCHAR(32)  NOT NULL,        -- hcn | cancellation | change_request | refund | other
        subject            VARCHAR(255) NULL,
        message            TEXT         NULL,
        sla_hours          DECIMAL(8,2) NOT NULL,        -- hours until first reminder
        reminder_every_hours DECIMAL(8,2) NOT NULL DEFAULT 12,
        max_escalation_level INT        NOT NULL DEFAULT 5,
        escalation_level   INT          NOT NULL DEFAULT 0,
        last_reminder_at   DATETIME     NULL,
        next_reminder_at   DATETIME     NOT NULL,
        status             VARCHAR(16)  NOT NULL DEFAULT 'open',  -- open|replied|resolved|dismissed
        replied_at         DATETIME     NULL,
        resolved_at        DATETIME     NULL,
        resolved_by        VARCHAR(64)  NULL,
        notes              TEXT         NULL,
        created_by         VARCHAR(64)  NULL,
        created_at         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_status_next (status, next_reminder_at),
        INDEX idx_request (request_id),
        INDEX idx_booking (booking_id),
        INDEX idx_supplier (supplier_id),
        INDEX idx_created (created_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: supplier_reminders ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (supplier_reminders) failed:', e.message);
  }

  // booking_reviews — append-only history of operator review actions on a
  // booking. Each row captures one transition: who set the status, what
  // status they picked (reviewed | flagged | in_progress), the note they
  // wrote, and when. Reading "latest status" = ORDER BY created_at DESC
  // LIMIT 1; the full timeline is the natural history shown in the dialog.
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS booking_reviews (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        booking_id      VARCHAR(64)  NULL,
        booking_code    VARCHAR(64)  NOT NULL,
        status          VARCHAR(32)  NOT NULL,    -- reviewed | flagged | in_progress
        notes           TEXT         NULL,
        created_by      VARCHAR(64)  NULL,        -- platform_users.id
        created_by_name VARCHAR(255) NULL,        -- snapshot for display
        created_by_email VARCHAR(255) NULL,       -- snapshot for display
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_booking_code (booking_code, created_at),
        INDEX idx_booking_id (booking_id),
        INDEX idx_status (status)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: booking_reviews ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (booking_reviews) failed:', e.message);
  }

  // Track which template each outbound email was rendered from. Used by the
  // booking-detail "Supplier Thread" view to show "sent via Cancellation
  // Request template" badges, and by reporting to count template adoption.
  try {
    await ensureColumn('emails', 'template_id', 'template_id VARCHAR(64) DEFAULT NULL');
    await ensureIndex('emails', 'idx_emails_template', 'INDEX idx_emails_template (template_id)');
    console.log('✅ Schema bootstrap: emails.template_id ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (emails.template_id) failed:', e.message);
  }

  // Multi-tenancy gate: every email row carries the app it belongs to. App
  // users can only see emails for apps in their allowed_app_ids; Ops sees
  // everything. Backfill from booking_code → bookings_cache.app_id for any
  // existing rows so the gate doesn't silently hide pre-migration data.
  try {
    await ensureColumn('emails', 'app_id', 'app_id VARCHAR(64) DEFAULT NULL');
    await ensureIndex('emails', 'idx_emails_app', 'INDEX idx_emails_app (app_id)');
    // One-shot backfill — bounded so it can't run away on a huge table.
    // Only touches rows that have a booking_code but no app_id yet.
    const [bf] = await pool.query(
      `UPDATE emails e
         JOIN bookings_cache b ON b.code = e.booking_code
          SET e.app_id = b.app_id
        WHERE e.app_id IS NULL AND b.app_id IS NOT NULL`
    );
    if (bf.affectedRows > 0) console.log(`✅ Backfilled emails.app_id for ${bf.affectedRows} row(s)`);
    console.log('✅ Schema bootstrap: emails.app_id ready (multi-tenancy gate)');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (emails.app_id) failed:', e.message);
  }

  // audit_events — tamper-evident log of money-mutating actions. Every
  // supplier topup / refund / balance-policy change writes a row here so
  // accounting can answer "who changed what, when, and what was it
  // before?" without grep-ing logs. We do NOT encrypt this table — it's
  // for auditors. Indexes match the two queries it gets: by entity (the
  // ledger view) and by actor (the user-activity view).
  try {
    await pool.query(`
      CREATE TABLE IF NOT EXISTS audit_events (
        id              VARCHAR(64) NOT NULL PRIMARY KEY,
        actor_user_id   VARCHAR(64) NULL,
        actor_email     VARCHAR(255) NULL,
        actor_name      VARCHAR(255) NULL,
        action          VARCHAR(64) NOT NULL,
        entity_type     VARCHAR(32) NOT NULL,
        entity_id       VARCHAR(64) NULL,
        before_json     JSON NULL,
        after_json      JSON NULL,
        reason          TEXT NULL,
        ip_address      VARCHAR(64) NULL,
        created_at      DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_audit_entity (entity_type, entity_id, created_at),
        INDEX idx_audit_actor  (actor_user_id, created_at),
        INDEX idx_audit_action (action, created_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: audit_events ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (audit_events) failed:', e.message);
  }

  // AI auto-classifier (Phase 4) — adds 4 columns to `emails` so the
  // inbox UI can show "AI suggests..." badges, plus a daily-quota log
  // table so we can enforce a per-day budget on Anthropic API calls.
  try {
    await ensureColumn('emails', 'ai_classified',
      'ai_classified TINYINT(1) NOT NULL DEFAULT 0');
    await ensureColumn('emails', 'ai_confidence',
      'ai_confidence DECIMAL(3,2) DEFAULT NULL');
    await ensureColumn('emails', 'ai_suggested_booking_code',
      'ai_suggested_booking_code VARCHAR(64) DEFAULT NULL');
    await ensureColumn('emails', 'ai_reason',
      'ai_reason VARCHAR(500) DEFAULT NULL');
    await ensureIndex('emails', 'idx_emails_ai_suggested',
      'INDEX idx_emails_ai_suggested (ai_suggested_booking_code, ai_confidence)');

    // Daily quota log — tracks AI calls per UTC day for budget enforcement
    // and cost reporting. PRIMARY KEY on `day` so the INSERT...ON DUPLICATE
    // KEY UPDATE in classifyEmailWithAI atomically increments.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS ai_classification_log (
        day                 DATE          NOT NULL PRIMARY KEY,
        calls               INT           NOT NULL DEFAULT 0,
        input_tokens        INT           NOT NULL DEFAULT 0,
        output_tokens       INT           NOT NULL DEFAULT 0,
        cache_read_tokens   INT           NOT NULL DEFAULT 0,
        last_call_at        DATETIME      NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    console.log('✅ Schema bootstrap: ai classifier columns + ai_classification_log ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (ai classifier) failed:', e.message);
  }

  // Plaintext migration for SMTP / IMAP passwords. Reverses the previous
  // encrypt-at-rest scheme: any row still wrapped in `enc:v1:` from an
  // older build is decrypted back to plaintext in place. Idempotent —
  // plaintext rows are skipped on every subsequent boot.
  //
  // For rows that CAN'T be decrypted with this server's key (different
  // SMTP_ENCRYPTION_KEY / JWT_SECRET than the row was encrypted under),
  // we leave them intact and print a loud, actionable warning naming
  // each affected account. The operator must re-enter the password
  // through the UI for those — there's no way to recover the plaintext
  // from an un-decryptable blob.
  try {
    const re = /^enc:v1:/;
    const [smtpRows] = await pool.query(
      "SELECT id, name, password FROM smtp_accounts WHERE password IS NOT NULL AND password <> ''"
    );
    let smtpDecrypted = 0, smtpUndecryptable = [];
    for (const r of smtpRows) {
      if (!re.test(String(r.password))) continue; // already plaintext
      const pt = decryptSecret(r.password);       // returns blob unchanged on key mismatch
      if (typeof pt === 'string' && !pt.startsWith(ENC_PREFIX)) {
        await pool.query('UPDATE smtp_accounts SET password = ? WHERE id = ?', [pt, r.id]);
        smtpDecrypted++;
      } else {
        smtpUndecryptable.push(r.name || r.id);
      }
    }
    const [imapRows] = await pool.query(
      "SELECT id, name, imap_password FROM email_inboxes WHERE imap_password IS NOT NULL AND imap_password <> ''"
    );
    let imapDecrypted = 0, imapUndecryptable = [];
    for (const r of imapRows) {
      if (!re.test(String(r.imap_password))) continue;
      const pt = decryptSecret(r.imap_password);
      if (typeof pt === 'string' && !pt.startsWith(ENC_PREFIX)) {
        await pool.query('UPDATE email_inboxes SET imap_password = ? WHERE id = ?', [pt, r.id]);
        imapDecrypted++;
      } else {
        imapUndecryptable.push(r.name || r.id);
      }
    }
    if (smtpDecrypted > 0 || imapDecrypted > 0) {
      console.log(`✅ Plaintext migration: ${smtpDecrypted} SMTP, ${imapDecrypted} IMAP password(s) decrypted back to plaintext`);
    } else if (smtpUndecryptable.length === 0 && imapUndecryptable.length === 0) {
      console.log('✅ SMTP/IMAP credentials: all stored as plaintext (no migration needed)');
    }
    if (smtpUndecryptable.length > 0 || imapUndecryptable.length > 0) {
      console.warn('');
      console.warn('🔑 ===== ENCRYPTED ROWS COULD NOT BE READ =====');
      console.warn('🔑 The following stored credentials are encrypted with a key this server');
      console.warn('🔑 doesn\'t have (most likely a DB restored from another instance):');
      if (smtpUndecryptable.length > 0) console.warn(`🔑   SMTP accounts: ${smtpUndecryptable.join(', ')}`);
      if (imapUndecryptable.length > 0) console.warn(`🔑   IMAP inboxes : ${imapUndecryptable.join(', ')}`);
      console.warn('🔑 Fix: open Settings → SMTP / Inbox, click Edit on each affected row,');
      console.warn('🔑      re-type the password, and Save. The new value will be stored as');
      console.warn('🔑      plaintext (encryption-at-rest has been removed).');
      console.warn('🔑 ============================================');
      console.warn('');
    }
  } catch (e) {
    console.warn('⚠️  Plaintext migration failed:', e.message);
  }

  // Seed default supplier-side email templates on first boot. We only do
  // this when ZERO supplier templates exist — re-running the server
  // shouldn't keep adding duplicates, and an operator who deletes a default
  // shouldn't see it come back. Each template body is HTML (rendered via
  // ReactQuill in the editor) and uses the placeholder vocabulary
  // documented in renderEmailTemplate(): {{booking_ref}}, {{supplier_name}},
  // {{services_table}}, etc. Operators can edit / disable / extend in
  // Settings → Templates.
  try {
    const [[{ c }]] = await pool.query(
      "SELECT COUNT(*) AS c FROM email_templates WHERE template_type = 'supplier'"
    );
    if (c === 0) {
      const seed = [
        {
          name: 'Booking Confirmation Request',
          subject: 'Confirmation requested — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>Hope you're well. We're reaching out about the booking below — could you please send through the confirmation number when you have a moment?</p>
<h3>Booking details</h3>
{{services_table}}
<p><strong>Lead passenger:</strong> {{customer_first_name}} {{customer_last_name}} · {{passenger_count}} pax</p>
<p>Thanks for your help with this one.</p>
<p>Best regards,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Confirmation Number Follow-up',
          subject: 'Following up — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>Just circling back on this booking — we haven't received the confirmation number yet, and the customer is asking. Would you be able to send it through today?</p>
<h3>Booking details</h3>
{{services_table}}
<p>Thanks so much,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Reconfirm Booking',
          subject: 'Pre-travel reconfirmation — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>Travel for the booking below is coming up — could you please reconfirm everything is in order?</p>
<h3>Booking details</h3>
{{services_table}}
<p><strong>Lead passenger:</strong> {{customer_first_name}} {{customer_last_name}} · {{passenger_count}} pax</p>
<p>If anything has changed, please let us know as soon as possible so we can update the customer.</p>
<p>Many thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Cancellation Request',
          subject: 'Cancellation — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>Could you please cancel the service(s) below for booking <strong>{{supplier_ref}}</strong>?</p>
<h3>Services to cancel</h3>
{{services_table}}
<p>Once processed, please send through:</p>
<ul>
  <li>The cancellation reference</li>
  <li>Any applicable cancellation charges</li>
  <li>The expected refund timeline (if applicable)</li>
</ul>
<p>Thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Cancellation Follow-up',
          subject: 'Cancellation status — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>Following up on the cancellation we sent over for booking <strong>{{supplier_ref}}</strong> — has it been processed?</p>
<h3>Booking details</h3>
{{services_table}}
<p>If you could share the cancellation reference + any charges/refund timeline, we'd really appreciate it.</p>
<p>Thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Date Change Request',
          subject: 'Date change — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>The customer on booking <strong>{{supplier_ref}}</strong> would like to change their travel dates. Could you check availability and any cost impact?</p>
<h3>Current booking</h3>
{{services_table}}
<p><strong>Requested new dates:</strong> <em>(please specify)</em><br/>
<strong>Lead passenger:</strong> {{customer_first_name}} {{customer_last_name}}</p>
<p>If a fare/rate difference applies, please let us know before processing.</p>
<p>Thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Name Change / Pax Update',
          subject: 'Name correction — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>We need to correct a passenger name on booking <strong>{{supplier_ref}}</strong>. Could you process the update below?</p>
<h3>Booking details</h3>
{{services_table}}
<p><strong>Updated passenger details:</strong> <em>(please specify the correct names exactly as on passport)</em></p>
<p>Please confirm once applied, and let us know of any name-change fees.</p>
<p>Thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Special Request',
          subject: 'Special request — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>The customer on booking <strong>{{supplier_ref}}</strong> has a special request — could you note it on file and let us know if it's possible?</p>
<h3>Booking details</h3>
{{services_table}}
<p><strong>Request:</strong> <em>(please specify — e.g. late check-in, dietary preference, accessibility, high-floor room, twin beds)</em></p>
<p>This is a request, not guaranteed — but anything you can do helps. Thanks!</p>
<p>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Schedule Change Acknowledge',
          subject: 'Schedule change accepted — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>We've received your schedule change for booking <strong>{{supplier_ref}}</strong> and the customer is informed. Confirming the new schedule on our side as well.</p>
<h3>Updated booking</h3>
{{services_table}}
<p>Please reissue the voucher / confirmation reflecting the new schedule when you can.</p>
<p>Thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Refund / Credit Request',
          subject: 'Refund status — {{supplier_ref}}',
          body: `<p>Hi {{supplier_name}} team,</p>
<p>Following up on the refund / credit due on booking <strong>{{supplier_ref}}</strong>. Could you share the current status?</p>
<h3>Booking details</h3>
{{services_table}}
<p>Specifically, we'd like to know:</p>
<ul>
  <li>The credit note reference (if issued)</li>
  <li>Expected refund date</li>
  <li>Any holdup we should be aware of</li>
</ul>
<p>Thanks,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
      ];
      const crypto = require('crypto');
      for (const t of seed) {
        await pool.query(
          `INSERT INTO email_templates (id, name, subject, body, template_type, is_active, created_by_name)
             VALUES (?, ?, ?, ?, 'supplier', 1, 'system-seed')`,
          [crypto.randomBytes(16).toString('hex'), t.name, t.subject, t.body]
        );
      }
      console.log(`✅ Seeded ${seed.length} default supplier email templates`);
    }
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (supplier template seed) failed:', e.message);
  }

  // Default customer-facing templates. Same idempotency guard — only insert
  // when no real customer templates exist (the legacy "Smoke Test" stub
  // created during early development is ignored). Operators can edit them
  // freely from Settings → Templates.
  try {
    const [[{ c }]] = await pool.query(
      "SELECT COUNT(*) AS c FROM email_templates WHERE template_type = 'customer' AND created_by_name = 'system-seed'"
    );
    if (c === 0) {
      const seed = [
        {
          name: 'Booking Confirmed',
          subject: '✓ Your booking is confirmed — {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>Great news — your booking is <strong>confirmed</strong>! Here's everything in one place:</p>
<h3>Your booking — {{booking_ref}}</h3>
{{services_table}}
<p>You'll receive your travel documents (vouchers, e-tickets) in a separate email shortly.</p>
<p><strong>Need anything?</strong> Just reply to this email — it goes straight to your travel team.</p>
<p>Safe travels,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Booking Documents — Voucher Attached',
          subject: '📎 Your travel documents — {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>Your travel documents are attached to this email. You'll find your voucher(s) as PDF — please keep a copy on your phone and print one to present at check-in.</p>
<h3>What's included — {{booking_ref}}</h3>
{{services_table}}
<p><strong>A few reminders:</strong></p>
<ul>
  <li>Bring photo ID matching the names on your booking</li>
  <li>Have your voucher ready at check-in (digital is fine)</li>
  <li>Reply to this email if anything looks off</li>
</ul>
<p>Have a wonderful trip!<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Cancellation Confirmed',
          subject: 'Cancellation confirmed — {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>We've processed the cancellation for your booking <strong>{{booking_ref}}</strong>. Sorry to see this one go — hopefully we'll see you again soon!</p>
<h3>Cancelled services</h3>
{{services_table}}
<p>If a refund applies, it will follow in a separate email — usually within 5–10 business days, depending on your bank.</p>
<p>Best regards,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Date Change Confirmed',
          subject: '✓ Date change confirmed — {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>All set — your booking <strong>{{booking_ref}}</strong> has been updated with the new dates. Please double-check everything below:</p>
<h3>Updated booking</h3>
{{services_table}}
<p>If any of these details aren't quite right, just reply and we'll fix it right away.</p>
<p>Looking forward to your trip,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Refund Issued',
          subject: 'Refund issued — {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>Your refund for booking <strong>{{booking_ref}}</strong> has been processed.</p>
<h3>Refund details</h3>
{{services_table}}
<p>The amount should appear on your statement within <strong>5–10 business days</strong>, depending on your bank. If it doesn't show up by then, just reply with this email and we'll trace it for you.</p>
<p>Thanks for your patience,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Reply to Customer Request',
          subject: 'Re: your request about {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>Thanks for reaching out about your booking <strong>{{booking_ref}}</strong> — happy to help.</p>
<p>{{notes}}</p>
<p>If anything else comes up, just reply to this email and we'll pick it right back up.</p>
<p>Best,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
        {
          name: 'Pre-travel Reminder',
          subject: '✈️ Your trip is coming up — {{booking_ref}}',
          body: `<p>Hi {{customer_first_name}},</p>
<p>Your trip is just around the corner — here's a quick reminder of what you've booked, plus a few things to double-check before you go:</p>
<h3>Your booking — {{booking_ref}}</h3>
{{services_table}}
<p><strong>Pre-travel checklist:</strong></p>
<ul>
  <li>Confirm passport validity (most countries need 6+ months)</li>
  <li>Keep your voucher handy on your phone</li>
  <li>Check visa requirements if travelling internationally</li>
  <li>Note your arrival times so you can plan transfers</li>
</ul>
<p>Have a fantastic trip — reply to this email if anything comes up.</p>
<p>{{ops_user_name}}<br/>{{app_name}}</p>`,
        },
      ];
      const crypto = require('crypto');
      for (const t of seed) {
        await pool.query(
          `INSERT INTO email_templates (id, name, subject, body, template_type, is_active, created_by_name)
             VALUES (?, ?, ?, ?, 'customer', 1, 'system-seed')`,
          [crypto.randomBytes(16).toString('hex'), t.name, t.subject, t.body]
        );
      }
      console.log(`✅ Seeded ${seed.length} default customer email templates`);
    }
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (customer template seed) failed:', e.message);
  }

  // Default voucher template — used by the "Send Voucher" action-bar
  // button which attaches the booking's voucher PDF to a friendly cover
  // email. Operators can add more (e.g. per-app branded variants) from
  // Settings → Templates.
  try {
    const [[{ c }]] = await pool.query(
      "SELECT COUNT(*) AS c FROM email_templates WHERE template_type = 'voucher' AND created_by_name = 'system-seed'"
    );
    if (c === 0) {
      const crypto = require('crypto');
      await pool.query(
        `INSERT INTO email_templates (id, name, subject, body, template_type, is_active, created_by_name)
           VALUES (?, ?, ?, ?, 'voucher', 1, 'system-seed')`,
        [
          crypto.randomBytes(16).toString('hex'),
          'Voucher — Booking Documents Attached',
          '📎 Your travel documents — {{booking_ref}}',
          `<p>Hi {{customer_first_name}},</p>
<p>Your travel documents are attached. Please keep a copy on your phone and present the voucher at check-in.</p>
<h3>What's attached — {{booking_ref}}</h3>
{{services_table}}
<p><strong>Quick reminders:</strong></p>
<ul>
  <li>Bring photo ID matching the booking name</li>
  <li>Voucher can be shown digitally — no need to print</li>
  <li>Reply to this email if anything looks off</li>
</ul>
<p>Have a wonderful trip,<br/>{{ops_user_name}}<br/>{{app_name}}</p>`,
        ]
      );
      console.log('✅ Seeded default voucher email template');
    }
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (voucher template seed) failed:', e.message);
  }

  // Support-request triage columns (app-user review flow). Idempotent — each
  // column/index is checked against INFORMATION_SCHEMA before ALTER.
  await ensureSupportTriageSchema();
}

// Helper: add a column if it doesn't already exist on the given table.
async function ensureColumn(table, column, ddl) {
  const [rows] = await pool.query(
    `SELECT COUNT(*) AS n FROM INFORMATION_SCHEMA.COLUMNS
     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
    [table, column]
  );
  if (rows[0].n === 0) {
    await pool.query(`ALTER TABLE \`${table}\` ADD COLUMN ${ddl}`);
  }
}

// ─────────────────────────────────────────────────────────────────────────
// SCHEMA MANIFEST
// Single source of truth for "what tables and columns this build expects to
// exist." Used by /api/admin/schema/check (read-only audit) and exercised
// by /api/admin/schema/repair (which calls ensureLocalSchema()). Whenever
// you add a CREATE TABLE or ensureColumn call to ensureLocalSchema, add the
// matching entry here so the health check stays accurate.
//
// Why a separate constant from ensureLocalSchema's inline DDL? Because the
// audit needs to reason about EXPECTATIONS without executing any DDL — and
// because ensureLocalSchema's DDL strings are full ALTER statements with
// defaults / collations / nullability mixed in, which would be noisy to
// parse. A flat list of (table → required_columns) is simpler, faster, and
// captures what the app code actually reads/writes.
const SCHEMA_MANIFEST = {
  // Auth + user management
  platform_users: ['id', 'email', 'password_hash', 'first_name', 'last_name',
                   'role', 'is_active', 'allowed_app_ids', 'last_login',
                   'created_at', 'email_preferences'],
  app_members_cache: ['app_id', 'account_id', 'email', 'first_name', 'last_name',
                      'enabled', 'status', 'roles', 'last_synced_at'],
  app_settings: ['app_id', 'skip_app_review', 'notes', 'updated_at', 'updated_by',
                 'app_code', 'app_name', 'bcc_emails', 'accent_color', 'logo_url'],

  // Suppliers + balance v2
  suppliers: ['id', 'name', 'code', 'is_active', 'merged_into_id', 'source',
              'settlement_currencies', 'default_settlement_currency',
              'balance_baseline_date', 'account_type', 'critical_threshold',
              'credit_limit', 'last_critical_alert_at',
              'critical_alert_cooldown_minutes'],
  supplier_contacts: ['id', 'supplier_id', 'type', 'value', 'label', 'is_primary',
                      'created_at'],
  supplier_email_rules: ['id', 'supplier_id', 'rule_type', 'pattern', 'priority',
                         'is_active', 'notes', 'created_at'],
  supplier_extraction_rules: ['id', 'supplier_id', 'field_name', 'pattern',
                              'is_active'],
  supplier_topups: ['id', 'supplier_id', 'amount', 'currency', 'paid_on',
                    'payment_method_id', 'reference', 'attachment_path',
                    'notes', 'created_by', 'created_at', 'deleted'],
  supplier_transactions_cache: ['id', 'supplier_id', 'type', 'amount', 'currency',
                                'txn_date', 'booking_id', 'booking_code',
                                'description', 'metadata', 'last_synced_at',
                                'original_currency', 'original_amount',
                                'fx_rate_used', 'fx_source'],
  payment_methods: ['id', 'name', 'is_active', 'created_at'],

  // Bookings cache (mirror of EasyGDS list endpoint)
  bookings_cache: ['id', 'code', 'app_id', 'agent_id', 'booking_status',
                   'payment_status', 'channel_type', 'b2b_only',
                   'contact_email', 'contact_name', 'contact_phone',
                   'pnr', 'travel_date', 'total_amount', 'currency',
                   'passenger_count', 'company_name', 'supplier_name',
                   'upstream_created', 'upstream_updated', 'raw', 'synced_at'],

  // Emails + IMAP/SMTP
  emails: ['id', 'message_id', 'thread_id', 'in_reply_to', 'references_chain',
           'direction', 'source', 'from_email', 'from_name', 'to_emails',
           'cc_emails', 'subject', 'snippet', 'body_text', 'body_html',
           'received_at', 'fetched_at', 'category', 'supplier_id', 'booking_id',
           'booking_code', 'support_request_id', 'auto_linked', 'confidence',
           'parsed_data', 'attachments_count', 'is_read', 'is_starred',
           'is_archived', 'inbox_id', 'template_id', 'app_id'],
  email_attachments: ['id', 'email_id', 'filename', 'mime_type', 'size_bytes'],
  email_booking_links: ['id', 'email_id', 'linked_type', 'linked_id',
                        'auto_linked', 'linked_by', 'linked_at', 'unlinked_at'],
  email_inboxes: ['id', 'name', 'description', 'imap_host', 'imap_port',
                  'imap_username', 'imap_password', 'imap_use_ssl',
                  'auto_process', 'check_interval', 'is_active',
                  'last_polled_at', 'last_error', 'created_at'],
  smtp_accounts: ['id', 'name', 'host', 'port', 'secure', 'username',
                  'password', 'from_email', 'from_name', 'is_default',
                  'is_active', 'last_used_at', 'last_error', 'created_at'],
  email_templates: ['id', 'name', 'subject', 'body', 'template_type',
                    'is_active', 'created_by', 'created_at'],

  // Support tickets
  support_requests: [
    'id', 'request_number', 'request_type', 'request_title',
    'status', 'message', 'app_id', 'created_at', 'updated_at',
    // Triage columns added later
    'product_id', 'product_type', 'change_type', 'field_changes',
  ],
  support_internal_notes: ['id', 'request_id', 'agent_id', 'agent_name',
                           'task_type', 'comment', 'attachments', 'audience'],
  support_activity_log: ['id', 'request_id', 'action_type', 'old_value',
                         'new_value', 'performed_by', 'performed_by_name',
                         'metadata', 'created_at'],
  support_approval_requests: ['id', 'request_id', 'parent_id', 'kind',
                              'from_party', 'to_party', 'amount', 'currency',
                              'description', 'change_payload', 'status',
                              'created_by', 'created_at', 'responded_by',
                              'responded_at', 'response_reason', 'applied_at',
                              'applied_by', 'resulting_charge_id', 'metadata'],
  ops_teams: ['id', 'name', 'is_active', 'created_by', 'created_at',
              'updated_at'],
  ops_team_members: ['team_id', 'user_id', 'role', 'created_at'],
  ops_permission_rules: ['id', 'name', 'target_type', 'target_id', 'action',
                         'effect', 'conditions_json', 'is_active',
                         'created_by', 'created_at', 'updated_at'],
  ops_action_approval_requests: ['id', 'action', 'status', 'requester_id',
                                 'requester_name', 'requester_email',
                                 'booking_id', 'booking_code', 'app_id',
                                 'payload_json', 'conditions_snapshot',
                                 'responded_by', 'responded_by_name',
                                 'responded_at', 'response_reason',
                                 'resulting_entity_type', 'resulting_entity_id',
                                 'created_at'],

  // Notifications + audit
  notifications: ['id', 'user_id', 'kind', 'title', 'body', 'priority',
                  'link_path', 'actor_id', 'actor_name', 'entity_type',
                  'entity_id', 'metadata', 'is_read', 'read_at', 'created_at'],
  notification_deliveries: ['id', 'notification_id'],
  audit_events: ['id', 'actor_user_id', 'actor_email', 'actor_name', 'action',
                 'entity_type', 'entity_id', 'before_json', 'after_json',
                 'reason', 'ip_address', 'created_at'],

  // Misc system
  system_settings: ['key', 'value', 'updated_at'],
};

/**
 * Read-only audit: compares SCHEMA_MANIFEST to live DB. Returns a per-table
 * report showing whether the table exists, which expected columns are
 * present, and which are missing. Total counts at the top so the UI can
 * render a "X tables OK, Y need attention" summary.
 *
 * One INFORMATION_SCHEMA round-trip per check invocation (not per table) —
 * fast even on a DB with hundreds of tables.
 */
async function checkSchemaHealth() {
  // Fetch the live schema in one shot.
  const [tableRows] = await pool.query(
    `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES
      WHERE TABLE_SCHEMA = DATABASE()`
  );
  const liveTables = new Set(tableRows.map(r => r.TABLE_NAME));
  const [colRows] = await pool.query(
    `SELECT TABLE_NAME, COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
      WHERE TABLE_SCHEMA = DATABASE()`
  );
  const liveColumns = new Map();  // table → Set(columns)
  for (const r of colRows) {
    if (!liveColumns.has(r.TABLE_NAME)) liveColumns.set(r.TABLE_NAME, new Set());
    liveColumns.get(r.TABLE_NAME).add(r.COLUMN_NAME);
  }

  const tables = [];
  let tablesMissing = 0, tablesWithMissingColumns = 0, columnsMissing = 0;

  for (const [tableName, expectedCols] of Object.entries(SCHEMA_MANIFEST)) {
    const tableExists = liveTables.has(tableName);
    if (!tableExists) {
      tablesMissing++;
      columnsMissing += expectedCols.length;
      tables.push({
        name: tableName,
        exists: false,
        expected_columns: expectedCols.length,
        present_columns: 0,
        missing_columns: expectedCols,
        status: 'missing_table',
      });
      continue;
    }
    const present = liveColumns.get(tableName) || new Set();
    const missing = expectedCols.filter(c => !present.has(c));
    if (missing.length > 0) {
      tablesWithMissingColumns++;
      columnsMissing += missing.length;
    }
    tables.push({
      name: tableName,
      exists: true,
      expected_columns: expectedCols.length,
      present_columns: expectedCols.length - missing.length,
      missing_columns: missing,
      status: missing.length === 0 ? 'ok' : 'missing_columns',
    });
  }

  // Tables we don't know about — informational only, not a problem.
  const expectedSet = new Set(Object.keys(SCHEMA_MANIFEST));
  const extras = [...liveTables].filter(t => !expectedSet.has(t)).sort();

  return {
    ok: tablesMissing === 0 && tablesWithMissingColumns === 0,
    summary: {
      tables_total: Object.keys(SCHEMA_MANIFEST).length,
      tables_ok: Object.keys(SCHEMA_MANIFEST).length - tablesMissing - tablesWithMissingColumns,
      tables_missing: tablesMissing,
      tables_with_missing_columns: tablesWithMissingColumns,
      columns_missing: columnsMissing,
      extra_tables: extras.length,
    },
    tables: tables.sort((a, b) => {
      // Sort: missing tables first, then tables with missing columns, then OK
      const order = { missing_table: 0, missing_columns: 1, ok: 2 };
      return order[a.status] - order[b.status] || a.name.localeCompare(b.name);
    }),
    extra_tables: extras,
  };
}

// Helper: add an index if it doesn't already exist on the given table.
async function ensureIndex(table, indexName, ddl) {
  const [rows] = await pool.query(
    `SELECT COUNT(*) AS n FROM INFORMATION_SCHEMA.STATISTICS
     WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?`,
    [table, indexName]
  );
  if (rows[0].n === 0) {
    await pool.query(`ALTER TABLE \`${table}\` ADD ${ddl}`);
  }
}

async function ensureSupportTriageSchema() {
  try {
    // support_activity_log isn't always created by the Supabase migrations when
    // the DB is bootstrapped locally — ensure it exists.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS support_activity_log (
        id                VARCHAR(64) NOT NULL PRIMARY KEY,
        request_id        VARCHAR(64) NOT NULL,
        action_type       VARCHAR(64) NOT NULL,
        old_value         TEXT NULL,
        new_value         TEXT NULL,
        performed_by      VARCHAR(64) NULL,
        performed_by_name VARCHAR(255) NULL,
        metadata          JSON NULL,
        created_at        DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_request (request_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // support_charges: net_amount / sale_amount let Ops record both the
    // supplier-side cost (net) and the client-facing sale price independently
    // when a chargeable amendment goes through. The legacy supplier_cost +
    // service_fee columns stay so old rows still render — new rows write to
    // both for consistency. Total still drives the customer-facing balance.
    await ensureColumn('support_charges', 'net_amount',  'net_amount DECIMAL(12,2) DEFAULT NULL');
    await ensureColumn('support_charges', 'sale_amount', 'sale_amount DECIMAL(12,2) DEFAULT NULL');

    // support_internal_notes: add an `audience` column so we can distinguish
    // three channels:
    //   ops_only  — Ops Team internal chat (default; hidden from App users)
    //   app_ops   — between App user and Ops (hidden from customer, visible
    //               to both sides; what the "App ↔ Ops" redirect lands in)
    //   app_only  — reserved for future App-internal notes
    await ensureColumn('support_internal_notes', 'audience', "audience VARCHAR(32) NOT NULL DEFAULT 'ops_only'");
    await ensureIndex('support_internal_notes', 'idx_audience', 'INDEX idx_audience (audience)');
    // Backfill: rows with task_type tagged as App↔Ops chatter should live in
    // the app_ops channel, not ops_only. Idempotent.
    await pool.query(`
      UPDATE support_internal_notes
      SET audience = 'app_ops'
      WHERE audience = 'ops_only'
        AND task_type IN ('app_to_ops','ops_to_app','return_to_ops','app_resubmit_reason')
    `).catch(() => {});

    // Extend support_requests with triage columns. We only touch columns that
    // don't already exist — safe to run on every boot.
    const cols = [
      ['app_id',                    "app_id VARCHAR(64) NULL"],
      ['triage_state',              "triage_state VARCHAR(32) NULL"],
      ['original_message',          "original_message TEXT NULL"],
      ['original_request_type',     "original_request_type VARCHAR(64) NULL"],
      ['original_field_changes',    "original_field_changes JSON NULL"],
      ['app_admin_edited_at',       "app_admin_edited_at DATETIME NULL"],
      ['app_admin_edited_by',       "app_admin_edited_by VARCHAR(64) NULL"],
      ['app_admin_edited_by_name',  "app_admin_edited_by_name VARCHAR(255) NULL"],
      ['forwarded_to_ops_at',       "forwarded_to_ops_at DATETIME NULL"],
      ['forwarded_to_ops_by',       "forwarded_to_ops_by VARCHAR(64) NULL"],
      ['rejection_reason',          "rejection_reason TEXT NULL"],
      ['rejected_by_app_at',        "rejected_by_app_at DATETIME NULL"],
      ['rejected_by_app_by',        "rejected_by_app_by VARCHAR(64) NULL"],
      ['app_review_resubmit_count', "app_review_resubmit_count INT NOT NULL DEFAULT 0"],
      ['ops_proposed_status',       "ops_proposed_status VARCHAR(32) NULL"],
      ['ops_proposed_message',      "ops_proposed_message TEXT NULL"],
      ['ops_proposed_at',           "ops_proposed_at DATETIME NULL"],
      ['ops_proposed_by',           "ops_proposed_by VARCHAR(64) NULL"],
      ['app_response_edited_at',    "app_response_edited_at DATETIME NULL"],
      ['app_response_edited_by',    "app_response_edited_by VARCHAR(64) NULL"],
      ['app_response_edited_by_name',"app_response_edited_by_name VARCHAR(255) NULL"],
      ['returned_to_ops_count',     "returned_to_ops_count INT NOT NULL DEFAULT 0"],
      ['previous_ops_status',       "previous_ops_status VARCHAR(32) NULL"],
      ['created_by_context',        "created_by_context VARCHAR(32) NULL"],
    ];
    for (const [col, ddl] of cols) await ensureColumn('support_requests', col, ddl);

    await ensureIndex('support_requests', 'idx_app_status',     'INDEX idx_app_status (app_id, status)');
    await ensureIndex('support_requests', 'idx_triage_state',   'INDEX idx_triage_state (triage_state)');
    await ensureIndex('support_requests', 'idx_deadline',       'INDEX idx_deadline (deadline)');

    // ── Email linking + supplier setup tables ─────────────────────────────
    // Canonical supplier registry (synced from upstream EasyGDS + extended
    // with our own setup: multi-contacts, routing rules, extraction rules).
    await pool.query(`
      CREATE TABLE IF NOT EXISTS suppliers (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        source_id       VARCHAR(64)  NULL,
        name            VARCHAR(255) NOT NULL,
        code            VARCHAR(64)  NULL,
        is_active       TINYINT(1)   NOT NULL DEFAULT 1,
        primary_email   VARCHAR(255) NULL,
        primary_phone   VARCHAR(64)  NULL,
        notes           TEXT         NULL,
        synced_at       DATETIME     NULL,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_source (source_id),
        INDEX idx_active (is_active)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // Where this supplier row came from. Two values today:
    //   'upstream'      → synced from EasyGDS /suppliers (default for legacy rows)
    //   'from_bookings' → discovered by scanning product.data.supplier_code in
    //                     cached bookings. Has no upstream id, so name starts
    //                     out NULL and the UI falls back to showing the code.
    //   'manual'        → operator-added directly (for canonical EasyGDS codes
    //                     that don't appear in any booking yet, e.g. BH for
    //                     Hotelbeds when only TPBH was discovered).
    // Kept separate so the upstream sync only touches its own rows and never
    // wipes out a supplier you've added contacts/rules to.
    await ensureColumn('suppliers', 'source',
      "source VARCHAR(20) NOT NULL DEFAULT 'upstream'");
    await ensureIndex('suppliers', 'idx_supplier_source', 'INDEX idx_supplier_source (source)');
    // The discovery-from-bookings flow inserts NULL names initially (the user
    // renames later). The original schema had `name VARCHAR(255) NOT NULL` —
    // relax to NULL so the INSERT doesn't fail. UI uses `name || code` for
    // display, so a NULL name just means "show the code instead".
    await pool.query(`ALTER TABLE \`suppliers\` MODIFY COLUMN name VARCHAR(255) NULL`).catch(() => {});

    // Variant linking: a row can declare itself a variant of another supplier
    // (e.g. discovered:TPBH points at manual:BH "Hotelbeds"). When this is
    // set, the Bookings table resolves the code through the link and shows
    // the canonical name; the row's own contacts/rules are still in the DB
    // but ignored for display. Only ONE level of linking is allowed (the
    // server's link endpoint refuses targets that are themselves linked) so
    // we never have to chase chains.
    await ensureColumn('suppliers', 'merged_into_id',
      'merged_into_id VARCHAR(64) NULL');
    await ensureIndex('suppliers', 'idx_supplier_merged_into', 'INDEX idx_supplier_merged_into (merged_into_id)');

    // Multiple emails / phones per supplier, each with a label (Reservations,
    // Accounting, Emergency, ...) and an is_primary flag.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_contacts (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        supplier_id     VARCHAR(64)  NOT NULL,
        type            VARCHAR(16)  NOT NULL,            -- email | phone
        value           VARCHAR(255) NOT NULL,
        label           VARCHAR(64)  NULL,
        is_primary      TINYINT(1)   NOT NULL DEFAULT 0,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_supplier (supplier_id),
        INDEX idx_type_value (type, value)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // Matching rules: which supplier owns an incoming email? Evaluated in
    // priority order (lower number = higher priority). First match wins.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_email_rules (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        supplier_id     VARCHAR(64)  NOT NULL,
        rule_type       VARCHAR(32)  NOT NULL,            -- exact_email | domain_wildcard | subject_regex | body_regex
        pattern         VARCHAR(512) NOT NULL,
        priority        INT          NOT NULL DEFAULT 100,
        is_active       TINYINT(1)   NOT NULL DEFAULT 1,
        notes           VARCHAR(255) NULL,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_supplier (supplier_id),
        INDEX idx_active_priority (is_active, priority)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // Field-extraction rules (regex with capture group). If supplier_id is NULL
    // the rule is global (fallback when no supplier-specific rule matched).
    await pool.query(`
      CREATE TABLE IF NOT EXISTS supplier_extraction_rules (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        supplier_id     VARCHAR(64)  NULL,                -- NULL = global default
        field_name      VARCHAR(64)  NOT NULL,            -- booking_ref | pnr | amount | currency | supplier_confirmation | ...
        source          VARCHAR(16)  NOT NULL DEFAULT 'body',  -- subject | body | headers
        pattern         VARCHAR(512) NOT NULL,
        fallback_value  VARCHAR(255) NULL,
        priority        INT          NOT NULL DEFAULT 100,
        is_active       TINYINT(1)   NOT NULL DEFAULT 1,
        notes           VARCHAR(255) NULL,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_supplier_field (supplier_id, field_name),
        INDEX idx_active (is_active, priority)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // Stored emails — headers, body, category, links back to booking / ticket.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS emails (
        id                    VARCHAR(64) NOT NULL PRIMARY KEY,
        message_id            VARCHAR(512) NULL,
        thread_id             VARCHAR(512) NULL,
        in_reply_to           VARCHAR(512) NULL,
        direction             VARCHAR(16)  NOT NULL DEFAULT 'inbound',
        source                VARCHAR(32)  NULL,
        from_email            VARCHAR(255) NULL,
        from_name             VARCHAR(255) NULL,
        to_emails             JSON         NULL,
        cc_emails             JSON         NULL,
        bcc_emails            JSON         NULL,
        subject               VARCHAR(512) NULL,
        snippet               VARCHAR(500) NULL,
        body_text             MEDIUMTEXT   NULL,
        body_html             MEDIUMTEXT   NULL,
        received_at           DATETIME     NULL,
        fetched_at            DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        category              VARCHAR(32)  NOT NULL DEFAULT 'unknown',
        supplier_id           VARCHAR(64)  NULL,
        booking_id            VARCHAR(64)  NULL,
        booking_code          VARCHAR(64)  NULL,
        support_request_id    VARCHAR(64)  NULL,
        auto_linked           TINYINT(1)   NOT NULL DEFAULT 0,
        confidence            FLOAT        NULL,
        parsed_data           JSON         NULL,
        attachments_count     INT          NOT NULL DEFAULT 0,
        raw_headers           JSON         NULL,
        is_read               TINYINT(1)   NOT NULL DEFAULT 0,
        is_starred            TINYINT(1)   NOT NULL DEFAULT 0,
        is_archived           TINYINT(1)   NOT NULL DEFAULT 0,
        INDEX idx_thread (thread_id),
        INDEX idx_from (from_email),
        INDEX idx_received (received_at),
        INDEX idx_category_supplier (category, supplier_id),
        INDEX idx_booking (booking_id),
        INDEX idx_booking_code (booking_code),
        INDEX idx_support_request (support_request_id),
        UNIQUE KEY ux_message_id (message_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // Store the full RFC-2822 References chain so outbound replies can keep
    // the whole ancestry (not just the immediate parent). Needed for Outlook
    // and some Apple Mail setups to thread multi-step conversations. Added
    // idempotently so upgrades don't need a migration file.
    await ensureColumn('emails', 'references_chain',
      'references_chain TEXT NULL');

    // Track which inbox account an email was fetched from. Lets the inbox
    // UI show "from <account>" badges and filter by inbox when multiple
    // accounts are configured. NULL on legacy rows ingested before this
    // column existed — the UI shows them as "(unknown inbox)". Indexed for
    // the filter query.
    await ensureColumn('emails', 'inbox_id',
      'inbox_id VARCHAR(64) NULL');
    await ensureIndex('emails', 'idx_email_inbox', 'INDEX idx_email_inbox (inbox_id)');

    // Lightweight attachment metadata (file itself lives on disk / S3).
    await pool.query(`
      CREATE TABLE IF NOT EXISTS email_attachments (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        email_id        VARCHAR(64)  NOT NULL,
        filename        VARCHAR(255) NOT NULL,
        mime_type       VARCHAR(128) NULL,
        size_bytes      BIGINT       NULL,
        storage_path    VARCHAR(512) NULL,
        content_id      VARCHAR(255) NULL,                -- inline images reference this
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_email (email_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // SMTP servers the outbound sender uses. Three ownership tiers via
    // owner_type+owner_id (system/app/user). Defaults are per-scope so each
    // tier has its own preferred row — there's no single "global default".
    await pool.query(`
      CREATE TABLE IF NOT EXISTS smtp_accounts (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        name            VARCHAR(128) NOT NULL,
        host            VARCHAR(255) NOT NULL,
        port            INT          NOT NULL DEFAULT 587,
        secure          TINYINT(1)   NOT NULL DEFAULT 0,    -- true for port 465
        username        VARCHAR(255) NOT NULL,
        password        VARCHAR(512) NOT NULL,
        from_email      VARCHAR(255) NOT NULL,
        from_name       VARCHAR(128) NULL,
        owner_type      VARCHAR(16)  NOT NULL DEFAULT 'system',
        owner_id        VARCHAR(64)  NULL,
        display_label   VARCHAR(128) NULL,
        is_default_for_scope TINYINT(1) NOT NULL DEFAULT 0,
        is_default      TINYINT(1)   NOT NULL DEFAULT 0,    -- legacy / system-wide
        is_active       TINYINT(1)   NOT NULL DEFAULT 1,
        last_used_at    DATETIME     NULL,
        last_error      VARCHAR(512) NULL,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_default (is_default),
        INDEX idx_owner (owner_type, owner_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    // Idempotent migrations for installs predating the ownership columns.
    await ensureColumn('smtp_accounts', 'owner_type', "owner_type VARCHAR(16) NOT NULL DEFAULT 'system'");
    await ensureColumn('smtp_accounts', 'owner_id', 'owner_id VARCHAR(64) NULL');
    await ensureColumn('smtp_accounts', 'display_label', 'display_label VARCHAR(128) NULL');
    await ensureColumn('smtp_accounts', 'is_default_for_scope', 'is_default_for_scope TINYINT(1) NOT NULL DEFAULT 0');
    await ensureIndex('smtp_accounts', 'idx_owner', 'INDEX idx_owner (owner_type, owner_id)');
    // One-time backfill: any pre-existing row with is_default=1 and no
    // is_default_for_scope yet → mark it as the system default.
    await pool.query(
      `UPDATE smtp_accounts SET is_default_for_scope = 1
       WHERE owner_type = 'system' AND is_default = 1 AND is_default_for_scope = 0`
    ).catch(() => {});

    // Configured mailboxes the ingestion worker polls (IMAP). One row per
    // connected inbox. owner_type+owner_id (system/app/user) drives who can
    // see/manage each inbox.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS email_inboxes (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        name            VARCHAR(128) NOT NULL,
        description     VARCHAR(512) NULL,
        imap_host       VARCHAR(255) NOT NULL,
        imap_port       INT          NOT NULL DEFAULT 993,
        imap_username   VARCHAR(255) NOT NULL,
        imap_password   VARCHAR(512) NOT NULL,
        imap_use_ssl    TINYINT(1)   NOT NULL DEFAULT 1,
        auto_process    TINYINT(1)   NOT NULL DEFAULT 1,
        check_interval  INT          NOT NULL DEFAULT 5,
        is_active       TINYINT(1)   NOT NULL DEFAULT 0,
        owner_type      VARCHAR(16)  NOT NULL DEFAULT 'system',
        owner_id        VARCHAR(64)  NULL,
        display_label   VARCHAR(128) NULL,
        last_polled_at  DATETIME     NULL,
        last_error      VARCHAR(512) NULL,
        created_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        UNIQUE KEY ux_host_user (imap_host, imap_username),
        INDEX idx_owner (owner_type, owner_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    // Idempotent migrations for installs predating ownership columns.
    await ensureColumn('email_inboxes', 'owner_type', "owner_type VARCHAR(16) NOT NULL DEFAULT 'system'");
    await ensureColumn('email_inboxes', 'owner_id', 'owner_id VARCHAR(64) NULL');
    await ensureColumn('email_inboxes', 'display_label', 'display_label VARCHAR(128) NULL');
    await ensureIndex('email_inboxes', 'idx_owner', 'INDEX idx_owner (owner_type, owner_id)');

    // Audit table for every link/unlink action on an email (polymorphic).
    await pool.query(`
      CREATE TABLE IF NOT EXISTS email_booking_links (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        email_id        VARCHAR(64)  NOT NULL,
        linked_type     VARCHAR(32)  NOT NULL,            -- booking | support_request | supplier
        linked_id       VARCHAR(64)  NOT NULL,
        auto_linked     TINYINT(1)   NOT NULL DEFAULT 0,
        linked_by       VARCHAR(64)  NULL,
        linked_at       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        unlinked_at     DATETIME     NULL,
        INDEX idx_email (email_id),
        INDEX idx_linked (linked_type, linked_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // app_settings — per-App configuration (e.g. whether the App has its own
    // operation team, or if agency/customer requests should go straight to Ops).
    await pool.query(`
      CREATE TABLE IF NOT EXISTS app_settings (
        app_id           VARCHAR(64) NOT NULL PRIMARY KEY,
        skip_app_review  TINYINT(1)  NOT NULL DEFAULT 0,
        is_enabled       TINYINT(1)  NOT NULL DEFAULT 1,
        notes            TEXT        NULL,
        updated_at       DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        updated_by       VARCHAR(64) NULL
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // support_approval_requests — two-sided negotiation layer for charges,
    // refunds, changes, and cancellations. Approvals chain via parent_id
    // when the App user relays an Ops→App proposal down to the customer.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS support_approval_requests (
        id                   VARCHAR(64) NOT NULL PRIMARY KEY,
        request_id           VARCHAR(64) NOT NULL,
        parent_id            VARCHAR(64) NULL,
        kind                 VARCHAR(32) NOT NULL,            -- charge|refund|change|cancellation
        from_party           VARCHAR(16) NOT NULL,            -- ops|app|customer
        to_party             VARCHAR(16) NOT NULL,            -- ops|app|customer
        amount               DECIMAL(12,2) NULL,
        currency             VARCHAR(8)  NULL,
        description          TEXT        NULL,
        change_payload       JSON        NULL,
        status               VARCHAR(16) NOT NULL DEFAULT 'pending',
        created_by           VARCHAR(64) NULL,
        created_by_name      VARCHAR(255) NULL,
        created_at           DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
        responded_by         VARCHAR(64) NULL,
        responded_by_name    VARCHAR(255) NULL,
        responded_at         DATETIME    NULL,
        response_reason      TEXT        NULL,
        applied_at           DATETIME    NULL,
        applied_by           VARCHAR(64) NULL,
        resulting_charge_id  VARCHAR(64) NULL,
        metadata             JSON        NULL,
        INDEX idx_request (request_id),
        INDEX idx_status (status),
        INDEX idx_to_party (to_party),
        INDEX idx_parent (parent_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // ops_teams + ops_permission_rules — local Ops users can be grouped into
    // teams and constrained by AND-only booking rules. Team rules intentionally
    // apply to members only; leaders approve member actions and are not hidden
    // by their team's member rules.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS ops_teams (
        id          VARCHAR(64)  NOT NULL PRIMARY KEY,
        name        VARCHAR(255) NOT NULL,
        is_active   TINYINT(1)   NOT NULL DEFAULT 1,
        created_by  VARCHAR(64)  NULL,
        created_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at  DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_active_name (is_active, name)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS ops_team_members (
        team_id     VARCHAR(64) NOT NULL,
        user_id     VARCHAR(64) NOT NULL,
        role        VARCHAR(16) NOT NULL DEFAULT 'member',
        created_at  DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
        PRIMARY KEY (team_id, user_id),
        INDEX idx_user_role (user_id, role),
        INDEX idx_team_role (team_id, role)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS ops_permission_rules (
        id               VARCHAR(64)  NOT NULL PRIMARY KEY,
        name             VARCHAR(255) NOT NULL,
        target_type      VARCHAR(16)  NOT NULL,
        target_id        VARCHAR(64)  NOT NULL,
        action           VARCHAR(64)  NOT NULL,
        effect           VARCHAR(32)  NOT NULL,
        conditions_json  JSON         NOT NULL,
        is_active        TINYINT(1)   NOT NULL DEFAULT 1,
        created_by       VARCHAR(64)  NULL,
        created_at       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at       DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_target_action (target_type, target_id, action, is_active),
        INDEX idx_action_active (action, is_active)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS ops_action_approval_requests (
        id                     VARCHAR(64)  NOT NULL PRIMARY KEY,
        action                 VARCHAR(64)  NOT NULL,
        status                 VARCHAR(16)  NOT NULL DEFAULT 'pending',
        requester_id           VARCHAR(64)  NOT NULL,
        requester_name         VARCHAR(255) NULL,
        requester_email        VARCHAR(255) NULL,
        booking_id             VARCHAR(64)  NULL,
        booking_code           VARCHAR(64)  NULL,
        app_id                 VARCHAR(64)  NULL,
        payload_json           JSON         NOT NULL,
        conditions_snapshot    JSON         NULL,
        responded_by           VARCHAR(64)  NULL,
        responded_by_name      VARCHAR(255) NULL,
        responded_at           DATETIME     NULL,
        response_reason        TEXT         NULL,
        resulting_entity_type  VARCHAR(64)  NULL,
        resulting_entity_id    VARCHAR(64)  NULL,
        created_at             DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_status_created (status, created_at),
        INDEX idx_requester_status (requester_id, status),
        INDEX idx_booking_status (booking_code, status)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // notifications — per-user inbox of events (ticket created, message posted,
    // approval proposed, etc.). Fanned-out at the moment an event occurs so
    // the list query is cheap (single-user scan) and the feed is durable
    // across sessions. Unread count drives the bell badge; toasts are
    // delta-detected on poll.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS notifications (
        id             VARCHAR(64)  NOT NULL PRIMARY KEY,
        user_id        VARCHAR(64)  NOT NULL,
        kind           VARCHAR(48)  NOT NULL,
        title          VARCHAR(255) NOT NULL,
        body           TEXT         NULL,
        priority       VARCHAR(16)  NOT NULL DEFAULT 'normal',
        link_path      VARCHAR(255) NULL,
        actor_id       VARCHAR(64)  NULL,
        actor_name     VARCHAR(255) NULL,
        entity_type    VARCHAR(32)  NULL,
        entity_id      VARCHAR(64)  NULL,
        metadata       JSON         NULL,
        is_read        TINYINT(1)   NOT NULL DEFAULT 0,
        read_at        DATETIME     NULL,
        created_at     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_user_unread (user_id, is_read, created_at),
        INDEX idx_user_created (user_id, created_at),
        INDEX idx_entity (entity_type, entity_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // email_preferences on platform_users — nullable JSON so default (= all
    // notifications on) applies to every user who hasn't explicitly opted
    // out yet. Nothing to migrate — we read/write through a helper that
    // coalesces NULL to the "everything on" shape.
    await ensureColumn('platform_users', 'email_preferences',
      'email_preferences JSON DEFAULT NULL');
    // email_signature on platform_users — per-user HTML signature appended
    // (or substituted via {{ops_user_signature}}) on outbound emails. NULL
    // = use a sensible default built from first/last name.
    await ensureColumn('platform_users', 'email_signature',
      'email_signature TEXT NULL');
    // preferred_smtp_account_id — last SMTP the user picked in the composer.
    // The send pipeline tries it first (after an explicit pick) before
    // falling back to scope defaults. NULL = no preference saved yet.
    await ensureColumn('platform_users', 'preferred_smtp_account_id',
      'preferred_smtp_account_id VARCHAR(64) NULL');

    // bcc_emails on app_settings — per-app audit/BCC recipients the admin
    // adds in Settings → Apps. Applied to every outbound notification that
    // concerns a ticket under this app. Stored as a JSON array of strings
    // so validation can happen at the edge (email shape check) instead of
    // in SQL.
    await ensureColumn('app_settings', 'app_code',
      "app_code VARCHAR(64) NULL");
    await ensureColumn('app_settings', 'app_name',
      "app_name VARCHAR(255) NULL");
    await ensureColumn('app_settings', 'bcc_emails',
      'bcc_emails JSON DEFAULT NULL');
    // is_enabled — master switch for whether this Ops platform handles the
    // app at all. When OFF, the app is hidden from every list/dashboard and
    // upstream data fetches are short-circuited so we don't waste bandwidth
    // on apps another team owns. Default 1 keeps existing apps unchanged.
    await ensureColumn('app_settings', 'is_enabled',
      'is_enabled TINYINT(1) NOT NULL DEFAULT 1');
    // Branding for the email shell — accent colour for the header bar
    // (defaults to orange #FF6B35 in wrapInEmailShell when null) and a
    // logo URL that gets rendered as an <img> in place of the text-only
    // brand pill. Operators set these in Settings → Apps.
    await ensureColumn('app_settings', 'accent_color',
      "accent_color VARCHAR(16) NULL");
    await ensureColumn('app_settings', 'logo_url',
      'logo_url VARCHAR(512) NULL');

    // notification_deliveries — audit log. One row per send attempt (incl.
    // skipped by preference / rate-limited). Lets us answer "was Sarah
    // emailed about ticket T-123?" in one query instead of tailing logs.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS notification_deliveries (
        id                 VARCHAR(64) NOT NULL PRIMARY KEY,
        notification_id    VARCHAR(64) NULL,
        recipient_user_id  VARCHAR(64) NULL,
        recipient_email    VARCHAR(255) NULL,
        entity_type        VARCHAR(32) NULL,
        entity_id          VARCHAR(64) NULL,
        kind               VARCHAR(48) NULL,
        subject            VARCHAR(512) NULL,
        smtp_account_id    VARCHAR(64) NULL,
        status             VARCHAR(24) NOT NULL,
        error              TEXT        NULL,
        sent_at            DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_recipient (recipient_user_id, sent_at),
        INDEX idx_entity (entity_type, entity_id),
        INDEX idx_notification (notification_id),
        INDEX idx_status (status)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    // email_templates — reusable subject/body snippets with {{variable}}
    // placeholders. Used from the Reply dialog in the email inbox (and later
    // support message composer). Kept per-platform for now; could be scoped
    // to app_id in a future iteration.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS email_templates (
        id             VARCHAR(64)  NOT NULL PRIMARY KEY,
        name           VARCHAR(255) NOT NULL,
        subject        VARCHAR(512) NOT NULL,
        body           MEDIUMTEXT   NOT NULL,
        template_type  VARCHAR(32)  NOT NULL DEFAULT 'customer',
        reminder_request_type VARCHAR(32) NULL,
        is_active      TINYINT(1)   NOT NULL DEFAULT 1,
        created_by     VARCHAR(64)  NULL,
        created_by_name VARCHAR(255) NULL,
        app_id         VARCHAR(64)  NULL,
        created_at     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        updated_at     DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
        INDEX idx_type (template_type),
        INDEX idx_active (is_active),
        INDEX idx_app (app_id)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);
    // Idempotent migration for installs that pre-date the reminder_request_type
    // column (added when supplier reminders were introduced). NULL = no
    // default reminder type for this template; the operator picks at send time.
    await ensureColumn('email_templates', 'reminder_request_type',
      'reminder_request_type VARCHAR(32) NULL');
    // ref_display — controls which reference is shown in the email shell
    // header subtitle. Allowed: 'booking' (booking code only — current
    // default for customer templates), 'supplier' (supplier ref only —
    // for supplier templates), 'both' (supplier ref FIRST then booking),
    // 'none' (suppress). NULL = legacy fallback ('booking' for customer
    // templates, 'supplier' for supplier templates).
    await ensureColumn('email_templates', 'ref_display',
      "ref_display VARCHAR(16) NULL");

    // ──────────────────────────────────────────────────────────────────
    //  Bookings cache + resilience tables
    // ──────────────────────────────────────────────────────────────────
    //
    //  bookings_cache       — local mirror of upstream bookings list rows.
    //                         Populated lazily by user activity (no always-on
    //                         poller). The Bookings + Monitor pages read
    //                         from here for instant loads. Booking detail
    //                         pages still hit upstream live.
    //
    //  bookings_detail_cache — opportunistic snapshot of the FULL detail
    //                         payload, written when a user successfully
    //                         opens a booking. Used as a fallback when
    //                         upstream is unreachable.
    //
    //  bookings_sync_state  — per-scope (per-app) tracking of last sync
    //                         time + watermark for incremental delta. One
    //                         row per app_id we've ever synced.
    //
    //  system_health        — log of upstream outages / degraded windows.
    //                         The circuit breaker writes here so we have an
    //                         audit trail of when EasyGDS was reachable.
    await pool.query(`
      CREATE TABLE IF NOT EXISTS bookings_cache (
        id                 VARCHAR(64)  NOT NULL PRIMARY KEY,
        code               VARCHAR(64)  NOT NULL,
        app_id             VARCHAR(64)  NULL,
        agent_id           VARCHAR(64)  NULL,
        booking_status     VARCHAR(32)  NULL,
        payment_status     VARCHAR(32)  NULL,
        channel_type       VARCHAR(32)  NULL,
        b2b_only           TINYINT(1)   NOT NULL DEFAULT 0,
        contact_email      VARCHAR(255) NULL,
        contact_name       VARCHAR(255) NULL,
        contact_phone      VARCHAR(64)  NULL,
        pnr                VARCHAR(64)  NULL,
        travel_date        DATE         NULL,
        total_amount       DECIMAL(12,2) NULL,
        currency           VARCHAR(8)   NULL,
        passenger_count    INT          NULL,
        company_name       VARCHAR(255) NULL,
        supplier_name      VARCHAR(255) NULL,
        upstream_created   DATETIME     NULL,
        upstream_updated   DATETIME     NULL,
        synced_at          DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        raw                JSON         NULL,
        UNIQUE KEY ux_code (code),
        INDEX idx_app_created (app_id, upstream_created),
        INDEX idx_app_agent_created (app_id, agent_id, upstream_created),
        INDEX idx_contact_email (contact_email),
        INDEX idx_pnr (pnr),
        INDEX idx_status (booking_status),
        INDEX idx_payment (payment_status),
        INDEX idx_synced_at (synced_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS bookings_detail_cache (
        booking_id         VARCHAR(64)  NOT NULL PRIMARY KEY,
        booking_code       VARCHAR(64)  NULL,
        raw                JSON         NOT NULL,
        fetched_at         DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        INDEX idx_fetched (fetched_at),
        INDEX idx_code (booking_code)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS bookings_sync_state (
        scope_key             VARCHAR(96) NOT NULL PRIMARY KEY,
        status                VARCHAR(16) NOT NULL DEFAULT 'idle',
        last_synced_at        DATETIME    NULL,
        last_modified_upstream DATETIME   NULL,
        last_error            TEXT        NULL,
        synced_count          INT         NOT NULL DEFAULT 0,
        duration_ms           INT         NULL,
        last_viewed_at        DATETIME    NULL,
        updated_at            DATETIME    NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    await pool.query(`
      CREATE TABLE IF NOT EXISTS system_health (
        id              VARCHAR(64)  NOT NULL PRIMARY KEY,
        service         VARCHAR(32)  NOT NULL,
        status          VARCHAR(16)  NOT NULL,
        message         TEXT         NULL,
        noticed_at      DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP,
        resolved_at     DATETIME     NULL,
        duration_ms     INT          NULL,
        INDEX idx_service_noticed (service, noticed_at),
        INDEX idx_open (service, resolved_at)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
    `);

    console.log('✅ Schema bootstrap: support_requests triage columns + approval_requests + notifications + email_templates + bookings_cache + system_health ready');
  } catch (e) {
    console.warn('⚠️  Schema bootstrap (support triage) failed:', e.message);
  }
}

// Start server only when run directly (not via Passenger)
if (require.main === module) {
  const PORT = process.env.PORT || 3001;
  app.listen(PORT, async () => {
    console.log(`✅ EasyGDS Operations API v3.0 running on http://localhost:${PORT}`);
    console.log(`   Environment: ${process.env.NODE_ENV || 'development'}`);
    console.log(`   MySQL: ${process.env.DB_NAME || 'tripovo_support'}`);
    console.log(`   EasyGDS Core: ${EASYGDS_CORE_URL}`);
    console.log(`   EasyGDS IAM: ${EASYGDS_IAM_URL}`);
    // Bootstrap local schema (platform_users etc.)
    await ensureLocalSchema();
    // Pre-warm EasyGDS token
    try { await getEasyGDSToken(); } catch (e) { console.warn('⚠️  EasyGDS token pre-warm failed:', e.message); }
  });
}
