# Authors: see git history
#
# Copyright (c) 2022 Authors
# Licensed under the GNU GPL version 3.0 or later.  See the file LICENSE for details.

from inkex import Boolean, Group, Path, PathElement, Transform, errormsg
from inkex.units import convert_unit
from shapely.geometry import LineString, MultiLineString, MultiPolygon, Point
from shapely.ops import linemerge, nearest_points, split, voronoi_diagram

from ..elements import FillStitch, Stroke
from ..i18n import _
from ..stitches.running_stitch import running_stitch
from ..svg import get_correction_transform
from ..utils import ensure_multi_line_string
from ..utils.geometry import Point as InkstitchPoint
from ..utils.geometry import line_string_to_point_list
from .base import InkstitchExtension


class FillToStroke(InkstitchExtension):
    def __init__(self, *args, **kwargs):
        InkstitchExtension.__init__(self, *args, **kwargs)
        self.arg_parser.add_argument("--options", dest="options", type=str, default="")
        self.arg_parser.add_argument("--info", dest="help", type=str, default="")
        self.arg_parser.add_argument("-t", "--threshold_mm", dest="threshold_mm", type=float, default=10)
        self.arg_parser.add_argument("-o", "--keep_original", dest="keep_original", type=Boolean, default=False)
        self.arg_parser.add_argument("-w", "--line_width_mm", dest="line_width_mm", type=float, default=1)
        self.arg_parser.add_argument("-g", "--close_gaps", dest="close_gaps", type=Boolean, default=False)

    def effect(self):
        if not self.svg.selected or not self.get_elements():
            errormsg(_("Please select one or more fill objects to render the centerline."))
            return

        fill_shapes, cut_lines, cut_line_nodes = self._get_shapes()

        if not fill_shapes:
            errormsg(_("Please select one or more fill objects to render the centerline."))
            return

        # convert user input from mm to px
        self.threshold = convert_unit(self.options.threshold_mm, 'px', 'mm')

        # convert to center line elements and insert into svg
        for element in fill_shapes:
            self._convert_to_centerline(element, cut_lines)

        # remove cut lines
        if not self.options.keep_original:
            self._remove_cutlines(cut_line_nodes)

    def _get_shapes(self):
        fill_shapes = []
        cut_lines = []
        cut_line_nodes = []
        for element in self.elements:
            if isinstance(element, FillStitch):
                fill_shapes.append(element)
            elif isinstance(element, Stroke):
                cut_lines.extend(list(element.as_multi_line_string().geoms))
                cut_line_nodes.append(element.node)
        return fill_shapes, cut_lines, cut_line_nodes

    def _convert_to_centerline(self, element, cut_lines):
        element_id = element.node.get_id()
        element_label = element.node.label
        group_name = element_label or element_id

        centerline_group = Group.new(f'{ group_name } { _("center line") }', id=self.uniqueId("centerline_group_"))
        parent = element.node.getparent()
        index = parent.index(element.node) + 1
        parent.insert(index, centerline_group)

        transform = Transform(get_correction_transform(parent, child=True))
        stroke_width = convert_unit(self.options.line_width_mm, 'px', 'mm')
        color = element.node.style('fill')
        style = f"fill:none;stroke:{ color };stroke-width:{ stroke_width }"

        multipolygon = element.shape
        multipolygon = self._apply_cut_lines(cut_lines, multipolygon)

        lines = self._get_lines(multipolygon)

        if self.options.close_gaps:
            lines = self._close_gaps(lines, cut_lines)

        # do not use a group in case there is only one line
        if len(lines) <= 1:
            parent.remove(centerline_group)
            centerline_group = parent

        # clean up
        if not self.options.keep_original:
            parent.remove(element.node)

        # insert new elements
        self._insert_elements(lines, centerline_group, index, element_id, element_label, transform, style)

    def _get_lines(self, multipolygon):
        lines = []
        for polygon in multipolygon.geoms:
            if polygon.area < 0.5:
                continue
            multilinestring = self._get_centerline(polygon)
            if multilinestring is None:
                continue
            lines.extend(multilinestring.geoms)
        return lines

    def _get_high_res_polygon(self, polygon):
        # use running stitch method
        runs = [running_stitch(line_string_to_point_list(polygon.exterior), 1, 0.1)]
        if len(runs[0]) < 3:
            return
        for interior in polygon.interiors:
            shape = running_stitch(line_string_to_point_list(interior), 1, 0.1)
            if len(shape) >= 3:
                runs.append(shape)
        return MultiPolygon([(runs[0], runs[1:])])

    def _get_centerline(self, polygon):
        # increase the resolution of the polygon
        polygon = self._get_high_res_polygon(polygon)
        if polygon is polygon.geom_type == 'MultiPolygon':
            return

        # generate voronoi centerline
        multilinestring = self._get_voronoi_centerline(polygon)
        if multilinestring is None:
            return
        # dead ends
        dead_ends = self._get_dead_end_lines(multilinestring)
        # avoid the splitting of line ends
        multilinestring = self._repair_splitted_ends(polygon, multilinestring, dead_ends)
        # update dead ends
        dead_ends = self._get_dead_end_lines(multilinestring)
        # filter small dead ends
        multilinestring = self._filter_short_dead_ends(multilinestring, dead_ends)
        if multilinestring is None:
            return
        # simplify polygon
        multilinestring = ensure_multi_line_string(multilinestring.simplify(0.1))
        if multilinestring is None:
            return
        return multilinestring

    def _get_voronoi_centerline(self, polygon):
        lines = voronoi_diagram(polygon, edges=True).geoms[0]
        if not lines.geom_type == 'MultiLineString':
            return
        multilinestring = []
        for line in lines.geoms:
            if polygon.covers(line):
                multilinestring.append(line)
        lines = linemerge(multilinestring)
        if lines.is_empty:
            return
        return ensure_multi_line_string(lines)

    def _apply_cut_lines(self, cut_lines, multipolygon):
        for cut_line in cut_lines:
            split_polygon = split(multipolygon, cut_line)
            poly = [polygon for polygon in split_polygon.geoms if polygon.geom_type == 'Polygon']
            multipolygon = MultiPolygon(poly)
        return multipolygon

    def _get_start_and_end_points(self, multilinestring):
        points = []
        for line in multilinestring.geoms:
            points.extend(line.coords[::len(line.coords)-1])
        return points

    def _get_dead_end_lines(self, multilinestring):
        start_and_end_points = self._get_start_and_end_points(multilinestring)
        dead_ends = []
        for line in multilinestring.geoms:
            num_neighbours_start = start_and_end_points.count(line.coords[0]) - 1
            num_neighbours_end = start_and_end_points.count(line.coords[-1]) - 1
            if num_neighbours_start == 0 or num_neighbours_end == 0:
                dead_ends.append(line)
        return dead_ends

    def _filter_short_dead_ends(self, multilinestring, dead_ends):
        lines = list(multilinestring.geoms)
        for i, line in enumerate(multilinestring.geoms):
            if line in dead_ends and line.length < self.threshold:
                lines.remove(line)
        lines = linemerge(lines)
        if lines.is_empty:
            lines = None
        else:
            lines = ensure_multi_line_string(lines)
        return lines

    def _repair_splitted_ends(self, polygon, multilinestring, dead_ends):
        lines = list(multilinestring.geoms)
        for i, dead_end in enumerate(dead_ends):
            if dead_end.length > self.threshold:
                continue
            self._join_end(polygon, lines, dead_ends, dead_end, i)
        return ensure_multi_line_string(linemerge(lines))

    def _join_end(self, polygon, lines, dead_ends, dead_end, index):
        coords = dead_end.coords
        for j in range(index + 1, len(dead_ends)):
            if dead_ends[j].length > self.threshold:
                continue
            common_point = set([coords[0], coords[-1]]).intersection(dead_ends[j].coords)
            if len(common_point) > 0:
                dead_point1 = coords[0]
                if dead_point1 in common_point:
                    dead_point1 = coords[-1]
                dead_point2 = dead_ends[j].coords[0]
                if dead_point2 in common_point:
                    dead_point2 = dead_ends[j].coords[-1]
                end_line = LineString([dead_point1, dead_point2])
                if polygon.covers(end_line):
                    dead_end_center_point = end_line.centroid
                else:
                    continue
                lines.append(LineString([dead_end_center_point, list(common_point)[0]]))
                if dead_end in lines:
                    lines.remove(dead_end)
                if dead_ends[j] in lines:
                    lines.remove(dead_ends[j])
                continue

    def _close_gaps(self, lines, cut_lines):
        snaped_lines = []
        lines = MultiLineString(lines)
        for i, line in enumerate(lines.geoms):
            # for each cutline check if a line starts or ends close to it
            # if so extend the line at the start/end for the distance of the nearest point and snap it to that other line
            # we do not want to snap it to the rest of the lines directly, this could push the connection point into an unwanted direction
            coords = list(line.coords)
            start = Point(coords[0])
            end = Point(coords[-1])
            l_l = lines.difference(line)
            for cut_line in cut_lines:
                distance = start.distance(l_l)
                if cut_line.distance(start) < 0.6:
                    distance = start.distance(l_l)
                    new_start_point = self._extend_line(line.coords[0], line.coords[1], distance)
                    coords[0] = nearest_points(Point(list(new_start_point)), l_l)[1]
                if cut_line.distance(end) < 0.6:
                    distance = end.distance(l_l)
                    new_end_point = self._extend_line(line.coords[-1], line.coords[-2], distance)
                    coords[-1] = nearest_points(Point(list(new_end_point)), l_l)[1]
            snaped_lines.append(LineString(coords))
        return snaped_lines

    def _extend_line(self, p1, p2, distance):
        start_point = InkstitchPoint.from_tuple(p1)
        end_point = InkstitchPoint.from_tuple(p2)
        direction = (end_point - start_point).unit()
        new_point = start_point - direction * distance
        return new_point

    def _remove_cutlines(self, cut_line_nodes):
        for cut_line in cut_line_nodes:
            # it is possible, that we get one element twice (if it has both, a fill and a stroke)
            # this means that we already removed it from the svg and we can ignore the error.
            try:
                cut_line.getparent().remove(cut_line)
            except AttributeError:
                pass

    def _insert_elements(self, lines, parent, index, element_id, element_label, transform, style):
        replace = False if len(lines) > 1 or self.options.keep_original else True
        for i, line in enumerate(lines):
            line_id = element_id if replace else self.uniqueId(f"{ element_id }_")
            centerline_element = PathElement(
                id=line_id,
                d=str(Path(line.coords)),
                style=style,
                transform=str(transform)
            )
            if element_label is not None:
                label = element_label if replace else f"{ element_label }_{ i }"
                centerline_element.label = label
            parent.insert(index, centerline_element)
