import { warning } from "../../lang/console.js";
import { vector2dPool } from "../../math/vector2d.ts";
import { collision } from "../../physics/collision.js";
import Container from "../../renderable/container.js";
import { off, on, VIEWPORT_ONRESIZE } from "../../system/event.ts";
import pool from "../../system/legacy_pool.js";
import { checkVersion } from "../../utils/utils.ts";
import { COLLISION_GROUP, TILED_SUPPORTED_VERSION } from "./constants.js";
import { getNewTMXRenderer } from "./renderer/autodetect.js";
import TMXGroup from "./TMXGroup.js";
import TMXLayer from "./TMXLayer.js";
import { createTMXObject } from "./TMXObjectFactory.js";
import TMXTileset from "./TMXTileset.js";
import TMXTilesetGroup from "./TMXTilesetGroup.js";
import {
	applyObjectOpacity,
	applyTMXProperties,
	parseTintColor,
	propagateBlendMode,
	resolveEmbeddedImage,
	tiledBlendMode,
} from "./TMXUtils.js";

/**
 * read the layer Data
 * @ignore
 */
function readLayer(map, data, z) {
	return new TMXLayer(
		map,
		data,
		map.tilewidth,
		map.tileheight,
		map.orientation,
		map.tilesets,
		z,
	);
}

/**
 * read the Image Layer Data
 * @ignore
 */
function readImageLayer(map, data, z) {
	// resolve embedded JSON image data if present
	resolveEmbeddedImage(data);

	// Normalize properties
	applyTMXProperties(data.properties, data);

	// derive repeat mode: legacy custom "repeat" property takes precedence
	// (for backward compatibility), then Tiled 1.8+ native repeatx/repeaty
	const repX = data.repeatx === true || data.repeatx === "1";
	const repY = data.repeaty === true || data.repeaty === "1";
	let repeat = data.properties?.repeat;
	if (typeof repeat === "undefined" && (repX || repY)) {
		if (repX && repY) {
			repeat = "repeat";
		} else if (repX) {
			repeat = "repeat-x";
		} else {
			repeat = "repeat-y";
		}
	}

	// Tiled 1.8+ parallax origin (map-level, baked into layer offset)
	const pox = +(map.data.parallaxoriginx ?? 0);
	const poy = +(map.data.parallaxoriginy ?? 0);
	const ratioX = +(data.parallaxx ?? 1.0);
	const ratioY = +(data.parallaxy ?? 1.0);
	const ox = +(data.offsetx ?? data.x ?? 0) + pox * ratioX;
	const oy = +(data.offsety ?? data.y ?? 0) + poy * ratioY;

	// create the layer
	const imageLayer = pool.pull(
		"ImageLayer",
		ox,
		oy,
		Object.assign(
			{
				name: data.name,
				image: data.image,
				ratio: vector2dPool.get(ratioX, ratioY),
				tint: parseTintColor(data.tintcolor),
				z: z,
				repeat: repeat,
			},
			data.properties,
		),
	);

	// set some additional flags
	const visible = data.visible ?? true;
	imageLayer.setOpacity(visible ? +data.opacity : 0);

	// layer blend mode (Tiled 1.12+)
	imageLayer.blendMode = tiledBlendMode(data.mode);

	return imageLayer;
}

/**
 * read the tileset Data
 * @ignore
 */
function readTileset(data, mapTilewidth, mapTileheight) {
	return new TMXTileset(data, mapTilewidth, mapTileheight);
}

/**
 * read the object group Data
 * @ignore
 */
function readObjectGroup(map, data, z) {
	return new TMXGroup(map, data, z);
}

/**
 * Recursively recompute absolute bounds for a container and all its
 * descendants. Used when the container's pos changes (centering on a wider
 * viewport): children cache their absolute bounds at addChild time, and
 * those caches are stale until each child happens to call updateBounds on
 * its own (which only happens organically when its own pos changes).
 * @ignore
 */
function refreshAbsoluteBounds(container) {
	container.forEach((child) => {
		child.updateBounds(true);
		if (child instanceof Container) {
			refreshAbsoluteBounds(child);
		}
	});
}

/**
 * a TMX Tile Map Object
 * Tiled QT +0.7.x format
 * @category Tilemap
 */
export default class TMXTileMap {
	/**
	 * @param {string} levelId - name of TMX map
	 * @param {object} data - TMX map in JSON format
	 * @example
	 * // create a new level object based on the TMX JSON object
	 * let level = new me.TMXTileMap(levelId, me.loader.getTMX(levelId));
	 * // add the level to the game world container
	 * level.addTo(app.world, true);
	 */
	constructor(levelId, data) {
		/**
		 * the level data (JSON)
		 * @ignore
		 */
		this.data = data;

		/**
		 * name of the tilemap
		 * @type {string}
		 */
		this.name = levelId;

		/**
		 * width of the tilemap in tiles
		 * @type {number}
		 */
		this.cols = +data.width;

		/**
		 * height of the tilemap in tiles
		 * @type {number}
		 */
		this.rows = +data.height;

		/**
		 * Tile width
		 * @type {number}
		 */
		this.tilewidth = +data.tilewidth;

		/**
		 * Tile height
		 * @type {number}
		 */
		this.tileheight = +data.tileheight;

		/**
		 * is the map an infinite map
		 * @type {number}
		 * @default 0
		 */
		this.infinite = +data.infinite || 0;

		/**
		 * the map orientation type. melonJS supports “orthogonal”, “isometric”, “staggered”, “hexagonal” and “oblique”.
		 * @type {string}
		 * @default “orthogonal”
		 */
		this.orientation = data.orientation;

		/**
		 * horizontal skew in pixels per tile row (oblique maps, since Tiled 1.12)
		 * @type {number}
		 * @default 0
		 */
		this.skewx = +(data.skewx ?? 0);

		/**
		 * vertical skew in pixels per tile column (oblique maps, since Tiled 1.12)
		 * @type {number}
		 * @default 0
		 */
		this.skewy = +(data.skewy ?? 0);

		/**
		 * the order in which tiles on orthogonal tile layers are rendered.
		 * (valid values are "left-down", "left-up", "right-down", "right-up")
		 * @type {string}
		 * @default "right-down"
		 */
		this.renderorder = data.renderorder || "right-down";

		/**
		 * the TMX format version
		 * @type {string}
		 */
		this.version = "" + data.version;

		/**
		 * The Tiled version used to save the file (since Tiled 1.0.1).
		 * @type {string}
		 */
		this.tiledversion = "" + data.tiledversion;

		/**
		 * The map class.
		 * @type {string}
		 */
		this.class = data.class;

		// tilesets for this map
		this.tilesets = null;

		// layers
		if (typeof this.layers === "undefined") {
			this.layers = [];
		}
		// group objects
		if (typeof this.objectGroups === "undefined") {
			this.objectGroups = [];
		}

		// Check if map is from melon editor
		this.isEditor = data.editor === "melon-editor";

		// object id
		this.nextobjectid = +data.nextobjectid || undefined;

		// hex/iso properties
		this.hexsidelength = +data.hexsidelength;
		this.staggeraxis = data.staggeraxis;
		this.staggerindex = data.staggerindex;

		// calculate the map bounding rect
		this.bounds = this.getRenderer().getBounds().clone();

		// map "real" size
		this.width = this.bounds.width;
		this.height = this.bounds.height;

		// background color
		this.backgroundcolor = data.backgroundcolor;

		// if version is undefined or empty it usually means the map was not created with Tiled
		if (this.version !== "undefined" && this.version !== "") {
			// deprecation warning if map tiled version is older than 1.5
			if (checkVersion(this.version, "1.5") < 0) {
				warning(
					`(${this.name}) Tiled Map format version 1.4 and below`,
					"format 1.5 or higher",
					"10.4.4",
				);
			}
			// warning if map version is newer than what we currently support
			if (checkVersion(this.version, TILED_SUPPORTED_VERSION) > 0) {
				console.warn(
					`(${this.name}) Tiled Map format version ${this.version} is newer than the currently supported version ${TILED_SUPPORTED_VERSION}. Some features may not work as expected.`,
				);
			}
		}

		// set additional map properties (if any)
		applyTMXProperties(this, data);

		// internal flag
		this.initialized = false;
	}

	/**
	 * Return the map default renderer
	 * @returns {TMXRenderer} a TMX renderer
	 */
	getRenderer() {
		if (
			typeof this.renderer === "undefined" ||
			!this.renderer.canRender(this)
		) {
			this.renderer = getNewTMXRenderer(this);
		}
		return this.renderer;
	}

	/**
	 * return the map bounding rect
	 * @returns {Bounds}
	 */
	getBounds() {
		// calculated in the constructor
		return this.bounds;
	}

	/**
	 * parse the map
	 * @ignore
	 */
	readMapObjects(data) {
		if (this.initialized === true) {
			return;
		}

		// to automatically increment z index
		let zOrder = 0;

		// Tileset information
		if (!this.tilesets) {
			// make sure we have a TilesetGroup Object
			this.tilesets = new TMXTilesetGroup();
		}

		// parse all tileset objects
		if (typeof data.tilesets !== "undefined") {
			const tilesets = data.tilesets;
			for (let i = 0, len = tilesets.length; i < len; i++) {
				this.tilesets.add(
					readTileset(tilesets[i], this.tilewidth, this.tileheight),
				);
			}
		}

		// check if a background image is defined
		if (this.background_image) {
			// add a new image layer
			this.layers.push(
				pool.pull("ImageLayer", 0, 0, {
					name: "background_image",
					image: this.background_image,
					z: zOrder++,
				}),
			);
		}

		const layers = data.layers;
		for (let i = 0, len = layers.length; i < len; i++) {
			const layer = layers[i];
			switch (layer.type) {
				case "imagelayer":
					this.layers.push(readImageLayer(this, layer, zOrder++));
					break;

				case "tilelayer":
					this.layers.push(readLayer(this, layer, zOrder++));
					break;

				case "objectgroup":
				case "group":
					this.objectGroups.push(readObjectGroup(this, layer, zOrder++));
					break;

				default:
					break;
			}
		}

		this.initialized = true;
	}

	/**
	 * add all the map layers and objects to the given container.
	 * note : this will not automatically update the camera viewport
	 * @param {Container} container - target container
	 * @param {boolean} [flatten=true] - if true, flatten all objects into the given container, else a `me.Container` object will be created for each corresponding groups
	 * @param {boolean} [setViewportBounds=false] - if true, set the viewport bounds to the map size, this should be set to true especially if adding a level to the game world container.
	 * @example
	 * // create a new level object based on the TMX JSON object
	 * let level = new me.TMXTileMap(levelId, me.loader.getTMX(levelId));
	 * // add the level to the game world container
	 * level.addTo(app.world, true, true);
	 */
	addTo(container, flatten, setViewportBounds) {
		const _sort = container.autoSort;
		const _depth = container.autoDepth;

		const levelBounds = this.getBounds();

		// disable auto-sort and auto-depth
		container.autoSort = false;
		container.autoDepth = false;

		if (this.backgroundcolor) {
			container.backgroundColor.parseCSS(this.backgroundcolor);
		}

		// add all layers instances
		this.getLayers().forEach((layer) => {
			container.addChild(layer);
		});

		// add all Object instances
		this.getObjects(flatten).forEach((object) => {
			container.addChild(object);
		});

		// resize the container accordingly
		container.resize(this.bounds.width, this.bounds.height);

		// sort everything (recursively)
		container.sort(true);

		/**
		 * callback funtion for the viewport resize event
		 * @ignore
		 */
		if (setViewportBounds === true) {
			const app = container.getRootAncestor().app;

			function _setBounds(width, height) {
				// adjust the viewport bounds if level is smaller
				app.viewport.setBounds(
					0,
					0,
					Math.max(levelBounds.width, width),
					Math.max(levelBounds.height, height),
				);
				// center the map if smaller than the current viewport
				const newX = Math.max(0, ~~((width - levelBounds.width) / 2));
				const newY = Math.max(0, ~~((height - levelBounds.height) / 2));
				if (container.pos.x !== newX || container.pos.y !== newY) {
					container.pos.set(newX, newY, container.pos.z);
					// A child caches its absolute bounds (centered around
					// ancestor.absPos at the time of the last `updateBounds`).
					// When the parent's pos changes, descendants that don't
					// move on their own (TMX layers, static collision
					// objects, triggers) keep stale absolute bounds, so
					// anything that reads them (debug overlay, viewport
					// culling) sees the pre-centering position. Walk the
					// tree once after a real move and refresh.
					refreshAbsoluteBounds(container);
				}
			}

			off(VIEWPORT_ONRESIZE, _setBounds);
			// force viewport bounds update
			_setBounds(app.viewport.width, app.viewport.height);
			// Replace the resize handler
			on(VIEWPORT_ONRESIZE, _setBounds);
		}

		//  set back auto-sort and auto-depth
		container.autoSort = _sort;
		container.autoDepth = _depth;
	}

	/**
	 * Return an Array of instantiated objects, based on the map object definition.
	 * Objects are created using the Tiled object factory registry, which dispatches
	 * to the appropriate factory based on the object's class, name, or structure.
	 * Custom factories can be registered via {@link registerTiledObjectFactory} or
	 * {@link registerTiledObjectClass}.
	 * @param {boolean} [flatten=true] - if true, flatten all objects into the returned array.
	 * when false, a `me.Container` object will be created for each corresponding groups
	 * @returns {Renderable[]} Array of Objects
	 */
	getObjects(flatten) {
		const objects = [];
		let targetContainer;

		// parse the map for objects
		this.readMapObjects(this.data);

		for (let g = 0; g < this.objectGroups.length; g++) {
			const group = this.objectGroups[g];

			// check if this is the collision shape group
			const isCollisionGroup = group.name
				.toLowerCase()
				.includes(COLLISION_GROUP);

			if (flatten === false) {
				// create a new container
				targetContainer = new Container(0, 0, this.width, this.height);

				// tiled uses 0,0 by default
				targetContainer.anchorPoint.set(0, 0);

				// set additional properties
				targetContainer.name = group.name;
				targetContainer.pos.z = group.z;
				targetContainer.setOpacity(group.opacity);
				targetContainer.blendMode = group.blendMode;

				// disable auto-sort and auto-depth
				targetContainer.autoSort = false;
				targetContainer.autoDepth = false;
			}

			// iterate through the group and add all objects into their
			// corresponding target Container
			for (let o = 0; o < group.objects.length; o++) {
				const settings = group.objects[o];

				// Tiled uses 0,0 by default
				if (typeof settings.anchorPoint === "undefined") {
					settings.anchorPoint = { x: 0, y: 0 };
				}
				// convert to melonJS renderable argument name
				if (typeof settings.tintcolor !== "undefined") {
					settings.tint = parseTintColor(settings.tintcolor);
				}

				// instantiate the object based on its type
				const obj = createTMXObject(settings, this);

				// configure collision on unnamed objects in collision groups.
				// Factory output uses `bodyDef` (engine-portable, declarative).
				// We rewrite the def fields so when the world auto-registers
				// the body, it picks up WORLD_SHAPE / static. Legacy objects
				// that still carry a pre-constructed `body` are patched
				// in-place for backward compatibility.
				if (isCollisionGroup && !settings.name) {
					if (obj.bodyDef) {
						obj.bodyDef.collisionType = collision.types.WORLD_SHAPE;
						obj.bodyDef.type = "static";
					} else if (obj.body) {
						obj.body.collisionType = collision.types.WORLD_SHAPE;
						obj.body.isStatic = true;
					}
				}

				// apply per-object opacity and visibility (skip TMXLayer)
				if (obj.isRenderable === true && !(settings instanceof TMXLayer)) {
					if (!settings.visible) {
						obj.setOpacity(0);
						if (
							typeof obj.renderable !== "undefined" &&
							obj.renderable.isRenderable === true
						) {
							obj.renderable.setOpacity(0);
						}
					} else if (settings.opacity < 1) {
						applyObjectOpacity(obj, settings.opacity);
					}
				}

				// propagate group blend mode to child objects
				propagateBlendMode(obj, group.blendMode);

				if (flatten !== false) {
					// apply group opacity when flattened
					if (obj.isRenderable === true) {
						applyObjectOpacity(obj, group.opacity);
					}
					objects.push(obj);
				} else {
					targetContainer.addChild(obj);
				}
			}

			// if we created a new container with children
			if (flatten === false && targetContainer.getChildren().length > 0) {
				// re-enable auto-sort and auto-depth
				targetContainer.autoSort = true;
				targetContainer.autoDepth = true;

				// add our container to the world
				objects.push(targetContainer);
			}
		}
		return objects;
	}

	/**
	 * return all the existing layers
	 * @returns {TMXLayer[]} Array of Layers
	 */
	getLayers() {
		// parse the map for objects
		this.readMapObjects(this.data);
		return this.layers;
	}

	/**
	 * destroy function, clean all allocated objects
	 */
	destroy() {
		this.tilesets = undefined;
		this.layers.length = 0;
		this.objectGroups.length = 0;
		this.initialized = false;
	}
}
