# SPDX-License-Identifier: GPL-3.0-or-later
"""Unit system (Nautical / Metric / Imperial) for every data readout.

One persisted choice drives how speeds, distances, vessel dimensions, along-route
distances and positions are formatted. Native (stored) units are always: speed =
knots, distance = nautical miles, along-route = kilometres, dimensions = metres,
position = decimal degrees. The ``fmt_*`` helpers convert + format for display; the
raw native value is what tables should sort on (conversion is monotonic, so native
order == displayed order).

Locked conventions (2026-07-08):
  * Nautical  — kn, nm (cables < 1 nm), metres for dims/draught, along-route in nm,
    position in degrees + decimal minutes (DMM).
  * Metric    — km/h, km/m, metres, along-route in km, position in decimal degrees.
  * Imperial  — mph, statute miles (feet for short), feet for dims, along-route in
    miles, position in DMM.
"""
MODES = ("nautical", "metric", "imperial")
_SETTINGS_KEY = "ais_nmea_source/units"

# conversion factors from the native unit
_KN_KMH = 1.852
_KN_MPH = 1.1507794
_NM_KM = 1.852
_NM_MI = 1.1507794
_NM_M = 1852.0
_NM_FT = 6076.115
_M_FT = 3.2808399
_KM_NM = 1.0 / 1.852
_KM_MI = 0.6213712
_CABLE_M = 185.2                 # 1 cable = 0.1 nm


# --------------------------------------------------------------------------- #
#  Mode state (persisted)
# --------------------------------------------------------------------------- #
def system():
    """Active unit system ('nautical'|'metric'|'imperial'), from QgsSettings."""
    try:
        from qgis.core import QgsSettings
        s = QgsSettings().value(_SETTINGS_KEY, "nautical")
    except Exception:                                   # noqa: BLE001
        s = "nautical"
    return s if s in MODES else "nautical"


def set_system(mode):
    if mode not in MODES:
        return
    try:
        from qgis.core import QgsSettings
        QgsSettings().setValue(_SETTINGS_KEY, mode)
    except Exception:                                   # noqa: BLE001  # nosec B110
        pass


# surfaces that want to refresh when the unit system changes (tables, strip, …)
_listeners = []


def register_listener(fn):
    """Register a zero-arg callable invoked (best-effort) after the system changes."""
    if fn not in _listeners:
        _listeners.append(fn)


def unregister_listener(fn):
    try:
        _listeners.remove(fn)
    except ValueError:
        pass


def notify():
    """Invoke all registered listeners (best-effort)."""
    for fn in list(_listeners):
        try:
            fn()
        except Exception:                               # noqa: BLE001  # nosec B110
            pass


# --------------------------------------------------------------------------- #
#  Number formatting: 1 decimal, trailing ".0" trimmed (20.0->"20", 7.5->"7.5")
# --------------------------------------------------------------------------- #
def _n(x, dec=1):
    try:
        s = ("%.*f" % (dec, float(x)))
    except (TypeError, ValueError):
        return "—"
    if "." in s:
        s = s.rstrip("0").rstrip(".")
    return s


# --------------------------------------------------------------------------- #
#  Formatters.  Each takes the NATIVE value and returns a display string.
# --------------------------------------------------------------------------- #
def fmt_speed(kn, mode=None):
    """Speed given in knots."""
    if kn is None:
        return "—"
    m = mode or system()
    if m == "metric":
        return "%s km/h" % _n(kn * _KN_KMH)
    if m == "imperial":
        return "%s mph" % _n(kn * _KN_MPH)
    return "%s kn" % _n(kn)


def speed_factor_suffix(mode=None):
    """(factor, suffix) to convert native knots for display — used to build the
    map label expression (which multiplies the SOG field)."""
    m = mode or system()
    if m == "metric":
        return (_KN_KMH, "km/h")
    if m == "imperial":
        return (_KN_MPH, "mph")
    return (1.0, "kn")


def fmt_distance(nm, mode=None):
    """A range/distance given in nautical miles (Nearby distance, CPA distance)."""
    if nm is None:
        return "—"
    m = mode or system()
    if m == "metric":
        metres = nm * _NM_M
        return "%s m" % _n(metres, 0) if metres < 1000 else "%s km" % _n(nm * _NM_KM, 2)
    if m == "imperial":
        feet = nm * _NM_FT
        return "%s ft" % _n(feet, 0) if feet < 1000 else "%s mi" % _n(nm * _NM_MI, 2)
    # nautical: cables under 1 nm
    if nm < 1.0:
        return "%s cables" % _n(nm * 10.0)
    return "%s nm" % _n(nm, 2)


def fmt_length(metres, mode=None):
    """A vessel dimension (length / beam / draught) given in metres."""
    if metres is None:
        return "—"
    if (mode or system()) == "imperial":
        return "%s ft" % _n(metres * _M_FT, 0)
    return "%s m" % _n(metres, 0)


def fmt_route(km, mode=None):
    """An along-route distance (route-km, DTG) given in kilometres."""
    if km is None:
        return "—"
    m = mode or system()
    if m == "nautical":
        return "%s nm" % _n(km * _KM_NM)
    if m == "imperial":
        return "%s mi" % _n(km * _KM_MI)
    return "%s km" % _n(km)


def route_unit(mode=None):
    """Short unit label for along-route distances ('nm'|'km'|'mi')."""
    m = mode or system()
    return {"nautical": "nm", "imperial": "mi"}.get(m, "km")


def conv_route(km, mode=None):
    """Numeric along-route value in the display unit (for axis ticks)."""
    if km is None:
        return None
    m = mode or system()
    if m == "nautical":
        return km * _KM_NM
    if m == "imperial":
        return km * _KM_MI
    return km


def fmt_position(lat, lon, mode=None):
    """Position string. Nautical/Imperial -> degrees + decimal minutes (DMM);
    Metric -> decimal degrees."""
    if lat is None or lon is None:
        return "—"
    m = mode or system()
    if m == "metric":
        return "%.5f, %.5f" % (lat, lon)
    return "%s  %s" % (_dmm(lat, "NS", 2), _dmm(lon, "EW", 3))


def fmt_lat(lat, mode=None):
    """A single latitude for its own column (DMM, or decimal degrees in metric)."""
    if lat is None:
        return ""
    if (mode or system()) == "metric":
        return "%.5f" % lat
    return _dmm(lat, "NS", 2)


def fmt_lon(lon, mode=None):
    """A single longitude for its own column (DMM, or decimal degrees in metric)."""
    if lon is None:
        return ""
    if (mode or system()) == "metric":
        return "%.5f" % lon
    return _dmm(lon, "EW", 3)


def _dmm(v, hemis, degwidth):
    """One coordinate as D° M.mm' H (degrees + decimal minutes + hemisphere)."""
    h = hemis[0] if v >= 0 else hemis[1]
    v = abs(v)
    d = int(v)
    minutes = (v - d) * 60.0
    return "%0*d° %05.2f' %s" % (degwidth, d, minutes, h)
