import * as net from 'net';
import {Buffer} from 'buffer';

export class ExtendableError extends Error {
	constructor(message: string = '', public readonly innerException?: Error) {
		super(message);
		this.message = message;
		this.name = this.constructor.name;
		Error.captureStackTrace(this, this.constructor);
	}
}

export class RconError extends ExtendableError {
	constructor(message: string, innerException?: Error) {
		super(message, innerException);
		Object.freeze(this);
	}
}

export const enum State {
	Disconnected = 0,
	Connecting = 0.5,
	Connected = 1,
	Authorized = 2,
	Refused = -1,
	Unauthorized = -2
}

const enum PacketType {
	AUTH = 0x03, // outgoing
	COMMAND = 0x02, // outgoing
	RESPONSE_AUTH = 0x02, // incoming
    RESPONSE = 0x00 // incoming
}

type Callback = (data: string | null, error?: Error) => void;

export interface RconConfig {
	host: string;
	port?: number;
	password: string;
	timeout?: number;
}

export namespace Defaults
{
	export const PORT:number = 25575;
	export const TIMEOUT:number = 5000;
}
Object.freeze(Defaults);


export class Rcon implements RconConfig {

	readonly host: string;
	readonly port: number;
	readonly password: string;
	readonly timeout: number;

	enableConsoleLogging: boolean = false;

	private _authPacketId: number = NaN;
	private _state: State = State.Disconnected;
	private _socket: net.Socket | undefined;
	private _lastRequestId: number = 0xF4240;
	private _callbacks: Map<number, Callback> = new Map();
	private _errors: Error[] = [];
	private _connector: Promise<Rcon> | undefined;
	private _sessionCount:number = 0;
    private _tempResponse:string = '';

	get errors(): Error[] {
		return this._errors.slice();
	}

	get state(): State {
		return this._state;
	}

	constructor(config: RconConfig) {
		let host = config.host;
		this.host = host = host && host.trim();
		if (!host)
			throw new TypeError('"host" argument cannot be empty');

		this.port = config.port || Defaults.PORT;

		const password = config.password;
		if (!password || !password.trim())
			throw new TypeError('"password" argument cannot be empty');

		this.password = password;
		this.timeout = config.timeout || Defaults.TIMEOUT;
	}

	connect(): Promise<Rcon> {
		const _ = this;
		let p = _._connector;
		if (!p) _._connector = p = new Promise<Rcon>((resolve, reject) => {
			_._state = State.Connecting;
			if (_.enableConsoleLogging) console.log(this.toString(), "Connecting...");
			const s = _._socket = net.createConnection(_.port, _.host);

			function cleanup(message?: string, error?: Error): RconError | void {
				if (error) _._errors.push(error);
				s.removeAllListeners();
				if (_._socket == s) _._socket = undefined;
				if (_._connector == p) _._connector = undefined;
				if (message) {
					if (_.enableConsoleLogging) console.error(_.toString(), message);
					if (message) return new RconError(message, error);
				}

			}

			// Look for connection failure...
			s.once('error', error => {
				_._state = State.Refused;
				reject(cleanup("Connection refused.", error)); // ** First point of failure.
			});

			// Look for successful connection...
			s.once('connect', () => {
				s.removeAllListeners('error');
				_._state = State.Connected;
				if (_.enableConsoleLogging) console.log(_.toString(), "Connected. Authorizing ...");

				s.on('data', data => _._handleResponse(data));

				s.on('error', error => {
					_._errors.push(error);
					if (_.enableConsoleLogging) console.error(_.toString(), error);
				});

				_._send(_.password, PacketType.AUTH).then(() => {
					_._state = State.Authorized;
					if (_.enableConsoleLogging) console.log(_.toString(), "Authorized.");
					resolve(_);
				}).catch(error => {
					_._state = State.Unauthorized;
					reject(cleanup("Authorization failed.", error)); // ** Second point of failure.
				});
			});

			s.once('end', () => {
				if (_.enableConsoleLogging) console.warn(this.toString(), "Disconnected.");
				_._state = State.Disconnected;
				cleanup();
			});
		});
		return p;
	}

	async session<T>(context:(rcon:Rcon,sessionId:number)=>Promise<T>):Promise<T>
	{
		const sessionId = ++this._sessionCount;
		let rcon:Rcon|undefined;
		try {
			rcon = await this.connect();
			return await context(rcon, sessionId);
		}
		finally {
			this._sessionCount--;
			if(!this._sessionCount && rcon)
				rcon.disconnect();
		}
	}

	toString(): string {
		return `RCON: ${this.host}:${this.port}`;
	}

	disconnect(): void {
		const s = this._socket;
		this._callbacks.clear();
		if (s) s.end();
		this._socket = undefined;
		this._connector = undefined;
	}

	_handleResponse(data: Buffer): void {
		const len = data.readInt32LE(0);
		if (!len) throw new RconError('Received empty response package');

		let id = data.readInt32LE(4);
		const type = data.readInt32LE(8);
		const callbacks = this._callbacks;
		const authId = this._authPacketId;
        let payload = data.toString('utf8', 12, len + 2);
        if (payload.charAt(payload.length - 1) === '\n')
            payload = payload.substring(0, payload.length - 1);

        // console.log("Received\n", "type:", type ,", response:", payload, ", current id: ", id, ", authId: ", authId);
        // console.log("callback: ", callbacks);

		if (id === -1 && !isNaN(authId) && type === PacketType.RESPONSE_AUTH) {
			if (callbacks.has(authId)) {
				id = authId;
				this._authPacketId = NaN;
				callbacks.get(authId)!(null, new RconError('Authentication failed.'));
			}
		}

        if (callbacks.has(id) && type === PacketType.RESPONSE_AUTH) {
            callbacks.get(id)!("");
        }
		
        if (type === PacketType.RESPONSE) {
            if (callbacks.has(id+1)) {
                this._tempResponse = this._tempResponse + payload;
            }else if (!callbacks.has(id+1) && this._tempResponse.length > 0 && payload.length === 0) {
                callbacks.get(id-1)!(this._tempResponse);
                this._tempResponse = '';
                callbacks.get(id)!("");
            }
        }

        //     if (callbacks.has(id)) {
        //         let str = data.toString('utf8', 12, len + 2);
        //         if (str.charAt(str.length - 1) === '\n')
        //             str = str.substring(0, str.length - 1);
    
        //         callbacks.get(id)!(str);
        //     }
		// callbacks.delete(id); // Possibly superfluous but best to be sure.
	}

	async send(data: string): Promise<string> {
		if (!this._connector || this._state <= 0)
			throw new RconError('Instance is not connected.');
        await this._connector;
        // console.log("exist callback: ", this._callbacks);
        const response = this._send(data, PacketType.COMMAND);
        this._send('', PacketType.RESPONSE)
		return await response
	}

    async sequentialSend(data: string[]): Promise<string[]> {
        if (!this._connector || this._state <= 0)
			throw new RconError('Instance is not connected.');
        await this._connector;

        let responseList = [];
        for (let i = 0; i < data.length; i++) {
            const response = this._send(data[i], PacketType.COMMAND);
            this._send('', PacketType.RESPONSE)
            responseList.push(await response);
        }

        return responseList;
    }


	private async _send(data: string, cmd: number): Promise<string> {
		const s = this._socket;
		if (!s || this._state <= 0)
			throw new RconError('Instance was disconnected.');

		const length = Buffer.byteLength(data);
		const id = ++this._lastRequestId;
		if (cmd === PacketType.AUTH) this._authPacketId = id;

        // console.log("Send command:", data, "current id: ", this._lastRequestId);
		const buf = Buffer.allocUnsafe(length + 14);
		buf.writeInt32LE(length + 10, 0);
		buf.writeInt32LE(id, 4); // Not sure how this is used or needed.
		buf.writeInt32LE(cmd, 8);
		buf.write(data, 12);
		buf.fill(0x00, length + 12);

		await s.write(buf, 'binary');

		return await new Promise<string>((resolve, reject) => {

			const cleanup = () => {
				clearTimeout(timeout);
				s.removeListener('end', onEnded);
				this._callbacks.delete(id);
				if (cmd === PacketType.AUTH) this._authPacketId = NaN;
			};

			const timeout = setTimeout(() => {
				cleanup();
				reject(new RconError('Request timed out'));
			}, this.timeout);

			const onEnded = () => {
				cleanup();
				reject(new RconError('Disconnected before response.'));
			};

			s.once('end', onEnded);

			this._callbacks.set(id, (data, err) => {
				cleanup();

				if (err) reject(err);
				if (data == null) reject(new RconError("No data returned."));
				else resolve(data);
			});
		});
	}
}

export default Rcon;