/**
 * lib/services/route53-change-submit.ts unit tests — ChangeBatch construction,
 * retryable-error classification, and bounded GetChange polling. No AWS SDK
 * mocking library, no network — pollChangeStatus is exercised with a
 * hand-rolled fake client exposing `send()`.
 */

import { describe, it, expect, vi } from 'vitest';
import {
  buildChangeBatch,
  isRetryableAwsError,
  submitRecordChange,
  pollChangeStatus,
} from './route53-change-submit';

describe('buildChangeBatch', () => {
  const baseRecordSet = {
    name: 'www.example.com.',
    type: 'A',
    ttl: 300,
    resourceRecords: [{ value: '1.2.3.4' }],
  };

  it('produces the expected ChangeBatch shape for UPSERT', () => {
    const batch = buildChangeBatch('UPSERT', baseRecordSet);
    expect(batch).toEqual({
      Changes: [
        {
          Action: 'UPSERT',
          ResourceRecordSet: {
            Name: 'www.example.com.',
            Type: 'A',
            TTL: 300,
            ResourceRecords: [{ Value: '1.2.3.4' }],
          },
        },
      ],
    });
  });

  it('sets Action to CREATE for a CREATE action', () => {
    const batch = buildChangeBatch('CREATE', baseRecordSet);
    expect(batch.Changes?.[0].Action).toBe('CREATE');
  });

  it('sets Action to DELETE for a DELETE action', () => {
    const batch = buildChangeBatch('DELETE', baseRecordSet);
    expect(batch.Changes?.[0].Action).toBe('DELETE');
  });

  it('emits SetIdentifier when present on the input', () => {
    const batch = buildChangeBatch('UPSERT', { ...baseRecordSet, setIdentifier: 'primary' });
    expect(batch.Changes?.[0].ResourceRecordSet?.SetIdentifier).toBe('primary');
  });

  it('omits SetIdentifier entirely when null/undefined rather than emitting undefined', () => {
    const batchNull = buildChangeBatch('UPSERT', { ...baseRecordSet, setIdentifier: null });
    expect('SetIdentifier' in (batchNull.Changes?.[0].ResourceRecordSet ?? {})).toBe(false);

    const batchUndefined = buildChangeBatch('UPSERT', baseRecordSet);
    expect('SetIdentifier' in (batchUndefined.Changes?.[0].ResourceRecordSet ?? {})).toBe(false);
  });

  it('emits the full TTL and complete ResourceRecords array for a DELETE from supplied current state', () => {
    const batch = buildChangeBatch('DELETE', {
      ...baseRecordSet,
      ttl: 600,
      resourceRecords: [{ value: '1.2.3.4' }, { value: '5.6.7.8' }],
    });
    const rrs = batch.Changes?.[0].ResourceRecordSet;
    expect(rrs?.TTL).toBe(600);
    expect(rrs?.ResourceRecords).toEqual([{ Value: '1.2.3.4' }, { Value: '5.6.7.8' }]);
  });

  it('throws when given record type NS', () => {
    expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'NS' })).toThrow(/NS/);
  });

  it('throws when given record type SOA (case-insensitive)', () => {
    expect(() => buildChangeBatch('UPSERT', { ...baseRecordSet, type: 'soa' })).toThrow(/SOA/i);
  });

  describe('TXT record quoting (RFC 1035 character-string)', () => {
    it('wraps a plain TXT value in double quotes — AWS rejects an unquoted Value with InvalidCharacterString', () => {
      const batch = buildChangeBatch('UPSERT', {
        ...baseRecordSet,
        type: 'TXT',
        resourceRecords: [{ value: 'phase24-verification' }],
      });
      expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords).toEqual([
        { Value: '"phase24-verification"' },
      ]);
    });

    it('escapes embedded double quotes and backslashes before quoting', () => {
      const batch = buildChangeBatch('UPSERT', {
        ...baseRecordSet,
        type: 'TXT',
        resourceRecords: [{ value: 'v=spf1 include:"weird\\path" ~all' }],
      });
      expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords?.[0].Value).toBe(
        '"v=spf1 include:\\"weird\\\\path\\" ~all"'
      );
    });

    it('splits a value over 255 characters into multiple quoted segments', () => {
      const long = 'a'.repeat(300);
      const batch = buildChangeBatch('UPSERT', {
        ...baseRecordSet,
        type: 'TXT',
        resourceRecords: [{ value: long }],
      });
      const value = batch.Changes?.[0].ResourceRecordSet?.ResourceRecords?.[0].Value ?? '';
      expect(value).toBe(`"${'a'.repeat(255)}" "${'a'.repeat(45)}"`);
    });

    it('does not quote non-TXT record values', () => {
      const batch = buildChangeBatch('UPSERT', baseRecordSet);
      expect(batch.Changes?.[0].ResourceRecordSet?.ResourceRecords).toEqual([{ Value: '1.2.3.4' }]);
    });
  });
});

describe('isRetryableAwsError', () => {
  it.each(['ThrottlingException', 'PriorRequestNotComplete', 'Throttling', 'ServiceUnavailable'])(
    'returns true for %s',
    (name) => {
      const err = new Error('boom');
      err.name = name;
      expect(isRetryableAwsError(err)).toBe(true);
    }
  );

  it('returns false for InvalidChangeBatch', () => {
    const err = new Error('boom');
    err.name = 'InvalidChangeBatch';
    expect(isRetryableAwsError(err)).toBe(false);
  });

  it('returns false for a non-Error input', () => {
    expect(isRetryableAwsError('not an error')).toBe(false);
    expect(isRetryableAwsError(undefined)).toBe(false);
  });
});

describe('submitRecordChange', () => {
  const recordSet = {
    name: 'www.example.com.',
    type: 'A',
    ttl: 300,
    resourceRecords: [{ value: '1.2.3.4' }],
  };

  it('sends a ChangeResourceRecordSetsCommand and returns the change id', async () => {
    const send = vi.fn().mockResolvedValue({
      ChangeInfo: { Id: '/change/C123', Status: 'PENDING' },
    });
    const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];

    const result = await submitRecordChange({
      zoneId: 'Z123',
      action: 'UPSERT',
      recordSet,
      client: fakeClient,
    });

    expect(result.changeId).toBe('/change/C123');
    expect(send).toHaveBeenCalledTimes(1);
  });

  it('retries up to 2 additional times on a retryable error, then succeeds', async () => {
    const throttling = new Error('throttled');
    throttling.name = 'ThrottlingException';
    const send = vi
      .fn()
      .mockRejectedValueOnce(throttling)
      .mockRejectedValueOnce(throttling)
      .mockResolvedValue({ ChangeInfo: { Id: '/change/C456', Status: 'PENDING' } });
    const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];

    const result = await submitRecordChange({
      zoneId: 'Z123',
      action: 'UPSERT',
      recordSet,
      client: fakeClient,
    });

    expect(result.changeId).toBe('/change/C456');
    expect(send).toHaveBeenCalledTimes(3);
  }, 10000);

  it('rethrows immediately on a non-retryable error without retrying', async () => {
    const invalid = new Error('invalid batch');
    invalid.name = 'InvalidChangeBatch';
    const send = vi.fn().mockRejectedValue(invalid);
    const fakeClient = { send } as unknown as Parameters<typeof submitRecordChange>[0]['client'];

    await expect(
      submitRecordChange({ zoneId: 'Z123', action: 'UPSERT', recordSet, client: fakeClient })
    ).rejects.toThrow('invalid batch');
    expect(send).toHaveBeenCalledTimes(1);
  });
});

describe('pollChangeStatus', () => {
  it('returns INSYNC as soon as the client reports ChangeInfo.Status === INSYNC', async () => {
    const send = vi.fn().mockResolvedValue({ ChangeInfo: { Status: 'INSYNC' } });
    const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
      | { client?: infer C }
      | undefined
      ? C
      : never;

    const status = await pollChangeStatus('/change/C1', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
    expect(status).toBe('INSYNC');
    expect(send).toHaveBeenCalledTimes(1);
  });

  it('returns PENDING once the timeout budget elapses without an INSYNC answer, and stops calling send() after returning', async () => {
    const send = vi.fn().mockResolvedValue({ ChangeInfo: { Status: 'PENDING' } });
    const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
      | { client?: infer C }
      | undefined
      ? C
      : never;

    const status = await pollChangeStatus('/change/C2', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
    expect(status).toBe('PENDING');

    const countAfterReturn = send.mock.calls.length;
    // Wait longer than the timeout budget to confirm no further calls happen.
    await new Promise((resolve) => setTimeout(resolve, 100));
    expect(send.mock.calls.length).toBe(countAfterReturn);
  });

  it('swallows a per-attempt GetChange error and keeps polling until the budget elapses', async () => {
    const send = vi.fn().mockRejectedValue(new Error('transient network blip'));
    const fakeClient = { send } as unknown as Parameters<typeof pollChangeStatus>[1] extends
      | { client?: infer C }
      | undefined
      ? C
      : never;

    const status = await pollChangeStatus('/change/C3', { client: fakeClient, timeoutMs: 50, intervalMs: 10 });
    expect(status).toBe('PENDING');
    expect(send.mock.calls.length).toBeGreaterThan(1);
  });
});
