/* eslint-disable jsdoc/no-undefined-types */
/*
 * On caching.
 *
 * I. We want to cache loaded images.
 * For that, there is Renderer.ImageCaches, "url"-><img> mapping
 *
 * II. We want to cache processed images.
 * For that, there is cachedImage and cachedProcessing in the CompositeLayer.
 * cachedProcessing is JSON string of all processing options.
 * We don't use some global "url + processing" cache because of bloat risk.
 * CanvasModel layers are not recreated on render, so rendering same model instance twice uses caching.
 *
 * III. We want to cache identical keyframes of animation, composed of multiple layers.
 * That's done in the animation code.
 *
 * IV. We want caches to persist between passages (if possible).
 * To do that we reuse same CanvasModel between passages.
 *
 * V. We still need to have separate instances of same model and don't want their caches to intersect.
 * For that, CanvasModels are cached by "slot" optional parameter.
 *
 * =======
 * Example
 * =======
 *
 * In the sidebar <<img>> widget, "main" CanvasModel is rendered using "sidebar" cache slot.
 * Whenever it is requested, it is same CanvasModel instance, so image processing is done only for changing layers.
 *
 * If in some test passage we render 10 more "main" CanvasModels without cache slot, they hold no cached layers and
 * are re-composed. (Source images are still cached globally under their url)
 */

/**
 * @typedef {object} CanvasModelLayer
 * @property {boolean} [show] Show this layer, default false (if no show:true or showfn present, needs explicit <<showlayer>>). Do not use undefined/null/0/"" to hide layer!
 * @property {string} [src] Image path. Either `src` or `srcfn` is required.
 * @property {number} [z] Z-index (rendering order), higher=above, lower=below. Either `z` of `zfn` is required.
 * @property {number} [alpha] Layer opacity, from 0 (invisible) to 1 (opaque, default).
 * @property {boolean} [desaturate] Convert image to grayscale (before recoloring), default false.
 * @property {number} [brightness] Adjust brightness, from -1 to +1 (before recoloring), default 0.
 * @property {number} [contrast] Adjust contrast (before recoloring), default 1.
 * @property {string} [blendMode] Recoloring mode (see docs for globalCompositeOperation; "hard-light", "multiply" and "screen" ), default none.
 * @property {string|object} [blend] Color for recoloring, CSS color string or gradient spec (see model.d.ts).
 * @property {string} [masksrc] Mask image path. If present, only parts where mask is opaque will be displayed.
 * @property {string} [animation] Name of animation to apply, default none.
 * @property {number} [frames] Frame numbers used to display static images, array of subsprite indices. For example, if model frame count is 6 but layer has only 3 subsprites, default frames would be [0, 0, 1, 1, 2, 2].
 * @property {string[]} [filters] Names of filters that should be applied to the layer; filters themselves are taken from model options.
 * @property {number} [dx] Layer X position on the image, default 0.
 * @property {number} [dy] Layer Y position on the image, default 0.
 * @property {number} [width] Layer subsprite width, default = model width.
 * @property {number} [height] Layer subsprite width, default = model height.
 *
 * The following functions can be used instead of constant properties. Their arguments are (options) where options are model options provided in render call (from _modeloptions variable for <<rendermodel>>/<<animatemodel>> widget).
 * @property {Function} [showfn] (options)=>boolean Function generating `show` property. Should return boolean, do not use undefined/null/0/"" to hide layer, use of !! (double not) operator recommended.
 * @property {Function} [srcfn] (options)=>string.
 * @property {Function} [zfn] (options)=>number.
 * @property {Function} [alphafn] (options)=>number.
 * @property {Function} [desaturatefn] (options)=>boolean.
 * @property {Function} [brightnessfn] (options)=>number.
 * @property {Function} [contrastftn] (options)=>number.
 * @property {Function} [blendModefn] (options)=>(string|object).
 * @property {Function} [blendfn] (options)=>string.
 * @property {Function} [masksrcfn] (options)=>string.
 * @property {Function} [animationfn] (options)=>string.
 * @property {Function} [framesfn] (options)=>number[].
 * @property {Function} [filtersfn] (options)=>string[].
 * @property {Function} [dxfn] (options)=>number.
 * @property {Function} [dyfn] (options)=>number.
 * @property {Function} [widthfn] (options)=>number.
 * @property {Function} [heightfn] (options)=>number.
 */

/**
 * @typedef {object} CanvasModelOptions
 * @property {string} name Model name, for debugging.
 * @property {number} width Frame width.
 * @property {number} height Frame height.
 * @property {number} frames Number of frames for CSS animation.
 * @property {Object<string, CanvasModelLayer>} layers Layers (by name).
 * @property {Function} [generatedOptions] Function ()=>string[] names of generated options.
 * @property {Function} [defaultOptions] Function ()=>object returning default options.
 * @property {Function} [preprocess] Preprocessing function (options)=>void to generate temp options.
 */

// Consider doing proper class inheritance
/**
 * @property {string} name Model name, for debugging.
 * @property {number} width Frame width.
 * @property {number} height Frame height.
 * @property {number} frames Number of frames for CSS animation.
 * @property {Function} defaultOptions Function ()=>object returning default options.
 * @property {string[]} generatedOptions Names of generated options.
 * @property {Object<string, CanvasModelLayer>} layers Layers (by name).
 * @property {CanvasModelLayer[]} layerList Layers.
 * @property {CanvasRenderingContext2D} canvas
 */
window.CanvasModel = class CanvasModel {
	/**
	 * @param {CanvasModelOptions} options
	 */
	constructor(options) {
		this.name = options.name;
		this.width = options.width;
		this.height = options.height;
		this.frames = options.frames || 1;
		if ("generatedOptions" in options) this.generatedOptions = options.generatedOptions;
		if ("defaultOptions" in options) this.defaultOptions = options.defaultOptions;
		if ("preprocess" in options) this.preprocess = options.preprocess;
		this.layers = clone(options.layers);
		for (const name in this.layers) {
			if (!Object.hasOwn(this.layers, name)) continue;
			const layer = this.layers[name];
			layer.name = name;
			assignDefaults(layer, {
				show: false, // By default, all layers have to be enabled manually
				brightness: 0.0,
				contrast: 1.0,
				blend: "",
				blendMode: "",
				alpha: 1.0,
				desaturate: false,
			});
			layer.defaultOptions = clone(layer); // deep copy
		}
		this.layerList = Object.values(this.layers);

		// Last used options
		this.options = { filters: {} };
		this.animated = false;
		this.canvas = null;
		this.rendererListener = null;
	}

	generatedOptions() {
		return [];
	}

	defaultOptions() {
		return {
			filters: {},
		};
	}

	createCanvas(cssAnimated) {
		return Renderer.createCanvas(this.width * (cssAnimated ? this.frames : 1), this.height);
	}

	reset() {
		this.options = {};
		for (const layer of this.layerList) {
			// Reset options
			jQuery.extend(true, layer, layer.defaultOptions);
		}
	}

	showLayer(name, filters) {
		const layer = this.layers[name];
		if (!layer) {
			console.error("Layer not found: " + this.name + "/" + name);
			return;
		}
		layer.show = true;
		for (const filter of filters) {
			if (filter === null || filter === undefined) continue; // null & undefined are allowed as "empty filter"
			if (typeof filter !== "object") {
				console.error("Invalid layer " + name + " filter " + typeof filter, filter);
				continue;
			}
			Object.assign(layer, filter);
		}
	}

	hideLayer(name) {
		const layer = this.layers[name];
		if (!layer) {
			console.error("Layer not found: " + this.name + "/" + name);
			return;
		}
		layer.show = false;
	}

	/**
	 * Update layers according to options and render them as static image.
	 *
	 * @param {CanvasRenderingContext2D} canvas Canvas to render on (can be created with {@link createCanvas}).
	 * @param {object} options Options to use when rendering model.
	 * @param {listener} listener For Renderer events.
	 */
	render(canvas, options, listener) {
		if (typeof options === "undefined") options = this.options;
		this.canvas = canvas;
		this.options = options;
		this.listener = listener;
		this.animated = false;
		this.redraw();
	}

	/**
	 * Update layers according to options and animate them.
	 *
	 * @param {CanvasRenderingContext2D} canvas Canvas to render on (can be created with {@link createCanvas}).
	 * @param {object} options Options to use when rendering model.
	 * @param {listener} listener For Renderer events.
	 * @returns {AnimatingCanvas} AnimatingCanvas object.
	 */
	animate(canvas, options, listener) {
		this.canvas = canvas;
		this.options = options;
		this.listener = listener;
		this.animated = true;
		return this.redraw();
	}

	/**
	 * Redraw the model onto same canvas.
	 */
	redraw() {
		if (!this.canvas) {
			Errors.report("CanvasModel.redraw() called but model was never rendered!");
			return;
		}
		Renderer.lastModel = this;
		if (this.animated) {
			return Renderer.animateLayers(this.canvas, this.compile(this.options), this.listener, true);
		} else {
			return Renderer.composeLayers(this.canvas, this.compile(this.options), this.canvas.canvas.width / this.width, this.listener);
		}
	}

	/**
	 * Pre-process options. Typically you calculate some expression here and store them as generated options
	 * Override in subclass.
	 *
	 * @param {options} options Model options.
	 */
	preprocess(options) {}

	/**
	 * Compile list of layers according to options.
	 *
	 * @param {options} options Model options.
	 * @returns {CompositeLayerSpec[]} Layers.
	 */
	compile(options) {
		const debug = V.debug;
		if (!options) options = { filters: {} };
		if (!("filters" in options)) options.filters = {};
		try {
			this.preprocess(options);
		} catch (e) {
			console.error(e);
			throw new Error("Error in model preprocessing: " + e.stack);
		}
		for (const layer of this.layerList) {
			// Reset some options
			layer.brightness = layer.defaultOptions.brightness;
			layer.contrast = layer.defaultOptions.contrast;
		}

		function propeval(layer, propname) {
			if (propname !== "show" && !debug && !layer.show) {
				// Situation A:
				// layer.srcfn: () => 'img/items/' + V.item.name + '.png'
				// and if V.item is undefined layer is skipped
				// So we don't want to eval skipped layer here
				//
				// Situation B:
				// Layer is skipped by mistake.
				// We want to debug its properties and show manually
				// So we might want to eval it still
				//
				// This is why we eval all properties in debug mode, but ignore their errors
				return;
			}
			const fnkey = propname + "fn";
			if (fnkey in layer) {
				try {
					layer[propname] = layer[fnkey](options);
				} catch (e) {
					if (layer.show) {
						console.error("Error evaluating layer " + layer.name + " property " + propname);
					}
				}
			}
		}

		for (const layer of this.layerList) {
			layer.show || propeval(layer, "show");
			propeval(layer, "src");
			if (!layer.src) {
				layer.src = ""; // force string value
				layer.show = false;
			}
			propeval(layer, "z");
			if (typeof layer.z !== "number" && layer.show !== false) console.error("Layer " + layer.name + " missing property z");
			propeval(layer, "alpha");
			propeval(layer, "blendMode");
			propeval(layer, "blend");
			propeval(layer, "desaturate");
			propeval(layer, "brightness");
			propeval(layer, "contrast");
			propeval(layer, "masksrc");
			propeval(layer, "animation");
			propeval(layer, "filters");
			propeval(layer, "dx");
			propeval(layer, "dy");
			propeval(layer, "width");
			propeval(layer, "height");
			if (layer.show !== false && layer.filters) {
				for (const filterName of layer.filters) {
					const filter = options.filters[filterName];
					if (!filter) {
						// console.warn("Layer " + layer.name + " needs filter " + filterName + " but it is not provided");
						continue;
					}
					Renderer.mergeLayerData(layer, filter, true);
				}
			}
		}
		return this.layerList;
	}
};

/**
 * @type {Object<string, CanvasModelOptions>}
 */
Renderer.CanvasModels = {};
/**
 * @type {Object<string, Object<string, CanvasModel>>}
 */
Renderer.CanvasModelCaches = {};
/**
 * Find or create new CanvasModel.
 *
 * @param {string} modelName CanvasModel name in Renderer.CanvasModels.
 * @param {string} [slot] Cache id to speed up rendering between passages.
 * @returns {CanvasModel}
 */
Renderer.locateModel = function (modelName, slot) {
	const options = Renderer.CanvasModels[modelName];
	if (!options) {
		Errors.report("Requested non-existing model " + modelName);
		return new CanvasModel({ name: "empty", width: 1, height: 1, layers: {} });
	}
	if (!slot) {
		return new CanvasModel(options);
	} else {
		let cache = Renderer.CanvasModelCaches[modelName];
		if (!cache) {
			cache = {};
			Renderer.CanvasModelCaches[modelName] = cache;
		}
		let model = cache[slot];
		if (model) return model;
		model = new CanvasModel(options);
		cache[slot] = model;
		return model;
	}
};

/**
 * Copies to targets keys from source that are not present there.
 * Shallow.
 *
 * @param {object} target Object to extend.
 * @param {object} source Default properties.
 * @returns {object} Target.
 */
function assignDefaults(target, source) {
	for (const k in source) {
		if (!Object.hasOwn(source, k)) continue;
		if (!(k in target)) target[k] = source[k];
	}
	return target;
}