import { RawSourceMap, DecodedSourceMap } from '@ampproject/remapping/dist/types/types';
import { getLocator } from 'locate-character';
import { MappedCode, SourceLocation, parse_attached_sourcemap, sourcemap_add_offset, combine_sourcemaps } from '../utils/mapped_code';
import { decode_map } from './decode_sourcemap';
import { replace_in_code, slice_source } from './replace_in_code';
import { MarkupPreprocessor, Source, Preprocessor, PreprocessorGroup, Processed } from './types';

export * from './types';

interface SourceUpdate {
	string?: string;
	map?: DecodedSourceMap;
	dependencies?: string[];
}

function get_file_basename(filename: string) {
	return filename.split(/[/\\]/).pop();
}

/**
 * Represents intermediate states of the preprocessing.
 */
class PreprocessResult implements Source {
	// sourcemap_list is sorted in reverse order from last map (index 0) to first map (index -1)
	// so we use sourcemap_list.unshift() to add new maps
	// https://github.com/ampproject/remapping#multiple-transformations-of-a-file
	sourcemap_list: Array<DecodedSourceMap | RawSourceMap> = [];
	dependencies: string[] = [];
	file_basename: string;

	get_location: ReturnType<typeof getLocator>;

	constructor(public source: string, public filename: string) {
		this.update_source({ string: source });

		// preprocess source must be relative to itself or equal null
		this.file_basename = filename == null ? null : get_file_basename(filename);
	}

	update_source({ string: source, map, dependencies }: SourceUpdate) {
		if (source != null) {
			this.source = source;
			this.get_location = getLocator(source);
		}

		if (map) {
			this.sourcemap_list.unshift(map);
		}

		if (dependencies) {
			this.dependencies.push(...dependencies);
		}
	}

	to_processed(): Processed {
		// Combine all the source maps for each preprocessor function into one
		const map: RawSourceMap = combine_sourcemaps(this.file_basename, this.sourcemap_list);

		return {
			// TODO return separated output, in future version where svelte.compile supports it:
			// style: { code: styleCode, map: styleMap },
			// script { code: scriptCode, map: scriptMap },
			// markup { code: markupCode, map: markupMap },

			code: this.source,
			dependencies: [...new Set(this.dependencies)],
			map: map as object,
			toString: () => this.source
		};
	}
}

/**
 * Convert preprocessor output for the tag content into MappedCode
 */
function processed_content_to_code(processed: Processed, location: SourceLocation, file_basename: string): MappedCode {
	// Convert the preprocessed code and its sourcemap to a MappedCode
	let decoded_map: DecodedSourceMap;
	if (processed.map) {
		decoded_map = decode_map(processed);

		// offset only segments pointing at original component source
		const source_index = decoded_map.sources.indexOf(file_basename);
		if (source_index !== -1) {
			sourcemap_add_offset(decoded_map, location, source_index);
		}
	}

	return MappedCode.from_processed(processed.code, decoded_map);
}

/**
 * Given the whole tag including content, return a `MappedCode`
 * representing the tag content replaced with `processed`.
 */
function processed_tag_to_code(
	processed: Processed,
	tag_name: 'style' | 'script',
	attributes: string,
	source: Source
): MappedCode {
	const { file_basename, get_location } = source;

	const build_mapped_code = (code: string, offset: number) =>
		MappedCode.from_source(slice_source(code, offset, source));

	const tag_open = `<${tag_name}${attributes || ''}>`;
	const tag_close = `</${tag_name}>`;

	const tag_open_code = build_mapped_code(tag_open, 0);
	const tag_close_code = build_mapped_code(tag_close, tag_open.length + source.source.length);

	parse_attached_sourcemap(processed, tag_name);

	const content_code = processed_content_to_code(processed, get_location(tag_open.length), file_basename);

	return tag_open_code.concat(content_code).concat(tag_close_code);
}

function parse_tag_attributes(str: string) {
	// note: won't work with attribute values containing spaces.
	return str
		.split(/\s+/)
		.filter(Boolean)
		.reduce((attrs, attr) => {
			const i = attr.indexOf('=');
			const [key, value] = i > 0 ? [attr.slice(0, i), attr.slice(i + 1)] : [attr];
			const [, unquoted] = (value && value.match(/^['"](.*)['"]$/)) || [];

			return { ...attrs, [key]: unquoted ?? value ?? true };
		}, {});
}

/**
 * Calculate the updates required to process all instances of the specified tag.
 */
async function process_tag(
	tag_name: 'style' | 'script',
	preprocessor: Preprocessor,
	source: Source
): Promise<SourceUpdate> {
	const { filename, source: markup } = source;
	const tag_regex =
		tag_name === 'style'
			? /<!--[^]*?-->|<style(\s[^]*?)?(?:>([^]*?)<\/style>|\/>)/gi
			: /<!--[^]*?-->|<script(\s[^]*?)?(?:>([^]*?)<\/script>|\/>)/gi;

	const dependencies: string[] = [];

	async function process_single_tag(
		tag_with_content: string,
		attributes = '',
		content = '',
		tag_offset: number
	): Promise<MappedCode> {
		const no_change = () => MappedCode.from_source(slice_source(tag_with_content, tag_offset, source));

		if (!attributes && !content) return no_change();

		const processed = await preprocessor({
			content: content || '',
			attributes: parse_tag_attributes(attributes || ''),
			markup,
			filename
		});

		if (!processed) return no_change();
		if (processed.dependencies) dependencies.push(...processed.dependencies);
		if (!processed.map && processed.code === content) return no_change();

		return processed_tag_to_code(processed, tag_name, attributes, slice_source(content, tag_offset, source));
	}

	const { string, map } = await replace_in_code(tag_regex, process_single_tag, source);

	return { string, map, dependencies };
}

async function process_markup(filename: string, process: MarkupPreprocessor, source: Source) {
	const processed = await process({
		content: source.source,
		filename
	});

	if (processed) {
		return {
			string: processed.code,
			map: processed.map
				? // TODO: can we use decode_sourcemap?
				  typeof processed.map === 'string'
					? JSON.parse(processed.map)
					: processed.map
				: undefined,
			dependencies: processed.dependencies
		};
	} else {
		return {};
	}
}

export default async function preprocess(
	source: string,
	preprocessor: PreprocessorGroup | PreprocessorGroup[],
	options?: { filename?: string }
): Promise<Processed> {
	// @ts-ignore todo: doublecheck
	const filename = (options && options.filename) || preprocessor.filename; // legacy

	const preprocessors = preprocessor ? (Array.isArray(preprocessor) ? preprocessor : [preprocessor]) : [];

	const markup = preprocessors.map(p => p.markup).filter(Boolean);
	const script = preprocessors.map(p => p.script).filter(Boolean);
	const style = preprocessors.map(p => p.style).filter(Boolean);

	const result = new PreprocessResult(source, filename);

	// TODO keep track: what preprocessor generated what sourcemap?
	// to make debugging easier = detect low-resolution sourcemaps in fn combine_mappings

	for (const process of markup) {
		result.update_source(await process_markup(filename, process, result));
	}

	for (const process of script) {
		result.update_source(await process_tag('script', process, result));
	}

	for (const preprocess of style) {
		result.update_source(await process_tag('style', preprocess, result));
	}

	return result.to_processed();
}