/** @import * as ESTree from 'estree' */
/** @import { AST, ValidatedCompileOptions, ValidatedModuleCompileOptions } from '#compiler' */
/** @import { ComponentAnalysis, Analysis } from '../../types' */
/** @import { Visitors, ComponentClientTransformState, ClientTransformState } from './types' */
import { walk } from 'zimmerframe';
import * as b from '#compiler/builders';
import { build_getter, is_state_source } from './utils.js';
import { render_stylesheet } from '../css/index.js';
import { dev, filename } from '../../../state.js';
import { AnimateDirective } from './visitors/AnimateDirective.js';
import { ArrowFunctionExpression } from './visitors/ArrowFunctionExpression.js';
import { AssignmentExpression } from './visitors/AssignmentExpression.js';
import { Attribute } from './visitors/Attribute.js';
import { AwaitBlock } from './visitors/AwaitBlock.js';
import { AwaitExpression } from './visitors/AwaitExpression.js';
import { BinaryExpression } from './visitors/BinaryExpression.js';
import { BindDirective } from './visitors/BindDirective.js';
import { BlockStatement } from './visitors/BlockStatement.js';
import { BreakStatement } from './visitors/BreakStatement.js';
import { CallExpression } from './visitors/CallExpression.js';
import { ClassBody } from './visitors/ClassBody.js';
import { Comment } from './visitors/Comment.js';
import { Component } from './visitors/Component.js';
import { ConstTag } from './visitors/ConstTag.js';
import { DebugTag } from './visitors/DebugTag.js';
import { EachBlock } from './visitors/EachBlock.js';
import { ExportNamedDeclaration } from './visitors/ExportNamedDeclaration.js';
import { ExpressionStatement } from './visitors/ExpressionStatement.js';
import { ForOfStatement } from './visitors/ForOfStatement.js';
import { Fragment } from './visitors/Fragment.js';
import { FunctionDeclaration } from './visitors/FunctionDeclaration.js';
import { FunctionExpression } from './visitors/FunctionExpression.js';
import { HtmlTag } from './visitors/HtmlTag.js';
import { Identifier } from './visitors/Identifier.js';
import { IfBlock } from './visitors/IfBlock.js';
import { KeyBlock } from './visitors/KeyBlock.js';
import { LabeledStatement } from './visitors/LabeledStatement.js';
import { LetDirective } from './visitors/LetDirective.js';
import { MemberExpression } from './visitors/MemberExpression.js';
import { OnDirective } from './visitors/OnDirective.js';
import { Program } from './visitors/Program.js';
import { RegularElement } from './visitors/RegularElement.js';
import { RenderTag } from './visitors/RenderTag.js';
import { SlotElement } from './visitors/SlotElement.js';
import { SnippetBlock } from './visitors/SnippetBlock.js';
import { SpreadAttribute } from './visitors/SpreadAttribute.js';
import { SvelteBody } from './visitors/SvelteBody.js';
import { SvelteComponent } from './visitors/SvelteComponent.js';
import { SvelteDocument } from './visitors/SvelteDocument.js';
import { SvelteElement } from './visitors/SvelteElement.js';
import { SvelteFragment } from './visitors/SvelteFragment.js';
import { SvelteBoundary } from './visitors/SvelteBoundary.js';
import { SvelteHead } from './visitors/SvelteHead.js';
import { SvelteSelf } from './visitors/SvelteSelf.js';
import { SvelteWindow } from './visitors/SvelteWindow.js';
import { TitleElement } from './visitors/TitleElement.js';
import { TransitionDirective } from './visitors/TransitionDirective.js';
import { UpdateExpression } from './visitors/UpdateExpression.js';
import { UseDirective } from './visitors/UseDirective.js';
import { AttachTag } from './visitors/AttachTag.js';
import { VariableDeclaration } from './visitors/VariableDeclaration.js';

/** @type {Visitors} */
const visitors = {
	_: function set_scope(node, { next, state }) {
		const scope = state.scopes.get(node);

		if (scope && scope !== state.scope) {
			const transform = { ...state.transform };

			for (const [name, binding] of scope.declarations) {
				if (
					binding.kind === 'normal' ||
					// Reads of `$state(...)` declarations are not
					// transformed if they are never reassigned
					(binding.kind === 'state' && !is_state_source(binding, state.analysis))
				) {
					delete transform[name];
				}
			}

			next({ ...state, transform, scope });
		} else {
			next();
		}
	},
	AnimateDirective,
	ArrowFunctionExpression,
	AssignmentExpression,
	Attribute,
	AwaitBlock,
	AwaitExpression,
	BinaryExpression,
	BindDirective,
	BlockStatement,
	BreakStatement,
	CallExpression,
	ClassBody,
	Comment,
	Component,
	ConstTag,
	DebugTag,
	EachBlock,
	ExportNamedDeclaration,
	ExpressionStatement,
	ForOfStatement,
	Fragment,
	FunctionDeclaration,
	FunctionExpression,
	HtmlTag,
	Identifier,
	IfBlock,
	KeyBlock,
	LabeledStatement,
	LetDirective,
	MemberExpression,
	OnDirective,
	Program,
	RegularElement,
	RenderTag,
	SlotElement,
	SnippetBlock,
	SpreadAttribute,
	SvelteBody,
	SvelteComponent,
	SvelteDocument,
	SvelteElement,
	SvelteFragment,
	SvelteBoundary,
	SvelteHead,
	SvelteSelf,
	SvelteWindow,
	TitleElement,
	TransitionDirective,
	UpdateExpression,
	UseDirective,
	AttachTag,
	VariableDeclaration
};

/**
 * @param {ComponentAnalysis} analysis
 * @param {ValidatedCompileOptions} options
 * @returns {ESTree.Program}
 */
export function client_component(analysis, options) {
	/** @type {ComponentClientTransformState} */
	const state = {
		analysis,
		options,
		scope: analysis.module.scope,
		scopes: analysis.module.scopes,
		is_instance: false,
		hoisted: [b.import_all('$', 'svelte/internal/client'), ...analysis.instance_body.hoisted],
		node: /** @type {any} */ (null), // populated by the root node
		legacy_reactive_imports: [],
		legacy_reactive_statements: new Map(),
		metadata: {
			namespace: options.namespace,
			bound_contenteditable: false
		},
		events: new Set(),
		preserve_whitespace: options.preserveWhitespace,
		state_fields: new Map(),
		transform: {},
		in_constructor: false,
		instance_level_snippets: [],
		module_level_snippets: [],
		is_standalone: false,

		// these are set inside the `Fragment` visitor, and cannot be used until then
		init: /** @type {any} */ (null),
		consts: /** @type {any} */ (null),
		snippets: /** @type {any} */ (null),
		let_directives: /** @type {any} */ (null),
		update: /** @type {any} */ (null),
		after_update: /** @type {any} */ (null),
		template: /** @type {any} */ (null),
		memoizer: /** @type {any} */ (null)
	};

	const module = /** @type {ESTree.Program} */ (
		walk(/** @type {AST.SvelteNode} */ (analysis.module.ast), state, visitors)
	);

	const instance_state = {
		...state,
		transform: { ...state.transform },
		scope: analysis.instance.scope,
		scopes: analysis.instance.scopes,
		is_instance: true
	};

	const instance = /** @type {ESTree.Program} */ (
		walk(/** @type {AST.SvelteNode} */ (analysis.instance.ast), instance_state, visitors)
	);

	const template = /** @type {ESTree.Program} */ (
		walk(
			/** @type {AST.SvelteNode} */ (analysis.template.ast),
			{
				...state,
				transform: instance_state.transform,
				scope: analysis.instance.scope,
				scopes: analysis.template.scopes
			},
			visitors
		)
	);

	module.body.unshift(...state.legacy_reactive_imports);

	/** @type {ESTree.Statement[]} */
	const store_setup = [];
	/** @type {ESTree.Statement} */
	let store_init = b.empty;
	/** @type {ESTree.VariableDeclaration[]} */
	const legacy_reactive_declarations = [];

	let needs_store_cleanup = false;

	for (const [name, binding] of analysis.instance.scope.declarations) {
		if (binding.kind === 'legacy_reactive') {
			legacy_reactive_declarations.push(
				b.const(
					name,
					b.call('$.mutable_source', undefined, analysis.immutable ? b.true : undefined)
				)
			);
		}
		if (binding.kind === 'store_sub') {
			if (store_setup.length === 0) {
				needs_store_cleanup = true;
				store_init = b.const(
					b.array_pattern([b.id('$$stores'), b.id('$$cleanup')]),
					b.call('$.setup_stores')
				);
			}

			// We're creating an arrow function that gets the store value which minifies better for two or more references
			const store_reference = build_getter(b.id(name.slice(1)), instance_state);
			const store_get = b.call('$.store_get', store_reference, b.literal(name), b.id('$$stores'));
			store_setup.push(
				b.const(
					binding.node,
					dev
						? b.thunk(
								b.sequence([
									b.call('$.validate_store', store_reference, b.literal(name.slice(1))),
									store_get
								])
							)
						: b.thunk(store_get)
				)
			);
		}
	}

	for (const [node] of analysis.reactive_statements) {
		const statement = [...state.legacy_reactive_statements].find(([n]) => n === node);
		if (statement === undefined) {
			throw new Error('Could not find reactive statement');
		}
		instance.body.push(statement[1]);
	}

	if (analysis.reactive_statements.size > 0) {
		instance.body.push(b.stmt(b.call('$.legacy_pre_effect_reset')));
	}

	/**
	 * Used to store the group nodes
	 * @type {ESTree.VariableDeclaration[]}
	 */
	const group_binding_declarations = [];
	for (const group of analysis.binding_groups.values()) {
		group_binding_declarations.push(b.const(group.name, b.array([])));
	}

	/** @type {Array<ESTree.Property | ESTree.SpreadElement>} */
	const component_returned_object = analysis.exports.flatMap(({ name, alias }) => {
		const binding = instance_state.scope.get(name);
		const expression = build_getter(b.id(name), instance_state);
		const getter = b.get(alias ?? name, [b.return(expression)]);

		if (expression.type === 'Identifier') {
			if (binding?.declaration_kind === 'let' || binding?.declaration_kind === 'var') {
				return [
					getter,
					b.set(alias ?? name, [b.stmt(b.assignment('=', expression, b.id('$$value')))])
				];
			} else if (!dev) {
				return b.init(alias ?? name, expression);
			}
		}

		if (binding?.kind === 'prop' || binding?.kind === 'bindable_prop') {
			return [getter, b.set(alias ?? name, [b.stmt(b.call(name, b.id('$$value')))])];
		}

		if (binding?.kind === 'state' || binding?.kind === 'raw_state') {
			const value = binding.kind === 'state' ? b.call('$.proxy', b.id('$$value')) : b.id('$$value');
			return [getter, b.set(alias ?? name, [b.stmt(b.call('$.set', b.id(name), value))])];
		}

		return getter;
	});

	const properties = [...analysis.instance.scope.declarations].filter(
		([name, binding]) =>
			(binding.kind === 'prop' || binding.kind === 'bindable_prop') && !name.startsWith('$$')
	);

	if (analysis.accessors) {
		for (const [name, binding] of properties) {
			const key = binding.prop_alias ?? name;

			const getter = b.get(key, [b.return(b.call(b.id(name)))]);

			const setter = b.set(key, [
				b.stmt(b.call(b.id(name), b.id('$$value'))),
				b.stmt(b.call('$.flush'))
			]);

			if (analysis.runes && binding.initial) {
				// turn `set foo($$value)` into `set foo($$value = expression)`
				setter.value.params[0] = {
					type: 'AssignmentPattern',
					left: b.id('$$value'),
					right: /** @type {ESTree.Expression} */ (binding.initial)
				};
			}

			component_returned_object.push(getter, setter);
		}
	}

	if (options.compatibility.componentApi === 4) {
		component_returned_object.push(
			b.init('$set', b.id('$.update_legacy_props')),
			b.init(
				'$on',
				b.arrow(
					[b.id('$$event_name'), b.id('$$event_cb')],
					b.call(
						'$.add_legacy_event_listener',
						b.id('$$props'),
						b.id('$$event_name'),
						b.id('$$event_cb')
					)
				)
			)
		);
	} else if (dev) {
		component_returned_object.push(b.spread(b.call(b.id('$.legacy_api'))));
	}

	const push_args = [b.id('$$props'), b.literal(analysis.runes)];
	if (dev) push_args.push(b.id(analysis.name));

	let component_block = b.block([
		store_init,
		...legacy_reactive_declarations,
		...group_binding_declarations
	]);

	const should_inject_context =
		dev ||
		analysis.needs_context ||
		analysis.reactive_statements.size > 0 ||
		component_returned_object.length > 0;

	component_block.body.push(
		...state.instance_level_snippets,
		.../** @type {ESTree.Statement[]} */ (instance.body)
	);

	if (should_inject_context && component_returned_object.length > 0) {
		component_block.body.push(b.var('$$exports', b.object(component_returned_object)));
	}
	component_block.body.unshift(...store_setup);

	if (!analysis.runes && analysis.needs_context) {
		component_block.body.push(b.stmt(b.call('$.init', analysis.immutable ? b.true : undefined)));
	}

	component_block.body.push(.../** @type {ESTree.Statement[]} */ (template.body));

	if (analysis.needs_mutation_validation) {
		component_block.body.unshift(
			b.var('$$ownership_validator', b.call('$.create_ownership_validator', b.id('$$props')))
		);
	}

	let should_inject_props =
		should_inject_context ||
		analysis.needs_props ||
		analysis.uses_props ||
		analysis.uses_rest_props ||
		analysis.uses_slots ||
		analysis.slot_names.size > 0;

	// trick esrap into including comments
	component_block.loc = instance.loc;

	if (!analysis.runes) {
		// Bind static exports to props so that people can access them with bind:x
		for (const { name, alias } of analysis.exports) {
			component_block.body.push(
				b.stmt(
					b.call(
						'$.bind_prop',
						b.id('$$props'),
						b.literal(alias ?? name),
						build_getter(b.id(name), instance_state)
					)
				)
			);
		}
	}

	if (analysis.css.ast !== null && analysis.inject_styles) {
		const hash = b.literal(analysis.css.hash);
		const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);

		state.hoisted.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));

		component_block.body.unshift(
			b.stmt(b.call('$.append_styles', b.id('$$anchor'), b.id('$$css')))
		);
	}

	// we want the cleanup function for the stores to run as the very last thing
	// so that it can effectively clean up the store subscription even after the user effects runs
	if (should_inject_context) {
		component_block.body.unshift(b.stmt(b.call('$.push', ...push_args)));

		let to_push;

		if (component_returned_object.length > 0) {
			let pop_call = b.call('$.pop', b.id('$$exports'));
			to_push = needs_store_cleanup ? b.var('$$pop', pop_call) : b.return(pop_call);
		} else {
			to_push = b.stmt(b.call('$.pop'));
		}

		component_block.body.push(to_push);
	}

	if (needs_store_cleanup) {
		component_block.body.push(b.stmt(b.call('$$cleanup')));

		if (component_returned_object.length > 0) {
			component_block.body.push(b.return(b.id('$$pop')));
		}
	}

	if (analysis.uses_rest_props) {
		const named_props = analysis.exports.map(({ name, alias }) => alias ?? name);
		for (const [name, binding] of analysis.instance.scope.declarations) {
			if (binding.kind === 'bindable_prop') named_props.push(binding.prop_alias ?? name);
		}

		component_block.body.unshift(
			b.const(
				'$$restProps',
				b.call(
					'$.legacy_rest_props',
					b.id('$$sanitized_props'),
					b.array(named_props.map((name) => b.literal(name)))
				)
			)
		);
	}

	if (analysis.uses_props || analysis.uses_rest_props) {
		const to_remove = [
			b.literal('children'),
			b.literal('$$slots'),
			b.literal('$$events'),
			b.literal('$$legacy')
		];
		if (analysis.custom_element) {
			to_remove.push(b.literal('$$host'));
		}

		component_block.body.unshift(
			b.const(
				'$$sanitized_props',
				b.call('$.legacy_rest_props', b.id('$$props'), b.array(to_remove))
			)
		);
	}

	if (analysis.uses_slots) {
		component_block.body.unshift(b.const('$$slots', b.call('$.sanitize_slots', b.id('$$props'))));
	}

	// Merge hoisted statements into module body.
	// Ensure imports are on top, with the order preserved, then module body, then hoisted statements
	/** @type {ESTree.ImportDeclaration[]} */
	const imports = [];
	/** @type {ESTree.Program['body']} */
	let body = [];

	for (const entry of [...module.body, ...state.hoisted]) {
		if (entry.type === 'ImportDeclaration') {
			imports.push(entry);
		} else {
			body.push(entry);
		}
	}

	body = [...imports, ...state.module_level_snippets, ...body];

	const component = b.function_declaration(
		b.id(analysis.name),
		should_inject_props ? [b.id('$$anchor'), b.id('$$props')] : [b.id('$$anchor')],
		component_block
	);

	if (options.hmr) {
		const id = b.id(analysis.name);

		const accept_fn_body = [
			b.stmt(b.call(b.member(b.member(id, b.id('$.HMR'), true), 'update'), b.id('module.default')))
		];

		if (analysis.css.hash) {
			// remove existing `<style>` element, in case CSS changed
			accept_fn_body.unshift(b.stmt(b.call('$.cleanup_styles', b.literal(analysis.css.hash))));
		}

		const hmr = b.block([
			b.stmt(b.assignment('=', id, b.call('$.hmr', id))),
			b.stmt(b.call('import.meta.hot.accept', b.arrow([b.id('module')], b.block(accept_fn_body))))
		]);

		body.push(component, b.if(b.id('import.meta.hot'), hmr), b.export_default(b.id(analysis.name)));
	} else {
		body.push(b.export_default(component));
	}

	if (dev) {
		// add `App[$.FILENAME] = 'App.svelte'` so that we can print useful messages later
		body.unshift(
			b.stmt(
				b.assignment('=', b.member(b.id(analysis.name), '$.FILENAME', true), b.literal(filename))
			)
		);
	}

	if (options.experimental.async) {
		body.unshift(b.imports([], 'svelte/internal/flags/async'));
	}

	if (!analysis.runes) {
		body.unshift(b.imports([], 'svelte/internal/flags/legacy'));
	}

	if (analysis.tracing) {
		body.unshift(b.imports([], 'svelte/internal/flags/tracing'));
	}

	if (options.discloseVersion) {
		body.unshift(b.imports([], 'svelte/internal/disclose-version'));
	}

	if (options.compatibility.componentApi === 4) {
		body.unshift(b.imports([['createClassComponent', '$$_createClassComponent']], 'svelte/legacy'));
		component_block.body.unshift(
			b.if(
				b.id('new.target'),
				b.return(
					b.call(
						'$$_createClassComponent',
						// When called with new, the first argument is the constructor options
						b.object([b.init('component', b.id(analysis.name)), b.spread(b.id('$$anchor'))])
					)
				)
			)
		);
	} else if (dev) {
		component_block.body.unshift(b.stmt(b.call('$.check_target', b.id('new.target'))));
	}

	if (analysis.props_id) {
		// need to be placed on first line of the component for hydration
		component_block.body.unshift(b.const(analysis.props_id, b.call('$.props_id')));
	}

	if (state.events.size > 0) {
		body.push(
			b.stmt(b.call('$.delegate', b.array(Array.from(state.events).map((name) => b.literal(name)))))
		);
	}

	const ce = options.customElementOptions ?? options.customElement;

	if (ce) {
		const ce_props = typeof ce === 'boolean' ? {} : ce.props || {};

		/** @type {ESTree.Property[]} */
		const props_str = [];

		for (const [name, prop_def] of Object.entries(ce_props)) {
			const binding = analysis.instance.scope.get(name);
			const key = binding?.prop_alias ?? name;

			if (
				!prop_def.type &&
				binding?.initial?.type === 'Literal' &&
				typeof binding?.initial.value === 'boolean'
			) {
				prop_def.type = 'Boolean';
			}

			const value = b.object(
				/** @type {ESTree.Property[]} */ (
					[
						prop_def.attribute ? b.init('attribute', b.literal(prop_def.attribute)) : undefined,
						prop_def.reflect ? b.init('reflect', b.true) : undefined,
						prop_def.type ? b.init('type', b.literal(prop_def.type)) : undefined
					].filter(Boolean)
				)
			);

			props_str.push(b.init(key, value));
		}

		for (const [name, binding] of properties) {
			const key = binding.prop_alias ?? name;
			if (ce_props[key]) continue;

			props_str.push(b.init(key, b.object([])));
		}

		const slots_str = b.array([...analysis.slot_names.keys()].map((name) => b.literal(name)));
		const accessors_str = b.array(
			analysis.exports.map(({ name, alias }) => b.literal(alias ?? name))
		);

		/** @type {ESTree.ObjectExpression | undefined} */
		let shadow_root_init;
		if (typeof ce === 'boolean' || ce.shadow === 'open' || ce.shadow === undefined) {
			shadow_root_init = b.object([b.init('mode', b.literal('open'))]);
		} else if (ce.shadow === 'none') {
			shadow_root_init = undefined;
		} else {
			shadow_root_init = ce.shadow;
		}

		const create_ce = b.call(
			'$.create_custom_element',
			b.id(analysis.name),
			b.object(props_str),
			slots_str,
			accessors_str,
			shadow_root_init,
			/** @type {any} */ (typeof ce !== 'boolean' ? ce.extend : undefined)
		);

		// If a tag name is provided, call `customElements.define`, otherwise leave to the user
		if (typeof ce !== 'boolean' && typeof ce.tag === 'string') {
			const define = b.stmt(b.call('customElements.define', b.literal(ce.tag), create_ce));

			if (options.hmr) {
				body.push(
					b.if(b.binary('==', b.call('customElements.get', b.literal(ce.tag)), b.null), define)
				);
			} else {
				body.push(define);
			}
		} else {
			body.push(b.stmt(create_ce));
		}
	}

	return {
		type: 'Program',
		sourceType: 'module',
		body
	};
}

/**
 * @param {Analysis} analysis
 * @param {ValidatedModuleCompileOptions} options
 * @returns {ESTree.Program}
 */
export function client_module(analysis, options) {
	/** @type {ClientTransformState} */
	const state = {
		analysis,
		options,
		scope: analysis.module.scope,
		scopes: analysis.module.scopes,
		state_fields: new Map(),
		transform: {},
		in_constructor: false,
		is_instance: false
	};

	const module = /** @type {ESTree.Program} */ (
		walk(/** @type {AST.SvelteNode} */ (analysis.module.ast), state, visitors)
	);

	const body = [b.import_all('$', 'svelte/internal/client')];

	if (analysis.tracing) {
		body.push(b.imports([], 'svelte/internal/flags/tracing'));
	}

	return {
		type: 'Program',
		sourceType: 'module',
		body: [...body, ...module.body]
	};
}
