package dev.latvian.mods.kubejs.recipe;

import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.mojang.serialization.Codec;
import com.mojang.serialization.DynamicOps;
import dev.latvian.mods.kubejs.plugin.builtin.wrapper.StringUtilsWrapper;
import dev.latvian.mods.kubejs.recipe.component.ComponentRole;
import dev.latvian.mods.kubejs.recipe.component.RecipeComponent;
import dev.latvian.mods.kubejs.recipe.schema.RecipeOptional;
import dev.latvian.mods.kubejs.recipe.schema.RecipeSchema;
import dev.latvian.mods.kubejs.recipe.schema.RecipeSchemaStorage;
import dev.latvian.mods.kubejs.recipe.schema.RecipeSchemaType;
import dev.latvian.mods.kubejs.util.Cast;
import dev.latvian.mods.rhino.type.TypeInfo;
import org.jspecify.annotations.Nullable;

import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedSet;

/// Represents a typed data key in a recipe schema. This refers to both the literal "key" used in the recipe JSON
/// and to a "holder" that can be used to lookup and set data in the recipe itself. It is also used by recipe constructors
/// in order to determine the order and types of arguments, as well as whether those arguments are optional or not.
///
/// A key is always associated with a [RecipeComponent] which represents the type of data that it holds.
///
/// Each recipe key has set of [#names] that it may be called by in JSON, as well as a [#functionNames] name
/// used in autogenerated methods and a primary [#name] used for serialization.
///
/// A key may also be [#optional], which means that its value may not always be present in JSON and will also not
/// be serialized or written to the value map unless it is declared as [#alwaysWrite].
///
/// By default, each key will have a "builder" method generated for scripts using the first [#functionNames] name.
/// You can disable this by setting [#noFunctions()].
///
/// Finally, some types of components such as items or fluids may perform validation to ensure that they
/// aren't holding empty data.
///
/// @param <T> The type of element held by this key's component
/// @see RecipeSchema
/// @see RecipeComponent
public final class RecipeKey<T> {
	public final RecipeComponent<T> component;
	public final TypeInfo typeInfo;
	public final Codec<T> codec;
	public final String name;
	public final ComponentRole role;
	public final SequencedSet<String> names;
	public @Nullable RecipeOptional<T> optional;
	public boolean excluded;
	public @Nullable List<String> functionNames;
	public boolean alwaysWrite;
	private @Nullable List<String> validFunctionNames;

	public RecipeKey(RecipeComponent<T> component, String name, ComponentRole role) {
		this.component = component;
		this.typeInfo = component.typeInfo();
		this.codec = component.codec();
		this.name = name;
		this.role = role;
		this.names = new LinkedHashSet<>(1);
		this.names.add(name);
		this.optional = null;
		this.excluded = false;
		this.functionNames = null;
		this.alwaysWrite = false;
	}

	@Override
	public int hashCode() {
		return name.hashCode();
	}

	@Override
	public String toString() {
		var sb = new StringBuilder(name);
		sb.append(':');
		sb.append(' ');
		sb.append(component);

		if (optional != null) {
			sb.append('?');
		}

		return sb.toString();
	}

	/// Marks this key as optional, meaning that it can be omitted when deserializing
	/// recipes from JSON and will not be serialized unless it is explicitly set.
	///
	/// @param value The default value of this key; note that the instance supplied here
	///              will be used directly and across multiple recipes, so make sure
	///              to only pass immutable objects!
	/// @apiNote This that this method _does not_ write the default value during
	/// recipe initialization unless [#alwaysWrite] is also set!
	/// @see #optional(RecipeOptional)
	public RecipeKey<T> optional(T value) {
		return optional(RecipeOptional.unit(value));
	}

	/// Marks this key as optional, meaning that it can be omitted when deserializing
	/// recipes from JSON and will not be serialized unless it is explicitly set.
	///
	/// @param value The default value of this key; unlike in [#optional(Object)],
	///              the value will be computed at recipe initialization time, which makes
	///              it safe to pass mutable objects here.
	/// @apiNote This method _does not_ write the default value during
	/// recipe initialization unless [#alwaysWrite] is also set!
	/// @see #optional(RecipeOptional)
	public RecipeKey<T> optional(RecipeOptional<T> value) {
		optional = value;
		return this;
	}

	/// Marks this key as optional, with a default value of `null`.
	///
	/// @implNote Use this in place of regular optional(x) only if the value is dynamic/too complicated to compute from type
	/// @see #optional(RecipeOptional)
	public RecipeKey<T> defaultOptional() {
		optional = Cast.to(RecipeOptional.DEFAULT);
		return this;
	}

	/// Returns true if this key has a defined optional value
	///
	/// @return Whether this key is optional
	public boolean optional() {
		return optional != null;
	}

	/// Adds an alternate name for this key in JSON
	///
	/// @param name Another name this key may be called by
	public RecipeKey<T> alt(String name) {
		names.add(name);
		return this;
	}

	/// Adds multiple alternate names for this key in JSON
	///
	/// @param names A list of names this key may be called by
	public RecipeKey<T> alt(String... names) {
		this.names.addAll(List.of(names));
		return this;
	}

	/// Excludes this key from auto-generated constructors.
	/// **Requires** optional() value to also be set.
	///
	/// This method does nothing if a custom constructor has been set.
	public RecipeKey<T> exclude() {
		excluded = true;
		return this;
	}

	/// Disables the generation of builder functions for this key.
	public RecipeKey<T> noFunctions() {
		functionNames = List.of();
		return this;
	}

	/// Sets a list of names that are used to auto-generate builder functions in JS, e.g. `.xp(value)`.
	/// The first one of these names will be the preferred one that documentation should recommend.
	public RecipeKey<T> functionNames(List<String> names) {
		functionNames = names;
		return this;
	}

	/// Sets a list of names that are used to auto-generate builder functions in JS, e.g. `.xp(value)`.
	/// The first one of these names will be the preferred one that documentation should recommend.
	public RecipeKey<T> functionNames(String... names) {
		return functionNames(List.of(names));
	}

	/// Set this in order to always write optional keys, even if their value hasn't changed.
	///
	/// This can also be used to always populate the value map with a default value for an optional key.
	public RecipeKey<T> alwaysWrite() {
		alwaysWrite = true;
		return this;
	}

	public List<String> getValidFunctionNames() {
		if (validFunctionNames == null) {
			var list = functionNames == null ? names : functionNames;

			validFunctionNames = new ArrayList<>(list.size());

			for (var n : list) {
				n = StringUtilsWrapper.snakeCaseToCamelCase(n.replace(':', '_').replace('/', '_'));

				if (RecipeFunction.isValidIdentifier(n.toCharArray())) {
					validFunctionNames.add(n);
				}
			}

			validFunctionNames = List.copyOf(validFunctionNames);
		}

		return validFunctionNames;
	}

	public String getPrimaryFunctionName() {
		var names = getValidFunctionNames();
		return names.isEmpty() ? "" : names.getFirst();
	}

	public JsonObject toJson(RecipeSchemaStorage storage, RecipeSchemaType type, DynamicOps<JsonElement> ops) {
		var json = new JsonObject();
		json.addProperty("name", name);

		if (!role.isOther()) {
			json.addProperty("role", role.getSerializedName());
		}

		json.add("type", RecipeSchemaStorage.COMPONENT_CODEC.encodeStart(ops, component).getOrThrow());

		if (optional != null) {
			if (optional.isDefault()) {
				json.addProperty("default_optional", true);
			} else {
				json.add("optional", codec.encodeStart(ops, optional.getDefaultValue(type)).getOrThrow());
			}
		}

		if (names.size() > 1) {
			var a = new JsonArray();

			for (var n : names) {
				if (!n.equals(name)) {
					a.add(n);
				}
			}

			json.add("alternative_names", a);
		}

		if (functionNames != null) {
			var a = new JsonArray();
			functionNames.forEach(a::add);
			json.add("function_names", a);
		}

		if (excluded) {
			json.addProperty("excluded", true);
		}

		if (alwaysWrite) {
			json.addProperty("always_write", true);
		}

		return json;
	}
}
