
from itertools import groupby
from logging import getLogger

from math import sqrt, ceil
from pygeotile.point import Point

from .activity import ActivityReader
from .waypoint import filter_none
from ...lib.date import to_time, format_time
from ...lib.utils import sign
from ...rtree import MatchType
from ...rtree.spherical import LocalTangent, SQRTree, Global
from ...sql.database import Timestamp
from ...sql.tables.activity import ActivityGroup
from ...sql.tables.segment import Segment, SegmentJournal
from ...sql.utils import add

log = getLogger(__name__)

NAMES = {'Latitude': 'lat',
         'Longitude': 'lon',
         'Distance': 'distance'}


class CalcFailed(Exception): pass


class SegmentReader(ActivityReader):

    def __init__(self, *args, inner_bound=10, match_bound=25, **kargs):
        self.inner_bound = inner_bound
        self.match_bound = match_bound
        super().__init__(*args, **kargs)

    def _startup(self, s):
        SegmentJournal.clean(s)
        super()._startup(s)
        self.__segments = {}
        for agroup in s.query(ActivityGroup).all():
            self.__segments[agroup.id] = self._read_segments(s, agroup)

    def _shutdown(self, s):
        SegmentJournal.clean(s)
        super()._shutdown(s)

    def _load_data(self, s, loader, data):
        super()._load_data(s, loader, data)
        ajournal, activity_group, first_timestamp, file_scan, define, records = data
        self._find_segments(s, ajournal, filter_none(NAMES.values(), loader.as_waypoints(NAMES)))

    def _find_segments(self, s, ajournal, waypoints):
        matches = sorted(self._initial_matches(s, ajournal.activity_group_id, waypoints), key=lambda m: m[2].name)
        for segment, segment_matches in groupby(matches, key=lambda m: m[2]):
            log.debug(f'Considering {segment.name}')
            ordered = sorted(segment_matches, key=lambda m: m[0])
            coallesced = list(self._coallesce(ordered))
            starts, finishes, segment = self._split(coallesced)
            while starts:
                start = starts.pop(-1)  # work backwards through starts
                copy = list(finishes)
                while copy:
                    finish = copy.pop(0)  # work forwards through finishes
                    if finish[0] > start[0]:
                        if self._try_segment(s, start, finish, waypoints, segment, ajournal):
                            copy = None  # exit search for this start
                            # move to a start that won't overlap (working backwards)
                            while starts and starts[-1][0] > start[0] - 0.5 * (finish[0] - start[0]):
                                starts.pop(-1)
                            if starts:
                                log.debug('Possible second segment')

    def _try_segment(self, s, starts, finishes, waypoints, segment, ajournal):
        try:
            log.info('Trying segment %s-%s for %s' % (starts, finishes, segment.name))
            d = waypoints[self._mid(finishes)].distance - waypoints[self._mid(starts)].distance
            if abs(d - segment.distance) / segment.distance > 0.2:
                raise CalcFailed('Distance between start and finish (%.1fkm) doesn\'t match segment (%.1fkm)' %
                                 (d / 1000, segment.distance / 1000))
            start_time = self._end_point(starts, waypoints, segment.start, self.inner_bound, True)
            finish_time = self._end_point(finishes, waypoints, segment.finish, self.inner_bound, False)
            sjournal = add(s, SegmentJournal(segment_id=segment.id, activity_journal=ajournal,
                                             start=start_time, finish=finish_time))
            log.info('Added %s for %s - %s' %
                           (segment.name, format_time(start_time), format_time(finish_time)))
            s.commit()  # needed to get id on sjournal (and let other workers in)
            # since source is sjournal we cannot use on_success
            Timestamp.set(s, self, constraint=segment, source=sjournal)
            return True
        except CalcFailed as e:
            log.warning(str(e))
            return False

    def _mid(self, indices):
        n = len(indices)
        return indices[n // 2]

    def _end_point(self, indices, waypoints, p, inner, hi_to_lo):
        '''
        Find the time of the point that makes the segment shortest while also being at a local
        minimum in distance from the start/finish point that is within inner m.
        '''
        metric = LocalTangent(p)
        lo, hi = self._limits(metric, waypoints, indices, p)
        log.info('Finding closest point to %s within %d: %f, %d: %f' %
                       (p, lo, self._dw(metric, p, waypoints, lo),
                        hi, self._dw(metric, p, waypoints, hi)))
        if hi_to_lo:
            start, finish = hi, lo
        else:
            start, finish = lo, hi
        minimum = self._minimum(metric, waypoints, start, finish, p, inner)
        # interpolate to fractional index
        i0, i1 = int(minimum), int(minimum) + 1
        k = (minimum - i0) / (i1 - i0)
        t0, t1 = waypoints[i0].time.timestamp(), waypoints[i1].time.timestamp()
        return to_time(t0 * (1 - k) + t1 * k)

    def _plot(self, p):
        '''
        Plot points for debugging
        '''
        lon, lat = p
        print('PLOT: %s' % (Point.from_latitude_longitude(lat, lon).meters, ))

    def _plotw(self, metric, waypoints, i, d):
        '''
        Plot points for debugging
        '''
        i1, i2 = int(i), int(i) + 1
        x1, y1 = metric.normalize((waypoints[i1].lon, waypoints[i1].lat))
        x2, y2 = metric.normalize((waypoints[i2].lon, waypoints[i2].lat))
        k = i - i1
        xk = x1 * (1 - k) + x2 * k
        yk = y1 * (1 - k) + y2 * k
        lon, lat = metric.denormalize((xk, yk))
        print('PLOT: %s %.1f' % (Point.from_latitude_longitude(lat, lon).meters, d))

    def _minimum(self, metric, waypoints, start, finish, p, inner):
        '''
        Find the (fractional) index of the point with minimum distance to the endpoint within the two limits.
        '''
        # self._plot(p)
        i = start
        while True:
            i, min_d = self._next_local_minimum(metric, p, waypoints, i, start, finish)
            log.info('Local minimum %.1f: %f (< %.1f?)' % (i, min_d, inner))
            # self._plotw(metric, waypoints, i, min_d)
            if min_d < inner:
                return i
            # move to next segment
            if start < finish:
                i = int(i + 1)
            else:
                i = ceil(i - 1)

    def _next_local_minimum(self, metric, p, waypoints, i, start, finish):
        '''
        Find the next point with a minimum distance to p, along the waypoints (interpolated),
        starting from i and between start and finish (moving towards finish).
        '''
        # this from basic algebra (derivation too long for a comment but pretty simple)
        # p0 is the point we want to minimize distance to
        # pi and pj are the end points of a nearby line segment
        # k is the fractional distance between pi and pj for the point nearest to p0
        # p1 is the point at k if it is between pi and pj
        # things are complicated by the need to return pj if it's the nearest point (discontinuity)
        p0 = metric.normalize(p)
        pi = metric.normalize((waypoints[i].lon, waypoints[i].lat))
        prev_discontinuity = None
        while start <= i < finish or start >= i > finish:
            j = i + sign(finish - start)
            pj = metric.normalize((waypoints[j].lon, waypoints[j].lat))
            dxji, dyji = pj[0] - pi[0], pj[1] - pi[1]
            dx0i, dy0i = p0[0] - pi[0], p0[1] - pi[1]
            k = (dxji * dx0i + dyji * dy0i) / (dxji**2 + dyji**2)
            if 0 < k < 1:  # we have a solution within the segment
                p1 = (pi[0] + k * (pj[0] - pi[0]), pi[1] + k * (pj[1] - pi[1]))
                return i + k * sign(finish - start), sqrt((p0[0] - p1[0])**2 + (p0[1] - p1[1])**2)
            # are we moving towards the target?
            if k > 1:
                # if so, save this endpoint as a possible minimum
                prev_discontinuity = (j, self._dw(metric, p, waypoints, j))
            else:
                # if not, then if we were before it was a minimum
                if prev_discontinuity:
                    return prev_discontinuity
            i, pi = j, pj
        raise CalcFailed('No minimum found')

    def _limits(self, metric, waypoints, indices, p):
        '''
        Given a pair of indices, move them apart so that the distance from the endpoint has increased
        in both directions (ie they bracket the endpoint in some sense).
        '''
        lo, hi = indices
        if lo == hi:
            lo, hi = lo-1, hi+1
        dl, dh = self._dw(metric, p, waypoints, lo), self._dw(metric, p, waypoints, hi)
        if dl < dh:
            lo, dl = self._inc_limit(metric, waypoints, p, dl, dh, lo, -1)
            hi, dh = self._inc_limit(metric, waypoints, p, dh, dl, hi, 1)
        else:
            hi, dh = self._inc_limit(metric, waypoints, p, dh, dl, hi, 1)
            lo, dl = self._inc_limit(metric, waypoints, p, dl, dh, lo, -1)
        log.info('Expanded %s to %s' % (indices, (lo, hi)))
        return lo, hi

    def _inc_limit(self, metric, waypoints, p, da, db, a, inc):
        '''
        Move one index.
        '''
        while da < db and ((inc > 0 and a < len(waypoints) - 1) or (inc < 0 and a > 0)):
            a += inc
            da = self._dw(metric, p, waypoints, a)
        return a, da

    def _dw(self, metric, p, waypoints, i):
        '''
        Distance between an waypoint and the endpoint.
        '''
        return self._d(metric, p, (waypoints[i].lon, waypoints[i].lat))

    def _d(self, metric, p1, p2):
        '''
        The distance between two points using the given metric.
        '''
        x1, y1 = metric.normalize(p1)
        x2, y2 = metric.normalize(p2)
        return sqrt((x1 - x2)**2 + (y1 - y2)**2)

    def _split(self, coallesced):
        '''
        Split waypoints for a single segment into separate start and finish points.
        '''
        starts, finishes, segment = [], [], None
        for i, start, segment in coallesced:
            if start:
                starts.append(i)
            else:
                finishes.append(i)
        return starts, finishes, segment

    def _coallesce(self, ordered_sm):
        '''
        Replace contiguous ranges of waypoints with a pair (min, max)
        '''
        prev, first = None, None
        for match in ordered_sm:
            if match != prev:  # skip duplicates
                if prev and (prev[1:2] != match[1:2] or prev[0]+1 != match[0]):
                    yield (first, prev[0]), prev[1], prev[2]
                    prev, first = None, None
                if first is None:
                    first = match[0]
            prev = match
        yield (first, prev[0]), prev[1], prev[2]

    def _initial_matches(self, s, agroup_id, waypoints):
        '''
        Check each waypoint against the r-tree and return all matches.
        '''
        found = set()
        for i, waypoint in enumerate(waypoints):
            for start, segment in self.__segments[agroup_id][[(waypoint.lon, waypoint.lat)]]:
                if segment not in found:
                    log.info(f'Candidate segment "{segment.name}" at ({segment.start_lon}, {segment.start_lat}) - '
                             f'({segment.finish_lon}, {segment.finish_lat})')
                    found.add(segment)
                log.debug(f'Match at {(waypoint.lon, waypoint.lat)} '
                          f'for {segment.name} {"start" if start else "finish"}')
                yield i, start, segment

    def _read_segments(self, s, agroup):
        '''
        Read segment endpoints into a global R-tree so we can detect when waypoints pass nearby.
        '''
        segments = Global(tree=lambda: SQRTree(default_border=self.match_bound, default_match=MatchType.OVERLAP))
        for segment in s.query(Segment).all():
            segments[[segment.start]] = (True, segment)
            segments[[segment.finish]] = (False, segment)
        if not segments:
            log.warning('No segments defined in database for %s' % agroup)
        return segments
