import { stripIndent } from 'common-tags';
import { promises as fs } from 'fs';
import type { Server } from 'net';
import type { SinonSpy, SinonStub } from 'sinon';
import { spy, stub } from 'sinon';
import { expect } from 'chai';

import prepare = require('~/test-lib/prepare');
import * as config from '~/src/config';
import * as deviceState from '~/src/device-state';
import Log from '~/lib/supervisor-console';
import balenaAPI = require('~/test-lib/mocked-balena-api');
import { schema } from '~/src/config/schema';
import ConfigJsonConfigBackend from '~/src/config/configJson';
import * as TargetState from '~/src/api-binder/poll';
import * as ApiHelper from '~/lib/api-helper';
import supervisorVersion = require('~/lib/supervisor-version');
import * as eventTracker from '~/src/event-tracker';
import * as constants from '~/lib/constants';

import { TypedError } from 'typed-error';
import { DeviceNotFoundError } from '~/lib/errors';

let ApiBinder: typeof import('~/src/api-binder');

class ExpectedError extends TypedError {}

const initModels = async (obj: Dictionary<any>, filename: string) => {
	await prepare();

	// @ts-expect-error setting read-only property
	config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename);
	await config.generateRequiredFields();

	// @ts-expect-error using private properties
	config.configJsonBackend.cache = await config.configJsonBackend.read();
	await config.generateRequiredFields();

	obj.logger = {
		clearOutOfDateDBLogs: () => {
			/* noop */
		},
	} as any;

	ApiBinder = require('~/src/api-binder');
	await ApiBinder.initialized();
	obj.apiBinder = ApiBinder;

	await deviceState.initialized();
	obj.deviceState = deviceState;
};

const mockProvisioningOpts = {
	apiEndpoint: 'http://0.0.0.0:3000',
	uuid: 'abcd',
	deviceApiKey: 'averyvalidkey',
	provisioningApiKey: 'anotherveryvalidkey',
	apiTimeout: 30000,
};

describe('ApiBinder', () => {
	const defaultConfigBackend = config.configJsonBackend;
	let server: Server;

	before(async () => {
		delete require.cache[require.resolve('~/src/api-binder')];

		spy(balenaAPI.balenaBackend!, 'registerHandler');
		server = balenaAPI.listen(3000);

		// TODO: remove when moving this suite to integration tests
		// @ts-expect-error replace the default mac address path for tests
		constants.macAddressPath = './test/data/sys/class/net';
	});

	after(() => {
		// @ts-expect-error setting read-only property
		balenaAPI.balenaBackend!.registerHandler.restore();
		try {
			server.close();
		} catch (error) {
			/* noop */
		}

		// TODO: remove when moving this suite to integration tests
		// @ts-expect-error restore the mac address path
		constants.macAddressPath = '/sys/class/net';
	});

	// We do not support older OS versions anymore, so we only test this case
	describe('on an OS with deviceApiKey support', () => {
		const components: Dictionary<any> = {};
		let eventTrackSpy: SinonSpy;

		before(async () => {
			eventTrackSpy = spy(eventTracker, 'track');
			await initModels(components, '/config-apibinder.json');
		});

		afterEach(() => {
			eventTrackSpy.resetHistory();
		});

		after(async () => {
			eventTrackSpy.restore();

			// @ts-expect-error setting read-only property
			config.configJsonBackend = defaultConfigBackend;
			await config.generateRequiredFields();
		});

		it('provisions a device', async () => {
			const opts = await config.get('provisioningOptions');
			await ApiHelper.provision(components.apiBinder.balenaApi, opts);

			expect(balenaAPI.balenaBackend!.registerHandler).to.be.calledOnce;
			expect(eventTrackSpy).to.be.called;
			expect(eventTrackSpy).to.be.calledWith('Device bootstrap success');

			// @ts-expect-error function does not exist on type
			balenaAPI.balenaBackend!.registerHandler.resetHistory();
		});

		it('exchanges keys if resource conflict when provisioning', async () => {
			// Get current config to extend
			const currentConfig = await config.get('provisioningOptions');

			// Stub config values so we have correct conditions
			const configStub = stub(config, 'get').resolves({
				...currentConfig,
				registered_at: null,
				provisioningApiKey: '123', // Previous test case deleted the provisioningApiKey so add one
				uuid: 'not-unique', // This UUID is used in mocked-balena-api as an existing registered UUID
			});

			// If api-binder reaches this function then tests pass
			// We throw an error so we don't have to keep stubbing
			const functionToReach = stub(
				ApiHelper,
				'exchangeKeyAndGetDeviceOrRegenerate',
			).throws(new ExpectedError());

			spy(Log, 'debug');

			try {
				const opts = await config.get('provisioningOptions');
				await ApiHelper.provision(components.apiBinder.balenaApi, opts);
			} catch (e) {
				// Check that the error thrown is from this test
				expect(e).to.be.instanceOf(ExpectedError);
			}

			expect(functionToReach.called).to.be.true;
			expect((Log.debug as SinonSpy).lastCall.lastArg).to.equal(
				'UUID already registered, trying a key exchange',
			);

			// Restore stubs
			configStub.restore();
			functionToReach.restore();
			(Log.debug as SinonStub).restore();
		});

		it('deletes the provisioning key', async () => {
			expect(await config.get('apiKey')).to.be.undefined;
		});

		it('sends the correct parameters when provisioning', async () => {
			const conf = JSON.parse(
				await fs.readFile('./test/data/config-apibinder.json', 'utf8'),
			);
			expect(balenaAPI.balenaBackend!.devices).to.deep.equal({
				'1': {
					id: 1,
					application: conf.applicationId,
					uuid: conf.uuid,
					device_type: conf.deviceType,
					api_key: conf.deviceApiKey,
					mac_address: '00:11:22:33:44:55 66:77:88:99:AA:BB',
					os_variant: 'dev',
					os_version: 'balenaOS 2.0.6+rev1',
					supervisor_version: supervisorVersion,
				},
			});
		});
	});

	describe('fetchDevice', () => {
		const components: Dictionary<any> = {};
		before(() => {
			return initModels(components, '/config-apibinder.json');
		});
		after(async () => {
			// @ts-expect-error setting read-only property
			config.configJsonBackend = defaultConfigBackend;
			await config.generateRequiredFields();
		});

		it('gets a device by its uuid from the balena API', async () => {
			// Manually add a device to the mocked API
			balenaAPI.balenaBackend!.devices[3] = {
				id: 3,
				user: 'foo',
				application: 1337,
				uuid: 'abcd',
				device_type: 'intel-nuc',
				api_key: 'verysecure',
			};

			const device = await ApiHelper.fetchDevice(
				components.apiBinder.balenaApi,
				'abcd',
				'someApiKey',
				30000,
			);
			expect(device).to.deep.equal(balenaAPI.balenaBackend!.devices[3]);
		});
	});

	describe('exchangeKeyAndGetDevice', () => {
		const components: Dictionary<any> = {};
		before(() => {
			return initModels(components, '/config-apibinder.json');
		});
		after(async () => {
			// @ts-expect-error setting read-only property
			config.configJsonBackend = defaultConfigBackend;
			await config.generateRequiredFields();
		});

		it('returns the device if it can fetch it with the deviceApiKey', async () => {
			spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');

			const fetchDeviceStub = stub(ApiHelper, 'fetchDevice');
			fetchDeviceStub.onCall(0).resolves({ id: 1 });

			const device = await ApiHelper.exchangeKeyAndGetDevice(
				components.apiBinder.balenaApi,
				mockProvisioningOpts,
			);

			expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called;
			expect(device).to.deep.equal({ id: 1 });
			expect(fetchDeviceStub).to.be.calledOnce;

			// @ts-expect-error function does not exist on type
			balenaAPI.balenaBackend.deviceKeyHandler.restore();
			fetchDeviceStub.restore();
		});

		it('throws if it cannot get the device with any of the keys', () => {
			spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
			const fetchDeviceStub = stub(ApiHelper, 'fetchDevice').throws(
				new DeviceNotFoundError(),
			);

			const promise = ApiHelper.exchangeKeyAndGetDevice(
				components.apiBinder.balenaApi,
				mockProvisioningOpts,
			);
			promise.catch(() => {
				/* noop */
			});

			return expect(promise).to.be.rejected.then(() => {
				expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.not.be.called;
				expect(fetchDeviceStub).to.be.calledTwice;
				fetchDeviceStub.restore();
				// @ts-expect-error function does not exist on type
				balenaAPI.balenaBackend.deviceKeyHandler.restore();
			});
		});

		it('exchanges the key and returns the device if the provisioning key is valid', async () => {
			spy(balenaAPI.balenaBackend!, 'deviceKeyHandler');
			const fetchDeviceStub = stub(ApiHelper, 'fetchDevice');
			fetchDeviceStub.onCall(0).throws(new DeviceNotFoundError());
			fetchDeviceStub.onCall(1).returns(Promise.resolve({ id: 1 }));

			const device = await ApiHelper.exchangeKeyAndGetDevice(
				components.apiBinder.balenaApi,
				mockProvisioningOpts as any,
			);
			expect(balenaAPI.balenaBackend!.deviceKeyHandler).to.be.calledOnce;
			expect(device).to.deep.equal({ id: 1 });
			expect(fetchDeviceStub).to.be.calledTwice;
			fetchDeviceStub.restore();
			// @ts-expect-error function does not exist on type
			balenaAPI.balenaBackend.deviceKeyHandler.restore();
		});
	});

	describe('unmanaged mode', () => {
		const components: Dictionary<any> = {};
		before(() => {
			return initModels(components, '/config-apibinder-offline.json');
		});
		after(async () => {
			// @ts-expect-error setting read-only property
			config.configJsonBackend = defaultConfigBackend;
			await config.generateRequiredFields();
		});

		it('does not generate a key if the device is in unmanaged mode', async () => {
			const mode = await config.get('unmanaged');
			// Ensure offline mode is set
			expect(mode).to.equal(true);
			// Check that there is no deviceApiKey
			const conf = await config.getMany(['deviceApiKey', 'uuid']);
			expect(conf['deviceApiKey']).to.be.empty;
			expect(conf['uuid']).to.not.be.undefined;
		});

		describe('Minimal config unmanaged mode', () => {
			const components2: Dictionary<any> = {};
			before(() => {
				return initModels(components2, '/config-apibinder-offline2.json');
			});

			it('does not generate a key with the minimal config', async () => {
				const mode = await config.get('unmanaged');
				expect(mode).to.equal(true);
				const conf = await config.getMany(['deviceApiKey', 'uuid']);
				expect(conf['deviceApiKey']).to.be.empty;
				return expect(conf['uuid']).to.not.be.undefined;
			});
		});
	});

	describe('healthchecks', () => {
		const components: Dictionary<any> = {};
		let configStub: SinonStub;
		let infoLobSpy: SinonSpy;
		let previousLastFetch: ReturnType<typeof process.hrtime>;

		before(async () => {
			await initModels(components, '/config-apibinder.json');
			previousLastFetch = TargetState.lastFetch;
		});

		after(async () => {
			// @ts-expect-error setting read-only property
			config.configJsonBackend = defaultConfigBackend;
			await config.generateRequiredFields();
		});

		beforeEach(() => {
			// This configStub will be modified in each test case so we can
			// create the exact conditions we want to for testing healthchecks
			configStub = stub(config, 'getMany');
			infoLobSpy = spy(Log, 'info');
		});

		afterEach(() => {
			configStub.restore();
			infoLobSpy.restore();
			(TargetState as any).lastFetch = previousLastFetch;
		});

		it('passes with correct conditions', async () => {
			// Set unmanaged to false so we check all values
			// The other values are stubbed to make it pass
			configStub.resolves({
				unmanaged: false,
				appUpdatePollInterval: 1000,
				connectivityCheckEnabled: false,
			});
			// Set lastFetch to now so it is within appUpdatePollInterval
			(TargetState as any).lastFetch = process.hrtime();
			expect(await components.apiBinder.healthcheck()).to.equal(true);
		});

		it('passes if unmanaged is true and exit early', async () => {
			// Setup failing conditions
			configStub.resolves({
				unmanaged: false,
				appUpdatePollInterval: null,
				connectivityCheckEnabled: false,
			});
			// Verify this causes healthcheck to fail
			expect(await components.apiBinder.healthcheck()).to.equal(false);
			// Do it again but set unmanaged to true
			configStub.resolves({
				unmanaged: true,
				appUpdatePollInterval: null,
				connectivityCheckEnabled: false,
			});
			expect(await components.apiBinder.healthcheck()).to.equal(true);
		});

		it('fails if appUpdatePollInterval not set in config and exit early', async () => {
			configStub.resolves({
				unmanaged: false,
				appUpdatePollInterval: null,
				connectivityCheckEnabled: false,
			});
			expect(await components.apiBinder.healthcheck()).to.equal(false);
			expect(Log.info).to.be.calledOnce;
			expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
				'Healthcheck failure - Config value `appUpdatePollInterval` cannot be null',
			);
		});

		it("fails when hasn't checked target state within poll interval", async () => {
			configStub.resolves({
				unmanaged: false,
				appUpdatePollInterval: 1,
				connectivityCheckEnabled: false,
			});
			expect(await components.apiBinder.healthcheck()).to.equal(false);
			expect(Log.info).to.be.calledOnce;
			expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
				'Healthcheck failure - Device has not fetched target state within appUpdatePollInterval limit',
			);
		});

		it('fails when stateReportHealthy is false', async () => {
			const currentState =
				require('~/src/api-binder/report') as typeof import('~/src/api-binder/report'); // eslint-disable-line

			configStub.resolves({
				unmanaged: false,
				appUpdatePollInterval: 1000,
				connectivityCheckEnabled: true,
			});

			// Set lastFetch to now so it is within appUpdatePollInterval
			(TargetState as any).lastFetch = process.hrtime();

			// Copy previous values to restore later
			const previousStateReportErrors = currentState.stateReportErrors;
			const previousDeviceStateConnected = components.deviceState.connected;

			// Set additional conditions not in configStub to cause a fail
			try {
				currentState.stateReportErrors = 4;
				components.deviceState.connected = true;

				expect(await components.apiBinder.healthcheck()).to.equal(false);

				expect(Log.info).to.be.calledOnce;
				expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
					stripIndent`
					Healthcheck failure - At least ONE of the following conditions must be true:
						- No connectivityCheckEnabled   ? false
						- device state is disconnected  ? false
						- stateReportErrors less then 3 ? false`,
				);
			} finally {
				// Restore previous values
				currentState.stateReportErrors = previousStateReportErrors;
				components.deviceState.connected = previousDeviceStateConnected;
			}
		});
	});
});
