/**
 * IP Tools
 * Pokemon Showdown - http://pokemonshowdown.com/
 *
 * IPTools file has various tools for IP parsing and IP-based blocking.
 *
 * These include DNSBLs: DNS-based blackhole lists, which list IPs known for
 * running proxies, spamming, or other abuse.
 *
 * We also maintain our own database of datacenter IP ranges (usually
 * proxies). These are taken from https://github.com/client9/ipcat
 * but include our own database as well.
 *
 * @license MIT
 */

const BLOCKLISTS = ['sbl.spamhaus.org', 'rbl.efnetrbl.org'];
const HOSTS_FILE = 'config/hosts.csv';
const PROXIES_FILE = 'config/proxies.csv';

import * as dns from 'dns';
import {FS, Net, Utils} from '../lib';

export interface AddressRange {
	minIP: number;
	maxIP: number;
	host?: string;
}

function removeNohost(hostname: string) {
	// Convert from old domain.tld.type-nohost format to new domain.tld?/type format
	if (hostname?.includes('-nohost')) {
		const parts = hostname.split('.');
		const suffix = parts.pop();
		return `${parts.join('.')}?/${suffix?.replace('-nohost', '')}`;
	}
	return hostname;
}

export const IPTools = new class {
	readonly dnsblCache = new Map<string, string | null>([
		['127.0.0.1', null],
	]);

	readonly connectionTestCache = new Map<string, boolean>();

	readonly ipRegex = /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])$/;
	readonly ipRangeRegex = /^(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]|\*)){0,2}\.(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9]|\*)$/;
	readonly hostRegex = /^.+\..{2,}$/;

	async lookup(ip: string) {
		const [dnsbl, host] = await Promise.all([
			IPTools.queryDnsbl(ip),
			IPTools.getHost(ip),
		]);
		const shortHost = this.shortenHost(host);
		const hostType = this.getHostType(shortHost, ip);
		return {dnsbl, host, shortHost, hostType};
	}

	queryDnsblLoop(ip: string, callback: (val: string | null) => void, reversedIpDot: string, index: number) {
		if (index >= BLOCKLISTS.length) {
			// not in any blocklist
			IPTools.dnsblCache.set(ip, null);
			callback(null);
			return;
		}
		const blocklist = BLOCKLISTS[index];
		dns.lookup(reversedIpDot + blocklist, 4, (err, res) => {
			if (!err) {
				// blocked
				IPTools.dnsblCache.set(ip, blocklist);
				callback(blocklist);
				return;
			}
			// not blocked, try next blocklist
			IPTools.queryDnsblLoop(ip, callback, reversedIpDot, index + 1);
		});
	}

	/**
	 * IPTools.queryDnsbl(ip, callback)
	 *
	 * Calls callb
	 * ack(blocklist), where blocklist is the blocklist domain
	 * if the passed IP is in a blocklist, or null if the IP is not in
	 * any blocklist.
	 *
	 * Return value matches isBlocked when treated as a boolean.
	 */
	queryDnsbl(ip: string) {
		if (!Config.dnsbl) return Promise.resolve(null);
		if (IPTools.dnsblCache.has(ip)) {
			return Promise.resolve(IPTools.dnsblCache.get(ip) || null);
		}
		const reversedIpDot = ip.split('.').reverse().join('.') + '.';
		return new Promise<string | null>((resolve, reject) => {
			IPTools.queryDnsblLoop(ip, resolve, reversedIpDot, 0);
		});
	}

	/*********************************************************
	 * IP parsing
	 *********************************************************/

	ipToNumber(ip: string) {
		ip = ip.trim();
		if (ip.includes(':') && !ip.includes('.')) {
			// IPv6, which PS does not support
			return null;
		}
		if (ip.startsWith('::ffff:')) ip = ip.slice(7);
		else if (ip.startsWith('::')) ip = ip.slice(2);
		let num = 0;
		const parts = ip.split('.');
		if (parts.length !== 4) return null;
		for (const part of parts) {
			num *= 256;

			const partAsInt = Utils.parseExactInt(part);
			if (isNaN(partAsInt) || partAsInt < 0 || partAsInt > 255) return null;
			num += partAsInt;
		}
		return num;
	}

	numberToIP(num: number) {
		const ipParts: string[] = [];
		if (num < 0 || num >= 256 ** 4 || num !== Math.trunc(num)) return null;
		while (num) {
			const part = num % 256;
			num = (num - part) / 256;
			ipParts.unshift(part.toString());
		}
		while (ipParts.length < 4) ipParts.unshift('0');
		if (ipParts.length !== 4) return null;
		return ipParts.join('.');
	}

	getCidrRange(cidr: string): AddressRange | null {
		if (!cidr) return null;
		const index = cidr.indexOf('/');
		if (index <= 0) {
			const ip = IPTools.ipToNumber(cidr);
			if (ip === null) return null;
			return {minIP: ip, maxIP: ip};
		}
		const low = IPTools.ipToNumber(cidr.slice(0, index));
		const bits = Utils.parseExactInt(cidr.slice(index + 1));
		// fun fact: IPTools fails if bits <= 1 because JavaScript
		// does << with signed int32s.
		if (low === null || !bits || bits < 2 || bits > 32) return null;
		const high = low + (1 << (32 - bits)) - 1;
		return {minIP: low, maxIP: high};
	}
	/** Is this an IP range supported by `stringToRange`? Note that exact IPs are also valid IP ranges. */
	isValidRange(range: string): boolean {
		return IPTools.stringToRange(range) !== null;
	}
	stringToRange(range: string | null): AddressRange | null {
		if (!range) return null;
		if (range.endsWith('*')) {
			const parts = range.replace('.*', '').split('.');
			if (parts.length > 3) return null;
			const [a, b, c] = parts;
			const minIP = IPTools.ipToNumber(`${a || '0'}.${b || '0'}.${c || '0'}.0`);
			const maxIP = IPTools.ipToNumber(`${a || '255'}.${b || '255'}.${c || '255'}.255`);
			if (minIP === null || maxIP === null) return null;
			return {minIP, maxIP};
		}
		const index = range.indexOf('-');
		if (index <= 0) {
			if (range.includes('/')) return IPTools.getCidrRange(range);
			const ip = IPTools.ipToNumber(range);
			if (ip === null) return null;

			return {maxIP: ip, minIP: ip};
		}
		const minIP = IPTools.ipToNumber(range.slice(0, index));
		const maxIP = IPTools.ipToNumber(range.slice(index + 1));

		if (minIP === null || maxIP === null || maxIP < minIP) return null;
		return {minIP, maxIP};
	}
	rangeToString(range: AddressRange, sep = '-') {
		return `${this.numberToIP(range.minIP)}${sep}${this.numberToIP(range.maxIP)}`;
	}

	/******************************
	 * Range management functions *
	 ******************************/

	checkPattern(patterns: AddressRange[], num: number | null) {
		if (num === null) return false;
		for (const pattern of patterns) {
			if (num >= pattern.minIP && num <= pattern.maxIP) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Returns a checker function for the passed IP range or array of
	 * ranges. The checker function returns true if its passed IP is
	 * in the range.
	 */
	checker(rangeString: string | string[]): (ip: string) => boolean {
		if (!rangeString?.length) return () => false;
		let ranges: AddressRange[] = [];
		if (typeof rangeString === 'string') {
			const rangePatterns = IPTools.stringToRange(rangeString);
			if (rangePatterns) ranges = [rangePatterns];
		} else {
			ranges = rangeString.map(IPTools.stringToRange).filter(x => x) as AddressRange[];
		}
		return (ip: string) => {
			const ipNumber = IPTools.ipToNumber(ip);
			return IPTools.checkPattern(ranges, ipNumber);
		};
	}

	/**
	 * Proxy and host management functions
	 */
	ranges: (AddressRange & {host: string})[] = [];
	singleIPOpenProxies = new Set<string>();
	torProxyIps = new Set<string>();
	proxyHosts = new Set<string>();
	residentialHosts = new Set<string>();
	mobileHosts = new Set<string>();
	async loadHostsAndRanges() {
		const data = await FS(HOSTS_FILE).readIfExists() + await FS(PROXIES_FILE).readIfExists();
		// Strip carriage returns for Windows compatibility
		const rows = data.split('\n').map(row => row.replace('\r', ''));
		const ranges = [];
		for (const row of rows) {
			if (!row) continue;
			let [type, hostOrLowIP, highIP, host] = row.split(',');
			if (!hostOrLowIP) continue;
			// Handle legacy data format
			host = removeNohost(host);
			hostOrLowIP = removeNohost(hostOrLowIP);

			switch (type) {
			case 'IP':
				IPTools.singleIPOpenProxies.add(hostOrLowIP);
				break;
			case 'HOST':
				IPTools.proxyHosts.add(hostOrLowIP);
				break;
			case 'RESIDENTIAL':
				IPTools.residentialHosts.add(hostOrLowIP);
				break;
			case 'MOBILE':
				IPTools.mobileHosts.add(hostOrLowIP);
				break;
			case 'RANGE':
				if (!host) continue;

				const minIP = IPTools.ipToNumber(hostOrLowIP);
				if (minIP === null) {
					Monitor.error(`Bad IP address in host or proxy file: '${hostOrLowIP}'`);
					continue;
				}
				const maxIP = IPTools.ipToNumber(highIP);
				if (maxIP === null) {
					Monitor.error(`Bad IP address in host or proxy file: '${highIP}'`);
					continue;
				}

				const range = {host: IPTools.urlToHost(host), maxIP, minIP};
				if (range.maxIP < range.minIP) throw new Error(`Bad range at ${hostOrLowIP}.`);
				ranges.push(range);
				break;
			}
		}
		IPTools.ranges = ranges;
		IPTools.sortRanges();
	}

	saveHostsAndRanges() {
		let hostsData = '';
		let proxiesData = '';
		for (const ip of IPTools.singleIPOpenProxies) {
			proxiesData += `IP,${ip}\n`;
		}
		for (const host of IPTools.proxyHosts) {
			proxiesData += `HOST,${host}\n`;
		}
		for (const host of IPTools.residentialHosts) {
			hostsData += `RESIDENTIAL,${host}\n`;
		}
		for (const host of IPTools.mobileHosts) {
			hostsData += `MOBILE,${host}\n`;
		}
		IPTools.sortRanges();
		for (const range of IPTools.ranges) {
			const data = `RANGE,${IPTools.rangeToString(range, ',')}${range.host ? `,${range.host}` : ``}\n`;
			if (range.host?.endsWith('/proxy')) {
				proxiesData += data;
			} else {
				hostsData += data;
			}
		}
		void FS(HOSTS_FILE).write(hostsData);
		void FS(PROXIES_FILE).write(proxiesData);
	}

	addOpenProxies(ips: string[]) {
		for (const ip of ips) {
			IPTools.singleIPOpenProxies.add(ip);
		}
		return IPTools.saveHostsAndRanges();
	}

	addProxyHosts(hosts: string[]) {
		for (const host of hosts) {
			IPTools.proxyHosts.add(host);
		}
		return IPTools.saveHostsAndRanges();
	}

	addMobileHosts(hosts: string[]) {
		for (const host of hosts) {
			IPTools.mobileHosts.add(host);
		}
		return IPTools.saveHostsAndRanges();
	}

	addResidentialHosts(hosts: string[]) {
		for (const host of hosts) {
			IPTools.residentialHosts.add(host);
		}
		return IPTools.saveHostsAndRanges();
	}

	removeOpenProxies(ips: string[]) {
		for (const ip of ips) {
			IPTools.singleIPOpenProxies.delete(ip);
		}
		return IPTools.saveHostsAndRanges();
	}

	removeResidentialHosts(hosts: string[]) {
		for (const host of hosts) {
			IPTools.residentialHosts.delete(host);
		}
		return IPTools.saveHostsAndRanges();
	}

	removeProxyHosts(hosts: string[]) {
		for (const host of hosts) {
			IPTools.proxyHosts.delete(host);
		}
		return IPTools.saveHostsAndRanges();
	}

	removeMobileHosts(hosts: string[]) {
		for (const host of hosts) {
			IPTools.mobileHosts.delete(host);
		}
		return IPTools.saveHostsAndRanges();
	}

	rangeIntersects(a: AddressRange, b: AddressRange) {
		try {
			this.checkRangeConflicts(a, [b]);
		} catch {
			return true;
		}
		return false;
	}

	checkRangeConflicts(insertion: AddressRange, sortedRanges: AddressRange[], widen?: boolean) {
		if (insertion.maxIP < insertion.minIP) {
			throw new Error(
				`Invalid data for address range ${IPTools.rangeToString(insertion)} (${insertion.host})`
			);
		}

		let iMin = 0;
		let iMax = sortedRanges.length;
		while (iMin < iMax) {
			const i = Math.floor((iMax + iMin) / 2);
			if (insertion.minIP > sortedRanges[i].minIP) {
				iMin = i + 1;
			} else {
				iMax = i;
			}
		}
		if (iMin < sortedRanges.length) {
			const next = sortedRanges[iMin];
			if (insertion.minIP === next.minIP && insertion.maxIP === next.maxIP) {
				throw new Error(`The address range ${IPTools.rangeToString(insertion)} (${insertion.host}) already exists`);
			}
			if (insertion.minIP <= next.minIP && insertion.maxIP >= next.maxIP) {
				if (widen) {
					if (sortedRanges[iMin + 1]?.minIP <= insertion.maxIP) {
						throw new Error("You can only widen one address range at a time.");
					}
					return iMin;
				}
				throw new Error(
					`Too wide: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` +
					`Intersects with: ${IPTools.rangeToString(next)} (${next.host})`
				);
			}
			if (insertion.maxIP >= next.minIP) {
				throw new Error(
					`Could not insert: ${IPTools.rangeToString(insertion)} ${insertion.host}\n` +
					`Intersects with: ${IPTools.rangeToString(next)} (${next.host})`
				);
			}
		}
		if (iMin > 0) {
			const prev = sortedRanges[iMin - 1];
			if (insertion.minIP >= prev.minIP && insertion.maxIP <= prev.maxIP) {
				throw new Error(
					`Too narrow: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` +
					`Intersects with: ${IPTools.rangeToString(prev)} (${prev.host})`
				);
			}
			if (insertion.minIP <= prev.maxIP) {
				throw new Error(
					`Could not insert: ${IPTools.rangeToString(insertion)} (${insertion.host})\n` +
					`Intersects with: ${IPTools.rangeToString(prev)} (${prev.host})`
				);
			}
		}
	}

	/*********************************************************
	 * Range handling functions
	 *********************************************************/

	urlToHost(url: string) {
		if (url.startsWith('http://')) url = url.slice(7);
		if (url.startsWith('https://')) url = url.slice(8);
		if (url.startsWith('www.')) url = url.slice(4);
		const slashIndex = url.indexOf('/');
		if (slashIndex > 0 && url[slashIndex - 1] !== '?') url = url.slice(0, slashIndex);
		return url;
	}

	sortRanges() {
		Utils.sortBy(IPTools.ranges, range => range.minIP);
	}

	getRange(minIP: number, maxIP: number) {
		for (const range of IPTools.ranges) {
			if (range.minIP === minIP && range.maxIP === maxIP) return range;
		}
	}

	addRange(range: AddressRange & {host: string}) {
		if (IPTools.getRange(range.minIP, range.maxIP)) {
			IPTools.removeRange(range.minIP, range.maxIP);
		}
		IPTools.ranges.push(range);
		return IPTools.saveHostsAndRanges();
	}

	removeRange(minIP: number, maxIP: number) {
		IPTools.ranges = IPTools.ranges.filter(dc => dc.minIP !== minIP || dc.maxIP !== maxIP);
		return IPTools.saveHostsAndRanges();
	}

	/**
	 * Will not reject; IPs with no RDNS entry will resolve to
	 * '[byte1].[byte2]?/unknown'.
	 */
	getHost(ip: string) {
		return new Promise<string>(resolve => {
			if (!ip) {
				resolve('');
				return;
			}

			const ipNumber = IPTools.ipToNumber(ip);
			if (ipNumber === null) throw new Error(`Bad IP address: '${ip}'`);
			for (const range of IPTools.ranges) {
				if (ipNumber >= range.minIP && ipNumber <= range.maxIP) {
					resolve(range.host);
					return;
				}
			}
			dns.reverse(ip, (err, hosts) => {
				if (err) {
					resolve(`${ip.split('.').slice(0, 2).join('.')}?/unknown`);
					return;
				}
				if (!hosts?.[0]) {
					if (ip.startsWith('50.')) {
						resolve('comcast.net?/res');
					} else if (ipNumber >= telstraRange.minIP && ipNumber <= telstraRange.maxIP) {
						resolve(telstraRange.host);
					} else {
						this.testConnection(ip, result => {
							if (result) {
								resolve(`${ip.split('.').slice(0, 2).join('.')}?/proxy`);
							} else {
								resolve(`${ip.split('.').slice(0, 2).join('.')}?/unknown`);
							}
						});
					}
				} else {
					resolve(hosts[0]);
				}
			});
		});
	}

	/**
	 * Does this IP respond to port 80? In theory, proxies are likely to
	 * respond, while residential connections are likely to reject connections.
	 *
	 * Callback is guaranteed to be called exactly once, within a 1000ms
	 * timeout.
	 */
	testConnection(ip: string, callback: (result: boolean) => void) {
		const cachedValue = this.connectionTestCache.get(ip);
		if (cachedValue !== undefined) {
			return callback(cachedValue);
		}

		// Node.js's documentation does not make this easy to write. I discovered
		// this behavior by manual testing:

		// A successful connection emits 'connect', which you should react to
		// with socket.destroy(), which emits 'close'.

		// Some IPs instantly reject connections, emitting 'error' followed
		// immediately by 'close'.

		// Some IPs just never respond, leaving you to time out. Node will
		// emit the 'timeout' event, but not actually do anything else, leaving
		// you to manually use socket.destroy(), which emits 'close'

		let connected = false;
		const socket = require('net').createConnection({
			port: 80,
			host: ip,
			timeout: 1000,
		}, () => {
			connected = true;
			this.connectionTestCache.set(ip, true);
			socket.destroy();
			return callback(true);
		});
		socket.on('error', () => {});
		socket.on('timeout', () => socket.destroy());
		socket.on('close', () => {
			if (!connected) {
				this.connectionTestCache.set(ip, false);
				return callback(false);
			}
		});
	}

	shortenHost(host: string) {
		if (host.split('.').pop()?.includes('/')) return host; // It has a suffix, e.g. leaseweb.com?/proxy
		let dotLoc = host.lastIndexOf('.');
		const tld = host.slice(dotLoc);
		if (tld === '.uk' || tld === '.au' || tld === '.br') dotLoc = host.lastIndexOf('.', dotLoc - 1);
		dotLoc = host.lastIndexOf('.', dotLoc - 1);
		return host.slice(dotLoc + 1);
	}

	/**
	 * Host types:
	 * - 'res' - normal residential ISP
	 * - 'shared' - like res, but shared among many people: bans will have collateral damage
	 * - 'mobile' - like res, but unstable IP (IP bans don't work)
	 * - 'proxy' - datacenters, VPNs, proxy services, other untrustworthy sources
	 *   (note that bots will usually be hosted on these)
	 * - 'res?' - likely res, but host not specifically whitelisted
	 * - 'unknown' - no rdns entry, treat with suspicion
	 */
	getHostType(host: string, ip: string) {
		if (Punishments.isSharedIp(ip)) {
			return 'shared';
		}
		if (this.singleIPOpenProxies.has(ip) || this.torProxyIps.has(ip)) {
			// single-IP open proxies
			return 'proxy';
		}

		if (/^he\.net(\?|)\/proxy$/.test(host)) {
			// Known to only be VPN services
			if (['74.82.60.', '72.52.87.', '65.49.126.'].some(range => ip.startsWith(range))) {
				return 'proxy';
			}
			// Hurricane Electric has an annoying habit of having residential
			// internet and datacenters on the same IP ranges - we get a lot of
			// legitimate users as well as spammers on VPNs from HE.

			// This splits the difference and treats it like any other unknown IP.
			return 'unknown';
		}
		// There were previously special cases for
		// 'digitalocean.proxy-nohost', 'servihosting.es.proxy-nohost'
		// DO is commonly used to host bots; I don't know who whitelisted
		// servihosting but I assume for a similar reason. This isn't actually
		// tenable; any service that can host bots can and does also host proxies.
		if (this.proxyHosts.has(host) || host.endsWith('/proxy')) {
			return 'proxy';
		}
		if (this.residentialHosts.has(host) || host.endsWith('/res')) {
			return 'res';
		}
		if (this.mobileHosts.has(host) || host.endsWith('/mobile')) {
			return 'mobile';
		}
		if (/^ip-[0-9]+-[0-9]+-[0-9]+\.net$/.test(host) || /^ip-[0-9]+-[0-9]+-[0-9]+\.eu$/.test(host)) {
			// OVH
			return 'proxy';
		}

		if (host.endsWith('/unknown')) {
			// rdns entry doesn't exist, and IP doesn't respond to a probe on port 80
			return 'unknown';
		}

		// rdns entry exists but is unrecognized
		return 'res?';
	}
	async updateTorRanges() {
		try {
			const raw = await Net('https://check.torproject.org/torbulkexitlist').get();
			const torIps = raw.split('\n');
			for (const ip of torIps) {
				if (this.ipRegex.test(ip)) {
					this.torProxyIps.add(ip);
				}
			}
		} catch {}
	}
};

const telstraRange: AddressRange & {host: string} = {
	minIP: IPTools.ipToNumber("101.160.0.0")!,
	maxIP: IPTools.ipToNumber("101.191.255.255")!,
	host: 'telstra.net?/res',
};

export default IPTools;

void IPTools.updateTorRanges();
