import {RequestHandler} from './RequestHandler.mjs';
import fs from 'node:fs';
import util from 'node:util';
import zlib from 'zlib';

const MATCH_INDEX = new RegExp('^(.*/)index\\.[^./]+$', 'i');
const PREF_ENCODINGS = ['gzip', 'deflate', 'identity'];

function passthroughMapper(file, type, data) {
	return data;
}

function bufferIfBetter(buffer, check) {
	if(buffer.byteLength < check.byteLength) {
		return buffer;
	}
	return null;
}

function getIndent(strings) {
	let maxLen = 0;
	for(const string of strings) {
		maxLen = Math.max(maxLen, string.length);
	}
	let indent = '';
	for(let i = 0; i < maxLen; ++ i) {
		indent += ' ';
	}
	return indent;
}

export class StaticRequestHandler extends RequestHandler {
	constructor(baseUrlPattern) {
		super('GET', new RegExp('^' + baseUrlPattern + '([^?]*)(\\?.*)?$'));

		this.baseUrlPattern = baseUrlPattern;
		this.mimes = new Map();
		this.defaultMime = 'text/plain; charset=utf-8';
		this.resources = new Map();
		this.awaiting = [];
		this.watchFiles = false;
		this.watchers = [];

		this.compressionOptions = {
			level: 9,
			memLevel: 8,
			windowBits: 15,
		};
	}

	_handleResource(req, res, resource, {pickEncoding, log}) {
		log('SERVE ' + resource.path);

		this.applyCommonHeaders(req, res);
		const encoding = pickEncoding(
			PREF_ENCODINGS.filter((enc) => (resource.encodings[enc] !== null)),
		);
		if(encoding !== 'identity') {
			res.setHeader('Content-Encoding', encoding);
		}
		res.setHeader('Content-Type', resource.type);
		res.end(resource.encodings[encoding]);
	}

	handle(req, res, info) {
		const resource = this.resources.get(info.match[1]);
		if(!resource) {
			return false;
		}
		return this._handleResource(req, res, resource, info);
	}

	_addWatchers(log) {
		this.watchers.forEach((entry) => {
			if(entry.watcher) {
				return;
			}
			const makeWatcher = () => fs.watch(entry.file, () => {
				/*
				 * If editor changed file by moving it, we are now
				 * watching the wrong file, so re-create watcher:
				 */
				entry.watcher.close();
				entry.watcher = makeWatcher();

				entry.fn().then((changed) => {
					if(changed) {
						log('RELOADED ' + entry.file);
					}
				});
			});
			entry.watcher = makeWatcher();
		});
	}

	_add(path, type, content) {
		let data = content;
		if(typeof content === 'string') {
			data = Buffer.from(content, 'utf8');
		}
		const existing = this.resources.get(path);
		if(existing && data.equals(existing.encodings.identity)) {
			return Promise.resolve(false);
		}
		return Promise.all([
			data,
			util.promisify(zlib.deflate)(data, this.compressionOptions),
			util.promisify(zlib.gzip)(data, this.compressionOptions),
		])
			.then(([identity, deflate, gzip]) => {
				const resource = {
					encodings: {
						deflate: bufferIfBetter(deflate, identity),
						gzip: bufferIfBetter(gzip, identity),
						identity,
					},
					path,
					type,
				};

				const match = MATCH_INDEX.exec(path);
				if(match) {
					this.resources.set(match[1], resource);
				}
				this.resources.set(path, resource);
				return true;
			});
	}

	_addFile(path, file, mapper) {
		const type = this.mimeTypeFor(file);
		const map = mapper || passthroughMapper;
		const fn = () => util.promisify(fs.readFile)(file)
			.then((data) => map(file, type, data))
			.then((data) => this._add(path, type, data));
		this.watchers.push({file, fn, watcher: null});
		return fn();
	}

	_addDir(path, file, mapper) {
		return util.promisify(fs.readdir)(file)
			.then((subFiles) => this._addResources(
				path + '/',
				file + '/',
				subFiles.filter((sub) => !sub.startsWith('.')),
				mapper,
			));
	}

	_addResource(path, file, mapper) {
		return util.promisify(fs.stat)(file)
			.then((stats) => {
				if(stats.isDirectory()) {
					return this._addDir(path, file, mapper);
				} else {
					return this._addFile(path, file, mapper);
				}
			});
	}

	_addResources(basePath, baseFs, files, mapper) {
		return Promise.all(files.map((file) => this._addResource(
			basePath + file,
			baseFs + file,
			mapper,
		)));
	}

	addMimeType(extension, type) {
		this.mimes.set(extension, type);
		return this;
	}

	mimeTypeFor(file) {
		const ext = file.substr(file.lastIndexOf('.') + 1);
		return this.mimes.get(ext) || this.defaultMime;
	}

	add(path, type, content = null) {
		let promise = null;
		if(content === null) {
			promise = this._add(path, this.mimeTypeFor(path), type);
		} else {
			promise = this._add(path, type, content);
		}
		this.awaiting.push(promise);
		return this;
	}

	addFile(path, file, mapper = null) {
		this.awaiting.push(this._addFile(path, file, mapper));
		return this;
	}

	addDir(basePath, baseFile, mapper = null) {
		this.awaiting.push(this._addDir(basePath, baseFile, mapper));
		return this;
	}

	addResource(path, file, mapper = null) {
		this.awaiting.push(this._addResource(path, file, mapper));
		return this;
	}

	addResources(basePath, baseFs, files, mapper = null) {
		this.awaiting.push(
			this._addResources(basePath, baseFs, files, mapper),
		);
		return this;
	}

	setFileWatch(enabled) {
		this.watchFiles = enabled;
		return this;
	}

	printInfo(target) {
		target.write(
			'Serving static resources at ' +
			(this.baseUrlPattern || '/') + ':\n',
		);

		const indent = getIndent(this.resources.keys());

		for(const [path, res] of this.resources) {
			const {encodings} = res;
			target.write(path + indent.substr(path.length));
			target.write(' ' + encodings.identity.byteLength + 'b');
			for(const enc of ['gzip', 'deflate']) {
				const buf = encodings[enc];
				if(buf !== null) {
					target.write(', ' + enc + ': ' + buf.byteLength + 'b');
				}
			}
			target.write('\n');
		}
	}

	begin({log}) {
		return Promise.all(this.awaiting).then(() => {
			if(this.watchFiles) {
				this._addWatchers(log);
			}
		});
	}

	close() {
		this.watchers.forEach((entry) => {
			if(entry.watcher) {
				entry.watcher.close();
				entry.watcher = null;
			}
		});
	}
}
