import { expect } from 'chai';

import * as imageManager from '~/src/compose/images';
import { createImage, withMockerode } from '~/test-lib/mockerode';
import { createDockerImage } from '~/test-lib/docker-helper';

import Docker from 'dockerode';
import * as db from '~/src/db';

// TODO: this code is duplicated in multiple tests
// create a test module with all helper functions like this
function createDBImage(
	{
		appId = 1,
		name = 'test-image',
		serviceName = 'test',
		...extra
	} = {} as Partial<imageManager.Image>,
) {
	return {
		appId,
		name,
		serviceName,
		...extra,
	} as imageManager.Image;
}

describe('compose/images', () => {
	const docker = new Docker();
	before(async () => {
		await db.initialized();
	});

	afterEach(async () => {
		await db.models('image').del();
	});

	after(async () => {
		await docker.pruneImages({ filters: { dangling: { false: true } } });
	});

	it('finds image by matching digest on the database', async () => {
		const dbImage = createDBImage({
			name: 'registry2.balena-cloud.com/v2/aaaaa@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
			dockerImageId:
				'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
		});
		await db.models('image').insert([dbImage]);

		const images = [
			createImage(
				{
					Id: 'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
				},
				{
					References: [
						// Different image repo
						'registry2.balena-cloud.com/v2/bbbb@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					],
				},
			),
		];

		// INFO: We use mockerode here because cannot create images with a specific digest on the engine
		// but we need to be able to test looking images by digest
		await withMockerode(
			async (mockerode) => {
				// Looking by name should fail, if not, this is a mockerode issue
				await expect(mockerode.getImage(dbImage.name).inspect()).to.be.rejected;

				// Looking up the image by id should succeed
				await expect(mockerode.getImage(dbImage.dockerImageId!).inspect()).to
					.not.be.rejected;

				// The image is found
				expect(await imageManager.inspectByName(dbImage.name))
					.to.have.property('Id')
					.that.equals(
						'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
					);

				// It couldn't find the image by name so it finds it by matching digest
				expect(mockerode.getImage).to.have.been.calledWith(
					'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
				);
			},
			{ images },
		);
	});

	it('finds image by tag on the engine', async () => {
		const dockerImageId = await createDockerImage(
			'some-image:some-tag',
			['io.balena.testing=1'],
			docker,
		);

		expect(await imageManager.inspectByName('some-image:some-tag'))
			.to.have.property('Id')
			.that.equals(dockerImageId);

		await expect(
			imageManager.inspectByName('non-existing-image:non-existing-tag'),
		).to.be.rejected;

		await docker.getImage('some-image:some-tag').remove();
	});

	it('finds image by reference on the engine', async () => {
		const images = [
			createImage(
				{
					Id: 'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
				},
				{
					References: [
						// the reference we expect to look for is registry2.balena-cloud.com/v2/one
						'registry2.balena-cloud.com/v2/one:delta-one@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
						'registry2.balena-cloud.com/v2/one:latest@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					],
				},
			),
		];

		// INFO: we cannot create specific references to test on the engine, we need to
		// use mockerode instead.
		// QUESTION: Maybe the image search is overspecified and we should find a
		// common identifier for all image search (e.g. label?)
		await withMockerode(
			async (mockerode) => {
				// This is really testing mockerode functionality
				expect(
					await mockerode.listImages({
						filters: {
							reference: ['registry2.balena-cloud.com/v2/one'],
						},
					}),
				).to.have.lengthOf(1);

				expect(
					await imageManager.inspectByName(
						// different target digest but same imageName
						'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					),
				)
					.to.have.property('Id')
					.that.equals(
						'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
					);

				expect(mockerode.getImage).to.have.been.calledWith(
					'sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc90e8e7',
				);

				// Looking for the reference with correct name tag shoud not throw
				await expect(
					imageManager.inspectByName(
						// different target digest but same tag
						'registry2.balena-cloud.com/v2/one:delta-one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					),
				).to.not.be.rejected;

				// Looking for a non existing reference should throw
				await expect(
					imageManager.inspectByName(
						'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					),
				).to.be.rejected;
				await expect(
					imageManager.inspectByName(
						'registry2.balena-cloud.com/v2/one:some-tag@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					),
				).to.be.rejected;
			},
			{ images },
		);
	});

	it('returns all images in both the database and the engine', async () => {
		await db.models('image').insert([
			createDBImage({
				name: 'first-image-name:first-image-tag',
				serviceName: 'app_1',
				dockerImageId: 'sha256:first-image-id',
			}),
			createDBImage({
				name: 'second-image-name:second-image-tag',
				serviceName: 'app_2',
				dockerImageId: 'sha256:second-image-id',
			}),
			createDBImage({
				name: 'registry2.balena-cloud.com/v2/three@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf558',
				serviceName: 'app_3',
				// Third image has different name but same docker id
				dockerImageId: 'sha256:second-image-id',
			}),
			createDBImage({
				name: 'fourth-image-name:fourth-image-tag',
				serviceName: 'app_4',
				// The fourth image exists on the engine but with the wrong id
				dockerImageId: 'sha256:fourth-image-id',
			}),
		]);

		const images = [
			createImage(
				{
					Id: 'sha256:first-image-id',
				},
				{
					References: ['first-image-name:first-image-tag'],
				},
			),
			createImage(
				{
					Id: 'sha256:second-image-id',
				},
				{
					References: [
						// The tag for the second image does not exist on the engine but it should be found
						'not-second-image-name:some-image-tag',
						'fourth-image-name:fourth-image-tag',
						'registry2.balena-cloud.com/v2/three@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf558',
					],
				},
			),
		];

		// Perform the test with our specially crafted data
		await withMockerode(
			async (mockerode) => {
				// failsafe to check for mockerode problems
				expect(
					await mockerode.listImages(),
					'images exist on the engine before test',
				).to.have.lengthOf(2);

				const availableImages = await imageManager.getAvailable();
				expect(availableImages).to.have.lengthOf(4);
			},
			{ images },
		);
	});

	it('removes a single legacy db images without dockerImageId', async () => {
		await createDockerImage(
			'image-name:image-tag',
			['io.balena.testing=1'],
			docker,
		);

		// Legacy images don't have a dockerImageId so they are queried by name
		const imageToRemove = createDBImage({
			name: 'image-name:image-tag',
		});

		await db.models('image').insert([imageToRemove]);

		// Check that our legacy image exists
		// failsafe to check for mockerode problems
		await expect(
			docker.getImage(imageToRemove.name).inspect(),
			'image exists on the engine before test',
		).to.not.be.rejected;

		// Check that the image exists on the db
		expect(
			await db.models('image').select().where(imageToRemove),
		).to.have.lengthOf(1);

		// Now remove this image...
		await imageManager.remove(imageToRemove);

		// Check that the image was removed from the db
		expect(await db.models('image').select().where(imageToRemove)).to.be.empty;
	});

	it('removes image from DB and engine when there is a single DB image with matching name', async () => {
		// Newer image
		const imageToRemove = createDBImage({
			name: 'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
			dockerImageId: 'sha256:image-id-one',
		});

		// Insert images into the db
		await db.models('image').insert([
			imageToRemove,
			createDBImage({
				name: 'registry2.balena-cloud.com/v2/two@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
				dockerImageId: 'sha256:image-id-two',
			}),
		]);

		// Engine image state
		const images = [
			// The image to remove
			createImage(
				{
					Id: 'sha256:image-id-one',
				},
				{
					References: [
						'registry2.balena-cloud.com/v2/one:delta-one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					],
				},
			),
			// Other images to test
			createImage(
				{
					Id: 'aaa',
				},
				{
					References: ['balena/aarch64-supervisor:11.11.11'],
				},
			),
			// The other image on the database
			createImage(
				{
					Id: 'sha256:image-id-two',
				},
				{
					References: [
						'registry2.balena-cloud.com/v2/two:delta-two@sha256:12345a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
					],
				},
			),
		];

		// Perform the test with our specially crafted data
		await withMockerode(
			async (mockerode) => {
				// Check that the image exists
				// this is really checking that mockerode works ok
				await expect(
					mockerode.getImage(imageToRemove.name).inspect(),
					'image exists on the engine before test',
				).to.not.be.rejected;

				// Check that only one image with this dockerImageId exists in the db
				// in memory db is a bit flaky sometimes, this checks for issues
				expect(
					await db.models('image').where(imageToRemove).select(),
					'image exists on db before the test',
				).to.have.lengthOf(1);

				// Now remove this image...
				await imageManager.remove(imageToRemove);

				// Check that the remove method was only called once
				expect(mockerode.removeImage).to.have.been.calledOnceWith(
					'registry2.balena-cloud.com/v2/one:delta-one',
				);

				// Check that the database no longer has this image
				expect(await db.models('image').select().where(imageToRemove)).to.be
					.empty;

				// Expect 1 entry left on the database
				expect(await db.models('image').select()).to.have.lengthOf(1);
			},
			{ images },
		);
	});

	it('removes the requested image even when there are multiple DB images with same docker ID', async () => {
		const dockerImageId = await createDockerImage(
			'registry2.balena-cloud.com/v2/one',
			['io.balena.testing=1'],
			docker,
		);

		const imageToRemove = createDBImage({
			name: 'registry2.balena-cloud.com/v2/one',
			dockerImageId,
		});

		const imageWithSameDockerImageId = createDBImage({
			name: 'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
			// Same imageId
			dockerImageId,
		});

		// Insert images into the db
		await db.models('image').insert([
			imageToRemove,
			// Another image from the same app
			imageWithSameDockerImageId,
		]);

		// Check that multiple images with the same dockerImageId are returned
		expect(
			await db
				.models('image')
				.where({ dockerImageId: imageToRemove.dockerImageId })
				.select(),
		).to.have.lengthOf(2);

		// Now remove these images
		await imageManager.remove(imageToRemove);

		// Check that the database no longer has this image
		expect(await db.models('image').select().where(imageToRemove)).to.be.empty;

		// Check that the image with the same dockerImageId is still on the database
		expect(
			await db
				.models('image')
				.select()
				.where({ dockerImageId: imageWithSameDockerImageId.dockerImageId }),
		).to.have.lengthOf(1);
	});

	it('removes image from DB by tag when deltas are being used', async () => {
		const imageToRemove = createDBImage({
			name: 'registry2.balena-cloud.com/v2/one@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
			dockerImageId: 'sha256:image-one-id',
		});

		const imageWithSameDockerImageId = createDBImage({
			name: 'registry2.balena-cloud.com/v2/two@sha256:2c969a1ba1c6bc10df53481f48c6a74dbd562cfb41ba58f81beabd03facf5582',
			// Same docker id
			dockerImageId: 'sha256:image-one-id',
		});

		// Insert images into the db
		await db.models('image').insert([
			imageToRemove,
			// Another image from the same app
			imageWithSameDockerImageId,
		]);

		// Engine image state
		const images = [
			// The image to remove
			createImage(
				{
					Id: imageToRemove.dockerImageId!,
				},
				{
					References: [
						// The image has two deltas with different digests than those in image.name
						'registry2.balena-cloud.com/v2/one:delta-one@sha256:6eb712fc797ff68f258d9032cf292c266cb9bd8be4cbdaaafeb5a8824bb104fd',
						'registry2.balena-cloud.com/v2/two:delta-two@sha256:f1154d76c731f04711e5856b6e6858730e3023d9113124900ac65c2ccc901234',
					],
				},
			),
		];

		// Perform the test with our specially crafted data
		await withMockerode(
			async (mockerode) => {
				// Check that the image is on the engine
				await expect(
					mockerode.getImage(imageToRemove.dockerImageId!).inspect(),
					'image can be found by id before the test',
				).to.not.be.rejected;

				// Check that a single image is returned when given entire object
				expect(
					await db.models('image').select().where(imageToRemove),
				).to.have.lengthOf(1);

				// Check that multiple images with the same dockerImageId are returned
				expect(
					await db
						.models('image')
						.where({ dockerImageId: imageToRemove.dockerImageId })
						.select(),
				).to.have.lengthOf(2);

				// Now remove these images
				await imageManager.remove(imageToRemove);

				// This tests the behavior
				expect(mockerode.removeImage).to.have.been.calledOnceWith(
					'registry2.balena-cloud.com/v2/one:delta-one',
				);

				// Check that the database no longer has this image
				expect(await db.models('image').select().where(imageToRemove)).to.be
					.empty;

				// Check that the image with the same dockerImageId is still on the database
				expect(
					await db.models('image').select().where(imageWithSameDockerImageId),
				).to.have.lengthOf(1);
			},
			{ images },
		);
	});
});
