import { CloudWatchEvents } from 'aws-sdk';
import { crc32 } from 'crc';

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

import {
	CustomResourceRequest,
	ResponseStatus,
	send as sendResponse,
} from './cfn-response';
import { Logger } from './logger';

const {
	/** ARN of the role to use with CloudWatch Events */
	CW_EVENTS_CONTINUATION_RULE_ROLE_ARN,
	/**
	 * ARN of the optional role to use by CloudWatch Events to use the service token
	 *
	 * From the `PutTargets` documentation:
	 * > To be able to make API calls against the resources that you own, Amazon EventBridge needs
	 * > the appropriate permissions. For Lambda and Amazon SNS resources, EventBridge relies on
	 * > resource-based policies. For EC2 instances, Kinesis Data Streams, Step Functions state
	 * > machines and API Gateway REST APIs, EventBridge relies on IAM roles that you specify in the
	 * > RoleARN argument in PutTargets. For more information, see Authentication and Access Control
	 * > in the Amazon EventBridge User Guide .
	 */
	CW_EVENTS_CONTINUATION_TARGET_ROLE_ARN,
} = process.env;

export interface Response<ResourceAttributes extends unknown> {
	physicalResourceId?: string;
	attributes?: ResourceAttributes;
}

/**
 * Response returned by the resource methods to indicate that long-running continuation is needed
 */
export interface ContinuationRequired<ContinuationAttributes extends unknown> {
	/** Number of seconds after which to retry the function */
	continuationAfter: number;

	/** Additional properties that should be provided in the continuation invocation */
	continuationAttributes: ContinuationAttributes;
}

function isContinuationRequired<RA, CA>(
	response?: Response<RA> | ContinuationRequired<CA>,
): response is ContinuationRequired<CA> {
	return typeof response === 'object' && 'continuationAfter' in response;
}

type ContinuedCustomResourceRequest<ContinuationAttributes> =
	CustomResourceRequest & {
		ContinuationAttributes: ContinuationAttributes;
	};
function isContinuedCustomResourceRequest<ContinuationAttributes>(
	request:
		| CustomResourceRequest
		| ContinuedCustomResourceRequest<ContinuationAttributes>,
): request is ContinuedCustomResourceRequest<ContinuationAttributes> {
	return (
		'ContinuationAttributes' in request &&
		typeof request.ContinuationAttributes === 'object'
	);
}

export abstract class CustomResource<
	ResourceAttributes extends unknown,
	S extends ObjectSchema,
	ContinuationAttributes extends unknown = unknown,
> {
	private static CW_EVENTS_TARGET_ID = 'CustomResource';

	private cwEvents = new CloudWatchEvents({ apiVersion: '2015-10-07' });
	private requestQueue: (() => Promise<void>)[] = [];

	private currentRequest?:
		| CustomResourceRequest
		| ContinuedCustomResourceRequest<ContinuationAttributes>;

	constructor(
		protected readonly schema: S,
		protected readonly logicalResourceId: string,
		protected readonly logger: Logger,
	) {}

	public abstract createResource(
		physicalResourceId: string,
		params: SchemaType<S>,
		continuationAttributes?: ContinuationAttributes,
	): Promise<
		Response<ResourceAttributes> | ContinuationRequired<ContinuationAttributes>
	>;

	public abstract deleteResource(
		physicalResourceId: string,
		params: SchemaType<S>,
		continuationAttributes?: ContinuationAttributes,
	): Promise<
		Response<ResourceAttributes> | ContinuationRequired<ContinuationAttributes>
	>;

	public abstract updateResource(
		physicalResourceId: string,
		params: SchemaType<S>,
		oldParams: unknown,
		continuationAttributes?: ContinuationAttributes,
	): Promise<
		Response<ResourceAttributes> | ContinuationRequired<ContinuationAttributes>
	>;

	public async handleRequest(
		request:
			| CustomResourceRequest
			| ContinuedCustomResourceRequest<ContinuationAttributes>,
	): Promise<ResponseStatus> {
		try {
			this.currentRequest = request;
			return await this.handleRequestInner(request);
		} finally {
			this.currentRequest = undefined;
		}
	}

	/**
	 * Get the request that is currently being processed
	 *
	 * This is mostly intended for integrations that want to watch the state of their
	 * resources, the returned value should not be changed in any way.
	 *
	 * @returns the current request when called during an execution of {@link handleRequest}, otherwise `undefined`
	 */
	public get request():
		| undefined
		| CustomResourceRequest
		| ContinuedCustomResourceRequest<ContinuationAttributes> {
		return this.currentRequest;
	}

	protected handleRequestInner(
		request:
			| CustomResourceRequest
			| ContinuedCustomResourceRequest<ContinuationAttributes>,
	): Promise<ResponseStatus> {
		let resolveRequest: (response: ResponseStatus) => void;
		let rejectRequest: (error: Error) => void;
		const result = new Promise<ResponseStatus>((resolve, reject) => {
			resolveRequest = resolve;
			rejectRequest = reject;
		});

		const processThisRequest = async () => {
			try {
				const { status } = await this.processRequest(request);
				resolveRequest(status);
			} catch (err) {
				rejectRequest(err);
			} finally {
				// Remove ourselves from the queue, and execute the next one.
				this.requestQueue.shift();
				const processNextRequest =
					this.requestQueue.length > 0 ? this.requestQueue[0] : undefined;
				if (processNextRequest) {
					void processNextRequest();
				}
			}
		};

		if (this.requestQueue.push(processThisRequest) === 1) {
			// What we added is the first entry, so start executing it.
			void processThisRequest();
		}
		return result;
	}

	protected async processRequest(
		request:
			| CustomResourceRequest
			| ContinuedCustomResourceRequest<ContinuationAttributes>,
	): Promise<{
		status: ResponseStatus;
	}> {
		// TODO: validate the parameters to conform to the schema

		// Default physical resource id
		const physicalResourceId =
			request.PhysicalResourceId ||
			[request.StackId, request.LogicalResourceId, request.RequestId].join('/');

		let continuationAttributes: ContinuationAttributes | undefined;
		if (isContinuedCustomResourceRequest(request)) {
			this.logger.log('Request is a continuation of an earlier request');
			continuationAttributes = request.ContinuationAttributes;
		}
		let status: ResponseStatus = 'FAILED';
		let statusReason: string | undefined;
		let response:
			| Response<ResourceAttributes>
			| ContinuationRequired<ContinuationAttributes>
			| undefined;
		try {
			const { ServiceToken: _ignoredServiceToken, ...properties } =
				request.ResourceProperties;
			switch (request.RequestType) {
				case 'Create':
					response = await this.createResource(
						physicalResourceId,
						properties as SchemaType<S>,
						continuationAttributes as ContinuationAttributes,
					);
					break;
				case 'Delete':
					response = await this.deleteResource(
						physicalResourceId,
						properties as SchemaType<S>,
						continuationAttributes as ContinuationAttributes,
					);
					break;
				case 'Update':
					{
						// Note that the old properties could be a completely different schema. It's the job of the developer
						// of the template to prevent/handle that.
						const { ServiceToken: _ignoredOldServiceToken, ...oldProperties } =
							request.OldResourceProperties!;
						response = await this.updateResource(
							physicalResourceId,
							properties as SchemaType<S>,
							oldProperties,
							continuationAttributes as ContinuationAttributes,
						);
					}
					break;
			}

			status = 'SUCCESS';
		} catch (err) {
			this.logger.warn(
				`Uncaught error when handling request "${request.RequestType}": ${err.message}`,
			);
			statusReason = err.message;
		}

		if (isContinuationRequired(response)) {
			// Schedule the invocation of the function again, with the additional attributes from
			// the response
			const { continuationAfter, continuationAttributes } = response;
			const continuationRuleName = this.getContinuationRuleName(request);

			const ruleRoleArn = CW_EVENTS_CONTINUATION_RULE_ROLE_ARN;
			const targetRoleArn = CW_EVENTS_CONTINUATION_TARGET_ROLE_ARN;

			// Ideally we want to put the target for the continuation first so that we don't miss
			// our own goal, but CWE doesn't work this way.
			// What does work however is to create the rule with a schedule expression pointing to a
			// day in the past, keep it "DISABLED", then add the target, and then update the rule to
			// enable it with a suitably-in-the-future expression.
			// Note that if the rule already exists this will similarly first disable it; as the rule name
			// and target ID are constant over the life-time of the rule this should all be idempotent.
			const prepPutRuleParams: CloudWatchEvents.PutRuleRequest = {
				Name: continuationRuleName,
				RoleArn: ruleRoleArn,
				ScheduleExpression: `cron(30 7 19 6 ? 2018)`,
				State: 'DISABLED',
			};
			await this.cwEvents.putRule(prepPutRuleParams).promise();

			const putTargetsParams: CloudWatchEvents.PutTargetsRequest = {
				Rule: continuationRuleName,
				Targets: [
					{
						Arn: request.ServiceToken,
						Id: CustomResource.CW_EVENTS_TARGET_ID,
						Input: JSON.stringify({
							...request,
							ContinuationAttributes: continuationAttributes,
						}),
						RoleArn: targetRoleArn,
					},
				],
			};
			await this.cwEvents.putTargets(putTargetsParams).promise();

			const now = new Date();
			const whenTs =
				Math.ceil((now.getTime() / 1000 + continuationAfter) / 60) * 60 * 1000;
			// Bump up to the next full minute, as we won't be getting scheduling if the time isn't "far enough"
			// in the future.
			const when = new Date(whenTs);
			if (when.getMinutes() === now.getMinutes()) {
				when.setMinutes(when.getMinutes() + 1);
			}

			// Build a cron expression for CWE
			// See also https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/ScheduledEvents.html
			const cronExpression = `${when.getUTCMinutes()} ${when.getUTCHours()} ${when.getUTCDate()} ${
				when.getUTCMonth() + 1
			} ? ${when.getUTCFullYear()}`;

			this.logger.log(
				`Scheduling continuation using CWE rule ${continuationRuleName} after ${continuationAfter}s (at ${cronExpression})`,
			);
			const schedulePutRuleParams: CloudWatchEvents.PutRuleRequest = {
				Name: continuationRuleName,
				RoleArn: ruleRoleArn,
				ScheduleExpression: `cron(${cronExpression})`,
				State: 'ENABLED',
			};
			await this.cwEvents.putRule(schedulePutRuleParams).promise();
		} else {
			// A "definite" status, so write that into the response document
			const responsePhysicalResourceId =
				response?.physicalResourceId || physicalResourceId;
			await sendResponse(
				request,
				status,
				statusReason,
				responsePhysicalResourceId,
				response?.attributes,
			);

			// If there are continuation attributes in the request, we know that this was a continuation
			// and therefore we now want to remove the rule.
			if (continuationAttributes) {
				const continuationRuleName = this.getContinuationRuleName(request);
				this.logger.log(`Cleaning up CWE rule ${continuationRuleName}`);
				try {
					await this.cwEvents
						.removeTargets({
							Rule: continuationRuleName,
							Ids: [CustomResource.CW_EVENTS_TARGET_ID],
						})
						.promise();
					await this.cwEvents
						.deleteRule({
							Name: continuationRuleName,
						})
						.promise();
				} catch (err) {
					// Best effort, didn't work, bad luck.
					// Given that the rule is a single date, this should merely produce garbage in CW Events, but not produce
					// any other side-effects.
					this.logger.warn(
						`Cannot remove continuation rule ${continuationRuleName}: ${err.message}`,
					);
				}
			}
		}
		return {
			status,
		};
	}

	private getContinuationRuleName(request: CustomResourceRequest): string {
		// Rule name, can be at most 64 characters and has a limited range of valid characters
		// We also know the rule must be unique for this request, so we try to cram as much as we can
		// into it.

		// StackId is something like "arn:aws:cloudformation:us-west-2:123456789012:stack/stack-name/guid", for our purposes
		// we care only about the stack name.
		const [, stackName] = request.StackId.split(/:/)[5].split(/\//);
		const safeRequestId = crc32(request.RequestId).toString(16);

		const continuationRuleName = `Continuation-${stackName}-${request.LogicalResourceId}`;
		return `${continuationRuleName.slice(0, 55)}-${safeRequestId}`;
	}
}
