/**
 * DC-108: Multi-host fleet management — deploy across multiple servers
 *
 * Foundation API for registering remote DashCaddy instances and coordinating
 * deployments across them. Each host runs its own DashCaddy container; this
 * module tracks the fleet state and can forward commands.
 *
 * GET    /api/v1/fleet/hosts           — list all registered hosts
 * POST   /api/v1/fleet/hosts           — register a new host
 * DELETE /api/v1/fleet/hosts/:hostId   — deregister a host
 * GET    /api/v1/fleet/status          — fleet-wide status overview
 * POST   /api/v1/fleet/deploy          — deploy to multiple hosts
 *
 * Host state is persisted in {dataDir}/fleet-hosts.json
 *
 * Security (SSRF hardening, DC-068):
 *   `POST /fleet/hosts` previously accepted any string as `hostname`, which
 *   the subsequent `GET /fleet/status` flow composed verbatim into
 *   `http://${hostname}:${port}/api/v1/system/health`. An authenticated
 *   dashboard operator could register `hostname: "127.0.0.1"` or
 *   `hostname: "169.254.169.254"` (cloud metadata service) and have the
 *   container reach that internal endpoint on their behalf. The
 *   `validateFleetHost()` + `resolveAndCheckAddress()` helpers in
 *   `src/utilities/fleet-validation.js` close that hole:
 *     - hostname syntax + port bounds + tag bounds (cheap, sync)
 *     - literal IPv4/IPv6 private-range check (sync)
 *     - DNS resolution + resolved-IP private-range check (async)
 *     - Probe URL built from the RESOLVED IP, not the user-supplied
 *       hostname, defeating DNS-rebinding attacks
 *     - Probe concurrency capped at MAX_PROBE_CONCURRENCY so a malicious or
 *       hung fleet can't stall the dashboard
 *     - `FLEET_ALLOW_PRIVATE_HOSTS=true` opt-in for Tailscale / RFC1918
 *       deployments where private hosts are intentional
 *
 * Hosts that violate validation are still surfaced in `GET /fleet/hosts`
 * (operator visibility), but `GET /fleet/status` skips them and tags them
 * `validation_failed` instead of probing.
 */
const express = require('express');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const { ok, errorResponse } = require('../src/utils/responses');
const { ErrorCodes } = require('../src/utilities/error-codes');
const {
  validateFleetHost,
  resolveAndCheckAddress,
} = require('../src/utilities/fleet-validation');

const HOSTS_FILE = process.env.FLEET_HOSTS_FILE || path.join(process.cwd(), 'data', 'fleet-hosts.json');
// Read lazily (per-request) so a test or operator script can flip the
  // opt-in at runtime without re-requiring the module.
  const ALLOW_PRIVATE_HOSTS = () => process.env.FLEET_ALLOW_PRIVATE_HOSTS === 'true';
// Cap concurrent probes in /fleet/status — a malicious fleet with N hosts
// would otherwise stall the dashboard with up to N parallel 3s timeouts.
const MAX_PROBE_CONCURRENCY = 5;
// Per-host probe timeout for /fleet/status.
const PROBE_TIMEOUT_MS = 3000;

module.exports = function({ log, asyncHandler }) {
  const wrap = asyncHandler || ((fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next));
  const router = express.Router();

  /**
   * Re-validate every stored host's hostname+port (defense-in-depth against
   * a hand-edited fleet-hosts.json or an environment where validation
   * loosened since the entry was written). Returns the host with a
   * `validation` field describing current policy compliance.
   */
  async function revalidateStoredHost(host, opts = {}) {
    const allowPrivate = !!opts.allowPrivate;
    const v = validateFleetHost({
      name: host.name,
      hostname: host.hostname,
      port: host.port,
      tags: host.tags,
    });
    if (!v.ok) {
      return { host, validation: { valid: false, code: v.code, message: v.message } };
    }
    // For DNS names, also resolve + check the resolved IP. Literal IPs are
    // already validated inside validateFleetHost(). Use `net.isIP` rather
    // than colon-presence heuristics so a real IPv6 with no dot is treated
    // as a literal (not as a DNS name), while URL-shaped strings like
    // `http://evil.com` (which contain both `:` and `/`) fall through to
    // the DNS-name path and get rejected by validateFleetHost()'s hostname
    // syntax check.
    const net = require('net');
    if (net.isIP(host.hostname) === 0) {
      const r = await resolveAndCheckAddress(host.hostname, { allowPrivate });
      if (!r.ok) {
        return { host, validation: { valid: false, code: r.code, message: r.message } };
      }
      return { host, validation: { valid: true, resolvedIp: r.ip, family: r.family } };
    }
    return { host, validation: { valid: true } };
  }

  /**
   * Run `worker(host)` over `hosts` with at most `MAX_PROBE_CONCURRENCY`
   * concurrent workers. Preserves order in the returned array so the
   * operator sees hosts in the same order they registered them.
   */
  async function runWithConcurrency(hosts, worker, limit = MAX_PROBE_CONCURRENCY) {
    const out = new Array(hosts.length);
    let next = 0;
    const runners = Array.from({ length: Math.min(limit, hosts.length) }, () => (async () => {
      while (true) {
        const i = next++;
        if (i >= hosts.length) return;
        out[i] = await worker(hosts[i], i);
      }
    })());
    await Promise.all(runners);
    return out;
  }

  async function loadHosts() {
    const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
    try {
      const data = await fsp.readFile(hostsFile, 'utf8');
      return JSON.parse(data);
    } catch {
      return [];
    }
  }

  async function saveHosts(hosts) {
    const hostsFile = process.env.FLEET_HOSTS_FILE || HOSTS_FILE;
    await fsp.mkdir(path.dirname(hostsFile), { recursive: true });
    await fsp.writeFile(hostsFile, JSON.stringify(hosts, null, 2));
  }

  // GET /api/v1/fleet/hosts
  router.get('/fleet/hosts', wrap(async (req, res) => {
    const hosts = await loadHosts();
    ok(res, { total: hosts.length, hosts });
  }));

  // POST /api/v1/fleet/hosts — register a new host
    router.post('/fleet/hosts', wrap(async (req, res) => {
      const body = req.body || {};
      const { apiKey, ...rest } = body;

      // DC-068 SSRF hardening: synchronous structural validation first
      // (hostname syntax, port bounds, tag bounds, literal-IPv4 private range).
      // DNS rebinding protection runs after this via resolveAndCheckAddress().
      const v = validateFleetHost(rest);
      if (!v.ok) {
        const logDetail = { code: v.code, message: v.message };
        // Redact any user-supplied hostname in the audit log; only keep the
        // error code + length, never the raw value (it may be attacker-supplied
        // junk that has nothing to do with the real fleet).
        if (typeof body.hostname === 'string') logDetail.hostnameLen = body.hostname.length;
        if (log) log.warn('fleet', 'Host registration rejected by validation', logDetail);
        return errorResponse(res, 400, v.message, { code: v.code });
      }
      const { name, hostname, port, tags } = v.normalized;

      // DC-068 DNS rebinding protection: if `hostname` is a DNS name (not a
      // literal IP), resolve it now and reject the registration if the resolved
      // address is private/reserved. The resolved IP is stored alongside the
      // hostname so /fleet/status probes it by IP, not by re-resolving the
      // name (closing the rebinding window). `net.isIP` distinguishes a real
      // IPv4 dotted-quad OR IPv6 from URL-shaped junk like `http://evil.com`
      // (which would otherwise be misclassified as IPv6 by a naive
      // colon-presence check).
      let resolvedIp = hostname;
      let dnsFamily = null;
      if (require('net').isIP(hostname) === 0) {
        const r = await resolveAndCheckAddress(hostname, { allowPrivate: ALLOW_PRIVATE_HOSTS() });
        if (!r.ok) {
          if (log) log.warn('fleet', 'Host registration rejected by DNS resolution', { code: r.code, message: r.message });
          return errorResponse(res, 400, r.message, { code: r.code });
        }
        resolvedIp = r.ip;
        dnsFamily = r.family;
      } else {
        // Literal IP — capture the IP family so /fleet/status and
        // /fleet/deploy can bracket-wrap IPv6 correctly when probes/URLs
        // are built from the resolved IP. resolvedIp stays equal to the
        // literal hostname so the existing test invariant still holds.
        dnsFamily = require('net').isIP(hostname);
      }

      const hosts = await loadHosts();

      // Check for duplicate (compare on the original hostname string, not the
      // resolved IP — operators know their hosts by name).
      if (hosts.some(h => h.hostname === hostname)) {
        return errorResponse(res, 409, `Host ${hostname} already registered`, {
          code: ErrorCodes.GENERAL.CONFLICT,
        });
      }

      const host = {
        id: crypto.randomUUID(),
        name,
        hostname,
        port,
        tags,
        status: 'unknown',
        registeredAt: new Date().toISOString(),
        lastSeen: null,
        containerCount: null,
        // DNS rebinding protection — probe by this IP, not by re-resolving.
        resolvedIp,
        dnsFamily,
        apiKey: apiKey ? '***' : null, // Never store the actual key
        apiKeyHash: apiKey ? crypto.createHash('sha256').update(apiKey).digest('hex') : null,
      };

      hosts.push(host);
      await saveHosts(hosts);

      if (log) log.info('fleet', 'Host registered', { name, hostname, resolvedIp, dnsFamily });

      ok(res, { host }, 201);
    }));

  // DELETE /api/v1/fleet/hosts/:hostId
  router.delete('/fleet/hosts/:hostId', wrap(async (req, res) => {
    const { hostId } = req.params;
    const hosts = await loadHosts();
    const filtered = hosts.filter(h => h.id !== hostId);

    if (filtered.length === hosts.length) {
      return errorResponse(res, 404, `Host ${hostId} not found`);
    }

    await saveHosts(filtered);
    ok(res, { message: 'Host deregistered' });
  }));

  // GET /api/v1/fleet/status — aggregate fleet status
  //
  // DC-068 SSRF hardening: every stored host is re-validated before probing
  // (defense-in-depth against a hand-edited fleet-hosts.json or a config
  // file written before this policy was enabled). Probes use the
  // `resolvedIp` captured at registration time — never re-resolve the
  // hostname, since DNS-rebinding attackers could flip the A record
  // between registration and probe. Probe concurrency is capped at
  // MAX_PROBE_CONCURRENCY so a malicious fleet with N hung hosts can't
  // stall the dashboard with up to N parallel timeouts.
  router.get('/fleet/status', wrap(async (req, res) => {
    const hosts = await loadHosts();

    // Validate all hosts (in parallel) and split into "probeable" vs
    // "validation_failed". Both lists are returned for operator visibility.
    const validated = await runWithConcurrency(
      hosts,
      (host) => revalidateStoredHost(host, { allowPrivate: ALLOW_PRIVATE_HOSTS() }),
      Math.max(MAX_PROBE_CONCURRENCY, hosts.length || 1)
    );

    const probeTargets = validated.filter((v) => v.validation.valid);
    const skipped = validated
      .filter((v) => !v.validation.valid)
      .map((v) => ({ ...v.host, status: 'validation_failed', validationError: v.validation.message }));

    const probeResults = await runWithConcurrency(probeTargets, async ({ host, validation }) => {
      const probeIp = validation.resolvedIp || host.hostname;
      const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp;
      const url = `http://${probeHost}:${host.port}/api/v1/system/health`;
      const controller = new AbortController();
      const timeout = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
      try {
        const response = await fetch(url, {
          signal: controller.signal,
          headers: host.apiKeyHash ? { 'x-api-key': host.apiKeyHash } : {},
        });
        if (response.ok) {
          const data = await response.json();
          host.status = data.status || 'healthy';
          host.lastSeen = new Date().toISOString();
          host.containerCount = data.checks?.services?.total || null;
        } else {
          host.status = 'unreachable';
        }
      } catch {
        host.status = 'offline';
      } finally {
        clearTimeout(timeout);
      }
      return host;
    }, MAX_PROBE_CONCURRENCY);

    const updatedHosts = [...probeResults, ...skipped];
    await saveHosts(updatedHosts);

    const summary = {
      total: updatedHosts.length,
      healthy: updatedHosts.filter((h) => h.status === 'healthy').length,
      degraded: updatedHosts.filter((h) => h.status === 'degraded').length,
      unhealthy: updatedHosts.filter((h) => h.status === 'unhealthy').length,
      offline: updatedHosts.filter((h) => h.status === 'offline' || h.status === 'unreachable').length,
      validation_failed: updatedHosts.filter((h) => h.status === 'validation_failed').length,
    };

    ok(res, { summary, hosts: updatedHosts });
  }));

  // POST /api/v1/fleet/deploy — deploy a template to multiple hosts
  //
  // DC-068 SSRF hardening: returns plan entries whose `deployUrl` is built
  // from `resolvedIp` (the address captured at registration time) — never
  // from the raw hostname. Operators copy-and-paste these URLs into the
  // forwarding tool of their choice; routing them through a literal IP
  // prevents a DNS-rebinding rename from pivoting the deploy call.
  router.post('/fleet/deploy', wrap(async (req, res) => {
    const { templateId, hostIds = [], config = {} } = req.body || {};

    if (!templateId) {
      return errorResponse(res, 400, 'templateId is required');
    }

    const hosts = await loadHosts();
    const targetHosts = hostIds.length > 0
      ? hosts.filter(h => hostIds.includes(h.id))
      : hosts;

    if (targetHosts.length === 0) {
      return errorResponse(res, 400, 'No valid hosts to deploy to');
    }

    // Build the plan. Each entry's `deployUrl` is built from the host's
    // resolved IP (or the literal hostname for literal-IP hosts) — never
    // from a re-resolution of the raw hostname. IPv6 literals must be
    // wrapped in `[...]` so the URL parser preserves them as a single
    // authority. Use `net.isIP` against the resolved IP rather than the
    // stored `dnsFamily` so legacy entries (those registered before
    // dnsFamily was captured) still get correct bracket wrapping.
    const plan = targetHosts.map(host => {
      const probeIp = host.resolvedIp || host.hostname;
      const probeHost = require('net').isIP(probeIp) === 6 ? `[${probeIp}]` : probeIp;
      return {
        hostId: host.id,
        hostname: host.hostname,
        templateId,
        config,
        status: 'pending',
        deployUrl: `http://${probeHost}:${host.port}/api/v1/apps/deploy`,
      };
    });

    ok(res, {
      templateId,
      totalHosts: plan.length,
      plan,
      message: 'Deployment plan generated. Forward each step to the host API.',
    });
  }));

  return router;
};
