# Copyright (c) 2021, Manfred Moitzi
# License: MIT License
import math
from typing import TYPE_CHECKING, Tuple, List, Optional, Dict, Any
from abc import abstractmethod
import logging

from ezdxf.math import (
    Vec2,
    Vec3,
    NULLVEC,
    UCS,
    decdeg2dms,
    arc_angle_span_rad,
    xround,
)
from ezdxf.entities import DimStyleOverride, Dimension, DXFEntity
from .dim_base import (
    BaseDimensionRenderer,
    get_required_defpoint,
    format_text,
    apply_dimpost,
    TextBox,
    Tolerance,
    Measurement,
    LengthMeasurement,
    compile_mtext,
    order_leader_points,
    get_center_leader_points,
)
from ezdxf.render.arrows import ARROWS, arrow_length
from ezdxf.tools.text import is_upside_down_text_angle
from ezdxf.math import intersection_line_line_2d

if TYPE_CHECKING:
    from ezdxf.eztypes import GenericLayoutType

__all__ = ["AngularDimension", "Angular3PDimension", "ArcLengthDimension"]

logger = logging.getLogger("ezdxf")

ARC_PREFIX = "( "


def has_required_attributes(entity: DXFEntity, attrib_names: List[str]):
    has = entity.dxf.hasattr
    return all(has(attrib_name) for attrib_name in attrib_names)


GRAD = 200.0 / math.pi
DEG = 180.0 / math.pi


def format_angular_text(
    value: float,
    angle_units: int,
    dimrnd: Optional[float],
    dimdec: int,
    dimzin: int,
    dimdsep: str,
) -> str:
    def decimal_format(_value: float) -> str:
        return format_text(
            _value,
            dimrnd=dimrnd,
            dimdec=dimdec,
            dimzin=dimzin,
            dimdsep=dimdsep,
        )

    def dms_format(_value: float) -> str:
        if dimrnd is not None:
            _value = xround(_value, dimrnd)
        d, m, s = decdeg2dms(_value)
        if dimdec > 4:
            places = dimdec - 5
            s = round(s, places)
            return f"{d:.0f}°{m:.0f}'{decimal_format(s)}\""
        if dimdec > 2:
            return f"{d:.0f}°{m:.0f}'{s:.0f}\""
        if dimdec > 0:
            return f"{d:.0f}°{m:.0f}'"
        return f"{d:.0f}°"

    # angular_unit:
    # 0 = Decimal degrees
    # 1 = Degrees/minutes/seconds
    # 2 = Grad
    # 3 = Radians
    text = ""
    if angle_units == 0:
        text = decimal_format(value * DEG) + "°"
    elif angle_units == 1:
        text = dms_format(value * DEG)
    elif angle_units == 2:
        text = decimal_format(value * GRAD) + "g"
    elif angle_units == 3:
        text = decimal_format(value) + "r"
    return text


_ANGLE_UNITS = [
    DEG,
    DEG,
    GRAD,
    1.0,
]


def to_radians(value: float, dimaunit: int) -> float:
    try:
        return value / _ANGLE_UNITS[dimaunit]
    except IndexError:
        return value / DEG


class AngularTolerance(Tolerance):
    def __init__(
        self,
        dim_style: DimStyleOverride,
        cap_height: float = 1.0,
        width_factor: float = 1.0,
        dim_scale: float = 1.0,
        angle_units: int = 0,
    ):
        self.angular_units = angle_units
        super().__init__(dim_style, cap_height, width_factor, dim_scale)
        # Tolerance values are interpreted in dimaunit:
        # dimtp 1 means 1 degree for dimaunit = 0 or 1, but 1 radians for
        # dimaunit = 3
        # format_text() requires radians as input:
        self.update_tolerance_text(
            to_radians(self.maximum, angle_units),
            to_radians(self.minimum, angle_units),
        )

    def format_text(self, value: float) -> str:
        """Rounding and text formatting of tolerance `value`, removes leading
        and trailing zeros if necessary.

        """
        return format_angular_text(
            value=value,
            angle_units=self.angular_units,
            dimrnd=None,
            dimdec=self.decimal_places,
            dimzin=self.suppress_zeros,
            dimdsep=self.text_decimal_separator,
        )

    def update_limits(self, measurement: float) -> None:
        # measurement is in radians, tolerance values are interpreted in
        # dimaunit: dimtp 1 means 1 degree for dimaunit = 0 or 1,
        # but 1 radians for dimaunit = 3
        # format_text() requires radians as input:
        upper_limit = measurement + to_radians(self.maximum, self.angular_units)
        lower_limit = measurement - to_radians(self.minimum, self.angular_units)
        self.text_upper = self.format_text(upper_limit)
        self.text_lower = self.format_text(lower_limit)
        self.text_width = self.get_text_width(self.text_upper, self.text_lower)


class AngleMeasurement(Measurement):
    def update(self, raw_measurement_value: float) -> None:
        self.raw_value = raw_measurement_value
        self.value = raw_measurement_value
        self.text = self.text_override(raw_measurement_value)

    def format_text(self, value: float) -> str:
        text = format_angular_text(
            value=value,
            angle_units=self.angle_units,
            dimrnd=None,
            dimdec=self.angular_decimal_places,
            dimzin=self.angular_suppress_zeros << 2,  # convert to dimzin value
            dimdsep=self.decimal_separator,
        )
        if self.text_post_process_format:
            text = apply_dimpost(text, self.text_post_process_format)
        return text


def fits_into_arc_span(length: float, radius: float, arc_span: float) -> bool:
    required_arc_span: float = length / radius
    return arc_span > required_arc_span


class _CurvedDimensionLine(BaseDimensionRenderer):
    def __init__(
        self,
        dimension: Dimension,
        ucs: "UCS" = None,
        override: DimStyleOverride = None,
    ):
        super().__init__(dimension, ucs, override)
        # Common parameters for all sub-classes:
        # Use hidden line detection for dimension line:
        # Disable expensive hidden line calculation if possible!
        self.remove_hidden_lines_of_dimline = True
        self.center_of_arc: Vec2 = self.get_center_of_arc()
        self.dim_line_radius: float = self.get_dim_line_radius()
        self.ext1_dir: Vec2 = self.get_ext1_dir()
        self.start_angle_rad: float = self.ext1_dir.angle
        self.ext2_dir: Vec2 = self.get_ext2_dir()
        self.end_angle_rad: float = self.ext2_dir.angle

        # Angle between extension lines for all curved dimensions:
        # equal to the angle measurement of angular dimensions
        self.arc_angle_span_rad: float = arc_angle_span_rad(
            self.start_angle_rad, self.end_angle_rad
        )
        self.center_angle_rad = (
            self.start_angle_rad + self.arc_angle_span_rad / 2.0
        )

        # Additional required parameters but calculated later by sub-classes:
        self.ext1_start = Vec2()  # start of 1st extension line
        self.ext2_start = Vec2()  # start of 2nd extension line

        # Class specific setup:
        self.update_measurement()
        if self.tol.has_limits:
            self.tol.update_limits(self.measurement.value)

        # Text width and -height is required first, text location and -rotation
        # are not valid yet:
        self.text_box = self.init_text_box()

        # Place arrows outside?
        self.arrows_outside = False

        self.setup_text_and_arrow_fitting()
        self.setup_text_location()

        # update text box location and -rotation:
        self.text_box.center = self.measurement.text_location
        self.text_box.angle = self.measurement.text_rotation
        self.geometry.set_text_box(self.text_box)

        # Update final text location in the DIMENSION entity:
        self.dimension.dxf.text_midpoint = self.measurement.text_location

    @property
    def ocs_center_of_arc(self) -> Vec3:
        return self.geometry.ucs.to_ocs(Vec3(self.center_of_arc))

    @property
    def dim_midpoint(self) -> Vec2:
        """Return the midpoint of the dimension line."""
        return self.center_of_arc + Vec2.from_angle(
            self.center_angle_rad, self.dim_line_radius
        )

    @abstractmethod
    def update_measurement(self) -> None:
        """Setup measurement text."""
        ...

    @abstractmethod
    def get_ext1_dir(self) -> Vec2:
        """Return the direction of the 1st extension line == start angle."""
        ...

    @abstractmethod
    def get_ext2_dir(self) -> Vec2:
        """Return the direction of the 2nd extension line == end angle."""
        ...

    @abstractmethod
    def get_center_of_arc(self) -> Vec2:
        """Return the center of the arc."""
        ...

    @abstractmethod
    def get_dim_line_radius(self) -> float:
        """Return the distance from the center of the arc to the dimension line
        location
        """
        ...

    @abstractmethod
    def get_defpoints(self) -> List[Vec2]:
        ...

    def transform_ucs_to_wcs(self) -> None:
        """Transforms dimension definition points into WCS or if required into
        OCS.
        """

        def from_ucs(attr, func):
            if dxf.is_supported(attr):
                point = dxf.get(attr, NULLVEC)
                dxf.set(attr, func(point))

        dxf = self.dimension.dxf
        ucs = self.geometry.ucs
        from_ucs("defpoint", ucs.to_wcs)
        from_ucs("defpoint2", ucs.to_wcs)
        from_ucs("defpoint3", ucs.to_wcs)
        from_ucs("defpoint4", ucs.to_wcs)
        from_ucs("defpoint5", ucs.to_wcs)
        from_ucs("text_midpoint", ucs.to_ocs)

    def default_location(self, shift: float = 0.0) -> Vec2:
        radius = (
            self.dim_line_radius
            + self.measurement.text_vertical_distance()
            + shift
        )
        text_radial_dir = Vec2.from_angle(self.center_angle_rad)
        return self.center_of_arc + text_radial_dir * radius

    def setup_text_and_arrow_fitting(self):
        # self.text_box.width includes the gaps between text and dimension line
        # Is the measurement text without the arrows too wide to fit between the
        # extension lines?
        self.measurement.is_wide_text = not fits_into_arc_span(
            self.text_box.width, self.dim_line_radius, self.arc_angle_span_rad
        )

        required_text_and_arrows_space: float = (
            # The suppression of the arrows is not taken into account:
            self.text_box.width
            + 2.0 * self.arrows.arrow_size
        )

        # dimatfit: measurement text fitting rule is ignored!
        # Place arrows outside?
        self.arrows_outside: bool = not fits_into_arc_span(
            required_text_and_arrows_space,
            self.dim_line_radius,
            self.arc_angle_span_rad,
        )
        # Place measurement text outside?
        self.measurement.text_is_outside = not fits_into_arc_span(
            required_text_and_arrows_space * 1.1,  # add some extra space
            self.dim_line_radius,
            self.arc_angle_span_rad,
        )

        if (
            self.measurement.text_is_outside
            and self.measurement.user_text_rotation is None
        ):
            # Intersection of the measurement text with the dimension line is
            # not possible:
            self.remove_hidden_lines_of_dimline = False

    def setup_text_location(self) -> None:
        """Setup geometric text properties (location, rotation) and the TextBox
        object.
        """
        # dimtix: measurement.force_text_inside is ignored
        # dimtih: measurement.text_inside_horizontal is ignored
        # dimtoh: measurement.text_outside_horizontal is ignored

        # text radial direction = center -> text
        text_radial_dir: Vec2  # text "vertical" direction
        measurement = self.measurement

        # determine text location:
        at_default_location: bool = measurement.user_location is None
        has_text_shifting: bool = bool(
            measurement.text_shift_h or measurement.text_shift_v
        )
        if at_default_location:
            # place text in the "horizontal" center of the dimension line at the
            # default location defined by measurement.text_valign (dimtad):
            text_radial_dir = Vec2.from_angle(self.center_angle_rad)
            shift_text_upwards: float = 0.0
            if measurement.text_is_outside:
                # reset vertical alignment to "above"
                measurement.text_valign = 1
                if measurement.is_wide_text:
                    # move measurement text "above" the extension line endings:
                    shift_text_upwards = self.extension_lines.extension_above
            measurement.text_location = self.default_location(
                shift=shift_text_upwards
            )
            if (
                measurement.text_valign > 0 and not has_text_shifting
            ):  # not in the center and no text shifting is applied
                # disable expensive hidden line calculation
                self.remove_hidden_lines_of_dimline = False
        else:
            # apply dimtmove: measurement.text_movement_rule
            user_location = measurement.user_location
            assert isinstance(user_location, Vec2)
            if measurement.relative_user_location:
                user_location += self.dim_midpoint
            measurement.text_location = user_location
            if measurement.text_movement_rule == 0:
                # Moves the dimension line with dimension text and
                # aligns the text direction perpendicular to the connection
                # line from the arc center to the text center:
                self.dim_line_radius = (
                    self.center_of_arc - user_location
                ).magnitude
                # Attributes about the text and arrow fitting have to be
                # updated now:
                self.setup_text_and_arrow_fitting()
            elif measurement.text_movement_rule == 1:
                # Adds a leader when dimension text, text direction is
                # "horizontal" or user text rotation if given.
                # Leader location is defined by dimtad (text_valign):
                # "center" - connects to the left or right center of the text
                # "below" - add a line below the text
                if measurement.user_text_rotation is None:
                    # override text rotation
                    measurement.user_text_rotation = 0.0
                measurement.text_is_outside = True  # by definition
            elif measurement.text_movement_rule == 2:
                # Allows text to be moved freely without a leader and
                # aligns the text direction perpendicular to the connection
                # line from the arc center to the text center:
                measurement.text_is_outside = True  # by definition
            text_radial_dir = (
                measurement.text_location - self.center_of_arc
            ).normalize()

        # set text "horizontal":
        text_tangential_dir = text_radial_dir.orthogonal(ccw=False)

        if at_default_location and has_text_shifting:
            # Apply text relative shift (ezdxf only feature)
            if measurement.text_shift_h:
                measurement.text_location += (
                    text_tangential_dir * measurement.text_shift_h
                )
            if measurement.text_shift_v:
                measurement.text_location += (
                    text_radial_dir * measurement.text_shift_v
                )

        # apply user text rotation; rotation in degrees:
        if measurement.user_text_rotation is None:
            rotation = text_tangential_dir.angle_deg
        else:
            rotation = measurement.user_text_rotation

        if not self.geometry.requires_extrusion:
            # todo: extrusion vector (0, 0, -1)?
            # Practically all DIMENSION entities are 2D entities,
            # where OCS == WCS, check WCS text orientation:
            wcs_angle = self.geometry.ucs.to_ocs_angle_deg(rotation)
            if is_upside_down_text_angle(wcs_angle):
                measurement.has_upside_down_correction = True
                rotation += 180.0  # apply to UCS rotation!
        measurement.text_rotation = rotation

    def get_leader_points(self) -> Tuple[Vec2, Vec2]:
        # Leader location is defined by dimtad (text_valign):
        # "center":
        # - connects to the left or right vertical center of the text
        # - distance between text and leader line is measurement.text_gap (dimgap)
        #   and is already included in the text_box corner points
        # - length of "leg": arrows.arrow_size
        # "below" - add a line below the text
        if self.measurement.text_valign == 0:  # "center"
            return get_center_leader_points(
                self.dim_midpoint, self.text_box, self.arrows.arrow_size
            )
        else:  # "below"
            c0, c1, c2, c3 = self.text_box.corners
            if self.measurement.has_upside_down_correction:
                p1, p2 = c2, c3
            else:
                p1, p2 = c0, c1
            return order_leader_points(self.dim_midpoint, p1, p2)

    def render(self, block: "GenericLayoutType") -> None:
        """Main method to create dimension geometry of basic DXF entities in the
        associated BLOCK layout.

        Args:
            block: target BLOCK for rendering

        """
        super().render(block)
        self.add_extension_lines()
        adjust_start_angle, adjust_end_angle = self.add_arrows()

        measurement = self.measurement
        if measurement.text:
            if self.geometry.supports_dxf_r2000:
                text = compile_mtext(measurement, self.tol)
            else:
                text = measurement.text
            self.add_measurement_text(
                text, measurement.text_location, measurement.text_rotation
            )
            if measurement.has_leader:
                p1, p2 = self.get_leader_points()
                self.add_leader(self.dim_midpoint, p1, p2)
        self.add_dimension_line(adjust_start_angle, adjust_end_angle)
        self.geometry.add_defpoints(self.get_defpoints())

    def add_extension_lines(self) -> None:
        ext_lines = self.extension_lines
        if not ext_lines.suppress1:
            self._add_ext_line(
                self.ext1_start, self.ext1_dir, ext_lines.dxfattribs(1)
            )
        if not ext_lines.suppress2:
            self._add_ext_line(
                self.ext2_start, self.ext2_dir, ext_lines.dxfattribs(2)
            )

    def _add_ext_line(
        self, start: Vec2, direction: Vec2, dxfattribs: Dict[str, Any]
    ) -> None:
        ext_lines = self.extension_lines
        center = self.center_of_arc
        radius = self.dim_line_radius
        ext_above = ext_lines.extension_above
        is_inside = (start - center).magnitude > radius

        if ext_lines.has_fixed_length:
            ext_below = ext_lines.length_below
            if is_inside:
                ext_below, ext_above = ext_above, ext_below
            start = center + direction * (radius - ext_below)
        else:
            offset = ext_lines.offset
            if is_inside:
                ext_above = -ext_above
                offset = -offset
            start += direction * offset
        end = center + direction * (radius + ext_above)
        self.add_line(start, end, dxfattribs=dxfattribs)

    def add_arrows(self) -> Tuple[float, float]:
        """Add arrows or ticks to dimension.

        Returns: dimension start- and end angle offsets to adjust the
            dimension line

        """
        arrows = self.arrows
        attribs = arrows.dxfattribs()
        radius = self.dim_line_radius
        if abs(radius) < 1e-12:
            return 0.0, 0.0

        start = self.center_of_arc + self.ext1_dir * radius
        end = self.center_of_arc + self.ext2_dir * radius
        angle1 = self.ext1_dir.orthogonal().angle_deg
        angle2 = self.ext2_dir.orthogonal().angle_deg
        outside = self.arrows_outside
        arrow1 = not arrows.suppress1
        arrow2 = not arrows.suppress2
        start_angle_offset = 0.0
        end_angle_offset = 0.0
        if arrows.tick_size > 0.0:  # oblique stroke, but double the size
            if arrow1:
                self.add_blockref(
                    ARROWS.oblique,
                    insert=start,
                    rotation=angle1,
                    scale=arrows.tick_size * 2.0,
                    dxfattribs=attribs,
                )
            if arrow2:
                self.add_blockref(
                    ARROWS.oblique,
                    insert=end,
                    rotation=angle2,
                    scale=arrows.tick_size * 2.0,
                    dxfattribs=attribs,
                )
        else:
            arrow_size = arrows.arrow_size
            # Note: The arrow blocks are correct as they are!
            # The arrow head is tilted to match the connection point of the
            # dimension line (even for datum arrows).
            # tilting angle = 1/2 of the arc angle defined by the arrow length
            arrow_tilt: float = arrow_size / radius * 0.5 * DEG
            start_angle = angle1 + 180.0
            end_angle = angle2
            if outside:
                start_angle += 180.0
                end_angle += 180.0
                arrow_tilt = -arrow_tilt
            scale = arrow_size
            if arrow1:
                self.add_blockref(
                    arrows.arrow1_name,
                    insert=start,
                    scale=scale,
                    rotation=start_angle + arrow_tilt,
                    dxfattribs=attribs,
                )  # reverse
            if arrow2:
                self.add_blockref(
                    arrows.arrow2_name,
                    insert=end,
                    scale=scale,
                    rotation=end_angle - arrow_tilt,
                    dxfattribs=attribs,
                )
            if not outside:
                # arrows inside extension lines:
                # adjust angles for the remaining dimension line
                if arrow1:
                    start_angle_offset = (
                        arrow_length(arrows.arrow1_name, arrow_size) / radius
                    )
                if arrow2:
                    end_angle_offset = (
                        arrow_length(arrows.arrow2_name, arrow_size) / radius
                    )
        return start_angle_offset, end_angle_offset

    def add_dimension_line(
        self,
        start_offset: float,
        end_offset: float,
    ) -> None:
        # Start- and end angle adjustments have to be limited between the
        # extension lines.
        # Negative offset extends the dimension line outside!
        start_angle: float = self.start_angle_rad
        end_angle: float = self.end_angle_rad
        arrows = self.arrows
        size = arrows.arrow_size
        radius = self.dim_line_radius
        max_adjustment: float = abs(self.arc_angle_span_rad) / 2.0

        if start_offset > max_adjustment:
            start_offset = 0.0
        if end_offset > max_adjustment:
            end_offset = 0.0

        self.add_arc(
            self.center_of_arc,
            radius,
            start_angle + start_offset,
            end_angle - end_offset,
            dxfattribs=self.dimension_line.dxfattribs(),
            # hidden line detection if text is not placed outside:
            remove_hidden_lines=self.remove_hidden_lines_of_dimline,
        )
        if self.arrows_outside and not arrows.has_ticks:
            # add arrow extension lines
            start_offset, end_offset = arrow_offset_angles(
                arrows.arrow1_name, size, radius
            )
            self.add_arrow_extension_line(
                start_angle - end_offset,
                start_angle - start_offset,
            )
            start_offset, end_offset = arrow_offset_angles(
                arrows.arrow1_name, size, radius
            )
            self.add_arrow_extension_line(
                end_angle + start_offset,
                end_angle + end_offset,
            )

    def add_arrow_extension_line(self, start_angle: float, end_angle: float):
        self.add_arc(
            self.center_of_arc,
            self.dim_line_radius,
            start_angle=start_angle,
            end_angle=end_angle,
            dxfattribs=self.dimension_line.dxfattribs(),
        )

    def add_measurement_text(
        self, dim_text: str, pos: Vec2, rotation: float
    ) -> None:
        """Add measurement text to dimension BLOCK.

        Args:
            dim_text: dimension text
            pos: text location
            rotation: text rotation in degrees

        """
        attribs = self.measurement.dxfattribs()
        self.add_text(dim_text, pos=pos, rotation=rotation, dxfattribs=attribs)


class _AngularCommonBase(_CurvedDimensionLine):
    def init_tolerance(
        self, scale: float, measurement: Measurement
    ) -> Tolerance:
        return AngularTolerance(
            self.dim_style,
            cap_height=measurement.text_height,
            width_factor=measurement.text_width_factor,
            dim_scale=scale,
            angle_units=measurement.angle_units,
        )

    def init_measurement(self, color: int, scale: float) -> Measurement:
        return AngleMeasurement(
            self.dim_style, self.default_color, self.dim_scale
        )

    def update_measurement(self) -> None:
        self.measurement.update(self.arc_angle_span_rad)


class AngularDimension(_AngularCommonBase):
    """
    Angular dimension line renderer. The dimension line is defined by two lines.

    Supported render types:

    - default location above
    - default location center
    - user defined location, text aligned with dimension line
    - user defined location horizontal text

    Args:
        dimension: DIMENSION entity
        ucs: user defined coordinate system
        override: dimension style override management object

    """

    # Required defpoints:
    # defpoint = start point of 1st leg (group code 10)
    # defpoint4 = end point of 1st leg (group code 15)
    # defpoint3 = start point of 2nd leg (group code 14)
    # defpoint2 = end point of 2nd leg (group code 13)
    # defpoint5 = location of dimension line (group code 16)

    # unsupported or ignored features (at least by BricsCAD):
    # dimtih: text inside horizontal
    # dimtoh: text outside horizontal
    # dimjust: text position horizontal
    # dimdle: dimline extension

    def __init__(
        self,
        dimension: Dimension,
        ucs: "UCS" = None,
        override: DimStyleOverride = None,
    ):
        self.leg1_start = get_required_defpoint(dimension, "defpoint")
        self.leg1_end = get_required_defpoint(dimension, "defpoint4")
        self.leg2_start = get_required_defpoint(dimension, "defpoint3")
        self.leg2_end = get_required_defpoint(dimension, "defpoint2")
        self.dim_line_location = get_required_defpoint(dimension, "defpoint5")
        super().__init__(dimension, ucs, override)
        # The extension line parameters depending on the location of the
        # dimension line related to the definition point.
        # Detect the extension start point.
        # Which definition point is closer to the dimension line:
        self.ext1_start = detect_closer_defpoint(
            direction=self.ext1_dir,
            base=self.dim_line_location,
            p1=self.leg1_start,
            p2=self.leg1_end,
        )
        self.ext2_start = detect_closer_defpoint(
            direction=self.ext2_dir,
            base=self.dim_line_location,
            p1=self.leg2_start,
            p2=self.leg2_end,
        )

    def get_defpoints(self) -> List[Vec2]:
        return [
            self.leg1_start,
            self.leg1_end,
            self.leg2_start,
            self.leg2_end,
            self.dim_line_location,
        ]

    def get_center_of_arc(self) -> Vec2:
        center = intersection_line_line_2d(
            (self.leg1_start, self.leg1_end),
            (self.leg2_start, self.leg2_end),
        )
        if center is None:
            logger.warning(
                f"Invalid colinear or parallel angle legs found in {self.dimension})"
            )
            # This case can not be created by the GUI in BricsCAD, but DXF
            # files can contain any shit!
            # The interpolation of the end-points is an arbitrary choice and
            # maybe not the best choice!
            center = self.leg1_end.lerp(self.leg2_end)
        return center

    def get_dim_line_radius(self) -> float:
        return (self.dim_line_location - self.center_of_arc).magnitude

    def get_ext1_dir(self) -> Vec2:
        center = self.center_of_arc
        start = (
            self.leg1_end
            if self.leg1_start.isclose(center)
            else self.leg1_start
        )
        return (start - center).normalize()

    def get_ext2_dir(self) -> Vec2:
        center = self.center_of_arc
        start = (
            self.leg2_end
            if self.leg2_start.isclose(center)
            else self.leg2_start
        )
        return (start - center).normalize()


class Angular3PDimension(_AngularCommonBase):
    """
    Angular dimension line renderer. The dimension line is defined by three
    points.

    Supported render types:

    - default location above
    - default location center
    - user defined location, text aligned with dimension line
    - user defined location horizontal text

    Args:
        dimension: DIMENSION entity
        ucs: user defined coordinate system
        override: dimension style override management object

    """

    # Required defpoints:
    # defpoint = location of dimension line (group code 10)
    # defpoint2 = 1st leg (group code 13)
    # defpoint3 = 2nd leg (group code 14)
    # defpoint4 = center of angle (group code 15)

    def __init__(
        self,
        dimension: Dimension,
        ucs: "UCS" = None,
        override: DimStyleOverride = None,
    ):
        self.dim_line_location = get_required_defpoint(dimension, "defpoint")
        self.leg1_start = get_required_defpoint(dimension, "defpoint2")
        self.leg2_start = get_required_defpoint(dimension, "defpoint3")
        self.center_of_arc = get_required_defpoint(dimension, "defpoint4")
        super().__init__(dimension, ucs, override)
        self.ext1_start = self.leg1_start
        self.ext2_start = self.leg2_start

    def get_defpoints(self) -> List[Vec2]:
        return [
            self.dim_line_location,
            self.leg1_start,
            self.leg2_start,
            self.center_of_arc,
        ]

    def get_center_of_arc(self) -> Vec2:
        return self.center_of_arc

    def get_dim_line_radius(self) -> float:
        return (self.dim_line_location - self.center_of_arc).magnitude

    def get_ext1_dir(self) -> Vec2:
        return (self.leg1_start - self.center_of_arc).normalize()

    def get_ext2_dir(self) -> Vec2:
        return (self.leg2_start - self.center_of_arc).normalize()


class ArcLengthMeasurement(LengthMeasurement):
    def format_text(self, value: float) -> str:
        text = format_text(
            value=value,
            dimrnd=self.text_round,
            dimdec=self.decimal_places,
            dimzin=self.suppress_zeros,
            dimdsep=self.decimal_separator,
        )
        if self.has_arc_length_prefix:
            text = ARC_PREFIX + text
        if self.text_post_process_format:
            text = apply_dimpost(text, self.text_post_process_format)
        return text


class ArcLengthDimension(_CurvedDimensionLine):
    """Arc length dimension line renderer.
    Requires DXF R2004.

    Supported render types:

    - default location above
    - default location center
    - user defined location, text aligned with dimension line
    - user defined location horizontal text

    Args:
        dimension: DXF entity DIMENSION
        ucs: user defined coordinate system
        override: dimension style override management object

    """

    # Required defpoints:
    # defpoint = location of dimension line (group code 10)
    # defpoint2 = 1st arc point (group code 13)
    # defpoint3 = 2nd arc point (group code 14)
    # defpoint4 = center of arc (group code 15)

    def __init__(
        self,
        dimension: Dimension,
        ucs: "UCS" = None,
        override: DimStyleOverride = None,
    ):
        self.dim_line_location = get_required_defpoint(dimension, "defpoint")
        self.leg1_start = get_required_defpoint(dimension, "defpoint2")
        self.leg2_start = get_required_defpoint(dimension, "defpoint3")
        self.center_of_arc = get_required_defpoint(dimension, "defpoint4")
        self.arc_radius = (self.leg1_start - self.center_of_arc).magnitude
        super().__init__(dimension, ucs, override)
        self.ext1_start = self.leg1_start
        self.ext2_start = self.leg2_start

    def get_defpoints(self) -> List[Vec2]:
        return [
            self.dim_line_location,
            self.leg1_start,
            self.leg2_start,
            self.center_of_arc,
        ]

    def init_measurement(self, color: int, scale: float) -> Measurement:
        return ArcLengthMeasurement(
            self.dim_style, self.default_color, self.dim_scale
        )

    def get_center_of_arc(self) -> Vec2:
        return self.center_of_arc

    def get_dim_line_radius(self) -> float:
        return (self.dim_line_location - self.center_of_arc).magnitude

    def get_ext1_dir(self) -> Vec2:
        return (self.leg1_start - self.center_of_arc).normalize()

    def get_ext2_dir(self) -> Vec2:
        return (self.leg2_start - self.center_of_arc).normalize()

    def update_measurement(self) -> None:
        angle = arc_angle_span_rad(self.start_angle_rad, self.end_angle_rad)
        arc_length = angle * self.arc_radius
        self.measurement.update(arc_length)


def detect_closer_defpoint(
    direction: Vec2, base: Vec2, p1: Vec2, p2: Vec2
) -> Vec2:
    # Calculate the projected distance onto the (normalized) direction vector:
    d0 = direction.dot(base)
    d1 = direction.dot(p1)
    d2 = direction.dot(p2)
    # Which defpoint is closer to the base point (d0)?
    if abs(d1 - d0) <= abs(d2 - d0):
        return p1
    return p2


def arrow_offset_angles(
    arrow_name: str, size: float, radius: float
) -> Tuple[float, float]:
    start_offset: float = 0.0
    end_offset: float = size / radius
    length = arrow_length(arrow_name, size)
    if length > 0.0:
        start_offset = length / radius
        end_offset *= 2.0
    return start_offset, end_offset
