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

import { Observable, Observer, SubscriptionLike, filter, map } from 'rxjs';

import { ConnectionState } from '../types/PubSub';

import { ReachabilityMonitor } from './ReachabilityMonitor';

// Internal types for tracking different connection states
type LinkedConnectionState = 'connected' | 'disconnected';
type LinkedHealthState = 'healthy' | 'unhealthy';
interface LinkedConnectionStates {
	networkState: LinkedConnectionState;
	connectionState: LinkedConnectionState | 'connecting';
	intendedConnectionState: LinkedConnectionState;
	keepAliveState: LinkedHealthState;
}

export const CONNECTION_CHANGE: Record<
	| 'KEEP_ALIVE_MISSED'
	| 'KEEP_ALIVE'
	| 'CONNECTION_ESTABLISHED'
	| 'CONNECTION_FAILED'
	| 'CLOSING_CONNECTION'
	| 'OPENING_CONNECTION'
	| 'CLOSED'
	| 'ONLINE'
	| 'OFFLINE',
	Partial<LinkedConnectionStates>
> = {
	KEEP_ALIVE_MISSED: { keepAliveState: 'unhealthy' },
	KEEP_ALIVE: { keepAliveState: 'healthy' },
	CONNECTION_ESTABLISHED: { connectionState: 'connected' },
	CONNECTION_FAILED: {
		intendedConnectionState: 'disconnected',
		connectionState: 'disconnected',
	},
	CLOSING_CONNECTION: { intendedConnectionState: 'disconnected' },
	OPENING_CONNECTION: {
		intendedConnectionState: 'connected',
		connectionState: 'connecting',
	},
	CLOSED: { connectionState: 'disconnected' },
	ONLINE: { networkState: 'connected' },
	OFFLINE: { networkState: 'disconnected' },
};

export class ConnectionStateMonitor {
	/**
	 * @private
	 */
	private _linkedConnectionState: LinkedConnectionStates;
	private _linkedConnectionStateObservable: Observable<LinkedConnectionStates>;
	private _linkedConnectionStateObserver?: Observer<LinkedConnectionStates>;
	private _networkMonitoringSubscription?: SubscriptionLike;
	private _initialNetworkStateSubscription?: SubscriptionLike;

	constructor() {
		this._networkMonitoringSubscription = undefined;
		this._linkedConnectionState = {
			networkState: 'connected',
			connectionState: 'disconnected',
			intendedConnectionState: 'disconnected',
			keepAliveState: 'healthy',
		};

		// Attempt to update the state with the current actual network state
		this._initialNetworkStateSubscription = ReachabilityMonitor().subscribe(
			({ online }) => {
				this.record(
					online ? CONNECTION_CHANGE.ONLINE : CONNECTION_CHANGE.OFFLINE,
				);
				this._initialNetworkStateSubscription?.unsubscribe();
			},
		);

		this._linkedConnectionStateObservable =
			new Observable<LinkedConnectionStates>(connectionStateObserver => {
				connectionStateObserver.next(this._linkedConnectionState);
				this._linkedConnectionStateObserver = connectionStateObserver;
			});
	}

	/**
	 * Turn network state monitoring on if it isn't on already
	 */
	private enableNetworkMonitoring() {
		// If no initial network state was discovered, stop trying
		this._initialNetworkStateSubscription?.unsubscribe();

		// Maintain the network state based on the reachability monitor
		if (this._networkMonitoringSubscription === undefined) {
			this._networkMonitoringSubscription = ReachabilityMonitor().subscribe(
				({ online }) => {
					this.record(
						online ? CONNECTION_CHANGE.ONLINE : CONNECTION_CHANGE.OFFLINE,
					);
				},
			);
		}
	}

	/**
	 * Turn network state monitoring off if it isn't off already
	 */
	private disableNetworkMonitoring() {
		this._networkMonitoringSubscription?.unsubscribe();
		this._networkMonitoringSubscription = undefined;
	}

	/**
	 * Get the observable that allows us to monitor the connection state
	 *
	 * @returns {Observable<ConnectionState>} - The observable that emits ConnectionState updates
	 */
	public get connectionStateObservable(): Observable<ConnectionState> {
		let previous: ConnectionState;

		// The linked state aggregates state changes to any of the network, connection,
		// intendedConnection and keepAliveHealth. Some states will change these independent
		// states without changing the overall connection state.

		// After translating from linked states to ConnectionState, then remove any duplicates
		return this._linkedConnectionStateObservable
			.pipe(
				map(value => {
					return this.connectionStatesTranslator(value);
				}),
			)
			.pipe(
				filter(current => {
					const toInclude = current !== previous;
					previous = current;

					return toInclude;
				}),
			);
	}

	/*
	 * Updates local connection state and emits the full state to the observer.
	 */
	record(statusUpdates: Partial<LinkedConnectionStates>) {
		// Maintain the network monitor
		if (statusUpdates.intendedConnectionState === 'connected') {
			this.enableNetworkMonitoring();
		} else if (statusUpdates.intendedConnectionState === 'disconnected') {
			this.disableNetworkMonitoring();
		}

		// Maintain the socket state
		const newSocketStatus = {
			...this._linkedConnectionState,
			...statusUpdates,
		};

		this._linkedConnectionState = { ...newSocketStatus };

		this._linkedConnectionStateObserver?.next(this._linkedConnectionState);
	}

	/*
	 * Translate the ConnectionState structure into a specific ConnectionState string literal union
	 */
	private connectionStatesTranslator({
		connectionState,
		networkState,
		intendedConnectionState,
		keepAliveState,
	}: LinkedConnectionStates): ConnectionState {
		if (connectionState === 'connected' && networkState === 'disconnected')
			return ConnectionState.ConnectedPendingNetwork;

		if (
			connectionState === 'connected' &&
			intendedConnectionState === 'disconnected'
		)
			return ConnectionState.ConnectedPendingDisconnect;

		if (
			connectionState === 'disconnected' &&
			intendedConnectionState === 'connected' &&
			networkState === 'disconnected'
		)
			return ConnectionState.ConnectionDisruptedPendingNetwork;

		if (
			connectionState === 'disconnected' &&
			intendedConnectionState === 'connected'
		)
			return ConnectionState.ConnectionDisrupted;

		if (connectionState === 'connected' && keepAliveState === 'unhealthy')
			return ConnectionState.ConnectedPendingKeepAlive;

		// All remaining states directly correspond to the connection state
		if (connectionState === 'connecting') return ConnectionState.Connecting;
		if (connectionState === 'disconnected') return ConnectionState.Disconnected;

		return ConnectionState.Connected;
	}
}
