import { RenderResultPluginAsset } from '@joplin/renderer/types';
import { join, dirname } from 'path';

type PluginAssetRecord = {
	element: HTMLElement;
};
const pluginAssetsAdded_: Record<string, PluginAssetRecord> = {};

const assetUrlMap_: Map<string, ()=> Promise<string>> = new Map();

// Some resources (e.g. CSS) reference other resources with relative paths. On web, due to sandboxing
// and how plugin assets are stored, these links need to be rewritten.
const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content: string) => {
	if (asset.mime === 'text/css') {
		const urlRegex = /(url\()([^)]+)(\))/g;

		// Converting resource paths to URLs is async. To handle this, we do two passes.
		// In the first, the original URLs are collected. In the second, the URLs are replaced.
		const replacements: [string, string][] = [];
		let replacementIndex = 0;
		content = content.replace(urlRegex, (match, _group1, url, _group3) => {
			const target = join(dirname(asset.path), url);
			if (!assetUrlMap_.has(target)) return match;
			const replaceString = `<<to-replace-with-url-${replacementIndex++}>>`;
			replacements.push([replaceString, target]);
			return `url(${replaceString})`;
		});

		for (const [replacement, path] of replacements) {
			const url = await assetUrlMap_.get(path)();
			content = content.replace(replacement, url);
		}

		return content;
	} else {
		return content;
	}
};

interface Options {
	inlineAssets: boolean;
	removeUnusedPluginAssets: boolean;
	container: HTMLElement;
	readAssetBlob?(path: string): Promise<Blob>;
}

// Note that this function keeps track of what's been added so as not to
// add the same CSS files multiple times.
const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Options) => {
	if (!assets) return 0;

	const pluginAssetsContainer = options.container;

	const prepareAssetBlobUrls = () => {
		for (const asset of assets) {
			const path = asset.path;
			if (!assetUrlMap_.has(path)) {
				// Fetching assets can be expensive -- avoid refetching assets where possible.
				let url: string|null = null;
				assetUrlMap_.set(path, async () => {
					if (url !== null) return url;

					const blob = await options.readAssetBlob(path);
					if (!blob) {
						url = '';
					} else {
						url = URL.createObjectURL(blob);
					}
					return url;
				});
			}
		}
	};

	if (options.inlineAssets) {
		prepareAssetBlobUrls();
	}

	const processedAssetIds = [];

	let addedCount = 0;
	for (let i = 0; i < assets.length; i++) {
		const asset = assets[i];

		// # and ? can be used in valid paths and shouldn't be treated as the start of a query or fragment
		const encodedPath = asset.path
			.replace(/#/g, '%23')
			.replace(/\?/g, '%3F');

		const assetId = asset.name ? asset.name : encodedPath;

		processedAssetIds.push(assetId);

		if (pluginAssetsAdded_[assetId]) continue;

		let element = null;

		if (options.inlineAssets) {
			if (asset.mime === 'application/javascript') {
				element = document.createElement('script');
			} else if (asset.mime === 'text/css') {
				element = document.createElement('style');
			}

			if (element) {
				const blob = await options.readAssetBlob(asset.path);
				if (blob) {
					const assetContent = await blob.text();
					element.appendChild(
						document.createTextNode(await rewriteInternalAssetLinks(asset, assetContent)),
					);
				}
			}
		} else {
			if (asset.mime === 'application/javascript') {
				element = document.createElement('script');
				element.src = encodedPath;
			} else if (asset.mime === 'text/css') {
				element = document.createElement('link');
				element.rel = 'stylesheet';
				element.href = encodedPath;
			}
		}
		if (element) {
			pluginAssetsContainer.appendChild(element);
		}

		addedCount++;
		pluginAssetsAdded_[assetId] = {
			element,
		};
	}

	// Once we have added the relevant assets, we also remove those that
	// are no longer needed. It's necessary in particular for the CSS
	// generated by noteStyle - if we don't remove it, we might end up
	// with two or more stylesheet and that will create conflicts.
	//
	// It was happening for example when automatically switching from
	// light to dark theme, and then back to light theme - in that case
	// the viewer would remain dark because it would use the dark
	// stylesheet that would still be in the DOM.
	//
	// In some cases, however, we only want to rerender part of the document.
	// In this case, old plugin assets may have been from the last full-page
	// render and should not be removed.
	if (options.removeUnusedPluginAssets) {
		for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
			if (!processedAssetIds.includes(assetId)) {
				try {
					asset.element.remove();
				} catch (error) {
					// We don't throw an exception but we log it since
					// it shouldn't happen
					console.warn('Tried to remove an asset but got an error. On asset:', asset, error);
				}
				pluginAssetsAdded_[assetId] = null;
			}
		}
	}

	return addedCount > 0;
};

export default addPluginAssets;
