# SPDX-License-Identifier: GPL-3.0-or-later
"""Curated open/free internet AIS sources + the aisstream.io JSON->record adapter.

Two integration classes (see ``transport``):
  * "tcp"        — the service is a raw NMEA-over-TCP stream, so the existing
    TcpReader handles it; the entry just presets host/port (e.g. Kystverket, NO).
  * "websocket"  — a JSON WebSocket feed (aisstream.io). A QWebSocket reader
    (aisstream_reader) streams JSON which this module maps to the plugin's decoded
    record dict, so everything downstream (binding, guardian, history, plotting)
    is unchanged.

Sources that need a bounding box (``needs_location``) are wired to the own-position
marker: the box is own-position ± a radius, so a brand-new user with no receiver
sees live traffic in THEIR area.
"""
import math

# Each source: id, name, transport, coverage, license, needs_key, needs_location.
SERVICES = [
    {
        "id": "kystverket",
        "name": "Norway — Kystverket  (instant, no signup)",
        "transport": "tcp",
        "host": "153.44.253.27",
        "port": 5631,
        "needs_key": False,
        "needs_location": False,
        "coverage": "Norwegian coast, 40–60 nm offshore",
        "license": "Open governmental data (NLOD 2.0).",
        # Short GUI notice; the full attribution, compliance finding and the licence
        # link live in the manual (an NLOD 'About page' is a compliant place).
        "notice": "Data provided by Kystverket Norway under governmental licence. "
                  "Consult the manual (the ? button) for the compliance statement "
                  "and a link to the original licence.",
        "signup": "https://www.kystverket.no/en/sea-transport-and-ports/ais/"
                  "access-to-ais-data/",
    },
    {
        "id": "aisstream",
        "name": "Global — aisstream.io  (free API key, your area)",
        "transport": "websocket",
        "url": "wss://stream.aisstream.io/v0/stream",
        "needs_key": True,
        "needs_location": True,
        "coverage": "Global — live stream in a box around your own position",
        "license": "Free; requires a personal (free) API key",
        "signup": "https://aisstream.io/",
    },
    {
        "id": "vesselapi",
        "name": "Global — VesselAPI  (free API key, your area)",
        "transport": "rest",
        "url": "https://api.vesselapi.com/v1/location/vessels/bounding-box",
        "needs_key": True,
        "needs_location": True,
        "poll_s": 30,
        "coverage": "Global — terrestrial AIS, polled in a box around your position",
        "license": "Free tier; requires a personal (free) API key",
        "signup": "https://dashboard.vesselapi.com/",
    },
]

_MSG_KEYS = ("FilterMessageTypes",)
POSITION_TYPES = ("PositionReport", "StandardClassBPositionReport",
                  "ExtendedClassBPositionReport")
STATIC_TYPES = ("ShipStaticData", "StaticDataReport")
SUBSCRIBE_TYPES = list(POSITION_TYPES) + list(STATIC_TYPES)


def get_service(sid):
    for s in SERVICES:
        if s["id"] == sid:
            return s
    return None


def bbox_around(lat, lon, radius_nm):
    """aisstream BoundingBoxes value: [[[lat_sw, lon_sw], [lat_ne, lon_ne]]].
    A box of +/- radius_nm around (lat, lon), longitude widened by latitude."""
    dlat = radius_nm / 60.0
    dlon = radius_nm / 60.0 / max(0.2, math.cos(math.radians(lat)))
    return [[[lat - dlat, lon - dlon], [lat + dlat, lon + dlon]]]


def subscription(api_key, bbox):
    """The JSON subscription message aisstream.io expects on connect."""
    return {"APIKey": api_key, "BoundingBoxes": bbox,
            "FilterMessageTypes": SUBSCRIBE_TYPES}


def _f(v):
    try:
        return float(v)
    except (TypeError, ValueError):
        return None


def aisstream_to_record(msg):
    """Map one aisstream.io JSON message to the plugin's decoded-record dict, or
    None for message types we don't plot. Mirrors ais_decoder's output shape."""
    if not isinstance(msg, dict):
        return None
    mtype = msg.get("MessageType")
    meta = msg.get("MetaData") or {}
    body = msg.get("Message") or {}
    mmsi = meta.get("MMSI") or meta.get("MMSI_String")
    if mmsi is None:
        return None
    mmsi = str(mmsi)

    if mtype in POSITION_TYPES:
        p = body.get(mtype) or {}
        lat = _f(p.get("Latitude", meta.get("latitude")))
        lon = _f(p.get("Longitude", meta.get("longitude")))
        if lat is None or lon is None:
            return None
        cog = _f(p.get("Cog"))
        sog = _f(p.get("Sog"))
        hdg = p.get("TrueHeading")
        rec = {
            "mmsi": mmsi, "has_position": True, "lat": lat, "lon": lon,
            "cog": cog if (cog is not None and cog < 360) else None,
            "sog": sog if (sog is not None and sog < 102.3) else None,
            "heading": hdg if (isinstance(hdg, (int, float)) and hdg != 511) else None,
            "nav_status": p.get("NavigationalStatus"),
            "rot": _f(p.get("RateOfTurn")),
            "msg_type": p.get("MessageID") or (18 if "ClassB" in mtype else 1),
        }
        name = (meta.get("ShipName") or "").strip()
        if name:
            rec["name"] = name
        return _with_desc(rec)

    if mtype in STATIC_TYPES:
        s = body.get(mtype) or {}
        dim = s.get("Dimension") or {}
        length = _sum(dim.get("A"), dim.get("B"))
        beam = _sum(dim.get("C"), dim.get("D"))
        rec = {
            "mmsi": mmsi, "has_position": False,
            "name": (s.get("Name") or meta.get("ShipName") or "").strip() or None,
            "callsign": (s.get("CallSign") or "").strip() or None,
            "imo": s.get("ImoNumber") or None,
            "ship_type": s.get("Type"),
            "length": length, "beam": beam,
            "draught": _f(s.get("MaximumStaticDraught")),
            "destination": (s.get("Destination") or "").strip() or None,
            "eta": _eta(s.get("Eta")),
            "msg_type": 5,
        }
        return _with_desc(rec)
    return None


def vesselapi_params(bbox):
    """VesselAPI bounding-box query params from an aisstream-style bbox."""
    sw, ne = bbox[0][0], bbox[0][1]
    return {"filter.latBottom": sw[0], "filter.latTop": ne[0],
            "filter.lonLeft": sw[1], "filter.lonRight": ne[1]}


def _g(v, *keys):
    for k in keys:
        if isinstance(v, dict) and v.get(k) is not None:
            return v[k]
    return None


def _extract_list(payload):
    """Pull the vessel array out of a VesselAPI response (tolerant of the
    envelope: a bare list, or wrapped under data/vessels/items/results)."""
    if isinstance(payload, list):
        return payload
    if isinstance(payload, dict):
        for k in ("data", "vessels", "items", "results", "positions"):
            if isinstance(payload.get(k), list):
                return payload[k]
        inner = payload.get("data")
        if isinstance(inner, dict):
            for k in ("vessels", "items", "results", "positions"):
                if isinstance(inner.get(k), list):
                    return inner[k]
    return []


def vesselapi_to_records(payload):
    """Map a VesselAPI bounding-box response to a list of plugin record dicts.
    Each row carries both position and identity, so one record does it all.
    Defensive about field spellings pending a live-response confirmation."""
    out = []
    for v in _extract_list(payload):
        mmsi = _g(v, "mmsi", "MMSI")
        lat = _f(_g(v, "latitude", "lat"))
        lon = _f(_g(v, "longitude", "lon", "lng"))
        if mmsi is None or lat is None or lon is None:
            continue
        cog = _f(_g(v, "cog", "course"))
        sog = _f(_g(v, "sog", "speed"))
        hdg = _g(v, "heading", "trueHeading")
        rec = {
            "mmsi": str(mmsi), "has_position": True, "lat": lat, "lon": lon,
            "cog": cog if (cog is not None and cog < 360) else None,
            "sog": sog if (sog is not None and sog < 102.3) else None,
            "heading": hdg if (isinstance(hdg, (int, float)) and hdg != 511) else None,
            "nav_status": _g(v, "navStatus", "navigationalStatus", "navStatusCode"),
            "name": (_g(v, "vesselName", "name", "shipName") or "").strip() or None,
            "imo": _g(v, "imo", "imoNumber"),
            "ship_type": _g(v, "shipType", "type", "vesselType"),
            "msg_type": 1,
        }
        out.append(_with_desc(rec))
    return out


def _sum(a, b):
    a, b = _f(a), _f(b)
    if a is None and b is None:
        return None
    return (a or 0.0) + (b or 0.0)


def _eta(eta):
    if not isinstance(eta, dict):
        return None
    try:
        from .ais_decoder import _format_eta
        return _format_eta(eta.get("Month"), eta.get("Day"), eta.get("Hour"),
                           eta.get("Minute"))
    except Exception:                              # noqa: BLE001
        return None


def _with_desc(rec):
    """Add ship_type_desc / nav_status_desc using the shared decoder tables so the
    portrayal matches the serial/TCP path (best-effort)."""
    try:
        from .ais_decoder import _ship_type, _nav_status
        if rec.get("ship_type") is not None:
            code, desc = _ship_type(rec["ship_type"])
            rec["ship_type"], rec["ship_type_desc"] = code, desc
        if rec.get("nav_status") is not None:
            code, desc = _nav_status(rec["nav_status"])
            rec["nav_status"], rec["nav_status_desc"] = code, desc
    except Exception:                              # noqa: BLE001  # nosec B110
        pass
    return rec
