import fs from 'fs';

import { getAPI } from '@anmiles/google-api-wrapper';
import { validate } from '@anmiles/zod-tools';
import type GoogleApis from 'googleapis';
// eslint-disable-next-line camelcase
import type { calendar_v3, MethodOptions } from 'googleapis/build/src/apis/calendar';
import { calendar } from 'googleapis/build/src/apis/calendar';
import '@anmiles/jest-extensions';
import mockFs from 'mock-fs';

import { sync } from '../sync';
import {   scheduleSchema } from '../types';
import type { LessonCalendar, Schedule } from '../types';
import { getSampleScheduleFile, getScheduleFile } from '../utils/paths';

jest.mock('@anmiles/google-api-wrapper');
jest.mock('@anmiles/logger');
jest.mock('googleapis/build/src/apis/calendar');

type Calendar = {
	id: string;
	summary: string;
};

type Event = {
	id?: string;
};

const profile = 'username';

const calendarApis = {
	calendarList: 'calendarList',
	events      : 'events',
} as const;

function mockGetItems(selectAPI: (api: typeof calendarApis) => typeof calendarApis[keyof typeof calendarApis]): typeof calendars | typeof events {
	switch (selectAPI(calendarApis)) {
		case calendarApis.calendarList: return calendars;
		case calendarApis.events: return events;
	}
}

const getItems = jest.mocked(jest.fn().mockImplementation(mockGetItems));

const api = {
	events: {
		insert: jest.fn(),
		delete: jest.fn(),
	},
};

// eslint-disable-next-line @typescript-eslint/require-await -- allow partial mock
const getAPIMock = jest.fn().mockImplementation(async () => ({ getItems, api }));

const auth = { kind: 'auth' };

let calendars: Calendar[];
let events: Event[];
let selectedCalendar: Calendar;

jest.mocked(getAPI).mockImplementation((...args: unknown[]) => getAPIMock(...args));

// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
jest.mocked(calendar).mockReturnValue(calendarApis as unknown as GoogleApis.calendar_v3.Calendar);

const scheduleJSON = fs.readJSON(getSampleScheduleFile());
const schedule     = validate(scheduleJSON, scheduleSchema);

beforeAll(() => {
	jest.spyOn(Intl.DateTimeFormat.prototype, 'resolvedOptions').mockReturnValue({
		locale         : 'en-US',
		calendar       : 'gregory',
		numberingSystem: 'latn',
		timeZone       : 'UTC',
		timeZoneName   : 'short',
	});

	jest.spyOn(Date.prototype, 'getFullYear').mockReturnValue(2025);
});

beforeEach(() => {
	mockFs({
		[getScheduleFile()]: JSON.stringify(schedule),
	});

	calendars = [
		{ id: 'lessons-1th', summary: 'First grade lessons' },
		{ id: 'lessons-hs', summary: 'High school lessons' },
		{ id: 'sections-1th', summary: 'First grade sections' },
	];

	events = [
		{ id: 'id1' },
		{},
		{ id: 'id3' },
	];

	selectedCalendar = calendars[0]!;
});

afterAll(() => {
	mockFs.restore();
});

const fullScopes = [
	'https://www.googleapis.com/auth/calendar.calendars.readonly',
	'https://www.googleapis.com/auth/calendar.calendarlist.readonly',
	'https://www.googleapis.com/auth/calendar.events.owned',
];

describe('src/lib/sync', () => {
	describe('sync', () => {
		it('should get calendar API', async () => {
			await sync(profile);

			expect(getAPIMock).toHaveBeenCalledWith(expect.toBeFunction([ auth ], calendarApis), profile, { temporary: true, scopes: fullScopes });
			expect(calendar).toHaveBeenCalledWith({ version: 'v3', auth });
		});

		it('should get all calendars without showing progress', async () => {
			await sync(profile);

			expect(getItems).toHaveBeenCalledWith(expect.anything(), {}, { hideProgress: true });
			expect(getItems.mock.calls[0]?.[0](calendarApis)).toEqual(calendarApis.calendarList);
		});

		it('should throw if there are no available calendars', async () => {
			calendars = [];

			const promise = sync(profile);

			await expect(promise).rejects.toEqual(new Error(`There are no available calendars for profile '${profile}'`));
		});

		it('should throw if the specified calendar is not found', async () => {
			const wrongCalendarName = 'Wrong calendar';

			const promise = sync(profile, wrongCalendarName);

			await expect(promise).rejects.toThrow(`There is no calendar '${wrongCalendarName}' for profile '${profile}'`);
		});

		it('should throw if there are no matching calendars', async () => {
			const savedSchedule = fs.readJSON<Schedule>(getScheduleFile());
			// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
			const savedCalendar = savedSchedule.calendars[0] as LessonCalendar;

			savedCalendar.name         = 'random calendar name';
			savedSchedule.calendars[0] = savedCalendar;

			mockFs({
				[getScheduleFile()]: JSON.stringify(savedSchedule),
			});

			const promise = sync(profile);

			await expect(promise).rejects.toEqual(new Error(`Unknown calendar 'random calendar name' for profile '${profile}'`));
		});

		it('should throw if no time described for a lesson', async () => {
			const savedSchedule = fs.readJSON<Schedule>(getScheduleFile());
			// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
			const savedCalendar = savedSchedule.calendars[0] as LessonCalendar;

			savedCalendar.days[1]!.push('6th lesson');
			savedSchedule.calendars[0] = savedCalendar;

			mockFs({
				[getScheduleFile()]: JSON.stringify(savedSchedule),
			});

			const promise = sync(profile);

			await expect(promise).rejects.toEqual(new Error('Cannot find time described for lesson #6 in calendar \'First grade lessons\''));
		});

		it('should get events for calendars without showing progress', async () => {
			await sync(profile);

			expect(getItems).toHaveBeenCalledTimes(4);

			expect(getItems).toHaveBeenCalledWith(
				expect.toBeFunction([ calendarApis ], calendarApis.calendarList),
				{},
				{ hideProgress: true },
			);

			calendars.forEach((calendar) => {
				expect(getItems).toHaveBeenCalledWith(
					expect.toBeFunction([ calendarApis ], calendarApis.events),
					{ calendarId: calendar.id },
					{ hideProgress: true },
				);
			});
		});

		it('should get events for the specified calendar without showing progress', async () => {
			await sync(profile, selectedCalendar.summary);

			expect(getItems).toHaveBeenCalledTimes(2);

			expect(getItems).toHaveBeenCalledWith(
				expect.toBeFunction([ calendarApis ], calendarApis.calendarList),
				{},
				{ hideProgress: true },
			);

			expect(getItems).toHaveBeenCalledWith(
				expect.toBeFunction([ calendarApis ], calendarApis.events),
				{ calendarId: selectedCalendar.id },
				{ hideProgress: true },
			);
		});

		it('should delete all existing events in calendars', async () => {
			await sync(profile);

			expect(api.events.delete).toHaveBeenCalledTimes(9);

			calendars.forEach((calendar) => {
				expect(api.events.delete).toHaveBeenCalledWith({ calendarId: calendar.id, eventId: 'id1' });
				expect(api.events.delete).toHaveBeenCalledWith({ calendarId: calendar.id, eventId: '' });
				expect(api.events.delete).toHaveBeenCalledWith({ calendarId: calendar.id, eventId: 'id3' });
			});
		});

		it('should create all events for schedule', async () => {
			await sync(profile);

			expect(api.events.insert.mock.calls.map(stringifyInsertCall).join('\n')).toMatchSnapshot();
		});
	});
});

// eslint-disable-next-line camelcase
function stringifyInsertCall(insertCall: [calendar_v3.Params$Resource$Events$Insert, MethodOptions]): string {
	return [
		insertCall[0].calendarId,
		JSON.stringify(insertCall[1]),
		insertCall[0].requestBody?.start?.dateTime,
		insertCall[0].requestBody?.start?.timeZone,
		insertCall[0].requestBody?.end?.dateTime,
		insertCall[0].requestBody?.end?.timeZone,
		insertCall[0].requestBody?.recurrence,
		insertCall[0].requestBody?.summary,
		insertCall[0].requestBody?.location,
	].join('\t');
}
