"""Engine behind the route-map window — pure Python, no Qt, testable offline.

Turns the fairway network + live vessel bindings into exactly what the window
renders, at two levels:

  * OVERVIEW : the coverage-masked subgraph (routes currently in range) drawn at
               real coordinates, plus a per-route inbound/outbound tally.
  * DETAIL   : one route as a single straight strip — its km range, end points,
               and the live vessels on it placed by route-km.

The window is a thin QGraphicsView over these structures; all the maths lives
here so it can be proven without QGIS.
"""
import math


def _project_t_d2(px, py, ax, ay, bx, by):
    """(t, dist^2): clamped projection parameter of p onto segment a->b and the
    squared distance to it. Metric-frame inputs."""
    dx, dy = bx - ax, by - ay
    l2 = dx * dx + dy * dy
    if l2 == 0.0:
        return 0.0, (px - ax) ** 2 + (py - ay) ** 2
    t = max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / l2))
    cx, cy = ax + t * dx, ay + t * dy
    return t, (px - cx) ** 2 + (py - cy) ** 2


def unpredictable_route(ship_type):
    """True for craft that don't keep lane discipline (so they're drawn as a
    neutral circle ON the axis, not a directional triangle in a lane):
    AIS 30-39 (fishing, towing, dredging, diving, military, sailing, pleasure)
    and 50-59 (pilot, SAR, tug, tender, anti-pollution, law enforcement, medical).
    """
    try:
        t = int(ship_type)
    except (TypeError, ValueError):
        return False
    return 30 <= t <= 39 or 50 <= t <= 59


class RouteMapModel:
    def __init__(self, network):
        """Wrap a built FairwayNetwork; caches its meta dict for quick lookups."""
        self.net = network
        self.meta = getattr(network, "meta", {}) or {}

    # ---- overview -------------------------------------------------------- #
    def active_subgraph(self, points, radius_m=8000.0):
        """Routes within `radius_m` of any footprint point (coverage proxy).

        points: iterable of (lon, lat). Returns dict with:
          nodes : {node: (lon, lat)}
          edges : [(u, v, routeid), ...]
          routes: set of routeids present
        """
        G = self.net.graph
        node_xy = self.meta.get("node_xy", {})
        if G is None or not points:
            return {"nodes": {}, "edges": [], "routes": set()}
        pm = [self.net.to_m(lo, la) for lo, la in points]
        r2 = radius_m * radius_m

        def near(nid):
            """True if graph node nid lies within radius_m of any footprint
            point (the coverage proxy that masks the in-range subgraph)."""
            xy = node_xy.get(nid)
            if not xy:
                return False
            x, y = self.net.to_m(*xy)
            for px, py in pm:
                if (x - px) ** 2 + (y - py) ** 2 <= r2:
                    return True
            return False

        keep = {}
        edges = []
        routes = set()
        cache = {}
        for u, v, d in G.edges(data=True):
            ku = cache.get(u)
            if ku is None:
                ku = cache[u] = near(u)
            kv = cache.get(v)
            if kv is None:
                kv = cache[v] = near(v)
            if ku or kv:
                if u not in keep and node_xy.get(u):
                    keep[u] = node_xy[u]
                if v not in keep and node_xy.get(v):
                    keep[v] = node_xy[v]
                edges.append((u, v, d.get("routeid")))
                routes.add(d.get("routeid"))
        return {"nodes": keep, "edges": edges, "routes": routes}

    # ---- octolinear schematic (condensed junction map) ------------------- #
    _OCTO_DIRS = [(1, 0), (1, 1), (0, 1), (-1, 1),
                  (-1, 0), (-1, -1), (0, -1), (1, -1)]

    def _contract_junctions(self, nodes, edges):
        """Reduce a region to inter-fairway junctions only: contract every
        pass-through point, keep nodes where 2+ fairways meet (or degree>=3).
        Returns (jnodes set, jedges [(u,v,routeid,length_m,name)])."""
        node_xy = self.meta.get("node_xy", {})
        names = self.meta.get("route_names", {})

        def m(nid):
            """Node id -> metric (x, y) for length measurement (closure)."""
            return self.net.to_m(*node_xy[nid])

        nr = {}
        adj = {}
        for u, v, rid in edges:
            if u not in node_xy or v not in node_xy:
                continue
            nr.setdefault(u, set()).add(rid)
            nr.setdefault(v, set()).add(rid)
            L = math.dist(m(u), m(v))
            adj.setdefault(u, []).append((v, rid, L))
            adj.setdefault(v, []).append((u, rid, L))
        jnodes = set(n for n in nr
                     if len(nr[n]) >= 2 or len(adj.get(n, ())) >= 3)
        jedges = {}
        for j in jnodes:
            for (nb, rid, L) in adj.get(j, ()):
                prev, cur, total, guard = j, nb, L, 0
                while cur not in jnodes and guard < 100000:
                    guard += 1
                    nxt = [(n, r, l) for (n, r, l) in adj.get(cur, ())
                           if n != prev]
                    if not nxt:
                        cur = None
                        break
                    n, r, l = nxt[0]
                    total += l
                    prev, cur = cur, n
                if cur is None or cur == j:
                    continue
                key = (min(j, cur), max(j, cur), rid)
                if key not in jedges or total < jedges[key]:
                    jedges[key] = total
        used = set()
        out = []
        for (u, v, rid), L in jedges.items():
            used.add(u)
            used.add(v)
            out.append((u, v, rid, L, names.get(rid, "route %s" % rid)))
        return used, out

    def _octolinear(self, jnodes, jedges):
        """Place junctions on an octolinear grid. Most links become a single
        straight horizontal/vertical/45° segment of quantized length; cycle
        links get a one-bend dogleg. Returns (pos{nid:(gx,gy)}, paths)."""
        if not jedges:
            return {}, []
        node_xy = self.meta.get("node_xy", {})
        adj = {}
        for (u, v, rid, L, name) in jedges:
            adj.setdefault(u, []).append((v, L))
            adj.setdefault(v, []).append((u, L))
        import statistics
        std = statistics.median([L for *_, L, _ in jedges]) or 1.0

        def m(nid):
            """Node id -> metric (x, y) for the layout geometry (closure)."""
            return self.net.to_m(*node_xy[nid])

        def snap_order(rdx, rdy):
            """The 8 octolinear directions ranked by alignment to the real
            direction (rdx, rdy) — best (most aligned) first (closure)."""
            n = math.hypot(rdx, rdy) or 1.0
            ux, uy = rdx / n, rdy / n
            return sorted(self._OCTO_DIRS,
                          key=lambda d: -((d[0] / math.hypot(*d)) * ux +
                                          (d[1] / math.hypot(*d)) * uy))

        from collections import deque
        root = max(jnodes, key=lambda n: len(adj.get(n, ())))
        pos = {root: (0, 0)}
        occ = {(0, 0)}
        Q = deque([root])
        while Q:
            u = Q.popleft()
            ux, uy = pos[u]
            umx, umy = m(u)
            for (v, L) in sorted(adj.get(u, ()), key=lambda e: -e[1]):
                if v in pos:
                    continue
                vmx, vmy = m(v)
                steps = max(1, min(4, round(L / std)))
                placed = False
                for (dx, dy) in snap_order(vmx - umx, vmy - umy):
                    for s in range(steps, steps + 5):
                        cand = (ux + dx * s, uy + dy * s)
                        if cand not in occ:
                            pos[v] = cand
                            occ.add(cand)
                            placed = True
                            break
                    if placed:
                        break
                if not placed:
                    r = 1
                    while not placed:
                        for dx, dy in self._OCTO_DIRS:
                            cand = (ux + dx * r, uy + dy * r)
                            if cand not in occ:
                                pos[v] = cand
                                occ.add(cand)
                                placed = True
                                break
                        r += 1
                Q.append(v)

        def path(p, q):
            """Octolinear route between two grid points: a single straight
            segment when already H/V/45°, else a one-bend dogleg (closure)."""
            dx, dy = q[0] - p[0], q[1] - p[1]
            if dx == 0 or dy == 0 or abs(dx) == abs(dy):
                return [p, q]
            sx = 1 if dx > 0 else -1
            sy = 1 if dy > 0 else -1
            mm = min(abs(dx), abs(dy))
            return [p, (p[0] + sx * mm, p[1] + sy * mm), q]

        paths = [(u, v, rid, name, path(pos[u], pos[v]))
                 for (u, v, rid, L, name) in jedges
                 if u in pos and v in pos]
        return pos, paths

    def octolinear_schematic(self, points, radius_m=8000.0):
        """Full condensed octolinear schematic for the in-range region.
        Returns {pos:{nid:(gx,gy)}, edges:[(u,v,rid,name,pts)], jnodes:set}."""
        sub = self.active_subgraph(points, radius_m)
        jnodes, jedges = self._contract_junctions(sub["nodes"], sub["edges"])
        pos, paths = self._octolinear(jnodes, jedges)
        return {"pos": pos, "edges": paths, "jnodes": set(pos.keys())}

    def tally(self, bindings):
        """Per-route inbound/outbound counts from current bindings.

        bindings: iterable of objects with .routeid, .on_fairway, .flow.
        Returns {routeid: {'name', 'in', 'out', 'total'}}.
        """
        names = self.meta.get("route_names", {})
        out = {}
        for b in bindings:
            if not getattr(b, "on_fairway", False) or b.routeid is None:
                continue
            row = out.setdefault(b.routeid, {"name": names.get(b.routeid) or "",
                                             "in": 0, "out": 0, "total": 0})
            row["total"] += 1
            if b.flow == "outbound":
                row["out"] += 1
            elif b.flow == "inbound":
                row["in"] += 1
        return out

    def route_table(self, bindings):
        """Per-route vessel counts for the Routes tab.

        Returns a list of ``{routeid, name, inbound, outbound, idle, total}``,
        one row per route that has at least one bound vessel, sorted by fairway
        NAME (case-insensitive) so the row order stays stable as counts change. A
        vessel on a fairway with no flow (static / moored / drifting) counts idle.
        """
        names = self.meta.get("route_names", {})
        rows = {}
        for b in bindings:
            if not getattr(b, "on_fairway", False) or b.routeid is None:
                continue
            r = rows.get(b.routeid)
            if r is None:
                r = rows[b.routeid] = {
                    "routeid": b.routeid,
                    "name": names.get(b.routeid) or ("route %s" % b.routeid),
                    "inbound": 0, "outbound": 0, "idle": 0, "total": 0}
            r["total"] += 1
            if b.flow == "inbound":
                r["inbound"] += 1
            elif b.flow == "outbound":
                r["outbound"] += 1
            else:
                r["idle"] += 1
        return sorted(rows.values(),
                      key=lambda r: (r["name"] or "").lower())

    def route_km_range(self, routeid):
        """(min_km, max_km) for a route from its stored end nodes, or None."""
        ends = self.meta.get("route_ends", {}).get(routeid, [])
        if not ends:
            return None
        kms = [k for k, _ in ends]
        return min(kms), max(kms)

    def route_strip(self, routeid, bindings, min_sog_kn=0.0):
        """One route as a straight strip.

        Each vessel carries its reported SOG (knots) so the view can colour the
        marker by speed. ``min_sog_kn`` drops vessels whose speed is *known* and
        below the threshold (slow/idle craft declutter the strip); a vessel with
        an unknown SOG is always kept, so a target is never silently lost.

        Returns {name, km_min, km_max, ends:[km_min, km_max],
                 vessels:[{mmsi, km, flow, sense, sog, predictable}]} or None.
        """
        rng = self.route_km_range(routeid)
        if rng is None:
            return None
        km_min, km_max = rng
        names = self.meta.get("route_names", {})
        vessels = []
        for b in bindings:
            if b.routeid == routeid and getattr(b, "on_fairway", False) \
                    and b.km is not None:
                sog = getattr(b, "sog", None)
                if sog is not None and sog < min_sog_kn:
                    continue                 # known-slow: declutter it out
                vessels.append({"mmsi": b.mmsi, "km": b.km, "flow": b.flow,
                                "sense": getattr(b, "course_sense", 0),
                                "sog": sog,
                                "predictable": not unpredictable_route(
                                    getattr(b, "ship_type", None))})
        return {"name": names.get(routeid) or "route %s" % routeid,
                "km_min": km_min, "km_max": km_max,
                "ends": [km_min, km_max], "vessels": vessels}

    # ---- geographic placement + route CPA -------------------------------- #
    def _ensure_route_segs(self):
        """Lazily index each route's segments by km (for point_at_km). Includes
        the user's custom routes (negative routeids) so the route-leg view and
        marker snapping work for them too."""
        if getattr(self, "_route_segs", None) is not None:
            return
        routes = {}
        seen = set()
        indexes = (self.net.seg_index, getattr(self.net, "custom_index", {}))
        for index in indexes:
            for recs in index.values():
                for rec in recs:
                    if id(rec) in seen:
                        continue
                    seen.add(id(rec))
                    (ax, ay, bx, by, fid, rid, name, kmb, kme, total, cum_i,
                     alon, alat, blon, blat) = rec
                    seg_len = math.hypot(bx - ax, by - ay)
                    if total <= 0:
                        continue
                    k0 = kmb + (cum_i / total) * (kme - kmb)
                    k1 = kmb + ((cum_i + seg_len) / total) * (kme - kmb)
                    routes.setdefault(rid, []).append(
                        (k0, k1, alon, alat, blon, blat))
        for rid in routes:
            routes[rid].sort(key=lambda s: min(s[0], s[1]))
        self._route_segs = routes

    def snap_marker(self, lon, lat, max_offset_m=None):
        """Snap a free point (a user reference marker) to the nearest fairway.

        Brute-forces the nearest segment across every route (a handful of
        user markers vs ~2000 routes is trivial) so a marker placed anywhere near
        the water resolves — unlike the binding grid, it isn't capped to a cell
        neighbourhood. Returns (routeid, km, offset_m), or None if no route is
        within ``max_offset_m`` (when given).
        """
        self._ensure_route_segs()
        if not self._route_segs:
            return None
        px, py = self.net.to_m(lon, lat)
        best = None                       # (dist2, routeid, km)
        for rid, segs in self._route_segs.items():
            for k0, k1, alon, alat, blon, blat in segs:
                ax, ay = self.net.to_m(alon, alat)
                bx, by = self.net.to_m(blon, blat)
                t, d2 = _project_t_d2(px, py, ax, ay, bx, by)
                if best is None or d2 < best[0]:
                    best = (d2, rid, k0 + t * (k1 - k0))
        if best is None:
            return None
        off = math.sqrt(best[0])
        if max_offset_m is not None and off > max_offset_m:
            return None
        return best[1], best[2], off

    def point_at_km(self, routeid, km):
        """(lon, lat, bearing_deg) at route-km `km`, or None."""
        self._ensure_route_segs()
        segs = self._route_segs.get(routeid)
        if not segs:
            return None
        best = None
        for k0, k1, alon, alat, blon, blat in segs:
            lo, hi = (k0, k1) if k0 <= k1 else (k1, k0)
            if lo <= km <= hi:
                f = 0.0 if k1 == k0 else (km - k0) / (k1 - k0)
                lon = alon + f * (blon - alon)
                lat = alat + f * (blat - alat)
                ax, ay = self.net.to_m(alon, alat)
                bx, by = self.net.to_m(blon, blat)
                brg = math.degrees(math.atan2(bx - ax, by - ay)) % 360.0
                return lon, lat, brg
            # track nearest endpoint as a fallback
            for kk, lo2, la2 in ((k0, alon, alat), (k1, blon, blat)):
                dd = abs(kk - km)
                if best is None or dd < best[0]:
                    best = (dd, lo2, la2, k0, k1, alon, alat, blon, blat)
        if best is not None:
            _, lo2, la2, k0, k1, alon, alat, blon, blat = best
            ax, ay = self.net.to_m(alon, alat)
            bx, by = self.net.to_m(blon, blat)
            brg = math.degrees(math.atan2(bx - ax, by - ay)) % 360.0
            return lo2, la2, brg
        return None

    @staticmethod
    def _along_speed_kmh(b):
        """Signed along-route speed (km/h), +km direction positive.

        Prefers reported SOG (stable) with the course sense for direction; falls
        back to the km-derived rate only if SOG is unavailable.
        """
        sog = getattr(b, "sog", None)
        cs = getattr(b, "course_sense", 0)
        if sog is not None and cs:
            return cs * sog * 1.852          # knots -> km/h, signed
        v = getattr(b, "along_kmh", None)
        if v is not None and abs(v) > 0.05:
            return v
        return None

    def object_eta(self, b, km_obj):
        """A vessel's arrival at a FIXED point on its route (a bridge or lock at
        route-km ``km_obj``) — the route-CPA against a stationary object. Returns
        {km, lon, lat, bearing, t_min, dtg_km} or None if the vessel isn't moving,
        isn't on a route, or is heading away from the object.
        """
        if getattr(b, "routeid", None) is None or getattr(b, "km", None) is None:
            return None
        s = self._along_speed_kmh(b)
        if s is None or abs(s) < 1e-6:
            return None
        t = (km_obj - b.km) / s              # hours
        if t < 0:
            return None                      # moving away from the object
        geo = self.point_at_km(b.routeid, km_obj)
        if geo is None:
            return None
        lon, lat, brg = geo
        return {"km": km_obj, "lon": lon, "lat": lat, "bearing": brg,
                "t_min": t * 60.0, "dtg_km": abs(s * t)}

    def route_cpa(self, b_a, b_b):
        """1-D meeting point of two vessels bound to the same route.

        Handles both cases on a shared route:
          * HEAD-ON  — opposite along-route directions: they pass each other.
          * OVERTAKING — same direction, different speed: the faster catches the
            slower. ``overtaking`` is True in the result and the view draws it
            green instead of blue.

        Distances are measured ALONG the route (not straight-line). DTG (distance
        to go to the meeting point) is reported per vessel and per role. Returns
        {routeid, km, lon, lat, bearing, t_min, overtaking, dist_a_km, dist_b_km,
        dtg_out_km, dtg_in_km, mmsi_in, mmsi_out} or None if they aren't closing
        on a shared route.
        """
        if b_a.routeid is None or b_a.routeid != b_b.routeid:
            return None
        if b_a.km is None or b_b.km is None:
            return None
        s_a = self._along_speed_kmh(b_a)
        s_b = self._along_speed_kmh(b_b)
        if s_a is None or s_b is None:
            return None
        denom = s_a - s_b
        if abs(denom) < 1e-6:
            return None                      # parallel / same speed -> no meeting
        t = (b_b.km - b_a.km) / denom        # hours
        if t < 0:
            return None                      # already passed / diverging
        km_meet = b_a.km + s_a * t
        geo = self.point_at_km(b_a.routeid, km_meet)
        if geo is None:
            return None
        lon, lat, brg = geo
        # same along-route direction (both signs equal) => overtaking, else head-on
        overtaking = (s_a > 0) == (s_b > 0)
        # which is inbound / outbound (fall back to km direction if flow blank)
        def is_out(b):
            """True if a binding is outbound — by its flow flag, falling back to
            its along-route speed sign when flow is blank (closure)."""
            if b.flow:
                return b.flow == "outbound"
            return self._along_speed_kmh(b) > 0  # crude fallback
        mmsi_out = b_a.mmsi if is_out(b_a) else b_b.mmsi
        mmsi_in = b_b.mmsi if mmsi_out == b_a.mmsi else b_a.mmsi
        # DTG per vessel, then keyed by the out/in roles the view labels with.
        dtg = {b_a.mmsi: abs(s_a * t), b_b.mmsi: abs(s_b * t)}
        return {"routeid": b_a.routeid, "km": km_meet, "lon": lon, "lat": lat,
                "bearing": brg, "t_min": t * 60.0, "overtaking": overtaking,
                "dist_a_km": abs(s_a * t), "dist_b_km": abs(s_b * t),
                "dtg_out_km": dtg[mmsi_out], "dtg_in_km": dtg[mmsi_in],
                "mmsi_in": mmsi_in, "mmsi_out": mmsi_out}
