'use strict';
const {URL} = require('url');
const EventEmitter = require('events');
const tls = require('tls');
const http2 = require('http2');

const kCurrentStreamsCount = Symbol('currentStreamsCount');
const kRequest = Symbol('request');

const nameKeys = [
	// `http2.connect()` options
	'maxDeflateDynamicTableSize',
	'maxSessionMemory',
	'maxHeaderListPairs',
	'maxOutstandingPings',
	'maxReservedRemoteStreams',
	'maxSendHeaderBlockLength',
	'paddingStrategy',
	'peerMaxConcurrentStreams',
	'settings',

	// `tls.connect()` options
	'localAddress',
	'family',
	'path',
	'rejectUnauthorized',
	'minDHSize',

	// `tls.createSecureContext()` options
	'ca',
	'cert',
	'clientCertEngine',
	'ciphers',
	'key',
	'pfx',
	'servername',
	'minVersion',
	'maxVersion',
	'secureProtocol',
	'crl',
	'honorCipherOrder',
	'ecdhCurve',
	'dhparam',
	'secureOptions',
	'sessionIdContext'
];

const removeSession = (where, name, session) => {
	if (where[name]) {
		const index = where[name].indexOf(session);

		if (index !== -1) {
			where[name].splice(index, 1);

			if (where[name].length === 0) {
				delete where[name];
			}

			return true;
		}
	}

	return false;
};

class Agent extends EventEmitter {
	constructor({timeout = 60000, maxSessions = Infinity, maxFreeSessions = 1} = {}) {
		super();

		this.busySessions = {};
		this.freeSessions = {};
		this.queue = {};

		this.timeout = timeout;
		this.maxSessions = maxSessions;
		this.maxFreeSessions = maxFreeSessions;
	}

	getName(authority, options = {}) {
		if (typeof authority === 'string') {
			authority = new URL(authority);
		}

		const port = authority.port || 443;
		const host = authority.hostname || authority.host || 'localhost';

		let name = `${host}:${port}`;

		// TODO: this should ignore defaults too
		for (const key of nameKeys) {
			if (options[key]) {
				if (typeof options[key] === 'object') {
					name += `:${JSON.stringify(options[key])}`;
				} else {
					name += `:${options[key]}`;
				}
			}
		}

		return name;
	}

	_processQueue(name) {
		const busyLength = this.busySessions[name] ? this.busySessions[name].length : 0;

		if (busyLength < this.maxSessions && this.queue[name] && !this.queue[name].completed) {
			this.queue[name]();

			this.queue[name].completed = true;
		}
	}

	async getSession(authority, options, preconnectOnly = false) {
		return new Promise((resolve, reject) => {
			const name = this.getName(authority, options);
			const detached = {resolve, reject};

			if (this.freeSessions[name]) {
				resolve(this.freeSessions[name][0]);

				return;
			}

			if (this.queue[name]) {
				let listenersLength = this.queue[name].listeners.length;

				if (this.queue[name].preconnectOnly) {
					listenersLength--;
				}

				if (listenersLength < this.maxFreeSessions) {
					this.queue[name].listeners.push(detached);

					if (this.queue[name].listeners.length === this.maxFreeSessions) {
						// All seats are taken, remove entry from the queue.
						delete this.queue[name];
					}

					return;
				}
			}

			const listeners = [detached];

			const free = () => {
				// If our entry is replaced,`completed` will be `false`.
				// Or the entry will be `undefined` if all seats are taken.
				if (this.queue[name] && this.queue[name].completed) {
					delete this.queue[name];
				}
			};

			this.queue[name] = () => {
				try {
					const session = http2.connect(authority, {
						createConnection: this.createConnection,
						...options
					});
					session[kCurrentStreamsCount] = 0;

					session.setTimeout(this.timeout, () => {
						session.close();
					});

					session.once('error', error => {
						session.destroy();

						for (const listener of listeners) {
							listener.reject(error);
						}
					});

					session.once('close', () => {
						free();

						removeSession(this.freeSessions, name, session);
						this._processQueue(name);
					});

					session.once('remoteSettings', () => {
						free();

						this.freeSessions[name] = [session];

						for (const listener of listeners) {
							listener.resolve(session);
						}
					});

					session[kRequest] = session.request;
					session.request = headers => {
						const stream = session[kRequest](headers, {
							endStream: false
						});

						session.ref();

						if (++session[kCurrentStreamsCount] >= session.remoteSettings.maxConcurrentStreams) {
							removeSession(this.freeSessions, name, session);

							if (this.busySessions[name]) {
								this.busySessions[name].push(session);
							} else {
								this.busySessions[name] = [session];
							}
						}

						stream.once('close', () => {
							if (--session[kCurrentStreamsCount] < session.remoteSettings.maxConcurrentStreams) {
								session.unref();

								if (removeSession(this.busySessions, name, session) && !session.destroyed) {
									if ((this.freeSessions[name] || []).length < this.maxFreeSessions) {
										if (this.freeSessions[name]) {
											this.freeSessions[name].push(session);
										} else {
											this.freeSessions[name] = [session];
										}
									} else {
										session.close();
									}
								}
							}
						});

						return stream;
					};
				} catch (error) {
					for (const listener of listeners) {
						listener.reject(error);
					}
				}
			};

			this.queue[name].listeners = listeners;
			this.queue[name].preconnectOnly = preconnectOnly;
			this.queue[name].completed = false;
			this._processQueue(name);
		});
	}

	async request(authority, options, headers) {
		const session = await this.getSession(authority, options);
		const stream = session.request(headers);

		return stream;
	}

	createConnection(authority, options) {
		return Agent.connect(authority, options);
	}

	static connect(authority, options) {
		options.ALPNProtocols = ['h2'];

		if (typeof options.servername === 'undefined') {
			options.servername = authority.host;
		}

		const port = authority.port || 443;
		const host = authority.hostname || authority.host || 'localhost';

		return tls.connect(port, host, options);
	}

	closeFreeSessions() {
		for (const freeSessions of Object.values(this.freeSessions)) {
			for (const session of freeSessions) {
				if (session[kCurrentStreamsCount] === 0) {
					session.close();
				}
			}
		}
	}

	destroy(error) {
		for (const busySessions of Object.values(this.busySessions)) {
			for (const session of busySessions) {
				session.destroy(error);
			}
		}

		for (const freeSessions of Object.values(this.freeSessions)) {
			for (const session of freeSessions) {
				session.destroy(error);
			}
		}
	}
}

module.exports = {
	Agent,
	globalAgent: new Agent()
};
