/**
 * whatsappSchedule — the server-side authority on WhatsApp message count
 *
 * `runWhatsAppNurturingPipeline` generates exactly one message per sequence-plan
 * entry, so `reconcilePlanToSchedule` is what decides how many messages a
 * campaign gets. These cover the three specification examples, the reconciliation
 * that makes the count independent of what the client sent, and the backward
 * -compatibility rule: no date range means no change to the old behaviour.
 */

import {
  buildSchedule,
  validateScheduleInputs,
  reconcilePlanToSchedule,
  applyScheduleToMessages,
  scheduleCountMismatch,
  toIsoDateString,
  FREQUENCY_INTERVAL_DAYS,
  MAX_SCHEDULED_MESSAGES,
} from '../whatsappSchedule';
import type { MessageFrequency } from '../../../models/WhatsAppCampaign';

const dates = (s: string, e: string, f: MessageFrequency) =>
  buildSchedule(s, e, f).slots.map((x) => x.date);

const planOf = (n: number) =>
  Array.from({ length: n }, (_, i) => ({
    day: i + 1,
    theme: `Theme ${i + 1}`,
    objective: `Objective ${i + 1}`,
    messageAngle: 'angle',
    contentApproach: 'approach',
  }));

const messagesOf = (n: number) =>
  Array.from({ length: n }, (_, i) => ({ id: `msg-${i + 1}`, day: i + 1, copy: `Copy ${i + 1}` }));

// ─── Specification examples ──────────────────────────────────────────────────

describe('specification examples', () => {
  it('Example 1 — 01→07 Aug daily → 7', () => {
    expect(dates('2026-08-01', '2026-08-07', 'daily')).toEqual([
      '2026-08-01', '2026-08-02', '2026-08-03', '2026-08-04',
      '2026-08-05', '2026-08-06', '2026-08-07',
    ]);
  });

  it('Example 2 — 01→07 Aug every 2 days → 4', () => {
    expect(dates('2026-08-01', '2026-08-07', 'alternate-day'))
      .toEqual(['2026-08-01', '2026-08-03', '2026-08-05', '2026-08-07']);
  });

  it('Example 3 — 01→14 Aug weekly → 2', () => {
    expect(dates('2026-08-01', '2026-08-14', 'weekly')).toEqual(['2026-08-01', '2026-08-08']);
  });

  it('matches the frontend copy of the interval table', () => {
    expect(FREQUENCY_INTERVAL_DAYS).toEqual({
      'daily': 1, 'alternate-day': 2, 'every-3-days': 3, 'weekly': 7,
    });
  });
});

// ─── Input normalisation ─────────────────────────────────────────────────────

describe('toIsoDateString', () => {
  it('accepts plain dates, ISO timestamps and Date objects', () => {
    expect(toIsoDateString('2026-08-01')).toBe('2026-08-01');
    expect(toIsoDateString('2026-08-01T00:00:00.000Z')).toBe('2026-08-01');
    expect(toIsoDateString(new Date(Date.UTC(2026, 7, 1)))).toBe('2026-08-01');
  });

  it('rejects anything else', () => {
    expect(toIsoDateString('01/08/2026')).toBeNull();
    expect(toIsoDateString('')).toBeNull();
    expect(toIsoDateString(null)).toBeNull();
    expect(toIsoDateString(12345)).toBeNull();
    expect(toIsoDateString(new Date('nonsense'))).toBeNull();
  });

  it('builds the same schedule from Dates as from strings', () => {
    // A campaign loaded from Mongo carries real Dates, not strings.
    const fromDates = buildSchedule(
      new Date(Date.UTC(2026, 7, 1)), new Date(Date.UTC(2026, 7, 7)), 'alternate-day',
    );
    expect(fromDates.slots.map((s) => s.date))
      .toEqual(['2026-08-01', '2026-08-03', '2026-08-05', '2026-08-07']);
  });
});

// ─── Validation ──────────────────────────────────────────────────────────────

describe('validateScheduleInputs', () => {
  it('accepts a valid range', () => {
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', 'daily')).toBeNull();
  });

  it('rejects each missing field in turn', () => {
    expect(validateScheduleInputs(undefined, '2026-08-07', 'daily')).toBe('start-required');
    expect(validateScheduleInputs('2026-08-01', undefined, 'daily')).toBe('end-required');
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', undefined)).toBe('frequency-required');
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', 'fortnightly')).toBe('frequency-required');
  });

  it('rejects an inverted range and impossible dates', () => {
    expect(validateScheduleInputs('2026-08-07', '2026-08-01', 'daily')).toBe('end-before-start');
    expect(validateScheduleInputs('2026-02-30', '2026-08-01', 'daily')).toBe('invalid-date');
  });
});

// ─── Plan reconciliation — this decides the message count ────────────────────

describe('reconcilePlanToSchedule', () => {
  const schedule = buildSchedule('2026-08-01', '2026-08-07', 'alternate-day'); // 4 slots

  it('truncates a plan longer than the schedule', () => {
    // The client asked for 7; the range and frequency allow 4.
    const result = reconcilePlanToSchedule(planOf(7), schedule);
    expect(result).toHaveLength(4);
    expect(result.map((p) => p.scheduledDate))
      .toEqual(['2026-08-01', '2026-08-03', '2026-08-05', '2026-08-07']);
    expect(result.map((p) => p.day)).toEqual([1, 3, 5, 7]);
  });

  it('pads a plan shorter than the schedule by cycling its themes', () => {
    const result = reconcilePlanToSchedule(planOf(2), schedule);
    expect(result).toHaveLength(4);
    expect(result.map((p) => p.theme)).toEqual(['Theme 1', 'Theme 2', 'Theme 1', 'Theme 2']);
    // Padded entries still get their own correct dates.
    expect(result[3].scheduledDate).toBe('2026-08-07');
  });

  it('keeps a correctly sized plan intact apart from the stamps', () => {
    const result = reconcilePlanToSchedule(planOf(4), schedule);
    expect(result.map((p) => p.theme)).toEqual(['Theme 1', 'Theme 2', 'Theme 3', 'Theme 4']);
    expect(result).toHaveLength(4);
  });

  it('leaves the plan untouched when there is no date range', () => {
    // Backward compatibility: the wizard and pre-existing campaigns send none,
    // and must keep their duration-driven plan exactly as it was.
    const empty = buildSchedule(undefined, undefined, 'daily');
    expect(empty.count).toBe(0);
    expect(reconcilePlanToSchedule(planOf(7), empty)).toHaveLength(7);
    expect(reconcilePlanToSchedule(planOf(7), empty)[0].scheduledDate).toBeUndefined();
  });

  it('survives a missing or malformed plan', () => {
    expect(reconcilePlanToSchedule(null, schedule)).toEqual([]);
    expect(reconcilePlanToSchedule([], schedule)).toEqual([]);
    expect(reconcilePlanToSchedule([null, undefined] as never, schedule)).toEqual([]);
  });

  it('produces no duplicate dates over a long range', () => {
    const long = buildSchedule('2026-01-01', '2026-06-30', 'weekly');
    const result = reconcilePlanToSchedule(planOf(3), long);
    expect(result).toHaveLength(long.count);
    expect(new Set(result.map((p) => p.scheduledDate)).size).toBe(long.count);
  });
});

// ─── Message stamping ────────────────────────────────────────────────────────

describe('applyScheduleToMessages', () => {
  const schedule = buildSchedule('2026-08-01', '2026-08-07', 'alternate-day'); // 4 slots

  it('maps each message to its scheduled date in order', () => {
    const result = applyScheduleToMessages(messagesOf(4), schedule);
    expect(result.map((m) => ({ copy: m.copy, scheduledDate: m.scheduledDate }))).toEqual([
      { copy: 'Copy 1', scheduledDate: '2026-08-01' },
      { copy: 'Copy 2', scheduledDate: '2026-08-03' },
      { copy: 'Copy 3', scheduledDate: '2026-08-05' },
      { copy: 'Copy 4', scheduledDate: '2026-08-07' },
    ]);
  });

  it('drops surplus messages the model produced beyond the schedule', () => {
    expect(applyScheduleToMessages(messagesOf(9), schedule)).toHaveLength(4);
  });

  it('does not invent messages when the model returned too few', () => {
    // Under-generation is left visible rather than padded with empty copy.
    const result = applyScheduleToMessages(messagesOf(2), schedule);
    expect(result).toHaveLength(2);
    expect(result.map((m) => m.scheduledDate)).toEqual(['2026-08-01', '2026-08-03']);
  });

  it('preserves every other field on the message', () => {
    const rich = [{ id: 'm1', day: 99, copy: 'c', cta: 'Book now', status: 'pending', contentBlocks: [] }];
    const [out] = applyScheduleToMessages(rich, schedule);
    expect(out.cta).toBe('Book now');
    expect(out.status).toBe('pending');
    expect(out.contentBlocks).toEqual([]);
    // The schedule overrides the model's own day numbering.
    expect(out.day).toBe(1);
  });

  it('leaves messages untouched when there is no date range', () => {
    const empty = buildSchedule(undefined, undefined, 'daily');
    const result = applyScheduleToMessages(messagesOf(7), empty);
    expect(result).toHaveLength(7);
    expect(result[0].scheduledDate).toBeUndefined();
  });

  it('survives a missing message list', () => {
    expect(applyScheduleToMessages(null, schedule)).toEqual([]);
    expect(applyScheduleToMessages(undefined, schedule)).toEqual([]);
  });
});

// ─── Count ceiling ───────────────────────────────────────────────────────────

describe('ceiling', () => {
  it('caps at the schema’s 90-message bound', () => {
    const result = buildSchedule('2026-01-01', '2026-12-31', 'daily');
    expect(result.count).toBe(MAX_SCHEDULED_MESSAGES);
    expect(result.truncated).toBe(true);
    expect(reconcilePlanToSchedule(planOf(5), result)).toHaveLength(MAX_SCHEDULED_MESSAGES);
  });
});

// ─── Count enforcement ───────────────────────────────────────────────────────

describe('scheduleCountMismatch', () => {
  const schedule = buildSchedule('2026-08-01', '2026-08-07', 'alternate-day'); // 4 slots

  it('passes when the count matches the schedule', () => {
    expect(scheduleCountMismatch(messagesOf(4), schedule)).toBeNull();
  });

  it('reports a short generation rather than saving missing sends', () => {
    // Padding the gap with invented messages would be worse than failing.
    const error = scheduleCountMismatch(messagesOf(2), schedule);
    expect(error).toContain('2 messages');
    expect(error).toContain('requires 4');
  });

  it('reports an empty generation', () => {
    expect(scheduleCountMismatch([], schedule)).toContain('0 messages');
    expect(scheduleCountMismatch(null, schedule)).toContain('0 messages');
  });

  it('is silent when no date range applies', () => {
    // Pre-existing duration-based campaigns must not be failed by a new rule.
    const empty = buildSchedule(undefined, undefined, 'daily');
    expect(scheduleCountMismatch(messagesOf(3), empty)).toBeNull();
    expect(scheduleCountMismatch([], empty)).toBeNull();
  });

  it('never fires after applyScheduleToMessages trimmed a surplus', () => {
    const trimmed = applyScheduleToMessages(messagesOf(9), schedule);
    expect(scheduleCountMismatch(trimmed, schedule)).toBeNull();
  });

  it('uses singular wording for a single message', () => {
    expect(scheduleCountMismatch(messagesOf(1), schedule)).toContain('1 message but');
  });
});

// ─── Frequency Setup is mandatory server-side ────────────────────────────────

describe('mandatory Frequency Setup (server)', () => {
  it('rejects an unset frequency', () => {
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', '')).toBe('frequency-required');
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', null)).toBe('frequency-required');
  });

  it('rejects the Blog Content frequencies, which WhatsApp does not support', () => {
    // The two modules have disjoint frequency vocabularies; accepting a blog
    // value here would silently produce an empty schedule.
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', 'bi-weekly')).toBe('frequency-required');
    expect(validateScheduleInputs('2026-08-01', '2026-08-07', 'monthly')).toBe('frequency-required');
  });
});
