// Copyright (c) 2026 defconxt. All rights reserved.
// Licensed under AGPL-3.0 — see LICENSE file for details.

/**
 * NERF Signal Bot — receives messages via signal-cli-rest-api WebSocket,
 * dispatches through the gateway, and replies.
 */

import { WebSocket } from 'ws';
import { sanitizeContent } from '../memory/sanitizer.js';

// ---------------------------------------------------------------------------
// Prefix mapping
// ---------------------------------------------------------------------------

const PREFIX_RE = /^(RED|BLUE|PURPLE|PRIVACY|RECON|INCIDENT|ARCHITECT):\s*/i;

export function mapPrefix(text) {
  const m = text.match(PREFIX_RE);
  if (m) {
    const mode = m[1].toUpperCase();
    const rest = text.slice(m[0].length);
    return `[MODE: ${mode}] ${rest}`;
  }
  return text;
}

// ---------------------------------------------------------------------------
// Markdown stripping
// ---------------------------------------------------------------------------

const MD_PATTERNS = [
  [/```[^\n]*\n([\s\S]*?)```/g, '$1'],
  [/^#{1,6}\s+/gm, ''],
  [/\*{2}([^*\n]+)\*{2}/g, '$1'],
  [/\*([^*\n]+)\*/g, '$1'],
  [/`([^`\n]+)`/g, '$1'],
  [/\[([^\]]+)\]\([^)]+\)/g, '$1'],
  [/^(\s*)[*-]\s+/gm, '$1• '],
];

export function stripMarkdown(text) {
  for (const [pattern, replacement] of MD_PATTERNS) {
    text = text.replace(pattern, replacement);
  }
  return text.trim();
}

// ---------------------------------------------------------------------------
// Session manager
// ---------------------------------------------------------------------------

export class SessionManager {
  constructor({ timeoutSeconds = 3600, maxPairs = 20 } = {}) {
    this._sessions = new Map();
    this._timeout = timeoutSeconds * 1000;
    this._maxPairs = maxPairs;
  }

  get(sender) {
    this.cleanup();
    const session = this._sessions.get(sender);
    return session ? [...session.history] : [];
  }

  update(sender, userMessage, assistantMessage) {
    if (!this._sessions.has(sender)) {
      this._sessions.set(sender, { history: [], lastActive: Date.now() });
    }
    const session = this._sessions.get(sender);
    session.history.push({ role: 'user', content: userMessage });
    session.history.push({ role: 'assistant', content: assistantMessage });
    session.lastActive = Date.now();

    const maxItems = this._maxPairs * 2;
    if (session.history.length > maxItems) {
      session.history = session.history.slice(-maxItems);
    }
  }

  reset(sender) { this._sessions.delete(sender); }

  cleanup() {
    const now = Date.now();
    for (const [sender, session] of this._sessions) {
      if (now - session.lastActive > this._timeout) {
        this._sessions.delete(sender);
      }
    }
  }

  get size() { return this._sessions.size; }
}

// ---------------------------------------------------------------------------
// Signal REST API client
// ---------------------------------------------------------------------------

async function sendMessage(signalService, phoneNumber, recipient, message) {
  const resp = await fetch(`${signalService}/v2/send`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      message,
      number: phoneNumber,
      recipients: [recipient],
    }),
  });
  if (!resp.ok) {
    throw new Error(`Send failed: ${resp.status} ${await resp.text()}`);
  }
}

async function sendReaction(signalService, phoneNumber, recipient, emoji, targetTimestamp) {
  try {
    await fetch(`${signalService}/v1/reactions/${phoneNumber}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        reaction: emoji,
        recipient,
        timestamp: targetTimestamp,
      }),
    });
  } catch { /* best-effort */ }
}

// ---------------------------------------------------------------------------
// Bot runner
// ---------------------------------------------------------------------------

/**
 * Start the NERF Signal bot.
 *
 * Connects to signal-cli-rest-api WebSocket, listens for messages,
 * dispatches through the gateway, and replies.
 *
 * @param {object} config
 * @param {string} config.signalService - signal-cli-rest-api URL
 * @param {string} config.phoneNumber - bot's phone number
 * @param {string[]} [config.whitelist] - allowed sender numbers
 * @param {number} [config.sessionTimeout] - session timeout in seconds
 */
export async function runBot(config) {
  const { signalService, phoneNumber, whitelist = [], sessionTimeout = 3600 } = config;
  if (!whitelist.length) {
    throw new Error(
      'Signal bot requires a whitelist of allowed sender numbers.\n' +
      'Set whitelist in config or pass --whitelist=+1234567890,+0987654321'
    );
  }
  const sessions = new SessionManager({ timeoutSeconds: sessionTimeout });
  const whitelistSet = new Set(whitelist);

  const wsUrl = `${signalService.replace('http', 'ws')}/v1/receive/${phoneNumber}`;

  let reconnectDelay = 1000;
  const maxDelay = 60000;

  // Per-sender message queue — prevents concurrent processing from clobbering responses
  const senderQueues = new Map();

  function enqueue(sender, fn) {
    if (!senderQueues.has(sender)) senderQueues.set(sender, Promise.resolve());
    const chain = senderQueues.get(sender).then(fn).catch((err) => {
      process.stderr.write(`  ✖ Queue error for ${sender}: ${err.message}\n`);
    });
    senderQueues.set(sender, chain);
  }

  function connect() {
    const ws = new WebSocket(wsUrl);

    ws.on('open', () => {
      process.stderr.write(`  ● Connected to ${wsUrl}\n`);
      reconnectDelay = 1000;
    });

    ws.on('message', async (data) => {
      try {
        const envelope = JSON.parse(data.toString());
        // Extract sender for queue routing
        const sender = envelope.envelope?.sourceUuid || envelope.envelope?.source || 'unknown';
        enqueue(sender, () => handleEnvelope(envelope, config, sessions, whitelistSet));
      } catch (err) {
        process.stderr.write(`  ✖ Message error: ${err.message}\n`);
      }
    });

    ws.on('close', () => {
      process.stderr.write(`  ⚠ WebSocket closed. Reconnecting in ${reconnectDelay / 1000}s...\n`);
      setTimeout(connect, reconnectDelay);
      reconnectDelay = Math.min(reconnectDelay * 2, maxDelay);
    });

    ws.on('error', (err) => {
      process.stderr.write(`  ✖ WebSocket error: ${err.message}\n`);
    });
  }

  connect();
}

async function handleEnvelope(envelope, config, sessions, whitelistSet) {
  const msg = envelope.envelope?.dataMessage;
  if (!msg || !msg.message) return;

  // Identify sender — prefer phone number, fall back to UUID
  const senderNumber = envelope.envelope?.sourceNumber || null;
  const senderUuid = envelope.envelope?.sourceUuid || envelope.envelope?.source || null;
  const sender = senderNumber || senderUuid;
  if (!sender) return;

  const text = msg.message.trim();
  if (!text) return;

  // Whitelist check — match against phone number OR UUID
  if (whitelistSet.size > 0) {
    const allowed = whitelistSet.has(sender)
      || (senderNumber && whitelistSet.has(senderNumber))
      || (senderUuid && whitelistSet.has(senderUuid));
    if (!allowed) {
      process.stderr.write(`  ⊘ Blocked message from ${senderNumber || senderUuid} (not in whitelist)\n`);
      return;
    }
  }

  const timestamp = msg.timestamp;

  // /reset command
  if (text === '/reset') {
    sessions.reset(sender);
    await sendMessage(config.signalService, config.phoneNumber, sender, 'Session cleared.');
    return;
  }

  // /help command
  if (text === '/help') {
    await sendMessage(config.signalService, config.phoneNumber, sender,
      'NERF Signal Bot Commands:\n\n' +
      '/scan <target> — Run vulnerability scan\n' +
      '/review <file> — Security code review\n' +
      '/osint <target> — OSINT investigation\n' +
      '/compliance <framework> — Compliance check\n' +
      '/search <query> — Search security data\n' +
      '/memory <query> — Search past findings\n' +
      '/status — System status\n' +
      '/reset — Clear session\n\n' +
      'Or send any security question directly.'
    );
    return;
  }

  // React with brain emoji to acknowledge
  await sendReaction(config.signalService, config.phoneNumber, sender, '🧠', timestamp);

  let response;
  const senderName = envelope.envelope?.sourceName || sender;
  process.stderr.write(`  ◉ ${senderName}: ${text.slice(0, 80)}${text.length > 80 ? '...' : ''}\n`);

  try {
    // Check for NERF CLI commands (slash-prefixed)
    const cmdMatch = text.match(/^\/(scan|review|osint|compliance|search|memory|status|score|domains|skills)\s*(.*)/i);

    if (cmdMatch) {
      // ── CLI command dispatch ──────────────────────────────────────
      response = await dispatchCommand(cmdMatch[1].toLowerCase(), cmdMatch[2].trim());
    } else {
      // ── Free-text query via Gateway (Anthropic SDK) ──────────────
      // Sanitize for prompt injection before dispatching to the LLM.
      const { sanitized: sanitizedText } = sanitizeContent(text);
      const mapped = mapPrefix(sanitizedText);
      const history = sessions.get(sender);

      const { Gateway } = await import('../gateway/gateway.js');
      const gw = new Gateway({ rag: true });
      response = await gw.send(mapped, history);

      // Update session history (only for conversational messages)
      sessions.update(sender, mapped, response);
    }
  } catch (err) {
    response = `[ERROR] ${err.message}`;
  }

  // Send reply (strip markdown, chunk if needed)
  const reply = stripMarkdown(response);
  if (reply.length > 5500) {
    const chunks = reply.match(/.{1,5500}/gs) || [reply];
    for (const chunk of chunks) {
      await sendMessage(config.signalService, config.phoneNumber, sender, chunk);
    }
  } else {
    await sendMessage(config.signalService, config.phoneNumber, sender, reply);
  }
}

// ---------------------------------------------------------------------------
// CLI command dispatch — wraps nerf gateway command handlers
// ---------------------------------------------------------------------------

/**
 * Dispatch a NERF CLI command and return the text result.
 *
 * @param {string} command - Command name (scan, review, osint, etc.)
 * @param {string} args - Raw argument string from the message
 * @returns {Promise<string>}
 */
async function dispatchCommand(command, args) {
  const handlers = await import('../gateway/commands.js');

  // Map command names to handler functions + argument assembly
  const dispatch = {
    scan: () => handlers.handleScan({ _: [], target: args || undefined }),
    review: () => handlers.handleReview({ _: [args].filter(Boolean), target: args || undefined }),
    osint: () => handlers.handleOsint({ _: [], target: args || undefined }),
    compliance: () => handlers.handleCompliance({ _: [], framework: args || 'nist-800-53' }),
    search: () => handlers.handleSearch({ _: [], query: args }),
    memory: () => handlers.handleSearch({ _: [], query: args }),  // memory search = search
    status: () => handlers.handleStatus({}),
    score: () => handlers.handleScore({}),
    domains: () => handlers.handleDomains({ _: [] }),
    skills: () => handlers.handleSkills({ _: [] }),
  };

  const handler = dispatch[command];
  if (!handler) return `Unknown command: ${command}`;

  const result = await handler();

  // Handler returns { output: string } or { error: true, message: string }
  // or structured data (search results, status, etc.)
  if (result?.error) return `[ERROR] ${result.message || 'Command failed'}`;
  if (typeof result === 'string') return result;

  // If output is a JSON string, parse and format it
  if (result?.output) {
    try {
      const parsed = JSON.parse(result.output);
      return formatResultForSignal(command, parsed);
    } catch {
      // Not JSON — return as-is (already human-readable)
      return result.output;
    }
  }

  // Format structured results for Signal readability
  return formatResultForSignal(command, result);
}

/**
 * Format structured command results as readable Signal messages.
 */
function formatResultForSignal(command, result) {
  if (!result || typeof result !== 'object') return String(result);

  // ── Scan results ────────────────────────────────────────────────
  if (result.target && result.findings !== undefined) {
    const lines = [`🔍 Scan: ${result.target}`, `Status: ${result.status || 'complete'}`, `Duration: ${result.duration_seconds?.toFixed(0) || '?'}s`, ''];
    if (!result.findings?.length) {
      lines.push('✅ No vulnerabilities found.');
    } else {
      lines.push(`⚠️ ${result.findings_count || result.findings.length} finding(s):\n`);
      for (const f of result.findings.slice(0, 10)) {
        const sev = f.severity || f.info?.severity || '?';
        const name = f.name || f.info?.name || f.template_id || 'Unknown';
        const url = f.matched_at || f.matched || f.host || '';
        lines.push(`• [${sev.toUpperCase()}] ${name}`);
        if (url) lines.push(`  ${url}`);
      }
      if (result.findings.length > 10) lines.push(`\n... and ${result.findings.length - 10} more`);
    }
    return lines.join('\n');
  }

  // ── OSINT results ───────────────────────────────────────────────
  if (result.dns || result.whois || result.target_type) {
    const lines = [`🔎 OSINT: ${result.target || command}`];
    if (result.dns?.a) lines.push(`DNS A: ${result.dns.a.join(', ')}`);
    if (result.dns?.mx) lines.push(`MX: ${result.dns.mx.join(', ')}`);
    if (result.dns?.ns) lines.push(`NS: ${result.dns.ns.join(', ')}`);
    if (result.whois?.registrar) lines.push(`Registrar: ${result.whois.registrar}`);
    if (result.whois?.creation_date) lines.push(`Created: ${result.whois.creation_date}`);
    if (result.technologies?.length) lines.push(`Tech: ${result.technologies.join(', ')}`);
    if (result.certificates?.length) lines.push(`Certs: ${result.certificates.length} found`);
    return lines.join('\n');
  }

  // ── Compliance results ──────────────────────────────────────────
  if (result.framework || result.controls) {
    const lines = [`📋 Compliance: ${result.framework || command}`];
    if (result.summary) {
      const s = result.summary;
      lines.push(`Total controls: ${s.total || '?'}`);
      if (s.covered !== undefined) lines.push(`Covered: ${s.covered}`);
      if (s.gaps !== undefined) lines.push(`Gaps: ${s.gaps}`);
      if (s.coverage_pct !== undefined) lines.push(`Coverage: ${s.coverage_pct}%`);
    }
    if (result.gaps?.length) {
      lines.push('\nTop gaps:');
      for (const g of result.gaps.slice(0, 5)) {
        lines.push(`• ${g.control || g.id}: ${g.description || g.title || ''}`);
      }
    }
    return lines.join('\n');
  }

  // ── Review results ──────────────────────────────────────────────
  if (result.findings && result.target && result.layers) {
    const lines = [`🔒 Review: ${result.target}`, `Findings: ${result.findings.length}`];
    for (const f of result.findings.slice(0, 10)) {
      lines.push(`• [${(f.severity || '?').toUpperCase()}] ${f.title || f.description || ''}`);
      if (f.location) lines.push(`  ${f.location}`);
    }
    return lines.join('\n');
  }

  // ── Search / memory results ─────────────────────────────────────
  if (result.results && Array.isArray(result.results)) {
    if (result.results.length === 0) return `No results found for "${result.query || command}".`;
    const lines = [`Found ${result.count || result.results.length} result(s):\n`];
    for (const r of result.results.slice(0, 5)) {
      const content = r.content || r.text || r.description || '';
      const source = r.source || r.source_file || '';
      lines.push(`${source ? '📄 ' + source + '\n' : ''}${content}`);
      lines.push('───');
    }
    return lines.join('\n').trim();
  }

  // ── Status-like objects ─────────────────────────────────────────
  if (result.status || result.version) {
    const lines = [];
    for (const [k, v] of Object.entries(result)) {
      if (typeof v === 'object' && v !== null) {
        lines.push(`${k}:`);
        for (const [k2, v2] of Object.entries(v)) {
          lines.push(`  ${k2}: ${v2}`);
        }
      } else {
        lines.push(`${k}: ${v}`);
      }
    }
    return lines.join('\n');
  }

  // ── Fallback — readable key:value ───────────────────────────────
  try {
    const lines = [];
    for (const [k, v] of Object.entries(result)) {
      if (Array.isArray(v)) {
        lines.push(`${k}: ${v.length} items`);
      } else if (typeof v === 'object' && v !== null) {
        lines.push(`${k}: ${JSON.stringify(v).slice(0, 200)}`);
      } else {
        lines.push(`${k}: ${v}`);
      }
    }
    return lines.join('\n') || '(empty result)';
  } catch {
    return JSON.stringify(result, null, 2).slice(0, 5000);
  }
}
