"""SC Controller - Background

Changes SVG on the fly and uptates that magnificent image on background with it.
Also supports clicking on areas defined in SVG image.
"""

from __future__ import annotations

import logging
import re
from collections import OrderedDict
from copy import deepcopy
from math import cos, sin
from math import pi as PI
from typing import TYPE_CHECKING
from xml.etree import ElementTree as ET

import gi

if TYPE_CHECKING:
	from typing import Self

	import gi.repository._Gdk3 as Gdk
	import gi.repository._Gtk3 as Gtk
	from gi.repository import GdkPixbuf, GObject, Rsvg
else:
	gi.require_version("Rsvg", "2.0")
	gi.require_version("Gdk", "3.0")
	gi.require_version("Gtk", "3.0")
	from gi.repository import Gdk, GdkPixbuf, GObject, Gtk, Rsvg
#gi.require_version("Rsvg", "2.0")
#gi.require_version("Gdk", "3.0")
#gi.require_version("Gtk", "3.0")
#from gi.repository import Gdk, GdkPixbuf, GObject, Gtk, Rsvg

# sys.modules.pop('xml.etree.ElementTree', None)
# sys.modules['_elementtree'] = None
# ET = importlib.import_module('xml.etree.ElementTree')

log = logging.getLogger("Background")
ET.register_namespace("", "http://www.w3.org/2000/svg")


class SVGWidget(Gtk.EventBox):
	FILENAME = "background.svg"
	CACHE_SIZE = 50

	__gsignals__ = {
		# Raised when mouse is over defined area
		"hover": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
		# Raised when mouse leaves all defined areas
		"leave": (GObject.SignalFlags.RUN_FIRST, None, ()),
		# Raised user clicks on defined area
		"click": (GObject.SignalFlags.RUN_FIRST, None, (object,)),
	}

	def __init__(self, filename: str, init_hilighted: bool = True) -> None:
		Gtk.EventBox.__init__(self)
		self.cache = OrderedDict()
		self.areas: list[Area] = []
		self.current_svg: str | bytes

		self.connect("motion-notify-event", self.on_mouse_moved)
		self.connect("button-press-event", self.on_mouse_click)
		self.set_events(Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.BUTTON_PRESS_MASK)

		self.size_override: tuple[int, int] | None = None
		self.image_width: float = 1
		self.image_height: float = 1
		self.set_image(filename)
		self.image: Gtk.Image = Gtk.Image()
		if init_hilighted:
			self.hilight({})
		self.add(self.image)
		self.show_all()

	def set_image(self, filename: str) -> None:
		with open(filename) as file:
			self.current_svg = file.read()
		self.cache = OrderedDict()
		self.areas = []
		self.parse_image()

	def parse_image(self) -> None:
		"""Goes trough SVG image, searches for all rects named 'AREA_SOMETHING' and generates area list from it.

		This area list is later used to determine over which button is mouse hovering.
		"""
		tree = ET.fromstring(self.current_svg.encode("utf-8"))
		SVGWidget.find_areas(tree, None, self.areas)
		self.image_width = float(tree.attrib["width"])
		self.image_height = float(tree.attrib["height"])

	def resize(self, width: int, height: int) -> None:
		"""Overrides image size.

		Doesn't keep aspect ratio and causes cache to be flushed, so this may be slow and nasty.
		"""
		self.size_override = width, height
		self.cache = OrderedDict()

	def on_mouse_click(self, trash, event) -> None:
		area = self.on_mouse_moved(trash, event)
		if area is not None:
			self.emit("click", area)

	def on_mouse_moved(self, trash, event) -> str | None:
		"""Not actual signal handler, just called from App."""
		x_offset = (self.get_allocation().width - self.image_width) / 2
		x = event.x - x_offset
		y = event.y
		for a in self.areas:
			# *TEST areas exist only to bound the Input Test cursor (looked up
			# by id via get_area_position), not as hover targets. Skip them so
			# they can't shadow a real control area — on the Deck the stick/dpad
			# TEST areas otherwise swallowed the hover and nothing highlighted.
			if a.name.endswith("TEST"):
				continue
			if a.contains(x, y):
				self.emit("hover", a.name)
				return a.name
		self.emit("leave")
		return None

	def get_area(self, id: str) -> Area | None:
		for a in self.areas:
			if a.name == id:
				return a
		return None

	def get_all_by_prefix(self, prefix: str) -> list[Area]:
		"""Searchs for areas using specific prefix.

		For prefix "AREA_", returns self.areas arrray. For anything else,
		re-parses current image and searches recursively for anything that matches, so it
		may be a good idea to not call this too often.
		"""
		if prefix == "AREA_":
			return self.areas
		lst: list[Area] = []
		tree = ET.fromstring(self.current_svg.encode("utf-8"))
		SVGWidget.find_areas(tree, None, lst, prefix=prefix)
		return lst

	def get_area_position(self, area_id: str) -> tuple[float, float, float, float]:
		"""Computes and returns area position on image as (x, y, width, height).

		Raises ValueError if such area is not found.
		"""
		# TODO: Maybe cache this?
		a = self.get_area(area_id)
		if a:
			return a.x, a.y, a.w, a.h
		raise ValueError(f"Area '{area_id}' not found")

	def get_viewbox(self) -> tuple[float, float, float, float]:
		"""Returns the SVG viewBox as (min_x, min_y, width, height).

		Used to map area coordinates (SVG document space) onto the rendered
		image when overlaying widgets such as the Input-Test cursor: a non-zero
		origin (e.g. headroom added with viewBox="0 -45 ...") otherwise shifts
		those overlays. Returns (0, 0, 0, 0) if no viewBox is present.
		"""
		svg = self.current_svg
		if isinstance(svg, bytes):
			svg = svg.decode("utf-8", "replace")
		m = re.search(
			r'viewBox\s*=\s*["\']\s*([-\d.eE]+)[\s,]+([-\d.eE]+)[\s,]+([-\d.eE]+)[\s,]+([-\d.eE]+)',
			svg or "",
		)
		if m:
			return tuple(float(g) for g in m.groups())
		return (0.0, 0.0, 0.0, 0.0)

	@staticmethod
	def find_areas(
		xml: ET.Element[str], parent_transform, areas: list[Area], get_colors: bool = False, prefix: str = "AREA_"
	) -> None:
		"""Recursively searches through XML for anything with ID of 'AREA_SOMETHING'

		Mutates passed `area` list by appending the results.
		"""
		for child in xml:
			child_transform = SVGEditor.matrixmul(
				parent_transform or SVGEditor.IDENTITY,
				SVGEditor.parse_transform(child),
			)
			if str(child.attrib.get("id")).startswith(prefix):
				# log.debug("Found SVG area %s", child.attrib['id'][5:])
				a = Area(child, child_transform)
				if get_colors:
					a.color = None
					if "style" in child.attrib:
						style = {y[0]: y[1] for y in [x.split(":", 1) for x in child.attrib["style"].split(";")]}
						if "fill" in style:
							a.color = SVGWidget.color_to_float(style["fill"])
				areas.append(a)
			else:
				SVGWidget.find_areas(child, child_transform, areas, get_colors=get_colors, prefix=prefix)

	def get_rect_area(self, element):
		"""Returns x, y, width and height of rect element relative to document root.

		element can be specified by its id.
		"""
		if type(element) is str:
			tree = ET.fromstring(self.current_svg.encode("utf-8"))
			# SVGEditor.update_parents(tree)
			element = SVGEditor.get_element(tree, element)
		width, height = 0, 0
		x, y = SVGEditor.get_translation(element, absolute=True)
		if "width" in element.attrib:
			width = float(element.attrib["width"])
		if "height" in element.attrib:
			height = float(element.attrib["height"])

		return x, y, width, height

	@staticmethod
	def color_to_float(colorstr: str) -> tuple[float, float, float, float]:
		"""Parses color expressed as RRGGBB (as in config)

		Each RGBA color part is a float of range 0 to 1
		"""
		color = Gdk.RGBA()
		if color.parse("#" + colorstr.strip("#")):
			return color.red, color.green, color.blue, color.alpha
		return 1, 0, 1, 1  # ugly purple

	def hilight(self, buttons) -> None:
		"""Hilights specified button, if same ID is found in svg"""
		cache_id = "|".join([f"{x}:{buttons[x]}" for x in buttons])
		if cache_id not in self.cache:
			# Ok, this is close to madness, but probably better than drawing 200 images by hand
			if len(buttons) == 0:
				# Quick way out - changes are not needed
				tmp = self.current_svg.encode("utf-8") if type(self.current_svg) is str else self.current_svg
				svg = Rsvg.Handle.new_from_data(tmp)
			else:
				# 1st, parse source as XML
				tree = ET.fromstring(self.current_svg)
				# 2nd, change colors of some elements
				for button in buttons:
					el = SVGEditor.find_by_id(tree, button)
					if el is not None:
						SVGEditor.recolor(el, buttons[button])

				# 3rd, turn it back into XML string......
				xml = ET.tostring(tree)

				# ... and now, parse that as XML again......
				svg = Rsvg.Handle.new_from_data(xml)
			while len(self.cache) >= self.CACHE_SIZE:
				self.cache.popitem(False)
			pixbuf = svg.get_pixbuf_and_error()
			if pixbuf is None:
				raise ValueError("Failed to render SVG to a pixbuf")
			if self.size_override:
				w, h = self.size_override
				self.cache[cache_id] = pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.BILINEAR)
			else:
				self.cache[cache_id] = pixbuf

		self.image.set_from_pixbuf(self.cache[cache_id])

	def get_pixbuf(self):
		"""Returns pixbuf of current image"""
		return self.image.get_pixbuf()

	def edit(self) -> SVGEditor:
		"""Returns new Editor instance bound to this widget"""
		return SVGEditor(self)


class Area:
	SPECIAL_CASES = (
		"LSTICK_UP",
		"LSTICK_DOWN",
		"LSTICK_LEFT",
		"LSTICK_RIGHT",
		"RSTICK_UP",
		"RSTICK_DOWN",
		"RSTICK_LEFT",
		"RSTICK_RIGHT",
		"DPAD_UP",
		"DPAD_DOWN",
		"DPAD_LEFT",
		"DPAD_RIGHT",
		"ABS",
		"MOUSE",
		"MINUSHALF",
		"PLUSHALF",
		"KEY",
	)

	"""Basicaly just a rectangle with a name"""

	def __init__(self, element: ET.Element, transform: list[list[float]]) -> None:
		self.color: tuple[float, float, float, float] | None
		self.name: str = element.attrib["id"].split("_")[1]
		# Check if this is an area name that images/buttons.svg uses
		#
		# LSTICK, RSTICK and DPAD are explicitly catching with all possible movements to allow them being used as
		#  buttons throughout the code without conflicting with buttons.svg
		# Hopefully this isn't used anywhere else
		if self.name in Area.SPECIAL_CASES:
			self.name = "_".join(element.attrib["id"].split("_")[1:3])
		width = float(element.attrib.get("width", 0))
		height = float(element.attrib.get("height", 0))
		corners = (
			SVGEditor.transform_point(transform, 0, 0),
			SVGEditor.transform_point(transform, width, 0),
			SVGEditor.transform_point(transform, 0, height),
			SVGEditor.transform_point(transform, width, height),
		)
		xs = [point[0] for point in corners]
		ys = [point[1] for point in corners]
		self.x = min(xs)
		self.y = min(ys)
		self.w = max(xs) - self.x
		self.h = max(ys) - self.y

	def contains(self, x, y):
		return x >= self.x and y >= self.y and x <= self.x + self.w and y <= self.y + self.h

	def __str__(self) -> str:
		return f"<Area {self.x},{self.y} {self.w}x{self.h}>"


class SVGEditor:
	"""Allows some basic edit operations by parsing SVG into dom tree and doing unholy mess on that.

	Constructed by SVGWidget.edit(), updates original SVGWidget when commit() is called.
	"""

	RE_PARSE_TRANSFORM = re.compile(r"([a-z]+)\(([-0-9\.,]+)\)(.*)")
	IDENTITY = ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0))

	def __init__(self, svgw: SVGWidget | str) -> None:
		if type(svgw) is str:
			self._svgw = None
			self._tree = ET.fromstring(svgw)
		# elif type(svgw) == unicode:
		# self._svgw = None
		# self._tree = ET.fromstring(svgw.encode("utf-8"))
		else:
			self._svgw = svgw
			if type(svgw.current_svg) is bytes:
				self._tree = ET.fromstring(svgw.current_svg.decode("utf-8"))
			else:
				self._tree = ET.fromstring(svgw.current_svg)

	def commit(self) -> Self:
		"""Sends modified SVG back to original SVGWidget instance.

		Return self.
		"""
		self._svgw.current_svg = ET.tostring(self._tree)
		self._svgw.cache = OrderedDict()
		self._svgw.hilight({})

		return self

	def to_string(self) -> bytes:
		"""Returns modivied SVG as string"""
		return ET.tostring(self._tree)

	def clone_element(self, id) -> ET.Element | None:
		"""Grabs element with specified ID, duplicates it and returns created element.

		Returned element may get invalidated when commit() is called.

		Returns None if element cannot be found
		"""
		# SVGEditor.update_parents(self)
		e = SVGEditor.get_element(self, id)
		if e is not None:
			clone = deepcopy(e)
			parent = SVGEditor.find_parent(self._tree, e)
			if parent is not None:
				parent.append(clone)
			return clone
		return None

	def remove_element(self, e: str | ET.Element | None) -> Self:
		"""Removes element with specified ID, or, if element is passed, remove that element.

		If 'id' is None, does nothing.

		Returns self.
		"""
		if type(e) is str:
			e = SVGEditor.get_element(self, e)
		if e is not None:
			parent = SVGEditor.find_parent(self._tree, e)
			if parent is not None:
				parent.remove(e)
		return self

	def keep(self, *ids) -> Self:
		"""Removes all elements but ones with ID specified.

		Keeps child elements as well.
		"""

		def recursive(element) -> None:
			for child in list(element):
				if (
					child.tag.endswith("metadata")
					or child.tag.endswith("defs")
					or child.tag.endswith("defs")
					or child.tag.endswith("namedview")
				):
					recursive(child)
				elif child.attrib.get("id") not in ids:
					element.remove(child)

		recursive(self._tree)
		return self

	@staticmethod
	def find_parent(tree: ET.Element, element: ET.Element) -> ET.Element | None:
		"""Find an element's parent without relying on custom attributes."""
		for parent in tree.iter():
			for child in parent:
				if child is element:
					return parent
		return None

	@staticmethod
	def get_element(tree, id: str) -> ET.Element | None:
		"""Recursively searches through XML until element with specified ID is found.

		Returns element or None, if there is not any.
		"""
		if isinstance(tree, SVGEditor):
			tree = tree._tree

		return SVGEditor.find_by_id(tree, id)

	@staticmethod
	def find_by_id(tree, id: str) -> ET.Element | None:
		"""Recursively searches through XML until element with specified ID is found.

		Returns element or None, if there is not any.
		"""
		for child in tree:
			if "id" in child.attrib and child.attrib["id"] == id:
				return child
			r = SVGEditor.find_by_id(child, id)
			if r is not None:
				return r
		return None

	@staticmethod
	def find_by_tag(tree, tag) -> ET.Element | None:
		"""Recursively searches through XML until element with specified tag is found.

		Returns element or None, if there is not any.
		"""
		for child in tree:
			if child.tag.endswith(tag):
				return child
			r = SVGEditor.find_by_tag(child, tag)
			if r is not None:
				return r
		return None

	@staticmethod
	def recolor(element, color) -> bool:
		"""Changes background color of element.

		If element is group, descends into first element with fill set.

		Returns True on success, False if element cannot be recolored.
		"""
		if element.tag.endswith("text"):
			# Text used as a label on a highlighted shape must become visible,
			# but retain its authored fill. Recoloring it to the shape's fill
			# makes inherited tspans disappear into the background and leaves
			# only their stroke visible.
			if "style" in element.attrib:
				style = {y[0]: y[1] for y in [x.split(":", 1) for x in element.attrib["style"].split(";")]}
				if "fill" in style:
					if len(color.strip("#")) == 8:
						alpha = float(int(color.strip("#")[0:2], 16)) / 255.0
						style["fill-opacity"] = style["opacity"] = str(alpha)
					else:
						style["fill-opacity"] = style["opacity"] = "1"
					element.attrib["style"] = ";".join([f"{x}:{style[x]}" for x in style])
					return True
		elif (
			element.tag.endswith("path")
			or element.tag.endswith("rect")
			or element.tag.endswith("circle")
			or element.tag.endswith("ellipse")
		):
			if "style" in element.attrib:
				style = {y[0]: y[1] for y in [x.split(":", 1) for x in element.attrib["style"].split(";")]}
				if "fill" in style:
					if len(color.strip("#")) == 8:
						style["fill"] = f"#{color[-6:]}"
						alpha = float(int(color.strip("#")[0:2], 16)) / 255.0
						style["fill-opacity"] = style["opacity"] = str(alpha)
					else:
						style["fill"] = color
						style["fill-opacity"] = style["opacity"] = "1"
					element.attrib["style"] = ";".join([f"{x}:{style[x]}" for x in style])
					return True
		elif element.tag.endswith("g"):
			# Group, needs to find RECT, CIRCLE or PATH, whatever comes first
			for child in element:
				SVGEditor.recolor(child, color)
			return True
		return False

	@staticmethod
	def _recolor(tree, s_from, s_to) -> None:
		"""Recursive part of recolor_strokes and recolor_background"""
		for child in tree:
			if "style" in child.attrib:
				if s_from in child.attrib["style"]:
					child.attrib["style"] = child.attrib["style"].replace(s_from, s_to)
			SVGEditor._recolor(child, s_from, s_to)

	def recolor_background(self, change_from, change_to) -> Self:
		"""Recursively travels entire DOM tree and changes every matching background color into specified color."""
		s_from = "fill:#%s" % (change_from,)
		s_to = "fill:#%s" % (change_to,)
		SVGEditor._recolor(self._tree, s_from, s_to)
		return self

	def recolor_strokes(self, change_from, change_to) -> Self:
		"""Recursively travels entire DOM tree and changes every matching line (stroke) color into specified color."""
		s_from = "stroke:#%s" % (change_from,)
		s_to = "stroke:#%s" % (change_to,)
		SVGEditor._recolor(self._tree, s_from, s_to)
		return self

	@staticmethod
	def matrixmul(
		X: list[list[float]]
		| tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]],
		Y: list[list[float]]
		| tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]],
		*a,
	) -> list[list[float]]:
		if len(a) > 0:
			return SVGEditor.matrixmul(SVGEditor.matrixmul(X, Y), a[0], *a[1:])
		return [[sum(a * b for a, b in zip(x, y)) for y in zip(*Y)] for x in X]

	@staticmethod
	def transform_point(matrix: list[list[float]], x: float, y: float) -> tuple[float, float]:
		"""Transform a point using an SVG affine transformation matrix."""
		return (
			matrix[0][0] * x + matrix[0][1] * y + matrix[0][2],
			matrix[1][0] * x + matrix[1][1] * y + matrix[1][2],
		)

	@staticmethod
	def scale(xml, sx: float, sy: float | None = None) -> None:
		"""Changes element scale.

		Creates or updates 'transform' attribute.
		"""
		sy = sy or sx
		SVGEditor.set_transform(
			xml,
			SVGEditor.matrixmul(
				SVGEditor.parse_transform(xml),
				[[sx, 0.0, 0.0], [0.0, sy, 0.0], [0.0, 0.0, 1.0]],
			),
		)

	@staticmethod
	def rotate(xml, a: float, x: float, y: float) -> None:
		"""Changes element rotation.

		Creates or updates 'transform' attribute.
		"""
		a = a * PI / 180.0
		SVGEditor.set_transform(
			xml,
			SVGEditor.matrixmul(
				SVGEditor.parse_transform(xml),
				[[1.0, 0.0, x], [0.0, 1.0, y], [0.0, 0.0, 1.0]],
				[[cos(a), -sin(a), 0], [sin(a), cos(a), 0], [0.0, 0.0, 1.0]],
				[[1.0, 0.0, -x], [0.0, 1.0, -y], [0.0, 0.0, 1.0]],
			),
		)

	@staticmethod
	def translate(xml, x: float, y: float) -> None:
		"""Changes element translation.

		Creates or updates 'transform' attribute.
		"""
		SVGEditor.set_transform(
			xml,
			SVGEditor.matrixmul(
				SVGEditor.parse_transform(xml),
				[[1.0, 0.0, x], [0.0, 1.0, y], [0.0, 0.0, 1.0]],
			),
		)

	@staticmethod
	def set_transform(
		xml,
		matrix: list[list[float]],
	) -> None:
		"""Sets element transformation matrix"""
		xml.attrib["transform"] = (
			f"matrix({matrix[0][0]},{matrix[1][0]},{matrix[0][1]},{matrix[1][1]},{matrix[0][2]},{matrix[1][2]})"
		)

	@staticmethod
	def get_translation(
		elm_or_matrix: ET.Element | list[list[float]],
		absolute: bool = False,
	) -> tuple[float, float]:
		if isinstance(elm_or_matrix, ET.Element):
			elm = elm_or_matrix
			matrix = SVGEditor.parse_transform(elm)
			parent = elm.find("..")
			while parent is not None:
				matrix = SVGEditor.matrixmul(matrix, SVGEditor.parse_transform(parent))
				parent = parent.find("..")
		else:
			matrix = elm_or_matrix

		return matrix[0][2], matrix[1][2]

	@staticmethod
	def get_size(elm):
		width, height = 1, 1
		if "width" in elm.attrib:
			width = float(elm.attrib["width"])
		if "height" in elm.attrib:
			height = float(elm.attrib["height"])
		return width, height

	@staticmethod
	def parse_transform(
		xml,
	) -> tuple[tuple[float, float, float], tuple[float, float, float], tuple[float, float, float]] | list[list[float]]:
		"""Returns element transform data in transformation matrix,"""
		matrix = SVGEditor.IDENTITY
		if "x" in xml.attrib or "y" in xml.attrib:
			x = float(xml.attrib.get("x", 0.0))
			y = float(xml.attrib.get("y", 0.0))
			# Assuming matrix is identity matrix here
			matrix = ((1.0, 0.0, x), (0.0, 1.0, y), (0.0, 0.0, 1.0))
		if "transform" in xml.attrib:
			transform = xml.attrib["transform"]
			match = SVGEditor.RE_PARSE_TRANSFORM.match(transform.strip())
			while match:
				op, values, transform = match.groups()
				if op == "translate":
					translation = [float(x) for x in values.split(",")[0:2]]
					while len(translation) < 2:
						translation.append(0.0)
					x, y = translation
					matrix = SVGEditor.matrixmul(matrix, ((1.0, 0.0, x), (0.0, 1.0, y), (0.0, 0.0, 1.0)))
				elif op == "rotate":
					rotation = [float(x) for x in values.split(",")[0:3]]
					while len(rotation) < 3:
						rotation.append(0.0)
					a, x, y = rotation
					a = a * PI / 180.0
					matrix = SVGEditor.matrixmul(
						matrix,
						[[1.0, 0.0, x], [0.0, 1.0, y], [0.0, 0.0, 1.0]],
						[[cos(a), -sin(a), 0], [sin(a), cos(a), 0], [0.0, 0.0, 1.0]],
						[[1.0, 0.0, -x], [0.0, 1.0, -y], [0.0, 0.0, 1.0]],
					)
				elif op == "scale":
					scale = tuple([float(x) for x in values.split(",")[0:2]])
					if len(scale) == 1:
						sx, sy = scale[0], scale[0]
					else:
						sx, sy = scale
					matrix = SVGEditor.matrixmul(matrix, ((sx, 0.0, 0.0), (0.0, sy, 0.0), (0.0, 0.0, 1.0)))
				elif op == "matrix":
					m = [float(x) for x in values.split(",")][0:6]
					while len(m) < 6:
						m.append(0.0)
					a, b, c, d, e, f = m
					matrix = SVGEditor.matrixmul(matrix, [[a, c, e], [b, d, f], [0, 0, 1]])

				match = SVGEditor.RE_PARSE_TRANSFORM.match(transform.strip())

		return matrix

	@staticmethod
	def set_text(xml, text) -> None:
		has_valid_children = False
		for child in xml:
			if child.tag.endswith("text") or child.tag.endswith("tspan"):
				has_valid_children = True
				SVGEditor.set_text(child, text)
		if not has_valid_children:
			xml.text = text

	def set_labels(self, labels: dict) -> Self:
		"""Replaces text on every element named LABEL_something with coresponding value from 'labels' dict."""

		def walk(xml):
			for child in xml:
				if "id" in child.attrib:
					if child.attrib["id"].startswith("LABEL_"):
						id = child.attrib["id"][6:]
						if id in labels:
							SVGEditor.set_text(child, labels[id])
				walk(child)

		walk(self._tree)
		return self

	@staticmethod
	def add_element(parent, e, **attributes) -> ET.Element:
		"""Creates new element as child of specified parent or, if 1st argument is ET.Element, adds that element.

		Returns created or passed element.
		"""
		if not isinstance(e, ET.Element):
			attributes = {k: str(attributes[k]) for k in attributes}
			e = ET.Element(e, attributes)
		parent.append(e)
		return e

	@staticmethod
	def load_from_file(filename: str) -> ET.Element[str] | None:
		with open(filename) as file:
			tree = ET.fromstring(file.read())
		return SVGEditor.find_by_tag(tree, "g")
