import {brandForHost} from '../brands.js';
import { randomBytes, randomUUID, createHash, scrypt as scryptCallback, timingSafeEqual, createPublicKey, verify } from 'node:crypto';
import { promisify } from 'node:util';
import { CommerceStore, PLANS } from './store.js';
import { StripeGateway } from './stripe.js';

const scrypt = promisify(scryptCallback), hash = value => createHash('sha256').update(value).digest('hex');
const opaque = () => randomBytes(32).toString('base64url');
const validToken = value => typeof value === 'string' && /^[A-Za-z0-9_-]{43}$/.test(value);
const equal = (a, b) => typeof a === 'string' && typeof b === 'string' && timingSafeEqual(Buffer.from(hash(a), 'hex'), Buffer.from(hash(b), 'hex'));
const fail = (status, message) => { throw Object.assign(new Error(message), { status }); };
const safeName = value => typeof value === 'string' && value.length <= 100 && !/[\x00-\x1f]/.test(value);
const fingerprintValid = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
const scryptOptions = { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 };

export async function createCommerce({ dbPath, origin, additionalOrigins = [], secureCookies = true, stripe = {}, fetchImpl, now = Date.now } = {}) {
  if (!origin || new URL(origin).origin !== origin || (secureCookies && !origin.startsWith('https://'))) throw new Error('Invalid commerce origin');
  if (!Array.isArray(additionalOrigins) || additionalOrigins.some(value => { try { return new URL(value).origin !== value || (secureCookies && !value.startsWith('https://')); } catch { return true; } })) throw new Error('Invalid commerce additional origins');
  const allowedOrigins = new Set([origin, ...additionalOrigins]);
  const store = new CommerceStore(dbPath);
  const gateway = new StripeGateway({ ...stripe, ...(fetchImpl ? { fetchImpl } : {}), now });
  const challenges = new Map(), grants = new Map(), limits = new Map(), locks = new Map(), downloadTickets = new Map();
  let passwordJobs = 0;
  const relayLimits = { free: 1e9, solo: 5e9, team: 25e9, business: 100e9 };
  const relayView = account => {
    const month = new Date(now()).toISOString().slice(0, 7);
    const { subscription, slots } = entitlement(account);
    return { relayUsedBytes: store.get('SELECT bytes FROM relay_usage WHERE account=? AND month=?', account, month)?.bytes || 0, relayLimitBytes: slots ? relayLimits[subscription.plan] : 0, relayPeriod: month, relayPeriodBasis: 'UTC calendar month' };
  };
  const cookieName = secureCookies ? '__Host-relio-account' : 'relio-account';
  const withLock = async (key, fn) => {
    const previous = locks.get(key) || Promise.resolve();
    const task = previous.catch(() => {}).then(fn); locks.set(key, task);
    try { return await task; } finally { if (locks.get(key) === task) locks.delete(key); }
  };
  const rate = (req, category, limit) => {
    const timestamp = now();
    for (const [key, value] of limits) if (value.until <= timestamp) limits.delete(key);
    const socketIp = req.socket.remoteAddress;
    const ip = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(socketIp) && typeof req.headers['x-real-ip'] === 'string' ? req.headers['x-real-ip'].slice(0, 100) : socketIp;
    const key = `${category}:${ip}`;
    let bucket = limits.get(key);
    if (!bucket) { if (limits.size >= 2048) fail(429, 'Trop de demandes.'); bucket = { until: timestamp + 60000, count: 0 }; limits.set(key, bucket); }
    if (++bucket.count > limit) fail(429, 'Trop de demandes. Réessayez dans une minute.');
  };
  const readBody = async req => {
    let size = 0; const chunks = [];
    for await (const chunk of req) { size += chunk.length; if (size > 256 * 1024) fail(413, 'Requête trop grande.'); chunks.push(chunk); }
    return Buffer.concat(chunks);
  };
  const bodyJson = raw => { try { const body = JSON.parse(raw.toString('utf8')); if (!body || typeof body !== 'object' || Array.isArray(body)) throw new Error(); return body; } catch { fail(400, 'Requête JSON invalide.'); } };
  const cookie = (res, token, maxAge = 86400) => res.setHeader('Set-Cookie', `${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secureCookies ? '; Secure' : ''}`);
  const session = req => {
    const tokens = (req.headers.cookie || '').split(';').map(part => part.trim()).filter(part => part.startsWith(`${cookieName}=`));
    if (tokens.length !== 1) return null;
    const token = tokens[0].slice(cookieName.length + 1);
    if (!validToken(token)) return null;
    return store.get('SELECT s.*,a.email,a.customer FROM sessions s JOIN accounts a ON a.id=s.account WHERE s.hash=? AND s.expires>?', hash(token), now());
  };
  const requireSession = (req, mutation) => {
    const s = session(req); if (!s) fail(401, 'Connexion requise.');
    if (mutation && (!allowedOrigins.has(req.headers.origin) || !equal(req.headers['x-csrf-token'], s.csrf))) fail(403, 'Requête refusée.');
    return s;
  };
  const accountRole = account => store.get('SELECT role FROM accounts WHERE id=?', account)?.role || 'user';
  const entitlement = account => {
    if (accountRole(account) === 'admin') return { subscription: { plan: 'business', status: 'active', paid_until: null, cancel_end: 0, complimentary: true }, slots: 100, free: false };
    const subscription = store.get('SELECT * FROM subscriptions WHERE account=?', account);
    const paid = subscription?.status === 'active' && subscription.paid_until > now() && Object.hasOwn(PLANS, subscription.plan);
    return paid ? { subscription, slots: PLANS[subscription.plan], free: false } : { subscription: { plan: 'free', status: 'active', paid_until: null, cancel_end: 0 }, slots: 1, free: true };
  };
  const activeLicense = id => {
    const license = store.get('SELECT * FROM licenses WHERE id=?', id);
    return license && license.state === 'enabled' && license.ordinal <= entitlement(license.account).slots ? license : null;
  };
  const licenses = account => {
    const { slots } = entitlement(account);
    return store.all('SELECT l.*,d.id AS device_id,d.machine_name FROM licenses l LEFT JOIN devices d ON d.license=l.id WHERE l.account=? ORDER BY l.ordinal', account).map(l => ({ id: l.id, name: l.name, service: l.service, department: l.service, ordinal: l.ordinal, entitled: l.ordinal <= slots, status: l.state === 'revoked' ? 'revoked' : l.state === 'disabled' ? 'disabled' : l.device_id ? 'activated' : 'not_activated', deviceId: l.device_id || null, machineName: l.machine_name || null }));
  };
  const brandingEnabled = account => ['team', 'business'].includes(entitlement(account).subscription.plan);
  const brandingView = account => {
    if (!brandingEnabled(account)) return null;
    const row = store.get('SELECT company_name,message,accent FROM account_branding WHERE account=?', account);
    return row ? { companyName: row.company_name, message: row.message, accent: row.accent } : null;
  };
  const accountView = s => {
    const { subscription, slots, free } = entitlement(s.account);
    store.transaction(() => { for (let ordinal = 1; ordinal <= slots; ordinal++) store.run('INSERT OR IGNORE INTO licenses(id,account,ordinal) VALUES(?,?,?)', randomUUID(), s.account, ordinal); });
    const period = new Date(now()).toISOString().slice(0, 7);
    const used = store.get('SELECT shares FROM monthly_usage WHERE account=? AND month=?', s.account, period)?.shares || 0;
    return { account: { id: s.account, email: s.email, emailVerified: false, role: accountRole(s.account) }, csrfToken: s.csrf, billingEnabled: gateway.enabled, brandingEnabled: brandingEnabled(s.account), branding: brandingView(s.account), subscription: subscription ? { plan: subscription.plan, status: subscription.status, paidUntil: subscription.paid_until, currentPeriodEnd: subscription.paid_until, cancelAtPeriodEnd: Boolean(subscription.cancel_end), complimentary: Boolean(subscription.complimentary), slots } : null, ...relayView(s.account), transfersUsed: used, transfersLimit: free ? 3 : null, transferPeriod: period, freeQuota: free ? { transfersUsed: used, transfersLimit: 3, period, maxShareLifetimeMs: 86400000 } : null, licenses: licenses(s.account), plans: Object.entries({ free: 1, ...PLANS }).map(([id, slots]) => ({ id, slots, relayLimitBytes: relayLimits[id] })) };
  };
  const issueSession = (res, account) => {
    const token = opaque(), csrf = opaque();
    store.run('DELETE FROM sessions WHERE expires<=?', now());
    store.run('INSERT INTO sessions(hash,account,csrf,expires) VALUES(?,?,?,?)', hash(token), account.id, csrf, now() + 86400000);
    cookie(res, token);
    return accountView({ account: account.id, email: account.email, csrf });
  };
  const ownedLicense = (s, id) => { const l = store.get('SELECT * FROM licenses WHERE id=? AND account=?', id, s.account); if (!l) fail(404, 'Licence introuvable.'); return l; };
  const publicKey = encoded => {
    try {
      if (typeof encoded !== 'string' || encoded.length > 128) throw new Error();
      const der = Buffer.from(encoded, 'base64');
      if (der.toString('base64') !== encoded) throw new Error();
      const key = createPublicKey({ key: der, format: 'der', type: 'spki' });
      if (key.asymmetricKeyType !== 'ed25519' || key.export({ format: 'der', type: 'spki' }).toString('base64') !== encoded) throw new Error();
      return key;
    } catch { fail(400, 'Clé appareil invalide.'); }
  };
  const challengeProof = (body, purpose, keyEncoded, target, name = '') => {
    const challenge = challenges.get(body.challengeId); challenges.delete(body.challengeId);
    if (!challenge || challenge.expires <= now() || challenge.purpose !== purpose || challenge.key !== keyEncoded || !fingerprintValid(body.fingerprint)) fail(403, 'Preuve appareil invalide ou expirée.');
    const transcript = JSON.stringify(['relio-device-v1', purpose, body.challengeId, challenge.challenge, target, name, body.fingerprint]);
    let signature;
    try { signature = Buffer.from(body.signature, 'base64'); } catch { fail(403, 'Preuve appareil invalide.'); }
    if (signature.length !== 64 || signature.toString('base64') !== body.signature || !verify(null, Buffer.from(transcript), publicKey(keyEncoded), signature)) fail(403, 'Preuve appareil invalide.');
  };
  const issuePublisher = device => {
    for (const [key, grant] of grants) if (grant.expiresAt <= now()) grants.delete(key);
    if (grants.size >= 10000) fail(503, 'Capacité temporairement atteinte.');
    const token = opaque(), expiresAt = now() + 600000;
    grants.set(hash(token), { deviceId: device.id, licenseId: device.license, generation: store.get('SELECT generation FROM licenses WHERE id=?', device.license).generation, expiresAt });
    return { deviceId: device.id, licenseId: device.license, publisherToken: token, expiresAt };
  };
  const isLicenseActive = identity => {
    if (!identity || typeof identity.accountId !== 'string' || typeof identity.deviceId !== 'string' || typeof identity.licenseId !== 'string') return false;
    const device = store.get('SELECT * FROM devices WHERE id=? AND license=?', identity.deviceId, identity.licenseId);
    const license = device && activeLicense(device.license);
    return Boolean(license && identity.generation === license.generation && (!identity.accountId || license.account === identity.accountId));
  };
  const verifyPublisher = token => {
    if (!validToken(token)) return null;
    const grant = grants.get(hash(token));
    if (!grant || grant.expiresAt <= now()) return null;
    const device = store.get('SELECT * FROM devices WHERE id=? AND license=?', grant.deviceId, grant.licenseId);
    const license = device && activeLicense(device.license);
    return license && license.generation === grant.generation ? { id: license.id, accountId: license.account, licenseId: license.id, deviceId: device.id, generation: license.generation, expiresAt: grant.expiresAt } : null;
  };
  const consumeRelay = (identity, wireBytes) => {
    if (!Number.isSafeInteger(wireBytes) || wireBytes < 0 || !isLicenseActive(identity)) return false;
    return store.transaction(() => {
      const license = activeLicense(identity.licenseId);
      if (!license) return false;
      const view = relayView(license.account);
      if (wireBytes > view.relayLimitBytes - view.relayUsedBytes) return false;
      store.run('INSERT INTO relay_usage(account,month,bytes) VALUES(?,?,?) ON CONFLICT(account,month) DO UPDATE SET bytes=bytes+excluded.bytes', license.account, view.relayPeriod, wireBytes);
      return true;
    });
  };
  const getPublisherBranding = identity => isLicenseActive(identity) ? brandingView(identity.accountId) : null;
  const getPublisherPolicy = identity => {
    if (!isLicenseActive(identity)) return null;
    const { subscription, free } = entitlement(identity.accountId);
    const period = new Date(now()).toISOString().slice(0, 7);
    const used = store.get('SELECT shares FROM monthly_usage WHERE account=? AND month=?', identity.accountId, period)?.shares || 0;
    return { plan: subscription.plan, monthlySharesRemaining: free ? Math.max(0, 3 - used) : null, maxShareLifetimeMs: free ? 86400000 : null };
  };
  const consumeShare = identity => store.transaction(() => {
    const policy = getPublisherPolicy(identity);
    if (!policy) return { allowed: false, remaining: 0, free: false };
    if (policy.plan !== 'free') return { allowed: true, remaining: null, free: false };
    if (policy.monthlySharesRemaining === 0) return { allowed: false, remaining: 0, free: true };
    const month = new Date(now()).toISOString().slice(0, 7);
    store.run('INSERT INTO monthly_usage(account,month,shares) VALUES(?,?,1) ON CONFLICT(account,month) DO UPDATE SET shares=shares+1', identity.accountId, month);
    return { allowed: true, remaining: policy.monthlySharesRemaining - 1, free: true, maxExpiresAt: now() + policy.maxShareLifetimeMs };
  });
  const authorizeDownload = (req, filename) => {
    if (!['Relio-macOS.zip', 'Relio-Windows.zip'].includes(filename)) return false;
    const s = session(req);
    if (s && entitlement(s.account).slots > 0) return true;
    const token = new URL(req.url, origin).searchParams.get('ticket');
    if (!validToken(token)) return false;
    const key = hash(token), ticket = downloadTickets.get(key);
    if (!ticket || ticket.filename !== filename || ticket.expires <= now() || !activeLicense(ticket.licenseId)) return false;
    if (req.method === 'GET') downloadTickets.delete(key);
    return ['GET', 'HEAD'].includes(req.method);
  };
  const syncCustomer = async customer => {
    const account = store.get('SELECT * FROM accounts WHERE customer=?', customer); if (!account) return;
    const list = await gateway.subscriptions(customer);
    if (!Array.isArray(list.data) || list.has_more) fail(502, 'État abonnement ambigu.');
    const subscriptions = list.data;
    const candidate = subscriptions.filter(s => s.customer === customer && s.status === 'active').sort((a, b) => b.created - a.created)[0];
    const current = store.get('SELECT * FROM subscriptions WHERE account=?', account.id);
    let plan = current?.plan || null, until = 0, status = 'inactive', subscriptionId = null, cancelEnd = false;
    if (candidate) {
      const fresh = await gateway.subscription(candidate.id);
      if (fresh.customer !== customer) fail(502, 'État abonnement invalide.');
      subscriptionId = fresh.id; cancelEnd = Boolean(fresh.cancel_at_period_end);
      const item = fresh.items?.data?.length === 1 ? fresh.items.data[0] : null;
      const price = item?.price;
      const freshPlan = Object.keys(PLANS).find(key => gateway.prices[key] === price?.id);
      const periodEnd = (fresh.current_period_end || item?.current_period_end || 0) * 1000;
      const invoice = fresh.latest_invoice;
      // A paid invoice for an earlier plan must never authorize a newly selected higher tier.
      const paid = invoice && typeof invoice === 'object' && invoice.status === 'paid' && invoice.subscription === fresh.id && invoice.lines?.has_more === false && invoice.lines.data?.some(line => line.price?.id === price?.id && line.quantity === 1 && line.period?.end * 1000 >= periodEnd && (line.subscription === fresh.id || invoice.subscription === fresh.id));
      if (fresh.status === 'active' && freshPlan && item.quantity === 1 && price.currency === 'eur' && price.recurring?.interval === 'month' && (price.recurring.interval_count || 1) === 1) {
        if (paid && periodEnd > now()) { plan = freshPlan; until = periodEnd; status = 'active'; }
        else if (current?.status === 'active' && current.subscription === fresh.id && current.paid_until > now()) { plan = current.plan; until = current.paid_until; status = 'active'; }
      }
    }
    store.transaction(() => {
      store.run('INSERT INTO subscriptions(account,subscription,plan,status,paid_until,cancel_end) VALUES(?,?,?,?,?,?) ON CONFLICT(account) DO UPDATE SET subscription=excluded.subscription,plan=excluded.plan,status=excluded.status,paid_until=excluded.paid_until,cancel_end=excluded.cancel_end', account.id, subscriptionId, plan, status, until, cancelEnd ? 1 : 0);
      for (let ordinal = 1; ordinal <= (status === 'active' ? PLANS[plan] || 0 : 0); ordinal++) store.run('INSERT OR IGNORE INTO licenses(id,account,ordinal) VALUES(?,?,?)', randomUUID(), account.id, ordinal);
      if (status === 'active') store.run('DELETE FROM checkouts WHERE account=?', account.id);
    });
  };
  const handle = async (req, res) => {
    const url = new URL(req.url, origin), pathname = url.pathname;
    if (!/^\/api\/(account|licenses|device|billing|admin)(\/|$)/.test(pathname)) return false;
    const respond = (code, value) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'X-Content-Type-Options': 'nosniff' }); res.end(JSON.stringify(value)); };
    try {
      if (pathname === '/api/billing/webhook') {
        if (req.method !== 'POST') fail(405, 'Méthode refusée.');
        const raw = await readBody(req);
        if (!gateway.verifyWebhook(raw, req.headers['stripe-signature'])) fail(gateway.enabled ? 400 : 503, 'Webhook indisponible ou signature invalide.');
        const event = bodyJson(raw);
        if (typeof event.id !== 'string' || !/^evt_[A-Za-z0-9]+$/.test(event.id)) fail(400, 'Événement invalide.');
        await withLock('stripe-webhooks', async () => {
          if (store.get('SELECT id FROM stripe_events WHERE id=?', event.id)) return;
          const object = event.data?.object;
          if (/^(customer\.subscription\.|invoice\.|checkout\.session\.)/.test(event.type || '') && typeof object?.customer === 'string') await withLock(`customer:${object.customer}`, () => syncCustomer(object.customer));
          store.run('INSERT OR IGNORE INTO stripe_events(id,created) VALUES(?,?)', event.id, now());
        });
        respond(200, { received: true }); return true;
      }
      if (pathname.startsWith('/api/admin/')) {
        const s = requireSession(req, false);
        if (accountRole(s.account) !== 'admin') fail(403, 'Accès administrateur requis.');
        if (req.method !== 'GET') fail(405, 'Méthode refusée.');
        if (pathname !== '/api/admin/overview') fail(404, 'Route introuvable.');
        const activeDevices = account => store.all('SELECT l.ordinal,l.state FROM devices d JOIN licenses l ON l.id=d.license WHERE l.account=?', account).filter(l => l.state === 'enabled' && l.ordinal <= entitlement(account).slots).length;
        const accounts = store.all('SELECT id,email,role,created FROM accounts ORDER BY created DESC,id DESC LIMIT 50').map(account => { const rights = entitlement(account.id); return { ...account, plan: rights.subscription.plan, slots: rights.slots, activeDevices: activeDevices(account.id) }; });
        const totalAccounts = store.get('SELECT COUNT(*) AS count FROM accounts').count;
        const deviceRows = store.all('SELECT l.account,l.ordinal,l.state FROM devices d JOIN licenses l ON l.id=d.license');
        const slotsByAccount = new Map();
        const totalActiveDevices = deviceRows.filter(row => { if (!slotsByAccount.has(row.account)) slotsByAccount.set(row.account, entitlement(row.account).slots); return row.state === 'enabled' && row.ordinal <= slotsByAccount.get(row.account); }).length;
        respond(200, { stats: { totalAccounts, activeDevices: totalActiveDevices }, accounts, limit: 50, truncated: totalAccounts > 50 }); return true;
      }
      if (req.method === 'GET' && pathname === '/api/account/me') { const s = requireSession(req, false); respond(200, accountView(s)); return true; }
      if (req.method !== 'POST') fail(405, 'Méthode refusée.');
      const isDevice = pathname.startsWith('/api/device/');
      if (!isDevice && !allowedOrigins.has(req.headers.origin)) fail(403, 'Origine refusée.');
      if (isDevice && req.headers.origin && !allowedOrigins.has(req.headers.origin)) fail(403, 'Origine refusée.');
      rate(req, isDevice ? 'device' : 'account', isDevice ? 60 : 30);
      const body = bodyJson(await readBody(req));
      if (pathname === '/api/account/register' || pathname === '/api/account/login') {
        rate(req, 'credentials', 10);
        if (passwordJobs >= 2) fail(429, 'Authentification occupée. Réessayez dans un instant.');
        passwordJobs++;
        try {
        const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254 || typeof body.password !== 'string' || body.password.length < 12 || body.password.length > 256) fail(400, 'Adresse e-mail ou mot de passe invalide (12 caractères minimum).');
        let account = store.get('SELECT * FROM accounts WHERE email=?', email);
        if (pathname.endsWith('/register')) {
          if (account) fail(409, 'Impossible de créer ce compte.');
          const salt = randomBytes(16).toString('hex'), derived = await scrypt(body.password, salt, 32, scryptOptions);
          account = { id: randomUUID(), email, password: `${salt}:${derived.toString('hex')}` };
          store.transaction(() => {
            const day = new Date(now()).toISOString().slice(0, 10);
            const ip = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(req.socket.remoteAddress) && typeof req.headers['x-real-ip'] === 'string' ? req.headers['x-real-ip'].slice(0, 100) : req.socket.remoteAddress;
            const ipHash = hash(`${day}:${ip}`);
            store.run('DELETE FROM registration_usage WHERE day<>?', day);
            if ((store.get('SELECT count FROM registration_usage WHERE ip_hash=? AND day=?', ipHash, day)?.count || 0) >= 3) fail(429, 'Limite quotidienne de création de comptes atteinte pour cette connexion.');
            if (store.get('SELECT COUNT(*) AS total FROM accounts').total >= 10000) fail(503, 'Les inscriptions sont temporairement suspendues.');
            try { store.run('INSERT INTO accounts(id,email,password,created) VALUES(?,?,?,?)', account.id, email, account.password, now()); } catch { fail(409, 'Impossible de créer ce compte.'); }
            store.run('INSERT INTO registration_usage(ip_hash,day,count) VALUES(?,?,1) ON CONFLICT(ip_hash,day) DO UPDATE SET count=count+1', ipHash, day);
          });
        } else {
          const [salt, saved] = (account?.password || `${'0'.repeat(32)}:${'0'.repeat(64)}`).split(':');
          const derived = await scrypt(body.password, salt, 32, scryptOptions);
          if (!account || !timingSafeEqual(derived, Buffer.from(saved, 'hex'))) fail(401, 'Adresse e-mail ou mot de passe incorrect.');
        }
        respond(200, issueSession(res, account)); return true;
        } finally { passwordJobs--; }
      }
      if (isDevice) {
        if (pathname === '/api/device/download') {
          if (!validToken(body.token) || !['macOS', 'Windows'].includes(body.platform)) fail(400, 'Demande de téléchargement invalide.');
          const activation = store.get('SELECT * FROM activation_tokens WHERE hash=? AND expires>?', hash(body.token), now());
          if (!activation || !activeLicense(activation.license)) fail(403, 'Invitation indisponible.');
          for (const [key, ticket] of downloadTickets) if (ticket.expires <= now()) downloadTickets.delete(key);
          if (downloadTickets.size >= 1000) fail(429, 'Trop de demandes de téléchargement.');
          const token = opaque(), filename = body.platform === 'macOS' ? 'Relio-macOS.zip' : 'Relio-Windows.zip';
          downloadTickets.set(hash(token), { filename, licenseId: activation.license, expires: now() + 60000 });
          respond(200, { url: `/downloads/${filename.replace(/^Relio-/, brandForHost(req.headers.host).name + '-')}?ticket=${token}` }); return true;
        }
        if (pathname === '/api/device/challenge') {
          publicKey(body.publicKey);
          if (!['activate', 'publish', 'deactivate'].includes(body.purpose)) fail(400, 'Action appareil invalide.');
          for (const [id, c] of challenges) if (c.expires <= now()) challenges.delete(id);
          if (challenges.size >= 1000) fail(429, 'Trop de demandes de connexion.');
          const challengeId = randomUUID(), challenge = opaque(), expiresAt = now() + 300000;
          challenges.set(challengeId, { challenge, key: body.publicKey, purpose: body.purpose, expires: expiresAt });
          respond(200, { challengeId, challenge, expiresAt }); return true;
        }
        if (pathname === '/api/device/activate') {
          if (!validToken(body.token) || !safeName(body.machineName) || !body.machineName || !fingerprintValid(body.fingerprint)) fail(400, 'Activation invalide.');
          challengeProof(body, 'activate', body.publicKey, hash(body.token), body.machineName);
          const device = store.transaction(() => {
            const activation = store.get('SELECT * FROM activation_tokens WHERE hash=? AND expires>?', hash(body.token), now());
            const license = activation && activeLicense(activation.license);
            if (!license || store.get('SELECT id FROM devices WHERE license=? OR key_hash=?', license.id, hash(body.publicKey))) fail(403, 'Activation indisponible.');
            if (entitlement(license.account).free) {
              const reserved = store.get('SELECT account FROM free_devices WHERE fingerprint=?', body.fingerprint);
              if (reserved && reserved.account !== license.account) fail(403, 'Un compte Free est déjà associé à cet ordinateur.');
              store.run('INSERT OR IGNORE INTO free_devices(fingerprint,account) VALUES(?,?)', body.fingerprint, license.account);
            }
            const device = { id: randomUUID(), license: license.id };
            store.run('INSERT INTO devices(id,license,public_key,key_hash,machine_name,fingerprint,created) VALUES(?,?,?,?,?,?,?)', device.id, license.id, body.publicKey, hash(body.publicKey), body.machineName, body.fingerprint, now());
            store.run('DELETE FROM activation_tokens WHERE license=?', license.id); return device;
          });
          respond(200, issuePublisher(device)); return true;
        }
        if (['/api/device/publish', '/api/device/deactivate'].includes(pathname)) {
          const device = typeof body.deviceId === 'string' && store.get('SELECT * FROM devices WHERE id=?', body.deviceId);
          if (!device) fail(403, 'Appareil indisponible.');
          const purpose = pathname.endsWith('/publish') ? 'publish' : 'deactivate';
          challengeProof(body, purpose, device.public_key, device.id);
          if (!equal(body.fingerprint, device.fingerprint)) fail(403, 'Preuve appareil invalide.');
          if (purpose === 'deactivate') { store.run('DELETE FROM devices WHERE id=?', device.id); respond(200, { deactivated: true }); return true; }
          if (!activeLicense(device.license)) fail(403, 'Licence indisponible.');
          respond(200, issuePublisher(device)); return true;
        }
        fail(404, 'Route introuvable.');
      }
      const s = requireSession(req, true);
      if (pathname === '/api/account/branding') {
        if (!brandingEnabled(s.account)) fail(403, 'La personnalisation nécessite une offre Pro 10 ou Pro 100 active.');
        if (Object.keys(body).some(key => !['companyName', 'message', 'accent'].includes(key)) || typeof body.companyName !== 'string' || body.companyName.length > 80 || /[\x00-\x1f<>]/.test(body.companyName) || typeof body.message !== 'string' || body.message.length > 300 || /[\x00-\x08\x0b\x0c\x0e-\x1f<>]/.test(body.message) || typeof body.accent !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(body.accent)) fail(400, 'Personnalisation invalide : nom de 80 caractères, message de 300 caractères et couleur hexadécimale.');
        store.run('INSERT INTO account_branding(account,company_name,message,accent) VALUES(?,?,?,?) ON CONFLICT(account) DO UPDATE SET company_name=excluded.company_name,message=excluded.message,accent=excluded.accent', s.account, body.companyName.trim(), body.message.trim(), body.accent.toLowerCase());
        respond(200, { branding: brandingView(s.account), brandingEnabled: true }); return true;
      }
      if (pathname === '/api/account/logout') { store.run('DELETE FROM sessions WHERE hash=?', s.hash); cookie(res, '', 0); respond(200, { loggedOut: true }); return true; }
      const licenseRoute = pathname.match(/^\/api\/licenses\/([a-f0-9-]{36})\/(assign|activation|disable|enable|reset|revoke)$/);
      if (licenseRoute) {
        const [, id, action] = licenseRoute, license = ownedLicense(s, id);
        if (action === 'assign') {
          const service = body.department ?? body.service ?? '';
          if (!safeName(body.name) || !safeName(service)) fail(400, 'Nom ou service invalide.');
          store.run('UPDATE licenses SET name=?,service=? WHERE id=?', body.name, service, id);
        } else if (action === 'activation') {
          if (!activeLicense(id) || store.get('SELECT id FROM devices WHERE license=?', id)) fail(409, 'Licence non disponible pour activation.');
          const token = opaque(), expiresAt = now() + 72 * 3600000;
          store.transaction(() => { store.run('DELETE FROM activation_tokens WHERE license=?', id); store.run('INSERT INTO activation_tokens(hash,license,expires) VALUES(?,?,?)', hash(token), id, expiresAt); });
          respond(200, { token, expiresAt, licenseId: id }); return true;
        } else if (action === 'reset') {
          store.transaction(() => { store.run('DELETE FROM devices WHERE license=?', id); store.run('DELETE FROM activation_tokens WHERE license=?', id); store.run('UPDATE licenses SET generation=generation+1 WHERE id=?', id); });
        } else {
          if (license.state === 'revoked' && action === 'enable') fail(409, 'Licence révoquée.');
          store.transaction(() => { store.run('UPDATE licenses SET state=?,generation=generation+1 WHERE id=?', action === 'enable' ? 'enabled' : action === 'disable' ? 'disabled' : 'revoked', id); if (action !== 'enable') store.run('DELETE FROM activation_tokens WHERE license=?', id); if (action === 'revoke') store.run('DELETE FROM devices WHERE license=?', id); });
        }
        respond(200, { licenses: licenses(s.account) }); return true;
      }
      if (pathname === '/api/billing/checkout') {
        if (accountRole(s.account) === 'admin') fail(409, 'Votre accès propriétaire Pro 100 est gratuit et ne nécessite aucun abonnement Stripe.');
        if (!gateway.enabled) fail(503, 'Paiement indisponible : configuration requise.');
        if (!Object.hasOwn(PLANS, body.plan)) fail(400, 'Offre invalide.');
        const result = await withLock(`checkout:${s.account}`, async () => {
          let account = store.get('SELECT * FROM accounts WHERE id=?', s.account);
          if (!account.customer) { const customer = await gateway.customer(account); if (!/^cus_[A-Za-z0-9]+$/.test(customer.id || '')) fail(502, 'Client paiement invalide.'); store.run('UPDATE accounts SET customer=? WHERE id=?', customer.id, account.id); account = { ...account, customer: customer.id }; }
          return withLock(`customer:${account.customer}`, async () => {
            await syncCustomer(account.customer);
            if (!entitlement(account.id).free) fail(409, 'Utilisez le portail de facturation pour modifier votre abonnement.');
            const existing = await gateway.subscriptions(account.customer);
            if (!Array.isArray(existing.data) || existing.has_more) fail(502, 'État abonnement ambigu.');
            if (existing.data.some(subscription => !['canceled', 'incomplete_expired'].includes(subscription.status))) fail(409, 'Un abonnement existe déjà. Utilisez le portail de facturation.');
            let checkout = store.get('SELECT * FROM checkouts WHERE account=?', account.id);
            if (checkout && checkout.expires > now()) { if (checkout.plan !== body.plan) fail(409, 'Une commande est déjà en cours.'); if (checkout.url) return { url: checkout.url, reusedCheckout: true }; }
            else { checkout = { key: `relio-checkout-${randomUUID()}`, expires: now() + 86400000 }; store.run('INSERT INTO checkouts(account,plan,key,expires) VALUES(?,?,?,?) ON CONFLICT(account) DO UPDATE SET plan=excluded.plan,key=excluded.key,url=NULL,expires=excluded.expires', account.id, body.plan, checkout.key, checkout.expires); }
            const created = await gateway.checkout(account.customer, body.plan, account.id, req.headers.origin, checkout.key);
            if (typeof created.url !== 'string' || new URL(created.url).origin !== 'https://checkout.stripe.com') fail(502, 'Lien paiement invalide.');
            store.run('UPDATE checkouts SET url=?,expires=? WHERE account=?', created.url, Math.min(checkout.expires, (created.expires_at || checkout.expires / 1000) * 1000), account.id);
            return { url: created.url };
          });
        }); respond(200, result); return true;
      }
      if (pathname === '/api/billing/portal') {
        if (accountRole(s.account) === 'admin') fail(409, 'Votre accès propriétaire Pro 100 est gratuit et ne nécessite aucun abonnement Stripe.');
        const account = store.get('SELECT * FROM accounts WHERE id=?', s.account);
        if (!gateway.enabled) fail(503, 'Paiement indisponible : configuration requise.');
        if (!account.customer) fail(409, 'Aucun compte de facturation.');
        const portal = await gateway.portal(account.customer, req.headers.origin);
        if (typeof portal.url !== 'string' || new URL(portal.url).origin !== 'https://billing.stripe.com') fail(502, 'Portail indisponible.');
        respond(200, { url: portal.url }); return true;
      }
      fail(404, 'Route introuvable.');
    } catch (error) { respond(error.status || 500, { error: error.status ? error.message : 'Erreur interne.' }); }
    return true;
  };
  return { handle, verifyPublisher, isLicenseActive, consumeRelay, consumeShare, getPublisherPolicy, getPublisherBranding, authorizeDownload, close: () => { challenges.clear(); grants.clear(); downloadTickets.clear(); store.close(); } };
}
