"""Naval/AIS ingestion — aisstream.io, sampled for a few seconds per run.

Additional analysis input for the political section, tagged
`"category": "naval_traffic"`. Not a section of its own.

What this can and cannot do, stated plainly because it is easy to get wrong:

AIS is a collision-avoidance transponder, not a surveillance system. Warships,
auxiliaries and anything else with a reason to be discreet sail with AIS off or
spoofed as a matter of routine, and the AIS "military ops" ship-type code is
self-declared and almost never set by an actual combatant. **This module cannot
detect a naval concentration and must never be read as if it could.** What it can
measure is merchant traffic, and merchant traffic *withdrawing* from a chokepoint
— the Red Sea transit collapse being the obvious case — is a real signal that
pairs with the freight-cost, insurance and crude-price material the financial and
news sources already supply. That, and only that, is why this exists.

Sourcing, all verified 2026-07-28:

  aisstream.io is the only genuinely free real-time AIS source found. A free
  account (GitHub sign-in) yields an API key; the key is the sole credential and
  is sent inside the subscription payload, not an HTTP header. It is WebSocket-
  only — there is no REST endpoint — which is why this module samples a short
  window rather than making a request. It is self-described as beta with no
  uptime SLA, so treat a run that returns nothing as normal rather than broken.
  A global subscription can push ~300 messages/second, so the bounding boxes
  below are not optional.

  AISHub remains contribute-to-access: you must stream raw NMEA from your own
  AIS receiver to their UDP endpoint and meet coverage/uptime thresholds before
  you get API credentials. That needs physical hardware this project does not
  have, so it is not an option here.

  MarineTraffic, VesselFinder and Spire are commercial/paid. Not integrated.

Configuration is env-only:

  AISSTREAM_API_KEY — required; free, from https://aisstream.io after sign-in.
  NAVAL_REGIONS     — semicolon-separated `Name:lamin,lomin,lamax,lomax` boxes,
                      same format as FLIGHT_REGIONS. Defaults to the maritime
                      chokepoints, which is where the signal is.
  NAVAL_SAMPLE_SECONDS — how long to hold the socket open. The default is
                      deliberately short: this is a traffic-density sample, not
                      a census, and a oneshot digest should not sit on a socket.
"""

import json
import logging
import os
import time
from datetime import datetime, timezone

LOG = logging.getLogger(__name__)

STREAM_URL = "wss://stream.aisstream.io/v0/stream"

DEFAULT_REGIONS = (
    "Red Sea / Bab el-Mandeb:12,38,20,45;"
    "Strait of Hormuz:24,54,28,58;"
    "Black Sea:41,27,47,42;"
    "Taiwan Strait:21,117,27,124;"
    "Suez Canal approaches:29,32,32,34"
)

# AIS ship-type code ranges, ITU-R M.1371. Coarse on purpose — the useful question
# is "what kind of trade is moving through here", not the exact hull category.
_TYPE_BUCKETS = (
    (80, 89, "tanker"),
    (70, 79, "cargo"),
    (60, 69, "passenger"),
    (40, 49, "high_speed_craft"),
    (30, 30, "fishing"),
    (35, 35, "self_declared_military_ops"),
    (55, 55, "self_declared_law_enforcement"),
)


def _parse_regions(raw):
    regions = []
    for chunk in raw.split(";"):
        chunk = chunk.strip()
        if not chunk:
            continue
        try:
            name, box = chunk.rsplit(":", 1)
            lamin, lomin, lamax, lomax = (float(part) for part in box.split(","))
            regions.append(
                {
                    "name": name.strip(),
                    "lamin": lamin,
                    "lomin": lomin,
                    "lamax": lamax,
                    "lomax": lomax,
                }
            )
        except Exception:
            LOG.warning("naval_traffic: could not parse region %r, skipping", chunk, exc_info=True)
    return regions


def _bucket(type_code):
    for low, high, label in _TYPE_BUCKETS:
        if low <= type_code <= high:
            return label
    return "other"


def _region_for(regions, lat, lon):
    for region in regions:
        if region["lamin"] <= lat <= region["lamax"] and region["lomin"] <= lon <= region["lomax"]:
            return region["name"]
    return None


def _sample(socket, regions, deadline):
    seen = {region["name"]: set() for region in regions}
    types = {region["name"]: {} for region in regions}
    names = {region["name"]: {} for region in regions}
    received = 0

    while time.monotonic() < deadline:
        try:
            raw = socket.recv()
        except Exception:
            # A recv timeout inside the sampling window is ordinary on a quiet box;
            # anything worse is caught by the caller.
            break
        if not raw:
            continue
        try:
            message = json.loads(raw)
        except ValueError:
            continue

        received += 1
        metadata = message.get("MetaData") or {}
        lat = metadata.get("latitude")
        lon = metadata.get("longitude")
        mmsi = metadata.get("MMSI")
        if lat is None or lon is None or mmsi is None:
            continue

        region_name = _region_for(regions, lat, lon)
        if region_name is None:
            continue

        seen[region_name].add(mmsi)

        static = (message.get("Message") or {}).get("ShipStaticData")
        if static and isinstance(static.get("Type"), int):
            label = _bucket(static["Type"])
            types[region_name][label] = types[region_name].get(label, 0) + 1
            ship_name = (static.get("Name") or metadata.get("ShipName") or "").strip()
            if ship_name and label in ("self_declared_military_ops", "self_declared_law_enforcement"):
                names[region_name][ship_name] = label

    return seen, types, names, received


def fetch(lookback_hours):
    del lookback_hours  # AIS arrives as a live stream; this is a sample of now, not of a window

    api_key = os.environ.get("AISSTREAM_API_KEY", "").strip()
    if not api_key:
        LOG.warning("naval_traffic: AISSTREAM_API_KEY not set, skipping")
        return []

    regions = _parse_regions(os.environ.get("NAVAL_REGIONS", DEFAULT_REGIONS))
    if not regions:
        LOG.warning("naval_traffic: NAVAL_REGIONS parsed to nothing, skipping")
        return []

    sample_seconds = float(os.environ.get("NAVAL_SAMPLE_SECONDS", "20"))

    # Imported here, not at module scope, so a missing/broken optional dependency
    # degrades this one source instead of the whole run.
    try:
        import websocket
    except ImportError:
        LOG.warning("naval_traffic: websocket-client is not installed, skipping", exc_info=True)
        return []

    socket = None
    try:
        socket = websocket.create_connection(STREAM_URL, timeout=sample_seconds)
        socket.send(
            json.dumps(
                {
                    "APIKey": api_key,
                    # aisstream orders corners [lat, lon], not GeoJSON's [lon, lat].
                    "BoundingBoxes": [
                        [[region["lamin"], region["lomin"]], [region["lamax"], region["lomax"]]]
                        for region in regions
                    ],
                    "FilterMessageTypes": ["PositionReport", "ShipStaticData"],
                }
            )
        )
        seen, types, names, received = _sample(socket, regions, time.monotonic() + sample_seconds)
    except Exception:
        LOG.warning("naval_traffic: aisstream sample failed, skipping", exc_info=True)
        return []
    finally:
        if socket is not None:
            try:
                socket.close()
            except Exception:
                LOG.debug("naval_traffic: socket close failed", exc_info=True)

    # aisstream rejects a bad key by closing the socket without an error message, so
    # a sample that saw literally nothing is indistinguishable from a rejected
    # subscription. Contributing nothing is honest; contributing "zero vessels in
    # every chokepoint" would be a fabricated finding the prompt would have to trust.
    if not received:
        LOG.warning("naval_traffic: no AIS messages in the sample window (bad key, or no traffic), skipping")
        return []

    observed_at = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
    results = []
    for region in regions:
        name = region["name"]
        results.append(
            {
                "source": "aisstream",
                "category": "naval_traffic",
                "region": name,
                "bbox": [region["lamin"], region["lomin"], region["lamax"], region["lomax"]],
                "observed_at": observed_at,
                "sample_seconds": sample_seconds,
                "distinct_vessels": len(seen[name]),
                "vessel_types": types[name],
                "self_declared_state_vessels": names[name],
                "attribution": "AIS data via aisstream.io",
                "caveat": (
                    "Civil AIS only, sampled for a few seconds. Warships routinely sail with "
                    "AIS off, so this cannot show naval force posture; it shows whether "
                    "merchant traffic is still using the chokepoint."
                ),
            }
        )

    LOG.info(
        "naval_traffic: %d region(s), %d distinct vessel(s) in a %ss sample",
        len(results),
        sum(len(value) for value in seen.values()),
        sample_seconds,
    )
    return results
