# Copyright 2025 Mews Labs
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from __future__ import annotations

import logging
from functools import cached_property
from typing import List, Union, Tuple, Dict, Optional, Literal

import numpy as np

from .abstract_meshes import AbstractMesh
from .geometry import (
    compute_faces_areas,
    compute_faces_centers,
    compute_faces_normals,
    compute_faces_radii,
    compute_gauss_legendre_2_quadrature,
    get_vertices_face,
)
from .clip import clip_faces
from .clean import clean_mesh
from .export import export_mesh
from .quality import _is_valid, check_mesh_quality
from .visualization import show_3d

LOG = logging.getLogger(__name__)


class Mesh(AbstractMesh):
    """Mesh class for representing and manipulating 3D surface meshes.

    Parameters
    ----------
    vertices : np.ndarray, optional
        Array of mesh vertices coordinates with shape (n_vertices, 3).
        Each row represents one vertex's (x, y, z) coordinates.
    faces : List[List[int]] or np.ndarray, optional
        Array of mesh connectivities for panels. Each row contains indices
        of vertices that form a face (triangles or quads).
    faces_metadata: Dict[str, np.ndarray]
        Some arrays with the same first dimension (should be the number of faces)
        storing some fields defined on all the faces of the mesh.
    name : str, optional
        Optional name for the mesh instance.
    auto_clean : bool, optional
        Whether to automatically clean the mesh upon initialization. Defaults to True.
    auto_check : bool, optional
        Whether to automatically check mesh quality upon initialization. Defaults to True.

    Attributes
    ----------
    vertices : np.ndarray
        Array of vertex coordinates with shape (n_vertices, 3).
    name : str or None
        Name of the mesh instance.
    """

    def __init__(
        self,
        vertices: np.ndarray = None,
        faces: Union[List[List[int]], np.ndarray] = None,
        *,
        faces_metadata: Optional[Dict[str, np.ndarray]] = None,
        quadrature_method: Optional[str] = None,
        name: Optional[str] = None,
        auto_clean: bool = True,
        auto_check: bool = True,
    ):
        # --- Vertices: always a NumPy array with shape (n,3) ---
        if vertices is None:
            self.vertices = np.empty((0, 3), dtype=np.float64)
        else:
            self.vertices = np.array(vertices, dtype=np.float64)

        # --- Faces: process using helper method ---
        self._faces: List[List[int]] = self._process_faces(faces)

        if faces_metadata is None:
            self.faces_metadata = {}
        else:
            self.faces_metadata = {k: np.asarray(faces_metadata[k]) for k in faces_metadata}

        for m in self.faces_metadata:
            assert self.faces_metadata[m].shape[0] == len(self._faces)

        # Optional name
        self.name = str(name) if name is not None else None

        self.quadrature_method = quadrature_method

        # Cleaning/quality (unless mesh is completely empty)
        if not (len(self.vertices) == 0 and len(self._faces) == 0):
            if not _is_valid(vertices, faces):
                raise ValueError(
                    "Mesh is invalid: faces contain out-of-bounds or negative indices."
                )

            if np.any(np.isnan(vertices)):
                raise ValueError(
                    "Mesh is invalid: vertices coordinates contains NaN values."
                )

            if auto_clean:
                self.vertices, self._faces, self.faces_metadata = clean_mesh(
                    self.vertices, self._faces, self.faces_metadata, max_iter=5, tol=1e-8
                )
                LOG.debug("Cleaned %s", str(self))

            if auto_check:
                check_mesh_quality(self)
                LOG.debug("Checked quality of %s", str(self))

        LOG.debug("New %s", str(self))


    ## MAIN METRICS AND DISPLAY

    @property
    def nb_vertices(self) -> int:
        """Number of vertices in the mesh."""
        return len(self.vertices)

    @property
    def nb_faces(self) -> int:
        """Number of faces in the mesh."""
        return len(self._faces)

    @property
    def nb_triangles(self) -> int:
        """Number of triangular faces (3-vertex) in the mesh."""
        return sum(1 for f in self._faces if len(f) == 3)

    @property
    def nb_quads(self) -> int:
        """Number of quadrilateral faces (4-vertex) in the mesh."""
        return sum(1 for f in self._faces if len(f) == 4)

    def summary(self):
        """Print a summary of the mesh properties.

        Notes
        -----
        Displays the mesh name, vertex count, face count, and bounding box.
        """
        print("Mesh Summary")
        print(f"  Name           : {self.name}")
        print(f"  Vertices count : {self.nb_vertices}")
        print(f"  Faces count    : {self.nb_faces}")
        print(
            f"  Bounding box     : {self.vertices.min(axis=0)} to {self.vertices.max(axis=0)}"
        )
        print(f"  Metadata keys  : {self.faces_metadata.keys()}")
        print(f"  Quadrature     : {self.quadrature_method}")

    def __str__(self) -> str:
        return (
                f"Mesh(vertices=[[... {self.nb_vertices} vertices ...]], "
                + f"faces=[[... {self.nb_faces} faces ...]]"
                + (f", quadrature_method={repr(self.quadrature_method)}" if self.quadrature_method is not None else "")
                + (f", name={repr(self.name)}" if self.name is not None else "")
                + ")"
        )

    def __short_str__(self) -> str:
        if self.name is not None:
            return f"Mesh(..., name={repr(self.name)})"
        else:
            return "Mesh(...)"

    def __repr__(self) -> str:
        return self.__str__()

    def _repr_pretty_(self, p, cycle):
        p.text(self.__str__())

    def __rich_repr__(self):
        class CustomRepr:
            def __init__(self, n, kind):
                self.n = n
                self.kind = kind
            def __repr__(self):
                return "[[... {} {} ...]]".format(self.n, self.kind)
        yield "vertices", CustomRepr(self.nb_vertices, "vertices")
        yield "faces", CustomRepr(self.nb_faces, "faces")
        yield "quadrature_method", self.quadrature_method
        yield "name", self.name

    def show(self, *, backend=None, **kwargs):
        """Visualize the mesh using the specified backend.

        Parameters
        ----------
        backend : str, optional
            Visualization backend to use. Options are 'pyvista' or 'matplotlib'.
            By default, try several until an installed one is found.
        normal_vectors: bool, optional
            If True, display normal vectors on each face.
        **kwargs
            Additional keyword arguments passed to the visualization backend.
            See :mod:`~capytaine.meshes.visualization`

        Returns
        -------
        object
            Visualization object returned by the backend (e.g., matplotlib figure).

        Raises
        ------
        NotImplementedError
            If the specified backend is not supported.
        """
        return show_3d(self, backend=backend, **kwargs)


    ## INITIALISATION

    @staticmethod
    def _has_leading_count_column(arr: np.ndarray) -> bool:
        """Check if a 2D array has a leading column containing vertex counts.

        Parameters
        ----------
        arr : np.ndarray
            2D array of face data

        Returns
        -------
        bool
            True if the first column appears to be vertex counts
        """
        if arr.ndim != 2 or arr.shape[1] <= 3:
            return False

        expected_count = arr.shape[1] - 1
        for row in arr:
            # Check if first value could be a vertex count (3 or 4)
            # and if it matches the expected count (total cols - 1)
            if row[0] != expected_count and row[0] not in [3, 4]:
                return False
        return True

    def _process_faces(self, faces: List[List[int]] | np.ndarray) -> List[List[int]]:
        """Process the faces input for the Mesh class.

        Parameters
        ----------
        faces : np.ndarray or list
            The faces data to process.

        Returns
        -------
        list
            A list of faces, where each face is a list of vertex indices.

        Notes
        -----
        If the input is a 2D array with a leading column containing face vertex counts
        (e.g., [[3, v1, v2, v3], [4, v1, v2, v3, v4]]), the count column will be
        automatically stripped. This is checked per-row to support mixed triangle/quad meshes.
        """
        if faces is None:
            return []
        elif isinstance(faces, list):
            # assume it's already a list of lists of ints
            return [list(f) for f in faces]
        else:
            # fallback: convert array → nested list
            arr = np.asarray(faces, dtype=int)

            # Detect & strip a leading "count" column if present
            if self._has_leading_count_column(arr):
                arr = arr[:, 1:]

            return arr.tolist()

    @classmethod
    def from_list_of_faces(
        cls,
        list_faces,
        *,
        quadrature_method=None,
        faces_metadata=None,
        name=None,
        auto_clean=True,
        auto_check=True
    ) -> "Mesh":
        """
        Create a Mesh instance from a list of faces defined by vertex coordinates.

        Parameters
        ----------
        list_faces : list of list of list of float
            Each face is defined by a list of 3D coordinates. For example:
            [
                [[x1, y1, z1], [x2, y2, z2], [x3, y3, z3]],
                [[x4, y4, z4], [x5, y5, z5], [x6, y6, z6]]
            ]
        faces_metadata: Optional[Dict[str, np.ndarray]]
        name: str, optional
            A name for the new mesh.
        auto_clean : bool, optional
            Whether to automatically clean the mesh upon initialization. Defaults to True.
        auto_check : bool, optional
            Whether to automatically check mesh quality upon initialization. Defaults to True.

        Returns
        -------
        Mesh
            An instance of Mesh with:
            - unique vertices extracted from the input
            - faces defined as indices into the vertex array
        """
        unique_vertices = []
        vertices_map = {}
        indexed_faces = []

        for face in list_faces:
            indexed_face = []
            for coord in face:
                key = tuple(coord)
                if key not in vertices_map:
                    vertices_map[key] = len(unique_vertices)
                    unique_vertices.append(coord)
                indexed_face.append(vertices_map[key])
            indexed_faces.append(indexed_face)

        return cls(
            vertices=np.array(unique_vertices),
            faces=indexed_faces,
            quadrature_method=quadrature_method,
            faces_metadata=faces_metadata,
            name=name
        )

    def as_list_of_faces(self) -> List[List[List[float]]]:
        """
        Convert the Mesh instance to a list of faces defined by vertex coordinates.

        Returns
        -------
        list of list of list of float
            Each face is defined by a list of 3D coordinates. For example:
            [
                [[x1, y1, z1], [x2, y2, z2], [x3, y3, z3]],
                [[x4, y4, z4], [x5, y5, z5], [x6, y6, z6]]
            ]
        """
        list_faces = []
        for face in self._faces:
            face_coords = [self.vertices[idx].tolist() for idx in face]
            list_faces.append(face_coords)
        if len(self.faces_metadata) > 0:
            LOG.debug(f"Dropping metadata of {self} to export as list of faces.")
        return list_faces

    def as_array_of_faces(self) -> np.ndarray:
        """Similar to as_list_of_faces but returns an array of shape
        (nb_faces, 3, 3) if only triangles, or (nb_faces, 4, 3) otherwise.
        """
        array = self.vertices[self.faces[:, :], :]
        if self.nb_quads == 0:
            array = array[:, :3, :]
        return array

    def export(self, format, **kwargs):
        return export_mesh(self, format, **kwargs)

    ## INTERFACE FOR BEM SOLVER

    @cached_property
    def faces_vertices_centers(self) -> np.ndarray:
        """Calculate the center of vertices that form the faces.

        Returns
        -------
        np.ndarray
            Array of shape (n_faces, 3) containing the centroid of each face's vertices.
        """
        centers_vertices = []
        for face in self._faces:
            if face[3] != face[2]:
                a, b, c, d = get_vertices_face(face, self.vertices)
                mean = (a + b + c + d) / 4
                centers_vertices.append(mean)
            else:
                a, b, c = get_vertices_face(face, self.vertices)
                mean = (a + b + c) / 3
                centers_vertices.append(mean)
        return np.array(centers_vertices)

    @cached_property
    def faces_normals(self) -> np.ndarray:
        """Normal vectors for each face.

        Returns
        -------
        np.ndarray
            Array of shape (n_faces, 3) containing unit normal vectors.
        """
        return compute_faces_normals(self.vertices, self._faces)

    @cached_property
    def faces_areas(self) -> np.ndarray:
        """Surface area of each face.

        Returns
        -------
        np.ndarray
            Array of shape (n_faces,) containing the area of each face.
        """
        return compute_faces_areas(self.vertices, self._faces)

    @cached_property
    def faces_centers(self) -> np.ndarray:
        """Geometric centers of each face.

        Returns
        -------
        np.ndarray
            Array of shape (n_faces, 3) containing the center point of each face.
        """
        return compute_faces_centers(self.vertices, self._faces)

    @cached_property
    def faces_radiuses(self) -> np.ndarray:
        """Radii of each face (circumradius or characteristic size).

        Returns
        -------
        np.ndarray
            Array of shape (n_faces,) containing the radius of each face.
        """
        return compute_faces_radii(self.vertices, self._faces)

    @cached_property
    def faces(self) -> np.ndarray:
        """Face connectivity as quadrilateral array.

        Returns
        -------
        np.ndarray
            Array of shape (n_faces, 4) where triangular faces are padded
            by repeating the last vertex.

        Notes
        -----
        This property converts all faces to a uniform quad representation
        for compatibility with libraries expecting fixed-width face arrays.
        """
        faces_as_quad = [f if len(f) == 4 else f + [f[-1]] for f in self._faces]
        return np.array(faces_as_quad, dtype=int)

    @cached_property
    def quadrature_points(self) -> Tuple[np.ndarray, np.ndarray]:
        """Quadrature points and weights for numerical integration.

        Returns
        -------
        tuple[np.ndarray, np.ndarray]
            (points, weights) where points has shape (n_faces, 1, 3) and
            weights has shape (n_faces, 1), using face centers and areas.
        """
        if self.quadrature_method is None:
            return (self.faces_centers.reshape((-1, 1, 3)), self.faces_areas.reshape(-1, 1))
        elif self.quadrature_method == "Gauss-Legendre 2":
            return compute_gauss_legendre_2_quadrature(self.vertices, self.faces)
        else:
            raise ValueError(f"Unknown quadrature_method: {self.quadrature_method}")

    ## TRANSFORMATIONS

    def with_quadrature(self, quadrature_method):
        return Mesh(
                self.vertices,
                self.faces,
                faces_metadata=self.faces_metadata,
                quadrature_method=quadrature_method,
                name=self.name,
                auto_clean=False,
                auto_check=False
                )

    def extract_faces(self, faces_id, *, name=None) -> "Mesh":
        """Extract a subset of faces by their indices and return a new Mesh instance.

        Parameters
        ----------
        faces_id : array_like
            Indices of faces to extract.
        name: str, optional
            A name for the new mesh

        Returns
        -------
        Mesh
            New mesh containing only the specified faces.
        """
        if isinstance(faces_id, np.ndarray):
            faces_id = faces_id.ravel()
        all_faces = self.as_list_of_faces()
        selected_faces = [all_faces[i] for i in faces_id]
        return Mesh.from_list_of_faces(
            selected_faces,
            faces_metadata={k: self.faces_metadata[k][selected_faces, ...] for k in self.faces_metadata},
            name=name,
            quadrature_method=self.quadrature_method,
            auto_clean=False,
            auto_check=False
        )

    def translated(self, shift, *, name=None) -> "Mesh":
        """Return a new Mesh translated along vector-like `shift`."""
        return Mesh(
            vertices=self.vertices + np.asarray(shift),
            faces=self._faces,
            faces_metadata=self.faces_metadata,
            name=name,
            quadrature_method=self.quadrature_method,
            auto_clean=False,
            auto_check=False,
        )

    def rotated_with_matrix(self, R, *, name=None) -> "Mesh":
        """Return a new Mesh rotated using the provided 3×3 rotation matrix."""
        new_vertices = self.vertices @ R.T
        return Mesh(
            vertices=new_vertices,
            faces=self._faces,
            name=name,
            faces_metadata=self.faces_metadata,
            quadrature_method=self.quadrature_method,
            auto_clean=False,
            auto_check=False,
        )

    def mirrored(self, plane: Literal['xOz', 'yOz'], *, name=None) -> "Mesh":
        new_vertices = self.vertices.copy()
        if plane == "xOz":
            new_vertices[:, 1] *= -1
        elif plane == "yOz":
            new_vertices[:, 0] *= -1
        else:
            raise ValueError(f"Unsupported value for plane: {plane}")
        new_faces = [f[::-1] for f in self._faces]  # Invert normals
        if name is None and self.name is not None:
            name = f"mirrored_{self.name}"
        return Mesh(
            new_vertices,
            new_faces,
            faces_metadata=self.faces_metadata,
            quadrature_method=self.quadrature_method,
            name=name,
            auto_clean=False,
            auto_check=False
        )

    def join_meshes(*meshes: List["Mesh"], return_masks=False, name=None) -> "Mesh":
        """Join several meshes and return a new Mesh instance.

        Parameters
        ----------
        meshes: List[Mesh]
            Meshes to be joined
        return_masks: bool, optional
            If True, additionally return a list of numpy masks establishing the
            origin of each face in the new mesh.
            (Default: False)
        name: str, optional
            A name for the new object

        Returns
        -------
        Mesh
            New mesh containing vertices and faces from all meshes.

        See Also
        --------
        __add__ : Implements the + operator for mesh joining.
        """
        if not all(isinstance(m, Mesh) for m in meshes):
            raise TypeError("Only Mesh instances can be added together.")

        faces = sum((m.as_list_of_faces() for m in meshes), [])

        if return_masks:
            # Add a temporary metadata to keep track of the origin of each face
            meshes = [m.with_metadata(origin_mesh_index=np.array([i]*m.nb_faces))
                      for i, m in enumerate(meshes)]

        faces_metadata = {k: np.concatenate([m.faces_metadata[k] for m in meshes], axis=0)
                             for k in AbstractMesh._common_metadata_keys(*meshes)}

        if all(meshes[0].quadrature_method == m.quadrature_method for m in meshes[1:]):
            quadrature_method = meshes[0].quadrature_method
        else:
            LOG.info("Dropping inconsistent quadrature method when joining meshes")
            quadrature_method = None

        if name is None and all(m.name is not None for m in meshes):
            name = "+".join([m.name for m in meshes])

        joined_mesh = Mesh.from_list_of_faces(
            faces,
            quadrature_method=quadrature_method,
            faces_metadata=faces_metadata,
            name=name,
            auto_check=False,
        )
        # If list of faces is trimmed for some reason, metadata will be updated accordingly

        if return_masks:
            # Extract the temporary metadata
            masks = [joined_mesh.faces_metadata['origin_mesh_index'] == i for i in range(len(meshes))]
            return joined_mesh.without_metadata('origin_mesh_index'), masks
        else:
            return joined_mesh

    def generate_lid(self, z=0.0, faces_max_radius=None, name=None):
        """
        Return a mesh of the internal free surface of the body.

        Parameters
        ----------
        z: float, optional
            Vertical position of the lid. Default: 0.0
        faces_max_radius: float, optional
            resolution of the mesh of the lid.
            Default: mean of hull mesh resolution.
        name: str, optional
            A name for the new mesh

        Returns
        -------
        Mesh
            lid of internal surface
        """
        from capytaine.meshes.predefined.rectangles import mesh_rectangle

        LOG.debug(f"Generating lid for {self.__str__()}")

        if name is None and self.name is not None:
            name = "lid for {}".format(self.name)

        clipped_hull_mesh = self.clipped(normal=(0, 0, 1), origin=(0, 0, z))
        # Alternatively: could keep only faces below z without proper clipping,
        # and it would work similarly.

        if clipped_hull_mesh.nb_faces == 0:
            return Mesh(None, None, name=name)

        x_span = clipped_hull_mesh.vertices[:, 0].max() - clipped_hull_mesh.vertices[:, 0].min()
        y_span = clipped_hull_mesh.vertices[:, 1].max() - clipped_hull_mesh.vertices[:, 1].min()
        x_mean = (clipped_hull_mesh.vertices[:, 0].max() + clipped_hull_mesh.vertices[:, 0].min())/2
        y_mean = (clipped_hull_mesh.vertices[:, 1].max() + clipped_hull_mesh.vertices[:, 1].min())/2

        if faces_max_radius is None:
            faces_max_radius = np.mean(clipped_hull_mesh.faces_radiuses)

        candidate_lid_size = (
                    max(faces_max_radius/2, 1.1*x_span),
                    max(faces_max_radius/2, 1.1*y_span),
                )
        # The size of the lid is at least the characteristic length of a face

        candidate_lid_mesh = mesh_rectangle(
                size=(candidate_lid_size[1], candidate_lid_size[0]),  # TODO Fix: Exchange x and y in mesh_rectangle
                faces_max_radius=faces_max_radius,
                center=(x_mean, y_mean, z),
                normal=(0.0, 0.0, -1.0),
                name="candidate_lid_mesh"
                )

        candidate_lid_points = candidate_lid_mesh.vertices[:, 0:2]

        hull_faces = clipped_hull_mesh.vertices[clipped_hull_mesh.faces, 0:2]
        edges_of_hull_faces = hull_faces[:, [1, 2, 3, 0], :] - hull_faces[:, :, :]  # Vectors between two consecutive points in a face
        # edges_of_hull_faces.shape = (nb_full_faces, 4, 2)
        lid_points_in_local_coords = candidate_lid_points[:, np.newaxis, np.newaxis, :] - hull_faces[:, :, :]
        # lid_points_in_local_coords.shape = (nb_candidate_lid_points, nb_full_faces, 4, 2)
        side_of_hull_edges = (lid_points_in_local_coords[..., 0] * edges_of_hull_faces[..., 1]
                             - lid_points_in_local_coords[..., 1] * edges_of_hull_faces[..., 0])
        # side_of_hull_edges.shape = (nb_candidate_lid_points, nb_full_faces, 4)
        point_is_above_panel = np.all(side_of_hull_edges <= 0, axis=-1) | np.all(side_of_hull_edges >= 0, axis=-1)
        # point_is_above_panel.shape = (nb_candidate_lid_points, nb_full_faces)

        # For all point in candidate_lid_points, and for all edges of all faces of
        # the hull mesh, check on which side of the edge is the point by using a
        # cross product.
        # If a point on the same side of all edges of a face, then it is inside.

        nb_panels_below_point = np.sum(point_is_above_panel, axis=-1)
        needs_lid = (nb_panels_below_point % 2 == 1).nonzero()[0]

        lid_faces = candidate_lid_mesh.faces[np.all(np.isin(candidate_lid_mesh.faces, needs_lid), axis=-1), :]

        if len(lid_faces) == 0:
            return Mesh(None, None, name=name)

        lid_mesh = Mesh(candidate_lid_mesh.vertices, lid_faces, name=name, auto_check=False)
        return lid_mesh

    def extract_lid(self, z=0.0):
        """
        Split the mesh into a mesh of the hull and a mesh of the lid.
        By default, the lid is composed of the horizontal faces on the z=0 plane.

        Parameters
        ----------
        plane: Plane
            The plane on which to look for lid faces.

        Returns
        -------
        2-ple of Mesh
            hull mesh and lid mesh
        """
        def is_on_plane(i_face):
            return np.isclose(self.faces_centers[i_face, 2], z) and (\
                    np.allclose(self.faces_normals[i_face, :], np.array([0.0, 0.0, 1.0])) or \
                    np.allclose(self.faces_normals[i_face, :], np.array([0.0, 0.0, -1.0]))
                                                                     )

        faces_on_plane = [
            i_face for i_face in range(self.nb_faces) if is_on_plane(i_face)
        ]
        lid_mesh = self.extract_faces(faces_on_plane)
        hull_mesh = self.extract_faces(list(set(range(self.nb_faces)) - set(faces_on_plane)))
        return hull_mesh, lid_mesh

    def with_normal_vector_going_down(self, **kwargs) -> "Mesh":
        # Kwargs are for backward compatibility with former inplace implementation of this.
        # It could be removed in the final release.
        """Ensure normal vectors point downward (negative z-direction).

        Returns
        -------
        Mesh
            Self if normals already point down, otherwise modifies face orientation.

        Notes
        -----
        Used for lid meshes to avoid irregular frequency issues by ensuring
        consistent normal vector direction.
        """
        # For lid meshes for irregular frequencies removal
        if np.allclose(self.faces_normals[:, 2], np.ones((self.nb_faces,))):
            # The mesh is horizontal with normal vectors going up
            LOG.warning(
                f"Inverting the direction of the normal vectors of {self} to be downward."
            )
            return Mesh(
                vertices=self.vertices,
                faces=self.faces[:, ::-1],
                faces_metadata=self.faces_metadata,
                quadrature_method=self.quadrature_method,
                name=self.name,
                auto_clean=False,
                auto_check=False,
            )
        else:
            return self

    def copy(self, *, faces_metadata=None, name=None) -> Mesh:
        # No-op for backward compatibility
        if faces_metadata is None:
            faces_metadata = self.faces_metadata.copy()
        if name is None:
            name = self.name
        return Mesh(
            vertices=self.vertices,
            faces=self._faces,
            faces_metadata=faces_metadata,
            quadrature_method=self.quadrature_method,
            name=name,
            auto_clean=False,
            auto_check=False
        )

    def merged(self, *, name=None) -> Mesh:
        # No-op to be extended to symmetries
        return self.copy(name=name)

    def clipped(self, *, origin, normal, name=None) -> "Mesh":
        """
        Clip the mesh by a plane defined by `origin` and `normal`.

        Parameters
        ----------
        origin : np.ndarray
            The point in space where the clipping plane intersects (3D point).
        normal : np.ndarray
            The normal vector defining the orientation of the clipping plane.
        name: Optional[str]
            A name for the newly created mesh

        Returns
        -------
        Mesh
            A new Mesh instance that has been clipped.
        """
        LOG.debug(f"Clipping {self.__str__()} with origin={origin} and normal={normal}")
        new_vertices, new_faces, face_parent = \
                clip_faces(self.vertices, self._faces, normal, origin)
        new_metadata = {k: self.faces_metadata[k][face_parent] for k in self.faces_metadata}
        if name is None and self.name is not None:
            name = f"{self.name}_clipped"
        return Mesh(
            vertices=new_vertices,
            faces=new_faces,
            faces_metadata=new_metadata,
            quadrature_method=self.quadrature_method,
            name=name,
            auto_check=False,
        )
