/**
 * D-12 DNS-delegation health check helpers.
 *
 * Split into a pure half (normalization + set comparison, fully unit-testable)
 * and an I/O half (live public DNS lookup via a dedicated `dns.Resolver()`
 * instance). The pure half never touches the network; the I/O half never
 * touches the process-global resolver — see the CRITICAL comment on
 * `resolveLiveNs` below (24-RESEARCH.md Pitfall 5 / threat T-24-13).
 */

import { Resolver } from 'dns';

// ============================================================================
// Pure half — NS normalization + comparison (unit-testable, no I/O)
// ============================================================================

/**
 * Normalize an unknown input (expected to be a JSONB-sourced string array,
 * e.g. route53_zones.authoritative_name_servers or a raw DNS answer) into a
 * lowercase, trailing-dot-stripped, de-duplicated, sorted list of hostnames.
 *
 * Returns [] for null, undefined, or any non-array input rather than
 * throwing — callers treat an empty list as "unjudgeable", not an error.
 */
export function normalizeNsList(input: unknown): string[] {
  if (!Array.isArray(input)) return [];
  const set = new Set<string>();
  for (const entry of input) {
    const normalized = String(entry).trim().toLowerCase().replace(/\.$/, '');
    if (normalized.length > 0) set.add(normalized);
  }
  return Array.from(set).sort();
}

export interface NsDelegationComparison {
  mismatch: boolean;
  authoritative: string[];
  live: string[];
  missingFromLive: string[];
  extraInLive: string[];
}

/**
 * Compare Route 53's authoritative NS list against a live public DNS answer.
 *
 * - An empty (normalized) authoritative list is unjudgeable — a zone Pulse
 *   has no recorded NS data for cannot be flagged as drifted. Returns
 *   `mismatch: false`.
 * - A non-empty authoritative list with an empty live answer IS a delegation
 *   problem (the domain resolved to nothing) — returns `mismatch: true`.
 * - Otherwise mismatch is true iff either side has an entry the other lacks.
 */
export function compareNsDelegation(authoritative: unknown, live: unknown): NsDelegationComparison {
  const authNorm = normalizeNsList(authoritative);
  const liveNorm = normalizeNsList(live);

  if (authNorm.length === 0) {
    return { mismatch: false, authoritative: authNorm, live: liveNorm, missingFromLive: [], extraInLive: [] };
  }
  if (liveNorm.length === 0) {
    return {
      mismatch: true,
      authoritative: authNorm,
      live: liveNorm,
      missingFromLive: [...authNorm],
      extraInLive: [],
    };
  }

  const authSet = new Set(authNorm);
  const liveSet = new Set(liveNorm);
  const missingFromLive = authNorm.filter((ns) => !liveSet.has(ns));
  const extraInLive = liveNorm.filter((ns) => !authSet.has(ns));

  return {
    mismatch: missingFromLive.length > 0 || extraInLive.length > 0,
    authoritative: authNorm,
    live: liveNorm,
    missingFromLive,
    extraInLive,
  };
}

// ============================================================================
// I/O half — live public DNS lookup (dedicated resolver instance)
// ============================================================================

export type ResolveLiveNsResult =
  | { ok: true; nameServers: string[] }
  | { ok: false; error: string };

/**
 * Resolve a domain's live NS records against a dedicated public resolver
 * (Cloudflare 1.1.1.1 + Google 8.8.8.8), NOT the container's default
 * resolver (24-RESEARCH.md Pitfall 5).
 *
 * CRITICAL: this constructs its own `new Resolver()` instance and calls
 * `setServers()` on THAT instance only. The process-global `dns.setServers()`
 * is never called anywhere in this file — doing so would repoint DNS
 * resolution for the entire Node process, including Postgres/Redis/AWS
 * hostname resolution (T-24-13).
 */
export function resolveLiveNs(domain: string, timeoutMs = 5000): Promise<ResolveLiveNsResult> {
  const target = domain.trim().replace(/\.$/, '');
  const resolver = new Resolver();
  resolver.setServers(['1.1.1.1', '8.8.8.8']);

  return new Promise((resolve) => {
    let settled = false;
    const timer = setTimeout(() => {
      if (settled) return;
      settled = true;
      resolver.cancel();
      resolve({ ok: false, error: `DNS lookup timed out after ${timeoutMs}ms` });
    }, timeoutMs);

    resolver.resolveNs(target, (err, addresses) => {
      if (settled) return;
      settled = true;
      clearTimeout(timer);
      if (err) {
        resolve({ ok: false, error: err.message });
        return;
      }
      resolve({ ok: true, nameServers: addresses });
    });
  });
}

export interface ZoneDelegationInput {
  id: string;
  name: string;
  authoritativeNameServers: unknown;
}

export interface ZoneDelegationResult {
  zoneId: string;
  zoneName: string;
  mismatch: boolean;
  error?: string;
  missingFromLive: string[];
  extraInLive: string[];
}

/**
 * Check live NS delegation for a batch of zones, bounded to at most
 * `opts.concurrency` (default 5) concurrent lookups so a large zone list
 * does not open hundreds of concurrent UDP sockets (T-24-14).
 *
 * Zones whose authoritativeNameServers normalizes to an empty list are
 * skipped (unjudgeable). A lookup failure yields `{ mismatch: false, error }`
 * — an unreachable resolver is an infrastructure problem, not evidence of
 * delegation drift, and must never be reported as a mismatch.
 */
export async function checkAllZoneDelegations(
  zones: ZoneDelegationInput[],
  opts?: { concurrency?: number },
): Promise<ZoneDelegationResult[]> {
  const concurrency = opts?.concurrency ?? 5;
  const judgeable = zones.filter((z) => normalizeNsList(z.authoritativeNameServers).length > 0);
  const results: ZoneDelegationResult[] = [];

  let cursor = 0;
  async function worker(): Promise<void> {
    while (cursor < judgeable.length) {
      const idx = cursor;
      cursor += 1;
      const zone = judgeable[idx];
      const live = await resolveLiveNs(zone.name);
      if (!live.ok) {
        results.push({
          zoneId: zone.id,
          zoneName: zone.name,
          mismatch: false,
          error: live.error,
          missingFromLive: [],
          extraInLive: [],
        });
        continue;
      }
      const comparison = compareNsDelegation(zone.authoritativeNameServers, live.nameServers);
      results.push({
        zoneId: zone.id,
        zoneName: zone.name,
        mismatch: comparison.mismatch,
        missingFromLive: comparison.missingFromLive,
        extraInLive: comparison.extraInLive,
      });
    }
  }

  const workers = Array.from({ length: Math.min(concurrency, judgeable.length) }, () => worker());
  await Promise.all(workers);
  return results;
}
