import { ACM, Route53 } from 'aws-sdk';

import type { SchemaType } from '@collaborne/json-schema-to-type';

import {
	ContinuationRequired,
	CustomResource,
	Response,
} from '../custom-resource';
import { Logger } from '../logger';
import { isDefined } from '../utils';

const EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA = {
	type: 'object' as const,
	properties: {
		DomainName: {
			type: 'string' as const,
		},
		ValidationDomain: {
			type: 'string' as const,
		},
	},
	required: ['DomainName' as const, 'ValidationDomain' as const],
};
type EmailDomainValidationOption = SchemaType<
	typeof EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA
>;

const DNS_DOMAIN_VALIDATION_OPTION_SCHEMA = {
	type: 'object' as const,
	properties: {
		DomainName: {
			type: 'string' as const,
		},
		HostedZoneId: {
			type: 'string' as const,
		},
	},
	required: ['DomainName' as const, 'HostedZoneId' as const],
};
type DNSDomainValidationOption = SchemaType<
	typeof DNS_DOMAIN_VALIDATION_OPTION_SCHEMA
>;

const SCHEMA = {
	type: 'object' as const,
	properties: {
		DomainName: {
			type: 'string' as const,
		},
		CertificateTransparencyLoggingPreference: {
			type: 'string' as const,
			enum: ['ENABLED', 'DISABLED'],
		},
		DomainValidationOptions: {
			type: 'array' as const,
			items: {
				oneOf: [
					EMAIL_DOMAIN_VALIDATION_OPTION_SCHEMA,
					DNS_DOMAIN_VALIDATION_OPTION_SCHEMA,
				] as const,
			},
		},
		Tags: {
			type: 'array' as const,
			items: {
				type: 'object' as const,
				properties: {
					Key: {
						type: 'string' as const,
					},
					Value: {
						type: 'string' as const,
					},
				},
				required: ['Key' as const],
			},
		},
	},
	additionalProperties: true as const,
	required: ['DomainName' as const],
};

interface ResourceAttributes {
	Arn: string;
	CertificateId: string;
}

function isEmailDomainValidationOption(
	option: EmailDomainValidationOption | DNSDomainValidationOption,
): option is EmailDomainValidationOption {
	return 'ValidationDomain' in option;
}

function isDNSDomainValidationOption(
	option: EmailDomainValidationOption | DNSDomainValidationOption,
): option is DNSDomainValidationOption {
	return 'HostedZoneId' in option;
}

function getCertificateId(certificateArn: string): string {
	const lastSlashIndex = certificateArn.lastIndexOf('/');
	return certificateArn.substring(lastSlashIndex + 1);
}

// XXX: The AWS::CertificateManager::Certificate uses the ARN as Ref, not an id (and then also doesn't have a .Arn attribute)
export class ACMCertificate extends CustomResource<
	ResourceAttributes,
	typeof SCHEMA,
	Response<ResourceAttributes>
> {
	private acm = new ACM({ region: 'us-east-1' });
	private route53 = new Route53();

	constructor(logicalResourceId: string, logger: Logger) {
		super(SCHEMA, logicalResourceId, logger);
	}

	public async createResource(
		physicalResourceId: string,
		params: SchemaType<typeof SCHEMA>,
		continuationAttributes: Response<ResourceAttributes>,
	): Promise<
		| Response<ResourceAttributes>
		| ContinuationRequired<Response<ResourceAttributes>>
	> {
		let response: Response<ResourceAttributes>;
		if (continuationAttributes) {
			response = continuationAttributes;
		} else {
			const attributes = await this.createCertificate(params);
			response = {
				physicalResourceId: `${physicalResourceId}/${attributes.CertificateId}`,
				attributes,
			};
		}

		return this.maybeContinuation(response);
	}

	public async deleteResource(
		physicalResourceId: string,
		params: SchemaType<typeof SCHEMA>,
	): Promise<Response<ResourceAttributes>> {
		// XXX: If there are multiple, what would happen? How could we identify "the one"?
		const certificateArn = await this.findCertificateArn(
			physicalResourceId,
			params.DomainName,
		);
		if (!certificateArn) {
			throw new Error(
				`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`,
			);
		}

		// Delete the certificate itself
		//
		// Note that we're not going to delete the Route53 entries now: If the deletion was "temporary" (for example due
		// to other stack failures that led to cleanups), the DNS records will be reusable for the next attempt.
		// This matches the behavior of the original AWS::ACM::Certificate resource; if needed we could make the deletion
		// optional as the information should be available in the certificate data.
		await this.acm
			.deleteCertificate({ CertificateArn: certificateArn })
			.promise();

		return {
			physicalResourceId,
			attributes: {
				Arn: certificateArn,
				CertificateId: getCertificateId(certificateArn),
			},
		};
	}

	public async updateResource(
		physicalResourceId: string,
		params: SchemaType<typeof SCHEMA>,
		oldParams: unknown,
		continuationAttributes: Response<ResourceAttributes>,
	): Promise<
		| Response<ResourceAttributes>
		| ContinuationRequired<Response<ResourceAttributes>>
	> {
		if (continuationAttributes) {
			return this.maybeContinuation(continuationAttributes);
		}

		const {
			CertificateTransparencyLoggingPreference: ctLoggingPreference,
			Tags: tags = [],
		} = params;
		const changedAttributes = this.findChangedAttributes(params, oldParams);
		if (
			changedAttributes.length === 1 &&
			changedAttributes[0] === 'CertificateTransparencyLoggingPreference'
		) {
			// Update the preference
			const certificateArn = await this.findCertificateArn(
				physicalResourceId,
				params.DomainName,
			);
			if (!certificateArn) {
				throw new Error(
					`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`,
				);
			}

			await this.acm
				.updateCertificateOptions({
					CertificateArn: certificateArn,
					Options: {
						CertificateTransparencyLoggingPreference: ctLoggingPreference,
					},
				})
				.promise();

			return {
				physicalResourceId,
				attributes: {
					Arn: certificateArn,
					CertificateId: getCertificateId(certificateArn),
				},
			};
		} else if (
			changedAttributes.length === 1 &&
			changedAttributes[0] === 'Tags'
		) {
			// Update tags
			const certificateArn = await this.findCertificateArn(
				physicalResourceId,
				params.DomainName,
			);
			if (!certificateArn) {
				throw new Error(
					`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`,
				);
			}

			const { Tags: existingTags = [] } = await this.acm
				.listTagsForCertificate({ CertificateArn: certificateArn })
				.promise();
			const tagsToAdd: ACM.Tag[] = [];
			for (const tag of tags) {
				if (
					!existingTags.find(
						({ Key: k, Value: v }) => k === tag.Key && v === tag.Value,
					)
				) {
					tagsToAdd.push(tag);
				}
			}
			await this.acm
				.addTagsToCertificate({
					CertificateArn: certificateArn,
					Tags: tagsToAdd,
				})
				.promise();

			const tagsToRemove: ACM.Tag[] = [];
			for (const tag of existingTags) {
				if (
					!tags.find(({ Key: k, Value: v }) => k === tag.Key && v === tag.Value)
				) {
					tagsToRemove.push(tag);
				}
			}
			await this.acm
				.removeTagsFromCertificate({
					CertificateArn: certificateArn,
					Tags: tagsToRemove,
				})
				.promise();

			return {
				physicalResourceId,
				attributes: {
					Arn: certificateArn,
					CertificateId: getCertificateId(certificateArn),
				},
			};
		} else {
			// Replace the whole thing, and return a new resource id.
			// If the old physical resource id contains the old certificate id, replace that, otherwise it's a legacy
			// resource and we need to _add_ the id.
			const certificateArn = await this.findCertificateArn(
				physicalResourceId,
				params.DomainName,
			);
			if (!certificateArn) {
				throw new Error(
					`Cannot find certificate ${physicalResourceId} (domain ${params.DomainName})`,
				);
			}

			const newAttributes = await this.createCertificate(
				params,
				physicalResourceId,
			);

			// Note that we just return the new id, and CloudFormation will call us at the end to clean up.
			let physicalResourceIdPrefix;
			if (physicalResourceId.endsWith(`/${getCertificateId(certificateArn)}`)) {
				const lastSlashIndex = physicalResourceId.lastIndexOf('/');
				physicalResourceIdPrefix = physicalResourceId.substring(
					lastSlashIndex + 1,
				);
			} else {
				physicalResourceIdPrefix = physicalResourceId;
			}
			const newPhysicalResourceId = `${physicalResourceIdPrefix}/${newAttributes.CertificateId}`;
			this.logger.log(
				`Replaced certificate ${physicalResourceId} with ${newPhysicalResourceId}`,
			);
			return this.maybeContinuation({
				physicalResourceId: newPhysicalResourceId,
				attributes: newAttributes,
			});
		}
	}

	private async findCertificateArn(
		physicalResourceId: string,
		domainName: string,
	): Promise<string | undefined> {
		async function listCertificates(
			acm: ACM,
			params: Pick<
				ACM.ListCertificatesRequest,
				'CertificateStatuses' | 'Includes'
			> = {},
		): Promise<ACM.CertificateSummary[]> {
			async function collect(
				knownCertificates: ACM.CertificateSummary[],
				token?: string,
			): Promise<ACM.CertificateSummary[]> {
				const {
					CertificateSummaryList: newCertificates = [],
					NextToken: nextToken,
				} = await acm
					.listCertificates({
						...params,
						NextToken: token,
					})
					.promise();

				const resources = [...knownCertificates, ...newCertificates];
				if (nextToken) {
					return collect(resources, nextToken);
				}
				return resources;
			}

			return collect([]);
		}

		const certificates = await listCertificates(this.acm);
		// Find the certificate that matches the domain, and ideally contains the id in the resource id.
		// For legacy certificate this isn't true, so we will accept any certificate (with a warning!)
		let certificate = certificates.find(c => {
			const certificateId = getCertificateId(c.CertificateArn!);
			// In theory the check for the endsWith is enough, and we don't need to look things up
			// by domain name.
			if (physicalResourceId.endsWith(`/${certificateId}`)) {
				if (c.DomainName !== domainName) {
					this.logger.warn(
						`Certificate ${c.CertificateArn} contains unexpected domain: ${c.DomainName} (should be ${domainName})`,
					);
				}
				return true;
			}
			return false;
		});

		if (!certificate) {
			this.logger.warn(
				`Cannot find certificate by id, falling back to search by domain name`,
			);
			certificate = certificates.find(c => c.DomainName === domainName);
		}
		return certificate?.CertificateArn;
	}

	/**
	 * Get the required resource records for validation
	 */
	/* Implementation note: This method is based on the observation that sometimes the resource record information seems
	 * to take a while to appear, so it will retry a couple of times if there are DNS validation options without resource
	 * records attached to it.
	 */
	private async getValidationResourceRecords(
		certificateArn: string,
	): Promise<ACM.ResourceRecord[]> {
		let interval: NodeJS.Timeout;
		let retries = 0;
		this.logger.log(`getValidationResourceRecords retries: ${retries}`);
		return new Promise<ACM.ResourceRecord[]>((resolve, reject) => {
			interval = setInterval(async () => {
				const { Certificate: certificate } = await this.acm
					.describeCertificate({ CertificateArn: certificateArn })
					.promise();
				if (!certificate) {
					reject(new Error(`Cannot find certificate ${certificateArn}`));
					return;
				}

				this.logger.log(
					`getValidationResourceRecords certificate: ${JSON.stringify(
						certificate,
					)}`,
				);

				const dnsDomainValidationOptions = (
					certificate.DomainValidationOptions ?? []
				).filter(options => options.ValidationMethod === 'DNS');
				if (dnsDomainValidationOptions.length === 0) {
					// Consider this "fatal": There's no indication that this would ever appear on its own so far in the docs.
					reject(
						new Error(
							`Cannot find DNS domain validation option in certificate ${certificateArn}: ${JSON.stringify(
								certificate,
							)}`,
						),
					);
					return;
				}

				this.logger.log(
					`getValidationResourceRecords dnsDomainValidationOptions: ${JSON.stringify(
						dnsDomainValidationOptions,
					)}`,
				);

				const resourceRecords = dnsDomainValidationOptions
					.map(option => option.ResourceRecord)
					.filter(isDefined);
				this.logger.log(
					`getValidationResourceRecords resourceRecords: ${JSON.stringify(
						resourceRecords,
					)}`,
				);

				const missingResourceRecords =
					dnsDomainValidationOptions.length - resourceRecords.length;
				if (missingResourceRecords > 0) {
					this.logger.error(
						`Missing resource records in ${missingResourceRecords} DNS validation options in certificate ${certificateArn} after ${++retries} attempts: ${JSON.stringify(
							certificate,
						)}`,
					);
					return;
				}

				this.logger.log(
					`Found all validation resource records in certificate ${certificateArn}: ${JSON.stringify(
						resourceRecords,
					)}`,
				);
				resolve(resourceRecords);
			}, Number(process.env.SLS_AWS_MONITORING_FREQUENCY || 5000));
		}).finally(() => {
			clearInterval(interval);
		});
	}

	private async createCertificate(
		params: SchemaType<typeof SCHEMA>,
		rawIdempotencyToken = `${this.logicalResourceId}-${Date.now()}`,
	): Promise<ResourceAttributes> {
		const {
			CertificateTransparencyLoggingPreference: ctLoggingPreference,
			DomainValidationOptions: domainValidationOptions = [],
			...baseRequest
		} = params;
		// Remove all DNS validation options: These aren't supported by the ACM class itself, but rather we need to implement them manually.
		const emailDomainValidationOptions: EmailDomainValidationOption[] =
			domainValidationOptions.filter(isEmailDomainValidationOption);
		const dnsDomainValidationOptions: DNSDomainValidationOption[] =
			domainValidationOptions.filter(isDNSDomainValidationOption);
		// There must be at most one of these, and that one option must be using the same domain name
		if (
			dnsDomainValidationOptions.length > 1 ||
			(dnsDomainValidationOptions.length === 1 &&
				dnsDomainValidationOptions[0].DomainName !== params.DomainName)
		) {
			throw new Error('Invalid DNS domain validation options');
		}

		try {
			// Request the certificate
			const request: ACM.RequestCertificateRequest = {
				...baseRequest,
				IdempotencyToken: rawIdempotencyToken
					.replace(/[^\w]/g, '')
					.slice(0, 32),
				DomainValidationOptions:
					emailDomainValidationOptions.length === 0
						? undefined
						: emailDomainValidationOptions,
				Options: {
					CertificateTransparencyLoggingPreference: ctLoggingPreference,
				},
			};
			const { CertificateArn: certificateArn } = await this.acm
				.requestCertificate(request)
				.promise();
			if (!certificateArn) {
				// Hopefully some hints are in Cloudtrail now ...
				throw new Error(
					'Failed to request certificate: No certificate ARN returned',
				);
			}

			// We have an ARN, so at least all initial parameters were good enough. Proceed working on the validation ...
			this.logger.log(`Certificate requested: ${certificateArn}`);

			// If validation was supposed to happen via email, or there are no options provided for DNS validation, then we're
			// done and the caller knows what to do.
			// Otherwise we need to go to Route53 and upsert the needed RR there.
			if (dnsDomainValidationOptions.length !== 0) {
				this.logger.log('Validation is DNS Domain Validation');
				const resourceRecords = await this.getValidationResourceRecords(
					certificateArn,
				);
				this.logger.log(`resourceRecords: ${JSON.stringify(resourceRecords)}`);
				const hostedZoneId = dnsDomainValidationOptions[0].HostedZoneId;
				this.logger.log(`hostedZoneId: ${hostedZoneId}`);
				const result = await this.route53
					.changeResourceRecordSets({
						HostedZoneId: hostedZoneId,
						ChangeBatch: {
							Changes: resourceRecords.map(resourceRecord => ({
								Action: 'UPSERT',
								ResourceRecordSet: {
									Name: resourceRecord.Name,
									Type: resourceRecord.Type,
									// The TTL can in theory be very large, but that would potentially hinder our ability
									// to quickly revoke a certificate. As there shouldn't be many requests to this either, 300
									// should be just fine.
									TTL: 300,
									ResourceRecords: [
										{
											Value: resourceRecord.Value,
										},
									],
								},
							})),
						},
					})
					.promise();

				this.logger.log(`Route53 change set: ${result.ChangeInfo.Id}`);
			}

			return {
				Arn: certificateArn,
				CertificateId: getCertificateId(certificateArn),
			};
		} catch (error) {
			this.logger.error(error);

			throw error;
		}
	}

	private findChangedAttributes(
		params: SchemaType<typeof SCHEMA>,
		oldParams: unknown,
	): string[] {
		function isChanged(to: any, from: any): boolean {
			switch (typeof from) {
				case 'undefined':
					return typeof to !== 'undefined';
				case 'object':
					return (
						typeof to !== 'object' ||
						!Object.entries(from).reduce(
							(r, [k, v]) => r && !isChanged(to[k], v),
							true,
						)
					);
				default:
					if (Array.isArray(from)) {
						return (
							!Array.isArray(to) ||
							from.length !== to.length ||
							!from.reduce((r, k, i) => r && to[i] === k, true)
						);
					}
					return from !== to;
			}
		}

		return Object.keys(params).filter(key =>
			isChanged(params[key], (oldParams as Record<any, any>)[key]),
		);
	}

	protected async maybeContinuation(
		response: Response<ResourceAttributes>,
	): Promise<
		| Response<ResourceAttributes>
		| ContinuationRequired<Response<ResourceAttributes>>
	> {
		// Initially wee have the id in the response, so we simply check now whether the certificate is valid.
		// If it is, great, return the response. Otherwise stuff the whole response into continuationAttributes, and
		// try again in a minute or two.
		const certificateArn = response.attributes?.Arn;
		if (!certificateArn) {
			throw new Error('Missing certificate ARN');
		}

		const { Certificate: certificate } = await this.acm
			.describeCertificate({ CertificateArn: certificateArn })
			.promise();
		if (!certificate) {
			throw new Error(`Cannot find certificate ${certificateArn}`);
		}

		if (certificate.Status === 'ISSUED') {
			this.logger.log('Certificate is ISSUED, stopping continuations');
			return response;
		} else if (certificate.Status === 'PENDING_VALIDATION') {
			this.logger.log(
				`Certificate is PENDING_VALIDATION, requesting continuation`,
			);
			return {
				continuationAfter: Number(process.env.RETRY_INTERVAL_SECONDS ?? '300'),
				continuationAttributes: response,
			};
		} else {
			throw new Error(`Certificate is in invalid status ${certificate.Status}`);
		}
	}
}
