/**
 * Fleet-host input validation — defends against SSRF on /api/v1/fleet/*.
 *
 * Why this lives in its own module instead of inline in routes/fleet.js:
 *   The fleet endpoints compose a user-supplied hostname + port into a URL
 *   that is then fetched from inside the dashcaddy-api container
 *   (DC-108, GET /fleet/status probes `http://${hostname}:${port}/api/v1/system/health`;
 *   POST /fleet/deploy returns `http://${hostname}:${port}/api/v1/apps/deploy`
 *   for the operator to call). Without validation, an authenticated dashboard
 *   operator could register a host with `hostname: "127.0.0.1"` or
 *   `hostname: "169.254.169.254"` (cloud metadata service) and have the
 *   container reach that internal endpoint on the operator's behalf. Worse:
 *   a hostname like `attacker.example.com` could exploit DNS rebinding
 *   (public IP at registration time → loopback IP at fetch time).
 *
 *   By extracting `validateFleetHost()`, `isPrivateOrReservedIPv4()`, and
 *   `isPrivateOrReservedIPv6()` here, the policy is unit-testable without
 *   booting Express + auth + CSRF, and a future route that wants the same
 *   guard can reuse it.
 *
 * Default-deny posture:
 *   - Reject IPv4 loopback (127.0.0.0/8), link-local (169.254.0.0/16 —
 *     including the AWS/GCP/Azure metadata address 169.254.169.254), RFC 1918
 *     private (10/8, 172.16/12, 192.168/16), CGNAT (100.64.0.0/10,
 *     which Tailscale uses), multicast (224.0.0.0/4), broadcast
 *     (255.255.255.255), and the reserved/documentation ranges (0.0.0.0/8,
 *     192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24,
 *     203.0.113.0/24, 240.0.0.0/4).
 *   - Reject IPv6 loopback (::1), link-local (fe80::/10), ULA (fc00::/7),
 *     multicast (ff00::/8), and the IPv4-mapped loopback (::ffff:127.0.0.1).
 *   - Allow public DNS hostnames (e.g. `fleet.example.com`) and public IPs.
 *   - To opt in to private-network hosts (a real fleet of homelab DashCaddy
 *     instances behind Tailscale or RFC1918), set FLEET_ALLOW_PRIVATE_HOSTS=true
 *     in the operator's environment. Even then, DNS-rebinding protection still
 *     resolves the hostname once before probing and rejects private results.
 *
 * Public API:
 *   validateFleetHost({ name, hostname, port, tags })
 *     -> { ok: true, normalized: {...} } | { ok: false, code, message }
 *   resolveAndCheckAddress(hostname)
 *     -> { ok: true, ip } | { ok: false, code, message }
 *     Resolves a DNS hostname to its first A/AAAA record and validates the
 *     resolved IP is also non-private (defends against DNS rebinding).
 *   isPrivateOrReservedIPv4(ip)
 *   isPrivateOrReservedIPv6(ip)
 */

'use strict';

const dns = require('dns').promises;

// IPv4 ranges that should NEVER be probed from the fleet container unless
// the operator has explicitly opted in via FLEET_ALLOW_PRIVATE_HOSTS.
// Order matters: most specific (longest prefix) first so a `192.168.x.y`
// check happens before a generic `192.*` swallow-all.
const PRIVATE_OR_RESERVED_IPV4 = [
  // ── Broadcast — checked first because 255.255.255.255 matches the
  //    `240.0.0.0/4 reserved` range and would otherwise be mislabeled.
  { cidr: '255.255.255.255/32', label: 'broadcast' },
  // ── Loopback (RFC 1122) ──
  // 127.0.0.0/8 — covers 127.0.0.1 and the rest of the loopback block.
  { cidr: '127.0.0.0/8', label: 'loopback (RFC 1122)' },
  // ── Link-local (RFC 3927) + cloud metadata ──
  // 169.254.0.0/16 covers AWS / GCP / Azure metadata at 169.254.169.254
  // (the canonical IMDS endpoint) and any other link-local address.
  { cidr: '169.254.0.0/16', label: 'link-local / cloud-metadata (RFC 3927, IMDS)' },
  // ── RFC 1918 private ──
  { cidr: '10.0.0.0/8', label: 'RFC 1918 private' },
  { cidr: '172.16.0.0/12', label: 'RFC 1918 private' },
  { cidr: '192.168.0.0/16', label: 'RFC 1918 private' },
  // ── CGNAT (RFC 6598) — Tailscale uses this range ──
  { cidr: '100.64.0.0/10', label: 'CGNAT / Tailscale (RFC 6598)' },
  // ── Multicast (RFC 5771) ──
  { cidr: '224.0.0.0/4', label: 'multicast (RFC 5771)' },
  // ── Reserved / documentation / benchmarks ──
  { cidr: '0.0.0.0/8', label: 'reserved "this network" (RFC 1122)' },
  { cidr: '192.0.0.0/24', label: 'IETF protocol assignments (RFC 6890)' },
  { cidr: '192.0.2.0/24', label: 'TEST-NET-1 documentation (RFC 5737)' },
  { cidr: '198.18.0.0/15', label: 'benchmark testing (RFC 2544)' },
  { cidr: '198.51.100.0/24', label: 'TEST-NET-2 documentation (RFC 5737)' },
  { cidr: '203.0.113.0/24', label: 'TEST-NET-3 documentation (RFC 5737)' },
  { cidr: '240.0.0.0/4', label: 'reserved for future use (RFC 1112)' },
];

/**
 * IPv4 reserved-range check. Returns { isPrivate, label } where label names
 * the matched range (loopback / RFC 1918 / etc.) for human-readable errors.
 */
function isPrivateOrReservedIPv4(ip) {
  if (typeof ip !== 'string') return { isPrivate: false, label: null };
  const parts = ip.split('.');
  if (parts.length !== 4) return { isPrivate: false, label: null };
  const nums = parts.map((p) => parseInt(p, 10));
  if (nums.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) {
    return { isPrivate: false, label: null };
  }
  // Decode the IP to a 32-bit unsigned integer for prefix matching.
  const asInt = ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0;
  for (const { cidr, label } of PRIVATE_OR_RESERVED_IPV4) {
    const [base, bits] = cidr.split('/');
    const prefix = parseInt(bits, 10);
    const baseParts = base.split('.').map((p) => parseInt(p, 10));
    const baseInt = ((baseParts[0] << 24) | (baseParts[1] << 16) | (baseParts[2] << 8) | baseParts[3]) >>> 0;
    // Build a mask by shifting prefix bits down from the top.
    const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0;
    if ((asInt & mask) === (baseInt & mask)) {
      return { isPrivate: true, label };
    }
  }
  // Broadcast is now handled by the cidr list (255.255.255.255/32 entry),
  // checked first to win over the 240.0.0.0/4 reserved-for-future-use range.
  return { isPrivate: false, label: null };
}

/**
 * IPv6 reserved-range check. Returns { isPrivate, label }.
 */
function isPrivateOrReservedIPv6(ip) {
  if (typeof ip !== 'string') return { isPrivate: false, label: null };
  // Normalize IPv4-mapped IPv6 (::ffff:127.0.0.1) -> delegate to v4 check.
  const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
  if (mapped) {
    const v4Check = isPrivateOrReservedIPv4(mapped[1]);
    return v4Check.isPrivate
      ? { isPrivate: true, label: `IPv4-mapped (${mapped[1]})` }
      : { isPrivate: false, label: null };
  }
  const lc = ip.toLowerCase();
  // ::1 loopback
  if (lc === '::1') return { isPrivate: true, label: 'IPv6 loopback (RFC 4291)' };
  // :: unspecified
  if (lc === '::') return { isPrivate: true, label: 'IPv6 unspecified (RFC 4291)' };
  // fe80::/10 link-local
  if (/^fe[89ab][0-9a-f]:/i.test(lc) || /^fe80::/i.test(lc)) {
    return { isPrivate: true, label: 'IPv6 link-local (RFC 4291)' };
  }
  // fc00::/7 unique-local (ULA)
  if (/^[fF][cdCE]/.test(lc)) {
    return { isPrivate: true, label: 'IPv6 unique-local (RFC 4193)' };
  }
  // ff00::/8 multicast
  if (/^ff[0-9a-fA-F]?[0-9a-fA-F]?:/.test(lc)) {
    return { isPrivate: true, label: 'IPv6 multicast (RFC 4291)' };
  }
  return { isPrivate: false, label: null };
}

/**
 * Lightweight hostname syntax check (RFC 1123-style DNS names + literal IPs).
 * `net.isIP` would also work for IP literals, but we accept IPv6 with
 * a leading colon here and delegate that branch separately.
 */
const RFC1123_LABEL = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
function isValidHostnameSyntax(hostname) {
  if (typeof hostname !== 'string') return false;
  if (hostname.length === 0 || hostname.length > 253) return false;
  // Trailing dot is legal (signals root); strip for label parsing.
  let h = hostname;
  if (h.endsWith('.')) h = h.slice(0, -1);
  if (h.length === 0) return false;
  const labels = h.split('.');
  if (labels.length === 0) return false;
  for (const label of labels) {
    if (!RFC1123_LABEL.test(label)) return false;
  }
  return true;
}

/**
 * Async DNS-resolve the hostname to its first A and AAAA records, run the
 * private-range check on each, and return the first non-private match. If
 * all resolved addresses are private (or the name doesn't resolve), report
 * the failure mode so the caller can return a meaningful 400.
 *
 * DNS-rebinding protection: by resolving ONCE at validation time and returning
 * the IP, a follow-up probe URL built from the resolved IP can't be pointed
 * at a different IP via a fast-flipping DNS record. For maximum robustness
 * the caller should pass the resolved IP back as the host's `resolvedIp` so
 * future `fetch()` calls use `http://<resolvedIp>:<port>`, not
 * `http://<hostname>:<port>`.
 */
async function resolveAndCheckAddress(hostname, opts = {}) {
  const allowPrivate = !!opts.allowPrivate;
  if (typeof hostname !== 'string' || hostname.length === 0) {
    return { ok: false, code: 'INVALID_HOSTNAME', message: 'hostname is required' };
  }
  // Literal IPv4 -- skip the DNS round-trip.
  if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) {
    const v4Check = isPrivateOrReservedIPv4(hostname);
    if (v4Check.isPrivate && !allowPrivate) {
      return {
        ok: false,
        code: 'PRIVATE_IPV4',
        message: `hostname "${hostname}" resolves to a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
      };
    }
    return { ok: true, ip: hostname, family: 4 };
  }
  // Literal IPv6 -- detect by containing a colon AND no `/` or `://`
  // substrings (URL-like strings contain colons but aren't IPv6). Use
  // Node's built-in `net.isIP` for the authoritative check; the
  // colon-presence check is a fast-path to skip the DNS call for obvious
  // IPv6 inputs.
  const net = require('net');
  const isLikelyIPv6 = hostname.includes(':') && net.isIP(hostname) === 6;
  if (isLikelyIPv6) {
    const v6Check = isPrivateOrReservedIPv6(hostname);
    if (v6Check.isPrivate && !allowPrivate) {
      return {
        ok: false,
        code: 'PRIVATE_IPV6',
        message: `hostname "${hostname}" resolves to a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
      };
    }
    return { ok: true, ip: hostname, family: 6 };
  }
  // Hostname syntax guard before DNS call -- saves an OS query for obvious junk.
  if (!isValidHostnameSyntax(hostname)) {
    return {
      ok: false,
      code: 'INVALID_HOSTNAME',
      message: `hostname "${hostname}" is not a valid DNS name or IP address`,
    };
  }
  // DNS resolve.
  let results;
  try {
    results = await dns.lookup(hostname, { all: true });
  } catch (err) {
    return {
      ok: false,
      code: 'DNS_RESOLUTION_FAILED',
      message: `hostname "${hostname}" did not resolve: ${err.code || err.message}`,
    };
  }
  if (!results || results.length === 0) {
    return {
      ok: false,
      code: 'DNS_NO_RECORDS',
      message: `hostname "${hostname}" has no A or AAAA records`,
    };
  }
  for (const r of results) {
    if (r.family === 4) {
      const v4Check = isPrivateOrReservedIPv4(r.address);
      if (v4Check.isPrivate && !allowPrivate) {
        return {
          ok: false,
          code: 'PRIVATE_IPV4',
          message: `hostname "${hostname}" resolves to ${r.address}, a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
        };
      }
      return { ok: true, ip: r.address, family: 4 };
    } else if (r.family === 6) {
      const v6Check = isPrivateOrReservedIPv6(r.address);
      if (v6Check.isPrivate && !allowPrivate) {
        return {
          ok: false,
          code: 'PRIVATE_IPV6',
          message: `hostname "${hostname}" resolves to ${r.address}, a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
        };
      }
      return { ok: true, ip: r.address, family: 6 };
    }
  }
  return {
    ok: false,
    code: 'DNS_NO_RECORDS',
    message: `hostname "${hostname}" has no usable A or AAAA records`,
  };
}

/**
 * Validate the full input shape of POST /fleet/hosts and POST /fleet/deploy.
 * On success, returns the normalized payload (with `port` coerced to int and
 * `hostname` lowercased). On failure, returns { ok: false, code, message } for
 * the caller to surface as a 400 errorResponse.
 *
 * Validates in this order (cheapest predicate first):
 *   1. name: string, 1..100 chars, no control chars
 *   2. hostname: syntax (IP or RFC 1123 DNS name); literal IPv4/v6 also runs
 *      the private-range check synchronously here
 *   3. port: integer 1..65535; port 22 explicitly rejected (SSH, not HTTP)
 *   4. tags: array of strings, max 20 items, each 1..50 chars, no control chars
 *
 * Note: DNS-rebinding check is async (resolveAndCheckAddress) and runs
 * separately, because this function is kept synchronous for testability.
 * Callers MUST invoke resolveAndCheckAddress after validateFleetHost
 * for DNS-named hosts.
 */
function validateFleetHost(input) {
  const { name, hostname, port, tags } = input || {};

  if (typeof name !== 'string' || name.length === 0 || name.length > 100) {
    return {
      ok: false,
      code: 'INVALID_NAME',
      message: 'name is required and must be 1..100 characters',
    };
  }
  // Disallow control chars in name (newlines would let a stored name break
  // log-file formats and could enable log injection if not properly escaped).
  if (/[\x00-\x1f]/.test(name)) {
    return {
      ok: false,
      code: 'INVALID_NAME',
      message: 'name must not contain control characters',
    };
  }

  if (typeof hostname !== 'string' || hostname.length === 0) {
    return {
      ok: false,
      code: 'INVALID_HOSTNAME',
      message: 'hostname is required',
    };
  }
  // Hard syntax check (catches obvious junk before any DNS call). Use
  // `net.isIP` to detect literal IPv4/IPv6 (handles both pure-v6 AND the
  // IPv4-mapped v6 `::ffff:x.y.z.w` correctly), then fall back to the
  // RFC 1123 DNS-name check.
  const syntaxIpFamily = require('net').isIP(hostname);
  if (syntaxIpFamily === 0 && !isValidHostnameSyntax(hostname)) {
    return {
      ok: false,
      code: 'INVALID_HOSTNAME',
      message: 'hostname must be a valid IPv4 address, IPv6 address, or DNS name',
    };
  }
  // If it's a literal IP, run the private-range check synchronously here.
  // Use `net.isIP` to distinguish a real IPv4 dotted-quad or IPv6 from
  // URL-shaped junk like `http://evil.com` (which contains both `:` and `.`
  // but is not a valid IP literal).
  const net = require('net');
  const ipFamily = net.isIP(hostname);
  if (ipFamily === 4) {
    const v4Check = isPrivateOrReservedIPv4(hostname);
    if (v4Check.isPrivate) {
      return {
        ok: false,
        code: 'PRIVATE_IPV4',
        message: `IPv4 address "${hostname}" is a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
      };
    }
  } else if (ipFamily === 6) {
    const v6Check = isPrivateOrReservedIPv6(hostname);
    if (v6Check.isPrivate) {
      return {
        ok: false,
        code: 'PRIVATE_IPV6',
        message: `IPv6 address "${hostname}" is a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`,
      };
    }
  }

  // Port bounds + SSH sentinel.
  const portNum = Number(port);
  if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
    return {
      ok: false,
      code: 'INVALID_PORT',
      message: 'port must be an integer in 1..65535',
    };
  }
  if (portNum === 22) {
    return {
      ok: false,
      code: 'INVALID_PORT',
      message: 'port 22 is reserved (SSH); the fleet API probe is HTTP, not SSH',
    };
  }

  // Tags — array of short strings.
  if (tags !== undefined) {
    if (!Array.isArray(tags)) {
      return {
        ok: false,
        code: 'INVALID_TAGS',
        message: 'tags must be an array of strings',
      };
    }
    if (tags.length > 20) {
      return {
        ok: false,
        code: 'INVALID_TAGS',
        message: 'tags may contain at most 20 entries',
      };
    }
    for (const t of tags) {
      if (typeof t !== 'string' || t.length === 0 || t.length > 50) {
        return {
          ok: false,
          code: 'INVALID_TAGS',
          message: 'each tag must be a string of 1..50 characters',
        };
      }
      if (/[\x00-\x1f]/.test(t)) {
        return {
          ok: false,
          code: 'INVALID_TAGS',
          message: 'tags must not contain control characters',
        };
      }
    }
  }

  return {
    ok: true,
    normalized: {
      name: name.trim(),
      hostname: hostname.toLowerCase(),
      port: portNum,
      tags: tags || [],
    },
  };
}

/**
 * Validate a `host:port` upstream string for use in Caddy's `reverse_proxy`.
 *
 * DC-074 SSRF hardening: an authenticated dashboard operator can call
 * POST /api/v1/site with `upstream: '10.0.0.1:80'` and end up with a
 * Caddyfile entry that proxies public traffic (https://attacker.example.com)
 * to an INTERNAL host (10.0.0.1:80). Caddy runs on DNS2 — same network
 * as the targets — so the proxy lands the request on the private host.
 * The operator doesn't even need DNS-rebinding tricks: a literal IPv4
 * like 192.168.1.1 is accepted by the existing `[a-z0-9.-]+:\d{1,5}`
 * upstream regex.
 *
 * Reuses `resolveAndCheckAddress()` to:
 *   - reject literal private IPv4 / IPv6
 *   - resolve DNS names and reject any private-IP answer
 *     (rebinding defense — the actual address Caddy connects to is
 *     the resolved IP at registration time; Caddy itself resolves
 *     the name per-request, so a malicious operator could flip the
 *     A record between registration and connection. Acceptable
 *     residual risk — the registration check is the main gate.)
 *   - cap port to 1..65535 (defense vs. `host:99999999` integer
 *     overflow / Caddy parser-bomb)
 *
 * Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true for operators who
 * intentionally proxy to private targets (faster than a public DNS
 * round-trip + central control plane).
 *
 * @param {string} upstream - "host:port" string (e.g. "10.0.0.1:80")
 * @param {object} [opts]
 * @param {boolean} [opts.allowPrivate] - override the env-var default
 * @returns {Promise<{ok: true, host: string, port: number, resolvedIp?: string, family?: number} | {ok: false, code: string, message: string}>}
 */
async function validateUpstream(upstream, opts = {}) {
  if (typeof upstream !== 'string' || upstream.length === 0) {
    return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream is required' };
  }

  // Split on the LAST colon so IPv6 literals like `[::1]:80` parse
  // correctly (and a malformed `[::1]` without port is rejected with
  // a clean code, not a confusing TypeError from Number()).
  const lastColon = upstream.lastIndexOf(':');
  if (lastColon < 0) {
    return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream must be host:port' };
  }
  const host = upstream.slice(0, lastColon);
  const portStr = upstream.slice(lastColon + 1);

  const portNum = Number(portStr);
  if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) {
    return { ok: false, code: 'INVALID_PORT', message: 'upstream port must be an integer 1..65535' };
  }

  // Allow-list the host charset BEFORE the DNS lookup so attacker
  // payloads can't make the resolver do work. Matches the fleet
  // isValidHostnameSyntax check; sites.js's own `[a-z0-9.-]+` regex
  // is more restrictive (only letters/digits/dots/hyphens) so
  // we widen here to also accept bracketed IPv6. Anything else gets
  // rejected pre-DNS.
  const isBracketedIPv6 = host.startsWith('[') && host.endsWith(']');
  const hostToCheck = isBracketedIPv6 ? host.slice(1, -1) : host;
  if (!isValidHostnameSyntax(hostToCheck) && require('net').isIP(hostToCheck) === 0) {
    return { ok: false, code: 'INVALID_HOST', message: `upstream host "${host}" is not a valid DNS name or IP address` };
  }

  const allowPrivate = typeof opts.allowPrivate === 'boolean'
    ? opts.allowPrivate
    : process.env.SITES_ALLOW_PRIVATE_UPSTREAMS === 'true';

  const r = await resolveAndCheckAddress(hostToCheck, { allowPrivate });
  if (!r.ok) return r; // bubbles up PRIVATE_IPV4 / PRIVATE_IPV6 / INVALID_HOSTNAME / DNS_*

  return {
    ok: true,
    host,
    port: portNum,
    resolvedIp: r.ip,
    family: r.family,
  };
}

module.exports = {
  validateFleetHost,
  resolveAndCheckAddress,
  isPrivateOrReservedIPv4,
  isPrivateOrReservedIPv6,
  isValidHostnameSyntax,
  validateUpstream,
};
