import { EventEmitter } from 'events';
import { inject, injectable } from 'inversify';
import { Redis, RedisOptions, SentinelAddress } from 'ioredis';
import ms from 'ms';
import { Redlock } from '@sesamecare-oss/redlock';
import {
	checkModuleEnabled,
	REDIS_DB,
	REDIS_HOST,
	REDIS_PASSWORD,
	REDIS_PORT,
	REDIS_SENTINEL_HOST_LIST,
	REDIS_SENTINEL_MASTER_NAME,
	REDIS_SENTINEL_PASSWORD,
	REDIS_USERNAME
} from '../environment.js';
import { internalError, DistributedEventPayload } from '../models/index.js';
import { LoggerService } from './index.js';

@injectable()
export class RedisService extends EventEmitter {
	protected readonly DEFAULT_TTL: number = ms('32 days');
	protected EVENT_CHANNEL = 'ov_meet_channel';
	protected redisPublisher: Redis;
	protected redisSubscriber: Redis;
	protected isConnected = false;
	protected eventHandler?: (event: DistributedEventPayload) => void;

	constructor(@inject(LoggerService) protected logger: LoggerService) {
		super();

		const redisOptions = this.loadRedisConfig();
		this.redisPublisher = new Redis(redisOptions);
		this.redisSubscriber = new Redis(redisOptions);

		this.setupEventHandlers();
	}

	protected setupEventHandlers(): void {
		const onConnect = () => {
			if (!this.isConnected) {
				this.logger.verbose('Connected to Redis');
			} else {
				this.logger.verbose('Reconnected to Redis');
			}

			this.isConnected = true;
			this.emit('redisConnected');
		};

		const onError = (error: Error) => {
			this.logger.error('Redis Error', error);
			this.emit('redisError', error);

			// If Redis connection fails during startup, terminate the process
			if (!this.isConnected) {
				this.logger.error('Failed to connect to Redis during startup. Terminating process...');
				process.exit(1);
			}
		};

		const onDisconnect = () => {
			this.isConnected = false;
			this.logger.warn('Redis disconnected');
			this.emit('redisDisconnected');
		};

		this.redisPublisher.on('connect', onConnect);
		this.redisSubscriber.on('connect', () => this.logger.verbose('Connected to Redis subscriber'));
		this.redisPublisher.on('error', onError);
		this.redisSubscriber.on('error', (error) => this.logger.error('Redis Subscriber Error', error));
		this.redisPublisher.on('end', onDisconnect);
		this.redisSubscriber.on('end', () => this.logger.warn('Redis subscriber disconnected'));
	}

	createRedlock(retryCount = -1, retryDelay = 200) {
		return new Redlock([this.redisPublisher], {
			driftFactor: 0.01,
			retryCount,
			retryDelay,
			retryJitter: 200 // Random variation in the time between retries.
		});
	}

	public onReady(callback: () => void) {
		if (this.isConnected) {
			callback();
		}

		this.on('redisConnected', callback);
	}

	public onceError(callback: () => void) {
		this.once('redisError', callback);
	}

	/**
	 * Publishes a message to a specified Redis channel.
	 *
	 * @param channel - The name of the Redis channel to publish the message to.
	 * @param message - The message to be published to the channel.
	 * @returns A promise that resolves when the message has been successfully published.
	 */
	async publishEvent(channel: string, message: string) {
		try {
			await this.redisPublisher.publish(channel, message);
		} catch (error) {
			this.logger.error('Error publishing message to Redis', error);
		}
	}

	/**
	 * Subscribes to a Redis channel.
	 *
	 * @param channel - The channel to subscribe to.
	 * @param callback - The callback function to execute when a message is received on the channel.
	 */
	subscribe(channel: string, callback: (message: string) => void) {
		this.logger.verbose(`Subscribing to Redis channel: ${channel}`);
		this.redisSubscriber.subscribe(channel, (err, count) => {
			if (err) {
				this.logger.error('Error subscribing to Redis channel', err);
				return;
			}

			this.logger.verbose(`Subscribed to ${channel}. Now subscribed to ${count} channel(s).`);
		});

		this.redisSubscriber.on('message', (receivedChannel, message) => {
			if (receivedChannel === channel) {
				callback(message);
			}
		});
	}

	/**
	 * Unsubscribes from a Redis channel.
	 *
	 * @param channel - The channel to unsubscribe from.
	 */
	unsubscribe(channel: string) {
		this.redisSubscriber.unsubscribe(channel, (err, count) => {
			if (err) {
				this.logger.error('Error unsubscribing from Redis channel', err);
				return;
			}

			this.logger.verbose(`Unsubscribed from channel ${channel}. Now subscribed to ${count} channel(s).`);
		});
	}

	/**
	 * Retrieves all keys from Redis that match the specified pattern.
	 *
	 * @param pattern - The pattern to match against Redis keys.
	 * @returns A promise that resolves to an array of matching keys.
	 * @throws {internalRecordingError} If there is an error retrieving keys from Redis.
	 */
	async getKeys(pattern: string): Promise<string[]> {
		let cursor = '0';
		const keys: Set<string> = new Set();

		do {
			const [nextCursor, partialKeys] = await this.redisPublisher.scan(cursor, 'MATCH', pattern);
			partialKeys.forEach((key) => keys.add(key));
			cursor = nextCursor;
		} while (cursor !== '0');

		return Array.from(keys);
	}

	/**
	 * Checks if a given key exists in the Redis store.
	 *
	 * @param {string} key - The key to check for existence.
	 * @returns {Promise<boolean>} - A promise that resolves to `true` if the key exists, otherwise `false`.
	 */
	async exists(key: string): Promise<boolean> {
		try {
			const result = await this.get(key);
			return !!result;
		} catch (error) {
			return false;
		}
	}

	get(key: string, hashKey?: string): Promise<string | null> {
		try {
			if (hashKey) {
				return this.redisPublisher.hget(key, hashKey);
			} else {
				return this.redisPublisher.get(key);
			}
		} catch (error) {
			this.logger.error('Error getting value from Redis', error);
			throw internalError('getting value from Redis');
		}
	}

	/**
	 * Sets a value in Redis with an optional TTL (time-to-live).
	 *
	 * @param {string} key - The key under which the value will be stored.
	 * @param {any} value - The value to be stored. Can be a string, number, boolean, or object.
	 * @param {boolean} [withTTL=true] - Whether to set a TTL for the key. Defaults to true.
	 * @returns {Promise<string>} - A promise that resolves to 'OK' if the operation is successful.
	 * @throws {Error} - Throws an error if the value type is invalid or if there is an issue setting the value in Redis.
	 */
	async set(key: string, value: string | number | boolean | object, withTTL = true): Promise<string> {
		try {
			const valueType = typeof value;

			if (valueType === 'string' || valueType === 'number') {
				if (withTTL) {
					await this.redisPublisher.set(key, value.toString(), 'EX', this.DEFAULT_TTL);
				} else {
					await this.redisPublisher.set(key, value.toString());
				}
			} else if (valueType === 'boolean') {
				const stringValue = value.toString();

				if (withTTL) {
					await this.redisPublisher.set(key, stringValue, 'EX', this.DEFAULT_TTL);
				} else {
					await this.redisPublisher.set(key, stringValue);
				}
			} else if (valueType === 'object') {
				await this.redisPublisher.hmset(key, value as Record<string, string | number>);

				if (withTTL) await this.redisPublisher.expire(key, this.DEFAULT_TTL);
			} else {
				throw new Error('Invalid value type');
			}

			return 'OK';
		} catch (error) {
			this.logger.error('Error setting value in Redis', error);
			throw error;
		}
	}

	/**
	 * Deletes one or more keys from Redis.
	 *
	 * @param keys - A single key string or an array of key strings to delete from Redis
	 * @returns A Promise that resolves to the number of keys that were successfully deleted
	 * @throws {Error} Throws an internal error if the deletion operation fails
	 */
	delete(keys: string | string[]): Promise<number> {
		try {
			if (typeof keys === 'string') {
				keys = [keys];
			}

			return this.redisPublisher.del(keys);
		} catch (error) {
			throw internalError(`deleting key from Redis`);
		}
	}

	/**
	 * Atomically sets a value only if the key doesn't exist (SET with NX option).
	 *
	 * @param {string} key - The key to set
	 * @param {string} value - The value to set
	 * @param {number} [ttlSeconds] - Optional TTL in seconds
	 * @returns {Promise<boolean>} - True if the key was set, false if it already existed
	 */
	async setIfNotExists(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
		try {
			let result: string | null;

			if (ttlSeconds) {
				result = await this.redisPublisher.set(key, value, 'EX', ttlSeconds, 'NX');
			} else {
				result = (await this.redisPublisher.setnx(key, value)) ? 'OK' : null;
			}

			return result === 'OK';
		} catch (error) {
			this.logger.error('Error setting value with NX option in Redis', error);
			throw error;
		}
	}

	/**
	 * Sets an expiration time on an existing key.
	 *
	 * @param {string} key - The key to set expiration on
	 * @param {number} ttlSeconds - TTL in seconds
	 * @returns {Promise<boolean>} - True if expiration was set successfully
	 */
	async setExpiration(key: string, ttlSeconds: number): Promise<boolean> {
		try {
			const result = await this.redisPublisher.expire(key, ttlSeconds);
			return result === 1;
		} catch (error) {
			this.logger.error('Error setting expiration in Redis', error);
			throw error;
		}
	}

	/**
	 * Removes and returns the member with the lowest score from a sorted set.
	 *
	 * @param {string} key - The key of the sorted set
	 * @param {number} count - Number of members to pop (default: 1)
	 * @returns {Promise<string[]>} - Array of popped members
	 */
	async popMinFromSortedSet(key: string, count = 1): Promise<string[]> {
		try {
			return await this.redisPublisher.zpopmin(key, count);
		} catch (error) {
			this.logger.error('Error popping min from sorted set in Redis', error);
			throw error;
		}
	}

	/**
	 * Adds one or more members to a sorted set.
	 *
	 * @param {string} key - The key of the sorted set
	 * @param {number} score - The score for the member
	 * @param {string} member - The member to add
	 * @returns {Promise<number>} - Number of elements added
	 */
	async addToSortedSet(key: string, score: number, member: string): Promise<number> {
		try {
			return await this.redisPublisher.zadd(key, score, member);
		} catch (error) {
			this.logger.error('Error adding to sorted set in Redis', error);
			throw error;
		}
	}

	cleanup() {
		this.logger.verbose('Cleaning up Redis connections');
		this.redisPublisher.quit();
		this.redisSubscriber.quit();
		this.removeAllListeners();

		if (this.eventHandler) {
			this.off('systemEvent', this.eventHandler);
			this.eventHandler = undefined;
		}

		this.isConnected = false;
		this.logger.verbose('Redis connections cleaned up');
	}

	private loadRedisConfig(): RedisOptions {
		// Check if openviduCall module is enabled. If not, exit the process
		checkModuleEnabled();

		//Check if Redis Sentinel is configured
		if (REDIS_SENTINEL_HOST_LIST) {
			const sentinels: Array<SentinelAddress> = [];
			const sentinelHosts = REDIS_SENTINEL_HOST_LIST.split(',');
			sentinelHosts.forEach((host) => {
				const rawHost = host.split(':');

				if (rawHost.length !== 2) {
					throw new Error('The Redis Sentinel host list is required');
				}

				const hostName = rawHost[0];
				const port = parseInt(rawHost[1]);
				sentinels.push({ host: hostName, port });
			});

			if (!REDIS_SENTINEL_PASSWORD) throw new Error('The Redis Sentinel password is required');

			this.logger.verbose('Using Redis Sentinel');
			return {
				sentinels,
				sentinelPassword: REDIS_SENTINEL_PASSWORD,
				username: REDIS_USERNAME,
				password: REDIS_PASSWORD,
				name: REDIS_SENTINEL_MASTER_NAME,
				db: Number(REDIS_DB),
				maxRetriesPerRequest: null // Infinite retries
			};
		} else {
			this.logger.verbose('Using Redis standalone');
			return {
				port: Number(REDIS_PORT),
				host: REDIS_HOST,
				username: REDIS_USERNAME,
				password: REDIS_PASSWORD,
				db: Number(REDIS_DB),
				maxRetriesPerRequest: null // Infinite retries
			};
		}
	}
}
