import { TimeStrategy, EndAction, TimerType, OntimeEvent } from 'ontime-types';
import { MILLIS_PER_HOUR } from 'ontime-utils';

import { assertType } from 'vitest';

import { calculateDayOffset, createEvent, deleteById, doesInvalidateMetadata, getInsertAfterId, hasChanges } from '../rundown.utils.js';
import { makeRundown } from '../__mocks__/rundown.mocks.js';

describe('test event validator', () => {
  it('validates a good object', () => {
    const event = {
      title: 'test',
    };
    const validated = createEvent(event, 1);

    expect(validated).toEqual(
      expect.objectContaining({
        title: expect.any(String),
        note: expect.any(String),
        timeStart: expect.any(Number),
        timeEnd: expect.any(Number),
        countToEnd: expect.any(Boolean),
        skip: expect.any(Boolean),
        revision: expect.any(Number),
        type: expect.any(String),
        id: expect.any(String),
        cue: '2',
        colour: expect.any(String),
        custom: expect.any(Object),
      }),
    );
  });

  it('fails an empty object', () => {
    const event = {};
    const validated = createEvent(event, 1);
    expect(validated).toEqual(null);
  });

  it('makes objects strings', () => {
    const event = {
      title: 2,
      note: '1899-12-30T08:00:10.000Z',
    };
    // @ts-expect-error -- we know this is wrong, testing imports outside domain
    const validated = createEvent(event, 1);
    if (validated === null) {
      throw new Error('unexpected value');
    }
    expect(typeof validated.title).toEqual('string');
    expect(typeof validated.note).toEqual('string');
  });

  it('enforces numbers on times', () => {
    const event = {
      timeStart: false,
      timeEnd: '2',
    };
    // @ts-expect-error -- we know this is wrong, testing imports outside domain
    const validated = createEvent(event);
    if (validated === null) {
      throw new Error('unexpected value');
    }
    assertType<number>(validated.timeStart);
    assertType<number>(validated.timeEnd);
    assertType<number>(validated.duration);
    expect(validated.timeStart).toEqual(0);
    expect(validated.timeEnd).toEqual(2);
    expect(validated.duration).toEqual(2);
  });

  it('handles bad objects', () => {
    const event = {
      title: {},
    };
    // @ts-expect-error -- we know this is wrong, testing imports outside domain
    const validated = createEvent(event);
    if (validated === null) {
      throw new Error('unexpected value');
    }
    expect(typeof validated.title).toEqual('string');
  });
});

describe('doesInvalidateMetadata()', () => {
  it('is stale if data contains timers', () => {
    const needsRecompute = [
      { timeStart: 10 },
      { timeEnd: 10 },
      { duration: 10 },
      { linkStart: true },
      { timerStrategy: TimeStrategy.LockDuration },
    ];

    for (const testCase of needsRecompute) {
      expect(doesInvalidateMetadata(testCase)).toBe(true);
    }
    expect.assertions(needsRecompute.length);
  });

  it('is not stale if data contains auxiliary dataset', () => {
    expect(
      doesInvalidateMetadata({
        cue: 'cue',
        title: 'title',
        note: 'note',
        endAction: EndAction.LoadNext,
        timerType: TimerType.Clock,
        colour: 'colour',
        timeWarning: 1,
        timeDanger: 2,
        custom: {
          lighting: '3',
        },
      }),
    ).toBe(false);
  });
});

describe('hasChanges()', () => {
  it('identifies objects with new values', () => {
    const newEvent = { id: '1', title: 'new-title' } as OntimeEvent;
    const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
    expect(hasChanges(existing, newEvent)).toBe(true);
  });
  it('identifies objects with all same values', () => {
    const newEvent = { id: '1', title: 'title' } as OntimeEvent;
    const existing = { id: '1', cue: 'cue', title: 'title' } as OntimeEvent;
    expect(hasChanges(existing, newEvent)).toBe(false);
  });
});

describe('deleteById()', () => {
  it('should delete the first instance of the specified ID from the array', () => {
    const array = ['id1', 'id2', 'id3', 'id4'];
    const result = deleteById(array, 'id2');
    expect(result).toStrictEqual(['id1', 'id3', 'id4']);
    expect(result).not.toBe(array); // Ensure a new array is returned
  });

  it('should not modify the array if the specified ID does not exist', () => {
    const array = ['id1', 'id2', 'id3', 'id4'];
    const result = deleteById(array, 'id5');
    expect(result).toStrictEqual(['id1', 'id2', 'id3', 'id4']);
  });

  it('should return the same array if it is empty', () => {
    const array: string[] = [];
    const result = deleteById(array, 'id1');
    expect(result).toStrictEqual([]);
  });

  it('should handle scenarios where the delete id is not found', () => {
    const array = ['id1', 'id2', 'id3'];
    const result = deleteById(array, 'id4');
    expect(result).toStrictEqual(['id1', 'id2', 'id3']);
  });
});

describe('calculateDayOffset()', () => {
  it('returns 0 if there is no previous event', () => {
    expect(calculateDayOffset({ timeStart: 0 }, null)).toBe(0);
  });

  it('returns 0 if the previous event duration is 0', () => {
    expect(calculateDayOffset({ timeStart: 0 }, { timeStart: 0, duration: 0 })).toBe(0);
  });

  it('returns 0 if event starts after previous', () => {
    expect(calculateDayOffset({ timeStart: 11 }, { timeStart: 10, duration: 2 })).toBe(0);
  });

  it('returns 1 if event starts before previous', () => {
    expect(calculateDayOffset({ timeStart: 9 }, { timeStart: 10, duration: 2 })).toBe(1);
  });

  it('returns 1 if event starts at the same time as one before', () => {
    expect(calculateDayOffset({ timeStart: 10 }, { timeStart: 10, duration: 2 })).toBe(1);
  });

  it('should account for an event that crossed midnight and there is a overlap', () => {
    expect(
      calculateDayOffset(
        { timeStart: MILLIS_PER_HOUR }, // starts at 01:00:00
        { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
      ),
    ).toBe(1);
  });

  it('should account for an event that crossed midnight and there is a gap', () => {
    expect(
      calculateDayOffset(
        { timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
        { timeStart: 23 * MILLIS_PER_HOUR, duration: 2 * MILLIS_PER_HOUR }, // ends at 01:00:00
      ),
    ).toBe(1);
  });

  it('should account for an event that crossed midnight with no overlaps or gaps', () => {
    expect(
      calculateDayOffset(
        { timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
        { timeStart: 20 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 02:00:00
      ),
    ).toBe(1);
  });

  it('should account for an event that finishes exactly at midnight', () => {
    expect(
      calculateDayOffset(
        { timeStart: 2 * MILLIS_PER_HOUR }, // starts at 02:00:00
        { timeStart: 23 * MILLIS_PER_HOUR, duration: 6 * MILLIS_PER_HOUR }, // ends at 24:00:00
      ),
    ).toBe(1);
  });
});

describe('getInsertAfterId()', () => {
  const rundown = makeRundown({
    flatOrder: ['a', 'b', 'c', 'd'],
  });

  it('returns afterId if provided', () => {
    expect(getInsertAfterId(rundown, 'b')).toBe('b');
  });

  it('returns the previous id before beforeId if provided', () => {
    expect(getInsertAfterId(rundown, undefined, 'c')).toBe('b');
  });

  it('returns undefined if neither afterId nor beforeId is provided', () => {
    expect(getInsertAfterId(rundown)).toBeNull();
  });

  it('returns undefined if beforeId is not found', () => {
    expect(getInsertAfterId(rundown, undefined, 'z')).toBeNull();
  });
});
