import Path2D from "./../geometries/path2d.js";
import { Color } from "./../math/color.ts";
import { Matrix3d } from "../math/matrix3d.ts";
import { Vector2d } from "../math/vector2d.ts";
import { CANVAS_ONRESIZE, emit } from "../system/event.ts";
import { createCanvas } from "./canvas_factory.js";
import { Gradient } from "./gradient.js";
import RenderState from "./renderstate.js";
import CanvasRenderTarget from "./rendertarget/canvasrendertarget.js";

/**
 * @import RenderTargetPool from "./rendertarget/render_target_pool.js";
 */

/**
 * @import {Rect} from "./../geometries/rectangle.ts";
 * @import {RoundRect} from "./../geometries/roundrect.ts";
 * @import {Polygon} from "../geometries/polygon.ts";
 * @import {Line} from "./../geometries/line.ts";
 * @import {Ellipse} from "./../geometries/ellipse.ts";
 * @import {Bounds} from "./../physics/bounds.ts";
 */

/**
 * a base renderer object
 * @category Rendering
 */
export default class Renderer {
	/**
	 * @param {ApplicationSettings} [options] - optional parameters for the renderer
	 */
	constructor(options) {
		/**
		 * The renderer renderTarget
		 * @name renderTarget
		 * @type {CanvasRenderTarget}
		 */
		this.renderTarget = new CanvasRenderTarget(
			options.width,
			options.height,
			// support case when a global canvas is available, e.g. webapp adapter for wechat
			typeof globalThis.canvas !== "undefined"
				? Object.assign(options, { canvas: globalThis.canvas })
				: options,
		);

		/**
		 * The given constructor options
		 * @public
		 * @type {object}
		 */
		this.settings = options;

		/**
		 * the requested video size ratio
		 * @public
		 * @type {number}
		 */
		this.designRatio = this.settings.width / this.settings.height;

		/**
		 * the scaling ratio to be applied to the main canvas
		 * @type {Vector2d}
		 * @default <1,1>
		 */
		this.scaleRatio = new Vector2d(this.settings.scale, this.settings.scale);

		/**
		 * true if the current rendering context is valid
		 * @default true
		 * @type {boolean}
		 */
		this.isContextValid = true;

		/**
		 * The GPU device identifier string (set by GPU renderers — WebGL today,
		 * WebGPU once it lands; undefined for Canvas).
		 * @type {string|undefined}
		 */
		this.GPURenderer = undefined;

		/**
		 * an optional custom shader to use instead of the default one.
		 * Set by a renderable's preDraw when a shader is assigned.
		 * (GPU renderers only; ignored by Canvas renderer)
		 * @type {GLShader|ShaderEffect|undefined}
		 * @ignore
		 */
		this.customShader = undefined;

		/**
		 * The render target pool for post-effect processing (ping-pong FBOs).
		 * Initialized by GPU renderers (WebGL, WebGPU). Null on Canvas renderer.
		 * @type {RenderTargetPool|null}
		 * @ignore
		 */
		this._renderTargetPool = null;

		/**
		 * The Path2D instance used by the renderer to draw primitives
		 * @type {Path2D}
		 */
		this.path2D = new Path2D();

		/**
		 * The renderer type : Canvas, WebGL, etc...
		 * (override this property with a specific value when implementing a custom renderer)
		 * @type {string}
		 */
		this.type = "Generic";

		/**
		 * The background color used to clear the main framebuffer.
		 * Note: alpha value will be set based on the transparent property of the renderer settings.
		 * @default black
		 * @type {Color}
		 */
		this.backgroundColor = new Color(
			0,
			0,
			0,
			this.settings.transparent ? 0.0 : 1.0,
		);

		/**
		 * The renderer state container (color, tint, transform, scissor, blend mode)
		 * with a zero-allocation save/restore stack.
		 * @type {RenderState}
		 */
		this.renderState = new RenderState();

		// expose renderState properties directly (same references)
		this.currentColor = this.renderState.currentColor;
		this.currentTint = this.renderState.currentTint;
		this.currentScissor = this.renderState.currentScissor;

		/**
		 * The thickness of lines for shape drawing operations like
		 * {@link Renderer#strokeRect}, {@link Renderer#strokeEllipse}
		 * and {@link Renderer#strokeLine}. Subclasses override the
		 * storage — `CanvasRenderer` proxies it to the underlying 2D
		 * context via a getter/setter, while `WebGLRenderer` treats it
		 * as a regular field consumed by its shape-stroke routines.
		 * @type {number}
		 * @default 1
		 */
		this.lineWidth = 1;

		/**
		 * @ignore
		 */
		this.maskLevel = 0;

		// the projectionMatrix (set through setProjection)
		this.projectionMatrix = new Matrix3d();

		// default uvOffset
		this.uvOffset = 0;

		/**
		 * The normal-map texture associated with the next `drawImage` call,
		 * if any. Set by `Sprite.draw` (and any other normal-map-aware
		 * renderable) just before calling `drawImage`, then cleared back
		 * to `null` after. The WebGL renderer reads this state and routes
		 * lit quads through the shader's lighting path; the Canvas
		 * renderer ignores it entirely.
		 * @type {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas|ImageBitmap|null}
		 */
		this.currentNormalMap = null;

		/**
		 * Number of active `Light2d` instances uploaded to the lit batcher
		 * for the current frame. Set by `setLightUniforms`. The WebGL
		 * renderer's `drawImage` reads this to decide between the unlit
		 * fast-path batcher (default) and the lit batcher (only when
		 * lights AND a normal map are both in play).
		 * @type {number}
		 */
		this.activeLightCount = 0;
	}

	/**
	 * @type {string}
	 */
	get currentBlendMode() {
		return this.renderState.currentBlendMode;
	}

	set currentBlendMode(value) {
		this.renderState.currentBlendMode = value;
	}

	/**
	 * Current per-renderable depth value. GPU batchers (WebGL today,
	 * WebGPU once it lands) push it into the vertex stream as the `z`
	 * component of each vertex — a no-op under the default orthographic
	 * projection, used by perspective (Camera3d) to
	 * scale and parallax sprites by distance. Mirrors `renderable.depth`,
	 * set automatically by `Renderable.preDraw` via {@link Renderer#setDepth}.
	 * @type {number}
	 * @default 0
	 */
	get currentDepth() {
		return this.renderState.currentDepth;
	}

	set currentDepth(value) {
		this.renderState.currentDepth = value;
	}

	/**
	 * return the height of the canvas which this renderer draws to
	 * @returns {number} height of the system Canvas
	 */
	get height() {
		return this.getCanvas().height;
	}

	set height(value) {
		this.resize(this.width, value);
	}

	/**
	 * return the width of the canvas which this renderer draws to
	 * @returns {number} width of the system Canvas
	 */
	get width() {
		return this.getCanvas().width;
	}

	set width(value) {
		this.resize(value, this.height);
	}

	/**
	 * prepare the framebuffer for drawing a new frame
	 */
	clear() {}

	/**
	 * render the main framebuffer on screen
	 */
	flush() {}

	/**
	 * Draw a textured triangle mesh.
	 * The mesh object must provide: `vertices` (Float32Array, x/y/z triplets),
	 * `uvs` (Float32Array, u/v pairs), `indices` (Uint16Array, triangle indices),
	 * `texture` (TextureAtlas), `vertexCount` (number), and optionally
	 * `cullBackFaces` (boolean, default true).
	 * WebGL uses hardware depth testing; Canvas uses painter's algorithm (back-to-front sort).
	 * @param {Mesh} mesh - a Mesh renderable or compatible object
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	drawMesh(mesh) {}

	/**
	 * Reset context state
	 */
	reset() {
		this.renderState.reset(this.width, this.height);
		this.resetTransform();
		this.setBlendMode(this.settings.blendMode);
		this.setColor("#000000");
		this.clearTint();
		this.cache.clear();
		this.clearMask();
	}

	/**
	 * Release any renderer-owned resources. Default is a no-op;
	 * subclasses override to tear down batchers, FBO pools, GL
	 * programs, etc. Called by {@link Application#destroy}.
	 */
	destroy() {}

	/**
	 * return a reference to the current render target corresponding canvas which this renderer draws to
	 * @returns {HTMLCanvasElement}
	 */
	getCanvas() {
		return this.renderTarget.canvas;
	}

	/**
	 * Create and return a new Canvas element (or `OffscreenCanvas` when
	 * supported and `returnOffscreenCanvas` is true). Centralized
	 * renderer-side allocator so every scratch / fallback / render-
	 * target canvas in the engine routes through the same
	 * `OffscreenCanvas`-aware path, instead of duplicating
	 * `document.createElement` calls that throw in worker contexts.
	 * @param {number} width - canvas width in pixels
	 * @param {number} height - canvas height in pixels
	 * @param {boolean} [returnOffscreenCanvas=false] - return an
	 *   `OffscreenCanvas` if the platform supports it
	 * @returns {HTMLCanvasElement|OffscreenCanvas} a new canvas of the given size
	 */
	static createCanvas(width, height, returnOffscreenCanvas = false) {
		return createCanvas(width, height, returnOffscreenCanvas);
	}

	/**
	 * Shared 1×1 fully-white canvas used as a no-op texture fallback.
	 * Renderers and renderables that need a "blank" texture binding
	 * (e.g. to satisfy a shader's sampler input when there's no real
	 * image — Kd-only `Mesh` materials, solid-color quad fills, etc.)
	 * should use this rather than allocating their own.
	 *
	 * Lazily created on first call; shared across every caller; uses
	 * `OffscreenCanvas` where supported (worker-safe). Static so it's
	 * accessible without a renderer instance (e.g. from a `Mesh`
	 * constructor that runs before the active renderer is set).
	 * @returns {HTMLCanvasElement|OffscreenCanvas} the shared 1×1 white canvas
	 */
	static getWhitePixel() {
		if (Renderer._whitePixel === null) {
			const c = Renderer.createCanvas(1, 1, true);
			const ctx = c.getContext("2d");
			if (ctx === null) {
				throw new Error(
					"Renderer.getWhitePixel: 2D context unavailable on the allocated canvas",
				);
			}
			ctx.fillStyle = "#ffffff";
			ctx.fillRect(0, 0, 1, 1);
			Renderer._whitePixel = c;
		}
		return Renderer._whitePixel;
	}

	/**
	 * return a reference to the current render target corresponding Context
	 * @returns {CanvasRenderingContext2D|WebGLRenderingContext}
	 */
	getContext() {
		return this.renderTarget.context;
	}

	/**
	 * return the list of supported compressed texture formats.
	 * The base implementation returns null for all formats (no GPU
	 * compressed texture support). GPU renderers (WebGL today, WebGPU
	 * once it lands) override this with the backend-specific extension /
	 * feature query.
	 * @returns {Object} an object with one key per extension family, each value is the backend's compressed-texture handle or null
	 */
	getSupportedCompressedTextureFormats() {
		return {
			astc: null,
			bptc: null,
			s3tc: null,
			s3tc_srgb: null,
			pvrtc: null,
			etc1: null,
			etc2: null,
		};
	}

	/**
	 * return true if the given compressed texture format is supported
	 * @param {number} format - a WebGL compressed texture format constant
	 * @returns {boolean}
	 */
	hasSupportedCompressedFormats(format) {
		const supportedFormats = this.getSupportedCompressedTextureFormats();
		for (const supportedFormat in supportedFormats) {
			const entry = supportedFormats[supportedFormat];
			if (entry === null || typeof entry === "undefined") {
				continue;
			}
			for (const extension in entry) {
				if (format === entry[extension]) {
					return true;
				}
			}
		}
		return false;
	}

	/**
	 * Begin capturing rendering to an offscreen buffer for post-effect processing.
	 * Call endPostEffect() after rendering to blit the result to the screen.
	 * No-op on Canvas renderer.
	 * @param {Renderable} renderable - the renderable with postEffects to apply
	 * @returns {boolean} false (Canvas renderer does not support post-effect processing)
	 * @ignore
	 */
	beginPostEffect(renderable) {
		// on Canvas, only set customShader for single-effect fast path
		const effects = renderable.postEffects.filter((fx) => {
			return fx.enabled !== false;
		});
		if (effects.length === 1) {
			this.customShader = effects[0];
		} else {
			this.customShader = undefined;
		}
		return false;
	}

	/**
	 * End post-effect capture and blit the offscreen buffer to the screen
	 * through the renderable's post-effects.
	 * No-op on Canvas renderer.
	 * @param {Renderable} renderable - the renderable with postEffects to apply
	 * @ignore
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	endPostEffect(renderable) {}

	/**
	 * Blit a texture to the screen through a shader effect.
	 * Draws a screen-aligned quad using the given texture as source
	 * and the given shader for post-processing (e.g. scanlines, desaturation).
	 * No-op on Canvas renderer.
	 * @param {WebGLTexture} source - the source texture to blit
	 * @param {number} x - destination x position
	 * @param {number} y - destination y position
	 * @param {number} width - destination width
	 * @param {number} height - destination height
	 * @param {ShaderEffect} shader - the shader effect to apply
	 * @param {boolean} [keepBlend=false] - if true, keep current blend mode (for sprite compositing)
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	blitEffect(source, x, y, width, height, shader, keepBlend) {}

	/**
	 * Sets the viewport for the renderer.
	 * Defines the affine transformation from normalized device coordinates to window coordinates.
	 * No-op on Canvas renderer.
	 * @param {number} [x=0] - x coordinate of the viewport origin
	 * @param {number} [y=0] - y coordinate of the viewport origin
	 * @param {number} [w] - width of the viewport (defaults to canvas width)
	 * @param {number} [h] - height of the viewport (defaults to canvas height)
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	setViewport(x = 0, y = 0, w, h) {}

	/**
	 * Clear the current render target to transparent black (color + stencil).
	 * Used to prepare FBOs for post-effect capture.
	 * No-op on Canvas renderer.
	 */
	clearRenderTarget() {}

	/**
	 * Enable the scissor test with the given rectangle.
	 * No-op on Canvas renderer.
	 * @param {number} x - x coordinate of the scissor rectangle
	 * @param {number} y - y coordinate of the scissor rectangle
	 * @param {number} width - width of the scissor rectangle
	 * @param {number} height - height of the scissor rectangle
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	enableScissor(x, y, width, height) {}

	/**
	 * Disable the scissor test, allowing rendering to the full viewport.
	 * No-op on Canvas renderer.
	 */
	disableScissor() {}

	/**
	 * Enable or disable alpha blending.
	 * No-op on Canvas renderer (Canvas always blends).
	 * @param {boolean} enable - true to enable blending, false to disable
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	setBlendEnabled(enable) {}

	/**
	 * returns the current blend mode for this renderer
	 * @returns {string} blend mode
	 */
	getBlendMode() {
		return this.currentBlendMode;
	}

	/**
	 * set the current blend mode.
	 * Subclasses (CanvasRenderer, WebGLRenderer, WebGPURenderer once it
	 * lands) implement the actual backend logic.
	 * @param {string} [mode="normal"] - blend mode
	 * @param {boolean} [premultipliedAlpha=true] - whether textures use premultiplied alpha (GPU renderers only)
	 */
	setBlendMode(mode = "normal") {
		this.currentBlendMode = mode;
	}

	/**
	 * Upload the active scene lights to the lit sprite pipeline.
	 *
	 * Called once per camera per frame by `Camera2d.draw()` (after the
	 * FBO is bound, before the world tree walk fires `Sprite.draw` for
	 * any normal-mapped sprite). GPU renderers (WebGL today, WebGPU once
	 * it lands) override this to pack the lights into the lit shader's
	 * uniform buffers; the Canvas renderer cannot do per-pixel normal-map
	 * lighting and silently ignores the call. The first time a non-empty
	 * light list is passed in Canvas mode, a one-shot console warning is
	 * emitted.
	 *
	 * Stage stays renderer-agnostic by passing the raw scene data —
	 * lights iterable and ambient color — and letting the renderer
	 * decide how to encode them.
	 * @param {Iterable<object>} [lights] - active `Light2d` instances; falsy/empty no-ops
	 * @param {object} [ambient] - ambient lighting color (0..255 RGB)
	 * @param {number} [translateX=0] - world-to-screen X translate (matches `Camera2d.draw()`)
	 * @param {number} [translateY=0] - world-to-screen Y translate
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	setLightUniforms(lights, ambient, translateX, translateY) {
		if (this._litPipelineWarned || !lights) {
			return;
		}
		// detect "non-empty" via `Set.size`, array length, or by iterating
		const hasAny =
			(typeof lights.size === "number" && lights.size > 0) ||
			(typeof lights.length === "number" && lights.length > 0) ||
			(typeof lights[Symbol.iterator] === "function" &&
				!lights[Symbol.iterator]().next().done);
		if (hasAny) {
			this._litPipelineWarned = true;
			console.warn(
				"melonJS: Light2d normal-map lighting requires the WebGL renderer; " +
					"the Canvas fallback renders sprites without per-pixel lighting. " +
					"Switch to `video.WEBGL` or `video.AUTO` to enable the lit pipeline.",
			);
		}
	}

	/**
	 * Render a `Light2d` instance.
	 *
	 * Each renderer implements its own strategy: the WebGL renderer
	 * draws lights as quads through a shared procedural radial-falloff
	 * fragment shader (no per-light texture, color and intensity
	 * encoded in the per-vertex tint so consecutive draws batch); the
	 * Canvas renderer caches a small `Gradient` config object per
	 * light in a `WeakMap` (rebuilt only when the light's radii /
	 * color / intensity change), rasterizes it with `Gradient.toCanvas()`
	 * into a single shared `CanvasRenderTarget`, and composites the
	 * result via `drawImage`. The base implementation is a no-op so
	 * renderers without a lighting path can be polymorphically
	 * substituted.
	 *
	 * Light2d itself is renderer-agnostic — it just calls
	 * `renderer.drawLight(this)` and relies on the renderer to pick
	 * the right machinery.
	 * @param {object} light - the Light2d instance to render
	 * @see Light2d
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	drawLight(light) {
		// base no-op; concrete renderers override
	}

	/**
	 * Draw a TMX tile layer. Default behavior:
	 *   - if `layer.canvasRenderer` is set (preRender bake), blit the cached
	 *     offscreen canvas in a single `drawImage` call;
	 *   - otherwise delegate to the layer's TMX orientation renderer for
	 *     the per-tile loop.
	 *
	 * `WebGLRenderer` overrides this to add the procedural shader fast
	 * path on top (when `layer.renderMode === "shader"`) and fall through
	 * to this base behavior for all other layers.
	 * @param {object} layer - the TMXLayer to draw
	 * @param {Rect} rect - the visible region in world coords
	 */
	drawTileLayer(layer, rect) {
		if (layer.canvasRenderer) {
			// clamp the source rect to the cached canvas bounds — the
			// visible region can start inside the layer (camera scrolled
			// in from the origin) and extend past the layer's right /
			// bottom edge, so the source width must be reduced by
			// `rect.pos.*` to avoid reading past the canvas
			const width = Math.min(rect.width, layer.width - rect.pos.x);
			const height = Math.min(rect.height, layer.height - rect.pos.y);
			if (width <= 0 || height <= 0) {
				return;
			}
			this.drawImage(
				layer.canvasRenderer.getCanvas(),
				rect.pos.x,
				rect.pos.y,
				width,
				height,
				rect.pos.x,
				rect.pos.y,
				width,
				height,
			);
			return;
		}
		layer.getRenderer().drawTileLayer(this, layer, rect);
	}

	/**
	 * Set the current fill & stroke style color.
	 * By default, or upon reset, the value is set to #000000.
	 * @param {Color|string|Gradient} color - css color value or a Gradient object
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	setColor(color) {
		// implemented by subclasses
	}

	/**
	 * get the current fill & stroke style color.
	 * @returns {Color} current global color
	 */
	getColor() {
		return this.currentColor;
	}

	/**
	 * Create a linear gradient that can be used with {@link Renderer#setColor}.
	 * @param {number} x0 - x-axis coordinate of the start point
	 * @param {number} y0 - y-axis coordinate of the start point
	 * @param {number} x1 - x-axis coordinate of the end point
	 * @param {number} y1 - y-axis coordinate of the end point
	 * @returns {Gradient} a Gradient object
	 */
	createLinearGradient(x0, y0, x1, y1) {
		return new Gradient("linear", [x0, y0, x1, y1]);
	}

	/**
	 * Create a radial gradient that can be used with {@link Renderer#setColor}.
	 * @param {number} x0 - x-axis coordinate of the start circle
	 * @param {number} y0 - y-axis coordinate of the start circle
	 * @param {number} r0 - radius of the start circle
	 * @param {number} x1 - x-axis coordinate of the end circle
	 * @param {number} y1 - y-axis coordinate of the end circle
	 * @param {number} r1 - radius of the end circle
	 * @returns {Gradient} a Gradient object
	 */
	createRadialGradient(x0, y0, r0, x1, y1, r1) {
		return new Gradient("radial", [x0, y0, r0, x1, y1, r1]);
	}

	/**
	 * Set the line dash pattern for stroke operations.
	 * @param {number[]} segments - an array of numbers specifying distances to alternately draw a line and a gap. An empty array clears the dash pattern (solid lines).
	 * @example
	 * // draw a dashed line
	 * renderer.setLineDash([10, 5]);
	 * renderer.strokeLine(0, 0, 100, 0);
	 * // clear the dash pattern
	 * renderer.setLineDash([]);
	 */
	setLineDash(segments) {
		// skip allocation if the pattern hasn't changed
		const current = this.renderState.lineDash;
		if (
			segments.length === current.length &&
			segments.every((v, i) => {
				return v === current[i];
			})
		) {
			return;
		}
		this.renderState.lineDash = segments.filter((v) => {
			return Number.isFinite(v) && v >= 0;
		});
	}

	/**
	 * Get the current line dash pattern.
	 * @returns {number[]} the current dash pattern
	 */
	getLineDash() {
		return this.renderState.lineDash;
	}

	/**
	 * return the current global alpha
	 * @returns {number}
	 */
	globalAlpha() {
		return this.currentColor.alpha;
	}

	/**
	 * check if the given rect or bounds overlaps with the renderer screen coordinates
	 * @param {Rect|Bounds} bounds
	 * @returns {boolean} true if overlaps
	 */
	overlaps(bounds) {
		return (
			bounds.left <= this.width &&
			bounds.right >= 0 &&
			bounds.top <= this.height &&
			bounds.bottom >= 0
		);
	}

	/**
	 * resizes the system canvas
	 * @param {number} width - new width of the canvas
	 * @param {number} height - new height of the canvas
	 */
	resize(width, height) {
		const canvas = this.getCanvas();
		if (width !== canvas.width || height !== canvas.height) {
			canvas.width = width;
			canvas.height = height;
			this.currentScissor[0] = 0;
			this.currentScissor[1] = 0;
			this.currentScissor[2] = width;
			this.currentScissor[3] = height;
			// publish the corresponding event
			emit(CANVAS_ONRESIZE, width, height);
		}
	}

	/**
	 * enable/disable image smoothing (scaling interpolation) for the current render target
	 * @param {boolean} [enable=false]
	 */
	setAntiAlias(enable = false) {
		if (this.settings.antiAlias !== enable) {
			this.settings.antiAlias = enable;
			this.renderTarget.setAntiAlias(enable);
		}
	}

	/**
	 * set/change the current projection matrix (GPU renderers only —
	 * the Canvas renderer applies projection via the 2D context's transform stack).
	 * @param {Matrix3d} matrix
	 */
	setProjection(matrix) {
		this.projectionMatrix.copy(matrix);
	}

	/**
	 * stroke the given shape
	 * @param {Rect|RoundRect|Polygon|Line|Ellipse|Bounds} shape - a shape object to stroke
	 * @param {boolean} [fill=false] - fill the shape with the current color if true
	 */
	stroke(shape, fill) {
		switch (shape.type) {
			// RoundRect
			case "RoundRect":
				if (fill) {
					this.fillRoundRect(
						shape.left,
						shape.top,
						shape.width,
						shape.height,
						shape.radius,
					);
				} else {
					this.strokeRoundRect(
						shape.left,
						shape.top,
						shape.width,
						shape.height,
						shape.radius,
					);
				}
				break;

			// Rect or Bounds
			case "Rectangle":
			case "Bounds":
				if (fill) {
					this.fillRect(shape.left, shape.top, shape.width, shape.height);
				} else {
					this.strokeRect(shape.left, shape.top, shape.width, shape.height);
				}
				break;

			// Polygon or Line
			case "Polygon":
			case "Line":
				if (fill) {
					this.fillPolygon(shape);
				} else {
					this.strokePolygon(shape);
				}
				break;

			case "Ellipse":
				if (shape.angle !== 0) {
					this.save();
					this.translate(shape.pos.x, shape.pos.y);
					this.rotate(shape.angle);
					if (fill) {
						this.fillEllipse(0, 0, shape.radiusV.x, shape.radiusV.y);
					} else {
						this.strokeEllipse(0, 0, shape.radiusV.x, shape.radiusV.y);
					}
					this.restore();
				} else if (fill) {
					this.fillEllipse(
						shape.pos.x,
						shape.pos.y,
						shape.radiusV.x,
						shape.radiusV.y,
					);
				} else {
					this.strokeEllipse(
						shape.pos.x,
						shape.pos.y,
						shape.radiusV.x,
						shape.radiusV.y,
					);
				}
				break;

			// Point
			case "Point":
				this.strokePoint(shape.x, shape.y);
				break;

			default:
				throw new Error("Invalid geometry for fill/stroke");
		}
	}

	/**
	 * fill the given shape
	 * @param {Rect|RoundRect|Polygon|Line|Ellipse|Bounds} shape - a shape object to fill
	 */
	fill(shape) {
		this.stroke(shape, true);
	}

	/**
	 * tint the given image or canvas using the given color
	 * @param {HTMLImageElement|HTMLCanvasElement|OffscreenCanvas} src - the source image to be tinted
	 * @param {Color|string} color - the color that will be used to tint the image
	 * @param {string} [mode="multiply"] - the composition mode used to tint the image
	 * @returns {HTMLCanvasElement|OffscreenCanvas} a new canvas or offscreencanvas (if supported) element representing the tinted image
	 */
	tint(src, color, mode = "multiply") {
		const attributes = {
			context: "2d",
			offscreenCanvas: true,
			transparent: true,
			antiAlias: this.settings.antiAlias,
		};
		const canvasTexture = new CanvasRenderTarget(
			src.width,
			src.height,
			attributes,
		);
		const context = canvasTexture.context;

		context.fillStyle = color instanceof Color ? color.toRGB() : color;
		context.fillRect(0, 0, src.width, src.height);

		context.globalCompositeOperation = mode;
		context.drawImage(src, 0, 0);
		context.globalCompositeOperation = "destination-atop";
		context.drawImage(src, 0, 0);

		return canvasTexture.canvas;
	}

	/**
	 * Push the current transform / alpha / clip state onto an internal
	 * stack. Pair with {@link Renderer#restore}. Implemented by subclasses.
	 */
	save() {}

	/**
	 * Pop the topmost saved state from the stack, restoring transform,
	 * alpha and clip. Pair with {@link Renderer#save}. Implemented by
	 * subclasses.
	 */
	restore() {}

	/**
	 * Translate the current transform by `(x, y)`. Implemented by subclasses.
	 * @param {number} x - horizontal translation in pixels
	 * @param {number} y - vertical translation in pixels
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	translate(x, y) {}

	/**
	 * Rotate the current transform by `angle` (radians). Implemented by subclasses.
	 * @param {number} angle - rotation angle in radians
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	rotate(angle) {}

	/**
	 * Scale the current transform by `(x, y)`. Implemented by subclasses.
	 * @param {number} x - horizontal scale factor
	 * @param {number} y - vertical scale factor
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	scale(x, y) {}

	/**
	 * Set the renderer global alpha (0..1) for subsequent draw calls.
	 * Implemented by subclasses.
	 * @param {number} alpha - opacity value in the [0..1] range
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	setGlobalAlpha(alpha) {}

	/**
	 * Fill an axis-aligned rectangle with the current color.
	 * Implemented by subclasses.
	 * @param {number} x - left edge in viewport pixels
	 * @param {number} y - top edge in viewport pixels
	 * @param {number} width - rectangle width
	 * @param {number} height - rectangle height
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	fillRect(x, y, width, height) {}

	/**
	 * Stroke (outline) an axis-aligned rectangle with the current color
	 * at the current {@link Renderer#lineWidth}. Implemented by subclasses.
	 * @param {number} x - left edge in viewport pixels
	 * @param {number} y - top edge in viewport pixels
	 * @param {number} width - rectangle width
	 * @param {number} height - rectangle height
	 * @param {boolean} [fill=false] - also fill the rectangle
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	strokeRect(x, y, width, height, fill) {}

	/**
	 * Fill an axis-aligned ellipse with the current color.
	 * Implemented by subclasses.
	 * @param {number} x - ellipse center X in viewport pixels
	 * @param {number} y - ellipse center Y in viewport pixels
	 * @param {number} w - horizontal radius
	 * @param {number} h - vertical radius
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	fillEllipse(x, y, w, h) {}

	/**
	 * Stroke (outline) an axis-aligned ellipse with the current color
	 * at the current {@link Renderer#lineWidth}. Implemented by subclasses.
	 * @param {number} x - ellipse center X in viewport pixels
	 * @param {number} y - ellipse center Y in viewport pixels
	 * @param {number} w - horizontal radius
	 * @param {number} h - vertical radius
	 * @param {boolean} [fill=false] - also fill the ellipse
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	strokeEllipse(x, y, w, h, fill) {}

	/**
	 * Draw a straight line between two points with the current color
	 * at the current {@link Renderer#lineWidth}. Implemented by subclasses.
	 * @param {number} startX - start X in viewport pixels
	 * @param {number} startY - start Y in viewport pixels
	 * @param {number} endX - end X in viewport pixels
	 * @param {number} endY - end Y in viewport pixels
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	strokeLine(startX, startY, endX, endY) {}

	/**
	 * Draw an image or sub-image into the framebuffer.
	 * Implemented by subclasses (`CanvasRenderer` / `WebGLRenderer` /
	 * future `WebGPURenderer`).
	 *
	 * The 9-argument signature shown here is the canonical form used
	 * throughout the engine; the concrete renderers also accept the
	 * shorter `drawImage(image, dx, dy)` and `drawImage(image, dx, dy,
	 * dWidth, dHeight)` variants from the HTML Canvas2D spec.
	 * @param {HTMLImageElement|SVGImageElement|HTMLVideoElement|HTMLCanvasElement|ImageBitmap|OffscreenCanvas|VideoFrame} image - the source image
	 * @param {number} sx - source rectangle left
	 * @param {number} sy - source rectangle top
	 * @param {number} sw - source rectangle width
	 * @param {number} sh - source rectangle height
	 * @param {number} dx - destination left
	 * @param {number} dy - destination top
	 * @param {number} dw - destination width
	 * @param {number} dh - destination height
	 */
	// eslint-disable-next-line no-unused-vars, @typescript-eslint/no-unused-vars
	drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh) {}

	/**
	 * A mask limits rendering elements to the shape and position of the given mask object.
	 * So, if the renderable is larger than the mask, only the intersecting part of the renderable will be visible.
	 * Mask are not preserved through renderer context save and restore.
	 * @param {Rect|RoundRect|Polygon|Line|Ellipse} [mask] - the shape defining the mask to be applied
	 * @param {boolean} [invert=false] - either the given shape should define what is visible (default) or the opposite
	 */
	setMask() {}

	/**
	 * disable (remove) the rendering mask set through setMask.
	 * @see Renderer#setMask
	 */
	clearMask() {}

	/**
	 * set a coloring tint for sprite based renderables
	 * @param {Color} tint - the tint color
	 * @param {number} [alpha] - an alpha value to be applied to the tint
	 */
	setTint(tint, alpha = tint.alpha) {
		// global tint color
		this.currentTint.copy(tint);
		this.currentTint.alpha *= alpha;
	}

	/**
	 * clear the rendering tint set through setTint.
	 * @see Renderer#setTint
	 */
	clearTint() {
		// reset to default
		this.currentTint.setFloat(1.0, 1.0, 1.0, 1.0);
	}

	/**
	 * Set the current per-renderable depth value. GPU batchers (WebGL
	 * today, WebGPU once it lands) push it into the vertex stream as the
	 * `z` component of each vertex — a no-op under the default
	 * orthographic projection, used by perspective (Camera3d) to scale
	 * and parallax sprites by distance.
	 *
	 * Typically called automatically by `Renderable.preDraw` from the
	 * renderable's `depth` property. User code only needs to call this
	 * directly when emitting draw calls outside of a `Renderable.draw()`
	 * — e.g. from a custom `Container.draw()` override.
	 *
	 * Honored by the save/restore stack like `setTint` / `setColor`.
	 * @param {number} depth - the depth value to set
	 * @see Renderable#depth
	 */
	setDepth(depth) {
		this.currentDepth = depth;
	}

	/**
	 * creates a Blob object representing the last rendered frame
	 * @param {string} [type="image/png"] - A string indicating the image format
	 * @param {number} [quality] - A Number between 0 and 1 indicating the image quality to be used when creating images using file formats that support lossy compression (such as image/jpeg or image/webp). A user agent will use its default quality value if this option is not specified, or if the number is outside the allowed range.
	 * @returns {Promise} A Promise returning a Blob object representing the last rendered frame
	 * @example
	 * renderer.convertToBlob().then((blob) => console.log(blob));
	 */
	toBlob(type = "image/png", quality) {
		return this.renderTarget.toBlob(type, quality);
	}

	/**
	 * creates an ImageBitmap object of the last frame rendered
	 * (not supported by standard Canvas)
	 * @param {string} [type="image/png"] - A string indicating the image format
	 * @param {number} [quality] - A Number between 0 and 1 indicating the image quality to be used when creating images using file formats that support lossy compression (such as image/jpeg or image/webp). A user agent will use its default quality value if this option is not specified, or if the number is outside the allowed range.
	 * @returns {Promise} A Promise returning an ImageBitmap.
	 * @example
	 * renderer.transferToImageBitmap().then((image) => console.log(image));
	 */
	toImageBitmap(type = "image/png", quality) {
		return this.renderTarget.toImageBitmap(type, quality);
	}

	/**
	 * returns a data URL containing a representation of the last frame rendered
	 * @param {string} [type="image/png"] - A string indicating the image format
	 * @param {number} [quality] - A Number between 0 and 1 indicating the image quality to be used when creating images using file formats that support lossy compression (such as image/jpeg or image/webp). A user agent will use its default quality value if this option is not specified, or if the number is outside the allowed range.
	 * @returns {Promise} A Promise returning a string containing the requested data URL.
	 * @example
	 * renderer.toDataURL().then((dataURL) => console.log(dataURL));
	 */
	toDataURL(type = "image/png", quality) {
		return this.renderTarget.toDataURL(type, quality);
	}
}

// Backing field for `Renderer.getWhitePixel()` — declared outside the
// class body so it's initialized to null at module load (static class
// fields aren't universally supported in our transpile target).
Renderer._whitePixel = null;
