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

# -*- coding: UTF-8 -*-

import math
from itertools import chain, groupby

import networkx
from shapely import geometry as shgeo
from shapely.ops import snap
from shapely.strtree import STRtree

from ..debug import debug
from ..stitch_plan import Stitch
from ..svg import PIXELS_PER_MM
from ..utils.geometry import Point as InkstitchPoint
from ..utils.geometry import line_string_to_point_list
from .fill import intersect_region_with_grating, stitch_row
from .running_stitch import running_stitch


class PathEdge(object):
    OUTLINE_KEYS = ("outline", "extra", "initial")
    SEGMENT_KEY = "segment"

    def __init__(self, nodes, key):
        self.nodes = nodes
        self._sorted_nodes = tuple(sorted(self.nodes))
        self.key = key

    def __getitem__(self, item):
        return self.nodes[item]

    def __hash__(self):
        return hash((self._sorted_nodes, self.key))

    def __eq__(self, other):
        return self._sorted_nodes == other._sorted_nodes and self.key == other.key

    def is_outline(self):
        return self.key in self.OUTLINE_KEYS

    def is_segment(self):
        return self.key == self.SEGMENT_KEY


@debug.time
def auto_fill(shape,
              angle,
              row_spacing,
              end_row_spacing,
              max_stitch_length,
              running_stitch_length,
              staggers,
              skip_last,
              starting_point,
              ending_point=None,
              underpath=True):
    fill_stitch_graph = []
    try:
        fill_stitch_graph = build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting_point, ending_point)
    except ValueError:
        # Small shapes will cause the graph to fail - min() arg is an empty sequence through insert node
        return fallback(shape, running_stitch_length)

    if not graph_is_valid(fill_stitch_graph, shape, max_stitch_length):
        return fallback(shape, running_stitch_length)

    travel_graph = build_travel_graph(fill_stitch_graph, shape, angle, underpath)
    path = find_stitch_path(fill_stitch_graph, travel_graph, starting_point, ending_point)
    result = path_to_stitches(path, travel_graph, fill_stitch_graph, angle, row_spacing,
                              max_stitch_length, running_stitch_length, staggers, skip_last)

    return result


def which_outline(shape, coords):
    """return the index of the outline on which the point resides

    Index 0 is the outer boundary of the fill region.  1+ are the
    outlines of the holes.
    """

    # I'd use an intersection check, but floating point errors make it
    # fail sometimes.

    point = shgeo.Point(*coords)
    outlines = list(shape.boundary)
    outline_indices = list(range(len(outlines)))
    closest = min(outline_indices, key=lambda index: outlines[index].distance(point))

    return closest


def project(shape, coords, outline_index):
    """project the point onto the specified outline

    This returns the distance along the outline at which the point resides.
    """

    outline = list(shape.boundary)[outline_index]
    return outline.project(shgeo.Point(*coords))


@debug.time
def build_fill_stitch_graph(shape, angle, row_spacing, end_row_spacing, starting_point=None, ending_point=None):
    """build a graph representation of the grating segments

    This function builds a specialized graph (as in graph theory) that will
    help us determine a stitching path.  The idea comes from this paper:

    http://www.sciencedirect.com/science/article/pii/S0925772100000158

    The goal is to build a graph that we know must have an Eulerian Path.
    An Eulerian Path is a path from edge to edge in the graph that visits
    every edge exactly once and ends at the node it started at.  Algorithms
    exist to build such a path, and we'll use Hierholzer's algorithm.

    A graph must have an Eulerian Path if every node in the graph has an
    even number of edges touching it.  Our goal here is to build a graph
    that will have this property.

    Based on the paper linked above, we'll build the graph as follows:

        * nodes are the endpoints of the grating segments, where they meet
        with the outer outline of the region the outlines of the interior
        holes in the region.
        * edges are:
        * each section of the outer and inner outlines of the region,
            between nodes
        * double every other edge in the outer and inner hole outlines

    Doubling up on some of the edges seems as if it will just mean we have
    to stitch those spots twice.  This may be true, but it also ensures
    that every node has 4 edges touching it, ensuring that a valid stitch
    path must exist.
    """

    debug.add_layer("auto-fill fill stitch")

    # Convert the shape into a set of parallel line segments.
    rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing, end_row_spacing)
    segments = [segment for row in rows_of_segments for segment in row]

    graph = networkx.MultiGraph()

    # First, add the grating segments as edges.  We'll use the coordinates
    # of the endpoints as nodes, which networkx will add automatically.
    for segment in segments:
        # networkx allows us to label nodes with arbitrary data.  We'll
        # mark this one as a grating segment.
        graph.add_edge(*segment, key="segment", underpath_edges=[])

    tag_nodes_with_outline_and_projection(graph, shape, graph.nodes())
    add_edges_between_outline_nodes(graph, duplicate_every_other=True)

    if starting_point:
        insert_node(graph, shape, starting_point)

    if ending_point:
        insert_node(graph, shape, ending_point)

    debug.log_graph(graph, "graph")

    return graph


def insert_node(graph, shape, point):
    """Add node to graph, splitting one of the outline edges"""

    point = tuple(point)
    outline = which_outline(shape, point)
    projection = project(shape, point, outline)
    projected_point = list(shape.boundary)[outline].interpolate(projection)
    node = (projected_point.x, projected_point.y)

    edges = []
    for start, end, key, data in graph.edges(keys=True, data=True):
        if key == "outline":
            edges.append(((start, end), data))

    edge, data = min(edges, key=lambda edge_data: shgeo.LineString(edge_data[0]).distance(projected_point))

    graph.remove_edge(*edge, key="outline")
    graph.add_edge(edge[0], node, key="outline", **data)
    graph.add_edge(node, edge[1], key="outline", **data)
    tag_nodes_with_outline_and_projection(graph, shape, nodes=[node])


def tag_nodes_with_outline_and_projection(graph, shape, nodes):
    for node in nodes:
        outline_index = which_outline(shape, node)
        outline_projection = project(shape, node, outline_index)

        graph.add_node(node, outline=outline_index, projection=outline_projection)


def add_boundary_travel_nodes(graph, shape):
    for outline_index, outline in enumerate(shape.boundary):
        prev = None
        for point in outline.coords:
            point = shgeo.Point(point)
            if prev is not None:
                # Subdivide long straight line segments.  Otherwise we may not
                # have a node near the user's chosen starting or ending point
                length = point.distance(prev)
                segment = shgeo.LineString((prev, point))
                if length > 1:
                    # Just plot a point every pixel, that should be plenty of
                    # resolution.  A pixel is around a quarter of a millimeter.
                    for i in range(1, int(length)):
                        subpoint = segment.interpolate(i)
                        graph.add_node((subpoint.x, subpoint.y), projection=outline.project(subpoint), outline=outline_index)

            graph.add_node((point.x, point.y), projection=outline.project(point), outline=outline_index)
            prev = point


def add_edges_between_outline_nodes(graph, duplicate_every_other=False):
    """Add edges around the outlines of the graph, connecting sequential nodes.

    This function assumes that all nodes in the graph are on the outline of the
    shape.  It figures out which nodes are next to each other on the shape and
    connects them in the graph with an edge.

    Edges are tagged with their outline number and their position on that
    outline.
    """

    nodes = list(graph.nodes(data=True))  # returns a list of tuples: [(node, {data}), (node, {data}) ...]
    nodes.sort(key=lambda node: (node[1]['outline'], node[1]['projection']))

    for outline_index, nodes in groupby(nodes, key=lambda node: node[1]['outline']):
        nodes = [node for node, data in nodes]

        # add an edge between each successive node
        for i, (node1, node2) in enumerate(zip(nodes, nodes[1:] + [nodes[0]])):
            data = dict(outline=outline_index, index=i)
            graph.add_edge(node1, node2, key="outline", **data)

            if i % 2 == 0:
                graph.add_edge(node1, node2, key="extra", **data)


def graph_is_valid(graph, shape, max_stitch_length):
    # The graph may be empty if the shape is so small that it fits between the
    # rows of stitching.  Certain small weird shapes can also cause a non-
    # eulerian graph.
    return not networkx.is_empty(graph) and networkx.is_eulerian(graph)


def fallback(shape, running_stitch_length):
    """Generate stitches when the auto-fill algorithm fails.

    If graph_is_valid() returns False, we're not going to be able to run the
    auto-fill algorithm.  Instead, we'll just do running stitch around the
    outside of the shape.  In all likelihood, the shape is so small it won't
    matter.
    """

    return running_stitch(line_string_to_point_list(shape.boundary[0]), running_stitch_length)


@debug.time
def build_travel_graph(fill_stitch_graph, shape, fill_stitch_angle, underpath):
    """Build a graph for travel stitches.

    This graph will be used to find a stitch path between two spots on the
    outline of the shape.

    If underpath is False, we'll just be traveling
    around the outline of the shape, so the graph will only contain outline
    edges.

    If underpath is True, we'll also allow travel inside the shape.  We'll
    fill the shape with a cross-hatched grid of lines.  We'll construct a
    graph from them and use a shortest path algorithm to construct travel
    stitch paths in travel().

    When underpathing, we "encourage" the travel() function to travel inside
    the shape rather than on the boundary.  We do this by weighting the
    boundary edges extra so that they're more "expensive" in the shortest path
    calculation.  We also weight the interior edges extra proportional to
    how close they are to the boundary.
    """

    graph = networkx.MultiGraph()

    # Add all the nodes from the main graph.  This will be all of the endpoints
    # of the rows of stitches.  Every node will be on the outline of the shape.
    # They'll all already have their `outline` and `projection` tags set.
    graph.add_nodes_from(fill_stitch_graph.nodes(data=True))

    if underpath:
        boundary_points, travel_edges = build_travel_edges(shape, fill_stitch_angle)

        # This will ensure that a path traveling inside the shape can reach its
        # target on the outline, which will be one of the points added above.
        tag_nodes_with_outline_and_projection(graph, shape, boundary_points)
    else:
        add_boundary_travel_nodes(graph, shape)

    add_edges_between_outline_nodes(graph)

    if underpath:
        process_travel_edges(graph, fill_stitch_graph, shape, travel_edges)

    debug.log_graph(graph, "travel graph")

    return graph


def weight_edges_by_length(graph, multiplier=1):
    for start, end, key in graph.edges:
        p1 = InkstitchPoint(*start)
        p2 = InkstitchPoint(*end)

        graph[start][end][key]["weight"] = multiplier * p1.distance(p2)


def get_segments(graph):
    segments = []
    for start, end, key, data in graph.edges(keys=True, data=True):
        if key == 'segment':
            segments.append(shgeo.LineString((start, end)))

    return segments


def process_travel_edges(graph, fill_stitch_graph, shape, travel_edges):
    """Weight the interior edges and pre-calculate intersection with fill stitch rows."""

    # Set the weight equal to 5x the edge length, to encourage travel()
    # to avoid them.
    weight_edges_by_length(graph, 5)

    segments = get_segments(fill_stitch_graph)

    # The shapely documentation is pretty unclear on this.  An STRtree
    # allows for building a set of shapes and then efficiently testing
    # the set for intersection.  This allows us to do blazing-fast
    # queries of which line segments overlap each underpath edge.
    strtree = STRtree(segments)

    # This makes the distance calculations below a bit faster.  We're
    # not looking for high precision anyway.
    outline = shape.boundary.simplify(0.5 * PIXELS_PER_MM, preserve_topology=False)

    for ls in travel_edges:
        # In most cases, ls will be a simple line segment.  If we're
        # unlucky, in rare cases we can get a tiny little extra squiggle
        # at the end that can be ignored.
        points = [InkstitchPoint(*coord) for coord in ls.coords]
        p1, p2 = points[0], points[-1]

        edge = (p1.as_tuple(), p2.as_tuple(), 'travel')

        for segment in strtree.query(ls):
            # It seems like the STRTree only gives an approximate answer of
            # segments that _might_ intersect ls.  Refining the result is
            # necessary but the STRTree still saves us a ton of time.
            if segment.crosses(ls):
                start, end = segment.coords
                fill_stitch_graph[start][end]['segment']['underpath_edges'].append(edge)

        # The weight of a travel edge is the length of the line segment.
        weight = p1.distance(p2)

        # Give a bonus to edges that are far from the outline of the shape.
        # This includes the outer outline and the outlines of the holes.
        # The result is that travel stitching will tend to hug the center
        # of the shape.
        weight /= ls.distance(outline) + 0.1

        graph.add_edge(*edge, weight=weight)

    # without this, we sometimes get exceptions like this:
    # Exception AttributeError: "'NoneType' object has no attribute 'GEOSSTRtree_destroy'" in
    #   <bound method STRtree.__del__ of <shapely.strtree.STRtree instance at 0x0D2BFD50>> ignored
    del strtree


def travel_grating(shape, angle, row_spacing):
    rows_of_segments = intersect_region_with_grating(shape, angle, row_spacing)
    segments = list(chain(*rows_of_segments))

    return shgeo.MultiLineString(segments)


def ensure_multi_line_string(thing):
    """Given either a MultiLineString or a single LineString, return a MultiLineString"""

    if isinstance(thing, shgeo.LineString):
        return shgeo.MultiLineString([thing])
    else:
        return thing


def build_travel_edges(shape, fill_angle):
    r"""Given a graph, compute the interior travel edges.

    We want to fill the shape with a grid of line segments that can be used for
    travel stitch routing.  Our goals:

      * not too many edges so that the shortest path algorithm is speedy
      * don't travel in the direction of the fill stitch rows so that the
        travel stitch doesn't visually disrupt the fill stitch pattern

    To do this, we'll fill the shape with three gratings: one at +45 degrees
    from the fill stitch angle, one at -45 degrees, and one at +90 degrees.
    The pattern looks like this:

    /|\|/|\|/|\
    \|/|\|/|\|/
    /|\|/|\|/|\
    \|/|\|/|\|/

    Returns: (endpoints, edges)
        endpoints - the points on travel edges that intersect with the boundary
                    of the shape
        edges     - the line segments we can travel on, as individual LineString
                    instances
    """

    # If the shape is smaller, we'll have less room to maneuver and it's more likely
    # we'll travel around the outside border of the shape.  Counteract that by making
    # the grid denser.
    if shape.area < 10000:
        scale = 0.5
    else:
        scale = 1.0

    grating1 = travel_grating(shape, fill_angle + math.pi / 4, scale * 2 * PIXELS_PER_MM)
    grating2 = travel_grating(shape, fill_angle - math.pi / 4, scale * 2 * PIXELS_PER_MM)
    grating3 = travel_grating(shape, fill_angle - math.pi / 2, scale * math.sqrt(2) * PIXELS_PER_MM)

    debug.add_layer("auto-fill travel")
    debug.log_line_strings(grating1, "grating1")
    debug.log_line_strings(grating2, "grating2")
    debug.log_line_strings(grating3, "grating3")

    endpoints = [coord for mls in (grating1, grating2, grating3)
                 for ls in mls
                 for coord in ls.coords]

    diagonal_edges = ensure_multi_line_string(grating1.symmetric_difference(grating2))

    # without this, floating point inaccuracies prevent the intersection points from lining up perfectly.
    vertical_edges = ensure_multi_line_string(snap(grating3.difference(grating1), diagonal_edges, 0.005))

    return endpoints, chain(diagonal_edges, vertical_edges)


def nearest_node(nodes, point, attr=None):
    point = shgeo.Point(*point)
    nearest = min(nodes, key=lambda node: shgeo.Point(*node).distance(point))

    return nearest


@debug.time
def find_stitch_path(graph, travel_graph, starting_point=None, ending_point=None):
    """find a path that visits every grating segment exactly once

    Theoretically, we just need to find an Eulerian Path in the graph.
    However, we don't actually care whether every single edge is visited.
    The edges on the outline of the region are only there to help us get
    from one grating segment to the next.

    We'll build a Eulerian Path using Hierholzer's algorithm.  A true
    Eulerian Path would visit every single edge (including all the extras
    we inserted in build_graph()),but we'll stop short once we've visited
    every grating segment since that's all we really care about.

    Hierholzer's algorithm says to select an arbitrary starting node at
    each step.  In order to produce a reasonable stitch path, we'll select
    the starting node carefully such that we get back-and-forth traversal like
    mowing a lawn.

    To do this, we'll use a simple heuristic: try to start from nodes in
    the order of most-recently-visited first.
    """

    graph = graph.copy()

    if not starting_point:
        starting_point = list(graph.nodes.keys())[0]

    starting_node = nearest_node(graph, starting_point)

    if ending_point:
        ending_node = nearest_node(graph, ending_point)
    else:
        ending_point = starting_point
        ending_node = starting_node

    # The algorithm below is adapted from networkx.eulerian_circuit().
    path = []
    vertex_stack = [(ending_node, None)]
    last_vertex = None
    last_key = None

    while vertex_stack:
        current_vertex, current_key = vertex_stack[-1]
        if graph.degree(current_vertex) == 0:
            if last_vertex:
                path.append(PathEdge((last_vertex, current_vertex), last_key))
            last_vertex, last_key = current_vertex, current_key
            vertex_stack.pop()
        else:
            ignore, next_vertex, next_key = pick_edge(graph.edges(current_vertex, keys=True))
            vertex_stack.append((next_vertex, next_key))
            graph.remove_edge(current_vertex, next_vertex, next_key)

    # The above has the excellent property that it tends to do travel stitches
    # before the rows in that area, so we can hide the travel stitches under
    # the rows.
    #
    # The only downside is that the path is a loop starting and ending at the
    # ending node.  We need to start at the starting node, so we'll just
    # start off by traveling to the ending node.
    #
    # Note, it's quite possible that part of this PathEdge will be eliminated by
    # collapse_sequential_outline_edges().

    if starting_node is not ending_node:
        path.insert(0, PathEdge((starting_node, ending_node), key="initial"))

    # If the starting and/or ending point falls far away from the end of a row
    # of stitches (like can happen at the top of a square), then we need to
    # add travel stitch to that point.
    real_start = nearest_node(travel_graph, starting_point)
    path.insert(0, PathEdge((real_start, starting_node), key="outline"))

    # We're willing to start inside the shape, since we'll just cover the
    # stitches.  We have to end on the outline of the shape.  This is mostly
    # relevant in the case that the user specifies an underlay with an inset
    # value, because the starting point (and possibly ending point) can be
    # inside the shape.
    outline_nodes = [node for node, outline in travel_graph.nodes(data="outline") if outline is not None]
    real_end = nearest_node(outline_nodes, ending_point)
    path.append(PathEdge((ending_node, real_end), key="outline"))

    return path


def pick_edge(edges):
    """Pick the next edge to traverse in the pathfinding algorithm"""

    # Prefer a segment if one is available.  This has the effect of
    # creating long sections of back-and-forth row traversal.
    for source, node, key in edges:
        if key == 'segment':
            return source, node, key

    return list(edges)[0]


def collapse_sequential_outline_edges(path):
    """collapse sequential edges that fall on the same outline

    When the path follows multiple edges along the outline of the region,
    replace those edges with the starting and ending points.  We'll use
    these to stitch along the outline later on.
    """

    start_of_run = None
    new_path = []

    for edge in path:
        if edge.is_segment():
            if start_of_run:
                # close off the last run
                new_path.append(PathEdge((start_of_run, edge[0]), "collapsed"))
                start_of_run = None

            new_path.append(edge)
        else:
            if not start_of_run:
                start_of_run = edge[0]

    if start_of_run:
        # if we were still in a run, close it off
        new_path.append(PathEdge((start_of_run, edge[1]), "collapsed"))

    return new_path


def travel(travel_graph, start, end, running_stitch_length, skip_last):
    """Create stitches to get from one point on an outline of the shape to another."""

    path = networkx.shortest_path(travel_graph, start, end, weight='weight')
    path = [Stitch(*p) for p in path]
    stitches = running_stitch(path, running_stitch_length)

    for stitch in stitches:
        stitch.add_tag('auto_fill_travel')

    # The path's first stitch will start at the end of a row of stitches.  We
    # don't want to double that last stitch, so we'd like to skip it.
    if skip_last and len(path) > 2:
        # However, we don't want to skip it if we've had to do any actual
        # travel in the interior of the shape.  The reason is that we can
        # potentially cut a corner and stitch outside the shape.
        #
        # If the path is longer than two nodes, then it is not a simple
        # transition from one row to the next, so we'll keep the stitch.
        return stitches
    else:
        # Just a normal transition from one row to the next, so skip the first
        # stitch.
        return stitches[1:]


@debug.time
def path_to_stitches(path, travel_graph, fill_stitch_graph, angle, row_spacing, max_stitch_length, running_stitch_length, staggers, skip_last):
    path = collapse_sequential_outline_edges(path)

    stitches = []

    # If the very first stitch is travel, we'll omit it in travel(), so add it here.
    if not path[0].is_segment():
        stitches.append(Stitch(*path[0].nodes[0]))

    for edge in path:
        if edge.is_segment():
            stitch_row(stitches, edge[0], edge[1], angle, row_spacing, max_stitch_length, staggers, skip_last)
            travel_graph.remove_edges_from(fill_stitch_graph[edge[0]][edge[1]]['segment'].get('underpath_edges', []))
        else:
            stitches.extend(travel(travel_graph, edge[0], edge[1], running_stitch_length, skip_last))

    return stitches
