'use strict';

const fs = require('fs');
const net = require('net');
const os = require('os');

const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost']);
const TAILSCALE_IPV6_PREFIX = 'fd7a:115c:a1e0:';

function stripIpv6Brackets(host) {
  const s = String(host || '').trim();
  return s.startsWith('[') && s.endsWith(']') ? s.slice(1, -1) : s;
}

function isTailscaleIPv4(host) {
  const h = stripIpv6Brackets(host);
  if (net.isIP(h) !== 4) return false;
  const parts = h.split('.').map((p) => Number(p));
  return parts.length === 4
    && parts[0] === 100
    && Number.isInteger(parts[1])
    && parts[1] >= 64
    && parts[1] <= 127;
}

function isTailscaleIPv6(host) {
  const h = stripIpv6Brackets(host).toLowerCase();
  return net.isIP(h) === 6 && h.startsWith(TAILSCALE_IPV6_PREFIX);
}

function isLoopbackHost(host) {
  const h = stripIpv6Brackets(host).toLowerCase();
  return LOOPBACK_HOSTS.has(h);
}

function hostHeaderName(host) {
  const raw = String(host || '').trim();
  if (!raw) return '';
  if (raw.startsWith('[')) {
    const end = raw.indexOf(']');
    return end >= 0 ? raw.slice(1, end).toLowerCase() : stripIpv6Brackets(raw).toLowerCase();
  }
  return raw.replace(/:\d+$/, '').toLowerCase();
}

function firstHeaderValue(value) {
  const raw = Array.isArray(value) ? value[0] : value;
  return String(raw || '').split(',')[0].trim();
}

function stripHeaderQuotes(value) {
  const raw = String(value || '').trim();
  if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
    return raw.slice(1, -1);
  }
  return raw;
}

function parseForwardedHeader(value) {
  const first = firstHeaderValue(value);
  const out = {};
  for (const piece of first.split(';')) {
    const idx = piece.indexOf('=');
    if (idx < 0) continue;
    const key = piece.slice(0, idx).trim().toLowerCase();
    const val = stripHeaderQuotes(piece.slice(idx + 1));
    if (key && val) out[key] = val;
  }
  return out;
}

function cleanHostHeaderValue(value) {
  const raw = stripHeaderQuotes(firstHeaderValue(value));
  if (!raw) return '';
  if (/[\s/?#@\\]/.test(raw)) return '';
  return raw;
}

function isManagedHttpsTunnelHost(host) {
  const name = hostHeaderName(host);
  return name.endsWith('.devtunnels.ms') || name.endsWith('.trycloudflare.com');
}

function trustedForwardedHost(req) {
  if (!isLoopbackAddress(req?.socket?.remoteAddress || '')) return '';
  const directHostName = hostHeaderName(req?.headers?.host || '');
  if (directHostName && !isLoopbackHost(directHostName)) return '';
  const forwarded = parseForwardedHeader(req?.headers?.forwarded || '');
  return cleanHostHeaderValue(
    req?.headers?.['x-forwarded-host']
    || req?.headers?.['x-original-host']
    || req?.headers?.['x-ms-original-host']
    || forwarded.host
  );
}

function cleanIpHeaderValue(value) {
  let raw = stripHeaderQuotes(firstHeaderValue(value));
  if (!raw || raw.toLowerCase() === 'unknown') return '';
  if (raw.startsWith('[')) {
    const end = raw.indexOf(']');
    raw = end >= 0 ? raw.slice(1, end) : stripIpv6Brackets(raw);
  } else if (net.isIP(raw) === 0) {
    const colonCount = (raw.match(/:/g) || []).length;
    if (colonCount === 1 && /^\d+\.\d+\.\d+\.\d+:\d+$/.test(raw)) {
      raw = raw.replace(/:\d+$/, '');
    }
  }
  raw = stripIpv6Brackets(raw);
  return net.isIP(raw) ? raw : '';
}

function forwardedClientIp(req) {
  const forwarded = parseForwardedHeader(req?.headers?.forwarded || '');
  for (const value of [
    req?.headers?.['x-forwarded-for'],
    req?.headers?.['x-real-ip'],
    req?.headers?.['cf-connecting-ip'],
    forwarded.for,
  ]) {
    const ip = cleanIpHeaderValue(value);
    if (ip) return ip;
  }
  return '';
}

function requestClientIp(req) {
  const direct = stripIpv6Brackets(req?.socket?.remoteAddress || '');
  if (!isLoopbackAddress(direct)) return direct;
  const tunnelHost = trustedForwardedHost(req) || cleanHostHeaderValue(req?.headers?.host);
  if (!isManagedHttpsTunnelHost(tunnelHost)) return direct;
  const forwardedIp = forwardedClientIp(req);
  if (forwardedIp && !isLoopbackAddress(forwardedIp)) return forwardedIp;
  const tunnelName = hostHeaderName(tunnelHost);
  return tunnelName ? `tunnel:${tunnelName}` : direct;
}

function isLoopbackAddress(address) {
  const h = stripIpv6Brackets(address).toLowerCase();
  return LOOPBACK_HOSTS.has(h) || h === '::ffff:127.0.0.1';
}

function primaryProcessIpLockoutExemptions(primaryHost) {
  const out = new Set(['127.0.0.1', '::1']);
  const host = stripIpv6Brackets(primaryHost).trim();
  if (host && !isLoopbackHost(host)) out.add(host);
  return Array.from(out);
}

function hasNonLoopbackBrowserOrigin(req) {
  for (const value of [req?.headers?.origin, req?.headers?.referer, req?.headers?.referrer]) {
    const raw = firstHeaderValue(value);
    if (!raw) continue;
    if (raw === 'null') return true;
    try {
      const host = new URL(raw).hostname;
      if (host && !isLoopbackHost(host)) return true;
    } catch {}
  }
  return false;
}

// Proxy/tunnel forwarding headers a genuinely local browser or CLI never sends.
// A devtunnel (or any reverse proxy) forwards remote traffic to CTM over the
// loopback interface, so socket.remoteAddress alone cannot tell local apart from
// tunneled. Azure Dev Tunnels and every standard proxy stamp X-Forwarded-* /
// Forwarded on the request; their presence on a loopback peer means the request
// originated off-box and must NOT be trusted as loopback admin.
const PROXY_FORWARDING_HEADERS = Object.freeze([
  'x-forwarded-for',
  'x-forwarded-host',
  'x-forwarded-proto',
  'forwarded',
  'x-real-ip',
  'x-ms-original-host',
  'x-original-host',
  'cf-connecting-ip',
]);

function hasProxyForwardingMarkers(req) {
  const headers = req?.headers || {};
  for (const name of PROXY_FORWARDING_HEADERS) {
    const value = headers[name];
    if (value !== undefined && value !== null && String(value).trim() !== '') return true;
  }
  return false;
}

// SECURITY INVARIANT: loopback === fully-trusted local admin, but ONLY for
// requests that are provably on-box. Trust requires ALL of: (1) a loopback socket
// peer, (2) no proxy/tunnel forwarding markers, (3) no non-loopback browser
// Origin/Referer, and (4) a loopback (or absent) Host. A managed tunnel forwards
// remote clients to 127.0.0.1, so any signal that the request originated off-box
// means it must authenticate with a device token like any other remote client.
function isLoopbackRequest(req) {
  if (!isLoopbackAddress(req?.socket?.remoteAddress || '')) return false;
  if (hasProxyForwardingMarkers(req)) return false;
  const forwardedHost = trustedForwardedHost(req);
  if (forwardedHost && !isLoopbackHost(hostHeaderName(forwardedHost))) return false;
  if (hasNonLoopbackBrowserOrigin(req)) return false;
  const host = hostHeaderName(req?.headers?.host || '');
  return !host || isLoopbackHost(host);
}

function normalizeBindHost(value) {
  const raw = String(value || '').trim() || '127.0.0.1';
  const host = stripIpv6Brackets(raw);
  const lower = host.toLowerCase();
  if (lower === '0.0.0.0' || lower === '::' || lower === '*') {
    return {
      ok: false,
      code: 'wildcard_host_not_allowed',
      message: 'CTM_HOST must be localhost or a specific Tailscale address, not a wildcard bind.',
    };
  }
  if (isLoopbackHost(host) || isTailscaleIPv4(host) || isTailscaleIPv6(host)) {
    return { ok: true, host: lower === 'localhost' ? 'localhost' : host };
  }
  return {
    ok: false,
    code: 'unsupported_host',
    message: 'CTM_HOST must be 127.0.0.1, ::1, localhost, or a Tailscale 100.64.0.0/10 or fd7a:115c:a1e0::/48 address.',
  };
}

function getBindHostCandidates(interfaces = os.networkInterfaces()) {
  const out = [{
    value: '127.0.0.1',
    label: 'Localhost only',
    family: 'IPv4',
    interface: 'lo0',
    kind: 'loopback',
  }];
  const seen = new Set(out.map((item) => item.value));
  for (const [name, addrs] of Object.entries(interfaces || {})) {
    for (const addr of addrs || []) {
      const address = stripIpv6Brackets(addr.address || '');
      if (!address || seen.has(address)) continue;
      const tailscale = isTailscaleIPv4(address) || isTailscaleIPv6(address);
      if (!tailscale) continue;
      seen.add(address);
      out.push({
        value: address,
        label: `${name} ${address}`,
        family: addr.family || (net.isIP(address) === 6 ? 'IPv6' : 'IPv4'),
        interface: name,
        kind: 'tailscale',
      });
    }
  }
  return out;
}

function splitOriginList(raw) {
  if (Array.isArray(raw)) return raw.map(String).map((s) => s.trim()).filter(Boolean);
  return String(raw || '').split(/[\s,]+/).map((s) => s.trim()).filter(Boolean);
}

function parseAllowedOrigins(raw) {
  const origins = [];
  const seen = new Set();
  for (const item of splitOriginList(raw)) {
    try {
      const u = new URL(item);
      if (u.protocol !== 'http:' && u.protocol !== 'https:') continue;
      if (u.username || u.password || u.pathname !== '/' || u.search || u.hash) continue;
      if (!seen.has(u.origin)) {
        seen.add(u.origin);
        origins.push(u.origin);
      }
    } catch {}
  }
  return origins;
}

function requestProtocol(req, hostOverride = '') {
  if (req?.socket?.encrypted) return 'https';
  const forwardedHeader = parseForwardedHeader(req?.headers?.forwarded || '');
  const forwarded = (firstHeaderValue(req?.headers?.['x-forwarded-proto']) || forwardedHeader.proto || '').toLowerCase();
  if (forwarded === 'https' || forwarded === 'http') return forwarded;
  return isManagedHttpsTunnelHost(hostOverride || req?.headers?.host || '') ? 'https' : 'http';
}

function requestOrigin(req) {
  const host = trustedForwardedHost(req) || cleanHostHeaderValue(req?.headers?.host);
  if (!host) return '';
  const protocol = requestProtocol(req, host);
  try {
    return new URL(`${protocol}://${host}`).origin;
  } catch {
    return '';
  }
}

function originHostIsLoopback(origin) {
  try {
    return isLoopbackHost(new URL(origin).hostname);
  } catch {
    return false;
  }
}

function requestBrowserOrigin(req, input) {
  let parsedOrigin = '';
  try {
    parsedOrigin = new URL(String(input || '').trim()).origin;
  } catch {
    return '';
  }
  if (!originHostIsLoopback(parsedOrigin)) return parsedOrigin;

  const forwarded = trustedForwardedHost(req);
  if (!isManagedHttpsTunnelHost(forwarded)) return parsedOrigin;
  return requestOrigin(req) || parsedOrigin;
}

function originAllowed(req, allowedOriginsRaw) {
  const fetchSite = String(req?.headers?.['sec-fetch-site'] || '').trim().toLowerCase();
  if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') {
    return {
      ok: false,
      code: 'fetch_site_not_allowed',
      message: 'Cross-site mutating requests are not allowed.',
    };
  }

  const originHeader = req?.headers?.origin;
  if (!originHeader) return { ok: true };
  if (originHeader === 'null') {
    return { ok: false, code: 'origin_not_allowed', message: 'Null Origin is not allowed for mutating requests.' };
  }

  let origin;
  origin = requestBrowserOrigin(req, originHeader);
  if (!origin) {
    return { ok: false, code: 'origin_not_allowed', message: 'Invalid Origin header.' };
  }

  const allowed = new Set(parseAllowedOrigins(allowedOriginsRaw));
  const sameOrigin = requestOrigin(req);
  if (sameOrigin) allowed.add(sameOrigin);
  if (allowed.has(origin)) return { ok: true };
  return { ok: false, code: 'origin_not_allowed', message: 'Origin is not on the CTM allowlist.' };
}

function authorizeRequestOrigin(req, rule, options = {}) {
  if (!rule || !rule.mutation) return { ok: true };
  return originAllowed(req, options.allowedOrigins);
}

function loadTlsOptions(env = process.env, fsImpl = fs) {
  const certPath = String(env.CTM_TLS_CERT || '').trim();
  const keyPath = String(env.CTM_TLS_KEY || '').trim();
  if (!certPath && !keyPath) return { enabled: false, scheme: 'http' };
  if (!certPath || !keyPath) {
    throw new Error('CTM_TLS_CERT and CTM_TLS_KEY must be set together.');
  }
  return {
    enabled: true,
    scheme: 'https',
    certPath,
    keyPath,
    options: {
      cert: fsImpl.readFileSync(certPath),
      key: fsImpl.readFileSync(keyPath),
    },
  };
}

module.exports = {
  getBindHostCandidates,
  isLoopbackHost,
  isLoopbackRequest,
  isTailscaleIPv4,
  isTailscaleIPv6,
  loadTlsOptions,
  normalizeBindHost,
  originAllowed,
  parseAllowedOrigins,
  primaryProcessIpLockoutExemptions,
  requestClientIp,
  requestBrowserOrigin,
  requestOrigin,
  requestProtocol,
  authorizeRequestOrigin,
  splitOriginList,
};
