# SPDX-License-Identifier: GPL-3.0-or-later
"""Voyage segmentation for the AIS history recorder.

Pure Python (no Qt/QGIS): given a stream of per-MMSI position fixes it decides
where one voyage ends and the next begins, mints a stable ``voyage_id``, and emits
the typed events the history store persists. Testable headless.

Locked voyage rules:
  * A voyage is ACTIVE while the vessel is under way (ANY making-way nav-status).
    It ENDS on **moored (5)** or **at anchor (1)**, or on a **coverage gap >
    coverage_gap_s**. **aground (6) does NOT end** a voyage (a calamity, not a
    passage boundary). **Class B / nav-status 15 ('not defined')** has no usable
    status, so it falls back to **SOG** (stationary below ``slow_kn``).
  * A short **debounce** stops a one-message status flicker (moored->underway->
    moored) spawning a spurious voyage.
  * Fixes (and the voyage LineString) accumulate ONLY while a voyage is open — a
    moored / between-voyages / out-of-coverage vessel produces register + events
    but no track points.

Emitted events are plain dicts with a ``"kind"``:
  coverage_in   {mmsi, t}
  coverage_out  {mmsi, t}                       # t = last fix before the gap
  nav_change    {mmsi, t, old, new}
  voyage_start  {mmsi, voyage_id, t}
  voyage_end    {mmsi, voyage_id, t, reason}    # reason: moored|anchored|stopped|coverage_timeout
  fix           {mmsi, voyage_id, t, lon, lat, sog, cog, heading, nav_status,
                 routeid, route_km, flow}
"""

_ANCHOR, _MOORED, _AGROUND, _UNDEFINED = 1, 5, 6, 15
_STATIONARY_STATUS = frozenset((_ANCHOR, _MOORED))
_UNKNOWN_STATUS = frozenset((None, _UNDEFINED))


class _VState:
    __slots__ = ("voyage_id", "voyage_start_t", "stationary", "pending",
                 "pend_n", "last_t", "last_nav", "in_coverage", "seen")

    def __init__(self):
        self.voyage_id = None
        self.voyage_start_t = None
        self.stationary = True        # committed under-way/stationary state
        self.pending = None           # raw state awaiting debounce
        self.pend_n = 0
        self.last_t = None
        self.last_nav = None
        self.in_coverage = False
        self.seen = False             # first fix processed?


class VoyageTracker:
    def __init__(self, node_id="local", slow_kn=2.0, debounce=3,
                 coverage_gap_s=3600.0):
        """node_id stamps every voyage_id (``node:mmsi:start_t``) so one node's
        history is a valid shard. slow_kn = SOG fallback threshold for Class B;
        debounce = fixes a state change must persist; coverage_gap_s = silence that
        ends a voyage (default 3600 = 60 min)."""
        self.node_id = node_id
        self.slow_kn = slow_kn
        self.debounce = max(1, int(debounce))
        self.coverage_gap_s = coverage_gap_s
        self._st = {}

    def _is_stationary(self, nav_status, sog):
        """True when the vessel counts as stopped: moored/anchored by status, or
        (Class B / undefined status) below the SOG threshold. Any other making-way
        status — including aground, fishing, sailing — is under way."""
        if nav_status in _STATIONARY_STATUS:
            return True
        if nav_status in _UNKNOWN_STATUS:
            return sog is not None and sog < self.slow_kn
        return False

    def _new_voyage_id(self, mmsi, t):
        return "%s:%s:%d" % (self.node_id, mmsi, int(t))

    def update(self, mmsi, t, lon=None, lat=None, sog=None, cog=None,
               heading=None, nav_status=None, routeid=None, route_km=None,
               flow=None):
        """Feed one position fix; return the list of events it produced."""
        st = self._st.get(mmsi)
        if st is None:
            st = self._st[mmsi] = _VState()
        ev = []

        # --- coverage gap: close an open voyage + mark out-of-coverage --------
        if st.last_t is not None and (t - st.last_t) > self.coverage_gap_s:
            if st.voyage_id is not None:
                ev.append({"kind": "voyage_end", "mmsi": mmsi,
                           "voyage_id": st.voyage_id, "t": st.last_t,
                           "reason": "coverage_timeout"})
                st.voyage_id = None
            if st.in_coverage:
                ev.append({"kind": "coverage_out", "mmsi": mmsi, "t": st.last_t})
                st.in_coverage = False
            st.pending = None; st.pend_n = 0

        if not st.in_coverage:
            ev.append({"kind": "coverage_in", "mmsi": mmsi, "t": t})
            st.in_coverage = True

        # --- nav-status change (register event) ------------------------------
        if nav_status != st.last_nav:
            ev.append({"kind": "nav_change", "mmsi": mmsi, "t": t,
                       "old": st.last_nav, "new": nav_status})
            st.last_nav = nav_status

        # --- stationary/under-way transition, debounced ----------------------
        raw = self._is_stationary(nav_status, sog)
        if not st.seen:
            st.stationary = raw          # first observation: commit immediately
            st.seen = True
            self._on_transition(st, mmsi, t, nav_status, ev)
        elif raw != st.stationary:
            if st.pending == raw:
                st.pend_n += 1
            else:
                st.pending, st.pend_n = raw, 1
            if st.pend_n >= self.debounce:
                st.stationary = raw
                st.pending, st.pend_n = None, 0
                self._on_transition(st, mmsi, t, nav_status, ev)
        else:
            st.pending, st.pend_n = None, 0

        # --- fix: only stored while a voyage is open -------------------------
        if st.voyage_id is not None:
            ev.append({"kind": "fix", "mmsi": mmsi, "voyage_id": st.voyage_id,
                       "t": t, "lon": lon, "lat": lat, "sog": sog, "cog": cog,
                       "heading": heading, "nav_status": nav_status,
                       "routeid": routeid, "route_km": route_km, "flow": flow})
        st.last_t = t
        return ev

    def _on_transition(self, st, mmsi, t, nav_status, ev):
        """Apply a committed stationary/under-way flip: end or start a voyage."""
        if st.stationary:
            if st.voyage_id is not None:
                reason = ("moored" if nav_status == _MOORED else
                          "anchored" if nav_status == _ANCHOR else "stopped")
                ev.append({"kind": "voyage_end", "mmsi": mmsi,
                           "voyage_id": st.voyage_id, "t": t, "reason": reason})
                st.voyage_id = None
        else:
            vid = self._new_voyage_id(mmsi, t)
            st.voyage_id = vid
            st.voyage_start_t = t
            ev.append({"kind": "voyage_start", "mmsi": mmsi,
                       "voyage_id": vid, "t": t})

    def sweep(self, now):
        """Close voyages / mark out-of-coverage for vessels that have gone silent
        past the gap without a new fix. Call periodically (e.g. once a minute).
        Returns the events produced."""
        ev = []
        for mmsi, st in self._st.items():
            if st.in_coverage and st.last_t is not None \
                    and (now - st.last_t) > self.coverage_gap_s:
                if st.voyage_id is not None:
                    ev.append({"kind": "voyage_end", "mmsi": mmsi,
                               "voyage_id": st.voyage_id, "t": st.last_t,
                               "reason": "coverage_timeout"})
                    st.voyage_id = None
                ev.append({"kind": "coverage_out", "mmsi": mmsi, "t": st.last_t})
                st.in_coverage = False
                st.pending = None; st.pend_n = 0
        return ev

    def open_voyages(self):
        """{mmsi: voyage_id} for vessels currently on an open voyage (for flush at
        shutdown, so their track/line is finalised)."""
        return {m: s.voyage_id for m, s in self._st.items()
                if s.voyage_id is not None}
