/**
 * @license
 * Copyright 2020-2021 Balena Ltd.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *    http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import { intVar } from '@balena/env-parsing';
import type { Request as ReleaseRequest } from '@balena/compose/dist/release';
import { expect } from 'chai';
import { promises as fs } from 'fs';
import * as _ from 'lodash';
import type * as nock from 'nock';
import * as path from 'path';
import * as sinon from 'sinon';

import { BalenaAPIMock } from '../nock/balena-api-mock';
import { expectStreamNoCRLF, testDockerBuildStream } from '../docker-build';
import { DockerMock, dockerResponsePath } from '../nock/docker-mock';
import { cleanOutput, runCommand, switchSentry } from '../helpers';
import type {
	ExpectedTarStreamFiles,
	ExpectedTarStreamFilesByService,
} from '../projects';
import { getDockerignoreWarn1, getDockerignoreWarn3 } from '../projects';

const repoPath = path.normalize(path.join(__dirname, '..', '..'));
const projectsPath = path.join(repoPath, 'tests', 'test-data', 'projects');

const commonResponseLines = {
	'build-POST.json': [
		'[Info] Building for armv7hf/raspberrypi3',
		'[Info] Docker Desktop detected (daemon architecture: "x86_64")',
		'[Info] Docker itself will determine and enable architecture emulation if required,',
		'[Info] without balena-cli intervention and regardless of the --emulated option.',
		// '[Build] main Step 1/4 : FROM busybox',
		'[Info] Creating release...',
		'[Info] Pushing images to registry...',
		'[Info] Saving release...',
		'[Success] Deploy succeeded!',
		'[Success] Release: 09f7c3e1fdec609be818002299edfc2a',
	],
};

const commonQueryParams = [
	['platform', 'linux/arm/v7'],
	['t', '${tag}'],
	['buildargs', '{}'],
	['labels', ''],
];

const commonComposeQueryParams = {
	t: '${tag}',
	buildargs: {
		MY_VAR_1: 'This is a variable',
		MY_VAR_2: 'Also a variable',
	},
	labels: '',
};

const commonComposeQueryParamsArmV7 = {
	...commonComposeQueryParams,
	platform: 'linux/arm/v7',
};

describe('balena deploy', function () {
	let api: BalenaAPIMock;
	let docker: DockerMock;
	const isWindows = process.platform === 'win32';

	this.beforeEach(() => {
		api = new BalenaAPIMock();
		docker = new DockerMock();
		api.expectGetWhoAmI({ optional: true, persist: true });
		api.expectGetApplication({ expandArchitecture: true });
		api.expectGetRelease();
		api.expectGetUser();
		api.expectGetService({ serviceName: 'main' });
		api.expectGetAuth();
		api.expectPostImage();
		api.expectPostImageIsPartOfRelease();

		docker.expectGetImages();
		docker.expectGetPing();
		docker.expectGetInfo({});
		docker.expectGetVersion({ persist: true });
		docker.expectPostImagesTag();
		docker.expectPostImagesPush();
		docker.expectDeleteImages();
	});

	this.afterEach(() => {
		// Check all expected api calls have been made and clean up.
		api.done();
		docker.done();
	});

	it('should create the expected --build tar stream (single container)', async () => {
		const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
		const expectedFiles: ExpectedTarStreamFiles = {
			'src/.dockerignore': { fileSize: 16, type: 'file' },
			'src/start.sh': { fileSize: 89, type: 'file' },
			'src/windows-crlf.sh': {
				fileSize: isWindows ? 68 : 70,
				testStream: isWindows ? expectStreamNoCRLF : undefined,
				type: 'file',
			},
			Dockerfile: { fileSize: 88, type: 'file' },
			'Dockerfile-alt': { fileSize: 30, type: 'file' },
		};
		const responseFilename = 'build-POST.json';
		const responseBody = await fs.readFile(
			path.join(dockerResponsePath, responseFilename),
			'utf8',
		);
		const expectedResponseLines = [
			...commonResponseLines[responseFilename],
			`[Info] No "docker-compose.yml" file found at "${projectPath}"`,
			`[Info] Creating default composition with source: "${projectPath}"`,
			...getDockerignoreWarn1(
				[path.join(projectPath, 'src', '.dockerignore')],
				'deploy',
			),
		];
		if (isWindows) {
			const fname = path.join(projectPath, 'src', 'windows-crlf.sh');
			expectedResponseLines.push(
				`[Info] Converting line endings CRLF -> LF for file: ${fname}`,
			);
		}

		api.expectPostRelease({});
		api.expectPatchImage({});
		api.expectPatchRelease({});
		api.expectPostImageLabel();
		docker.expectGetManifestBusybox();

		await testDockerBuildStream({
			commandLine: `deploy testApp --build --source ${projectPath}`,
			dockerMock: docker,
			expectedFilesByService: { main: expectedFiles },
			expectedQueryParamsByService: { main: commonQueryParams },
			expectedResponseLines,
			projectPath,
			responseBody,
			responseCode: 200,
			services: ['main'],
		});
	});

	it('should handle the contract and final status for a final (non-draft) release', async () => {
		const projectPath = path.join(
			projectsPath,
			'no-docker-compose',
			'with-contract',
		);
		const expectedFiles: ExpectedTarStreamFiles = {
			'src/start.sh': { fileSize: 30, type: 'file' },
			Dockerfile: { fileSize: 88, type: 'file' },
			'balena.yml': { fileSize: 55, type: 'file' },
		};
		const responseFilename = 'build-POST.json';
		const responseBody = await fs.readFile(
			path.join(dockerResponsePath, responseFilename),
			'utf8',
		);
		const expectedResponseLines = [
			...commonResponseLines[responseFilename],
			`[Info] No "docker-compose.yml" file found at "${projectPath}"`,
			`[Info] Creating default composition with source: "${projectPath}"`,
		];

		api.expectPostRelease({
			inspectRequest: (_uri: string, requestBody: nock.Body) => {
				const body = requestBody.valueOf() as Partial<ReleaseRequest>;
				expect(body.contract).to.be.equal(
					'{"name":"testContract","type":"sw.application","version":"1.5.2"}',
				);
				expect(body.is_final).to.be.true;
			},
		});
		api.expectPatchImage({});
		api.expectPatchRelease({});
		api.expectPostImageLabel();
		docker.expectGetManifestBusybox();

		await testDockerBuildStream({
			commandLine: `deploy testApp --build --source ${projectPath}`,
			dockerMock: docker,
			expectedFilesByService: { main: expectedFiles },
			expectedQueryParamsByService: { main: commonQueryParams },
			expectedResponseLines,
			projectPath,
			responseBody,
			responseCode: 200,
			services: ['main'],
		});
	});

	it('should handle the contract and final status for a draft release', async () => {
		const projectPath = path.join(
			projectsPath,
			'no-docker-compose',
			'with-contract',
		);
		const expectedFiles: ExpectedTarStreamFiles = {
			'src/start.sh': { fileSize: 30, type: 'file' },
			Dockerfile: { fileSize: 88, type: 'file' },
			'balena.yml': { fileSize: 55, type: 'file' },
		};
		const responseFilename = 'build-POST.json';
		const responseBody = await fs.readFile(
			path.join(dockerResponsePath, responseFilename),
			'utf8',
		);
		const expectedResponseLines = [
			...commonResponseLines[responseFilename],
			`[Info] No "docker-compose.yml" file found at "${projectPath}"`,
			`[Info] Creating default composition with source: "${projectPath}"`,
		];

		api.expectPostRelease({
			inspectRequest: (_uri: string, requestBody: nock.Body) => {
				const body = requestBody.valueOf() as Partial<ReleaseRequest>;
				expect(body.contract).to.be.equal(
					'{"name":"testContract","type":"sw.application","version":"1.5.2"}',
				);
				expect(body.semver).to.be.equal('1.5.2');
				expect(body.is_final).to.be.false;
			},
		});
		api.expectPatchImage({});
		api.expectPatchRelease({});
		api.expectPostImageLabel();
		docker.expectGetManifestBusybox();

		await testDockerBuildStream({
			commandLine: `deploy testApp --build --draft --source ${projectPath}`,
			dockerMock: docker,
			expectedFilesByService: { main: expectedFiles },
			expectedQueryParamsByService: { main: commonQueryParams },
			expectedResponseLines,
			projectPath,
			responseBody,
			responseCode: 200,
			services: ['main'],
		});
	});

	it('should update a release with status="failed" on error (single container)', async () => {
		let sentryStatus: boolean | undefined;
		const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
		const expectedFiles: ExpectedTarStreamFiles = {
			'src/.dockerignore': { fileSize: 16, type: 'file' },
			'src/start.sh': { fileSize: 89, type: 'file' },
			'src/windows-crlf.sh': { fileSize: 70, type: 'file' },
			Dockerfile: { fileSize: 88, type: 'file' },
			'Dockerfile-alt': { fileSize: 30, type: 'file' },
		};
		const responseFilename = 'build-POST.json';
		const responseBody = await fs.readFile(
			path.join(dockerResponsePath, responseFilename),
			'utf8',
		);
		const expectedResponseLines = ['[Error] Deploy failed'];
		const errMsg = 'Patch Image Error';
		const expectedErrorLines = [`Request error: ${errMsg}`];
		// The SDK should produce an "unexpected" BalenaRequestError, which
		// causes the CLI to call process.exit() with process.exitCode = 1
		const expectedExitCode = 1;

		api.expectPostRelease({});
		docker.expectGetManifestBusybox();

		let failedImagePatchRequests = 0;
		// Mock this patch HTTP request to return status code 500, in which case
		// the release status should be saved as "failed" rather than "success"
		const maxRequestRetries = intVar('BALENARCTEST_API_RETRY_MAX_ATTEMPTS');
		expect(
			maxRequestRetries,
			'BALENARCTEST_API_RETRY_MAX_ATTEMPTS must be >= 2 for this test',
		).to.be.greaterThanOrEqual(2);
		api.expectPatchImage({
			replyBody: errMsg,
			statusCode: 500,
			// b/c failed requests are retried
			times: maxRequestRetries,
			inspectRequest: (_uri, requestBody) => {
				const imageBody = requestBody as Partial<
					import('@balena/compose/dist/release/models').ImageModel
				>;
				expect(imageBody.status).to.equal('success');
				failedImagePatchRequests++;
			},
		});
		// Check that the CLI patches the release with status="failed"
		api.expectPatchRelease({
			inspectRequest: (_uri, requestBody) => {
				const releaseBody = requestBody as Partial<
					import('@balena/compose/dist/release/models').ReleaseModel
				>;
				expect(releaseBody.status).to.equal('failed');
			},
		});
		api.expectPostImageLabel();

		try {
			sentryStatus = await switchSentry(false);
			sinon.stub(process, 'exit');

			await testDockerBuildStream({
				commandLine: `deploy testApp --build --source ${projectPath} --noconvert-eol`,
				dockerMock: docker,
				expectedFilesByService: { main: expectedFiles },
				expectedQueryParamsByService: { main: commonQueryParams },
				expectedErrorLines,
				expectedExitCode,
				expectedResponseLines,
				projectPath,
				responseBody,
				responseCode: 200,
				services: ['main'],
			});
			expect(failedImagePatchRequests).to.equal(maxRequestRetries);
		} finally {
			await switchSentry(sentryStatus);
			// @ts-expect-error claims restore does not exist
			process.exit.restore();
		}
	});

	it('should create the expected --build tar stream after retrying failing OData requests (single container)', async () => {
		const projectPath = path.join(projectsPath, 'no-docker-compose', 'basic');
		const expectedFiles: ExpectedTarStreamFiles = {
			'src/.dockerignore': { fileSize: 16, type: 'file' },
			'src/start.sh': { fileSize: 89, type: 'file' },
			'src/windows-crlf.sh': {
				fileSize: isWindows ? 68 : 70,
				testStream: isWindows ? expectStreamNoCRLF : undefined,
				type: 'file',
			},
			Dockerfile: { fileSize: 88, type: 'file' },
			'Dockerfile-alt': { fileSize: 30, type: 'file' },
		};
		const responseFilename = 'build-POST.json';
		const responseBody = await fs.readFile(
			path.join(dockerResponsePath, responseFilename),
			'utf8',
		);
		const expectedResponseLines = [
			...commonResponseLines[responseFilename],
			`[Info] No "docker-compose.yml" file found at "${projectPath}"`,
			`[Info] Creating default composition with source: "${projectPath}"`,
			...getDockerignoreWarn1(
				[path.join(projectPath, 'src', '.dockerignore')],
				'deploy',
			),
		];
		if (isWindows) {
			const fname = path.join(projectPath, 'src', 'windows-crlf.sh');
			expectedResponseLines.push(
				`[Info] Converting line endings CRLF -> LF for file: ${fname}`,
			);
		}

		api.expectPostRelease({});
		docker.expectGetManifestBusybox();

		const maxRequestRetries = intVar('BALENARCTEST_API_RETRY_MAX_ATTEMPTS');
		expect(
			maxRequestRetries,
			'BALENARCTEST_API_RETRY_MAX_ATTEMPTS must be >= 2 for this test',
		).to.be.greaterThanOrEqual(2);
		let failedImagePatchRequests = 0;
		let succesfullImagePatchRequests = 0;
		api
			.optPatch(/^\/v6\/image($|[(?])/, { times: maxRequestRetries })
			.reply((_uri, requestBody) => {
				const imageBody = requestBody as Partial<
					import('@balena/compose/dist/release/models').ImageModel
				>;
				expect(imageBody.status).to.equal('success');
				if (failedImagePatchRequests < maxRequestRetries - 1) {
					failedImagePatchRequests++;
					return [500, 'Patch Image Error'];
				}
				succesfullImagePatchRequests++;
				return [200, 'OK'];
			});
		api.expectPatchRelease({});
		api.expectPostImageLabel();

		await testDockerBuildStream({
			commandLine: `deploy testApp --build --source ${projectPath}`,
			dockerMock: docker,
			expectedFilesByService: { main: expectedFiles },
			expectedQueryParamsByService: { main: commonQueryParams },
			expectedResponseLines,
			projectPath,
			responseBody,
			responseCode: 200,
			services: ['main'],
		});
		expect(failedImagePatchRequests).to.equal(maxRequestRetries - 1);
		expect(succesfullImagePatchRequests).to.equal(1);
	});

	it('should create the expected tar stream (docker-compose, --multi-dockerignore)', async () => {
		const projectPath = path.join(projectsPath, 'docker-compose', 'basic');
		const service1Dockerfile = (
			await fs.readFile(
				path.join(projectPath, 'service1', 'Dockerfile.template'),
				'utf8',
			)
		).replace('%%BALENA_MACHINE_NAME%%', 'raspberrypi3');

		console.error(
			`Dockerfile.template (replaced) length=${service1Dockerfile.length}`,
		);
		console.error(service1Dockerfile);

		const expectedFilesByService: ExpectedTarStreamFilesByService = {
			service1: {
				Dockerfile: {
					contents: service1Dockerfile,
					fileSize: service1Dockerfile.length,
					type: 'file',
				},
				'Dockerfile.template': { fileSize: 144, type: 'file' },
				'file1.sh': { fileSize: 12, type: 'file' },
				'test-ignore.txt': { fileSize: 12, type: 'file' },
			},
			service2: {
				'.dockerignore': { fileSize: 12, type: 'file' },
				'Dockerfile-alt': { fileSize: 13, type: 'file' },
				'file2-crlf.sh': {
					fileSize: isWindows ? 12 : 14,
					testStream: isWindows ? expectStreamNoCRLF : undefined,
					type: 'file',
				},
			},
		};
		const responseFilename = 'build-POST.json';
		const responseBody = await fs.readFile(
			path.join(dockerResponsePath, responseFilename),
			'utf8',
		);
		const expectedQueryParamsByService = {
			service1: Object.entries(
				_.merge({}, commonComposeQueryParams, {
					buildargs: { SERVICE1_VAR: 'This is a service specific variable' },
				}),
			),
			service2: Object.entries(
				_.merge({}, commonComposeQueryParamsArmV7, {
					buildargs: {
						COMPOSE_ARG: 'an argument defined in the docker-compose.yml file',
					},
					dockerfile: 'Dockerfile-alt',
				}),
			),
		};
		const expectedResponseLines: string[] = [
			...commonResponseLines[responseFilename],
			...[
				'[Build] service1 Step 1/4 : FROM busybox',
				'[Build] service2 Step 1/4 : FROM busybox',
			],
			...getDockerignoreWarn3('deploy'),
		];
		if (isWindows) {
			expectedResponseLines.push(
				`[Info] Converting line endings CRLF -> LF for file: ${path.join(
					projectPath,
					'service2',
					'file2-crlf.sh',
				)}`,
			);
		}

		api.expectPostRelease({});
		api.expectPatchImage({});
		api.expectPatchRelease({});
		docker.expectGetManifestRpi3Alpine();
		docker.expectGetManifestBusybox();

		await testDockerBuildStream({
			commandLine: `deploy testApp --build --source ${projectPath} --multi-dockerignore`,
			dockerMock: docker,
			expectedFilesByService,
			expectedQueryParamsByService,
			expectedResponseLines,
			projectPath,
			responseBody,
			responseCode: 200,
			services: ['service1', 'service2'],
		});
	});
});

describe('balena deploy: project validation', function () {
	let api: BalenaAPIMock;

	this.beforeEach(() => {
		api = new BalenaAPIMock();
		api.expectGetWhoAmI({ optional: true, persist: true });
	});

	this.afterEach(() => {
		// Check all expected api calls have been made and clean up.
		api.done();
	});

	it('should raise ExpectedError if a Dockerfile cannot be found', async () => {
		const projectPath = path.join(
			projectsPath,
			'docker-compose',
			'basic',
			'service2',
		);
		const expectedErrorLines = [
			'Error: no "Dockerfile[.*]", "docker-compose.yml" or "package.json" file',
			`found in source folder "${projectPath}"`,
		];

		const { out, err } = await runCommand(
			`deploy testApp --source ${projectPath}`,
		);
		expect(cleanOutput(err, true)).to.include.members(expectedErrorLines);
		expect(out).to.be.empty;
	});
});