import { b, x, p } from 'code-red';
import Renderer from './Renderer.js';
import { walk } from 'estree-walker';
import { extract_names } from 'periscopic';
import { invalidate } from './invalidate.js';
import { apply_preprocessor_sourcemap } from '../../utils/mapped_code.js';
import { flatten } from '../../utils/flatten.js';
import check_enable_sourcemap from '../utils/check_enable_sourcemap.js';
import { push_array } from '../../utils/push_array.js';

/**
 * @param {import('../Component.js').default} component
 * @param {import('../../interfaces.js').CompileOptions} options
 * @returns {{ js: import('estree').Node[]; css: import('../../interfaces.js').CssResult; }}
 */
export default function dom(component, options) {
	const { name } = component;
	const renderer = new Renderer(component, options);
	const { block } = renderer;
	block.has_outro_method = true;
	/** @type {import('estree').Node[][]} */
	const body = [];
	if (renderer.file_var) {
		const file = component.file ? x`"${component.file}"` : x`undefined`;
		body.push(b`const ${renderer.file_var} = ${file};`);
	}
	const css = component.stylesheet.render(options.filename);
	const css_sourcemap_enabled = check_enable_sourcemap(options.enableSourcemap, 'css');
	if (css_sourcemap_enabled) {
		css.map = apply_preprocessor_sourcemap(
			options.filename,
			css.map,
			/** @type {string | import('@ampproject/remapping').RawSourceMap | import('@ampproject/remapping').DecodedSourceMap} */ (
				options.sourcemap
			)
		);
	} else {
		css.map = null;
	}
	const styles =
		css_sourcemap_enabled && component.stylesheet.has_styles && options.dev
			? `${css.code}\n/*# sourceMappingURL=${css.map.toUrl()} */`
			: css.code;
	const add_css = component.get_unique_name('add_css');
	const should_add_css = !!styles && (options.customElement || options.css === 'injected');
	if (should_add_css) {
		body.push(b`
			function ${add_css}(target) {
				@append_styles(target, "${component.stylesheet.id}", "${styles}");
			}
		`);
	}
	// fix order
	// TODO the deconflicted names of blocks are reversed... should set them here
	const blocks = renderer.blocks.slice().reverse();
	push_array(
		body,
		blocks.map((block) => {
			// TODO this is a horrible mess — renderer.blocks
			// contains a mixture of Blocks and Nodes
			if (/** @type {import('./Block.js').default} */ (block).render)
				return /** @type {import('./Block.js').default} */ (block).render();
			return block;
		})
	);
	if (options.dev && !options.hydratable) {
		block.chunks.claim.push(
			b`throw new @_Error("options.hydrate only works if the component was compiled with the \`hydratable: true\` option");`
		);
	}
	const uses_slots = component.var_lookup.has('$$slots');

	/** @type {import('estree').Node[] | undefined} */
	let compute_slots;
	if (uses_slots) {
		compute_slots = b`
			const $$slots = @compute_slots(#slots);
		`;
	}
	const uses_props = component.var_lookup.has('$$props');
	const uses_rest = component.var_lookup.has('$$restProps');
	const $$props = uses_props || uses_rest ? '$$new_props' : '$$props';
	const props = component.vars.filter((variable) => !variable.module && variable.export_name);
	const writable_props = props.filter((variable) => variable.writable);
	const omit_props_names = component.get_unique_name('omit_props_names');
	const compute_rest = x`@compute_rest_props($$props, ${omit_props_names.name})`;
	const rest = uses_rest
		? b`
		const ${omit_props_names.name} = [${props.map((prop) => `"${prop.export_name}"`).join(',')}];
		let $$restProps = ${compute_rest};
	`
		: null;
	const set =
		uses_props || uses_rest || writable_props.length > 0 || component.slots.size > 0
			? x`
			${$$props} => {
				${
					uses_props &&
					renderer.invalidate(
						'$$props',
						x`$$props = @assign(@assign({}, $$props), @exclude_internal_props($$new_props))`
					)
				}
				${
					uses_rest &&
					!uses_props &&
					x`$$props = @assign(@assign({}, $$props), @exclude_internal_props($$new_props))`
				}
				${uses_rest && renderer.invalidate('$$restProps', x`$$restProps = ${compute_rest}`)}
				${writable_props.map(
					(prop) =>
						b`if ('${prop.export_name}' in ${$$props}) ${renderer.invalidate(
							prop.name,
							x`${prop.name} = ${$$props}.${prop.export_name}`
						)};`
				)}
				${
					component.slots.size > 0 &&
					b`if ('$$scope' in ${$$props}) ${renderer.invalidate(
						'$$scope',
						x`$$scope = ${$$props}.$$scope`
					)};`
				}
			}
		`
			: null;
	const accessors = [];
	const not_equal = component.component_options.immutable ? x`@not_equal` : x`@safe_not_equal`;

	/** @type {import('estree').Node[] | import('estree').Node} */
	let missing_props_check;

	/** @type {import('estree').Expression} */
	let inject_state;

	/** @type {import('estree').Expression} */
	let capture_state;

	/** @type {import('estree').Node[] | import('estree').Node} */
	let props_inject;
	props.forEach((prop) => {
		const variable = component.var_lookup.get(prop.name);
		if (!variable.writable || component.component_options.accessors) {
			accessors.push({
				type: 'MethodDefinition',
				kind: 'get',
				key: { type: 'Identifier', name: prop.export_name },
				value: x`function() {
					return ${
						prop.hoistable
							? prop.name
							: x`this.$$.ctx[${renderer.context_lookup.get(prop.name).index}]`
					}
				}`
			});
		} else if (component.compile_options.dev) {
			accessors.push({
				type: 'MethodDefinition',
				kind: 'get',
				key: { type: 'Identifier', name: prop.export_name },
				value: x`function() {
					throw new @_Error("<${component.tag}>: Props cannot be read directly from the component instance unless compiling with 'accessors: true' or '<svelte:options accessors/>'");
				}`
			});
		}
		if (component.component_options.accessors) {
			if (variable.writable && !renderer.readonly.has(prop.name)) {
				accessors.push({
					type: 'MethodDefinition',
					kind: 'set',
					key: { type: 'Identifier', name: prop.export_name },
					value: x`function(${prop.name}) {
						this.$$set({ ${prop.export_name}: ${prop.name} });
						@flush();
					}`
				});
			} else if (component.compile_options.dev) {
				accessors.push({
					type: 'MethodDefinition',
					kind: 'set',
					key: { type: 'Identifier', name: prop.export_name },
					value: x`function(value) {
						throw new @_Error("<${component.tag}>: Cannot set read-only property '${prop.export_name}'");
					}`
				});
			}
		} else if (component.compile_options.dev) {
			accessors.push({
				type: 'MethodDefinition',
				kind: 'set',
				key: { type: 'Identifier', name: prop.export_name },
				value: x`function(value) {
					throw new @_Error("<${component.tag}>: Props cannot be set directly on the component instance unless compiling with 'accessors: true' or '<svelte:options accessors/>'");
				}`
			});
		}
	});
	component.instance_exports_from.forEach((exports_from) => {
		const import_declaration = {
			...exports_from,
			type: 'ImportDeclaration',
			specifiers: [],
			source: exports_from.source
		};
		component.imports.push(/** @type {import('estree').ImportDeclaration} */ (import_declaration));
		exports_from.specifiers.forEach((specifier) => {
			if (component.component_options.accessors) {
				const name = component.get_unique_name(specifier.exported.name);
				import_declaration.specifiers.push({
					...specifier,
					type: 'ImportSpecifier',
					imported: specifier.local,
					local: name
				});
				accessors.push({
					type: 'MethodDefinition',
					kind: 'get',
					key: { type: 'Identifier', name: specifier.exported.name },
					value: x`function() {
						return ${name}
					}`
				});
			} else if (component.compile_options.dev) {
				accessors.push({
					type: 'MethodDefinition',
					kind: 'get',
					key: { type: 'Identifier', name: specifier.exported.name },
					value: x`function() {
						throw new @_Error("<${component.tag}>: Props cannot be read directly from the component instance unless compiling with 'accessors: true' or '<svelte:options accessors/>'");
					}`
				});
			}
		});
	});
	if (component.compile_options.dev) {
		// checking that expected ones were passed
		const expected = props.filter((prop) => prop.writable && !prop.initialised);
		if (expected.length) {
			missing_props_check = b`
				$$self.$$.on_mount.push(function () {
					${expected.map(
						(prop) => b`
					if (${prop.name} === undefined && !(('${prop.export_name}' in $$props) || $$self.$$.bound[$$self.$$.props['${prop.export_name}']])) {
						@_console.warn("<${component.tag}> was created without expected prop '${prop.export_name}'");
					}`
					)}
				});
			`;
		}
		const capturable_vars = component.vars.filter(
			(v) => !v.internal && !v.global && !v.name.startsWith('$$')
		);
		if (capturable_vars.length > 0) {
			capture_state = x`() => ({ ${capturable_vars.map((prop) => p`${prop.name}`)} })`;
		}
		const injectable_vars = capturable_vars.filter(
			(v) => !v.module && v.writable && v.name[0] !== '$'
		);
		if (uses_props || injectable_vars.length > 0) {
			inject_state = x`
				${$$props} => {
					${
						uses_props &&
						renderer.invalidate('$$props', x`$$props = @assign(@assign({}, $$props), $$new_props)`)
					}
					${injectable_vars.map(
						(v) =>
							b`if ('${v.name}' in $$props) ${renderer.invalidate(
								v.name,
								x`${v.name} = ${$$props}.${v.name}`
							)};`
					)}
				}
			`;
			props_inject = b`
				if ($$props && "$$inject" in $$props) {
					$$self.$inject_state($$props.$$inject);
				}
			`;
		}
	}
	// instrument assignments
	if (component.ast.instance) {
		let scope = component.instance_scope;
		const map = component.instance_scope_map;

		/** @type {import('estree').Node | null} */
		let execution_context = null;
		walk(component.ast.instance.content, {
			enter(node) {
				if (map.has(node)) {
					scope = /** @type {import('periscopic').Scope} */ (map.get(node));
					if (!execution_context && !scope.block) {
						execution_context = node;
					}
				} else if (
					!execution_context &&
					node.type === 'LabeledStatement' &&
					node.label.name === '$'
				) {
					execution_context = node;
				}
			},
			leave(node) {
				if (map.has(node)) {
					scope = scope.parent;
				}
				if (execution_context === node) {
					execution_context = null;
				}
				if (node.type === 'AssignmentExpression' || node.type === 'UpdateExpression') {
					const assignee = node.type === 'AssignmentExpression' ? node.left : node.argument;
					// normally (`a = 1`, `b.c = 2`), there'll be a single name
					// (a or b). In destructuring cases (`[d, e] = [e, d]`) there
					// may be more, in which case we need to tack the extra ones
					// onto the initial function call
					const names = new Set(extract_names(/** @type {import('estree').Node} */ (assignee)));
					this.replace(invalidate(renderer, scope, node, names, execution_context === null));
				}
			}
		});
		component.rewrite_props(({ name, reassigned, export_name }) => {
			const value = `$${name}`;
			const i = renderer.context_lookup.get(`$${name}`).index;
			const insert =
				reassigned || export_name
					? b`${`$$subscribe_${name}`}()`
					: b`@component_subscribe($$self, ${name}, #value => $$invalidate(${i}, ${value} = #value))`;
			if (component.compile_options.dev) {
				return b`@validate_store(${name}, '${name}'); ${insert}`;
			}
			return insert;
		});
	}
	const args = [x`$$self`];
	const has_invalidate =
		props.length > 0 ||
		component.has_reactive_assignments ||
		component.slots.size > 0 ||
		capture_state ||
		inject_state;
	if (has_invalidate) {
		args.push(x`$$props`, x`$$invalidate`);
	} else if (component.compile_options.dev) {
		// $$props arg is still needed for unknown prop check
		args.push(x`$$props`);
	}
	// has_create_fragment is intentionally to be true in dev mode.
	const has_create_fragment = component.compile_options.dev || block.has_content();
	if (has_create_fragment) {
		body.push(b`
			function create_fragment(#ctx) {
				${block.get_contents()}
			}
		`);
	}
	body.push(b`
		${component.extract_javascript(component.ast.module)}

		${component.fully_hoisted}
	`);
	const filtered_props = props.filter((prop) => {
		const variable = component.var_lookup.get(prop.name);
		if (variable.hoistable) return false;
		return prop.name[0] !== '$';
	});
	const reactive_stores = component.vars.filter(
		(variable) => variable.name[0] === '$' && variable.name[1] !== '$'
	);
	const instance_javascript = component.extract_javascript(component.ast.instance);
	const has_definition =
		component.compile_options.dev ||
		(instance_javascript && instance_javascript.length > 0) ||
		filtered_props.length > 0 ||
		uses_props ||
		component.partly_hoisted.length > 0 ||
		renderer.initial_context.length > 0 ||
		component.reactive_declarations.length > 0 ||
		capture_state ||
		inject_state;
	const definition = has_definition
		? component.alias('instance')
		: { type: 'Literal', value: null };
	const reactive_store_subscriptions = reactive_stores
		.filter((store) => {
			const variable = component.var_lookup.get(store.name.slice(1));
			return !variable || variable.hoistable;
		})
		.map(
			({ name }) => b`
			${component.compile_options.dev && b`@validate_store(${name.slice(1)}, '${name.slice(1)}');`}
			@component_subscribe($$self, ${name.slice(1)}, $$value => $$invalidate(${
				renderer.context_lookup.get(name).index
			}, ${name} = $$value));
		`
		);
	const resubscribable_reactive_store_unsubscribers = reactive_stores
		.filter((store) => {
			const variable = component.var_lookup.get(store.name.slice(1));
			return variable && (variable.reassigned || variable.export_name);
		})
		.map(({ name }) => b`$$self.$$.on_destroy.push(() => ${`$$unsubscribe_${name.slice(1)}`}());`);
	if (has_definition) {
		/** @type {import('estree').Node | import('estree').Node[]} */
		const reactive_declarations = [];

		/** @type {import('estree').Node[]} */
		const fixed_reactive_declarations = []; // not really 'reactive' but whatever
		component.reactive_declarations.forEach((d) => {
			const dependencies = Array.from(d.dependencies);
			const uses_rest_or_props = !!dependencies.find((n) => n === '$$props' || n === '$$restProps');
			const writable = dependencies.filter((n) => {
				const variable = component.var_lookup.get(n);
				return variable && (variable.export_name || variable.mutated || variable.reassigned);
			});
			const condition =
				!uses_rest_or_props && writable.length > 0 && renderer.dirty(writable, true);
			let statement = d.node; // TODO remove label (use d.node.body) if it's not referenced
			if (condition)
				statement = /** @type {import('estree').Statement} */ (
					b`if (${condition}) { ${statement} }`[0]
				);
			if (condition || uses_rest_or_props) {
				reactive_declarations.push(statement);
			} else {
				fixed_reactive_declarations.push(statement);
			}
		});
		const injected = Array.from(component.injected_reactive_declaration_vars).filter((name) => {
			const variable = component.var_lookup.get(name);
			return variable.injected && variable.name[0] !== '$';
		});
		const reactive_store_declarations = reactive_stores.map((variable) => {
			const $name = variable.name;
			const name = $name.slice(1);
			const store = component.var_lookup.get(name);
			if (store && (store.reassigned || store.export_name)) {
				const unsubscribe = `$$unsubscribe_${name}`;
				const subscribe = `$$subscribe_${name}`;
				const i = renderer.context_lookup.get($name).index;
				return b`let ${$name}, ${unsubscribe} = @noop, ${subscribe} = () => (${unsubscribe}(), ${unsubscribe} = @subscribe(${name}, $$value => $$invalidate(${i}, ${$name} = $$value)), ${name})`;
			}
			return b`let ${$name};`;
		});

		/** @type {import('estree').Node[] | undefined} */
		let unknown_props_check;
		if (component.compile_options.dev && !(uses_props || uses_rest)) {
			unknown_props_check = b`
				const writable_props = [${writable_props.map((prop) => x`'${prop.export_name}'`)}];
				@_Object.keys($$props).forEach(key => {
					if (!~writable_props.indexOf(key) && key.slice(0, 2) !== '$$' && key !== 'slot') @_console.warn(\`<${
						component.tag
					}> was created with unknown prop '\${key}'\`);
				});
			`;
		}
		const return_value = {
			type: 'ArrayExpression',
			elements: renderer.initial_context.map(
				(member) =>
					/** @type {import('estree').Expression} */ ({
						type: 'Identifier',
						name: member.name
					})
			)
		};
		body.push(b`
			function ${definition}(${args}) {
				${injected.map((name) => b`let ${name};`)}

				${rest}

				${reactive_store_declarations}

				${reactive_store_subscriptions}

				${resubscribable_reactive_store_unsubscribers}

				${
					component.slots.size || component.compile_options.dev || uses_slots
						? b`let { $$slots: #slots = {}, $$scope } = $$props;`
						: null
				}
				${
					component.compile_options.dev &&
					b`@validate_slots('${component.tag}', #slots, [${[...component.slots.keys()]
						.map((key) => `'${key}'`)
						.join(',')}]);`
				}
				${compute_slots}

				${instance_javascript}

				${missing_props_check}
				${unknown_props_check}

				${
					renderer.binding_groups.size > 0 &&
					b`const $$binding_groups = [${[...renderer.binding_groups.keys()].map((_) => x`[]`)}];`
				}

				${component.partly_hoisted}

				${set && b`$$self.$$set = ${set};`}

				${capture_state && b`$$self.$capture_state = ${capture_state};`}

				${inject_state && b`$$self.$inject_state = ${inject_state};`}

				${/* before reactive declarations */ props_inject}

				${
					reactive_declarations.length > 0 &&
					b`
				$$self.$$.update = () => {
					${reactive_declarations}
				};
				`
				}

				${fixed_reactive_declarations}

				${uses_props && b`$$props = @exclude_internal_props($$props);`}

				return ${return_value};
			}
		`);
	}
	const prop_indexes = /** @type {import('estree').ObjectExpression} */ (
		x`{
		${props
			.filter((v) => v.export_name && !v.module)
			.map((v) => p`${v.export_name}: ${renderer.context_lookup.get(v.name).index}`)}
	}`
	);
	let dirty;
	if (renderer.context_overflow) {
		dirty = x`[]`;
		for (let i = 0; i < renderer.context.length; i += 31) {
			/** @type {any} */ (dirty).elements.push(x`-1`);
		}
	}
	const superclass = {
		type: 'Identifier',
		name: options.dev ? '@SvelteComponentDev' : '@SvelteComponent'
	};
	const optional_parameters = [];
	if (should_add_css) {
		optional_parameters.push(add_css);
	} else if (dirty) {
		optional_parameters.push(x`null`);
	}
	if (dirty) {
		optional_parameters.push(dirty);
	}
	const declaration = /** @type {import('estree').ClassDeclaration} */ (
		b`
		class ${name} extends ${superclass} {
			constructor(options) {
				super(${options.dev && 'options'});
				@init(this, options, ${definition}, ${
			has_create_fragment ? 'create_fragment' : 'null'
		}, ${not_equal}, ${prop_indexes}, ${optional_parameters});
				${
					options.dev &&
					b`@dispatch_dev("SvelteRegisterComponent", { component: this, tagName: "${name.name}", options, id: create_fragment.name });`
				}
			}
		}
	`[0]
	);
	push_array(declaration.body.body, accessors);
	body.push(/** @type {any} */ (declaration));
	if (options.customElement) {
		const props_str = writable_props.reduce((def, prop) => {
			def[prop.export_name] =
				component.component_options.customElement?.props?.[prop.export_name] || {};
			if (prop.is_boolean && !def[prop.export_name].type) {
				def[prop.export_name].type = 'Boolean';
			}
			return def;
		}, {});
		const slots_str = [...component.slots.keys()].map((key) => `"${key}"`).join(',');
		const accessors_str = accessors
			.filter(
				(accessor) =>
					accessor.kind === 'get' &&
					!writable_props.some((prop) => prop.export_name === accessor.key.name)
			)
			.map((accessor) => `"${accessor.key.name}"`)
			.join(',');
		const use_shadow_dom =
			component.component_options.customElement?.shadow !== 'none' ? 'true' : 'false';

		const create_ce = x`@create_custom_element(${name}, ${JSON.stringify(
			props_str
		)}, [${slots_str}], [${accessors_str}], ${use_shadow_dom}, ${
			component.component_options.customElement?.extend
		})`;

		if (component.component_options.customElement?.tag) {
			body.push(
				b`@_customElements.define("${component.component_options.customElement.tag}", ${create_ce});`
			);
		} else {
			body.push(b`${create_ce}`);
		}
	}

	if (options.discloseVersion === true) {
		component.imports.unshift({
			type: 'ImportDeclaration',
			specifiers: [],
			source: {
				type: 'Literal',
				value: `${options.sveltePath ?? 'svelte'}/internal/disclose-version`
			}
		});
	}

	return { js: flatten(body), css };
}
