"""A self-managing in-memory QGIS point layer of AIS vessels, keyed by MMSI.

Each vessel is one feature. Position reports move the feature; static messages
(Type 5 for Class A, Type 24 A/B for Class B) bind voyage/identity data — name,
callsign, IMO, ship type, dimensions, draught, destination, ETA — to the same
MMSI. Static data is cached per MMSI so it survives across messages: Type 24
arrives in two halves (Part A = name, Part B = callsign/type/size) and they
accumulate, and static that arrives before a position is applied as soon as the
vessel's first position creates its feature.

All methods run on the GUI thread. Styling is best-effort so a cosmetic API
difference between QGIS versions can never stop data plotting.
"""
import os
import time
from datetime import datetime

from qgis.core import (
    QgsVectorLayer, QgsFeature, QgsGeometry, QgsPointXY, QgsProject,
)

# Live-data model. Each tuple is (internal record key, layer field name, type).
# _idx is keyed by the record key so the write path is unchanged; the attribute
# table the user sees / exports uses the formal field names. Position is held in
# the feature geometry, so there are no lon/lat columns.
_FIELDS = [
    ("mmsi",           "vessel_stationID",                  "str"),
    ("name",           "vessel_name",                       "str"),
    ("callsign",       "vessel_callSign",                   "str"),
    ("imo",            "vessel_Id",                         "int"),
    ("ship_type",      "vessel_typeCode",                   "int"),
    ("ship_type_desc", "vessel_typeDescription",            "str"),
    ("length",         "vessel_length",                     "double"),
    ("beam",           "vessel_beam",                       "double"),
    ("draught",        "vessel_draught",                    "double"),
    ("sog",            "vessel_speedOverGround",            "double"),
    ("cog",            "vessel_courseOverGround",           "double"),
    ("heading",        "vessel_heading",                    "int"),
    ("rot",            "vessel_rateOfTurn",                 "double"),
    ("nav_status",     "vessel_navigationStatusCode",       "int"),
    ("nav_status_desc","vessel_navigationStatusDescription", "str"),
    ("destination",    "voyage_destination",                "str"),
    ("eta",            "voyage_ETA",                        "str"),
    ("msg_type",       "transponder_messageType",           "int"),
    ("last_seen",      "transponder_timestamp",             "str"),
    ("fairway_name",   "fairway_name",                      "str"),
    ("fairway_km",     "fairway_routeKm",                   "double"),
    ("fairway_on",     "fairway_onFairway",                 "str"),
    ("fairway_dir",    "fairway_direction",                 "str"),
    ("fairway_flow",   "fairway_flow",                      "str"),
    ("fairway_side",   "fairway_side",                      "str"),
]

# Keys carried from a decoded record into the per-MMSI static cache.
_STATIC_KEYS = ("name", "callsign", "imo", "ship_type", "ship_type_desc",
                "length", "beam", "draught", "destination", "eta")
# Attributes refreshed on every position report.
_DYNAMIC_KEYS = ("cog", "sog", "heading", "rot", "nav_status",
                 "nav_status_desc", "msg_type",
                 "fairway_name", "fairway_km", "fairway_on", "fairway_dir",
                 "fairway_flow", "fairway_side")

# Bundled style (authored in QGIS) applied on layer creation if present.
_QML_RELPATH = os.path.join("styles", "ais_vessels.qml")

# Default map tip used until a QML with its own map tip is bundled.
_DEFAULT_MAPTIP = (
    '<table style="font-family:sans-serif;font-size:11px;">'
    '<tr><td colspan="2"><b>[% coalesce("vessel_name", \'Unknown vessel\') %]</b></td></tr>'
    '<tr><td>MMSI</td><td>[% "vessel_stationID" %]</td></tr>'
    '<tr><td>Callsign</td><td>[% coalesce("vessel_callSign", \'—\') %]</td></tr>'
    '<tr><td>IMO</td><td>[% coalesce("vessel_Id", \'—\') %]</td></tr>'
    '<tr><td>Type</td><td>[% coalesce("vessel_typeDescription", \'—\') %]</td></tr>'
    '<tr><td>Size</td><td>[% coalesce("vessel_length", \'?\') %] × '
    '[% coalesce("vessel_beam", \'?\') %] m</td></tr>'
    '<tr><td>Draught</td><td>[% coalesce("vessel_draught", \'?\') %] m</td></tr>'
    '<tr><td>Destination</td><td>[% coalesce("voyage_destination", \'—\') %]</td></tr>'
    '<tr><td>ETA</td><td>[% coalesce("voyage_ETA", \'—\') %]</td></tr>'
    '<tr><td>SOG / COG</td><td>[% coalesce("vessel_speedOverGround", \'?\') %] kn / '
    '[% coalesce("vessel_courseOverGround", \'?\') %]°</td></tr>'
    '<tr><td>Status</td><td>[% coalesce("vessel_navigationStatusDescription", \'—\') %]</td></tr>'
    '<tr><td>ROT</td><td>[% coalesce("vessel_rateOfTurn", \'—\') %] °/min</td></tr>'
    '<tr><td>Fairway</td><td>[% coalesce("fairway_name", \'—\') %]'
    ' [% coalesce("fairway_direction", \'\') %]</td></tr>'
    '<tr><td>Route km</td><td>[% coalesce("fairway_routeKm", \'—\') %]</td></tr>'
    '<tr><td>Flow</td><td>[% coalesce("fairway_flow", \'—\') %]'
    ' [% if(coalesce("fairway_side",\'\')!=\'\', \'(\' || "fairway_side" || \')\', \'\') %]</td></tr>'
    '<tr><td>Last seen</td><td>[% "transponder_timestamp" %]</td></tr>'
    '</table>'
)


def _qfield(name, kind):
    """Build a QgsField that works on both Qt5 (QGIS 3) and Qt6 (QGIS 4),
    without the QGIS 3.38+ deprecation warning. kind: 'str'/'int'/'double'."""
    from .qgis_compat import make_field
    return make_field(name, kind)


class VesselLayer:
    LAYER_NAME = "liveFeedAIS"
    REPAINT_INTERVAL = 0.7  # seconds; throttle canvas refreshes

    def __init__(self, registry=None):
        """Start with no layer; an optional VesselRegistry supplies last-known
        identity/dimensions for vessels seen in earlier sessions."""
        self.layer = None
        self._layer_id = None           # project layer id of our layer, if any
        self.registry = registry       # persistent MMSI store (optional)
        self._fid_by_mmsi = {}
        self._static_by_mmsi = {}   # mmsi -> accumulated static field dict
        self._idx = {}
        self._last_repaint = 0.0

    # ---- layer lifecycle -------------------------------------------------
    def _alive(self):
        """True only if our layer still exists in the project.

        Checked via the stored id string, never by calling .id() on self.layer:
        once the user removes the layer, QGIS deletes the underlying C++ object
        and touching the Python wrapper would raise.
        """
        if not self._layer_id:
            return False
        return QgsProject.instance().mapLayer(self._layer_id) is not None

    def ensure_layer(self):
        """Return the live layer, recreating it if it was removed.

        Called both on every message and on Connect, so deleting the layer from
        the panel can never strand the feed: the next Connect (or message)
        rebuilds it, re-applies the style, and refills from the live stream.
        """
        proj = QgsProject.instance()
        if self._layer_id:
            existing = proj.mapLayer(self._layer_id)
            if existing is not None:
                self.layer = existing       # refresh the handle, keep plotting
                return existing
        layer = QgsVectorLayer("Point?crs=EPSG:4326", self.LAYER_NAME, "memory")
        pr = layer.dataProvider()
        pr.addAttributes([_qfield(fname, kind) for _rk, fname, kind in _FIELDS])
        layer.updateFields()
        self._idx = {rk: layer.fields().indexFromName(fname)
                     for rk, fname, _k in _FIELDS}
        self._style(layer)
        proj.addMapLayer(layer)
        self.layer = layer
        self._layer_id = layer.id()
        self._fid_by_mmsi = {}       # feature ids reset with a fresh layer
        return layer

    def _style(self, layer):
        """Apply the default symbol/labels/map-tip, then let a bundled QML (if
        present) override them, then overlay the route-CPA highlight rule."""
        # Apply sensible programmatic defaults first (symbol, labels, map tip)…
        self._apply_default_style(layer)
        # …then let a bundled QML, authored in QGIS, override them if present.
        qml = os.path.join(os.path.dirname(__file__), _QML_RELPATH)
        if os.path.exists(qml):
            try:
                res = layer.loadNamedStyle(qml)
                ok = res[1] if isinstance(res, (tuple, list)) and len(res) > 1 else True
                if ok:
                    layer.triggerRepaint()
            except Exception:
                pass
        # …finally, overlay the route-CPA highlight on top of whatever symbology
        # is now in place (preserving it).
        self._add_cpa_highlight_rule(layer)

    def _add_cpa_highlight_rule(self, layer):
        """Overlay a blue dot on the two route-CPA vessels.

        Converts the current renderer to rule-based (which preserves the existing
        symbology exactly) and appends ONE rule that draws a blue circle on any
        target whose ``vessel_stationID`` matches the project variables
        ``@routeCPAVesselOne`` / ``@routeCPAVesselTwo`` — set live by the route-map
        window while a route-CPA is active (empty otherwise, so nothing matches).
        Appended last, so the dot sits on top of the normal symbol. Idempotent and
        best-effort: any cosmetic-API difference just leaves the base symbology."""
        try:
            from qgis.core import QgsRuleBasedRenderer, QgsMarkerSymbol
        except Exception:
            return
        try:
            base = layer.renderer()
            if base is None:
                return
            if isinstance(base, QgsRuleBasedRenderer):
                rb = base.clone()
            else:
                rb = QgsRuleBasedRenderer.convertFromRenderer(base)
            if rb is None:
                return
            root = rb.rootRule()
            label = "Route-CPA vessel"
            for child in root.children():           # idempotent: never add twice
                if child.label() == label:
                    return
            blue = QgsMarkerSymbol.createSimple({
                "name": "circle", "color": "0,90,255,255",
                "outline_color": "255,255,255,255", "outline_width": "0.4",
                "size": "2.6"})
            rule = QgsRuleBasedRenderer.Rule(blue)
            rule.setLabel(label)
            # array_contains + coalesce so empty/unset variables give a definite
            # FALSE (not SQL NULL) — i.e. no dots when no CPA is active.
            rule.setFilterExpression(
                "array_contains(array("
                "coalesce(to_string(@routeCPAVesselOne), ''), "
                "coalesce(to_string(@routeCPAVesselTwo), '')), "
                'to_string("vessel_stationID"))')
            root.appendChild(rule)
            layer.setRenderer(rb)
        except Exception:
            pass

    def _apply_default_style(self, layer):
        """Programmatic defaults: orange triangle marker, name/MMSI labels and
        the HTML map tip — each step best-effort across QGIS versions."""
        try:
            from qgis.core import QgsMarkerSymbol, QgsSingleSymbolRenderer
            sym = QgsMarkerSymbol.createSimple({
                "name": "triangle", "size": "4",
                "color": "255,90,0,255", "outline_color": "60,20,0,255",
            })
            layer.setRenderer(QgsSingleSymbolRenderer(sym))
        except Exception:
            pass
        try:
            from qgis.core import (QgsPalLayerSettings,
                                   QgsVectorLayerSimpleLabeling)
            settings = QgsPalLayerSettings()
            settings.fieldName = 'coalesce("vessel_name", "vessel_stationID")'
            settings.isExpression = True
            layer.setLabeling(QgsVectorLayerSimpleLabeling(settings))
            layer.setLabelsEnabled(True)
        except Exception:
            pass
        try:
            layer.setMapTipTemplate(_DEFAULT_MAPTIP)
        except Exception:
            pass

    # ---- updates ---------------------------------------------------------
    def update(self, rec):
        """Apply one decoded record: accumulate + persist any static fields, then
        move/create the vessel feature on a position report (or bind static-only
        data to an existing feature). Throttled repaint at the end."""
        mmsi = rec.get("mmsi")
        if not mmsi:
            return
        self.ensure_layer()

        # Accumulate any static fields present in this message.
        static = {k: rec[k] for k in _STATIC_KEYS if rec.get(k) is not None}
        if static:
            self._static_by_mmsi.setdefault(mmsi, {}).update(static)

        # Static identity/dimension data is authoritative — persist for reuse
        # across sightings and sessions (name, length, beam, ship type).
        if static and self.registry is not None:
            self.registry.set(mmsi,
                              name=rec.get("name"),
                              callsign=rec.get("callsign"),
                              imo=rec.get("imo"),
                              length=rec.get("length"),
                              beam=rec.get("beam"),
                              ship_type=rec.get("ship_type"),
                              ship_type_desc=rec.get("ship_type_desc"))

        if rec.get("has_position"):
            self._upsert_position(mmsi, rec)
        elif static and mmsi in self._fid_by_mmsi:
            # Static-only message for a vessel already on the map: bind it now.
            self._change_attrs(self._fid_by_mmsi[mmsi],
                               {self._idx[k]: v for k, v in static.items()})

        self._maybe_repaint()

    def _upsert_position(self, mmsi, rec):
        """Move an existing vessel feature to its new position (refreshing dynamic
        fields), or create it — seeding identity from this session's static cache
        and, failing that, the persistent registry."""
        pr = self.layer.dataProvider()
        geom = QgsGeometry.fromPointXY(QgsPointXY(rec["lon"], rec["lat"]))
        ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

        if mmsi in self._fid_by_mmsi:
            fid = self._fid_by_mmsi[mmsi]
            pr.changeGeometryValues({fid: geom})
            changes = {self._idx["last_seen"]: ts}
            for key in _DYNAMIC_KEYS:
                if rec.get(key) is not None:
                    changes[self._idx[key]] = rec[key]
            self._change_attrs(fid, changes)
        else:
            attrs = [None] * len(_FIELDS)
            attrs[self._idx["mmsi"]] = str(mmsi)
            attrs[self._idx["last_seen"]] = ts
            for key in _DYNAMIC_KEYS:
                if rec.get(key) is not None:
                    attrs[self._idx[key]] = rec[key]
            # Apply any static data already cached for this MMSI.
            for key, val in self._static_by_mmsi.get(mmsi, {}).items():
                attrs[self._idx[key]] = val
            # Nothing cached yet this session? Fall back to the last-known
            # name/dimensions/type from the registry so a vessel seen before is
            # already labelled and correctly sized/shaped before fresh static
            # arrives. A later static message overwrites these.
            if self.registry is not None:
                cached = self.registry.get_record(mmsi)
                for key in ("name", "callsign", "imo", "length", "beam",
                            "ship_type", "ship_type_desc"):
                    if attrs[self._idx[key]] is None and cached.get(key) is not None:
                        attrs[self._idx[key]] = cached[key]
            feat = QgsFeature(self.layer.fields())
            feat.setGeometry(geom)
            feat.setAttributes(attrs)
            ok, out = pr.addFeatures([feat])
            if ok and out:
                self._fid_by_mmsi[mmsi] = out[0].id()

    def _change_attrs(self, fid, changes):
        """Write {field_index: value} onto one feature (best-effort)."""
        try:
            self.layer.dataProvider().changeAttributeValues({fid: changes})
        except Exception:
            pass

    def _maybe_repaint(self, force=False):
        """Repaint the layer, throttled to REPAINT_INTERVAL unless forced."""
        now = time.monotonic()
        if force or (now - self._last_repaint) >= self.REPAINT_INTERVAL:
            self._last_repaint = now
            if self.layer is not None:
                self.layer.triggerRepaint()

    def vessel_count(self):
        """Number of distinct vessels currently plotted on the layer."""
        return len(self._fid_by_mmsi)
