/**
 * Web dashboard server for the SIP proxy.
 *
 * Serves a bundled web component frontend (ts_web/) and pushes
 * live updates over WebSocket.  The frontend is built with
 * @design.estate/dees-element web components and bundled with esbuild.
 */

import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { WebSocketServer, WebSocket } from 'ws';
import { maskConfig, type IAppConfig } from './config.ts';
import type { FaxBoxManager } from './faxbox.ts';
import type { FaxJobManager } from './faxjobs.ts';
import { handleWebRtcSignaling } from './webrtcbridge.ts';
import type { VoiceboxManager } from './voicebox.ts';

interface IHandleRequestContext {
  getStatus: () => unknown;
  getConfig: () => IAppConfig;
  updateConfig: (updatesArg: any) => Promise<IAppConfig>;
  log: (msg: string) => void;
  onStartCall: (number: string, deviceId?: string, providerId?: string) => Promise<{ id: string } | null>;
  onHangupCall: (callId: string) => boolean;
  faxBoxManager?: FaxBoxManager;
  faxJobManager?: FaxJobManager;
  voiceboxManager?: VoiceboxManager;
}

interface IWebUiOptions extends IHandleRequestContext {
  port: number;
  onWebRtcOffer?: (sessionId: string, sdp: string, ws: WebSocket) => Promise<void>;
  onWebRtcIce?: (sessionId: string, candidate: unknown) => Promise<void>;
  onWebRtcClose?: (sessionId: string) => Promise<void>;
  onWebRtcAccept?: (callId: string, sessionId: string) => void;
}

interface IWebRtcSocketMessage {
  type?: string;
  sessionId?: string;
  callId?: string;
  sdp?: string;
  candidate?: unknown;
  userAgent?: string;
  _remoteIp?: string | null;
  [key: string]: unknown;
}

// ---------------------------------------------------------------------------
// WebSocket broadcast
// ---------------------------------------------------------------------------

const wsClients = new Set<WebSocket>();

function timestamp(): string {
  return new Date().toISOString().replace('T', ' ').slice(0, 19);
}

export function broadcastWs(type: string, data: unknown): void {
  if (!wsClients.size) return;
  const msg = JSON.stringify({ type, data, ts: timestamp() });
  for (const ws of wsClients) {
    try {
      if (ws.readyState === WebSocket.OPEN) ws.send(msg);
    } catch {
      wsClients.delete(ws);
    }
  }
}

// ---------------------------------------------------------------------------
// Static file cache (loaded at startup)
// ---------------------------------------------------------------------------

const staticFiles = new Map<string, { data: Buffer; contentType: string }>();

function loadStaticFiles(): void {
  const cwd = process.cwd();

  // Load index.html
  const htmlPath = path.join(cwd, 'html', 'index.html');
  try {
    const data = fs.readFileSync(htmlPath);
    staticFiles.set('/', { data, contentType: 'text/html; charset=utf-8' });
    staticFiles.set('/index.html', { data, contentType: 'text/html; charset=utf-8' });
  } catch {
    const fallback = Buffer.from(`<!DOCTYPE html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>SIP Router</title>
<style>body{margin:0;background:#0f172a;color:#e2e8f0;font-family:system-ui}sipproxy-app{display:block;position:fixed;inset:0;overflow:hidden}</style>
</head><body><sipproxy-app></sipproxy-app><script type="module" src="/bundle.js"></script></body></html>`);
    staticFiles.set('/', { data: fallback, contentType: 'text/html; charset=utf-8' });
    staticFiles.set('/index.html', { data: fallback, contentType: 'text/html; charset=utf-8' });
  }

  // Load bundle.js
  const bundlePath = path.join(cwd, 'dist_ts_web', 'bundle.js');
  try {
    const data = fs.readFileSync(bundlePath);
    staticFiles.set('/bundle.js', { data, contentType: 'application/javascript; charset=utf-8' });
  } catch { /* Bundle not found */ }
}

// ---------------------------------------------------------------------------
// HTTP request handler
// ---------------------------------------------------------------------------

async function handleRequest(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  context: IHandleRequestContext,
): Promise<void> {
  const { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager } = context;
  const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
  const method = req.method || 'GET';

  // API: status.
  if (url.pathname === '/api/status') {
    return sendJson(res, getStatus());
  }

  // API: start call (with optional providerId).
  if (url.pathname === '/api/call' && method === 'POST') {
    try {
      const body = await readJsonBody(req);
      const number = body?.number;
      if (!number || typeof number !== 'string') {
        return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
      }
      const call = await onStartCall(number, body?.deviceId, body?.providerId);
      if (call) return sendJson(res, { ok: true, callId: call.id });
      return sendJson(res, { ok: false, error: 'call origination failed — provider not registered or no ports available' }, 503);
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: hangup.
  if (url.pathname === '/api/hangup' && method === 'POST') {
    try {
      const body = await readJsonBody(req);
      const callId = body?.callId;
      if (!callId || typeof callId !== 'string') {
        return sendJson(res, { ok: false, error: 'missing "callId" field' }, 400);
      }
      return sendJson(res, { ok: onHangupCall(callId) });
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: send outbound fax.
  if (url.pathname === '/api/fax' && method === 'POST') {
    try {
      const body = await readJsonBody(req);
      const number = body?.number;
      let filePath = body?.filePath;
      if (!number || typeof number !== 'string') {
        return sendJson(res, { ok: false, error: 'missing "number" field' }, 400);
      }
      if (!filePath || typeof filePath !== 'string') {
        return sendJson(res, { ok: false, error: 'missing "filePath" field' }, 400);
      }
      if (faxBoxManager) {
        filePath = await faxBoxManager.prepareOutboundFaxFile(filePath);
      }
      const { sendFax } = await import('./proxybridge.ts');
      const callId = await sendFax(number, filePath, body?.providerId);
      if (callId) {
        log(`[dashboard] fax started: ${callId} -> ${number} file=${filePath}`);
        return sendJson(res, { ok: true, callId });
      }
      return sendJson(res, { ok: false, error: 'fax origination failed' }, 503);
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: fax jobs.
  if (url.pathname === '/api/fax/jobs' && method === 'GET' && faxJobManager) {
    return sendJson(res, { ok: true, jobs: faxJobManager.getJobs() });
  }

  // API: fax inbox - list messages.
  const faxListMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)$/);
  if (faxListMatch && method === 'GET' && faxBoxManager) {
    const boxId = faxListMatch[1];
    return sendJson(res, { ok: true, messages: faxBoxManager.getMessages(boxId) });
  }

  // API: fax inbox - stream TIFF.
  const faxFileMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)\/file$/);
  if (faxFileMatch && method === 'GET' && faxBoxManager) {
    const [, boxId, msgId] = faxFileMatch;
    const filePath = await faxBoxManager.getMessageFilePath(boxId, msgId);
    if (!filePath) return sendJson(res, { ok: false, error: 'not found' }, 404);
    const stat = fs.statSync(filePath);
    res.writeHead(200, {
      'Content-Type': 'image/tiff',
      'Content-Length': stat.size.toString(),
      'Accept-Ranges': 'bytes',
    });
    fs.createReadStream(filePath).pipe(res);
    return;
  }

  // API: fax inbox - delete message.
  const faxDeleteMatch = url.pathname.match(/^\/api\/fax\/inboxes\/([^/]+)\/([^/]+)$/);
  if (faxDeleteMatch && method === 'DELETE' && faxBoxManager) {
    const [, boxId, msgId] = faxDeleteMatch;
    return sendJson(res, { ok: await faxBoxManager.deleteMessage(boxId, msgId) });
  }

  // API: add a SIP device to a call (mid-call INVITE to desk phone).
  if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addleg') && method === 'POST') {
    try {
      const callId = url.pathname.split('/')[3];
      const body = await readJsonBody(req);
      if (!body?.deviceId) return sendJson(res, { ok: false, error: 'missing deviceId' }, 400);
      const { addDeviceLeg } = await import('./proxybridge.ts');
      const legId = await addDeviceLeg(callId, body.deviceId);
      if (legId) {
        return sendJson(res, { ok: true, legId });
      } else {
        return sendJson(res, { ok: false, error: 'device not registered or call not found' }, 404);
      }
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: add external participant (dial out) to existing call.
  if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/addexternal') && method === 'POST') {
    try {
      const callId = url.pathname.split('/')[3];
      const body = await readJsonBody(req);
      if (!body?.number) return sendJson(res, { ok: false, error: 'missing number' }, 400);
      const { addLeg: addLegFn } = await import('./proxybridge.ts');
      const legId = await addLegFn(callId, body.number, body.providerId);
      return sendJson(res, { ok: !!legId, legId });
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: remove leg from call.
  if (url.pathname.startsWith('/api/call/') && url.pathname.endsWith('/removeleg') && method === 'POST') {
    try {
      const callId = url.pathname.split('/')[3];
      const body = await readJsonBody(req);
      if (!body?.legId) return sendJson(res, { ok: false, error: 'missing legId' }, 400);
      const { removeLeg: removeLegFn } = await import('./proxybridge.ts');
      const ok = await removeLegFn(callId, body.legId);
      return sendJson(res, { ok });
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: transfer leg (not yet implemented).
  if (url.pathname === '/api/transfer' && method === 'POST') {
    try {
      const body = await readJsonBody(req);
      if (!body?.sourceCallId || !body?.legId || !body?.targetCallId) {
        return sendJson(res, { ok: false, error: 'missing sourceCallId, legId, or targetCallId' }, 400);
      }
      return sendJson(res, { ok: false, error: 'not yet implemented' }, 501);
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: get config (sans passwords).
  if (url.pathname === '/api/config' && method === 'GET') {
    try {
      return sendJson(res, maskConfig(getConfig()));
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 500);
    }
  }

  // API: update config.
  if (url.pathname === '/api/config' && method === 'POST') {
    try {
      const updates = await readJsonBody(req);
      const config = await updateConfig(updates);
      log('[config] updated smartdata config');
      return sendJson(res, { ok: true, config: maskConfig(config) });
    } catch (e: any) {
      return sendJson(res, { ok: false, error: e.message }, 400);
    }
  }

  // API: voicemail - list messages.
  const vmListMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)$/);
  if (vmListMatch && method === 'GET' && voiceboxManager) {
    const boxId = vmListMatch[1];
    return sendJson(res, { ok: true, messages: voiceboxManager.getMessages(boxId) });
  }

  // API: voicemail - unheard count.
  const vmUnheardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/unheard$/);
  if (vmUnheardMatch && method === 'GET' && voiceboxManager) {
    const boxId = vmUnheardMatch[1];
    return sendJson(res, { ok: true, count: voiceboxManager.getUnheardCount(boxId) });
  }

  // API: voicemail - stream audio.
  const vmAudioMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/audio$/);
  if (vmAudioMatch && method === 'GET' && voiceboxManager) {
    const [, boxId, msgId] = vmAudioMatch;
    const audioPath = await voiceboxManager.getMessageAudioPath(boxId, msgId);
    if (!audioPath) return sendJson(res, { ok: false, error: 'not found' }, 404);
    const stat = fs.statSync(audioPath);
    res.writeHead(200, {
      'Content-Type': 'audio/wav',
      'Content-Length': stat.size.toString(),
      'Accept-Ranges': 'bytes',
    });
    fs.createReadStream(audioPath).pipe(res);
    return;
  }

  // API: voicemail - mark as heard.
  const vmHeardMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)\/heard$/);
  if (vmHeardMatch && method === 'POST' && voiceboxManager) {
    const [, boxId, msgId] = vmHeardMatch;
    return sendJson(res, { ok: await voiceboxManager.markHeard(boxId, msgId) });
  }

  // API: voicemail - delete message.
  const vmDeleteMatch = url.pathname.match(/^\/api\/voicemail\/([^/]+)\/([^/]+)$/);
  if (vmDeleteMatch && method === 'DELETE' && voiceboxManager) {
    const [, boxId, msgId] = vmDeleteMatch;
    return sendJson(res, { ok: await voiceboxManager.deleteMessage(boxId, msgId) });
  }

  // Static files.
  const file = staticFiles.get(url.pathname);
  if (file) {
    res.writeHead(200, {
      'Content-Type': file.contentType,
      'Cache-Control': 'no-cache, no-store, must-revalidate',
      'Pragma': 'no-cache',
      'Expires': '0',
    });
    res.end(file.data);
    return;
  }

  // SPA fallback.
  const index = staticFiles.get('/');
  if (index) {
    res.writeHead(200, { 'Content-Type': index.contentType });
    res.end(index.data);
    return;
  }

  res.writeHead(404);
  res.end('Not Found');
}

// ---------------------------------------------------------------------------
// HTTP + WebSocket server (Node.js)
// ---------------------------------------------------------------------------

export function initWebUi(
  options: IWebUiOptions,
): void {
  const {
    port,
    getStatus,
    getConfig,
    updateConfig,
    log,
    onStartCall,
    onHangupCall,
    faxBoxManager,
    faxJobManager,
    voiceboxManager,
    onWebRtcOffer,
    onWebRtcIce,
    onWebRtcClose,
    onWebRtcAccept,
  } = options;

  loadStaticFiles();

  // Serve HTTPS if cert exists, otherwise fall back to HTTP.
  const certPath = path.join(process.cwd(), '.nogit', 'cert.pem');
  const keyPath = path.join(process.cwd(), '.nogit', 'key.pem');
  let useTls = false;
  let server: http.Server | https.Server;

  try {
    const cert = fs.readFileSync(certPath, 'utf8');
    const key = fs.readFileSync(keyPath, 'utf8');
    server = https.createServer({ cert, key }, (req, res) =>
      handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
    );
    useTls = true;
  } catch {
    server = http.createServer((req, res) =>
      handleRequest(req, res, { getStatus, getConfig, updateConfig, log, onStartCall, onHangupCall, faxBoxManager, faxJobManager, voiceboxManager }).catch(() => { res.writeHead(500); res.end(); }),
    );
  }

  // WebSocket server on the same port.
  const wss = new WebSocketServer({ server, path: '/ws' });

  wss.on('connection', (socket, req) => {
    const remoteIp = req.socket.remoteAddress || null;
    wsClients.add(socket);
    socket.send(JSON.stringify({ type: 'status', data: getStatus(), ts: timestamp() }));

    socket.on('message', (raw) => {
      try {
        const msg = JSON.parse(raw.toString()) as IWebRtcSocketMessage;
        if (msg.type === 'webrtc-offer' && msg.sessionId) {
          // Forward to Rust proxy-engine for WebRTC handling.
          if (onWebRtcOffer && typeof msg.sdp === 'string') {
            log(`[webrtc-ws] offer msg keys: ${Object.keys(msg).join(',')}, sdp type: ${typeof msg.sdp}, sdp len: ${msg.sdp?.length || 0}`);
            onWebRtcOffer(msg.sessionId, msg.sdp, socket).catch((e: any) =>
              log(`[webrtc] offer error: ${e.message}`));
          }
        } else if (msg.type === 'webrtc-ice' && msg.sessionId) {
          if (onWebRtcIce) {
            onWebRtcIce(msg.sessionId, msg.candidate).catch(() => {});
          }
        } else if (msg.type === 'webrtc-hangup' && msg.sessionId) {
          if (onWebRtcClose) {
            onWebRtcClose(msg.sessionId).catch(() => {});
          }
        } else if (msg.type === 'webrtc-accept' && msg.callId) {
          log(`[webrtc] accept: call=${msg.callId} session=${msg.sessionId || 'none'}`);
          if (onWebRtcAccept && msg.sessionId) {
            onWebRtcAccept(msg.callId, msg.sessionId);
          }
        } else if (msg.type?.startsWith('webrtc-')) {
          msg._remoteIp = remoteIp;
          if (msg.type) {
            handleWebRtcSignaling(socket, msg as IWebRtcSocketMessage & { type: string });
          }
        }
      } catch { /* ignore */ }
    });

    socket.on('close', () => wsClients.delete(socket));
    socket.on('error', () => wsClients.delete(socket));
  });

  server.listen(port, '0.0.0.0', () => {
    log(`web ui listening on ${useTls ? 'https' : 'http'}://0.0.0.0:${port}`);
  });

  setInterval(() => broadcastWs('status', getStatus()), 1000);
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function sendJson(res: http.ServerResponse, data: unknown, status = 200): void {
  res.writeHead(status, { 'Content-Type': 'application/json' });
  res.end(JSON.stringify(data));
}

function readJsonBody(req: http.IncomingMessage): Promise<any> {
  return new Promise((resolve, reject) => {
    const chunks: Buffer[] = [];
    req.on('data', (c) => chunks.push(c));
    req.on('end', () => {
      try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
      catch (e) { reject(e); }
    });
    req.on('error', reject);
  });
}
