
// IMPORTS /////////////////////////////////////////////////////////////////////

import _                                    from 'lodash';
import * as defaults                        from 'defaults';
import * as levelUtil                       from 'levels';
import codependency                         from 'codependency';
import net                                  from 'net';
import text                                 from 'text';
import tls                                  from 'tls';
import urlUtil                              from 'url';
import { build }                            from 'serialize';
import { Writable }                         from 'stream';
import { BadOptionsError, LogentriesError } from 'error';

// SYMBOLS /////////////////////////////////////////////////////////////////////

const $bufferFull     = Symbol('bufferFull property');
const $cannotConnect  = Symbol('cannotConnect property');
const $connection     = Symbol('connection property');
const $console        = Symbol('console property');
const $failedAttempts = Symbol('failedAttempts property');
const $flatten        = Symbol('flatten property');
const $flattenArrays  = Symbol('flattenArrays property');
const $getConnection  = Symbol('getConnection method');
const $host           = Symbol('host property');
const $levels         = Symbol('levels property');
const $Logger         = Symbol('Logger property (winston / bunyan)');
const $minLevel       = Symbol('minLevel property');
const $port           = Symbol('port property');
const $replacer       = Symbol('replacer property');
const $secure         = Symbol('secure property');
const $serialize      = Symbol('serialize method');
const $tempLevel      = Symbol('tempLevel property (winston)');
const $timeout        = Symbol('timeout property');
const $timestamp      = Symbol('timestamp property');
const $token          = Symbol('token property');
const $toLevel        = Symbol('toLevel method');
const $withLevel      = Symbol('withLevel property');
const $withStack      = Symbol('withStack property');

// PATTERNS ////////////////////////////////////////////////////////////////////

const newline      = /\n/g;
const tokenPattern = /[a-f\d]{8}-([a-f\d]{4}-){3}[a-f\d]{12}/;

// UTIL ////////////////////////////////////////////////////////////////////////

const finalizeLogString = (log, token) =>
	`${ token } ${ log.toString().replace(newline, '\u2028') }\n`;

const getConsoleMethod = lvl => {
	return lvl == 3 ? 'warn' : lvl > 3 ? 'error' : 'log';
};

const getSafeProp = (log, prop) => {
	while (prop in log) prop = '_' + prop;
	return prop;
};

const requirePeer = codependency.register(module);

// DECORATORS //////////////////////////////////////////////////////////////////

const nonConfigurable = (target, name, descriptor) => {
	descriptor.configurable = false;
	descriptor.enumerable = true;
	return descriptor;
};

const unwritable = (target, name, descriptor) => {
	descriptor.writable = false;
	return descriptor;
};

const enumerable = (target, name, descriptor) => {
	descriptor.enumerable = true;
	return descriptor;
};

// LOGGER //////////////////////////////////////////////////////////////////////

export default class Logger extends Writable {
	constructor(opts) {
		super({
			objectMode: true,
			highWaterMark: opts.bufferSize || defaults.bufferSize
		});

		// Sanity checks

		if (_.isUndefined(opts))
			throw new BadOptionsError(opts, text.noOptions());

		if (!_.isObject(opts))
			throw new BadOptionsError(opts, text.optionsNotObj(typeof opts));

		if (_.isUndefined(opts.token))
			throw new BadOptionsError(opts, text.noToken());

		if (!_.isString(opts.token) || !tokenPattern.test(opts.token))
			throw new BadOptionsError(opts, text.invalidToken(opts.token));

		// Log method aliases

		this[$levels] = levelUtil.normalize(opts);

		for (const lvlName of this.levels) {
			if (lvlName in this)
				throw new BadOptionsError(opts, text.levelConflict(lvlName));

			Object.defineProperty(this, lvlName, {
				enumerable: true,
				writable: false,
				value() {
					this.log.apply(this, [ lvlName, ...arguments ]);
				}
			});
		}

		// Other permanent options

		this[$token] = opts.token;

		// Editable options

		this.port =
			opts.port || (opts.secure ? defaults.portSecure : defaults.port);

		this.flatten       = opts.flatten;
		this.flattenArrays =
			'flattenArrays' in opts ? opts.flattenArrays : opts.flatten;

		this.host          = opts.host;
		this.console       = opts.console;
		this.minLevel      = opts.minLevel;
		this.replacer      = opts.replacer;
		this.timeout       = opts.timeout;
		this.timestamp     = opts.timestamp;
		this.withLevel     = 'withLevel' in opts ? opts.withLevel : true;
		this.withStack     = opts.withStack;
		this[$secure]      = opts.secure;

		// Other

		this[$cannotConnect]  = false;
		this[$failedAttempts] = 0;
	}

	// Stream methods

	@unwritable
	async _write(log, enc, cb) {
		log = finalizeLogString(log, this[$token]);

		try {
			const conn = await this[$getConnection]();
			conn.write(log);

		} catch (err) {
			this.emit('error', err);

		} finally {
			cb();
		}
	}

	@unwritable
	end() {
		this.closeConnection();
		super.end(...arguments);
	}

	@unwritable
	setDefaultEncoding() { /* no. */ }

	// Public methods

	@unwritable
	@enumerable
	log(lvl, log) {
		// lvl is optional
		if (log === undefined) {
			log = lvl;
			lvl = null;
		}

		let lvlName;

		if (lvl || lvl === 0) {
			[ lvl, lvlName ] = this[$toLevel](lvl);

			// If lvl is present, it must be recognized
			if (!lvl && lvl !== 0) {
				this.emit('error', new LogentriesError(text.unknownLevel(lvl)));
				return;
			}

			// If lvl is below minLevel, it is dismissed
			if (lvl < this.minLevel)
				return;
		}

		// If log is an array, it is treated as a collection of log events
		if (_.isArray(log)) {
			if (log.length)
				for (const $log of log) this.log(lvl, $log);
			else
				this.emit('error', new LogentriesError(text.noLogMessage()));

			return;
		}

		// If log is an object, it is serialized to string and may be augmented
		// with timestamp and level. For strings, these may be prepended.

		if (_.isObject(log)) {

			let safeTime, safeLevel;

			if (this.timestamp) {
				safeTime = getSafeProp(log, 'time');
				log[safeTime] = new Date();
			}

			if (this.withLevel && lvlName) {
				safeLevel = getSafeProp(log, 'level');
				log[safeLevel] = lvlName;
			}

			log = this[$serialize](log);

			if (!log) {
				this.emit('error', new LogentriesError(text.serializedEmpty()));
				return;
			}

			if (this.console)
				console[getConsoleMethod(lvl)](JSON.parse(log));

			if (safeTime) delete log[safeTime];
			if (safeLevel) delete log[safeLevel];

		} else {
			if (_.isEmpty(log)) {
				this.emit('error', new LogentriesError(text.noLogMessage()));
				return;
			}

			log = [ log.toString() ];

			if (this.withLevel && lvlName)
				log.unshift(lvlName);

			if (this.timestamp)
				log.unshift((new Date()).toISOString());

			log = log.join(' ');

			if (this.console)
				console[getConsoleMethod(lvl)](log);
		}

		if (this[$bufferFull]) {
			this.emit('error', new LogentriesError(text.bufferFull(log)));
			return;
		}

		this.emit('log', log);

		const mayWriteFurther = this.write(log);

		if (!mayWriteFurther) {
			this[$bufferFull] = true;
			this.once('drain', () => this[$bufferFull] = false);
		}
	}

	@unwritable
	@enumerable
	closeConnection() {
		if (!this[$connection]) return;

		this[$connection].then(conn => conn.end());
	}

	// Private methods

	[$toLevel](val) {
		let num;

		if (levelUtil.isNumberValid(val))
			num = parseInt(parseInt(val)); // -0
		else
			num = this[$levels].indexOf(val);

		const name = this[$levels][num];

		return name ? [ num, name ] : [];
	}

	async [$getConnection](_resolve, _reject) {
		// The $connection property is a promise. On error, manual close, or
		// timeout, it deletes itself.

		if (this[$connection]) return this[$connection];

		if (this[$failedAttempts])
			await new Promise(resolve => {
				setTimeout(resolve, (this[$failedAttempts] ** 1.5) * 1000);
			});

		this[$connection] = new Promise((resolve, reject) => {
			resolve = _resolve || resolve;
			reject  = _reject || reject;

			if (this[$cannotConnect]) {
				reject(new LogentriesError(text.cannotConnect()));
				return;
			}

			const opts = {
				host: this.host,
				port: this.port
			};

			let connection;
			let resolved = false;

			if (this.secure)
				connection = tls.connect(opts, () => {
					if (!connection.authorized) {
						this[$failedAttempts]++;
						const errMsg = connection.authorizationError;
						this.emit(new LogentriesError(text.authError(errMsg)));
					}
				});
			else
				connection = net.createConnection(opts);

			connection.setTimeout(this.timeout);

			// Expose the connection’s drain event
			connection.on('drain', () =>
				setImmediate(() => this.emit('connection drain'))
			);

			// This is an inactivity timeout, not a connection timeout
			connection.on('timeout', () => {
				connection.end();
				delete this[$connection];
			});

			// Error rejects the promise
			connection.on('error', () => {
				connection.destroy();
				delete this[$connection];

				// If this error occurred before a connection was established,
				// we will try again up to a point.
				if (!resolved) {
					this[$failedAttempts]++;

					if (this[$failedAttempts] >= defaults.maxFailedAttempts) {
						this[$cannotConnect] = true;

						setTimeout(() => {
							this[$cannotConnect] = false;
						}, defaults.retryTimeout);
					}

					this[$getConnection](resolve, reject);
				}
			});

			// Close will emit whether it was intended or error-caused:
			connection.on('close', () => {
				delete this[$connection];

				this.emit('disconnected');
			});

			// This will probably never be triggered since the communication is
			// one-way, but just in case:
			connection.on('end', () => {
				delete this[$connection];
			});

			// Yay
			connection.on('connect', () => {
				this[$failedAttempts] = 0;
				resolved = true;
				resolve(connection);

				this.emit('connected');
			});
		});

		return this[$connection];
	}

	// Coercive settable options

	@nonConfigurable
	get console() { return this[$console]; }
	set console(val) { this[$console] = !!val; }

	@nonConfigurable
	get flatten() { return this[$flatten]; }
	set flatten(val) {
		this[$flatten] = !!val;
		this[$serialize] = build(this);
	}

	@nonConfigurable
	get flattenArrays() { return this[$flattenArrays]; }
	set flattenArrays(val) {
		this[$flattenArrays] = !!val;
		this[$serialize] = build(this);
	}

	@nonConfigurable
	get host() { return this[$host]; }
	set host(val) {
		if (!_.isString(val) || !val.length) {
			this[$host] = defaults.host;
			return;
		}

		val = val.replace(/^https?:\/\//, '');

		const url = urlUtil.parse(`http://${ val }`);

		this[$host] = url.hostname || defaults.host;

		if (url.port) this.port = url.port;
	}

	@nonConfigurable
	get minLevel() { return this[$minLevel]; }
	set minLevel(val) {
		const [ num ] = this[$toLevel](val);

		this[$minLevel] = _.isNumber(num) ? num : 0;
	}

	@nonConfigurable
	get port() { return this[$port]; }
	set port(val) {
		val = parseFloat(val);
		if (Number.isInteger(val) && _.inRange(val, 65536)) this[$port] = val;
	}

	@nonConfigurable
	get replacer() { return this[$replacer]; }
	set replacer(val) {
		this[$replacer] = _.isFunction(val) ? val : undefined;
		this[$serialize] = build(this);
	}

	@nonConfigurable
	get timeout() { return this[$timeout]; }
	set timeout(val) {
		if (Number.isInteger(val) && val >= 0)
			this[$timeout] = parseInt(val);

		if (!_.isNumber(this[$timeout]))
			this[$timeout] = defaults.timeout;

		this.closeConnection();
	}

	@nonConfigurable
	get timestamp() { return this[$timestamp]; }
	set timestamp(val) { this[$timestamp] = !!val; }

	@nonConfigurable
	get withLevel() { return this[$withLevel]; }
	set withLevel(val) { this[$withLevel] = !!val; }

	@nonConfigurable
	get withStack() { return this[$withStack]; }
	set withStack(val) {
		this[$withStack] = !!val;
		this[$serialize] = build(this);
	}

	// Immutable properties

	@nonConfigurable
	get levels() { return this[$levels] && this[$levels].slice(); }

	@nonConfigurable
	get secure() { return this[$secure]; }

	// Exposed but externally immutable

	@nonConfigurable
	get serialize() { return this[$serialize]; }

	// Deprecated (to support migrants from le_node)

	@unwritable
	level(name) {
		console.warn(text.deprecatedLevelMethod());
		if (~this[$levels].indexOf(name)) this.minLevel = name;
	}

	@unwritable
	winston() {
		console.warn(text.deprecatedWinstonMethod());
	}

	// Static methods

	@unwritable
	static provisionWinston() {
		const winston = requirePeer('winston');

		if (winston.transports.Logentries) return;

		const Transport = winston.Transport;

		class LogentriesTransport extends Transport {
			constructor(opts) {
				super(opts);

				opts = _.clone(opts || {});

				opts.minLevel =
					opts.minLevel || opts.level || this[$tempLevel] || 0;

				opts.levels = opts.levels || winston.levels;

				delete this[$tempLevel];

				this[$Logger] = new Logger(opts);
			}

			@unwritable
			@enumerable
			log(lvl, msg, meta, cb) {
				if (!_.isEmpty(meta))
					if (_.isString(msg))
						msg += ' ' + this[$Logger][$serialize](meta);

					else if (_.isObject(msg))
						msg[getSafeProp(msg, 'meta')] = meta;

				this[$Logger].log(lvl, msg);

				setImmediate(cb.bind(null, null, true));
			}

			@nonConfigurable
			get name() { return 'logentries'; }

			@nonConfigurable
			get level() {
				const [ , lvlName ] =
					this[$Logger][$toLevel](this[$Logger].minLevel);

				return lvlName;
			}
			set level(val) {
				if (!this[$Logger])
					this[$tempLevel] = val;
				else
					this[$Logger].minLevel = val;
			}

			@nonConfigurable
			get levels() {
				return this[$Logger].levels.reduce((acc, lvlName, lvlNum) => {
					acc[lvlName] = lvlNum;
					return acc;
				}, {});
			}
		}

		winston.transports.Logentries = LogentriesTransport;
	}

	static bunyanStream(opts) {
		const stream      = new BunyanStream(opts);
		const [ , level ] = stream[$Logger][$toLevel](stream[$Logger].minLevel);
		const type        = 'raw';
		const name        = 'logentries';

		// Defer to Bunyan’s handling of minLevel

		stream[$Logger].minLevel = 0;

		return { level, name, stream, type };
	}
}

// PROVISION WINSTON ///////////////////////////////////////////////////////////

const winston = requirePeer('winston', { optional: true });

if (winston) Logger.provisionWinston();

// BUNYAN STREAM ///////////////////////////////////////////////////////////////

class BunyanStream extends Writable {
	constructor(opts) {
		super({
			objectMode: true,
			highWaterMark: opts.bufferSize || defaults.bufferSize
		});

		opts = _.clone(opts || {});

		opts.timestamp = false;
		opts.levels    = opts.levels || defaults.bunyanLevels;

		this[$Logger] = new Logger(opts);
	}

	@unwritable
	_write(log, enc, cb) {
		const lvl = (log.level / 10) - 1;

		this[$Logger].log(lvl, log);

		setImmediate(cb);
	}
}
