from copy import deepcopy, copy

import numpy as np
from numpy.linalg import norm
import svgpathtools as svgpath  # https://github.com/mathandy/svgpathtools

from pygarment.garmentcode.utils import R2D
from pygarment.garmentcode.utils import close_enough
from pygarment.garmentcode.utils import c_to_list
from pygarment.garmentcode.utils import list_to_c
from pygarment.pattern.utils import rel_to_abs_2d, abs_to_rel_2d

ILENGTH_S_TOL = 1e-10   # NOTE: tolerance value for evaluating curve parameter (t) from acr length

class Edge:
    """Edge an individual segment of a panel border connecting two panel
    vertices, the basic building block of panels

    Edges are defined on 2D coordinate system with Start vertex as an origin
        and (End-Start) as Ox axis
    """

    def __init__(self, start=None, end=None, label='') -> None:
        """ Simple edge initialization.
        Parameters: 
            * start, end: from/to vertices that the edge connects,
                describing the _interface_ of an edge
            * label: semantic label of the edge to be writted down as a property on assembly

            # TODOLOW Add support for fold schemes to allow guided folds at
            the edge (e.g. pleats)
        """
        if start is None:
            start = [0, 0]
        if end is None:
            end = [0, 0]
        assert not all(close_enough(s, e) for s, e in zip(start, end)), 'Start and end of an edge should differ'

        self.start = start  # NOTE: careful with references to vertex objects
        self.end = end

        # Semantic label
        self.label = label

        # ID w.r.t. other edges in a super-panel
        # Filled out at the panel assembly time
        self.geometric_id = 0

    def length(self):
        """Return current length of an edge.
            Since vertices may change their locations externally, the length
            is dynamically evaluated
        """
        return self._straight_len()

    def _straight_len(self):
        """Length of the edge ignoring the curvature"""
        return norm(np.asarray(self.end) - np.asarray(self.start))

    def __eq__(self, __o: object, tol=1e-2) -> bool:
        """Special implementation of comparison: same edges == edges can be
        connected by flat stitch
        Edges are the same if their length is the same (if their flattened
        representation is the same) => vertices do not have to be on the
        same locations

            NOTE: The edges may not have the same curvature and still be
            considered equal ("connectible")
        """

        if not isinstance(__o, Edge):
            return False

        # Base length is the same
        if close_enough(self.length(), __o.length(), tol=tol):
            return False

        return True

    def __str__(self) -> str:
        return f'Straight:[{self.start[0]:.2f}, {self.start[1]:.2f}]->[{self.end[0]:.2f}, {self.end[1]:.2f}]'

    def __repr__(self) -> str:
        """ 'Official string representation' -- for nice printing of lists of edges
        
        https://stackoverflow.com/questions/3558474/how-to-apply-str-function-when-printing-a-list-of-objects-in-python
        """
        return self.__str__()

    def midpoint(self):
        """Center of the edge"""
        return (np.array(self.start) + np.array(self.end)) / 2

    def shortcut(self):
        """Return straight shortcut for an edge, 
            as `np.array`
        
            For straight edges it's the same as the edge itself
        """

        return np.array([self.start, self.end])

    def flip_x_axis(self):
        """Flips the Bezier curve along the x-axis by inverting the y-coordinates"""

        self.start[1] = -self.start[1]
        self.end[1] = -self.end[1]
        return self


    # Representation
    def as_curve(self):
        """As svgpath curve object"""
        # Get the nodes correcly
        nodes = np.vstack((self.start, self.end))

        params = nodes[:, 0] + 1j*nodes[:, 1]

        return svgpath.Line(*params)

    def linearize(self, n_verts_inside = 0):
        """Return a linear approximation of an edge using the same vertex objects
        
            # NOTE: for the linear edge it is an egde if n_verts_inside = 0,
            # else n_verts_inside = number of vertices (excluding the start
            and end vertices) used to create a linearization of the edge
        """

        if not n_verts_inside:
            return EdgeSequence(self)
        else:
            n = n_verts_inside + 1
            tvals = np.linspace(0, 1, n, endpoint=False)[1:]

            curve = self.as_curve()
            edge_verts = [c_to_list(curve.point(t)) for t in tvals]
            seq = self.to_edge_sequence(edge_verts)

            return seq

    def to_edge_sequence(self, edge_verts):
        """
        Returns the edge as a sequence of STRAIGHT edges based on points
        sampled on the edge between `self.start` and `self.end` (edge_verts).
        """
        seq = EdgeSequence(Edge(self.start, edge_verts[0]))
        for i in range(1, len(edge_verts)):
            seq.append(Edge(seq[-1].end, edge_verts[i]))
        seq.append(Edge(seq[-1].end, self.end))

        return seq

    # Actions
    def reverse(self):
        """Flip the direction of the edge"""
        self.start, self.end = self.end, self.start

        return self
    
    def reflect_features(self):
        """Reflect edge fetures from one side of the edge to the other"""
        # Nothing to do for straight edge
        return self

    def snap_to(self, new_start=None):
        """Translate the edge vertices s.t. the start is at new_start
        """
        if new_start is None:
            new_start = [0, 0]

        self.end[0] = self.end[0] - self.start[0] + new_start[0]
        self.end[1] = self.end[1] - self.start[1] + new_start[1]
        self.start[:] = new_start
        return self

    def rotate(self, angle):
        """Rotate edge by angle in place, using first point as a reference

        Parameters: 
            angle -- desired rotation angle in radians (!)
        """
        curr_start = copy(self.start)
        
        # set the start point to zero
        self.snap_to([0, 0])
        self.end[:] = np.matmul(R2D(angle), self.end)
        
        # recover the original location
        self.snap_to(curr_start)

        return self

    def subdivide_len(self, fractions: list, connect_internal_verts=True):
        """Add intermediate vertices to an edge, 
            splitting its length according to fractions
            while preserving the overall shape

            * merge_internal -- if False, the newly inserted vertices would be
                defined
                as independent objects for each edge. If True, vertex objects
                    will be shared
        """
        # Parametrized by length
        new_edges = self._subdivide(fractions, by_length=True)

        if connect_internal_verts:
            self._merge_subdiv_vertices(new_edges)
        
        return new_edges
    
    def subdivide_param(self, fractions: list, connect_internal_verts=True):
        """Add intermediate vertices to an edge, 
            splitting its curve parametrization according to fractions
            while preserving the overall shape

            NOTE: for line, it's the same as subdivision by length
        """
        
        new_edges = self._subdivide(fractions, by_length=False)

        if connect_internal_verts:
            self._merge_subdiv_vertices(new_edges)
        
        return new_edges

    def _subdivide(self, fractions: list, by_length=True):
        """Subdivide edge by length or curve parametrization

            NOTE: equivalent for straight lines
        """

        frac = [abs(f) for f in fractions]
        if not close_enough(fsum := sum(frac), 1, 1e-4):
            raise RuntimeError(f'Edge Subdivision::ERROR::fraction is incorrect. The sum {fsum} is not 1')

        vec = np.asarray(self.end) - np.asarray(self.start)
        verts = [self.start]
        seq = EdgeSequence()
        for i in range(len(frac) - 1):
            verts.append(
                [verts[-1][0] + frac[i]*vec[0],
                verts[-1][1] + frac[i]*vec[1]]
            )
            seq.append(Edge(verts[-2], verts[-1]))
        verts.append(self.end)
        seq.append(Edge(verts[-2], verts[-1]))
        
        return seq

    def _merge_subdiv_vertices(self, subdivision):
        """Merge the vertices from cosecutive edges in the given edge subdivision"""

        for i in range(1, len(subdivision)):
            subdivision[i].start = subdivision[i-1].end
        return subdivision

    # Assembly into serializable object
    def assembly(self):
        """Returns the dict-based representation of edges, 
            compatible with core -> BasePattern JSON (dict) 
        """
        properties = {"endpoints": [0, 1]}
        if self.label:
            properties['label'] = self.label

        return [self.start, self.end], properties


class CircleEdge(Edge):
    """Curvy edge as circular arc"""

    def __init__(self, start=None, end=None, cy=None, label='') -> None:
        """
            Define a circular arc edge
            * start, end: from/to vertices that the edge connects
            * cy: third point on a circle arc (= control point). 
                Expressed relatively w.r.t. distance between start and end. 
                X value for control point is fixed at x=0.5 (edge center) to
                avoid ambiguity
            * label: semantic label of the edge to be writted down as a property on assembly
            
            NOTE: representing control point in relative coordinates
            allows preservation of curvature (arc angle, relative radius
            w.r.t. straight edge length)
            When distance between vertices shrinks / extends

            NOTE: full circle not supported: start & end should differ
        """
        if start is None:
            start = [0, 0]
        if end is None:
            end = [1, 0]
        super().__init__(start, end, label=label)
        self.control_y = cy

    def length(self):
        """Return current length of an edge.
            Since vertices may change their locations externally, the length
            is dynamically evaluated
        """
        return self._rel_radius() * self._straight_len() * self._arc_angle()

    def __str__(self) -> str:

        points = [self.start, [0.5, self.control_y]]

        str = [f'[{p[0]:.2f}, {p[1]:.2f}]->' for p in points]
        str += [f'[{self.end[0]:.2f}, {self.end[1]:.2f}]']

        return 'Arc:' + ''.join(str)
    
    def midpoint(self):
        """Center of the edge"""
        return rel_to_abs_2d(self.start, self.end, [0.5, self.control_y])

    # Actions
    def reverse(self):
        """Flip the direction of the edge, accounting for curvatures"""

        self.start, self.end = self.end, self.start
        self.control_y *= -1

        return self
    
    def reflect_features(self):
        """Reflect edge features from one side of the edge to the other"""

        self.control_y *= -1

        return self

    def _subdivide(self, fractions: list, by_length=False):
        """Add intermediate vertices to an edge, 
            splitting its parametrization according to fractions
            while preserving the overall shape
        
            NOTE: param subdiv == length subdiv for circle arcs
        """
        # NOTE: subdivide_param() is the same as subdivide_len()
        # So parent implementation is ok
        # TODOLOW Implementation is very similar to CurveEdge param-based subdivision

        from pygarment.garmentcode.edge_factory import EdgeFactory  # TODOLOW: ami - better solution?
        frac = [abs(f) for f in fractions]
        if not close_enough(fsum := sum(frac), 1, 1e-4):
            raise RuntimeError(f'Edge Subdivision::ERROR::fraction is incorrect. The sum {fsum} is not 1')

        curve = self.as_curve()
        # Sub-curves
        covered_fr = 0
        subcurves = []
        for fr in fractions:
            subcurves.append(curve.cropped(covered_fr, covered_fr + fr))
            covered_fr += fr

        # Convert to CircleEdge objects
        subedges = EdgeSequence()
        for curve in subcurves:
            subedges.append(EdgeFactory.from_svg_curve(curve))
        # Reference the first/last vertices correctly
        subedges[0].start = self.start
        subedges[-1].end = self.end

        return subedges

    # Special tools for circle representation
    def as_curve(self):
        """Represent as svgpath Arc"""

        radius, la, sweep = self.as_radius_flag()

        return svgpath.Arc(
            list_to_c(self.start),
            list_to_c([radius, radius]), 0, la, sweep,
            list_to_c(self.end)
        )
  
    def as_radius_flag(self):
        """Return circle representation as radius and arc flags"""

        return (self._rel_radius() * self._straight_len(), 
                self._is_large_arc(),
                self.control_y < 0)   # left/right orientation 
  
    def as_radius_angle(self):
        """Return circle representation as radius and an angle"""

        return (
            self._rel_radius() * self._straight_len(),
            self._arc_angle(),
            self.control_y < 0
        )

    def linearize(self, n_verts_inside = 9):
        """Return a linear approximation of an edge using the same vertex objects
           NOTE: n_verts_inside = number of vertices (excluding the start
            and end vertices) used to create a linearization of the edge
        """
        n = n_verts_inside + 1
        tvals = np.linspace(0, 1, n, endpoint=False)[1:]

        curve = self.as_curve()
        edge_verts = [c_to_list(curve.point(t)) for t in tvals]
        seq = self.to_edge_sequence(edge_verts)

        return seq

    # NOTE: The following values are calculated at runtime to allow 
    # changes to control point after the edge definition
    def _rel_radius(self, abs_radius=None):
        """Eval relative radius (w.r.t. straight distance) from 3-point
        representation"""

        if abs_radius: 
            return abs_radius / self._straight_len()

        # Using the formula for radius of circumscribed circle
        # https://en.wikipedia.org/wiki/Circumscribed_circle#Other_properties

        # triangle sides, assuming the begginning and end of an edge are at
        # (0, 0) and (1, 0)
        # accordingly
        a = 1
        b = norm([0.5, self.control_y])
        c = norm([0.5 - 1, self.control_y])
        p = (a + b + c) / 2  # semiperimeter

        rad = a * b * c / np.sqrt(p * (p - a) * (p - b) * (p - c)) / 4

        return rad

    def _arc_angle(self):
        """Eval arc angle from control point"""
        rel_rad = self._rel_radius()
        
        # NOTE: Bound the sin to avoid out of bounds errors
        # due to floating point error accumulation
        arc = 2 * np.arcsin(min(max(1 / rel_rad / 2, -1.), 1.))  

        if self._is_large_arc():
            arc = 2 * np.pi - arc
        
        return arc
    
    def _is_large_arc(self):
        """Indicate if the arc sweeps the large or small angle"""
        return abs(self.control_y) > self._rel_radius()

    def assembly(self):
        """Returns the dict-based representation of edges, 
            compatible with core -> BasePattern JSON (dict) 
        """
        ends, props = super().assembly()

        # NOTE: arc representation is the same as in SVG
        rad, large_arc, right = self.as_radius_flag()
        props['curvature'] = {
                    "type": 'circle',
                    "params": [rad, int(large_arc), int(right)]
                }
        return ends, props


class CurveEdge(Edge):
    """Curvy edge as Besier curve / B-spline"""

    def __init__(self, start=None, end=None, control_points=None,
                 relative=True, 
                 label='') -> None:
        """Define a Bezier curve edge
            * start, end: from/to vertices that the edge connects
            * control_points: coordinated of Bezier control points.
                Specification of One control point creates the Quadratic Bezier, 
                Specification of 2 control points creates Cubic Bezier. 
                Other degrees are not supported.
            * label: semantic label of the edge to be writted down as a property on assembly

            * relative: specify whether the control point coordinated are given 
            relative to the edge length (True) or in 2D coordinate system of a
                panel (False)

        """
        if control_points is None:
            control_points = []
        if start is None:
            start = [0, 0]
        if end is None:
            end = [0, 0]
        super().__init__(start, end, label=label)

        self.control_points = control_points

        if len(self.control_points) > 2:
            raise NotImplementedError(f'{self.__class__.__name__}::ERROR::Up to 2 control points (cubic Bezier) are supported')

        # Storing control points as relative since it preserves overall curve
        # shape during edge extension/contraction
        if not relative:
            self.control_points = [abs_to_rel_2d(self.start, self.end, c).tolist()
                                   for c in self.control_points]
    def flip_x_axis(self):
        """Flips the Bezier curve along the x-axis by inverting the y-coordinates"""
        abs_control_points = [rel_to_abs_2d(self.start, self.end, c) for c in self.control_points]
        self.start[1] = -self.start[1]
        self.end[1] = -self.end[1]

        abs_control_points= [[x, -y] for x, y in abs_control_points]
        self.control_points= [abs_to_rel_2d(self.start, self.end, c).tolist() for c in abs_control_points]
        return self
    def length(self):
        """Length of Bezier curve edge"""
        curve = self.as_curve()
        
        return curve.length()

    def __str__(self) -> str:

        points = [self.start] + self.control_points

        str = [f'[{p[0]:.2f}, {p[1]:.2f}]->' for p in points]
        str += [f'[{self.end[0]:.2f}, {self.end[1]:.2f}]']

        return 'Curve:' + ''.join(str)
    
    def midpoint(self):
        """Center of the edge"""
        curve = self.as_curve()

        t_mid = curve.ilength(curve.length()/2, s_tol=ILENGTH_S_TOL)
        return c_to_list(curve.point(t_mid))
    
    def _subdivide(self, fractions: list, by_length=False):
        """Add intermediate vertices to an edge, 
            splitting its curve parametrization or overall length according to 
            fractions while preserving the overall shape
        """
        from pygarment.garmentcode.edge_factory import EdgeFactory  # TODOLOW: ami - better solution?
        curve = self.as_curve()

        # Sub-curves
        covered_fr, prev_t = 0, 0
        clen = curve.length()
        subcurves = []
        for fr in fractions:
            covered_fr += fr
            if by_length:
                next_t = curve.ilength(clen * covered_fr, s_tol=ILENGTH_S_TOL)
                subcurves.append(curve.cropped(prev_t, next_t))
                prev_t = next_t
            else:
                subcurves.append(curve.cropped(covered_fr - fr, covered_fr))

        # Convert to CurveEdge objects
        subedges = EdgeSequence()
        for curve in subcurves:
            subedges.append(EdgeFactory.from_svg_curve(curve))
        # Reference the first/last vertices correctly
        subedges[0].start = self.start
        subedges[-1].end = self.end

        return subedges

    # Actions
    def reverse(self):
        """Flip the direction of the edge, accounting for curvatures"""

        self.start, self.end = self.end, self.start

        # change order of control points
        if len(self.control_points) == 2:
            self.control_points[0], self.control_points[1] = self.control_points[1], self.control_points[0]

        # Update coordinates
        for p in self.control_points:
            p[0], p[1] = 1 - p[0], -p[1]

        return self
    
    def reflect_features(self):
        """Reflect edge fetures from one side of the edge to the other"""

        for p in self.control_points:
            p[1] = -p[1]

        return self
    
    def as_curve(self, absolute=True):
        """As svgpath curve object

            Converting on the fly as exact vertex location might have been updated since
            the creation of the edge
        """
        # Get the nodes correcly
        if absolute:
            cp = [rel_to_abs_2d(self.start, self.end, c) for c in self.control_points]
            nodes = np.vstack((self.start, cp, self.end))
        else:
            cp = self.control_points
            nodes = np.vstack(([0, 0], cp, [1, 0]))
        
        params = nodes[:, 0] + 1j*nodes[:, 1]

        return svgpath.QuadraticBezier(*params) if len(cp) < 2 else svgpath.CubicBezier(*params)

    def linearize(self, n_verts_inside=9):
        """Return a linear approximation of an edge using the same vertex objects
           NOTE: n_verts_inside = number of vertices (excluding the start
           and end vertices) used to create a linearization of the edge

        """        
        n = n_verts_inside + 1
        tvals_init = np.linspace(0, 1, n, endpoint=False)[1:]

        curve = self.as_curve(absolute=False)
        curve_lengths = tvals_init * curve.length()
        tvals = [curve.ilength(c_len, s_tol=ILENGTH_S_TOL) for c_len in curve_lengths]

        edge_verts = [rel_to_abs_2d(self.start, self.end, c_to_list(curve.point(t))) for t in tvals]
        seq = self.to_edge_sequence(edge_verts)

        return seq

    def _extreme_points(self):
        """Return extreme points (on Y) of the current edge
            NOTE: this does NOT include the border vertices of an edge
        """

        # Variation of https://github.com/mathandy/svgpathtools/blob/5c73056420386753890712170da602493aad1860/svgpathtools/bezier.py#L197
        curve = self.as_curve(absolute=False)   # relative coords to find real extremizers
        poly = svgpath.bezier2polynomial(curve, return_poly1d=True)
        y = svgpath.imag(poly)
        dy = y.deriv()
        y_extremizers = svgpath.polyroots(
            dy, realroots=True, condition=lambda r: 0 < r < 1)

        extreme_points = np.array(
            [rel_to_abs_2d(self.start, self.end, c_to_list(curve.point(t)))
             for t in y_extremizers]
        )

        return extreme_points

    # Assembly into serializable object
    def assembly(self):
        """Returns the dict-based representation of edges, 
            compatible with core -> BasePattern JSON (dict) 
        """

        ends, props = super().assembly()

        props['curvature'] = {
                    "type": 'quadratic' if len(self.control_points) == 1 else 'cubic',
                    "params": self.control_points
                }
        return ends, props

    
class EdgeSequence:
    """Represents a sequence of (possibly chained) edges (e.g. every next edge
        starts from the same vertex that the previous edge ends with and
        allows building some typical edge sequences
    """
    def __init__(self, *args, verbose: bool = False) -> None:
        self.edges = []
        self.verbose = verbose
        for arg in args:
            self.append(arg)

    # ANCHOR Properties
    def __getitem__(self, i):
        if isinstance(i, slice):
            # return an EdgeSequence object for slices
            e_slice = self.edges[i]
            return EdgeSequence(e_slice)
        else:
            return self.edges[i]

    def index(self, elem):
        # Find the same object (by reference) 
        # list.index() is doing something different..
        # https://stackoverflow.com/a/47057419
        return next(i for i, e in enumerate(self.edges) if elem is e)

    def __len__(self):
        """Number of edges in the sequence"""
        return len(self.edges)

    def __contains__(self, item):
        # check presence by comparing references
        return any([item is e for e in self.edges])

    def __str__(self) -> str:
        return 'EdgeSeq: ' + str(self.edges)
    
    def __repr__(self) -> str:
        return self.__str__()

    def length(self):
        """Total length of edges"""
        return sum([e.length() for e in self.edges])

    def isLoop(self):
        return self.edges[0].start == self.edges[-1].end and len(self) > 1 #modify is to ==

    def isChained(self):
        """Does the sequence of edges represent correct chain?"""
        if len(self) < 2:
            return False

        for i in range(1, len(self.edges)):
            if self.edges[i].start is not self.edges[i-1].end:
                if self.verbose:
                    # This should be helpful to catch bugs
                    print(f'{self.__class__.__name__}::WARNING!::Edge sequence is not properly chained')
                return False
        return True

    def fractions(self) -> list:
        """Fractions of the lengths of each edge in sequence w.r.t. 
            the whole sequence
        """
        total_len = sum([e.length() for e in self.edges])

        return [e.length() / total_len for e in self.edges]

    def lengths(self) -> list:
        """Lengths of individual edges in the sequence"""
        return [e.length() for e in self.edges]

    def verts(self):
        """Return all vertex objects"""
        verts = [self.edges[0].start]
        for e in self.edges:
            if e.start is not verts[-1]:  # avoid adding the vertices of chained edges twice
                verts.append(e.start)
            verts.append(e.end)
        if verts[0] is verts[-1]:  # don't double count the loop origin
            verts.pop(-1)
        return verts

    def shortcut(self):
        """Opening of an edge sequence as a vector
        
            # NOTE May not reflect true shortcut if the egdes were flipped but
                the order remained
        """
        return np.array([self[0].start, self[-1].end]) 

    def bbox(self):
        """
        This function evaluates the 2D bounding box of the current panel and
        returns the panel vertices which are located on the bounding box (
        b_points).
        Output:
            * bbox (list): [min_x, max_x, min_y, max_y] of verts_2d
            * b_points (list): list of 2D vertices representing the b_points,
            i.e., the vertices of verts_2d located on the bounding box
        """
        # Take linear version of the edges
        # To correctly process edges with extreme curvatures

        lin_edges = EdgeSequence([e.linearize() for e in self.edges])
        verts_2d = np.asarray(lin_edges.verts())
        mi = verts_2d.min(axis=0)
        ma = verts_2d.max(axis=0)
        xs = [mi[0], ma[0]]
        ys = [mi[1], ma[1]]
        #return points on bounding box
        b_points = []
        for v in verts_2d:
            if v[0] in xs or v[1] in ys:
                b_points.append(v)
        if len(b_points) == 2:
            if not any(np.array_equal(arr, mi) for arr in b_points):
                b_points = [b_points[0], mi, b_points[1]]
            else:
                p = [mi[0],ma[1]]
                b_points = [b_points[0],p,b_points[1]]

        # FIXME Use one common order for the bbox output
        bbox = [mi[0], ma[0], mi[1], ma[1]]

        return bbox, b_points

    # ANCHOR Modifiers
    # All modifiers return self object to allow chaining
    # Wrappers around python's list
    def append(self, item):
        if isinstance(item, Edge):
            self.edges.append(item)
        elif isinstance(item, list):  # List of edge / EdgeSeq objects
            for e in item:
                self.append(e)
        elif isinstance(item, EdgeSequence):
            self.edges += item.edges
        else:
            raise ValueError(f'{self.__class__.__name__}::ERROR::Trying to add object of incompatible type {type(item)}')
        return self

    def insert(self, i, item):
        if isinstance(item, Edge):
            self.edges.insert(i, item)
        elif isinstance(item, list) or isinstance(item, EdgeSequence):
            for j in range(len(item)):
                self.edges.insert(i + j, item[j])
        else:
            raise NotImplementedError(f'{self.__class__.__name__}::ERROR::incerting object of {type(item)} not suported (yet)')
        return self
    
    def pop(self, i):
        if isinstance(i, Edge):
            i = self.index(i)
        self.edges.pop(i)
        return self

    def substitute(self, orig, new):
        """Remove orign item from the list and place seq into it's place
            orig can be either an id of an item to remove 
            or an instance of Edge that exists in the current sequence
        """
        if isinstance(orig, Edge):
            orig = self.index(orig)
        if orig < 0: 
            orig = len(self) + orig 
        self.pop(orig)
        self.insert(orig, new)
        return self

    def reverse(self):
        """Reverse edge sequence in-place"""
        self.edges.reverse()
        for edge in self.edges:
            edge.reverse()
        return self

    # EdgeSequence-specific
    def translate_by(self, shift):
        """Translate the edge seq vertices s.t. the first vertex is at new_origin
        """
        for v in self.verts():
            v[0] += shift[0]
            v[1] += shift[1]
        return self

    def snap_to(self, new_origin=None):
        """Translate the edge seq vertices s.t. the first vertex is at new_origin
        """
        if new_origin is None:
            new_origin = [0, 0]
        start = copy(self[0].start)
        shift = [new_origin[0] - start[0], new_origin[1] - start[1]]
        self.translate_by(shift)

        return self

    def close_loop(self):
        """if edge loop is not closed, add and edge to close it"""
        self.isChained()  # print warning if smth is wrong
        if not self.isLoop():
            self.append(Edge(self[-1].end, self[0].start))
        return self

    def rotate(self, angle):
        """Rotate edge sequence by angle in place, using first point as a reference

        Parameters: 
            angle -- desired rotation angle in radians (!)
        """
        curr_start = copy(self[0].start)
        
        # set the start point to zero
        self.snap_to([0, 0])
        rot = R2D(angle)

        for v in self.verts():
            v[:] = np.matmul(rot, v)
        
        # recover the original location
        self.snap_to(curr_start)

        return self

    def extend(self, factor):
        """Extend or shrink the edges along the line from start of the first
        edge to the end of the last edge in sequence. The start of the first
        edge remains fixed
        """
        # TODOLOW Version With preservation of total length?
        # TODOLOW Base extention factor on change in total length of edges rather
        # than on the shortcut length

        # FIXME extending by negative factor should be predictable (e.g. opposite direction of extention)

        # Need to take the target line from the chained order
        if not self.isChained():  
            chained_edges = self.chained_order()
            chained_edges.isChained()
            if chained_edges.isLoop():
                print(f'{self.__class__.__name__}::WARNING::Extending looped edge sequences is not available')
                return self
        else: 
            chained_edges = self
        
        target_line = np.array(chained_edges[-1].end) - np.array(chained_edges[0].start)
        target_line = target_line / norm(target_line)

        # gather vertices
        verts_coords = self.verts()
        nverts_coords = np.array(verts_coords)
        
        # adjust their position based on projection to the target line
        verts_projection = np.empty(nverts_coords.shape)
        fixed = nverts_coords[0]
        for i in range(nverts_coords.shape[0]):
            verts_projection[i] = (nverts_coords[i] - fixed).dot(target_line) * target_line

        new_verts = verts_coords - (1 - factor) * verts_projection

        # Update vertex objects
        for i in range(len(verts_coords)):
            verts_coords[i][:] = new_verts[i]

        return self

    def reflect(self, v0, v1):
        """Reflect 2D points w.r.t. 1D line defined by two points"""
        v0, v1 = np.asarray(v0), np.asarray(v1)
        vec = np.asarray(v1) - np.asarray(v0)
        vec = vec / norm(vec)  # normalize

        # https://demonstrations.wolfram.com/ReflectionMatrixIn2D/#more
        Ref = np.array([
            [1 - 2 * vec[1]**2,  2*vec[0]*vec[1]],
            [2*vec[0]*vec[1],    - 1 + 2 * vec[1]**2]
            ])
        
        # translate -> reflect -> translate back
        for v in self.verts():
            v[:] = np.matmul(Ref, np.asarray(v) - v0) + v0

        # Reflect edge features (curvatures, etc.)
        for e in self.edges:
            e.reflect_features()

        return self

    def propagate_label(self, label):
        """Propagate label to sub-edges
        NOTE: Recommended to perform after all edge modification 
            operations (stitching, cutting, inserting) were completed
            Support for edge label propagation through those operations is not (yet) implemented
        # TODO Edge labels on cuts/reassemble in the 
        """
        for e in self.edges:
            e.label = label

    # ANCHOR New sequences & versions
    def copy(self):
        """Create a copy of a current edge sequence preserving the chaining
        property of edge sequences"""
        new_seq = deepcopy(self)

        # deepcopy recreates the vertex objects on both sides of the edges
        # in changed edges those vertex objects are supposed to be shared
        # by neighbor edges

        for i in range(1, len(new_seq)):
            if self[i].start is self[i-1].end:
                new_seq[i].start = new_seq[i-1].end
            
        if self.isLoop():
            new_seq[-1].end = new_seq[0].start

        return new_seq

    def chained_order(self):
        """ Attempt to restore a chain in the EdgeSequence
            The chained edge sequence may lose its property if the edges
                were reversed externally.
            This routine created a copy of the correct sequence with aligned
                the order of edges,

            It might be useful for various calculations
        
        """
        chained = self.copy()
        
        for i in range(len(chained)):
            # Assuming the previous one is already sorted
            if i > 0 and chained[i].end is chained[i-1].end:
                chained[i].reverse()
            # Not connected to the previous one
            elif (i + 1 < len(chained)
                    and (chained[i].start is chained[i+1].start or chained[i].start is chained[i+1].end)):
                chained[i].reverse()
            # not connected to anything or connected properly -- leave as is
        
        return chained
