#!/usr/bin/env python3
"""Provision a Meshtastic node for STORM — player node or gateway, any model.

Version: see __version__ / RELEASE_DATE below and the file name (mesh-provision-v<version>.py).

Sets everything the 2026-09-29 field test had to find by hand and then
VERIFIES it on the device:

  every node   channels from the STORM channel URL (name, PSK, uplink/downlink,
               position_precision 32 on the primary — fw 2.7 sends no position
               without it),
               region + modem preset from the same URL,
               device.rebroadcast_mode = ALL   (KNOWN_ONLY never decrypts an
                                                unknown node — new players!)
               lora.config_ok_to_mqtt = true   (else gateways drop its packets)
               lora.ignore_mqtt = false        (else it drops STORM downlink)
  gateway      mqtt module on: broker, user, password, TLS, root msh/<region>
               WiFi model  → WiFi + direct MQTT (no proxy needed)
               no WiFi     → proxy_to_client (phone app or tools/mesh-gw-proxy.py)
  player       mqtt module off (the player's node is not a gateway)

Secrets never go on the command line or to the output: the channel URL comes
from a file or env, passwords from env or an interactive prompt.

Usage:
  python3 tools/mesh-provision-v1.0.0.py                      # asks for the link: channel link = player, storm-gw:// link = gateway
  python3 tools/mesh-provision-v1.0.0.py --role player  --channel-url-file ~/storm-channel.txt
  python3 tools/mesh-provision-v1.0.0.py --role gateway --channel-url-file ~/storm-channel.txt \\
          --mqtt-user gw-tester-02 [--wifi-ssid MojeWifi]
  python3 tools/mesh-provision-v1.0.0.py --role player --check      # only verify, change nothing
  python3 tools/mesh-provision-v1.0.0.py --role player --gps off    # node has GPS, but the phone should measure position

Env: MESH_CHANNEL_URL, MESH_MQTT_PASS, MESH_WIFI_PSK (prompted when missing).
"""

__version__ = '1.0.0'          # při každé změně chování zvednout + přejmenovat soubor
RELEASE_DATE = '2026-10-05'    # mesh-provision-v<verze>.py — ať je z názvu vidět, co kdo má

import argparse
import base64
import getpass
import os
import sys
import threading
import time

import meshtastic.serial_interface
from meshtastic.protobuf import apponly_pb2, channel_pb2, config_pb2

SECRET_FIELDS = {'password', 'wifi_psk', 'psk'}


def die(msg):
    print('ERROR:', msg)
    sys.exit(2)


GATEWAY_LINK_PREFIX = 'storm-gw://'


def decode_gateway_link(link):
    """`storm-gw://<base64url JSON {v, url, mqtt}>` z adminu (balíček skupiny →
    „Odkaz brány“). None, když to odkaz brány není."""
    if not link.startswith(GATEWAY_LINK_PREFIX):
        return None
    import json
    raw = link[len(GATEWAY_LINK_PREFIX):]
    raw += '=' * (-len(raw) % 4)
    try:
        data = json.loads(base64.urlsafe_b64decode(raw).decode('utf-8'))
    except Exception as e:  # noqa: BLE001
        die(f'the gateway link is not valid: {e}')
    if not data.get('url'):
        die('the gateway link carries no channel link')
    return data


def decode_channel_url(url):
    b64 = url.split('/#')[-1]
    b64 += '=' * (-len(b64) % 4)
    cs = apponly_pb2.ChannelSet()
    cs.ParseFromString(base64.urlsafe_b64decode(b64))
    if not cs.settings:
        die('the channel link contains no channel')
    return cs


def autodetect_port():
    """Port nodu bez parametru: jeden připojený Meshtastic = jasná volba.
    Na Windows je to COMx, na Linuxu /dev/ttyACM0, na macOS /dev/cu.usbmodem…"""
    try:
        from meshtastic.util import findPorts
        ports = findPorts(True)
    except Exception:  # noqa: BLE001
        ports = []
    if len(ports) == 1:
        return ports[0]
    if not ports:
        die('no node found on USB — connect it with a data cable (not charge-only) and run again;'
            ' or set the port by hand: --port COM5 (Windows) or --port /dev/ttyACM0 (Linux)')
    die('more than one device on USB: ' + ', '.join(ports) + ' — pick the port by hand: --port <port>')


def connect(port, retries=1, pause=4):
    """Open the node. After a reboot the port is back before the firmware
    answers (2026-10-01: „write failed: Input/output error“ on the first try),
    so the post-write verification retries for a while."""
    last = None
    for attempt in range(1, retries + 1):
        try:
            return meshtastic.serial_interface.SerialInterface(port)
        except Exception as e:  # noqa: BLE001
            last = e
            if attempt < retries:
                print(f'   … node not answering yet ({e}), retrying in {pause} s')
                time.sleep(pause)
    die(f'could not connect to {port}: {last}')


def wait_for_reboot(port, timeout=90):
    """The node reboots after a commit — wait until its port is back."""
    t0 = time.time()
    while os.path.exists(port) and time.time() - t0 < 10:
        time.sleep(0.5)
    while time.time() - t0 < timeout:
        if os.path.exists(port):
            time.sleep(4)
            return
        time.sleep(1)
    die(f'{port} did not come back within {timeout} s after reboot')


def show(field, value):
    return '•••' if field in SECRET_FIELDS and value else repr(value)


def desired_settings(args, cs, meta):
    """[(section, field, value, why)] — section is a localConfig/moduleConfig name."""
    lora = cs.lora_config if cs.HasField('lora_config') else None
    region = lora.region if lora and lora.region else config_pb2.Config.LoRaConfig.RegionCode.Value(args.region)
    want = [
        ('lora', 'region', region, 'region from the channel link'),
        ('lora', 'use_preset', True, ''),
        ('lora', 'tx_enabled', True, ''),
        ('lora', 'config_ok_to_mqtt', True, 'otherwise gateways will not forward its packets to the server'),
        ('lora', 'ignore_mqtt', False, 'otherwise it drops messages and positions from STORM'),
        ('device', 'rebroadcast_mode', config_pb2.Config.DeviceConfig.RebroadcastMode.Value('ALL'),
         'KNOWN_ONLY never decrypts an unknown node'),
        ('bluetooth', 'enabled', not args.no_bluetooth,
         'the player connects with the phone' if args.role == 'player' else 'for the phone proxy; disable with --no-bluetooth'),
    ]
    if lora:
        want.append(('lora', 'modem_preset', lora.modem_preset, 'preset from the channel link'))
        if lora.hop_limit:
            want.append(('lora', 'hop_limit', lora.hop_limit, ''))
    region_name = config_pb2.Config.LoRaConfig.RegionCode.Name(region)
    if args.role == 'gateway':
        want += [
            ('mqtt', 'enabled', True, ''),
            ('mqtt', 'address', args.mqtt_host, ''),
            ('mqtt', 'username', args.mqtt_user, ''),
            ('mqtt', 'password', args.mqtt_pass, ''),
            ('mqtt', 'tls_enabled', not args.no_tls, ''),
            ('mqtt', 'root', args.mqtt_root or f'msh/{region_name}', 'root topic from the group package'),
            ('mqtt', 'json_enabled', False, ''),
            ('mqtt', 'map_reporting_enabled', False, ''),
        ]
        if meta.get('hasWifi') and args.wifi_ssid:
            want += [
                ('network', 'wifi_enabled', True, 'the gateway reaches the broker itself over WiFi'),
                ('network', 'wifi_ssid', args.wifi_ssid, ''),
                ('network', 'wifi_psk', args.wifi_psk, ''),
                ('mqtt', 'proxy_to_client_enabled', False, ''),
            ]
        else:
            want.append(('mqtt', 'proxy_to_client_enabled', True,
                         'without WiFi it reaches the broker through a phone or tools/mesh-gw-proxy.py'))
    else:
        want.append(('mqtt', 'enabled', False, 'a player node is not a gateway'))
    # GPS nodu: když ho node má, vysílá svoji polohu sám — appka pak vypne GPS
    # telefonu a bere polohu z nodu (anténa venku, lepší příjem). Chytré
    # vysílání = jen při pohybu, ať stojící node nezatěžuje rádio.
    # --gps auto (výchozí) = podle toho, co firmware hlásí (NOT_PRESENT = bez modulu).
    gps_mode = config_pb2.Config.PositionConfig.GpsMode
    has_gps = meta.get('gpsMode') != gps_mode.Value('NOT_PRESENT')
    if args.gps == 'on' or (args.gps == 'auto' and has_gps and args.role == 'player'):
        want += [
            # shared/positionPolicy.js: stojí = 1× za 5 min (základní interval, firmware ho
            # drží i s chytrým vysíláním), jde = při posunu ≥ 20 m nejvýš 1× za 2 min.
            ('position', 'gps_mode', gps_mode.Value('ENABLED'), 'the node broadcasts its own GPS, the phone takes it over'),
            ('position', 'position_broadcast_secs', 300, 'standing still: heartbeat once per 5 min'),
            ('position', 'position_broadcast_smart_enabled', True, 'moving: only when it moved'),
            ('position', 'broadcast_smart_minimum_distance', 20, 'metres'),
            ('position', 'broadcast_smart_minimum_interval_secs', 120, 'at most once per 2 min'),
        ]
    elif args.gps == 'off' and has_gps:
        want.append(('position', 'gps_mode', gps_mode.Value('DISABLED'), 'node GPS disabled on request'))
    return want


def section_obj(node, section):
    if section == 'mqtt':
        return node.moduleConfig.mqtt
    return getattr(node.localConfig, section)


POSITION_PRECISION_FULL = 32


def ensure_position_precision(cs):
    """Firmware 2.7 vysílá vlastní polohu jen na první kanál, který má
    module_settings.position_precision != 0 — jinak tiše nic (node s fixem
    nehlásil polohu ani telefonu, iPhone 2026-10-04). Server to do odkazu
    dává od 2026-10-04; starší odkaz doplníme tady, ať provisioning
    nevyrobí němý node."""
    if cs.settings and cs.settings[0].module_settings.position_precision == 0:
        cs.settings[0].module_settings.position_precision = POSITION_PRECISION_FULL
    return cs


def channel_diffs(node, cs):
    out = []
    for i, want in enumerate(cs.settings):
        have = node.channels[i] if node.channels and i < len(node.channels) else None
        expected_role = channel_pb2.Channel.Role.PRIMARY if i == 0 else channel_pb2.Channel.Role.SECONDARY
        if (have is None or have.role != expected_role or have.settings.name != want.name
                or have.settings.psk != want.psk
                or have.settings.uplink_enabled != want.uplink_enabled
                or have.settings.downlink_enabled != want.downlink_enabled):
            out.append(f'channel {i} ({want.name or "default"}) does not match the link')
        elif (have.settings.module_settings.position_precision
              != want.module_settings.position_precision):
            out.append(f'channel {i} ({want.name or "default"}): position precision '
                       f'{have.settings.module_settings.position_precision} instead of '
                       f'{want.module_settings.position_precision} — the node does NOT broadcast its position')
    for i in range(len(cs.settings), len(node.channels or [])):
        if node.channels[i].role != channel_pb2.Channel.Role.DISABLED:
            out.append(f'extra channel {i} ({node.channels[i].settings.name}) — not in the link')
    return out


def write_channels(node, cs):
    """Channels exactly as in the URL; any extra channel is disabled
    (setURL alone would leave it in place)."""
    for i, st in enumerate(cs.settings):
        ch = node.channels[i]
        ch.role = channel_pb2.Channel.Role.PRIMARY if i == 0 else channel_pb2.Channel.Role.SECONDARY
        ch.settings.CopyFrom(st)
        node.writeChannel(i)
    for i in range(len(cs.settings), len(node.channels)):
        ch = node.channels[i]
        if ch.role != channel_pb2.Channel.Role.DISABLED:
            print(f'   disabling channel {i} ({ch.settings.name}) — not in the link')
            ch.role = channel_pb2.Channel.Role.DISABLED
            ch.settings.Clear()
            node.writeChannel(i)


def check(node, want, cs):
    problems = []
    for section, field, value, why in want:
        have = getattr(section_obj(node, section), field)
        if have != value:
            problems.append((section, field, have, value, why))
    return problems, channel_diffs(node, cs)


def report(title, problems, chan_problems):
    print(f'\n== {title}')
    if not problems and not chan_problems:
        print('   ✅ everything matches')
        return True
    for section, field, have, value, why in problems:
        print(f'   ❌ {section}.{field}: is {show(field, have)}, should be {show(field, value)}'
              + (f'  ({why})' if why else ''))
    for p in chan_problems:
        print('   ❌', p)
    return False


def finish(iface, code):
    """Close the serial link and leave. meshtastic's close() can hang on some
    USB stacks (2026-10-01: a --check run kept /dev/ttyACM1 busy for minutes,
    the next run timed out) — a watchdog ends the process regardless."""
    threading.Timer(5, lambda: os._exit(code)).start()
    try:
        iface.close()
    except Exception:
        pass
    sys.stdout.flush()
    os._exit(code)


def main():
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument('--version', action='version', version=f'mesh-provision v{__version__} ({RELEASE_DATE})')
    ap.add_argument('--port', help='serial port of the node; found automatically when omitted (single connected node)')
    ap.add_argument('--role', choices=['player', 'gateway'], default='player', help='player (default) or gateway')
    ap.add_argument('--channel-url-file', help='file with the STORM channel link (https://meshtastic.org/e/#…); the script asks when omitted')
    ap.add_argument('--region', default='EU_868', help='used only when the channel link carries no region')
    ap.add_argument('--long-name')
    ap.add_argument('--short-name')
    ap.add_argument('--mqtt-host', default='storm.metrocitysquad.cz')
    ap.add_argument('--mqtt-user')
    ap.add_argument('--mqtt-root', help='root topic from the group package (msh/<region>/<group channel>); default msh/<region>')
    ap.add_argument('--no-tls', action='store_true')
    ap.add_argument('--wifi-ssid')
    ap.add_argument('--no-bluetooth', action='store_true')
    ap.add_argument('--gps', choices=['auto', 'on', 'off'], default='auto',
                    help='node GPS: auto = enable when the node has one (player); on/off = force')
    ap.add_argument('--check', action='store_true', help='verify only, change nothing')
    ap.add_argument('--yes', action='store_true', help='do not ask before writing')
    args = ap.parse_args()
    print(f'mesh-provision v{__version__} ({RELEASE_DATE})')

    url = os.environ.get('MESH_CHANNEL_URL')
    if args.channel_url_file:
        url = open(os.path.expanduser(args.channel_url_file)).read().strip()
    if not url:
        # Bez souboru i proměnné: zeptat se přímo (balíček z adminu → „Odkaz kanálu“
        # pro hráče, „Odkaz brány“ pro bránu — role se pozná podle odkazu).
        try:
            url = input('Paste the STORM channel link (player) or gateway link (https://meshtastic.org/e/#… or storm-gw://…): ').strip()
        except (EOFError, KeyboardInterrupt):
            url = ''
        if not url:
            die('missing link: --channel-url-file, the MESH_CHANNEL_URL variable, or paste it at the prompt')
    gw = decode_gateway_link(url)
    if gw:
        # Odkaz brány: kanály + účet brokeru + root v jednom; parametry na
        # příkazové řádce mají přednost, když jsou zadané.
        args.role = 'gateway'
        url = gw['url']
        mq = gw.get('mqtt') or {}
        args.mqtt_host = args.mqtt_host if args.mqtt_host != 'storm.metrocitysquad.cz' else (mq.get('host') or args.mqtt_host)
        args.mqtt_user = args.mqtt_user or mq.get('username')
        args.mqtt_root = args.mqtt_root or mq.get('root')
        if mq.get('tls') is False:
            args.no_tls = True
        if mq.get('password') and not os.environ.get('MESH_MQTT_PASS'):
            os.environ['MESH_MQTT_PASS'] = mq['password']
        print(f'Gateway link: broker {args.mqtt_host}, account {args.mqtt_user}, root {args.mqtt_root}')
    cs = ensure_position_precision(decode_channel_url(url))

    port = args.port or autodetect_port()
    print(f'Port: {port}')
    iface = connect(port)
    node = iface.localNode
    meta = {}
    if iface.metadata:
        m = iface.metadata
        meta = {'hwModel': m.hw_model, 'fw': m.firmware_version, 'hasWifi': m.hasWifi,
                'hasEthernet': m.hasEthernet}
    # GPS modul: firmware hlásí NOT_PRESENT, když žádný nenašel.
    try:
        meta['gpsMode'] = node.localConfig.position.gps_mode
    except Exception:
        meta['gpsMode'] = None
    gps_mode_enum = config_pb2.Config.PositionConfig.GpsMode
    gps_txt = ('?' if meta.get('gpsMode') is None
               else 'no' if meta['gpsMode'] == gps_mode_enum.Value('NOT_PRESENT')
               else 'yes (' + gps_mode_enum.Name(meta['gpsMode']).lower() + ')')
    from meshtastic.protobuf import mesh_pb2
    hw = mesh_pb2.HardwareModel.Name(meta['hwModel']) if 'hwModel' in meta else '?'
    my = '!%08x' % iface.myInfo.my_node_num
    print(f'Node {my} — {hw}, firmware {meta.get("fw", "?")}, '
          f'WiFi: {"yes" if meta.get("hasWifi") else "no"}, Ethernet: {"yes" if meta.get("hasEthernet") else "no"}, '
          f'GPS: {gps_txt}')

    if args.role == 'gateway' and not args.check:
        if not args.mqtt_user:
            die('a gateway needs --mqtt-user (broker account, e.g. gw-tester-02)')
        args.mqtt_pass = os.environ.get('MESH_MQTT_PASS') or getpass.getpass('MQTT password: ')
        if not args.mqtt_pass:
            # 2026-10-02: prázdná proměnná prošla zápisem a brána pak hlásila
            # „Not authorized“; kontrola prázdné s prázdným porovnala jako shodu.
            die('empty MQTT password — the gateway could not log in to the broker (MESH_MQTT_PASS or the prompt)')
        if meta.get('hasWifi') and not args.wifi_ssid and not args.yes:
            try:
                args.wifi_ssid = input('WiFi SSID for the gateway (Enter = no WiFi, use the phone/USB proxy): ').strip() or None
            except (EOFError, KeyboardInterrupt):
                args.wifi_ssid = None
        if meta.get('hasWifi') and args.wifi_ssid:
            args.wifi_psk = os.environ.get('MESH_WIFI_PSK') or getpass.getpass(f'WiFi password for {args.wifi_ssid}: ')
        if not meta.get('hasWifi') and args.wifi_ssid:
            print('   ⚠️ this model has no WiFi — ignoring --wifi-ssid, the gateway will use a proxy')
    if args.role == 'gateway' and args.check:
        # verify against what is on the device for the account/secret fields
        mq = node.moduleConfig.mqtt
        args.mqtt_user = args.mqtt_user or mq.username
        # S MESH_MQTT_PASS v prostředí se heslo v bráně porovná s ním (rozdíl =
        # ❌), bez něj se bere to z brány a hlídá se jen, že není prázdné.
        args.mqtt_pass = os.environ.get('MESH_MQTT_PASS') or mq.password
        args.wifi_psk = node.localConfig.network.wifi_psk
        args.wifi_ssid = args.wifi_ssid or (node.localConfig.network.wifi_ssid if meta.get('hasWifi') else None)
        if not mq.password:
            print('   ⚠️ the gateway has no MQTT password')

    want = desired_settings(args, cs, meta)
    problems, chan_problems = check(node, want, cs)
    ok = report('State before changes' if not args.check else 'Check', problems, chan_problems)

    if args.check and not ok:
        print('\n   To fix this, write the settings to the node: run the same command without --check.')
    if args.check or (ok and not args.long_name and not args.short_name):
        finish(iface, 0 if ok else 1)

    if not args.yes and input('\nWrite these changes to the node? [y/N] ').strip().lower() not in ('y', 'yes', 'a', 'ano'):
        print('Nothing changed.')
        finish(iface, 1)
    print('Writing…')
    node.beginSettingsTransaction()
    for section in sorted({s for s, *_ in problems}):
        obj = section_obj(node, section)
        for s, field, _have, value, _why in problems:
            if s == section:
                setattr(obj, field, value)
        node.writeConfig(section)
    if chan_problems:
        write_channels(node, cs)
    if args.long_name or args.short_name:
        node.setOwner(long_name=args.long_name, short_name=args.short_name)
    node.commitSettingsTransaction()  # the node reboots after this
    time.sleep(2)
    iface.close()

    print('The node is rebooting, waiting…')
    wait_for_reboot(port)
    iface = connect(port, retries=8)
    node = iface.localNode
    problems, chan_problems = check(node, want, cs)
    ok = report('Verification after writing', problems, chan_problems)
    finish(iface, 0 if ok else 1)


if __name__ == '__main__':
    main()
