import cssesc from 'cssesc';
import type {
	SSRResult,
	TransitionAnimation,
	TransitionAnimationPair,
	TransitionAnimationValue,
	TransitionDirectionalAnimations,
} from '../../@types/astro.js';
import { fade, slide } from '../../transitions/index.js';
import { markHTMLString } from './escape.js';

const transitionNameMap = new WeakMap<SSRResult, number>();
function incrementTransitionNumber(result: SSRResult) {
	let num = 1;
	if (transitionNameMap.has(result)) {
		num = transitionNameMap.get(result)! + 1;
	}
	transitionNameMap.set(result, num);
	return num;
}

export function createTransitionScope(result: SSRResult, hash: string) {
	const num = incrementTransitionNumber(result);
	return `astro-${hash}-${num}`;
}

type Entries<T extends Record<string, any>> = Iterable<[keyof T, T[keyof T]]>;

const getAnimations = (name: TransitionAnimationValue) => {
	if (name === 'fade') return fade();
	if (name === 'slide') return slide();
	if (typeof name === 'object') return name;
};

const addPairs = (
	animations: TransitionDirectionalAnimations | Record<string, TransitionAnimationPair>,
	stylesheet: ViewTransitionStyleSheet,
) => {
	for (const [direction, images] of Object.entries(animations) as Entries<typeof animations>) {
		for (const [image, rules] of Object.entries(images) as Entries<
			(typeof animations)[typeof direction]
		>) {
			stylesheet.addAnimationPair(direction, image, rules);
		}
	}
};

// Chrome (121) accepts custom-idents for view-transition-names as generated by cssesc,
// but it just ignores them during view transitions if they contain escaped 7-bit ASCII characters
// like \<space> or \. A special case are digits and minus at the beginning of the string,
// which cssesc also encodes as \xx
const reEncodeValidChars: string[] =
	'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_'
		.split('')
		.reduce((v, c) => ((v[c.charCodeAt(0)] = c), v), [] as string[]);
const reEncodeInValidStart: string[] = '-0123456789_'
	.split('')
	.reduce((v, c) => ((v[c.charCodeAt(0)] = c), v), [] as string[]);

function reEncode(s: string) {
	let result = '';
	let codepoint;
	// we work on codepoints that might use more than 16bit, not character codes.
	// so the index will often step by 1 as usual or by 2 if the codepoint is greater than 0xFFFF
	for (let i = 0; i < s.length; i += (codepoint ?? 0) > 0xffff ? 2 : 1) {
		codepoint = s.codePointAt(i);
		if (codepoint !== undefined) {
			// this should never happen, they said!

			// If we find a character in the range \x00 - \x7f that is not one of the reEncodeValidChars,
			// we replace it with its hex value escaped by an underscore for decodability (and better readability,
			// because most of them are punctuations like ,'"":;_..., and '_' might be a better choice than '-')
			// The underscore itself (code 95) is also escaped and encoded as two underscores to avoid
			// collisions between original and encoded strings.
			// All other values are just copied over
			result +=
				codepoint < 0x80
					? codepoint === 95
						? '__'
						: (reEncodeValidChars[codepoint] ?? '_' + codepoint.toString(16).padStart(2, '0'))
					: String.fromCodePoint(codepoint);
		}
	}
	// Digits and minus sign at the beginning of the string are special, so we simply prepend an underscore
	return reEncodeInValidStart[result.codePointAt(0) ?? 0] ? '_' + result : result;
}

export function renderTransition(
	result: SSRResult,
	hash: string,
	animationName: TransitionAnimationValue | undefined,
	transitionName: string,
) {
	if (typeof (transitionName ?? '') !== 'string') {
		throw new Error(`Invalid transition name {${transitionName}}`);
	}
	// Default to `fade` (similar to `initial`, but snappier)
	if (!animationName) animationName = 'fade';
	const scope = createTransitionScope(result, hash);
	const name = transitionName ? cssesc(reEncode(transitionName), { isIdentifier: true }) : scope;
	const sheet = new ViewTransitionStyleSheet(scope, name);

	const animations = getAnimations(animationName);
	if (animations) {
		addPairs(animations, sheet);
	} else if (animationName === 'none') {
		sheet.addFallback('old', 'animation: none; mix-blend-mode: normal;');
		sheet.addModern('old', 'animation: none; opacity: 0; mix-blend-mode: normal;');
		sheet.addAnimationRaw('new', 'animation: none; mix-blend-mode: normal;');
		sheet.addModern('group', 'animation: none');
	}

	result._metadata.extraHead.push(markHTMLString(`<style>${sheet.toString()}</style>`));
	return scope;
}

export function createAnimationScope(
	transitionName: string,
	animations: Record<string, TransitionAnimationPair>,
) {
	const hash = Math.random().toString(36).slice(2, 8);
	const scope = `astro-${hash}`;
	const sheet = new ViewTransitionStyleSheet(scope, transitionName);

	addPairs(animations, sheet);

	return { scope, styles: sheet.toString().replaceAll('"', '') };
}

class ViewTransitionStyleSheet {
	private modern: string[] = [];
	private fallback: string[] = [];

	constructor(
		private scope: string,
		private name: string,
	) {}

	toString() {
		const { scope, name } = this;
		const [modern, fallback] = [this.modern, this.fallback].map((rules) => rules.join(''));
		return [
			`[data-astro-transition-scope="${scope}"] { view-transition-name: ${name}; }`,
			this.layer(modern),
			fallback,
		].join('');
	}

	private layer(cssText: string) {
		return cssText ? `@layer astro { ${cssText} }` : '';
	}

	private addRule(target: 'modern' | 'fallback', cssText: string) {
		this[target].push(cssText);
	}

	addAnimationRaw(image: 'old' | 'new' | 'group', animation: string) {
		this.addModern(image, animation);
		this.addFallback(image, animation);
	}

	addModern(image: 'old' | 'new' | 'group', animation: string) {
		const { name } = this;
		this.addRule('modern', `::view-transition-${image}(${name}) { ${animation} }`);
	}

	addFallback(image: 'old' | 'new' | 'group', animation: string) {
		const { scope } = this;
		this.addRule(
			'fallback',
			// Two selectors here, the second in case there is an animation on the root.
			`[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
			[data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`,
		);
	}

	addAnimationPair(
		direction: 'forwards' | 'backwards' | string,
		image: 'old' | 'new',
		rules: TransitionAnimation | TransitionAnimation[],
	) {
		const { scope, name } = this;
		const animation = stringifyAnimation(rules);
		const prefix =
			direction === 'backwards'
				? `[data-astro-transition=back]`
				: direction === 'forwards'
					? ''
					: `[data-astro-transition=${direction}]`;
		this.addRule('modern', `${prefix}::view-transition-${image}(${name}) { ${animation} }`);
		this.addRule(
			'fallback',
			`${prefix}[data-astro-transition-fallback="${image}"] [data-astro-transition-scope="${scope}"],
			${prefix}[data-astro-transition-fallback="${image}"][data-astro-transition-scope="${scope}"] { ${animation} }`,
		);
	}
}

type AnimationBuilder = {
	toString(): string;
	[key: string]: string[] | ((k: string) => string);
};

function addAnimationProperty(builder: AnimationBuilder, prop: string, value: string | number) {
	let arr = builder[prop];
	if (Array.isArray(arr)) {
		arr.push(value.toString());
	} else {
		builder[prop] = [value.toString()];
	}
}

function animationBuilder(): AnimationBuilder {
	return {
		toString() {
			let out = '';
			for (let k in this) {
				let value = this[k];
				if (Array.isArray(value)) {
					out += `\n\t${k}: ${value.join(', ')};`;
				}
			}
			return out;
		},
	};
}

function stringifyAnimation(anim: TransitionAnimation | TransitionAnimation[]): string {
	if (Array.isArray(anim)) {
		return stringifyAnimations(anim);
	} else {
		return stringifyAnimations([anim]);
	}
}

function stringifyAnimations(anims: TransitionAnimation[]): string {
	const builder = animationBuilder();

	for (const anim of anims) {
		if (anim.duration) {
			addAnimationProperty(builder, 'animation-duration', toTimeValue(anim.duration));
		}
		if (anim.easing) {
			addAnimationProperty(builder, 'animation-timing-function', anim.easing);
		}
		if (anim.direction) {
			addAnimationProperty(builder, 'animation-direction', anim.direction);
		}
		if (anim.delay) {
			addAnimationProperty(builder, 'animation-delay', anim.delay);
		}
		if (anim.fillMode) {
			addAnimationProperty(builder, 'animation-fill-mode', anim.fillMode);
		}
		addAnimationProperty(builder, 'animation-name', anim.name);
	}

	return builder.toString();
}

function toTimeValue(num: number | string) {
	return typeof num === 'number' ? num + 'ms' : num;
}
