import io
import os
from pathlib import Path
import struct
import numpy as np
import random
from meshgen.primitives import (
    Domain,
    Element,
    Node,
    Edge,
    Vertex,
    Triangle,
    Quadrilateral,
    Tetrahedron,
    Pyramid,
    Prism,
    Hexahedron,
)
import meshgen.jcm
import meshgen.comsol
import meshgen.gmsh
import meshgen.util

import logging

logger = logging.getLogger(__name__)

_MIRROR = 2**28
_TRANSPARENT = 2**29
_PERIODIC = 2**30
_MIRROR_JCM = -29_856_126


class MeshError(Exception):
    """Error for :class:`Mesh`."""


class Mesh:
    """Mesh definition.

    A mesh is the collection of one or multiple domains, their elements and nodes. The
    mesh must be 'closed', so all nodes must belong to an element and the nodes of all
    elements must be included. Similarly, this applies to elements and domains. Changes
    of the attributes are not forbidden so changes are at the users discretion. However,
    the consistency of the attributes is as well.

    The domains, elements, and nodes are stored as keys in dictionaries with an integer
    tag (or None) as value. This tag can be subject to change. However, the 'original'
    tag can be restored from the constituents itself.

    Attributes:
        domains (dict): :class:`Domain`, tag (int) pairs
        elements (dict): :class:`Element`, tag (int) pairs
        nodes (dict): :class:`Node`, tag (int) pairs
        dims (set): dimensions of the domains and elements
        sdim (int): spatial dimension
        origin (str): type of mesh from which it was loaded ('comsol', 'jcm', 'gmsh')
        info (dict): additional information
    """

    def __init__(
        self, domains=None, elements=None, nodes=None, *, origin=None, info=None
    ):
        """Initialize mesh.

        Args:
            domains (dict): :class:`Domain`, tag (int) pairs
            elements (dict): :class:`Element`, tag (int) pairs
            nodes (dict): :class:`Node`, tag (int) pairs
            origin (str): type of mesh from which it was loaded ('comsol', 'jcm',
                'gmsh')
            info (dict): additional information
        """
        if domains is None:
            domains = {}
        if elements is None:
            elements = {e: e.tag for domain in domains for e in domain}
        if nodes is None:
            nodes = {n: n.tag for element in elements for n in element}
        self.sdim = next(iter(nodes)).sdim
        self.dims = {domain.dim for domain in domains}
        self.nodes = dict(nodes)
        self.elements = dict(elements)
        self.domains = dict(domains)
        self._sdim = self.sdim  # if sdim is 3 and all z values are zero, this is 2
        self.check()
        self.origin = origin
        self.info = {} if info is None else info

    def __repr__(self):
        return (
            "Mesh({...}, {...}, {...}, "
            f"dims={self.dims}, sdim={self.sdim}, origin='{self.origin}')"
        )

    def check(self):
        """Check the mesh for consistency."""
        elements = {element for domain in self.domains for element in domain}
        if elements != self.elements.keys():
            raise MeshError("'domains' and 'elements' incompatible")
        nodes = {node for element in self.elements for node in element}
        if nodes != self.nodes.keys():
            raise MeshError("'elements' and 'nodes' incompatible")
        for node in self.nodes:
            if node.sdim != self.sdim:
                raise MeshError("mesh contains nodes of mixed dimension")
        for domain in self.domains:
            if domain.dim not in self.dims:
                raise MeshError(f"'dims' is missing dimension '{domain.dim}'")
        if self.sdim == 3 and max(self.dims) < 3 and all(n[2] == 0 for n in self.nodes):
            self._sdim = 2

    def elements_by_type(self):
        """Return the elements sorted by their type.

        Returns:
            dict
        """
        ret = {}
        for element, tag in self.elements.items():
            ret.setdefault(element.__class__, {})[element] = tag
        return {e: ret[e] for e in Element.__subclasses__() if e in ret}

    def tag_restore(self):
        """Update dictionaries with the tags of each constituent."""
        for domain in self.domains:
            self.domains[domain] = domain.tag
        for element in self.elements:
            self.elements[element] = element.tag
        for node in self.nodes:
            self.nodes[node] = node.tag

    def tag_comsol(self):
        """Tag domains to be compatible with COMSOL."""
        if self.origin == "comsol":
            self.tag_restore()
            return
        self.nodes = {n: i for i, n in enumerate(self.nodes)}
        domain_tags = [0] * (self._sdim + 1)
        domain_tags[self._sdim] = 1
        jcm_boundary = self.info.get("jcm_boundary", {})
        for domain in self.domains:
            tag = domain_tags[domain.dim]
            domain_tags[domain.dim] += 1
            mode = set(self.info.get("phystags", {}).get(domain, [])) & {
                _MIRROR,
                _PERIODIC,
                _TRANSPARENT,
            }
            if len(mode) > 1:
                raise MeshError("unrecognized special mode in 'phystags'")
            if len(mode) == 1:
                mode = next(iter(mode))
            else:
                mode = None
            for element in domain:
                top = int(jcm_boundary.get(element, [0])[0])
                if mode == _MIRROR or element.tag == _MIRROR_JCM:
                    self.elements[element] = tag + _MIRROR
                elif mode == _PERIODIC or top == -1:
                    self.elements[element] = tag + _PERIODIC
                elif mode == _TRANSPARENT or top == -2:
                    self.elements[element] = tag + _TRANSPARENT
                else:
                    self.elements[element] = tag

    def tag_jcm(self):
        """Tag domains to be compatible with JCMsuite."""
        if self.origin == "jcm":
            self.tag_restore()
            return
        self.nodes = {n: i + 1 for i, n in enumerate(self.nodes)}
        domain_tags = [1] * (self._sdim + 1)
        self.info["jcm_boundary"] = {}  # delete previous versions
        for domain in self.domains:
            tag = domain_tags[domain.dim]
            domain_tags[domain.dim] += 1
            mode = set(self.info.get("phystags", {}).get(domain, [])) & {
                _MIRROR,
                _PERIODIC,
                _TRANSPARENT,
            }
            if len(mode) > 1:
                raise MeshError("unrecognized special mode in 'phystags'")
            if len(mode) == 1:
                mode = next(iter(mode))
            else:
                mode = None
            for element in domain:
                if mode == _MIRROR or element.tag & _MIRROR:
                    self.elements[element] = _MIRROR_JCM
                elif mode == _PERIODIC or element.tag & _PERIODIC:
                    # Actually, here we would need to define the jcm_boundary -1 and the
                    # nodes on the opposite side. However, we ignore this info for now.
                    self.elements[element] = tag
                elif mode == _TRANSPARENT or element.tag & _TRANSPARENT:
                    self.elements[element] = tag
                    self.info["jcm_boundary"][element] = ["-2"]
                else:
                    self.elements[element] = tag

    def tag_gmsh(self):
        """Tag domains to be compatible with gmsh."""
        if self.origin == "gmsh":
            self.tag_restore()
            return
        self.nodes = {n: i + 1 for i, n in enumerate(self.nodes)}
        self.elements = {n: i + 1 for i, n in enumerate(self.elements)}
        self.domains = {n: i + 1 for i, n in enumerate(self.domains)}
        jcm_boundary = self.info.get("jcm_boundary", {})
        self.info["phystags"] = {}  # delete previous versions
        for domain in self.domains:
            if len(domain) == 0:
                continue
            element = next(iter(domain))
            top = jcm_boundary.get(element, [0])[0]
            if element.tag == _MIRROR_JCM or element.tag & _MIRROR:
                self.info["phystags"][domain] = [_MIRROR]
            elif top == -1 or element.tag & _PERIODIC:
                self.info["phystags"][domain] = [_PERIODIC]
            elif top == -2 or element.tag & _TRANSPARENT:
                self.info["phystags"][domain] = [_TRANSPARENT]

    @classmethod
    def read(cls, name):
        """Read file.

        The file with the given name is read. The type of the file is determined by its
        extentsion.

        Args:
            name (str): file name
        """
        funcs = {
            ".jcm": cls.read_jcm,
            ".mphtxt": cls.read_comsol,
            ".msh": cls.read_gmsh,
            ".msh4": cls.read_gmsh,
        }
        ext = os.path.splitext(name)[-1]
        func = funcs[ext]
        with open(name, mode="rb") as fobj:
            try:
                return func(fobj)
            except KeyError as e:
                if ext != ".jcm":
                    raise e

        logger.warning(
            """Encountered an error while reading the mesh.
This is likely due to unsupported feature in the binary .jcm file (e.g.) curvilinear elements.
Trying to convert and read the file as ASCII .jcm file."""
        )

        temp_ascii = Path(name).with_suffix(".ascii.jcm")
        meshgen.jcm.jcm_binary_to_ascii(name, temp_ascii)
        with open(temp_ascii, mode="r") as fobj_ascii:
            return func(fobj_ascii)

    def write(self, name, mode="x", *, skip_1d=False):
        """Write the mesh to a file.

        The mesh is written to a file with the given name. The mode can be chosen
        according to the documentation of `open()`. The legacy JCMsuite mesh files can
        also be written (applies to 2D meshes only).

        Args:
            name (str): file name
            mode (str, default 'x'): writing mode (overwrite 'w', append 'a', write but
                fail when already exiting 'x')
            skip_1d (bool, default False): Skip writing 1D elements for 2D meshes in
                COMSOL, because they can cause loading errors there. They can be kept,
                if all 2D domains are bounded by 1D elements.
        """
        funcs = {
            ".jcm": self.write_jcm,
            ".mphtxt": self.write_comsol,
            ".msh": self.write_gmsh,
            ".msh4": self.write_gmsh,
        }
        func = funcs[os.path.splitext(name)[-1]]
        kwargs = {}
        if "b" in mode and func != self.write_jcm:
            raise ValueError("invalid mode")
        if skip_1d and func == self.write_comsol:
            kwargs["skip_1d"] = True
        with open(name, mode) as fobj:
            func(fobj, **kwargs)

    @classmethod
    def read_jcm(cls, fobj):
        """Read a JCMsuite grid file (.jcm).

        Given a file object of the jcm file, it is read and the corresponding mesh is
        created.

        Args:
            fobj (file object): file to read
        """
        header = meshgen.jcm.read_header_jcm(fobj)
        num_subgrids = int(header.get("<I>NSubGrids", "0"))
        has_subgrids = num_subgrids > 0

        if header["__MODE__"] == "BINARY0":
            if isinstance(fobj, io.TextIOBase):
                raise ValueError("binary file must be opened in binary mode")

            if fobj.read(1) != b"0":
                raise ValueError("invalid binary file")
            binary = True
        elif header["__MODE__"] == "TEXT":
            fobj = meshgen.util.iter_wo_comments(
                fobj,
                skips={
                    "# Vertical Grid\n": meshgen.util.skip_to(
                        "# horizontal boundary id Mappings"
                    )
                },
            )
            binary = False
        else:
            raise ValueError("invalid '__MODE__'")

        subgrids = []
        subgrid_names = (
            [f"SubGrid{i+1}:" for i in range(num_subgrids)] if has_subgrids else [""]
        )
        for subgrid in subgrid_names:
            domains_bulk = {}
            domains_boundary = {}
            nodes = {}
            elements = {}
            boundary_info = {}

            dim = space_dim = int(header[f"<I>{subgrid}SpaceDim"])
            manifold_dim = int(header[f"<I>{subgrid}ManifoldDim"])
            if space_dim == manifold_dim == 2:
                element_types = (Triangle, Quadrilateral, Edge)
            elif space_dim == manifold_dim == 3:
                element_types = (
                    Tetrahedron,
                    Pyramid,
                    Prism,
                    Hexahedron,
                    Triangle,
                    Quadrilateral,
                )
            else:
                raise ValueError("invalid dimensions")

            num_points = int(header[f"<I>{subgrid}NPoints"])
            logger.info(f"reading {num_points} Points from subgrid '{subgrid}'")
            for _ in range(num_points):
                tag, *values = meshgen.jcm.read_jcm(fobj, f"i{dim}d", binary)
                nodes[tag] = Node(values, tag=tag)
            for ecls in element_types:
                key = ecls.jcm_info[dim].replace("<I>", f"<I>{subgrid}")
                num = int(header.get(key, "0"))
                # curr_line, fobj = meshgen.util.peek(fobj)
                # logger.debug(f"current line content: {curr_line}")
                logger.info(
                    f"reading {num} of '{ecls.jcm_name}' elements from subgrid '{subgrid}'"
                )
                for _ in range(num):
                    *values, tag = meshgen.jcm.read_jcm(
                        fobj, f"{ecls.n_nodes + 1}i", binary
                    )
                    element = ecls(
                        meshgen.jcm.jcm_node_order([nodes[i] for i in values], ecls),
                        tag=tag,
                    )
                    elements[element] = tag
                    if ecls.dim == dim:
                        domains_bulk.setdefault(tag, []).append(element)
                    else:
                        top = meshgen.jcm.read_jcm(fobj, "i", binary)
                        if top[0] == -1:
                            top = top + meshgen.jcm.read_jcm(
                                fobj, f"{ecls.n_nodes}i", binary
                            )
                        boundary_info[element] = top
                        domains_boundary.setdefault((tag, top[0]), []).append(element)
            domains = {Domain(val, tag=tag): tag for tag, val in domains_bulk.items()}
            domains.update(
                {
                    Domain(val, tag=tag): tag
                    for (tag, _), val in domains_boundary.items()
                }
            )
            subgrids.append(
                cls(
                    domains,
                    elements,
                    {v: k for k, v in nodes.items()},
                    origin="jcm",
                    info={"jcm_boundary": boundary_info, "jcm_header": header},
                )
            )

        return subgrids

    @classmethod
    def read_comsol(cls, fobj):
        """Read a COMSOL mesh file (.mphtxt).

        Given a file object of the mphtxt file, it is read and the corresponding mesh is
        created.

        Args:
            fobj (file object): file to read
        """
        # Domains for 0d, 1d, 2d, and 3d
        domains = {}

        element_types = {e.comsol_name: e for e in Element.__subclasses__()}

        itr = meshgen.util.iter_wo_comments(fobj)
        # 1. version
        if next(itr) != "0 1":
            raise ValueError("invalid file version number")
        # 2. skip tags
        meshname = " \n".join(next(itr) for _ in range(int(next(itr))))
        # 3. skip types
        for _ in range(int(next(itr))):
            next(itr)
        # 4. object number?
        if next(itr) != "0 0 1":
            raise ValueError("invalid file object number")
        # 5. class
        if next(itr) != "4 Mesh":
            raise ValueError("invalid class")
        # 6. another version
        if next(itr) != "4":
            raise ValueError("invalid version")
        # 7. dimension
        sdim = int(next(itr))
        # 8. nodes
        consume = int(next(itr))
        nodes = {}
        tag_min = int(next(itr))
        for i in range(consume):
            nodes[tag_min + i] = Node(map(float, next(itr).split()), tag=tag_min + i)
        # 9. Elements
        elements = {}
        for _ in range(int(next(itr))):
            mode = next(itr)
            ecls = element_types[mode]
            dim = ecls.dim
            if ecls.n_nodes != int(next(itr)):
                raise ValueError(f"error reading '{mode}'")
            new_elements = []
            for __ in range(int(next(itr))):
                values = next(itr).split()
                new_elements.append(
                    meshgen.comsol.comsol_node_order(
                        [nodes[int(i)] for i in values], ecls
                    )
                )
            for i in range(int(next(itr))):
                tag = int(next(itr))
                element = ecls(new_elements[i], tag=tag)
                elements[element] = tag
                domains.setdefault((dim, tag), []).append(element)
        domains = {
            Domain(val, tag=tag, dim=dim, sdim=sdim): tag
            for (dim, tag), val in domains.items()
        }
        return cls(
            domains,
            elements,
            {v: k for k, v in nodes.items()},
            origin="comsol",
            info={"meshname": meshname},
        )

    @classmethod
    def read_gmsh(cls, fobj):
        """Read a gmsh mesh file (.msh).

        Given a file object of the gmsh file, it is read and the corresponding mesh is
        created.

        Args:
            fobj (file object): file to read
        """
        itr = meshgen.gmsh.gmsh_iter(fobj)
        element_types = {e.gmsh_type: e for e in Element.__subclasses__()}
        domains = {}
        elements = {}
        nodes = {}
        nodes0d = {}
        physnames = {}
        phystags = {}
        boundingpoints = {}
        for line in itr:
            if line == "$MeshFormat":
                if next(itr) != "4.1 0 8":
                    raise ValueError("unsupported version")
                if next(itr) != "$EndMeshFormat":
                    raise ValueError("invalid section 'MeshFormat'")
            elif line == "$PhysicalNames":
                for i in range(int(next(itr))):
                    dim, tag, *name = next(itr).split(" ")
                    physnames[(int(dim), int(tag))] = " ".join(name)
                if next(itr) != "$EndPhysicalNames":
                    raise ValueError("invalid section 'PhysicalNames'")
            elif line == "$Entities":
                blocks = map(int, next(itr).split())
                for dim, nblocks in enumerate(blocks):
                    for i in range(nblocks):
                        values = next(itr).split()
                        tag = int(values[0])
                        start = 7
                        if dim == 0:
                            start = 4
                            # gmsh add their "Points" only here without a node, so we
                            # store them with negative tags and append them in the end
                            # to the other nodes
                            nodes0d[-tag] = Node(map(float, values[1:4]), tag=-tag)
                            element = Vertex([nodes0d[-tag]], tag=tag)
                            elements[element] = tag
                            domains[(0, tag)] = [element]
                        inc = int(values[start])
                        start += 1
                        phystags[(dim, tag)] = list(
                            map(int, values[start : start + inc])
                        )
                        if dim == 0:
                            continue
                        start += inc
                        inc = int(values[start])
                        start += 1
                        boundingpoints[(dim, tag)] = list(
                            map(int, values[start : start + inc])
                        )
                if next(itr) != "$EndEntities":
                    raise ValueError("invalid section 'Entitites'")
            elif line == "$Nodes":
                nblocks = int(next(itr).split()[0])
                for _ in range(nblocks):
                    dim, domaintag, _, ntags = map(int, next(itr).split())
                    tags = [int(next(itr)) for _ in range(ntags)]
                    for tag in tags:
                        nodes[tag] = Node(map(float, next(itr).split()), tag=tag)
                    domains.setdefault((dim, domaintag), [])
                if next(itr) != "$EndNodes":
                    raise ValueError("invalid section 'Nodes'")
            elif line == "$Elements":
                nblocks = int(next(itr).split()[0])
                for _ in range(nblocks):
                    dim, domaintag, element_type, ntags = map(int, next(itr).split())
                    ecls = element_types[element_type]
                    domain = domains.setdefault((dim, domaintag), [])
                    for _ in range(ntags):
                        tag, *nodetags = map(int, next(itr).split())
                        element = ecls([nodes[i] for i in nodetags], tag=tag)
                        elements[element] = tag
                        domain.append(element)
                if next(itr) != "$EndElements":
                    raise ValueError("invalid section 'Elements'")
        nodes.update(nodes0d)
        sdim = next(iter(nodes.values())).sdim
        domains = {
            (dim, tag): Domain(val, tag=tag, dim=dim, sdim=sdim)
            for (dim, tag), val in domains.items()
        }
        phystags = {domains[k]: v for k, v in phystags.items()}
        boundingpoints = {domains[k]: v for k, v in boundingpoints.items()}
        domains = {v: k for k, v in domains.items()}
        info = {
            "phystags": phystags,
            "boundingpoints": boundingpoints,
        }
        if physnames:
            info["physnames"] = physnames
        return cls(
            domains,
            elements,
            {v: k for k, v in nodes.items()},
            origin="gmsh",
            info=info,
        )

    def write_comsol(self, fobj, *, skip_1d=False):
        """Write a COMSOL file (.mphtxt).

        Args:
            fobj (file object): file to write to
            skip_1d (bool, default False): Skip writing 1D elements for 2D meshes in
                COMSOL, because they can cause loading errors there. They can be kept,
                if all 2D domains are bounded by 1D elements.
        """
        self.tag_comsol()
        fobj.write(
            """# Created by meshparser.

# Major & minor version
0 1
"""  # noqa: W291
        )
        meshname = self.info.get("meshname", "5 mesh1")
        fobj.write(str(len(meshname.split("\n"))))
        fobj.write(
            f""" # number of tags
# Tags
{meshname}
1 # number of types
# Types
3 obj

# --------- Object 0 ----------

0 0 1
4 Mesh # class
4 # version
{self._sdim} # sdim
{len(self.nodes)} # number of mesh vertices
0 # lowest mesh vertex index

# Mesh vertex coordinates
"""  # noqa: W291
        )
        for node in self.nodes:
            fobj.write(" ".join(f"{i:.17g}" for i in node[: self._sdim]) + " \n")

        elements_by_type = self.elements_by_type()
        if self._sdim == 2 and skip_1d:
            elements_by_type.pop(Edge, None)
        fobj.write(
            f"""
{len(elements_by_type)} # number of element types
"""
        )
        for i, (ecls, elements) in enumerate(elements_by_type.items()):
            fobj.write(
                f"""
# Type #{i}

{ecls.comsol_name} # type name


{ecls.n_nodes} # number of vertices per element
{len(elements)} # number of elements
# Elements
"""
            )
            for element in elements:
                fobj.write(
                    " ".join(
                        meshgen.comsol.comsol_node_order(
                            [str(self.nodes[node]) for node in element], ecls
                        )
                    )
                    + " \n"
                )
            fobj.write(
                f"""
{len(elements)} # number of geometric entity indices
# Geometric entity indices
"""
            )
            fobj.write(
                " \n".join([str(self.elements[element]) for element in elements])
                + " \n"
            )

    def write_jcm(self, fobj):
        """Write a JCMsuite file (.jcm).

        Args:
            fobj (file object): file to write to
        """
        self.tag_jcm()
        elements_by_type = self.elements_by_type()
        binary = not isinstance(fobj, io.TextIOBase)
        header = f"""/* <BLOBHead>
__BLOBTYPE__=Grid
__MODE__={'BINARY0' if binary else 'TEXT'}
__OPTIONS__=LHExchanged
__OWNER__=JCMwave
__TAG__={random.randbytes(16).hex()}
__VERSION__=1.1.0
"""
        coordinate = self.info.get("jcm_header", {}).get("CoordinateSystem")
        if coordinate is not None:
            header += f"CoordinateSystem={coordinate}\n"
        if self._sdim == 2:
            element_types = (Triangle, Quadrilateral, Edge)
        elif self._sdim == 3:
            element_types = (
                Tetrahedron,
                Pyramid,
                Prism,
                Hexahedron,
                Triangle,
                Quadrilateral,
            )
        header += "\n".join(
            sorted(
                [
                    f"<I>ManifoldDim={max(self.dims)}",
                    f"<I>NPoints={len(self.nodes)}",
                    "<I>NRefinementSteps=0",
                    f"<I>SpaceDim={self._sdim}",
                ]
                + [
                    f"{ecls.jcm_info[self._sdim]}={len(elements_by_type.get(ecls, []))}"
                    for ecls in element_types
                ]
            )
        )
        cmin, cmax = meshgen.util.coord_minmax(self.nodes)
        header += f"""
<F>BBox_x_max={cmax[0]:.15e}
<F>BBox_x_min={cmin[0]:.15e}
<F>BBox_y_max={cmax[1]:.15e}
<F>BBox_y_min={cmin[1]:.15e}
<F>BBox_z_max={cmax[2]:.15e}
<F>BBox_z_min={cmin[2]:.15e}
<F>OriginX=0.000000000000000e+00
<F>OriginY=0.000000000000000e+00
<F>OriginZ=0.000000000000000e+00
<F>Twist=0.000000000000000e+00
*/
"""
        jcm_boundary = self.info.get("jcm_boundary", {})

        if binary:
            fobj.write(header.encode())
            fobj.write(b"0")
            for node, tag in self.nodes.items():
                fobj.write(struct.pack(f"<i{self._sdim}d", tag, *node[: self._sdim]))
            for ecls in element_types:
                if ecls not in elements_by_type:
                    continue
                for element in elements_by_type[ecls]:
                    fobj.write(
                        struct.pack(
                            f"<{element.n_nodes + 1}i",
                            *meshgen.jcm.jcm_node_order(
                                [self.nodes[node] for node in element], ecls
                            ),
                            self.elements[element],
                        )
                    )
                    if element.dim < self._sdim:
                        boundary = jcm_boundary.get(element, [1])
                        fobj.write(struct.pack(f"{len(boundary)}i", *boundary))
            return

        fobj.write(header)
        fobj.write("# Points ###################\n# Points      (n, x, y, z)\n")
        for node, tag in self.nodes.items():
            fobj.write(
                f"# P\n{tag}\n"
                + "\n".join(f"{i:.15e}" for i in node[: self._sdim])
                + "\n"
            )
        boundary_header = True
        for ecls in element_types:
            if ecls not in elements_by_type:
                continue
            if boundary_header and ecls.dim < self._sdim:
                fobj.write("# Boundary patches ###################\n")
                boundary_header = False
            fobj.write(ecls.jcm_header[self._sdim])
            for element in elements_by_type[ecls]:
                fobj.write(
                    element.jcm_short_name
                    + "\n"
                    + "\n".join(
                        meshgen.jcm.jcm_node_order(
                            [str(self.nodes[node]) for node in element], ecls
                        )
                    )
                    + f"\n{self.elements[element]}\n"
                )
                if element.dim < self._sdim:
                    fobj.write(
                        "\n".join([str(i) for i in jcm_boundary.get(element, [1])])
                        + "\n"
                    )

    def write_gmsh(self, fobj):
        """Write a gmsh file (.msh).

        Args:
            fobj (file object): file to write to
        """
        self.tag_gmsh()
        fobj.write(
            """$MeshFormat
4.1 0 8
$EndMeshFormat
"""
        )
        physnames = self.info.get("physnames")
        if physnames is not None:
            fobj.write(f"$PhysicalNames\n{len(physnames)}\n")
            for (dim, tag), physname in physnames.items():
                fobj.write(f"{dim} {tag} {physname}\n")
            fobj.write("$EndPhysicalNames\n")
        domains_by_dim = dict(
            sorted(
                [((d.dim, tag), d) for d, tag in self.domains.items()],
                key=lambda item: item[0],
            )
        )

        fobj.write(
            f"""$Entities
{' '.join(str(sum(ddim == dim for ddim, _ in domains_by_dim)) for dim in range(4))}
"""
        )
        for (dim, tag), domain in domains_by_dim.items():
            if dim == 0:
                if len(domain) > 1:
                    raise ValueError("Entity of dimension 0 can only contain one Point")
                if len(domain) == 0:
                    continue
                x, y, z = next(iter(domain))[0].in_3d
                fobj.write(f"{tag} {x:.16g} {y:.16g} {z:.16g}")
            else:
                cmin, cmax = meshgen.util.coord_minmax(
                    {node for element in domain for node in element}
                )
                fobj.write(
                    f"{tag} {cmin[0]:.16g} {cmin[1]:.16g} {cmin[2]:.16g} "
                    f"{cmax[0]:.16g} {cmax[1]:.16g} {cmax[2]:.16g}"
                )

            phystags = self.info.get("phystags", {}).get(domain, [])
            fobj.write(f" {' '.join(map(str, [len(phystags)] + phystags))}")
            if dim == 0:
                fobj.write(" \n")
                continue
            bpoints = self.info.get("boundingpoints", {}).get(domain, [])
            fobj.write(f" {' '.join(map(str, [len(bpoints)] + bpoints))} \n")
        nodes = {node: tag for node, tag in self.nodes.items() if tag > 0}
        fobj.write(
            f"""$EndEntities
$Nodes
{len(self.domains)} {len(nodes)} {min(nodes.values())} {max(nodes.values())}
"""
        )
        for (dim, tag), domain in domains_by_dim.items():
            nodes_domain = {}
            for element in domain:
                for node in element:
                    nodetag = nodes.pop(node, None)
                    if nodetag is not None:
                        nodes_domain[node] = nodetag
            nodes_domain = dict(sorted(nodes_domain.items(), key=lambda item: item[1]))
            fobj.write(f"{dim} {tag} 0 {len(nodes_domain)}\n")
            for nodetag in nodes_domain.values():
                fobj.write(f"{nodetag}\n")
            for node in nodes_domain:
                x, y, z = node.in_3d
                fobj.write(f"{x:.16g} {y:.16g} {z:.16g}\n")
        fobj.write("$EndNodes\n$Elements\n")
        nblocks = sum(
            len({type(e) for e in domain}) for domain in self.domains if domain.dim > 0
        )
        nelements = sum(len(domain) for domain in self.domains if domain.dim > 0)

        fobj.write(
            f"{nblocks} {nelements} "
            f"{min(self.elements.values())} {max(self.elements.values())}\n"
        )
        elements_by_domain_and_type = {}

        for (dim, _), domain in domains_by_dim.items():
            val = np.array([d for d in domain], dtype=object)
            vallist = [d for d in domain]
            gmv = np.array([d.gmsh_type for d in domain])
            gsort = np.sort(gmv)
            uni, ind = np.unique(gsort, return_index=True)
            if dim == 0:
                continue
            elements_by_domain_and_type[domain] = {}
            lsgroup = []
            ind = np.append(ind, len(val))
            for i in range(len(ind) - 1):
                lsgroup.append(vallist[ind[i] : ind[i + 1]])
            for i, u in enumerate(uni):
                elements_by_domain_and_type[domain][u] = lsgroup[i]
        gmsh_types = sorted(
            [e.gmsh_type for e in Element.__subclasses__() if e.gmsh_type is not None]
        )
        for domain, elements_by_type in elements_by_domain_and_type.items():
            domaintag = self.domains[domain]
            for gmsh_type in gmsh_types:
                elements = elements_by_type.get(gmsh_type, None)
                if elements is None:
                    continue

                fobj.write(f"{domain.dim} {domaintag} {gmsh_type} {len(elements)}\n")
                for element in elements:
                    fobj.write(
                        f"{self.elements[element]} "
                        f"{' '.join(str(self.nodes[node]) for node in element)} \n"
                    )
        fobj.write("$EndElements\n")

    def gmsh(self, model):
        """Create a gmsh model.

        Add the stored mesh to the model given. The model has to be initialized before
        handing it to this method. Afterwards, it can be attempted to generate surfaces
        with `model.mesh.createTopology()`, if they are not included in the mesh.

        Args:
            model (gmsh.Model): gmsh model

        Returns:
            gmsh.Model
        """
        self.tag_gmsh()
        for domain, tag in self.domains.items():
            model.addDiscreteEntity(domain.dim, tag)

            nodes_coords = []
            nodes_tags = []
            for element in domain:
                for node in element:
                    if self.nodes[node] not in nodes_tags:
                        nodes_coords.extend(node.in_3d)
                        nodes_tags.append(self.nodes[node])
            model.mesh.addNodes(domain.dim, tag, nodes_tags, nodes_coords)

            element_tags = {}
            element_nodes = {}
            for element in domain:
                element_tags.setdefault(element.gmsh_type, []).append(
                    self.elements[element]
                )
                element_nodes.setdefault(element.gmsh_type, []).extend(
                    [self.nodes[node] for node in element]
                )
            model.mesh.addElements(
                domain.dim,
                tag,
                list(element_tags.keys()),
                list(element_tags.values()),
                list(element_nodes.values()),
            )
        return model
