// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { ConsoleLogger } from '../Logger';
import { isBrowser } from '../utils';
import { AmplifyError } from '../errors';
import { record } from '../providers/pinpoint';
import { Amplify, fetchAuthSession } from '../singleton';

import { ServiceWorkerErrorCode, assert } from './errorHelpers';

/**
 * Handler invoked on every service worker `statechange` event, receiving the
 * worker's current lifecycle state.
 */
export type ServiceWorkerStateChangeHandler = (
	state: ServiceWorkerState,
) => void;

/**
 * Options for {@link ServiceWorker.register}.
 */
export interface ServiceWorkerOptions {
	/**
	 * Optional handler invoked on every service worker `statechange` event.
	 *
	 * When provided, this handler replaces the built-in Pinpoint auto-recording:
	 * the built-in analytics event is only recorded when no handler is supplied,
	 * which prevents duplicate telemetry for the same state change.
	 */
	onStateChange?: ServiceWorkerStateChangeHandler;
}

/**
 * Provides a means to registering a service worker in the browser
 * and communicating with it via postMessage events.
 * https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API/
 *
 * postMessage events are currently not supported in all browsers. See:
 * https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API
 *
 * At the minmum this class will register the service worker and listen
 * and attempt to dispatch messages on state change and record analytics
 * events based on the service worker lifecycle.
 */
export class ServiceWorkerClass {
	// The active service worker will be set once it is registered
	private _serviceWorker?: ServiceWorker;

	// The service worker registration object
	private _registration?: ServiceWorkerRegistration;

	// The application server public key for Push
	// https://web-push-codelab.glitch.me/
	private _publicKey?: string;

	// push subscription
	private _subscription?: PushSubscription;

	// Optional handler invoked on every service worker state change
	private _onStateChange?: ServiceWorkerStateChangeHandler;

	// The AWS Amplify logger
	private _logger: ConsoleLogger = new ConsoleLogger('ServiceWorker');

	/**
	 * Get the currently active service worker
	 */
	get serviceWorker(): ServiceWorker {
		assert(
			this._serviceWorker !== undefined,
			ServiceWorkerErrorCode.UndefinedInstance,
		);

		return this._serviceWorker;
	}

	/**
	 * Register the service-worker.js file in the browser
	 * Make sure the service-worker.js is part of the build
	 * for example with Angular, modify the angular-cli.json file
	 * and add to "assets" array "service-worker.js"
	 *
	 * Note: when `options.onStateChange` is omitted, this method implicitly
	 * records service worker lifecycle (`statechange`) events to Amazon
	 * Pinpoint. That built-in auto-recording is deprecated and will be removed
	 * in a future major version — only the implicit Pinpoint recording is
	 * deprecated, not `register()` itself. Provide `options.onStateChange` to
	 * observe lifecycle state changes and emit vendor-neutral telemetry instead.
	 * @param {string} filePath Service worker file. Defaults to "/service-worker.js"
	 * @param {string} scope The service worker scope. Defaults to "/"
	 *  - API Doc: https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/register
	 * @param {ServiceWorkerOptions} [options] Optional registration options. When
	 * `onStateChange` is provided it is invoked on every service worker state
	 * change and replaces the built-in Pinpoint auto-recording. It is also
	 * invoked once with the worker's current state at registration time, so an
	 * already-active worker (which dispatches no `statechange` event) is still
	 * observed. This initial emit applies only to `onStateChange`; the built-in
	 * Pinpoint path is unaffected.
	 * @returns {Promise}
	 *	- resolve(ServiceWorkerRegistration)
	 *	- reject(Error)
	 **/
	register(
		filePath = '/service-worker.js',
		scope = '/',
		options?: ServiceWorkerOptions,
	) {
		this._onStateChange = options?.onStateChange;
		this._logger.debug(`registering ${filePath}`);
		this._logger.debug(`registering service worker with scope ${scope}`);

		return new Promise((resolve, reject) => {
			if (navigator && 'serviceWorker' in navigator) {
				navigator.serviceWorker
					.register(filePath, {
						scope,
					})
					.then(registration => {
						if (registration.installing) {
							this._serviceWorker = registration.installing;
						} else if (registration.waiting) {
							this._serviceWorker = registration.waiting;
						} else if (registration.active) {
							this._serviceWorker = registration.active;
						}
						this._registration = registration;
						this._setupListeners();
						this._logger.debug(
							`Service Worker Registration Success: ${registration}`,
						);

						resolve(registration);
					})
					.catch(error => {
						this._logger.debug(`Service Worker Registration Failed ${error}`);

						reject(
							new AmplifyError({
								name: ServiceWorkerErrorCode.Unavailable,
								message: 'Service Worker not available',
								underlyingError: error,
							}),
						);
					});
			} else {
				reject(
					new AmplifyError({
						name: ServiceWorkerErrorCode.Unavailable,
						message: 'Service Worker not available',
					}),
				);
			}
		});
	}

	/**
	 * Enable web push notifications. If not subscribed, a new subscription will
	 * be created and registered.
	 * 	Test Push Server: https://web-push-codelab.glitch.me/
	 * 	Push Server Libraries: https://github.com/web-push-libs/
	 * 	API Doc: https://developers.google.com/web/fundamentals/codelabs/push-notifications/
	 * @param publicKey
	 * @returns {Promise}
	 * 	- resolve(PushSubscription)
	 *  - reject(Error)
	 */
	enablePush(publicKey: string) {
		assert(
			this._registration !== undefined,
			ServiceWorkerErrorCode.UndefinedRegistration,
		);
		this._publicKey = publicKey;

		return new Promise((resolve, reject) => {
			if (isBrowser()) {
				assert(
					this._registration !== undefined,
					ServiceWorkerErrorCode.UndefinedRegistration,
				);
				this._registration.pushManager.getSubscription().then(subscription => {
					if (subscription) {
						this._subscription = subscription;
						this._logger.debug(
							`User is subscribed to push: ${JSON.stringify(subscription)}`,
						);
						resolve(subscription);
					} else {
						this._logger.debug(`User is NOT subscribed to push`);

						return this._registration!.pushManager.subscribe({
							userVisibleOnly: true,
							applicationServerKey: this._urlB64ToUint8Array(publicKey),
						})
							.then(pushManagerSubscription => {
								this._subscription = pushManagerSubscription;
								this._logger.debug(
									`User subscribed: ${JSON.stringify(pushManagerSubscription)}`,
								);
								resolve(pushManagerSubscription);
							})
							.catch(error => {
								this._logger.error(error);
							});
					}
				});
			} else {
				reject(
					new AmplifyError({
						name: ServiceWorkerErrorCode.Unavailable,
						message: 'Service Worker not available',
					}),
				);
			}
		});
	}

	/**
	 * Convert a base64 encoded string to a Uint8 array for the push server key
	 * @param base64String
	 */
	private _urlB64ToUint8Array(base64String: string) {
		const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
		const base64 = (base64String + padding)
			.replace(/-/g, '+')
			.replace(/_/g, '/');

		const rawData = window.atob(base64);
		const outputArray = new Uint8Array(rawData.length);

		for (let i = 0; i < rawData.length; ++i) {
			outputArray[i] = rawData.charCodeAt(i);
		}

		return outputArray;
	}

	/**
	 * Send a message to the service worker. The service worker needs
	 * to implement `self.addEventListener('message') to handle the
	 * message. This ***currently*** does not work in Safari or IE.
	 * @param {object | string} message An arbitrary JSON object or string message to send to the service worker
	 *	- see: https://developer.mozilla.org/en-US/docs/Web/API/Transferable
	 * @returns {Promise}
	 **/
	send(message: object | string) {
		if (this._serviceWorker) {
			this._serviceWorker.postMessage(
				typeof message === 'object' ? JSON.stringify(message) : message,
			);
		}
	}

	/**
	 * Listen for service worker state change and message events
	 * https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorker/state
	 *
	 * Each call to `register()` attaches its own `statechange` listener. The
	 * `onStateChange` handler is captured in a local at listener-creation time,
	 * so re-registering with a different handler only affects its own listener
	 * and never re-targets a previously attached one.
	 **/
	_setupListeners() {
		const onStateChange = this._onStateChange;

		this.serviceWorker.addEventListener('statechange', async () => {
			const currentState = this.serviceWorker.state;
			this._logger.debug(`ServiceWorker statechange: ${currentState}`);

			// Notify a consumer-provided handler, isolating any error it throws so
			// it cannot surface as an unhandled rejection from the listener.
			await this._notifyStateChange(onStateChange, currentState);

			// When no handler is provided, fall back to the built-in (deprecated)
			// Pinpoint auto-recording. A supplied handler overrides it, preventing
			// double-recording of the same state change.
			if (!onStateChange) {
				const {
					appId,
					region,
					bufferSize,
					flushInterval,
					flushSize,
					resendLimit,
				} = Amplify.getConfig().Analytics?.Pinpoint ?? {};
				const { credentials } = await fetchAuthSession();

				if (appId && region && credentials) {
					// Pinpoint is configured, record an event
					record({
						appId,
						region,
						category: 'Core',
						credentials,
						bufferSize,
						flushInterval,
						flushSize,
						resendLimit,
						event: {
							name: 'ServiceWorker',
							attributes: {
								state: currentState,
							},
						},
					});
				}
			}
		});

		// A worker that is already in a state when this listener is attached
		// (e.g. `activated` on a repeat visit, where `register()` resolves via
		// the `registration.active` branch) emits no `statechange` event, so the
		// consumer hook would otherwise never observe the current state. Notify
		// the hook once with the current state to close that gap. The built-in
		// Pinpoint path is intentionally NOT invoked here: doing so would change
		// existing (no-hook) behavior, which must remain identical to today.
		const initialState = this.serviceWorker.state;
		if (onStateChange && initialState) {
			this._logger.debug(`ServiceWorker initial state: ${initialState}`);
			this._notifyStateChange(onStateChange, initialState).catch(() => {
				// _notifyStateChange already logs handler errors; this catch only
				// satisfies the no-floating-promises contract for the fire-and-forget
				// initial emit.
			});
		}

		this.serviceWorker.addEventListener('message', event => {
			this._logger.debug(`ServiceWorker message event: ${event}`);
		});
	}

	/**
	 * Invoke the consumer `onStateChange` handler with the given state, isolating
	 * any error it throws (or rejects with, for an async handler) so it cannot
	 * surface as an unhandled rejection. Awaiting the handler means a rejected
	 * promise from an async handler is caught here too; `await undefined`
	 * resolves immediately for sync or absent handlers.
	 */
	private async _notifyStateChange(
		onStateChange: ServiceWorkerStateChangeHandler | undefined,
		state: ServiceWorkerState,
	) {
		try {
			await onStateChange?.(state);
		} catch (e) {
			this._logger.error('onStateChange handler threw', e);
		}
	}
}
