"""AIS Guardian detection engine — pure Python, no QGIS dependencies.

This is the brain of the Guardian feature. It is deliberately free of any QGIS
/ Qt imports so it can be unit-tested in isolation and reused anywhere. The QGIS
layers (zones GeoPackage, live feed) and the dock GUI feed it plain values and
render what it returns.

What it implements (v1):
  * Alarm 1 — guard-zone entry / within
  * Alarm 2 — guard-zone exit (left-zone)
  * Alarm 3 — navigation-status change (moored/anchored -> under way using engine)
  * Alarm 4 — coverage MAPPING ONLY (Class A / Class B reception density); no alarm

Key behaviours (per the locked design):
  * Detection tests the SEGMENT between two consecutive fixes, not just the
    point, so a fast slow-updating vessel can't jump across a thin zone unseen.
  * One incident = one continuous visit. A 60 s (configurable) temporal exit
    grace collapses tide/jitter loitering into a single incident; a genuine
    leave-and-return after the grace = two incidents.
  * Entry is reported immediately; exit is confirmed when the grace expires.
  * Two-level gating: a zone is evaluated only if its master alarm switch is on
    AND the zone's own `armed` flag is on, AND the vessel is a target.
  * No acknowledge / no auto-clear timer: `active_alarm()` simply reports whether
    any incident is currently open, which drives the red status light.

Geometry is planar on lon/lat (degrees). For the small areas guard zones cover
this is fine; metric-radius circles are passed in already polygonised. A
QgsGeometry-backed implementation could replace these helpers later for full
robustness, but this keeps the engine testable and dependency-free.
"""
from collections import namedtuple

# ---- target modes -----------------------------------------------------------
MODE_ALL = "all"            # any vessel
MODE_LIST = "list"          # only MMSIs in the zone's target group
MODE_ALLOW = "allowlist"    # alarm on anything NOT in the group (intrusion)
MODE_SHIPTYPE = "shiptype"  # by ship type

# ---- navigation status codes we care about ----------------------------------
NAV_UNDERWAY_ENGINE = 0
NAV_AT_ANCHOR = 1
NAV_MOORED = 5
# "static" = the vessel is made fast / not navigating
_STATIC_NAV = frozenset((NAV_AT_ANCHOR, NAV_MOORED))
# "dynamic" = the vessel is now under way (any recognised variant). Excludes
# aground(6) and the reserved/undefined codes (9-15) which aren't "under way".
#   0 under way using engine   2 not under command   3 restricted manoeuvrability
#   4 constrained by draught   7 engaged in fishing  8 under way sailing
_DYNAMIC_NAV = frozenset((0, 2, 3, 4, 7, 8))

# nav statuses we log a one-line note for (log-only, replaces Alarm 3)
_NAV_LOG_TEXT = {0: "under way using engine", 5: "moored", 1: "at anchor"}

# ---- AIS classes by message type --------------------------------------------
_CLASS_A_TYPES = frozenset((1, 2, 3, 5))
_CLASS_B_TYPES = frozenset((18, 19, 24))

# An alarm event handed back to the caller (-> chime / red light / event log).
Event = namedtuple("Event", "kind zone_id zone_name mmsi lon lat time enter_time detail")


# =============================================================================
# geometry (pure python)
# =============================================================================
def point_in_ring(x, y, ring):
    """Ray-casting point-in-polygon for a single ring of (lon, lat) tuples."""
    inside = False
    n = len(ring)
    if n < 3:
        return False
    j = n - 1
    for i in range(n):
        xi, yi = ring[i]
        xj, yj = ring[j]
        if ((yi > y) != (yj > y)) and \
           (x < (xj - xi) * (y - yi) / ((yj - yi) or 1e-15) + xi):
            inside = not inside
        j = i
    return inside


def _seg_intersect(a, b, c, d):
    """True if segment a-b crosses segment c-d."""
    def ccw(p, q, r):
        """Signed orientation of the turn p->q->r (>0 left, <0 right, 0 collinear)."""
        return (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0])
    d1 = ccw(c, d, a)
    d2 = ccw(c, d, b)
    d3 = ccw(a, b, c)
    d4 = ccw(a, b, d)
    if ((d1 > 0) != (d2 > 0)) and ((d3 > 0) != (d4 > 0)):
        return True
    return False


def segment_touches_ring(a, b, ring):
    """True if the segment a-b is inside or crosses the boundary of `ring`.

    Catches a transit where neither endpoint is inside but the path cuts
    through the zone (the thin-tripwire case).
    """
    if point_in_ring(a[0], a[1], ring) or point_in_ring(b[0], b[1], ring):
        return True
    n = len(ring)
    for i in range(n):
        if _seg_intersect(a, b, ring[i], ring[(i + 1) % n]):
            return True
    return False


# =============================================================================
# zones + targeting
# =============================================================================
class GuardZone:
    def __init__(self, zone_id, name, ring, armed=True,
                 alarm_on_entry=True, alarm_on_exit=False,
                 target_mode=MODE_ALL, target_group_id=None,
                 target_group_ids=None, ship_type_filter=None):
        """Define one guard zone: its ring, arm state, entry/exit alarm flags,
        and targeting (all vessels, one/many guard groups, or a ship-type
        filter). Accepts a single group id and/or a set, unioned together."""
        self.zone_id = zone_id
        self.name = name
        self.ring = list(ring)              # outer ring [(lon,lat), ...]
        self.armed = armed
        self.alarm_on_entry = alarm_on_entry
        self.alarm_on_exit = alarm_on_exit
        self.target_mode = target_mode
        # A zone may reference one OR several guard groups (many-to-many).
        ids = set()
        if target_group_id is not None:
            ids.add(target_group_id)
        if target_group_ids:
            ids.update(target_group_ids)
        self.target_group_ids = ids
        self.target_group_id = target_group_id   # kept for back-compat
        self.ship_type_filter = ship_type_filter


def _ship_type_matches(ship_type, flt):
    """True if a ship type matches a filter, which may be an inclusive (lo, hi)
    range tuple, a set/list of codes, or a single code."""
    if ship_type is None or flt is None:
        return False
    if isinstance(flt, tuple) and len(flt) == 2:        # (lo, hi) inclusive
        return flt[0] <= ship_type <= flt[1]
    try:
        return ship_type in flt                          # set/list of codes
    except TypeError:
        return ship_type == flt


def _zone_members(zone, group_members):
    """Union of MMSIs across all guard groups a zone references."""
    s = set()
    for gid in zone.target_group_ids:
        s |= set(group_members.get(gid, ()))
    return s


def is_target(zone, mmsi, ship_type, group_members):
    """Whether a vessel is in scope for a zone, per its target_mode: all, in a
    referenced group (list), NOT in a group (allowlist/intrusion), or ship-type."""
    mode = zone.target_mode
    if mode == MODE_ALL:
        return True
    if mode == MODE_LIST:
        return mmsi in _zone_members(zone, group_members)
    if mode == MODE_ALLOW:
        return mmsi not in _zone_members(zone, group_members)
    if mode == MODE_SHIPTYPE:
        return _ship_type_matches(ship_type, zone.ship_type_filter)
    return False


def ais_class(msg_type):
    """'A' / 'B' / None for an AIS message type (coverage is split by class)."""
    if msg_type in _CLASS_A_TYPES:
        return "A"
    if msg_type in _CLASS_B_TYPES:
        return "B"
    return None


# =============================================================================
# coverage accumulator (Alarm 4 mapping only)
# =============================================================================
class CoverageAccumulator:
    """Bins received positions into a lon/lat grid, separately per AIS class.
    `source` is the current receiver position string (sourcePosition); cells are
    saved stamped with it, so moving the receiver freezes the old set and starts
    a new one."""
    def __init__(self, cell_deg=0.01):
        """Empty Class A / Class B grids at the given cell size, with no source
        position set yet."""
        self.cell_deg = cell_deg
        self.cells = {"A": {}, "B": {}}
        self.source = None

    def reset(self):
        """Clear both class grids (keeps the current source position)."""
        self.cells = {"A": {}, "B": {}}

    def add(self, msg_type, lon, lat):
        """Bin one received position into the grid for its AIS class (A/B);
        ignores messages with no class or no position."""
        cls = ais_class(msg_type)
        if cls is None or lon is None or lat is None:
            return
        # Bin to the nearest grid node. Using round() (rather than floor on a
        # division by 0.1, which is not float-representable) keeps the binning
        # stable near cell boundaries.
        inv = 1.0 / self.cell_deg
        key = (round(lon * inv), round(lat * inv))
        grid = self.cells[cls]
        grid[key] = grid.get(key, 0) + 1

    def count(self, cls):
        """Number of distinct occupied cells for a class (coverage extent)."""
        return len(self.cells.get(cls, {}))

    def total(self, cls):
        """Total received hits across all cells for a class (reception density)."""
        return sum(self.cells.get(cls, {}).values())


# =============================================================================
# engine
# =============================================================================
class _ZoneVesselState:
    __slots__ = ("inside", "open", "grace_until", "enter_time", "leave_time",
                 "leave_pos")

    def __init__(self):
        """Track one (zone, vessel) pair: whether it's currently inside, whether
        an incident is open, the exit-grace deadline, and enter/leave timing."""
        self.inside = False
        self.open = False
        self.grace_until = None
        self.enter_time = None
        self.leave_time = None
        self.leave_pos = (None, None)


class GuardianEngine:
    def __init__(self, grace_seconds=60.0, coverage_cell_deg=0.01,
                 presence_timeout=600.0):
        """Create the engine with its exit-grace, coverage cell size and
        presence timeout, and empty zone/group/coverage/per-vessel state."""
        self.grace_seconds = grace_seconds
        self.presence_timeout = presence_timeout   # silent this long -> "exited"
        self.zones = {}                  # zone_id -> GuardZone
        self.group_members = {}          # group_id -> set(mmsi)
        self.master = {"a1": False, "a2": False, "a3": False, "map": False}
        self.coverage = CoverageAccumulator(coverage_cell_deg)
        self._last_pos = {}              # mmsi -> (lon, lat)
        self._zv = {}                    # (zone_id, mmsi) -> _ZoneVesselState
        self._seen = {}                  # mmsi -> last-heard time (presence log)
        self._navlast = {}               # mmsi -> last nav_status (nav log)
        self._exit_active_until = 0.0    # exit is momentary -> hold its light briefly

    # ---- configuration ---------------------------------------------------
    def set_zones(self, zones):
        """Replace the active guard zones (indexed by zone_id)."""
        self.zones = {z.zone_id: z for z in zones}

    def set_group_members(self, mapping):
        """Replace the guard-group membership map {group_id: set(mmsi)}."""
        self.group_members = {g: set(m) for g, m in mapping.items()}

    def set_nav_watch(self, watch):
        """Deprecated: Alarm 3 was replaced by log-only presence/nav events for
        all vessels (no per-vessel watch list). Kept as a no-op for callers."""
        return

    def set_master(self, a1=None, a2=None, a3=None, mapping=None):
        """Set the master switches (None leaves a switch unchanged): a1 entry,
        a2 exit, a3 event logging, mapping = coverage accumulation."""
        for k, v in (("a1", a1), ("a2", a2), ("a3", a3), ("map", mapping)):
            if v is not None:
                self.master[k] = bool(v)

    # ---- main entry point ------------------------------------------------
    def update(self, mmsi, lon, lat, msg_type=None, ship_type=None,
               nav_status=None, now=0.0):
        """Feed one decoded position/message. Returns a list of Events."""
        mmsi = str(mmsi)
        events = []

        # Alarm 4 mapping always accumulates when the mapping toggle is on.
        if self.master["map"]:
            self.coverage.add(msg_type, lon, lat)

        # Event logging (replaces Alarm 3): log-only, all vessels, no chime.
        if self.master["a3"]:
            events += self._log_update(mmsi, nav_status, lon, lat, now)

        # Alarms 1 & 2 — zones. Need a position.
        if lon is not None and lat is not None:
            prev = self._last_pos.get(mmsi)
            for zone in self.zones.values():
                if not self._zone_live(zone):
                    continue
                if not is_target(zone, mmsi, ship_type, self.group_members):
                    continue
                events += self._zone_update(zone, mmsi, prev, (lon, lat), now)
            self._last_pos[mmsi] = (lon, lat)

        return events

    def tick(self, now):
        """Process grace expiries (call periodically). Returns Events."""
        events = []
        for (zid, mmsi), st in list(self._zv.items()):
            if st.open and st.grace_until is not None and st.grace_until <= now:
                zone = self.zones.get(zid)
                if zone is not None and zone.alarm_on_exit and self.master["a2"]:
                    events.append(Event("exit", zid, zone.name, mmsi,
                                        st.leave_pos[0], st.leave_pos[1],
                                        now, st.enter_time, "left zone"))
                    self._exit_active_until = now + self.grace_seconds
                st.open = False
                st.grace_until = None
        # presence: vessels silent past the timeout -> "exited the coverage area"
        if self.master["a3"]:
            for mmsi, last in list(self._seen.items()):
                if now - last > self.presence_timeout:
                    lp = self._last_pos.get(mmsi, (None, None))
                    events.append(Event("presence_out", None, None, mmsi,
                                        lp[0], lp[1], now, None,
                                        "exited the coverage area"))
                    del self._seen[mmsi]
                    self._navlast.pop(mmsi, None)
        return events

    def active_alarm(self):
        """True if any incident is currently open -> drives the red light."""
        for st in self._zv.values():
            if st.open:
                return True
        return False

    def active_alarms(self, now=None):
        """Per-type active state for the alarm lights (a1 entry, a2 exit). Nav
        logging is log-only and never lights an alarm."""
        a1 = False
        for (zid, _m), st in self._zv.items():
            if st.open:
                zone = self.zones.get(zid)
                if zone is not None and zone.alarm_on_entry:
                    a1 = True
        a2 = (self._exit_active_until > 0) if now is None else (now < self._exit_active_until)
        return {"a1": a1, "a2": a2}

    def alarming_mmsis(self):
        """Set of MMSIs with an open zone incident right now.
        Drives the red vessel-name highlight in the 'Vessels nearby' table."""
        out = set()
        for (_zid, mmsi), st in self._zv.items():
            if st.open:
                out.add(mmsi)
        return out

    # ---- internals -------------------------------------------------------
    def _zone_live(self, zone):
        """True if a zone should be evaluated now: it's armed AND at least one of
        its enabled alarms (entry/exit) also has its master switch on."""
        if not zone.armed:
            return False
        return ((zone.alarm_on_entry and self.master["a1"]) or
                (zone.alarm_on_exit and self.master["a2"]))

    def _zone_update(self, zone, mmsi, prev, curr, now):
        """Advance one vessel's state for one zone given its previous and current
        fix. Tests the segment between fixes (not just the point) so a thin zone
        can't be jumped, opens an incident on entry, and arms the exit grace on
        leaving. Returns any entry Events (exit is emitted later from tick)."""
        events = []
        st = self._zv.setdefault((zone.zone_id, mmsi), _ZoneVesselState())
        now_inside = point_in_ring(curr[0], curr[1], zone.ring)

        if prev is None:
            entry_edge = now_inside
            exit_edge = False
        else:
            touch = segment_touches_ring(prev, curr, zone.ring)
            entry_edge = (not st.inside) and (now_inside or touch)
            exit_edge = (st.inside and not now_inside) or \
                        ((not st.inside) and (not now_inside) and touch)

        if entry_edge:
            if not st.open:
                st.open = True
                st.enter_time = now
                st.grace_until = None
                if zone.alarm_on_entry and self.master["a1"]:
                    events.append(Event("entry", zone.zone_id, zone.name, mmsi,
                                        curr[0], curr[1], now, now, "entered zone"))
            else:
                st.grace_until = None  # re-entry within grace = same incident

        if exit_edge and st.open:
            st.grace_until = now + self.grace_seconds
            st.leave_time = now
            st.leave_pos = curr

        st.inside = now_inside
        return events

    def _log_update(self, mmsi, nav_status, lon, lat, now):
        """Log-only events: presence (entered coverage) + nav-status changes.
        No alarm/chime/light — these go straight to the event log."""
        events = []
        if mmsi not in self._seen:
            events.append(Event("presence_in", None, None, mmsi, lon, lat, now,
                                now, "entered the coverage area"))
        self._seen[mmsi] = now
        if nav_status is not None:
            if nav_status != self._navlast.get(mmsi) and nav_status in _NAV_LOG_TEXT:
                events.append(Event("nav", None, None, mmsi, lon, lat, now, now,
                                    _NAV_LOG_TEXT[nav_status]))
            self._navlast[mmsi] = nav_status
        return events
