"""
build123d topology

name: composite.py
by:   Gumyr
date: January 07, 2025

desc:

This module defines advanced composite geometric entities for the build123d CAD system. It
introduces the `Compound` class as a central concept for managing groups of shapes, alongside
specialized subclasses such as `Curve`, `Sketch`, and `Part` for 1D, 2D, and 3D objects,
respectively. These classes streamline the construction and manipulation of complex geometric
assemblies.

Key Features:
- **Compound Class**:
  - Represents a collection of geometric shapes (e.g., vertices, edges, faces, solids) grouped
    hierarchically.
  - Supports operations like adding, removing, and combining shapes, as well as querying volumes,
    centers, and intersections.
  - Provides utility methods for unwrapping nested compounds and generating 3D text or coordinate
    system triads.

- **Specialized Subclasses**:
  - `Curve`: Handles 1D objects like edges and wires.
  - `Sketch`: Focused on 2D objects, such as faces.
  - `Part`: Manages 3D solids and assemblies.

- **Advanced Features**:
  - Includes Boolean operations, hierarchy traversal, and bounding box-based intersection detection.
  - Supports transformations, child-parent relationships, and dynamic updates.

This module leverages OpenCascade for robust geometric operations while offering a Pythonic
interface for efficient and extensible CAD modeling workflows.

license:

    Copyright 2025 Gumyr

    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 copy
import os
import sys
import warnings
from itertools import combinations
from typing import Type, Union

from collections.abc import Iterable, Iterator, Sequence

import OCP.TopAbs as ta
from OCP.BRepAlgoAPI import BRepAlgoAPI_Fuse
from OCP.Font import (
    Font_FA_Bold,
    Font_FA_Italic,
    Font_FA_Regular,
    Font_FontMgr,
    Font_SystemFont,
)
from OCP.GProp import GProp_GProps
from OCP.NCollection import NCollection_Utf8String
from OCP.StdPrs import StdPrs_BRepTextBuilder as Font_BRepTextBuilder, StdPrs_BRepFont
from OCP.TCollection import TCollection_AsciiString
from OCP.TopAbs import TopAbs_ShapeEnum
from OCP.TopoDS import (
    TopoDS,
    TopoDS_Builder,
    TopoDS_Compound,
    TopoDS_Iterator,
    TopoDS_Shape,
)
from anytree import PreOrderIter
from build123d.build_enums import Align, CenterOf, FontStyle
from build123d.geometry import (
    TOLERANCE,
    Axis,
    Color,
    Location,
    Plane,
    Vector,
    VectorLike,
    logger,
)
from typing_extensions import Self

from .one_d import Edge, Wire, Mixin1D
from .shape_core import (
    Shape,
    ShapeList,
    SkipClean,
    Joint,
    downcast,
    shapetype,
    topods_dim,
)
from .three_d import Mixin3D, Solid
from .two_d import Face, Shell
from .utils import (
    _extrude_topods_shape,
    _make_topods_compound_from_shapes,
    tuplify,
    unwrapped_shapetype,
)
from .zero_d import Vertex


class Compound(Mixin3D, Shape[TopoDS_Compound]):
    """A Compound in build123d is a topological entity representing a collection of
    geometric shapes grouped together within a single structure. It serves as a
    container for organizing diverse shapes like edges, faces, or solids. This
    hierarchical arrangement facilitates the construction of complex models by
    combining simpler shapes. Compound plays a pivotal role in managing the
    composition and structure of intricate 3D models in computer-aided design
    (CAD) applications, allowing engineers and designers to work with assemblies
    of shapes as unified entities for efficient modeling and analysis."""

    order = 4.0

    project_to_viewport = Mixin1D.project_to_viewport
    # ---- Constructor ----

    def __init__(
        self,
        obj: TopoDS_Compound | Iterable[Shape] | None = None,
        label: str = "",
        color: Color | None = None,
        material: str = "",
        joints: dict[str, Joint] | None = None,
        parent: Compound | None = None,
        children: Sequence[Shape] | None = None,
    ):
        """Build a Compound from Shapes

        Args:
            obj (TopoDS_Compound | Iterable[Shape], optional): OCCT Compound or shapes
            label (str, optional): Defaults to ''.
            color (Color, optional): Defaults to None.
            material (str, optional): tag for external tools. Defaults to ''.
            joints (dict[str, Joint], optional): names joints. Defaults to None.
            parent (Compound, optional): assembly parent. Defaults to None.
            children (Sequence[Shape], optional): assembly children. Defaults to None.
        """

        if isinstance(obj, Iterable):
            topods_compound = _make_topods_compound_from_shapes(
                [s.wrapped for s in obj]
            )
        else:
            topods_compound = obj

        super().__init__(
            obj=topods_compound,
            label=label,
            color=color,
            parent=parent,
        )
        self.material = "" if material is None else material
        self.joints = {} if joints is None else joints
        self.children = [] if children is None else children

    # ---- Properties ----

    @property
    def _dim(self) -> int | None:
        """The dimension of the shapes within the Compound - None if inconsistent"""
        return topods_dim(self.wrapped)

    @property
    def volume(self) -> float:
        """volume - the volume of this Compound"""
        # when density == 1, mass == volume
        return sum(i.volume for i in [*self.get_type(Solid), *self.get_type(Shell)])

    # ---- Class Methods ----

    @classmethod
    def cast(
        cls, obj: TopoDS_Shape
    ) -> Vertex | Edge | Wire | Face | Shell | Solid | Compound:
        "Returns the right type of wrapper, given a OCCT object"

        # define the shape lookup table for casting
        constructor_lut = {
            ta.TopAbs_VERTEX: Vertex,
            ta.TopAbs_EDGE: Edge,
            ta.TopAbs_WIRE: Wire,
            ta.TopAbs_FACE: Face,
            ta.TopAbs_SHELL: Shell,
            ta.TopAbs_SOLID: Solid,
            ta.TopAbs_COMPOUND: Compound,
        }

        shape_type = shapetype(obj)
        # NB downcast is needed to handle TopoDS_Shape types
        return constructor_lut[shape_type](downcast(obj))

    @classmethod
    def extrude(cls, obj: Shell, direction: VectorLike) -> Compound:
        """extrude

        Extrude a Shell into a Compound.

        Args:
            direction (VectorLike): direction and magnitude of extrusion

        Raises:
            ValueError: Unsupported class
            RuntimeError: Generated invalid result

        Returns:
            Edge: extruded shape
        """
        return Compound(
            TopoDS.Compound_s(_extrude_topods_shape(obj.wrapped, direction))
        )

    @classmethod
    def make_text(
        cls,
        txt: str,
        font_size: float,
        font: str = "Arial",
        font_path: str | None = None,
        font_style: FontStyle = FontStyle.REGULAR,
        align: Align | tuple[Align, Align] | None = (Align.CENTER, Align.CENTER),
        position_on_path: float = 0.0,
        text_path: Edge | Wire | None = None,
    ) -> Compound:
        """2D Text that optionally follows a path.

        The text that is created can be combined as with other sketch features by specifying
        a mode or rotated by the given angle.  In addition, edges have been previously created
        with arc or segment, the text will follow the path defined by these edges. The start
        parameter can be used to shift the text along the path to achieve precise positioning.

        Args:
            txt: text to be rendered
            font_size: size of the font in model units
            font: font name
            font_path: path to font file
            font_style: text style. Defaults to FontStyle.REGULAR.
            align (Union[Align, tuple[Align, Align]], optional): align min, center, or max
                of object. Defaults to (Align.CENTER, Align.CENTER).
            position_on_path: the relative location on path to position the text,
                between 0.0 and 1.0. Defaults to 0.0.
            text_path: a path for the text to follows. Defaults to None - linear text.

        Returns:
            a Compound object containing multiple Faces representing the text

        Examples::

            fox = Compound.make_text(
                txt="The quick brown fox jumped over the lazy dog",
                font_size=10,
                position_on_path=0.1,
                text_path=jump_edge,
            )

        """
        # pylint: disable=too-many-locals

        def position_face(orig_face: Face) -> Face:
            """
            Reposition a face to the provided path

            Local coordinates are used to calculate the position of the face
            relative to the path. Global coordinates to position the face.
            """
            assert text_path is not None
            bbox = orig_face.bounding_box()
            face_bottom_center = Vector((bbox.min.X + bbox.max.X) / 2, 0, 0)
            relative_position_on_wire = (
                position_on_path + face_bottom_center.X / path_length
            )
            wire_tangent = text_path.tangent_at(relative_position_on_wire)
            wire_angle = Vector(1, 0, 0).get_signed_angle(wire_tangent)
            wire_position = text_path.position_at(relative_position_on_wire)

            return orig_face.translate(wire_position - face_bottom_center).rotate(
                Axis(wire_position, (0, 0, 1)),
                -wire_angle,
            )

        if sys.platform.startswith("linux"):
            os.environ["FONTCONFIG_FILE"] = "/etc/fonts/fonts.conf"
            os.environ["FONTCONFIG_PATH"] = "/etc/fonts/"

        font_kind = {
            FontStyle.REGULAR: Font_FA_Regular,
            FontStyle.BOLD: Font_FA_Bold,
            FontStyle.ITALIC: Font_FA_Italic,
        }[font_style]

        mgr = Font_FontMgr.GetInstance_s()

        if font_path and mgr.CheckFont(TCollection_AsciiString(font_path).ToCString()):
            font_t = Font_SystemFont(TCollection_AsciiString(font_path))
            font_t.SetFontPath(font_kind, TCollection_AsciiString(font_path))
            mgr.RegisterFont(font_t, True)

        else:
            font_t = mgr.FindFont(TCollection_AsciiString(font), font_kind)

        logger.info(
            "Creating text with font %s located at %s",
            font_t.FontName().ToCString(),
            font_t.FontPath(font_kind).ToCString(),
        )

        builder = Font_BRepTextBuilder()
        font_i = StdPrs_BRepFont(
            NCollection_Utf8String(font_t.FontName().ToCString()),
            font_kind,
            float(font_size),
        )
        text_flat = Compound(builder.Perform(font_i, NCollection_Utf8String(txt)))

        # Align the text from the bounding box
        align_text = tuplify(align, 2)
        text_flat = text_flat.translate(
            Vector(*text_flat.bounding_box().to_align_offset(align_text))
        )

        if text_path is not None:
            path_length = text_path.length
            text_flat = Compound([position_face(f) for f in text_flat.faces()])

        return text_flat

    @classmethod
    def make_triad(cls, axes_scale: float) -> Compound:
        """The coordinate system triad (X, Y, Z axes)"""
        x_axis = Edge.make_line((0, 0, 0), (axes_scale, 0, 0))
        y_axis = Edge.make_line((0, 0, 0), (0, axes_scale, 0))
        z_axis = Edge.make_line((0, 0, 0), (0, 0, axes_scale))
        arrow_arc = Edge.make_spline(
            [(0, 0, 0), (-axes_scale / 20, axes_scale / 30, 0)],
            [(-1, 0, 0), (-1, 1.5, 0)],
        )
        arrow = Wire([arrow_arc, copy.copy(arrow_arc).mirror(Plane.XZ)])
        x_label = (
            Compound.make_text(
                "X", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
            )
            .move(Location(x_axis @ 1))
            .edges()
        )
        y_label = (
            Compound.make_text(
                "Y", font_size=axes_scale / 4, align=(Align.MIN, Align.CENTER)
            )
            .rotate(Axis.Z, 90)
            .move(Location(y_axis @ 1))
            .edges()
        )
        z_label = (
            Compound.make_text(
                "Z", font_size=axes_scale / 4, align=(Align.CENTER, Align.MIN)
            )
            .rotate(Axis.Y, 90)
            .rotate(Axis.X, 90)
            .move(Location(z_axis @ 1))
            .edges()
        )
        triad = Curve(
            [
                x_axis,
                y_axis,
                z_axis,
                arrow.moved(Location(x_axis @ 1)),
                arrow.rotate(Axis.Z, 90).moved(Location(y_axis @ 1)),
                arrow.rotate(Axis.Y, -90).moved(Location(z_axis @ 1)),
                *x_label,
                *y_label,
                *z_label,
            ]
        )

        return triad

    # ---- Instance Methods ----

    def __add__(self, other: None | Shape | Iterable[Shape]) -> Compound:
        """Combine other to self `+` operator

        Note that if all of the objects are connected Edges/Wires the result
        will be a Wire, otherwise a Shape.
        """
        if self._dim == 1:
            curve = Curve() if self.wrapped is None else Curve(self.wrapped)
            self.copy_attributes_to(curve, ["wrapped", "_NodeMixin__children"])
            return curve + other

        summands: ShapeList[Shape]
        if other is None:
            summands = ShapeList()
        else:
            summands = ShapeList(
                shape
                for o in ([other] if isinstance(other, Shape) else other)
                if o is not None
                for shape in o.get_top_level_shapes()
            )
        # If there is nothing to add return the original object
        if not summands:
            return self

        summands = ShapeList(
            s for s in self.get_top_level_shapes() + summands if s is not None
        )

        # Only fuse the parts if necessary
        if len(summands) <= 1:
            result: Shape = Compound(summands[0:1])
        else:
            fuse_op = BRepAlgoAPI_Fuse()
            fuse_op.SetFuzzyValue(TOLERANCE)
            self.copy_attributes_to(summands[0], ["wrapped", "_NodeMixin__children"])
            bool_result = self._bool_op(summands[:1], summands[1:], fuse_op)
            if isinstance(bool_result, list):
                result = Compound(bool_result)
                self.copy_attributes_to(result, ["wrapped", "_NodeMixin__children"])
            else:
                result = bool_result

        if SkipClean.clean:
            result = result.clean()

        return result

    def __and__(self, other: Shape | Iterable[Shape]) -> Compound:
        """Intersect other to self `&` operator"""
        intersection = Shape.__and__(self, other)
        intersection = Compound(
            intersection if isinstance(intersection, list) else [intersection]
        )
        self.copy_attributes_to(intersection, ["wrapped", "_NodeMixin__children"])
        return intersection

    def __bool__(self) -> bool:
        """
        Check if empty.
        """

        return TopoDS_Iterator(self.wrapped).More()

    def __iter__(self) -> Iterator[Shape]:
        """
        Iterate over subshapes.

        """

        iterator = TopoDS_Iterator(self.wrapped)

        while iterator.More():
            yield Compound.cast(iterator.Value())
            iterator.Next()

    def __len__(self) -> int:
        """Return the number of subshapes"""
        count = 0
        if self.wrapped is not None:
            for _ in self:
                count += 1
        return count

    def __repr__(self):
        """Return Compound info as string"""
        if hasattr(self, "label") and hasattr(self, "children"):
            result = (
                f"{self.__class__.__name__} at {id(self):#x}, label({self.label}), "
                + f"#children({len(self.children)})"
            )
        else:
            result = f"{self.__class__.__name__} at {id(self):#x}"
        return result

    def __sub__(self, other: None | Shape | Iterable[Shape]) -> Compound:
        """Cut other to self `-` operator"""
        difference = Shape.__sub__(self, other)
        difference = Compound(
            difference if isinstance(difference, list) else [difference]
        )
        self.copy_attributes_to(difference, ["wrapped", "_NodeMixin__children"])

        return difference

    def center(self, center_of: CenterOf = CenterOf.MASS) -> Vector:
        """Return center of object

        Find center of object

        Args:
            center_of (CenterOf, optional): center option. Defaults to CenterOf.MASS.

        Raises:
            ValueError: Center of GEOMETRY is not supported for this object
            NotImplementedError: Unable to calculate center of mass of this object

        Returns:
            Vector: center
        """
        if center_of == CenterOf.GEOMETRY:
            raise ValueError("Center of GEOMETRY is not supported for this object")
        if center_of == CenterOf.MASS:
            properties = GProp_GProps()
            calc_function = Shape.shape_properties_LUT[unwrapped_shapetype(self)]
            if calc_function:
                calc_function(self.wrapped, properties)
                middle = Vector(properties.CentreOfMass())
            else:
                raise NotImplementedError
        elif center_of == CenterOf.BOUNDING_BOX:
            middle = self.bounding_box().center()
        return middle

    def compound(self) -> Compound | None:
        """Return the Compound"""
        shape_list = self.compounds()
        entity_count = len(shape_list)
        if entity_count != 1:
            warnings.warn(
                f"Found {entity_count} compounds, returning first",
                stacklevel=2,
            )
        return shape_list[0] if shape_list else None

    def compounds(self) -> ShapeList[Compound]:
        """compounds - all the compounds in this Shape"""
        if self.wrapped is None:
            return ShapeList()
        if isinstance(self.wrapped, TopoDS_Compound):
            # pylint: disable=not-an-iterable
            sub_compounds = [c for c in self if isinstance(c.wrapped, TopoDS_Compound)]
            sub_compounds.append(self)
        else:
            sub_compounds = []
        return ShapeList(sub_compounds)

    def do_children_intersect(
        self, include_parent: bool = False, tolerance: float = 1e-5
    ) -> tuple[bool, tuple[Shape | None, Shape | None], float]:
        """Do Children Intersect

        Determine if any of the child objects within a Compound/assembly intersect by
        intersecting each of the shapes with each other and checking for
        a common volume.

        Args:
            include_parent (bool, optional): check parent for intersections. Defaults to False.
            tolerance (float, optional): maximum allowable volume difference. Defaults to 1e-5.

        Returns:
            tuple[bool, tuple[Shape, Shape], float]:
                do the object intersect, intersecting objects, volume of intersection
        """
        children: list[Shape] = list(PreOrderIter(self))
        if not include_parent:
            children.pop(0)  # remove parent
        # children_bbox = [child.bounding_box().to_solid() for child in children]
        children_bbox = [
            Solid.from_bounding_box(child.bounding_box()) for child in children
        ]
        child_index_pairs = [
            tuple(map(int, comb))
            for comb in combinations(list(range(len(children))), 2)
        ]
        for child_index_pair in child_index_pairs:
            # First check for bounding box intersections ..
            # .. then confirm with actual object intersections which could be complex
            bbox_intersection = children_bbox[child_index_pair[0]].intersect(
                children_bbox[child_index_pair[1]]
            )
            if bbox_intersection is not None:
                obj_intersection = children[child_index_pair[0]].intersect(
                    children[child_index_pair[1]]
                )
                if obj_intersection is not None:
                    common_volume = (
                        0.0
                        if isinstance(obj_intersection, list)
                        else obj_intersection.volume
                    )
                    if common_volume > tolerance:
                        return (
                            True,
                            (
                                children[child_index_pair[0]],
                                children[child_index_pair[1]],
                            ),
                            common_volume,
                        )
        return (False, (None, None), 0.0)

    def get_type(
        self,
        obj_type: (
            type[Vertex]
            | type[Edge]
            | type[Face]
            | type[Shell]
            | type[Solid]
            | type[Wire]
        ),
    ) -> list[Vertex | Edge | Face | Shell | Solid | Wire]:
        """get_type

        Extract the objects of the given type from a Compound. Note that this
        isn't the same as Faces() etc. which will extract Faces from Solids.

        Args:
            obj_type (Union[Vertex, Edge, Face, Shell, Solid, Wire]): Object types to extract

        Returns:
            list[Union[Vertex, Edge, Face, Shell, Solid, Wire]]: Extracted objects
        """

        type_map = {
            Vertex: TopAbs_ShapeEnum.TopAbs_VERTEX,
            Edge: TopAbs_ShapeEnum.TopAbs_EDGE,
            Face: TopAbs_ShapeEnum.TopAbs_FACE,
            Shell: TopAbs_ShapeEnum.TopAbs_SHELL,
            Solid: TopAbs_ShapeEnum.TopAbs_SOLID,
            Wire: TopAbs_ShapeEnum.TopAbs_WIRE,
            Compound: TopAbs_ShapeEnum.TopAbs_COMPOUND,
        }
        results = []
        for comp in self.compounds():
            iterator = TopoDS_Iterator()
            iterator.Initialize(comp.wrapped)
            while iterator.More():
                child = iterator.Value()
                if child.ShapeType() == type_map[obj_type]:
                    results.append(obj_type(downcast(child)))
                iterator.Next()

        return results

    def unwrap(self, fully: bool = True) -> Self | Shape:
        """Strip unnecessary Compound wrappers

        Args:
            fully (bool, optional): return base shape without any Compound
                wrappers (otherwise one Compound is left). Defaults to True.

        Returns:
            Union[Self, Shape]: base shape
        """
        if len(self) == 1:
            single_element = next(iter(self))
            self.copy_attributes_to(single_element, ["wrapped", "_NodeMixin__children"])

            # If the single element is another Compound, unwrap it recursively
            if isinstance(single_element, Compound):
                # Unwrap recursively and copy attributes down
                unwrapped = single_element.unwrap(fully)
                if not fully:
                    unwrapped = type(self)(unwrapped.wrapped)
                self.copy_attributes_to(unwrapped, ["wrapped", "_NodeMixin__children"])
                return unwrapped

            return single_element if fully else self

        # If there are no elements or more than one element, return self
        return self

    def _post_attach(self, parent: Compound):
        """Method call after attaching to `parent`."""
        logger.debug("Updated parent of %s to %s", self.label, parent.label)
        parent.wrapped = _make_topods_compound_from_shapes(
            [c.wrapped for c in parent.children]
        )

    def _post_attach_children(self, children: Iterable[Shape]):
        """Method call after attaching `children`."""
        if children:
            kids = ",".join([child.label for child in children])
            logger.debug("Adding children %s to %s", kids, self.label)
            self.wrapped = _make_topods_compound_from_shapes(
                [c.wrapped for c in self.children]
            )
        # else:
        #     logger.debug("Adding no children to %s", self.label)

    def _post_detach(self, parent: Compound):
        """Method call after detaching from `parent`."""
        logger.debug("Removing parent of %s (%s)", self.label, parent.label)
        if parent.children:
            parent.wrapped = _make_topods_compound_from_shapes(
                [c.wrapped for c in parent.children]
            )
        else:
            parent.wrapped = None

    def _post_detach_children(self, children):
        """Method call before detaching `children`."""
        if children:
            kids = ",".join([child.label for child in children])
            logger.debug("Removing children %s from %s", kids, self.label)
            self.wrapped = _make_topods_compound_from_shapes(
                [c.wrapped for c in self.children]
            )
        # else:
        #     logger.debug("Removing no children from %s", self.label)

    def _pre_attach(self, parent: Compound):
        """Method call before attaching to `parent`."""
        if not isinstance(parent, Compound):
            raise ValueError("`parent` must be of type Compound")

    def _pre_attach_children(self, children):
        """Method call before attaching `children`."""
        if not all(isinstance(child, Shape) for child in children):
            raise ValueError("Each child must be of type Shape")

    def _remove(self, shape: Shape) -> Compound:
        """Return self with the specified shape removed.

        Args:
          shape: Shape:
        """
        comp_builder = TopoDS_Builder()
        comp_builder.Remove(self.wrapped, shape.wrapped)
        return self


class Curve(Compound):
    """A Compound containing 1D objects - aka Edges"""

    __add__ = Mixin1D.__add__  # type: ignore
    # ---- Properties ----

    @property
    def _dim(self) -> int:
        return 1

    # ---- Instance Methods ----

    def __matmul__(self, position: float) -> Vector:
        """Position on curve operator @ - only works if continuous"""
        return Wire(self.edges()).position_at(position)

    def __mod__(self, position: float) -> Vector:
        """Tangent on wire operator % - only works if continuous"""
        return Wire(self.edges()).tangent_at(position)

    def __xor__(self, position: float) -> Location:
        """Location on wire operator ^ - only works if continuous"""
        return Wire(self.edges()).location_at(position)

    def wires(self) -> ShapeList[Wire]:  # type: ignore
        """A list of wires created from the edges"""
        return Wire.combine(self.edges())


class Sketch(Compound):
    """A Compound containing 2D objects - aka Faces"""

    # ---- Properties ----

    @property
    def _dim(self) -> int:
        return 2


class Part(Compound):
    """A Compound containing 3D objects - aka Solids"""

    # ---- Properties ----

    @property
    def _dim(self) -> int:
        return 3
