import http from 'node:http';
import {BRANDS,brandForHost,brandAsset} from './brands.js';
import { readFile, realpath, stat } from 'node:fs/promises';
import { createReadStream } from 'node:fs';
import { isIP } from 'node:net';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID, createHash, timingSafeEqual } from 'node:crypto';
import { WebSocketServer, WebSocket } from 'ws';
import { PublisherAccess, parsePublisherKeys } from './access.js';

const here = path.dirname(fileURLToPath(import.meta.url));
const validId = x => typeof x === 'string' && /^(?:[a-f0-9]{32}|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i.test(x);
export function validateIceServers(servers) {
  if (!Array.isArray(servers) || servers.length > 8) throw new Error('Invalid ICE servers');
  return servers.map(s => {
    const urls = Array.isArray(s.urls) ? s.urls : [s.urls];
    if (!urls.length || urls.length > 8 || urls.some(u => typeof u !== 'string' || !/^(stun|stuns|turn|turns):[^\s/@?#]+(?::\d+)?(?:\?transport=(udp|tcp))?$/.test(u))) throw new Error('Invalid ICE URL');
    return { urls, ...(typeof s.username === 'string' ? { username: s.username } : {}), ...(typeof s.credential === 'string' ? { credential: s.credential } : {}) };
  });
}

export async function startServer({ port = 0, host = '127.0.0.1', iceServers = [], publicDir = path.join(here, '../public'), srcDir = path.join(here, '../src'), publicOrigin = process.env.PUBLIC_ORIGIN, additionalPublicOrigins = [], commerce = null, trial = null, allowLegacyInvites = false, maxShares = commerce ? 500 : 100, maxSessions = commerce ? 200 : 100, sweepMs = 1000, heartbeatMs = 30000, maxConnectionsPerIp = 12, inactiveClientMs = 300000, publisherKeys = [], requirePublisherKey = false, turnSecret, turnHost, turnPort = 3478, turnTlsPort = null, publicSite = false, localBrand = 'transferwing', downloadsDir, downloadNames = ['Relio-macOS.zip', 'Relio-Windows.zip', 'Relyo-macOS.zip', 'Relyo-Windows.zip'] } = {}) {
  if (!Object.hasOwn(BRANDS,localBrand)) throw new Error('Unknown local brand');
  iceServers = validateIceServers(iceServers);
  if (!Number.isInteger(maxConnectionsPerIp) || maxConnectionsPerIp < 1 || !Number.isSafeInteger(inactiveClientMs) || inactiveClientMs < 1) throw new Error('Invalid connection limits');
  if (publicOrigin && new URL(publicOrigin).origin !== publicOrigin) throw new Error('PUBLIC_ORIGIN must be an origin');
  if (commerce && ['handle', 'verifyPublisher', 'isLicenseActive', 'consumeRelay', 'authorizeDownload'].some(name => typeof commerce[name] !== 'function')) throw new Error('Invalid commerce integration');
  if (requirePublisherKey && !publisherKeys.length && !commerce) throw new Error('Publisher invitation keys required');
  if (!Array.isArray(additionalPublicOrigins) || additionalPublicOrigins.some(value => { try { return new URL(value).origin !== value || !value.startsWith('https://'); } catch { return true; } })) throw new Error('Invalid public origins');
  const access = new PublisherAccess({ keys: publisherKeys, turnSecret, turnHost, turnPort, turnTlsPort });
  const requiresPublisherKey = Boolean(commerce || trial) || publisherKeys.length > 0;
  if (turnHost && !requiresPublisherKey) throw new Error('TURN requires publisher invitation keys');
  if (!Array.isArray(downloadNames) || downloadNames.some(name => typeof name !== 'string' || !/^[A-Za-z0-9._-]+\.zip$/.test(name))) throw new Error('Invalid download names');
  const publicIce = publicSite || commerce ? iceServers.flatMap(s => { const urls = s.urls.filter(u => /^stuns?:/.test(u)); return urls.length ? [{ urls }] : []; }) : iceServers;
  const licenseActive = identity => { try { return Boolean(commerce?.isLicenseActive(identity)); } catch { return false; } };
  const publisherIdentity = token => { try { return commerce?.verifyPublisher(token) || null; } catch { return null; } };
  const ipRates = new Map();
  const loopback = ip => ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ip);
  const clientIp = req => {
    const forwarded = req.headers['x-real-ip'];
    return loopback(req.socket.remoteAddress) && typeof forwarded === 'string' && isIP(forwarded) ? forwarded : req.socket.remoteAddress;
  };
  const joinAllowed = ip => {
    const now = Date.now();
    for (const [key, value] of ipRates) if (now - value.start >= 60000) ipRates.delete(key);
    let bucket = ipRates.get(ip);
    if (!bucket) { if (ipRates.size >= 64) return false; bucket = { start: now, count: 0 }; ipRates.set(ip, bucket); }
    return ++bucket.count <= 60;
  };
  const clients = new Map(), shares = new Map(), sessions = new Map();
  const globalRelayBudget = { tokens: 8 * 1024 * 1024, at: Date.now() };
  const globalRelayPackets = { tokens: 2048, at: Date.now() };
  const consumeRelay = (bucket, bytes, rate) => { const now=Date.now();bucket.tokens=Math.min(rate,bucket.tokens+Math.max(0,now-bucket.at)*rate/1000);bucket.at=now;if(bytes>bucket.tokens)return false;bucket.tokens-=bytes;return true; };
  const iceExpiry = list => {
    const deadlines = list.filter(s => s.username && s.urls.some(url => /^turns?:/.test(url))).map(s => Number(s.username.split(':')[0]) * 1000).filter(Number.isSafeInteger);
    return deadlines.length ? Math.min(...deadlines) : null;
  };
  let boundPort;
  const send = (c, value) => {
    if (c?.ws.readyState !== WebSocket.OPEN) return;
    const message = JSON.stringify(value);
    if (c.ws.bufferedAmount + Buffer.byteLength(message) > 1024 * 1024) {
      // Terminate immediately: a close frame could itself remain behind the stalled queue.
      c.ws.terminate(); return;
    }
    c.ws.send(message);
  };
  const error = (c, req, code, message) => send(c, { type: 'error', ...(typeof req?.requestId === 'string' ? { requestId: req.requestId.slice(0, 100) } : {}), ...(validId(req?.sessionId) ? { sessionId: req.sessionId } : {}), code, message });
  const endSession = (id, reason) => {
    const s = sessions.get(id); if (!s) return;
    clearTimeout(s.relayTimer);
    sessions.delete(id); shares.get(s.shareId)?.sessions.delete(id);
    for (const id of [s.owner, s.receiver]) send(clients.get(id), { type: 'peer-left', sessionId: s.id, shareId: s.shareId, peerId: id === s.owner ? s.receiver : s.owner, reason });
  };
  const cancelRelay = s => { clearTimeout(s.relayTimer);s.relayPending=false;s.relayReady=null;for(const id of [s.owner,s.receiver])send(clients.get(id),{type:'relay-cancelled',sessionId:s.id}); };
  const removeShare = (id, reason) => {
    const s = shares.get(id); if (!s) return;
    for (const sessionId of [...s.sessions]) endSession(sessionId, reason);
    shares.delete(id);
  };
  let lastLicenseSweep = -Infinity;
  const expire = () => {
    const now = Date.now();
    const checkLicenses = now - lastLicenseSweep >= Math.min(1000, sweepMs);
    if (checkLicenses) lastLicenseSweep = now;
    for (const [id, s] of shares) {
      if (checkLicenses && s.licenseIdentity && !licenseActive(s.licenseIdentity)) removeShare(id, 'license-inactive');
      else if (s.expiresAt <= now) removeShare(id, 'expired');
    }
    const active = new Set([...shares.values()].map(s => s.owner));
    for (const s of sessions.values()) { active.add(s.owner); active.add(s.receiver); }
    for (const c of clients.values()) {
      if (checkLicenses && c.licenseIdentity && !licenseActive(c.licenseIdentity)) { c.licenseIdentity = null; c.ws.terminate(); continue; }
      if (c.licenseIdentity) active.add(c.id);
      if (active.has(c.id)) c.lastAuthorizedAt = now;
      else if (now - c.lastAuthorizedAt >= inactiveClientMs) c.ws.terminate();
    }
  };
  const trusted = req => {
    const allowed = new Set([`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`]);
    if (publicOrigin) allowed.add(new URL(publicOrigin).host);
    for (const value of additionalPublicOrigins) allowed.add(new URL(value).host);
    if (!allowed.has(req.headers.host)) return false;
    if (req.headers.origin) {
      const origins = new Set([`http://127.0.0.1:${boundPort}`, `http://localhost:${boundPort}`, `http://[::1]:${boundPort}`]);
      if (publicOrigin) origins.add(publicOrigin);
      for (const value of additionalPublicOrigins) origins.add(value);
      let localOrigin = false;
      try { const origin = new URL(req.headers.origin); localOrigin = origin.origin === req.headers.origin && origin.protocol === 'http:' && origin.hostname === '127.0.0.1'; } catch {}
      if (!origins.has(req.headers.origin) && !localOrigin) return false;
    }
    return true;
  };
  const server = http.createServer(async (req, res) => {
    res.setHeader('X-Content-Type-Options', 'nosniff');
    res.setHeader('Referrer-Policy', 'no-referrer');
    res.setHeader('Cache-Control', 'no-store');
    res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self' https://t.mailtingpot.com wss://t.mailtingpot.com https://transferwing.com wss://transferwing.com https://relyo.online wss://relyo.online; object-src 'none'; base-uri 'none'; frame-ancestors 'none'");
    const respond = (status, value) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(value)); };
    if (!trusted(req)) return respond(403, { error: 'Forbidden origin or host' });
    const brand = brandForHost(req.headers.host, localBrand);
    if (req.headers.origin) { res.setHeader('Access-Control-Allow-Origin', req.headers.origin); res.setHeader('Vary', 'Origin'); }
    try {
      if (trial && req.url === '/api/trial' && req.method === 'POST') {
        let raw=''; for await (const chunk of req) { raw+=chunk; if(raw.length>4096)return respond(413,{error:'Requête trop grande.'}); }
        try { const body=JSON.parse(raw||'{}'); return respond(200,trial.issue(body.token,clientIp(req))); }
        catch { return respond(429,{error:'Essai indisponible. Réutilisez votre navigateur habituel ou réessayez demain.'}); }
      }
      if (commerce && await commerce.handle(req, res)) return;
      if (req.method !== 'GET' && req.method !== 'HEAD') return respond(405, { error: 'Method not allowed' });
      const url = new URL(req.url, 'http://local');
      if (url.pathname === '/src/brand.js') { res.writeHead(200, {'Content-Type':'text/javascript; charset=utf-8'}); return res.end(req.method === 'HEAD' ? undefined : `export const brand = ${JSON.stringify(brand)};`); }
      if (url.pathname === '/api/brand') return respond(200, brand);
      if (url.pathname === '/api/health') return respond(200, { ok: true });
      if (url.pathname === '/api/config') return respond(200, { iceServers: publicIce, requiresPublisherKey, commerceEnabled: Boolean(commerce), trialEnabled: Boolean(trial), publicSite, publicOrigin: publicOrigin || null });
      if (url.pathname.startsWith('/api/shares/')) {
        expire(); const id = url.pathname.slice(12), s = shares.get(id);
        return s ? respond(200, { id, online: true, expiresAt: s.expiresAt, branding: s.licenseIdentity ? commerce?.getPublisherBranding?.(s.licenseIdentity) ?? null : null }) : respond(404, { error: 'Share unavailable' });
      }
      if (url.pathname.startsWith('/downloads/')) {
        const requestedName = decodeURIComponent(url.pathname.slice('/downloads/'.length));
        const name = ({'TransferWing-macOS.zip':'Relio-macOS.zip','TransferWing-Windows.zip':'Relio-Windows.zip'})[requestedName] || requestedName;
        if (!downloadsDir || !downloadNames.includes(name)) return respond(404, { error: 'Not found' });
        const root = await realpath(downloadsDir), file = await realpath(path.resolve(root, name));
        if (!file.startsWith(root + path.sep)) return respond(404, { error: 'Not found' });
        const info = await stat(file);
        if (!info.isFile()) return respond(404, { error: 'Not found' });
        if (commerce && !trial && !commerce.authorizeDownload(req, name.replace(/^Relyo-/, 'Relio-'))) return respond(403, { error: 'Active subscription or download authorization required' });
        const downloadName = ({'Relio-macOS.zip':'TransferWing-macOS.zip','Relio-Windows.zip':'TransferWing-Windows.zip'})[name] || requestedName;
        res.writeHead(200, { 'Content-Type': 'application/zip', 'Content-Length': info.size, 'Content-Disposition': `attachment; filename="${downloadName}"` });
        if (req.method === 'HEAD') return res.end();
        const stream = createReadStream(file);
        stream.on('error', () => res.destroy()); res.on('close', () => stream.destroy()); stream.pipe(res); return;
      }
      const isSrc = url.pathname.startsWith('/src/');
      const root = await realpath(isSrc ? srcDir : publicDir);
      const name = decodeURIComponent(isSrc ? url.pathname.slice(5) : url.pathname === '/' ? publicSite ? 'landing.html' : 'index.html' : ['/app', '/app/'].includes(url.pathname) ? 'index.html' : ['/account', '/account/'].includes(url.pathname) ? 'account.html' : ['/securite', '/securite/'].includes(url.pathname) ? 'security.html' : ['/activate', '/activate/'].includes(url.pathname) ? 'activate.html' : url.pathname.slice(1));
      if (name.split('/').some(p => p.startsWith('.')) || name.includes('\\')) return respond(404, { error: 'Not found' });
      const file = await realpath(path.resolve(root, name));
      if (!file.startsWith(root + path.sep)) return respond(404, { error: 'Not found' });
      const mime = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8' }[path.extname(file)];
      if (!mime) return respond(404, { error: 'Not found' });
      const data = brandAsset(await readFile(file), mime, brand); res.writeHead(200, { 'Content-Type': mime }); res.end(req.method === 'HEAD' ? undefined : data);
    } catch { respond(404, { error: 'Not found' }); }
  });
  const wss = new WebSocketServer({ noServer: true, maxPayload: 96 * 1024, perMessageDeflate: false });
  server.on('upgrade', (req, socket, head) => {
    if (req.url !== '/ws' || !trusted(req) || clients.size >= (commerce ? 500 : 200) || [...clients.values()].filter(c => c.ip === clientIp(req) && !c.licenseIdentity).length >= maxConnectionsPerIp) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
    wss.handleUpgrade(req, socket, head, ws => wss.emit('connection', ws, req));
  });
  wss.on('connection', (ws, req) => {
    const ip = clientIp(req);
    const c = { id: randomUUID(), ws, ip, lastAuthorizedAt: Date.now(), alive: true, window: Date.now(), messages: 0, joins: 0 };
    clients.set(c.id, c); send(c, { type: 'welcome', clientId: c.id });
    ws.on('pong', () => { c.alive = true; });
    ws.on('error', () => {});
    ws.on('message', (raw, binary) => {
      if (ws.readyState !== WebSocket.OPEN) return;
      if (Date.now() - c.window >= 60000) { c.window = Date.now(); c.messages = 0; c.joins = 0; }
      let m;
      try {
        if (binary) throw new Error(); m = JSON.parse(raw.toString());
        if (!m || typeof m !== 'object' || Array.isArray(m)) throw new Error();
      } catch { if(++c.messages>600)ws.close(1008,'Message rate limit');else error(c, null, 'BAD_MESSAGE', 'Invalid JSON message'); return; }
      const relaySession=m.type==='relay-packet'?sessions.get(m.sessionId):null;
      const validRelay=relaySession?.transport==='websocket'&&[relaySession.owner,relaySession.receiver].includes(c.id);
      if(!validRelay&&++c.messages>600){ws.close(1008,'Message rate limit');return;}
      if (m.type === 'join' && (++c.joins > 30 || !joinAllowed(c.ip))) { ws.close(1008, 'Join rate limit'); return; }
      expire();
      if (ws.readyState !== WebSocket.OPEN) return;
      if (m.type === 'authenticate') {
        const identity = publisherIdentity(m.publisherToken);
        if (!identity) { const beta=(!commerce || allowLegacyInvites) && access.authenticate(m.publisherToken); if(beta)return send(c,{type:'authenticated',requestId:m.requestId}); return error(c, m, 'UNAUTHORIZED', 'Clé d’activation ou licence valide nécessaire.'); }
        if (c.licenseIdentity && (c.licenseIdentity.licenseId !== identity.licenseId || c.licenseIdentity.deviceId !== identity.deviceId || c.licenseIdentity.generation !== identity.generation)) return error(c, m, 'FORBIDDEN', 'Reconnect to change device authorization');
        if ([...clients.values()].some(other => other.id !== c.id && other.licenseIdentity?.licenseId === identity.licenseId && other.ws.readyState === WebSocket.OPEN)) return error(c, m, 'LICENSE_IN_USE', 'This device license already has an active connection');
        c.licenseIdentity = Object.freeze({ ...identity });
        c.lastAuthorizedAt = Date.now();
        send(c, { type: 'authenticated', policy: commerce.getPublisherPolicy?.(identity), requestId: m.requestId, licenseId: identity.licenseId, deviceId: identity.deviceId, expiresAt: identity.expiresAt });
      } else if (m.type === 'create') {
        const identity = publisherIdentity(m.publisherToken);
        const trialIdentity = !identity && !m.publisherToken && trial?.get(m.trialToken);
        let publisher = identity;
        if (identity && (!c.licenseIdentity || c.licenseIdentity.licenseId !== identity.licenseId || c.licenseIdentity.deviceId !== identity.deviceId || c.licenseIdentity.generation !== identity.generation)) return error(c, m, 'UNAUTHORIZED', 'Authenticate this device connection first');
        if (!identity && (!commerce || allowLegacyInvites)) publisher = requiresPublisherKey ? access.authenticate(m.publisherToken) : null;
        if (requiresPublisherKey && !publisher && !trialIdentity) return error(c, m, 'UNAUTHORIZED', 'Publisher authorization required');
        if (typeof m.joinTokenHash !== 'string' || !/^[a-f0-9]{64}$/.test(m.joinTokenHash)) return error(c, m, 'INVALID_SHARE', 'Invalid join capability');
        if (!validId(m.shareId) || !Number.isSafeInteger(m.expiresAt) || m.expiresAt <= Date.now() || m.expiresAt > Date.now() + 7 * 86400000 || !Number.isInteger(m.maxPeers) || m.maxPeers < 1 || m.maxPeers > 10) return error(c, m, 'INVALID_SHARE', 'Invalid share settings');
        // Only the declared largest size reaches the broker. Names and file content remain encrypted.
        // A modified P2P client can lie about this declaration; it is not content inspection.
        const fileLimit = identity ? commerce.getPublisherPolicy?.(identity)?.maxFileBytes : trialIdentity ? 1e9 : null;
        if (fileLimit != null && (!Number.isSafeInteger(m.largestFileBytes) || m.largestFileBytes < 0)) return error(c, m, 'FILE_SIZE_REQUIRED', 'Mettez à jour votre application pour créer ce partage.');
        if (fileLimit != null && m.largestFileBytes > fileLimit) return error(c, m, 'FILE_TOO_LARGE', `Un fichier dépasse la limite de votre offre (${fileLimit / 1e9} Go par fichier). Choisissez un fichier plus petit ou une offre supérieure.`);
        if (shares.has(m.shareId)) return error(c, m, 'CONFLICT', 'Share already exists');
        if (shares.size >= maxShares || [...shares.values()].filter(s => s.owner === c.id || (publisher && s.publisher?.id === publisher.id)).length >= 10) return error(c, m, 'CAPACITY', 'Share limit reached');
        if(trialIdentity && !trial.consume(m.trialToken))return error(c,m,'TRIAL_EXHAUSTED','Vos trois transferts gratuits ont été utilisés. Activez une licence pour continuer.');
        let shareQuota = null;
        if (identity && commerce.consumeShare) {
          try { shareQuota = commerce.consumeShare(identity); }
          catch { return error(c, m, 'UNAVAILABLE', 'Quota temporairement indisponible. Réessayez.'); }
          if (!shareQuota.allowed) return error(c, m, shareQuota.free ? 'FREE_QUOTA' : 'UNAUTHORIZED', shareQuota.free ? 'Quota Free atteint : vos 3 transferts de ce mois sont utilisés. Choisissez une offre payante dans Mon compte ou attendez le prochain mois.' : 'Licence inactive.');
        }
        shares.set(m.shareId, { trialId:trialIdentity?.id, owner: c.id, publisher, licenseIdentity: identity ? Object.freeze({ ...identity }) : null, joinTokenHash: Buffer.from(m.joinTokenHash, 'hex'), expiresAt: trialIdentity ? Math.min(m.expiresAt,Date.now()+86400000) : Math.min(m.expiresAt, shareQuota?.maxExpiresAt ?? m.expiresAt), maxPeers: m.maxPeers, sessions: new Set() });
        send(c, { type: 'created', requestId: m.requestId, shareId: m.shareId, ...(trialIdentity ? {trialRemaining:3-trial.get(m.trialToken).used,expiresAt:shares.get(m.shareId).expiresAt} : {}), ...(shareQuota?.free ? {freeRemaining:shareQuota.remaining,expiresAt:shares.get(m.shareId).expiresAt} : {}) });
      } else if (m.type === 'join') {
        const s = shares.get(m.shareId);
        const tokenValid = typeof m.joinToken === 'string' && /^[A-Za-z0-9_-]{43}$/.test(m.joinToken);
        const hash = createHash('sha256').update(tokenValid ? m.joinToken : '').digest();
        const matches = timingSafeEqual(hash, s?.joinTokenHash || Buffer.alloc(32));
        if (!s || !tokenValid || !matches) return error(c, m, 'UNAVAILABLE', 'Share unavailable');
        if (s.owner === c.id) return error(c, m, 'FORBIDDEN', 'Cannot join own share');
        const ownerSessions = [...sessions.values()].filter(session => session.owner === s.owner || (s.publisher && session.publisherId === s.publisher.id)).length;
        if (s.sessions.size >= s.maxPeers || sessions.size >= (turnHost && !commerce ? Math.min(maxSessions, 20) : maxSessions) || ownerSessions >= 20 || [...sessions.values()].filter(s => s.receiver === c.id).length >= 10) return error(c, m, 'CAPACITY', 'Session limit reached');
        const id = randomUUID(); sessions.set(id, { id, shareId: m.shareId, owner: s.owner, receiver: c.id, publisherId: s.publisher?.id, lastIceRenewal: 0 }); s.sessions.add(id);
        const receiverIce = s.licenseIdentity || s.trialId ? publicIce : s.publisher ? access.getIceServers(s.publisher, id, { role: 'receiver' }) : publicIce;
        const ownerIce = s.licenseIdentity || s.trialId ? publicIce : s.publisher ? access.getIceServers(s.publisher, id, { role: 'publisher' }) : publicIce;
        send(c, { type: 'joined', requestId: m.requestId, shareId: m.shareId, peerId: s.owner, sessionId: id, iceServers: receiverIce, iceExpiresAt: turnHost && !s.licenseIdentity ? iceExpiry(receiverIce) : null });
        send(clients.get(s.owner), { type: 'peer', shareId: m.shareId, peerId: c.id, sessionId: id, iceServers: ownerIce, iceExpiresAt: turnHost && !s.licenseIdentity ? iceExpiry(ownerIce) : null });
      } else if (m.type === 'direct-ready' || m.type === 'relay-start' || m.type === 'relay-ready' || m.type === 'relay-packet') {
        const s=sessions.get(m.sessionId);
        if(!s||![s.owner,s.receiver].includes(c.id))return error(c,m,'FORBIDDEN','Session unavailable');
        if(m.type==='direct-ready'){
          if(s.transport==='websocket')return error(c,m,'FORBIDDEN','Relay already committed');
          s.directReady=true;if(s.relayPending)cancelRelay(s);
        }else if(m.type==='relay-start'){
          if(s.owner!==c.id)return error(c,m,'FORBIDDEN','Owner required');
          if(s.directReady)return error(c,m,'ALREADY_CONNECTED','Direct channel already connected');
          if(s.transport==='websocket'||s.relayPending)return error(c,m,'ALREADY_CONNECTED','Transport already selected');
          if([...sessions.values()].filter(session=>session.relayPending||session.transport==='websocket').length>=6)return error(c,m,'CAPACITY','WebSocket relay capacity reached');
          s.relayPending=true;s.relayReady=new Set();
          s.relayTimer=setTimeout(()=>{if(sessions.has(s.id)&&s.relayPending)cancelRelay(s);},10000);
          for(const clientId of [s.receiver,s.owner])send(clients.get(clientId),{type:'relay-preparing',sessionId:s.id,...(clientId===c.id?{requestId:m.requestId}:{})});
        }else if(m.type==='relay-ready'){
          if(!s.relayPending||s.directReady)return;
          s.relayReady.add(c.id);if(s.relayReady.size===2){clearTimeout(s.relayTimer);s.relayPending=false;s.relayReady=null;s.transport='websocket';s.relayBudget={tokens:2*1024*1024,at:Date.now()};s.relayPackets={tokens:256,at:Date.now()};for(const clientId of [s.owner,s.receiver])send(clients.get(clientId),{type:'relay-started',sessionId:s.id});}
        }else{
          if(s.transport!=='websocket')return error(c,m,'FORBIDDEN','Relay not started');
          if(typeof m.payload!=='string'||m.payload.length>24576||!/^[A-Za-z0-9_-]+$/.test(m.payload)){ws.close(1008,'Invalid relay packet');return;}
          const bytes=Buffer.from(m.payload,'base64url');
          if(bytes.length<29||bytes.toString('base64url')!==m.payload){ws.close(1008,'Invalid relay packet');return;}
          if(!consumeRelay(s.relayPackets,1,256)||!consumeRelay(globalRelayPackets,1,2048)||!consumeRelay(s.relayBudget,bytes.length,2*1024*1024)||!consumeRelay(globalRelayBudget,bytes.length,8*1024*1024)){ws.close(1008,'Relay traffic limit');return;}
          const forwarded = { type: 'relay-packet', sessionId: s.id, payload: m.payload };
          const target = clients.get(s.owner === c.id ? s.receiver : s.owner);
          if (!target || target.ws.readyState !== WebSocket.OPEN) return endSession(s.id, 'peer-offline');
          const wireBytes = Buffer.byteLength(JSON.stringify(forwarded));
          if (target.ws.bufferedAmount + wireBytes > 1024 * 1024) { target.ws.terminate(); return endSession(s.id, 'slow-recipient'); }
          const identity = shares.get(s.shareId)?.licenseIdentity;
          if (identity) {
            let allowed = false;
            try { allowed = commerce.consumeRelay(identity, wireBytes); } catch {}
            if (!allowed) {
              for (const peerId of [s.owner, s.receiver]) error(clients.get(peerId), { sessionId: s.id }, 'RELAY_QUOTA', 'Monthly relay quota exhausted or license inactive');
              return endSession(s.id, 'relay-quota');
            }
          }
          send(target, forwarded);
        }
      } else if (m.type === 'renew-ice') {
        const session = sessions.get(m.sessionId), share = shares.get(session?.shareId);
        if (!session || !share || ![session.owner, session.receiver].includes(c.id)) return error(c, m, 'FORBIDDEN', 'Session unavailable');
        if (!turnHost || !share.publisher || share.licenseIdentity || session.transport==='websocket') return error(c, m, 'UNAVAILABLE', 'Relay renewal unavailable');
        if (Date.now() - session.lastIceRenewal < 60000) return error(c, m, 'RATE_LIMIT', 'Relay renewal too frequent');
        session.lastIceRenewal = Date.now();
        const generation = randomUUID();
        // Receiver configuration precedes any new offer routed by the owner on this socket.
        for (const [clientId, role] of [[session.receiver, 'receiver'], [session.owner, 'publisher']]) {
          const renewed = access.getIceServers(share.publisher, session.id, { role });
          send(clients.get(clientId), { type: 'ice-updated', ...(clientId === c.id ? { requestId: m.requestId } : {}), sessionId: session.id, generation, iceServers: renewed, iceExpiresAt: iceExpiry(renewed) });
        }
      } else if (m.type === 'signal' || m.type === 'leave') {
        const s = sessions.get(m.sessionId);
        if (!s && m.type === 'leave') return;
        if (!s || (s.owner !== c.id && s.receiver !== c.id)) return error(c, m, 'FORBIDDEN', 'Session unavailable');
        if (m.type === 'leave') endSession(s.id, 'left');
        else send(clients.get(s.owner === c.id ? s.receiver : s.owner), { type: 'signal', sessionId: s.id, peerId: c.id, payload: m.payload });
      } else if (m.type === 'revoke') {
        if (shares.get(m.shareId)?.owner !== c.id) return error(c, m, 'FORBIDDEN', 'Share unavailable');
        removeShare(m.shareId, 'revoked'); send(c, { type: 'revoked', requestId: m.requestId, shareId: m.shareId });
      } else error(c, m, 'UNKNOWN_TYPE', 'Unknown message type');
    });
    ws.on('close', () => {
      for (const [id, s] of shares) if (s.owner === c.id) removeShare(id, 'owner-offline');
      for (const [id, s] of sessions) if (s.receiver === c.id) endSession(id, 'peer-offline');
      clients.delete(c.id);
    });
  });
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, host, resolve); });
  boundPort = server.address().port;
  const sweep = setInterval(expire, sweepMs); sweep.unref();
  const heartbeat = setInterval(() => { for (const c of clients.values()) { if (!c.alive) c.ws.terminate(); else { c.alive = false; c.ws.ping(); } } }, heartbeatMs); heartbeat.unref();
  return { server, wss, port: boundPort, url: `http://${host.includes(':') ? `[${host}]` : host}:${boundPort}`, close: async () => {
    clearInterval(sweep); clearInterval(heartbeat);
    for (const c of clients.values()) c.ws.terminate();
    await new Promise(resolve => wss.close(resolve));
    await new Promise(resolve => server.close(resolve));
  } };
}

if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  const trial = process.env.TRIAL_DB ? new (await import('./trial.js')).TrialAccess(process.env.TRIAL_DB) : null;
  const commerce = process.env.COMMERCE_DB ? await (await import('./commerce/service.js')).createCommerce({
    dbPath: process.env.COMMERCE_DB, origin: process.env.PUBLIC_ORIGIN,
    additionalOrigins: process.env.PUBLIC_ORIGINS_JSON ? JSON.parse(process.env.PUBLIC_ORIGINS_JSON) : [],
    stripe: { secretKey: process.env.STRIPE_SECRET_KEY, webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
      prices: { solo: process.env.STRIPE_PRICE_SOLO, team: process.env.STRIPE_PRICE_TEAM, business: process.env.STRIPE_PRICE_BUSINESS } },
  }) : null;
  const allowLegacyInvites = process.env.ALLOW_LEGACY_INVITES === '1';
  if (process.env.PUBLIC_SITE === '1' && !commerce && !allowLegacyInvites) throw new Error('Public service requires commerce or explicit legacy beta mode');
  const app = await startServer({ additionalPublicOrigins: process.env.PUBLIC_ORIGINS_JSON ? JSON.parse(process.env.PUBLIC_ORIGINS_JSON) : [], commerce, trial, allowLegacyInvites, port: Number(process.env.PORT || 4317), iceServers: process.env.ICE_SERVERS_JSON ? JSON.parse(process.env.ICE_SERVERS_JSON) : [], publisherKeys: parsePublisherKeys(), requirePublisherKey: process.env.REQUIRE_PUBLISHER_KEY === '1', turnSecret: process.env.TURN_SECRET, turnHost: process.env.TURN_HOST, turnPort: Number(process.env.TURN_PORT || 3478), turnTlsPort: process.env.TURN_TLS_PORT ? Number(process.env.TURN_TLS_PORT) : null, publicSite: process.env.PUBLIC_SITE === '1', downloadsDir: process.env.DOWNLOADS_DIR });
  console.log(`TransferWing: ${app.url}`);
  for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, async () => { await app.close(); commerce?.close(); trial?.close(); process.exit(0); });
}
