import { ClientOptions, createClient } from 'ldapjs';
import debug from 'debug';
import * as tls from 'tls';
import * as fs from 'fs';
import { IAuthentication } from '../types/Authentication';

const usernameFields = ['posixUid', 'mail'];

const log = debug('radius:auth:google-ldap');
// TLS:
// https://github.com/ldapjs/node-ldapjs/issues/307

interface IGoogleLDAPAuthOptions {
	/** base DN
	 *  e.g. 'dc=hokify,dc=com', */
	base: string;
	searchBase?: string; // default ou=users,{{base}}
	tls: {
		keyFile: string;
		certFile: string;
	};
	/** tls options
	 * e.g. {
			key: fs.readFileSync('ldap.gsuite.key'),
			cert: fs.readFileSync('ldap.gsuite.crt')
		} */
	tlsOptions?: tls.TlsOptions;
}

export class GoogleLDAPAuth implements IAuthentication {
	private lastDNsFetch: Date;

	private allValidDNsCache: { [key: string]: string };

	private base: string;

	private config: ClientOptions;

	searchBase: string;

	constructor(config: IGoogleLDAPAuthOptions) {
		this.base = config.base;
		this.searchBase = config.searchBase || `ou=users,${this.base}`;

		const tlsOptions = {
			key: fs.readFileSync(config.tls.keyFile),
			cert: fs.readFileSync(config.tls.certFile),
			servername: 'ldap.google.com',
			...config.tlsOptions,
		};

		this.config = {
			url: 'ldaps://ldap.google.com:636',
			tlsOptions,
		};

		this.fetchDNs().catch((err) => {
			console.error('fatal error google ldap auth, cannot fetch DNs', err);
		});
	}

	private async fetchDNs() {
		const dns: { [key: string]: string } = {};

		await new Promise<void>((resolve, reject) => {
			const ldapDNClient = createClient(this.config).on('error', (error) => {
				console.error('Error in ldap', error);
				reject(error);
			});

			ldapDNClient.search(
				this.searchBase,
				{
					scope: 'sub',
				},
				(err, res) => {
					if (err) {
						reject(err);
						return;
					}

					res.on('searchEntry', (entry) => {
						// log('entry: ' + JSON.stringify(entry.object));
						usernameFields.forEach((field) => {
							const index = entry.object[field] as string;
							dns[index] = entry.object.dn;
						});
					});

					res.on('searchReference', (referral) => {
						log(`referral: ${referral.uris.join()}`);
					});

					res.on('error', (ldapErr) => {
						console.error(`error: ${JSON.stringify(ldapErr)}`);
						reject(ldapErr);
					});

					res.on('end', (result) => {
						log(`ldap status: ${result?.status}`);

						// replace with new dns
						this.allValidDNsCache = dns;
						// log('allValidDNsCache', this.allValidDNsCache);
						resolve();
					});
				}
			);
		});
		this.lastDNsFetch = new Date();
	}

	async authenticate(
		username: string,
		password: string,
		count = 0,
		forceFetching = false
	): Promise<boolean> {
		const cacheValidTime = new Date();
		cacheValidTime.setHours(cacheValidTime.getHours() - 12);

		/*
		 just a test for super slow google responses
		await new Promise((resolve, reject) => {
			setTimeout(resolve, 10000); // wait 10 seconds
		})
		 */

		let dnsFetched = false;

		if (!this.lastDNsFetch || this.lastDNsFetch < cacheValidTime || forceFetching) {
			log('fetching dns');
			await this.fetchDNs();
			dnsFetched = true;
		}

		if (count > 5) {
			throw new Error('Failed to authenticate with LDAP!');
		}
		// const dn = ;
		const dn = this.allValidDNsCache[username];
		if (!dn) {
			if (!dnsFetched && !forceFetching) {
				return this.authenticate(username, password, count, true);
			}
			// console.log('this.allValidDNsCache', this.allValidDNsCache);
			console.error(`invalid username, not found in DN: ${username}`); // , this.allValidDNsCache);
			return false;
		}

		const authResult: boolean = await new Promise((resolve, reject) => {
			// we never unbding a client, therefore create a new client every time
			const authClient = createClient(this.config);

			authClient.bind(dn, password, (err, res) => {
				if (err) {
					if (err && (err as any).stack && (err as any).stack.includes(`ldap.google.com closed`)) {
						count += 1;
						// wait 1 second to give the ldap error handler time to reconnect
						setTimeout(() => resolve(this.authenticate(dn, password)), 2000);
						return;
					}

					resolve(false);
					// console.error('ldap error', err);
					// reject(err);
				}
				if (res) resolve(res);
				else reject();

				authClient.unbind();
			});
		});

		return authResult;
	}
}
