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

/**
 * NERF OpenAI-compatible completion proxy.
 *
 * Intercepts chat completion requests, injects relevant security skills
 * as system context, forwards to the upstream LLM, and logs interactions.
 *
 * SECURITY (SEC-002): Two enforcement layers protect against unauthorized use:
 *
 *   1. Localhost binding (default): The proxy binds to 127.0.0.1 by default.
 *      At startup, if the configured host is not a loopback address and
 *      requireAuth is false, the proxy throws rather than start insecurely.
 *
 *   2. Token auth (opt-in): Set requireAuth: true in ProxyConfig (or set
 *      NERF_PROXY_AUTH=1) to require a Bearer token on all POST routes.
 *      The token is validated via HMAC-SHA256 against NERF_PROXY_SECRET.
 *      Generate a token with ProxyConfig.generateToken(secret).
 */

import { createServer as httpCreateServer, request as httpRequest } from 'node:http';
import { createHmac, timingSafeEqual, randomBytes } from 'node:crypto';
import { URL } from 'node:url';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path';

// ---------------------------------------------------------------------------
// Proxy config
// ---------------------------------------------------------------------------

/** Loopback addresses that are considered safe for unauthenticated binding. */
const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);

export class ProxyConfig {
  constructor(opts = {}) {
    this.upstreamUrl = opts.upstreamUrl || process.env.NERF_UPSTREAM_URL || 'http://localhost:11434/v1';
    this.host = opts.host || process.env.NERF_PROXY_HOST || '127.0.0.1';
    this.port = opts.port ?? parseInt(process.env.NERF_PROXY_PORT || '8100', 10);
    this.injectSkills = opts.injectSkills ?? true;
    this.maxSkills = opts.maxSkills ?? 3;
    this.logInteractions = opts.logInteractions ?? true;
    this.modelOverride = opts.modelOverride || '';
    this.corsOrigins = opts.corsOrigins ?? [
      'http://localhost:8100', 'http://127.0.0.1:8100',
      'http://localhost:3000', 'http://127.0.0.1:3000',
    ];

    // requireAuth: when true, POST routes require a Bearer token.
    // Defaults to true when NERF_PROXY_AUTH=1 or when binding to a
    // non-loopback address (enforced at startup in createOpenAIProxy).
    const envAuth = process.env.NERF_PROXY_AUTH === '1';
    this.requireAuth = opts.requireAuth ?? envAuth ?? false;

    // Secret used for HMAC token validation. Read from env; fall back to
    // a per-process random secret (tokens generated in this process only).
    this._secret = process.env.NERF_PROXY_SECRET || randomBytes(32).toString('hex');
  }

  /**
   * Generate a Bearer token valid for this proxy instance.
   * @param {string} [clientId='default']
   * @returns {string}
   */
  generateToken(clientId = 'default') {
    const nonce = randomBytes(16).toString('hex');
    const payload = `${clientId}:${nonce}`;
    const sig = createHmac('sha256', this._secret).update(payload).digest('hex');
    return `${payload}:${sig}`;
  }

  /**
   * Validate a Bearer token. Returns clientId on success, null on failure.
   * @param {string} token
   * @returns {string|null}
   */
  validateToken(token) {
    if (!token || typeof token !== 'string') return null;
    const parts = token.split(':');
    if (parts.length !== 3) return null;
    const [clientId, nonce, sig] = parts;
    const expected = createHmac('sha256', this._secret)
      .update(`${clientId}:${nonce}`)
      .digest('hex');
    if (sig.length !== expected.length) return null;
    if (!timingSafeEqual(Buffer.from(sig, 'utf8'), Buffer.from(expected, 'utf8'))) return null;
    return clientId;
  }
}

// ---------------------------------------------------------------------------
// Skill injection
// ---------------------------------------------------------------------------

/** Security keyword → domain mapping for skill matching. */
const KEYWORD_DOMAINS = {
  xss: ['web-application-security', 'browser-security'],
  'sql injection': ['web-application-security', 'database-security'],
  sqli: ['web-application-security', 'database-security'],
  'privilege escalation': ['linux-security', 'windows-security'],
  privesc: ['linux-security', 'windows-security'],
  'active directory': ['active-directory-security'],
  kerberos: ['active-directory-security'],
  malware: ['malware-analysis'],
  'reverse engineer': ['reverse-engineering'],
  incident: ['incident-response'],
  forensic: ['digital-forensics', 'cloud-forensics'],
  osint: ['osint-techniques'],
  reconnaissance: ['osint-techniques', 'network-security'],
  container: ['container-security'],
  docker: ['container-security'],
  kubernetes: ['container-security'],
  cloud: ['cloud-security'],
  aws: ['cloud-security'],
  azure: ['cloud-security'],
  api: ['api-security'],
  cryptograph: ['cryptography'],
  encrypt: ['cryptography'],
  phishing: ['social-engineering'],
  exploit: ['exploit-development', 'binary-exploitation'],
  'buffer overflow': ['binary-exploitation'],
  rop: ['binary-exploitation'],
  c2: ['c2-frameworks'],
  'command and control': ['c2-frameworks'],
  siem: ['log-analysis'],
  detection: ['log-analysis', 'threat-intelligence'],
  sigma: ['log-analysis'],
  'threat intel': ['threat-intelligence'],
  'zero trust': ['zero-trust'],
  compliance: ['compliance-auditing'],
  gdpr: ['privacy-engineering'],
  privacy: ['privacy-engineering'],
  network: ['network-security'],
  firewall: ['network-security'],
  wireless: ['wireless-security'],
  wifi: ['wireless-security'],
  mobile: ['mobile-security'],
  android: ['mobile-security'],
  ios: ['mobile-security'],
  'supply chain': ['supply-chain-security'],
  password: ['password-cracking'],
  'brute force': ['password-cracking'],
  'bug bounty': ['bug-bounty'],
};

/**
 * Find matching NERF skills for the conversation.
 * @param {object[]} messages
 * @param {number} [maxSkills=3]
 * @returns {string}
 */
export function findMatchingSkills(messages, maxSkills = 3) {
  const skillsRoot = resolve(__dirname_workaround(), '..', '..', 'skills');
  if (!existsSync(skillsRoot)) return '';

  const userText = messages
    .filter((m) => m.role === 'user' && typeof m.content === 'string')
    .map((m) => m.content)
    .join(' ')
    .toLowerCase();

  if (!userText) return '';

  const matchedDomains = [];
  for (const [keyword, domains] of Object.entries(KEYWORD_DOMAINS)) {
    if (userText.includes(keyword)) {
      for (const d of domains) {
        if (!matchedDomains.includes(d)) matchedDomains.push(d);
      }
    }
  }

  if (!matchedDomains.length) return '';

  const injections = [];
  for (const domain of matchedDomains.slice(0, maxSkills)) {
    const domainDir = join(skillsRoot, domain);
    if (!existsSync(domainDir)) continue;

    const skillMd = join(domainDir, 'SKILL.md');
    if (existsSync(skillMd)) {
      try {
        const content = readFileSync(skillMd, 'utf8');
        const lines = content.split('\n');
        const descLines = [];
        let inBody = false;
        for (const line of lines) {
          if (inBody) {
            if (line.trim() === '' && descLines.length) break;
            if (line.trim().startsWith('#')) {
              if (descLines.length) break;
              continue;
            }
            descLines.push(line.trim());
          } else if (line.trim().startsWith('# ') || line.trim() === '---') {
            inBody = true;
          }
        }
        if (descLines.length) {
          injections.push(`[NERF/${domain}] ${descLines.slice(0, 3).join(' ')}`);
        }
      } catch { /* skip */ }
    }

    const techDir = join(domainDir, 'techniques');
    if (existsSync(techDir)) {
      try {
        const techniques = readdirSync(techDir)
          .filter((t) => { try { return statSync(join(techDir, t)).isDirectory(); } catch { return false; } })
          .sort()
          .slice(0, 8);
        if (techniques.length) {
          injections.push(`  Available techniques: ${techniques.map((t) => t.replace(/-/g, ' ')).join(', ')}`);
        }
      } catch { /* skip */ }
    }
  }

  if (!injections.length) return '';

  return `\n\n--- NERF Security Context ---\n${injections.join('\n')}\n--- End NERF Context ---\n`;
}

/** Resolve dirname equivalent for ESM. */
function __dirname_workaround() {
  try {
    return new URL('.', import.meta.url).pathname;
  } catch {
    return process.cwd();
  }
}

// ---------------------------------------------------------------------------
// Proxy server
// ---------------------------------------------------------------------------

/**
 * Create an OpenAI-compatible proxy server.
 * @param {ProxyConfig} [config]
 * @returns {{ server: import('node:http').Server, config: ProxyConfig, start: () => Promise<number>, stop: () => Promise<void> }}
 */
export function createOpenAIProxy(config) {
  config = config instanceof ProxyConfig ? config : new ProxyConfig(config);

  // SEC-002: Refuse to start if bound to a non-loopback address without auth.
  // An unauthenticated proxy on a network interface allows any local process
  // (or any host on the same network) to access the upstream LLM unchecked.
  if (!LOOPBACK_HOSTS.has(config.host) && !config.requireAuth) {
    throw new Error(
      `OpenAI proxy refuses to start: host '${config.host}' is not a loopback ` +
      'address and requireAuth is false. Either bind to 127.0.0.1 or set ' +
      'requireAuth: true and configure NERF_PROXY_SECRET.',
    );
  }

  function setCors(res, reqOrigin) {
    const allowed = config.corsOrigins || [];
    const origin = allowed.includes(reqOrigin) ? reqOrigin : '';
    if (origin) {
      res.setHeader('Access-Control-Allow-Origin', origin);
      res.setHeader('Vary', 'Origin');
    }
    res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
    res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
  }

  function respond(res, status, data, reqOrigin) {
    const body = JSON.stringify(data);
    setCors(res, reqOrigin);
    res.writeHead(status, { 'Content-Type': 'application/json' });
    res.end(body);
  }

  function readBody(req) {
    return new Promise((resolve) => {
      const chunks = [];
      let size = 0;
      req.on('data', (chunk) => {
        size += chunk.length;
        if (size > 10 * 1024 * 1024) { resolve(null); req.destroy(); return; }
        chunks.push(chunk);
      });
      req.on('end', () => {
        if (size === 0) return resolve(null);
        try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
        catch { resolve(null); }
      });
      req.on('error', () => resolve(null));
    });
  }

  function forwardPost(path, body, headers) {
    return new Promise((resolve, reject) => {
      const upstream = new URL(config.upstreamUrl.replace(/\/+$/, '') + path);
      const data = JSON.stringify(body);
      const reqHeaders = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) };
      if (headers?.authorization) reqHeaders.Authorization = headers.authorization;

      const req = httpRequest(
        { hostname: upstream.hostname, port: upstream.port, path: upstream.pathname, method: 'POST', headers: reqHeaders, timeout: 120000 },
        (res) => {
          const chunks = [];
          res.on('data', (c) => chunks.push(c));
          res.on('end', () => {
            try { resolve({ status: res.statusCode, body: JSON.parse(Buffer.concat(chunks).toString()) }); }
            catch { resolve({ status: res.statusCode, body: { error: 'invalid upstream response' } }); }
          });
        },
      );
      req.on('error', reject);
      req.on('timeout', () => { req.destroy(); reject(new Error('upstream timeout')); });
      req.write(data);
      req.end();
    });
  }

  function forwardStream(path, body, headers, res, reqOrigin) {
    return new Promise((resolve, reject) => {
      const upstream = new URL(config.upstreamUrl.replace(/\/+$/, '') + path);
      const data = JSON.stringify(body);
      const reqHeaders = { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) };
      if (headers?.authorization) reqHeaders.Authorization = headers.authorization;

      const req = httpRequest(
        { hostname: upstream.hostname, port: upstream.port, path: upstream.pathname, method: 'POST', headers: reqHeaders, timeout: 120000 },
        (upstreamRes) => {
          setCors(res, reqOrigin);
          res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' });
          upstreamRes.on('data', (chunk) => { res.write(chunk); });
          upstreamRes.on('end', () => { res.end(); resolve(); });
        },
      );
      req.on('error', (err) => { respond(res, 502, { error: 'upstream stream failed' }, reqOrigin); reject(err); });
      req.write(data);
      req.end();
    });
  }

  let _leaderboard = null;
  async function logInteraction(messages, result) {
    try {
      if (!_leaderboard) {
        const { SkillLeaderboard } = await import('../../execution-engine/leaderboard.js');
        _leaderboard = new SkillLeaderboard();
      }
      const skillPath = messages?.[0]?.role === 'system' ? 'openai-proxy' : 'unknown';
      const success = result && !result.error;
      _leaderboard.recordInvocation(skillPath, success, success ? 1.0 : 0.0, {
        source: 'openai-proxy',
        messageCount: messages?.length || 0,
      });
    } catch {
      // Leaderboard logging is best-effort; don't break the proxy
    }
  }

  async function handleRequest(req, res) {
    const reqOrigin = req.headers.origin || '';
    setCors(res, reqOrigin);

    if (req.method === 'OPTIONS') {
      res.writeHead(204);
      res.end();
      return;
    }

    const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
    const path = url.pathname;

    // GET routes
    if (req.method === 'GET') {
      if (path === '/health' || path === '/v1/health') {
        return respond(res, 200, { status: 'ok', proxy: 'nerf' }, reqOrigin);
      }
      if (path === '/v1/models' || path === '/v1/models/') {
        return respond(res, 200, {
          object: 'list',
          data: [{
            id: config.modelOverride || 'nerf-proxy',
            object: 'model',
            created: Math.floor(Date.now() / 1000),
            owned_by: 'nerf',
          }],
        }, reqOrigin);
      }
      return respond(res, 404, { error: 'not found' }, reqOrigin);
    }

    // POST routes
    if (req.method === 'POST') {
      // Auth check for POST routes when requireAuth is enabled.
      // GET /health and /v1/models remain unauthenticated.
      if (config.requireAuth) {
        const authHeader = req.headers['authorization'] || '';
        const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
        const clientId = config.validateToken(token);
        if (!clientId) {
          process.stderr.write(
            `[openai-proxy] 401 unauthorized POST ${url.pathname} from ${req.socket?.remoteAddress}\n`,
          );
          return respond(res, 401, { error: 'unauthorized: valid Bearer token required' }, reqOrigin);
        }
      }

      const body = await readBody(req);
      if (!body) return respond(res, 400, { error: 'invalid or empty body' }, reqOrigin);

      if (path === '/v1/chat/completions') {
        const messages = body.messages || [];

        // Inject skills
        if (config.injectSkills && messages.length) {
          const skillContext = findMatchingSkills(messages, config.maxSkills);
          if (skillContext) {
            if (messages[0]?.role === 'system') {
              messages[0].content = (messages[0].content || '') + skillContext;
            } else {
              messages.unshift({ role: 'system', content: skillContext });
            }
            body.messages = messages;
          }
        }

        // Model override
        if (config.modelOverride) body.model = config.modelOverride;

        // Forward
        try {
          if (body.stream) {
            await forwardStream('/v1/chat/completions', body, req.headers, res, reqOrigin);
          } else {
            const result = await forwardPost('/v1/chat/completions', body, req.headers);
            respond(res, result.status, result.body, reqOrigin);
            if (config.logInteractions) logInteraction(messages, result.body);
          }
        } catch {
          respond(res, 502, { error: 'upstream unavailable' }, reqOrigin);
        }
        return;
      }

      if (path === '/v1/completions') {
        try {
          const result = await forwardPost('/v1/completions', body, req.headers);
          respond(res, result.status, result.body, reqOrigin);
        } catch {
          respond(res, 502, { error: 'upstream unavailable' }, reqOrigin);
        }
        return;
      }

      return respond(res, 404, { error: 'not found' }, reqOrigin);
    }

    respond(res, 405, { error: 'method not allowed' }, reqOrigin);
  }

  const server = httpCreateServer(handleRequest);

  return {
    server,
    config,
    start() {
      return new Promise((resolve, reject) => {
        server.listen(config.port, config.host, () => {
          resolve(server.address().port);
        });
        server.once('error', reject);
      });
    },
    stop() {
      return new Promise((resolve) => { server.close(() => resolve()); });
    },
  };
}
