/**
 * Command pattern to allow for editing history
 */

const _commands_events = new Observer();

/** Global Commands Object */
const commands = makeReadOnly(
	{
		/** Current History Index Reader */
		get current() {
			return this._current;
		},
		/** Current History Index (private) */
		_current: -1,
		/**
		 * Command History (private)
		 *
		 * @type {CommandEntry[]}
		 */
		_history: [],
		/** The types of commands we can run (private) */
		_types: {},

		/**
		 * Undoes the last commands in the history
		 *
		 * @param {number} [n] Number of actions to undo
		 */
		async undo(n = 1) {
			for (var i = 0; i < n && this.current > -1; i++) {
				try {
					await this._history[this._current--].undo();
				} catch (e) {
					console.warn("[commands] Failed to undo command");
					console.warn(e);
					this._current++;
					break;
				}
			}
		},
		/**
		 * Redoes the next commands in the history
		 *
		 * @param {number} [n] Number of actions to redo
		 */
		async redo(n = 1) {
			for (var i = 0; i < n && this.current + 1 < this._history.length; i++) {
				try {
					await this._history[++this._current].redo();
				} catch (e) {
					console.warn("[commands] Failed to redo command");
					console.warn(e);
					this._current--;
					break;
				}
			}
		},

		/**
		 * Clears the history
		 */
		async clear() {
			await this.undo(this._history.length);

			this._history.splice(0, this._history.length);

			_commands_events.emit({
				action: "clear",
				state: {},
				current: commands._current,
			});
		},

		/**
		 * Imports an exported command and runs it
		 *
		 * @param {{name: string, title: string, data: any}} exported Exported command
		 */
		async import(exported) {
			await this.runCommand(
				exported.command,
				exported.title,
				{},
				{importData: exported.data}
			);
		},

		/**
		 * Exports all commands in the history
		 */
		async export() {
			return Promise.all(
				this._history.map(async (command) => command.export())
			);
		},

		/**
		 *	Creates a basic command, that can be done and undone
		 *
		 * They must contain a 'run' method that performs the action for the first time,
		 * a 'undo' method that undoes that action and a 'redo' method that does the
		 * action again, but without requiring parameters. 'redo' is by default the
		 * same as 'run'.
		 *
		 * The 'run' and 'redo' functions will receive a 'options' parameter which will be
		 * forwarded directly to the operation, and a 'state' parameter that
		 * can be used to store state for undoing things.
		 *
		 * The 'state' object will be passed to the 'undo' function as well.
		 *
		 * @param {string} name Command identifier (name)
		 * @param {CommandDoCallback} run A method that performs the action for the first time
		 * @param {CommandUndoCallback} undo A method that reverses what the run method did
		 * @param {object} opt Extra options
		 * @param {CommandDoCallback} opt.redo A method that redoes the action after undone (default: run)
		 * @param {(state: any) => any} opt.exportfn A method that exports a serializeable object
		 * @param {(value: any, state: any) => any} opt.importfn A method that imports a serializeable object
		 * @returns {Command}
		 */
		createCommand(name, run, undo, opt = {}) {
			defaultOpt(opt, {
				redo: run,
				exportfn: null,
				importfn: null,
			});

			const command = async function runWrapper(title, options, extra = {}) {
				// Create copy of options and state object
				const copy = {};
				Object.assign(copy, options);
				const state = {};

				defaultOpt(extra, {
					recordHistory: true,
					importData: null,
				});

				const exportfn =
					opt.exportfn ?? ((state) => Object.assign({}, state.serializeable));
				const importfn =
					opt.importfn ??
					((value, state) => (state.serializeable = Object.assign({}, value)));
				const redo = opt.redo;

				/** @type {CommandEntry} */
				const entry = {
					id: guid(),
					title,
					state,
					async export() {
						return {
							command: name,
							title,
							data: await exportfn(state),
						};
					},
					extra: extra.extra,
				};

				if (extra.importData) {
					await importfn(extra.importData, state);
					state.imported = extra.importData;
				}

				// Attempt to run command
				try {
					console.debug(`[commands] Running '${title}'[${name}]`);
					await run(title, copy, state);
				} catch (e) {
					console.warn(
						`[commands] Error while running command '${name}' with options:`
					);
					console.warn(copy);
					console.warn(e);
					return;
				}

				const undoWrapper = async () => {
					console.debug(
						`[commands] Undoing '${title}'[${name}], currently ${this._current}`
					);
					await undo(title, state);
					_commands_events.emit({
						id: entry.id,
						name,
						action: "undo",
						state,
						current: this._current,
					});
				};
				const redoWrapper = async () => {
					console.debug(
						`[commands] Redoing '${title}'[${name}], currently ${this._current}`
					);
					await redo(title, copy, state);
					_commands_events.emit({
						id: entry.id,
						name,
						action: "redo",
						state,
						current: this._current,
					});
				};

				entry.undo = undoWrapper;
				entry.redo = redoWrapper;

				if (!extra.recordHistory) return entry;

				// Add to history
				if (commands._history.length > commands._current + 1) {
					commands._history.forEach((entry, index) => {
						if (index >= commands._current + 1)
							_commands_events.emit({
								id: entry.id,
								name,
								action: "deleted",
								state,
								current: this._current,
							});
					});

					commands._history.splice(commands._current + 1);
				}

				commands._history.push(entry);
				commands._current++;

				_commands_events.emit({
					id: entry.id,
					name,
					action: "run",
					state,
					current: commands._current,
				});

				return entry;
			};

			this._types[name] = command;

			return command;
		},
		/**
		 * Runs a command
		 *
		 * @param {string} name The name of the command to run
		 * @param {string} title The display name of the command on the history panel view
		 * @param {any} options The options to be sent to the command to be run
		 * @param {CommandExtraParams} extra Extra running options
		 * @return {Promise<{undo: () => void, redo: () => void}>} The command's return value
		 */
		async runCommand(name, title, options = null, extra = {}) {
			defaultOpt(extra, {
				recordHistory: true,
				extra: {},
			});
			if (!this._types[name])
				throw new ReferenceError(`[commands] Command '${name}' does not exist`);

			return this._types[name](title, options, extra);
		},
	},
	"commands",
	["_current"]
);

/**
 * Draw Image Command, used to draw a Image to a context
 */
commands.createCommand(
	"drawImage",
	(title, options, state) => {
		if (
			!state.imported &&
			(!options ||
				options.image === undefined ||
				options.x === undefined ||
				options.y === undefined)
		)
			throw "Command drawImage requires options in the format: {image, x, y, w?, h?, layer?}";

		// Check if we have state
		if (!state.layer) {
			/** @type {Layer} */
			let layer = options.layer;
			if (!options.layer && state.layerId)
				layer = imageCollection.layers[state.layerId];

			if (!options.layer && !state.layerId) layer = uil.layer;

			state.layer = layer;
			state.context = layer.ctx;

			if (!state.imported) {
				const canvas = document.createElement("canvas");
				canvas.width = options.image.width;
				canvas.height = options.image.height;
				canvas.getContext("2d").drawImage(options.image, 0, 0);

				state.image = canvas;

				// Saving what was in the canvas before the command
				const imgData = state.context.getImageData(
					options.x,
					options.y,
					options.w || options.image.width,
					options.h || options.image.height
				);
				state.box = {
					x: options.x,
					y: options.y,
					w: options.w || options.image.width,
					h: options.h || options.image.height,
				};
				// Create Image
				const cutout = document.createElement("canvas");
				cutout.width = state.box.w;
				cutout.height = state.box.h;
				cutout.getContext("2d").putImageData(imgData, 0, 0);
				state.original = cutout;
			}
		}

		// Apply command
		state.context.drawImage(
			state.image,
			0,
			0,
			state.image.width,
			state.image.height,
			state.box.x,
			state.box.y,
			state.box.w,
			state.box.h
		);
	},
	(title, state) => {
		// Clear destination area
		state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
		// Undo
		state.context.drawImage(state.original, state.box.x, state.box.y);
	},
	{
		exportfn: (state) => {
			const canvas = document.createElement("canvas");
			canvas.width = state.image.width;
			canvas.height = state.image.height;
			canvas.getContext("2d").drawImage(state.image, 0, 0);

			const originalc = document.createElement("canvas");
			originalc.width = state.original.width;
			originalc.height = state.original.height;
			originalc.getContext("2d").drawImage(state.original, 0, 0);

			return {
				image: canvas.toDataURL(),
				original: originalc.toDataURL(),
				box: state.box,
				layer: state.layer.id,
			};
		},
		importfn: async (value, state) => {
			state.box = value.box;
			state.layerId = value.layer;

			const img = document.createElement("img");
			img.src = value.image;
			await img.decode();

			const imagec = document.createElement("canvas");
			imagec.width = state.box.w;
			imagec.height = state.box.h;
			imagec.getContext("2d").drawImage(img, 0, 0);

			const orig = document.createElement("img");
			orig.src = value.original;
			await orig.decode();

			const originalc = document.createElement("canvas");
			originalc.width = state.box.w;
			originalc.height = state.box.h;
			originalc.getContext("2d").drawImage(orig, 0, 0);

			state.image = imagec;
			state.original = originalc;
		},
	}
);

commands.createCommand(
	"eraseImage",
	(title, options, state) => {
		if (
			!state.imported &&
			(!options ||
				options.x === undefined ||
				options.y === undefined ||
				options.w === undefined ||
				options.h === undefined)
		)
			throw "Command eraseImage requires options in the format: {x, y, w, h, ctx?}";

		if (state.imported) {
			state.layer = imageCollection.layers[state.layerId];
			state.context = state.layer.ctx;
		}

		// Check if we have state
		if (!state.layer) {
			const layer = (options.layer || state.layerId) ?? uil.layer;
			state.layer = layer;
			state.mask = options.mask;
			state.context = layer.ctx;

			// Saving what was in the canvas before the command
			state.box = {
				x: options.x,
				y: options.y,
				w: options.w,
				h: options.h,
			};
			// Create Image
			const cutout = document.createElement("canvas");
			cutout.width = state.box.w;
			cutout.height = state.box.h;
			cutout
				.getContext("2d")
				.drawImage(
					state.context.canvas,
					options.x,
					options.y,
					options.w,
					options.h,
					0,
					0,
					options.w,
					options.h
				);
			state.original = new Image();
			state.original.src = cutout.toDataURL();
		}

		// Apply command
		const style = state.context.fillStyle;
		state.context.fillStyle = "black";

		const op = state.context.globalCompositeOperation;
		state.context.globalCompositeOperation = "destination-out";

		if (state.mask)
			state.context.drawImage(
				state.mask,
				state.box.x,
				state.box.y,
				state.box.w,
				state.box.h
			);
		else
			state.context.fillRect(
				state.box.x,
				state.box.y,
				state.box.w,
				state.box.h
			);

		state.context.fillStyle = style;
		state.context.globalCompositeOperation = op;
	},
	(title, state) => {
		// Clear destination area
		state.context.clearRect(state.box.x, state.box.y, state.box.w, state.box.h);
		// Undo
		state.context.drawImage(state.original, state.box.x, state.box.y);
	},
	{
		exportfn: (state) => {
			let mask = null;

			if (state.mask) {
				const maskc = document.createElement("canvas");
				maskc.width = state.mask.width;
				maskc.height = state.mask.height;
				maskc.getContext("2d").drawImage(state.mask, 0, 0);

				mask = maskc.toDataURL();
			}

			const originalc = document.createElement("canvas");
			originalc.width = state.original.width;
			originalc.height = state.original.height;
			originalc.getContext("2d").drawImage(state.original, 0, 0);

			return {
				original: originalc.toDataURL(),
				mask,
				box: state.box,
				layer: state.layer.id,
			};
		},
		importfn: async (value, state) => {
			state.box = value.box;
			state.layerId = value.layer;

			if (value.mask) {
				const mask = document.createElement("img");
				mask.src = value.mask;
				await mask.decode();

				const maskc = document.createElement("canvas");
				maskc.width = state.box.w;
				maskc.height = state.box.h;
				maskc.getContext("2d").drawImage(mask, 0, 0);

				state.mask = maskc;
			}

			const orig = document.createElement("img");
			orig.src = value.original;
			await orig.decode();

			const originalc = document.createElement("canvas");
			originalc.width = state.box.w;
			originalc.height = state.box.h;
			originalc.getContext("2d").drawImage(orig, 0, 0);

			state.original = originalc;
		},
	}
);
