"""AIS decoding with multi-fragment reassembly.

Serial feeds arrive one sentence at a time. Single-fragment messages (position
reports — types 1/2/3 and Class B type 18/19) decode immediately. Multi-fragment
messages (type 5 static/voyage, type 24) span two sentences tied together by the
sequential message id + channel, so we buffer fragments until the set is complete
and then decode them together.

Decoding itself is delegated to ``pyais`` (pure-Python, bundled in ``libs/``).
If pyais isn't present the assembler is a no-op and ``PYAIS_AVAILABLE`` is False,
so callers can degrade gracefully.
"""
from . import vendor
vendor.setup()  # ensure bundled pyais is importable

try:
    from pyais import decode as _pyais_decode
    PYAIS_AVAILABLE = True
except Exception:
    _pyais_decode = None
    PYAIS_AVAILABLE = False


def _sanitize(rec):
    """Replace AIS 'not available' sentinel values with None."""
    cog = rec.get("cog")
    if cog is not None and cog >= 360:
        rec["cog"] = None
    sog = rec.get("sog")
    if sog is not None and sog >= 102.3:
        rec["sog"] = None
    hdg = rec.get("heading")
    if hdg is not None and hdg >= 511:
        rec["heading"] = None
    return rec


def _clean_str(v):
    """Trim a string field to a non-empty value, or None (drops blanks/non-str)."""
    if isinstance(v, str):
        v = v.strip()
        return v or None
    return None


def _dim_sum(a, b):
    """Length = bow+stern, beam = port+starboard. 0 means 'not available'."""
    if a is None or b is None:
        return None
    return (a + b) or None


def _ship_type(value):
    """Return (numeric_code, human_label) from a pyais ship_type value."""
    if value is None:
        return None, None
    try:
        code = int(value)
    except Exception:
        return None, None
    if not code:  # 0 = NotAvailable
        return None, None
    label = getattr(value, "name", None)  # enum case (Type 5)
    if not label:
        try:
            from pyais.constants import ShipType
            label = ShipType(code).name
        except Exception:
            label = str(code)
    if label in (None, "NotAvailable"):
        return code, None
    return code, label


def _rot(value):
    """Rate of turn in degrees/minute, or None when not available.

    pyais already converts the raw AIS field to deg/min. The encoded values
    ±127 ('turning, no rate indicator') and ±128 ('not available') come back as
    TurnRate sentinels — both mean 'no usable rate', so we drop them.
    """
    if value is None:
        return None
    try:
        v = float(value)
    except Exception:
        return None
    if abs(v) >= 127:
        return None
    return v


def _nav_status(value):
    """Return (code 0-15, human label) for a navigation-status value."""
    if value is None:
        return None, None
    try:
        code = int(value)
    except Exception:
        return None, None
    label = getattr(value, "name", None) or str(code)
    return code, label


def _format_eta(month, day, hour, minute):
    """Format an AIS voyage ETA as 'MM-DD HH:MM', or None when unset (all-zero
    or missing fields). AIS ETA carries no year."""
    vals = [month, day, hour, minute]
    if any(v is None for v in vals) or all(v == 0 for v in vals):
        return None
    try:
        return f"{int(month):02d}-{int(day):02d} {int(hour):02d}:{int(minute):02d}"
    except Exception:
        return None


def _normalize(msg):
    """Turn a pyais message object into a flat, provider-friendly dict."""
    d = {}
    if hasattr(msg, "asdict"):
        try:
            d = msg.asdict()
        except Exception:
            d = {}

    def get(*names):
        """First non-None value found under any of the given attribute/key
        names, looking in the asdict() copy then on the message object."""
        for n in names:
            if n in d and d[n] is not None:
                return d[n]
            v = getattr(msg, n, None)
            if v is not None:
                return v
        return None

    lat = get("lat")
    lon = get("lon")
    has_pos = (
        isinstance(lat, (int, float)) and isinstance(lon, (int, float))
        and -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0
    )

    name = get("shipname")
    if isinstance(name, str):
        name = name.strip() or None

    st_code, st_desc = _ship_type(get("ship_type"))
    length = _dim_sum(get("to_bow"), get("to_stern"))
    beam = _dim_sum(get("to_port"), get("to_starboard"))
    imo = get("imo") or None
    draught = get("draught") or None

    mmsi = get("mmsi")
    nav_code, nav_desc = _nav_status(get("status"))
    rec = {
        "mmsi": str(mmsi) if mmsi is not None else None,
        "msg_type": get("msg_type", "type"),
        "lat": float(lat) if has_pos else None,
        "lon": float(lon) if has_pos else None,
        "has_position": has_pos,
        # dynamic
        "cog": get("course"),
        "sog": get("speed"),
        "heading": get("heading"),
        "rot": _rot(get("turn")),
        "nav_status": nav_code,
        "nav_status_desc": nav_desc,
        # static / voyage (present in Type 5 and Type 24 A/B)
        "name": name,
        "callsign": _clean_str(get("callsign")),
        "imo": int(imo) if imo else None,
        "ship_type": st_code,
        "ship_type_desc": st_desc,
        "length": length,
        "beam": beam,
        "draught": float(draught) if draught else None,
        "destination": _clean_str(get("destination")),
        "eta": _format_eta(get("month"), get("day"), get("hour"), get("minute")),
    }
    return _sanitize(rec)


class AISAssembler:
    """Feed raw !AIVDM/!AIVDO sentences; get back normalized vessel records."""

    def __init__(self):
        """Start with empty per-(seq_id, channel) fragment buffers."""
        self._buffers = {}  # (seq_id, channel) -> {frag_num: sentence}

    def push(self, sentence):
        """Return a normalized dict when a full message is decoded, else None."""
        if not PYAIS_AVAILABLE:
            return None
        s = sentence.strip()
        fields = s.split(",")
        if len(fields) < 7:
            return None
        try:
            frag_count = int(fields[1])
            frag_num = int(fields[2])
        except ValueError:
            return None

        if frag_count <= 1:
            return self._decode([s])

        key = (fields[3], fields[4])  # sequential id + channel
        buf = self._buffers.setdefault(key, {})
        buf[frag_num] = s
        if len(buf) >= frag_count:
            ordered = [buf[i] for i in sorted(buf)]
            self._buffers.pop(key, None)
            return self._decode(ordered)
        return None

    def _decode(self, sentences):
        """Decode one complete sentence set via pyais and normalize it to our
        flat dict; returns None if either step fails (never raises)."""
        try:
            msg = _pyais_decode(*[x.encode("ascii", "replace") for x in sentences])
        except Exception:
            return None
        try:
            return _normalize(msg)
        except Exception:
            return None
