/**
 * AWS Route 53 DNS sync — change-batch construction and bounded propagation
 * poll.
 *
 * Lives under lib/** (not app/api/**) so vitest.config.ts's `include:
 * ['lib/**\/*.test.ts']` glob can reach it — the AWS-command construction and
 * polling loop are pure/testable, and route files are not.
 *
 * D-01 defence in depth: `buildChangeBatch` throws on NS/SOA independently of
 * `validateRecordWrite` (lib/services/route53-record-validation.ts), which is
 * the primary, earlier-running gate. This is a second backstop so no future
 * caller can bypass it by calling this module directly.
 *
 * 24-RESEARCH.md Anti-Patterns: never use the SDK's built-in
 * resource-record-sets-changed waiter inside a request handler — its default
 * config is a 30-second interval with 60 attempts (up to 30 minutes).
 * `pollChangeStatus` is a short, bounded, hand-rolled poll instead.
 */

import {
  Route53Client,
  ChangeResourceRecordSetsCommand,
  GetChangeCommand,
  type ChangeBatch,
  type ChangeAction,
  type ResourceRecordSet,
} from '@aws-sdk/client-route-53';
import { getRoute53Client } from './route53-factory';

const ZONE_DELEGATION_TYPES = new Set(['NS', 'SOA']);
const TXT_MAX_SEGMENT_LENGTH = 255;

/**
 * TXT (and SPF) records use RFC 1035 character-string RDATA — AWS rejects a
 * bare, unquoted value with `InvalidCharacterString`. A Value must be one or
 * more double-quoted segments, each up to 255 bytes; segments longer than
 * that are split and re-concatenated (segments render back as one string).
 * Internal backslashes and double quotes must be backslash-escaped before
 * quoting. A/AAAA/CNAME/MX/SRV values are plain (unquoted) and pass through
 * `formatResourceRecordValue` unchanged.
 */
function formatTxtValue(value: string): string {
  const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  const segments: string[] = [];
  for (let i = 0; i < escaped.length; i += TXT_MAX_SEGMENT_LENGTH) {
    segments.push(escaped.slice(i, i + TXT_MAX_SEGMENT_LENGTH));
  }
  if (segments.length === 0) segments.push('');
  return segments.map((segment) => `"${segment}"`).join(' ');
}

function formatResourceRecordValue(type: string, value: string): string {
  return type === 'TXT' ? formatTxtValue(value) : value;
}

const RETRYABLE_ERROR_NAMES = new Set([
  'ThrottlingException',
  'Throttling',
  'PriorRequestNotComplete',
  'ServiceUnavailable',
]);

export interface ChangeSubmitRecordSet {
  name: string;
  type: string;
  ttl: number;
  resourceRecords: Array<{ value: string }>;
  setIdentifier?: string | null;
}

/**
 * Build a `@aws-sdk/client-route-53` ChangeBatch for a single recordset
 * change. Omits `SetIdentifier` from the emitted object when null/undefined
 * rather than setting it to `undefined` — AWS's SDK serializer treats an
 * explicit `undefined` property differently from an absent one in some
 * marshalling paths, so we simply never set the key.
 *
 * Throws when `type.toUpperCase()` is `NS` or `SOA` — a second, independent
 * D-01 enforcement point (T-24-01, defence in depth) so a caller cannot
 * bypass `validateRecordWrite` by calling this module directly.
 */
export function buildChangeBatch(
  action: 'CREATE' | 'UPSERT' | 'DELETE',
  recordSet: ChangeSubmitRecordSet
): ChangeBatch {
  const type = recordSet.type.toUpperCase();
  if (ZONE_DELEGATION_TYPES.has(type)) {
    throw new Error(
      `Refusing to build a change batch for record type ${type} — NS and SOA are zone-delegation records (D-01)`
    );
  }

  const resourceRecordSet: ResourceRecordSet = {
    Name: recordSet.name,
    Type: type as ResourceRecordSet['Type'],
    TTL: recordSet.ttl,
    ResourceRecords: recordSet.resourceRecords.map((r) => ({
      Value: formatResourceRecordValue(type, r.value),
    })),
  };

  if (recordSet.setIdentifier !== null && recordSet.setIdentifier !== undefined) {
    resourceRecordSet.SetIdentifier = recordSet.setIdentifier;
  }

  return {
    Changes: [
      {
        Action: action as ChangeAction,
        ResourceRecordSet: resourceRecordSet,
      },
    ],
  };
}

/**
 * Classify an AWS SDK error as retryable. `PriorRequestNotComplete` is a
 * per-zone serialization constraint (two writes to the same hosted zone in
 * quick succession), not a hard failure — per 24-RESEARCH.md Pitfall 4.
 */
export function isRetryableAwsError(err: unknown): boolean {
  if (!(err instanceof Error)) return false;
  return RETRYABLE_ERROR_NAMES.has(err.name);
}

function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export interface SubmitRecordChangeInput {
  zoneId: string;
  action: 'CREATE' | 'UPSERT' | 'DELETE';
  recordSet: ChangeSubmitRecordSet;
  client?: Route53Client;
}

export interface SubmitRecordChangeResult {
  changeId: string | null;
  awsResponse: unknown;
}

const RETRY_BACKOFFS_MS = [750, 1500];

/**
 * Construct a ChangeResourceRecordSetsCommand and send it. On a retryable
 * error (per `isRetryableAwsError`), retry up to 2 additional times with
 * 750ms then 1500ms backoff; any other error rethrows immediately.
 *
 * Does not add a general backoff wrapper around every AWS call — the SDK's
 * built-in retry strategy already handles transport-level retries
 * (24-RESEARCH.md "Don't Hand-Roll"). This retry is specifically for the
 * application-level PriorRequestNotComplete / throttling cases.
 */
export async function submitRecordChange(
  input: SubmitRecordChangeInput
): Promise<SubmitRecordChangeResult> {
  const client = input.client ?? getRoute53Client();
  const command = new ChangeResourceRecordSetsCommand({
    HostedZoneId: input.zoneId,
    ChangeBatch: buildChangeBatch(input.action, input.recordSet),
  });

  let lastError: unknown;
  for (let attempt = 0; attempt <= RETRY_BACKOFFS_MS.length; attempt++) {
    try {
      const response = await client.send(command);
      return {
        changeId: response.ChangeInfo?.Id ?? null,
        awsResponse: response,
      };
    } catch (err) {
      lastError = err;
      if (!isRetryableAwsError(err) || attempt === RETRY_BACKOFFS_MS.length) {
        throw err;
      }
      await sleep(RETRY_BACKOFFS_MS[attempt]);
    }
  }

  // Unreachable — the loop above always returns or throws — but keeps
  // TypeScript's control-flow analysis satisfied.
  throw lastError;
}

export interface PollChangeStatusOptions {
  client?: Route53Client;
  timeoutMs?: number;
  intervalMs?: number;
}

const DEFAULT_POLL_TIMEOUT_MS = 15000;
const DEFAULT_POLL_INTERVAL_MS = 2000;

/**
 * Bounded poll of GetChangeCommand until ChangeInfo.Status === 'INSYNC' or
 * the timeout budget elapses, in which case 'PENDING' is returned and no
 * further calls are made. Per-attempt errors are swallowed and polling
 * continues until the budget elapses — a transient GetChange failure is not
 * a write failure; the write was already accepted by AWS.
 *
 * CRITICAL: do NOT use the SDK's built-in resource-record-sets-changed
 * waiter here — its default config (30s interval, 60 attempts) can block an
 * HTTP request handler for up to 30 minutes.
 */
export async function pollChangeStatus(
  changeId: string,
  opts?: PollChangeStatusOptions
): Promise<'INSYNC' | 'PENDING'> {
  const client = opts?.client ?? getRoute53Client();
  const timeoutMs = opts?.timeoutMs ?? DEFAULT_POLL_TIMEOUT_MS;
  const intervalMs = opts?.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    try {
      const response = await client.send(new GetChangeCommand({ Id: changeId }));
      if (response.ChangeInfo?.Status === 'INSYNC') {
        return 'INSYNC';
      }
    } catch {
      // Swallow — the write already succeeded; GetChange transiently
      // failing is not a write failure. Keep polling until the budget
      // elapses.
    }

    if (Date.now() >= deadline) break;
    await sleep(intervalMs);
  }

  return 'PENDING';
}
