#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# \file network_config
#
# Part of the source code of the Signalytic platform.
#
# \author AT
# \date 2025-01-30
#
# \copyright
# Copyright (C) Wendepunkt Medical Innovations, Inc - All Rights Reserved.
# Unauthorized copying of this file, via any medium is strictly prohibited.
# Proprietary and confidential
#
# For information: dev@signalytic.ca
#
# Signalytic Network Configuration - reconfigures networking on the local system
# and all connected APs to match the desired configuration. Configuration is
# loaded from node configuration using `bglocalconf` unless otherwise specified.
#

import os
import sys
import logging
import json
import subprocess
import re
import socket
import traceback
import ipaddress
import fnmatch

from chrony import update_chrony, apply_chrony
from dnsmasq import update_dnsmasq, apply_dnsmasq
from firewall import update_firewall
from interfaces import update_interfaces, apply_interfaces
from config_utils import safe_write

NETCONFIG_FILE = '/var/signalytic/etc/_network'
HELP_MSG = f'''
Signalytic Network Configuration

Usage: network_config [ OPTIONS ]

Options
  -h, --help
  -d, --debug
  -q, --quiet
  -f, --force            Force an update (and restart services)
  -r, --revert           Revert all interfaces to their previous state
  --reset-firewall       Flush all iptables state and restart Docker before applying config
  --factory-reset        Restore all interfaces to their original state
  --dry-run              Display changes but don't actually update configuration
  
Configuration data is loaded from node configuration using `bglocalconf`.
Network configuration data is saved to `{NETCONFIG_FILE}[.bak]`. Updates are
applied (and services reset) only when NEW configuration data is available.
'''


def _strip_empty_lists(d):
  """Recursively remove empty-list values so saved config compares equal to processed config."""
  if isinstance(d, dict):
    return {k: _strip_empty_lists(v) for k, v in d.items() if not (isinstance(v, list) and not v)}
  if isinstance(d, list):
    return [_strip_empty_lists(i) for i in d]
  return d


def network_config(**kwargs) -> int:
  """
  Configure networking for the server and connected APs. If any step fails,
  revert all previous steps to their previous state. Note that APs are updated
  through the ap_manager service.
  :param kwargs: optional keyword arguments
  :return: system exit code, 0 on success
  """
  # load previous config once — used both as hostname-resolution fallback
  # and for the up-to-date comparison below
  previous_config = {}
  if os.path.exists(NETCONFIG_FILE):
    try:
      previous_config = load_net_config(NETCONFIG_FILE)
    except Exception as e:
      logging.warning(f'network_config: could not load previous config: {e}')

  # load network config data
  if kwargs.get('factory_reset', False):
    config = process_config({}, **kwargs)
  elif kwargs.get('revert', False):
    backup = f'{NETCONFIG_FILE}.bak'
    config = load_net_config(backup) if os.path.exists(backup) else process_config({}, **kwargs)
  else:
    config = load_node_config(**kwargs)
    config = process_config(config, previous=previous_config, **kwargs)

  # if no config changes we can stop early
  force_apply = kwargs.get('force', False) or kwargs.get('factory_reset', False) or kwargs.get('reset_firewall', False)
  if previous_config and not force_apply:
    if _strip_empty_lists(previous_config) == _strip_empty_lists(config):
      logging.info('network_config: up to date')
      return 0

  # update configuration files; continue past errors so state is at least consistent
  logging.info('network_config: updating network configuration')
  rval_ifaces, needs_ifaces = update_interfaces(config, **kwargs)
  rval_dns,   needs_dns    = update_dnsmasq(config, **kwargs)
  rval_ntp,   needs_ntp    = update_chrony(config, **kwargs)
  rval_fw,    _            = update_firewall(config, **kwargs)
  rval = rval_ifaces | rval_dns | rval_ntp | rval_fw

  if kwargs.get('dry_run', False):
    logging.info('network_config: dry-run: no changes applied')
  else:
    if needs_ifaces or force_apply:
      logging.debug('network_config: applying interfaces')
      rval |= apply_interfaces(config)
    if needs_dns or force_apply:
      logging.debug('network_config: applying dnsmasq')
      rval |= apply_dnsmasq()
    if needs_ntp or force_apply:
      logging.debug('network_config: applying chrony')
      rval |= apply_chrony()
    # note: firewall changes take effect immediately
    if rval == 0:
      rval = save_net_config(config)
    # all done
    if rval == 0:
      logging.info('network_config: network update complete')
    else:
      logging.error('network_config: network update failed')

  return rval


def load_node_config(**kwargs) -> dict:
  """
  Load network configuration data from node config
  :param kwargs:
  :return: dict containing network configuration data
  """
  # find all keys in local config starting with 'lan.' or 'wan.'
  config = {}
  localconf = subprocess.run('bglocalconf', text=True, capture_output=True, check=True, timeout=10).stdout
  for k, v in re.findall(r'^\s*([lw]an\S*)\s*=\s*(.*)$', localconf, re.MULTILINE):
    logging.debug(f'load_node_config: {k} = {v}')

    # cleanup value
    if v.startswith('"'):
      v = v.strip('"')
    elif '.' in v:
      v = float(v)
    else:
      v = int(v)

    # get prefix
    is_list = False
    index = 0
    parts = k.split('.')
    if parts[-1][0] == '[':
      is_list = True
      index = int(parts[-1][1:])
      parts = parts[:-1]

    # add value to config
    obj = config
    for p in parts[:-1]:
      obj = obj.setdefault(p, {})
    if is_list:
      obj = obj.setdefault(parts[-1], [])
      if len(obj) <= index:
        for i in range(len(obj), index+1):
          obj.append(None)
      obj[index] = v
    else:
      obj[parts[-1]] = v

  logging.debug(f'load_node_config: config data: {json.dumps(config, indent=2)}')
  return config


def load_net_config(path: str, **kwargs) -> dict:
  """
  Load network config data from a previous run
  :param path: path to network config file
  :param kwargs:
  :return: dict containing network configuration data
  """
  config = {}
  with open(path) as f:
    for line in f:
      line = line.strip()  # drop newline
      try:
        k, v = line.split('=', maxsplit=1)
      except Exception as e:
        logging.error(f'load_net_config: ignoring unexpected line "{line}"')
        continue
      # value either string or int
      if v.startswith('"') and v.endswith('"'):
        v = v[1:-1].replace('\\"', '"')
      else:
        v = int(v)
      # split key by '.' and add parent keys to config object
      parts = k.split('.')
      obj = config
      # check for array
      if parts[-1].startswith('['):
        for p in parts[:-2]:
          obj = obj.setdefault(p, {})
        obj = obj.setdefault(parts[-2], [])
        index = int(parts[-1][1:])
        for i in range(len(obj), index+1):
          obj.append(None)
        obj[index] = v
      else:
        for p in parts[:-1]:
          obj = obj.setdefault(p, {})
        # add value to config object
        obj[parts[-1]] = v
  return config


def save_net_config(config, **kwargs) -> int:
  """
  Save config data to a file containing a set of key-value pairs (1 per line)
  :param config: dict config data
  :param kwargs:
  :return: exit code, 0 on success
  """
  content = '\n'.join(dict_to_kv(config))
  return safe_write(NETCONFIG_FILE, content)


def dict_to_kv(data: dict, prefix='') -> list:
  """
  Convert a dict to a list of key-value pairs, using '.' to indicate sub-objects
  :param data: dict input data
  :return: list of kv assignments "KEY=VALUE"
  """
  kv_pairs = []
  for k, v in data.items():
    if isinstance(v, dict):
      kv_pairs.extend(dict_to_kv(v, f'{prefix}{k}.'))
    elif isinstance(v, str):
      v = v.replace('"', '\\"')
      kv_pairs.append(f'{prefix}{k}="{v}"')
    elif isinstance(v, int):
      kv_pairs.append(f'{prefix}{k}={v}')
    elif isinstance(v, list):
      for i, vi in enumerate(v):
        if isinstance(vi, str):
          vi = vi.replace('"', '\\"')
          kv_pairs.append(f'{prefix}{k}.[{i}="{vi}"')
        elif isinstance(vi, int):
          kv_pairs.append(f'{prefix}{k}.[{i}={vi}')
        elif vi is None:
          # ignore: the first entry may be None if it was removed
          # from the management node but still remains in the array
          # e.g., lan.network.net1.interfaces.[0=None
          pass
        else:
          raise NotImplementedError(f'unsupported type ({type(vi)}): {prefix}{k}.[{i}={vi}')
    else:
      raise NotImplementedError(f'unsupported type ({type(v)}): {prefix}{k}={v}')
  return kv_pairs


def _resolve_to_cidrs(entry: str) -> list:
  """
  Resolve a single allow/block entry to a list of CIDR strings.
  If the entry is already a valid IP or CIDR it is returned as-is (normalised).
  Hostnames are resolved to IPv4 /32 CIDRs via DNS. Returns an empty list and
  logs a warning if resolution fails.
  """
  entry = entry.strip()
  try:
    net = ipaddress.ip_network(entry, strict=False)
    return [str(net)]
  except ValueError:
    pass
  try:
    results = socket.getaddrinfo(entry, None, socket.AF_INET)
    ips = sorted({r[4][0] for r in results})
    cidrs = [f'{ip}/32' for ip in ips]
    logging.debug(f'_resolve_to_cidrs: {entry} -> {cidrs}')
    return cidrs
  except Exception as e:
    logging.warning(f'_resolve_to_cidrs: failed to resolve "{entry}": {e}')
    return []


def _resolve_fw_list(entries) -> tuple:
  """
  Resolve allow/block entries to a flat list of CIDR strings.
  Returns (cidrs, had_failure) where had_failure is True if any entry
  failed to resolve (so the caller can decide whether to fall back to
  cached CIDRs rather than applying a partial or empty rule set).
  """
  result = []
  had_failure = False
  for entry in (entries or []):
    if entry is not None:
      resolved = _resolve_to_cidrs(str(entry))
      if resolved:
        result.extend(resolved)
      else:
        had_failure = True
  return result, had_failure


def _is_iface_pattern(entry: str) -> bool:
  """An entry is a glob pattern if it contains shell wildcard metacharacters."""
  return any(c in entry for c in '*?[')


def _resolve_wan_interfaces(entries, present=None) -> list:
  """
  Expand wan.interfaces entries to concrete interface names.
  - Literal names (no glob metacharacters) are kept as-is and in order, even if
    not currently present (preserves prior behaviour; e.g. wwan0, onboard NICs).
  - Glob patterns (containing * ? [) are matched against interfaces present in
    /sys/class/net; matches are sorted alphabetically for deterministic ordering.
  - Order of entries is preserved; duplicates dropped (first occurrence wins).
  - A pattern matching nothing contributes nothing (a normal state, not an error).
  """
  if present is None:
    try:
      present = sorted(os.listdir('/sys/class/net'))
    except OSError as e:
      logging.warning(f'_resolve_wan_interfaces: cannot list interfaces: {e}')
      present = []
  result = []
  for entry in (entries or []):
    if entry is None:
      continue
    entry = str(entry).strip()
    if not entry:
      continue
    if _is_iface_pattern(entry):
      for name in sorted(fnmatch.filter(present, entry)):
        if name not in result:
          result.append(name)
    elif entry not in result:
      result.append(entry)
  return result


def _split_address(addr_cidr: str):
  ipi = ipaddress.ip_interface(addr_cidr)
  net = ipi.network
  ip = ipi.ip

  # Require a usable host IP (reject network/broadcast addresses)
  if ip == net.network_address or (
          hasattr(net, "broadcast_address") and ip == net.broadcast_address):
    raise ValueError(f'invalid host address "{addr_cidr}": '
                     f'must not be network or broadcast')

  gateway = str(ip)
  subnet_cidr = f'{net.network_address}/{net.prefixlen}'
  netmask = str(net.netmask)
  return gateway, subnet_cidr, netmask


def _enforce_min_prefix(addr_cidr, label):
  """Widen a configured address to at least /24 (with a warning) so the default
  DHCP pool always fits; subnets smaller than /24 can't hold it. The host IP is
  preserved within the widened /24."""
  ipi = ipaddress.ip_interface(addr_cidr)
  if ipi.network.prefixlen > 24:
    widened = f'{ipi.ip}/24'
    logging.warning(f'process_config: {label} subnet {addr_cidr} is smaller than '
                    f'/24; widening to {widened}')
    return widened
  return addr_cidr


def process_config(config, previous=None, **kwargs) -> dict:
  """
  Preprocess configuration for VLAN-aware bridge model:
    [lan]         -> untagged on lan-br0
    [lan.vlan100] -> tagged on lan-br0.100
    - add default lan interfaces if non specified
    - add/update default network
    - assign virtual interfaces where necessary
    - calculate netmask
  :param config: network config data
  :return: dict with updated config data
  """
  wan = config.setdefault('wan', {})
  wan.setdefault('interfaces', ['wwan0'])
  wan['interfaces'] = _resolve_wan_interfaces(wan['interfaces'])
  # wwan0 (cell modem) is always a WAN interface; append as lowest priority if
  # not explicitly listed, so the firewall, interfaces, and routing all see it
  # (mirrors update-routes always appending wwan0).
  if 'wwan0' not in wan['interfaces']:
    wan['interfaces'].append('wwan0')

  lan = config.setdefault('lan', {})

  # add 'default' network if missing
  lan.setdefault('interfaces', ['enP4p65s0'])
  lan.setdefault('address', '192.168.14.1/24')
  lan.setdefault('ssid', 'signalytic')
  lan.setdefault('psk', 'Blockgraph200')

  lan['address'] = _enforce_min_prefix(lan['address'], 'default LAN')
  gw, subnet_cidr, netmask = _split_address(lan['address'])
  net = ipaddress.ip_network(subnet_cidr, strict=False)

  defaults = {
    'interface': 'lan-br0',
    'subnet': subnet_cidr,
    'gateway': gw,
    'netmask': netmask,
    'static_start': str(net[0] + 11),
    'static_end':   str(net[0] + 30),
    'dhcp_start':   str(net[0] + 31),
    'dhcp_end':     str(net[-1] - 5),
    'dhcp_lease': '12h',
    'forward_to_wan': lan.get('forward_to_wan', 0),
    'allow': lan.get('allow', []),
    'block': lan.get('block', []),
  }
  for k, v in defaults.items():
    lan.setdefault(k, v)

  # add/update VLAN network info directly under lan['vlan<VID>']
  for name, vcfg in ((k, v) for k, v in lan.items() if re.match(r'^vlan\d+$', k)):
    vid = int(name[4:])
    addr = vcfg.get('address')
    if not addr:
      logging.warning(f'process_config: {name} missing "address"; skipping')
      continue

    addr = _enforce_min_prefix(addr, name)
    vcfg['address'] = addr            # keep the serialized _network consistent
    v_gw, v_subnet_cidr, v_netmask = _split_address(addr)
    v_net = ipaddress.ip_network(v_subnet_cidr)

    vlan = lan.setdefault(name, {})
    vlan.update({
      'interface': f'lan-br0.{vid}',
      'subnet': v_subnet_cidr,
      'gateway': v_gw,
      'netmask': v_netmask,
      'forward_to_wan': int(vcfg.get('forward_to_wan', 0)),
      'allow': vcfg.get('allow', []),
      'block': vcfg.get('block', []),
      'ssid': vcfg.get('ssid', ''),
      'psk':  vcfg.get('psk', ''),
      'dhcp': int(vcfg.get('dhcp', 1)),
    })

    if vlan['dhcp']:
      if 'dhcp_range' in vcfg:
        start, end = [s.strip() for s in vcfg['dhcp_range'].split(',', 1)]
      else:
        start, end = str(v_net[0] + 31), str(v_net[-1] - 5)
      vlan.update({
        'dhcp_start': start,
        'dhcp_end': end,
        'dhcp_lease': '12h'
      })

  # validate VLAN networks (use updated entries in lan)
  default_gw, default_subnet_cidr, _ = _split_address(lan['address'])
  default_net = ipaddress.ip_network(default_subnet_cidr)
  invalid = set()

  seen_nets = set()
  seen_gws  = set()

  for name in list(lan.keys()):
    if not re.match(r'^vlan\d+$', name):
      continue

    vcfg = lan[name]
    vid = int(name[4:])

    # VID range 2..4094
    if not (2 <= vid <= 4094):
      logging.warning(f'process_config: {name} invalid VID {vid} (must be 2..4094)')
      invalid.add(name)
      continue

    vnet = ipaddress.ip_network(vcfg['subnet'])
    vgw  = ipaddress.ip_address(vcfg['gateway'])

    # duplicate subnets / gateways across VLANs
    if vnet in seen_nets:
      logging.warning(f'process_config: {name} duplicate subnet {vnet}')
      invalid.add(name)
      continue
    seen_nets.add(vnet)

    if vgw in seen_gws:
      logging.warning(f'process_config: {name} duplicate gateway {vgw}')
      invalid.add(name)
      continue
    seen_gws.add(vgw)

    # disallow duplicating the default LAN network/gateway
    if vnet == default_net:
      logging.warning(f'process_config: {name} subnet {vnet} duplicates default LAN {default_net}')
      invalid.add(name)
      continue
    if str(vgw) == default_gw:
      logging.warning(f'process_config: {name} gateway {vgw} duplicates default LAN gateway {default_gw}')
      invalid.add(name)
      continue

  for name in invalid:
    lan.pop(name, None)

  # Resolve firewall targets for base LAN and each valid VLAN
  # On any resolution failure, fall back to previous firewall rules (forward_to_wan+allow+block)
  failed = False
  for fw_key in ('allow', 'block'):
    cidrs, had_failure = _resolve_fw_list(lan.get(fw_key))
    if had_failure:
      failed = True
    else:
      lan[fw_key] = cidrs
  if failed:
    logging.warning(f'process_config: failed to resolve firewall rules for LAN; using previous firewall rules')
    prev_lan = (previous or {}).get('lan', {})
    lan['forward_to_wan'] = prev_lan.get('forward_to_wan', 0)
    lan['allow'] = prev_lan.get('allow', [])
    lan['block'] = prev_lan.get('block', [])
  for name, vcfg in ((k, v) for k, v in lan.items() if re.match(r'^vlan\d+$', k)):
    failed = False
    for target in ('allow', 'block'):
      cidrs, had_failure = _resolve_fw_list(vcfg.get(target))
      if had_failure:
        failed = True
      else:
        vcfg[target] = cidrs
    if failed:
      logging.warning(f'process_config: failed to resolve firewall rules for VLAN {name}; using previous firewall rules')
      prev_vcfg = (previous or {}).get('lan', {}).get(name, {})
      vcfg['forward_to_wan'] = prev_vcfg.get('forward_to_wan', 0)
      vcfg['allow'] = prev_vcfg.get('allow', [])
      vcfg['block'] = prev_vcfg.get('block', [])

  logging.debug(f'process_config: updated config data: {json.dumps(config, indent=2)}')
  return config


def main() -> int:
  """
  Main entry point to the network_config script
  :return: exit code, 0 on success, 0 on success
  """

  # default options
  options = {}
  log_level = logging.INFO

  # collect user args
  for arg in sys.argv[1:]:

    if arg in ('-h', '--help', 'help'):
      print(HELP_MSG)
      return 0

    elif arg in ('-d', '--debug'):
      log_level = logging.DEBUG

    elif arg in ('-q', '--quiet'):
      log_level = logging.WARNING

    elif arg in ('-f', '--force'):
      options['force'] = True

    elif arg in ('-r', '--revert'):
      options['revert'] = True

    elif arg == '--reset-firewall':
      options['reset_firewall'] = True

    elif arg in ('--factory-reset'):
      options['factory_reset'] = True

    elif arg == '--dry-run':
      options['dry_run'] = True

    else:
      print(f'Invalid argument "{arg}"')
      print()
      print(HELP_MSG)
      return 1

  # initialize logging
  logging.basicConfig(
    level=log_level,
    format='[%(asctime)s] %(levelname)s: %(message)s'
  )

  # check if ifupdown2 is installed
  if subprocess.getoutput("dpkg-query -W -f='${Status}' ifupdown2 2>/dev/null") \
          != "install ok installed":
    logging.error('ERROR: ifupdown2 missing')
    subprocess.call([
      "bgsystemlog",
      "-l", "WARNING",
      "-s", "NETWORK",
      "-m", "network_config: ifupdown2 missing"
    ])
    sys.exit(1)

  # run network configuration
  try:
    return network_config(**options)
  except Exception as e:
    logging.error(f'Exception occurred: {e}')
    traceback.print_exc()
    return 1


# run main() when executed directly
if __name__ == '__main__':
  sys.exit(main())
