'''

Usage::

    # Imports
    from pyroute2 import NDB, WireGuard

    IFNAME = 'wg1'

    # Create a WireGuard interface
    with NDB() as ndb:
        with ndb.interfaces.create(kind='wireguard', ifname=IFNAME) as link:
            link.add_ip('10.0.0.1/24')
            link.set(state='up')

    # Create WireGuard object
    wg = WireGuard()

    # Add a WireGuard configuration + first peer
    peer = {'public_key': 'TGFHcm9zc2VCaWNoZV9DJ2VzdExhUGx1c0JlbGxlPDM=',
            'endpoint_addr': '8.8.8.8',
            'endpoint_port': 8888,
            'persistent_keepalive': 15,
            'allowed_ips': ['10.0.0.0/24', '8.8.8.8/32']}
    wg.set(IFNAME, private_key='RCdhcHJlc0JpY2hlLEplU2VyYWlzTGFQbHVzQm9ubmU=',
           fwmark=0x1337, listen_port=2525, peer=peer)

    # Add second peer with preshared key
    peer = {'public_key': 'RCdBcHJlc0JpY2hlLFZpdmVMZXNQcm9iaW90aXF1ZXM=',
            'preshared_key': 'Pz8/V2FudFRvVHJ5TXlBZXJvR3Jvc3NlQmljaGU/Pz8=',
            'endpoint_addr': '8.8.8.8',
            'endpoint_port': 9999,
            'persistent_keepalive': 25,
            'allowed_ips': ['::/0']}
    wg.set(IFNAME, peer=peer)

    # Delete second peer
    peer = {'public_key': 'RCdBcHJlc0JpY2hlLFZpdmVMZXNQcm9iaW90aXF1ZXM=',
            'remove': True}
    wg.set(IFNAME, peer=peer)

    # Get information of the interface
    wg.info(IFNAME)

    # Get specific value from the interface
    wg.info(IFNAME)[0].get('WGDEVICE_A_PRIVATE_KEY')


NOTES:

* The `get()` method always returns iterable
* Using `set()` method only requires an interface name
* The `peer` structure is described as follow::

    struct peer_s {
        public_key:            # Base64 public key - required
        remove:                # Boolean - optional
        update_only:           # Boolean - optional
        preshared_key:         # Base64 preshared key - optional
        endpoint_addr:         # IPv4 or IPv6 endpoint - optional
        endpoint_port :        # endpoint Port - required only if endpoint_addr
        persistent_keepalive:  # time in seconds to send keep alive - optional
        replace_allowed_ips:   # Boolean - optional
        allowed_ips:           # list of CIDRs allowed - optional
    }
'''

import errno
import logging
import struct
from base64 import b64decode, b64encode
from binascii import a2b_hex
from socket import AF_INET, AF_INET6, inet_ntop, inet_pton
from time import ctime

from pyroute2.netlink import (
    NLA_F_NESTED,
    NLM_F_ACK,
    NLM_F_DUMP,
    NLM_F_REQUEST,
    genlmsg,
    nla,
)
from pyroute2.netlink.generic import (
    AsyncGenericNetlinkSocket,
    GenericNetlinkSocket,
)

# Defines from uapi/wireguard.h
WG_GENL_NAME = "wireguard"
WG_GENL_VERSION = 1
WG_KEY_LEN = 32

# WireGuard Device commands
WG_CMD_GET_DEVICE = 0
WG_CMD_SET_DEVICE = 1

# Wireguard Device attributes
WGDEVICE_A_UNSPEC = 0
WGDEVICE_A_IFINDEX = 1
WGDEVICE_A_IFNAME = 2
WGDEVICE_A_PRIVATE_KEY = 3
WGDEVICE_A_PUBLIC_KEY = 4
WGDEVICE_A_FLAGS = 5
WGDEVICE_A_LISTEN_PORT = 6
WGDEVICE_A_FWMARK = 7
WGDEVICE_A_PEERS = 8

# WireGuard Device flags
WGDEVICE_F_REPLACE_PEERS = 0x1

# WireGuard Allowed IP attributes
WGALLOWEDIP_A_UNSPEC = 0
WGALLOWEDIP_A_FAMILY = 1
WGALLOWEDIP_A_IPADDR = 2
WGALLOWEDIP_A_CIDR_MASK = 3

# WireGuard Peer flags
WGPEER_F_REMOVE_ME = 0x1
WGPEER_F_REPLACE_ALLOWEDIPS = 0x2
WGPEER_F_UPDATE_ONLY = 0x4

# Specific defines
WG_MAX_PEERS = 1000
WG_MAX_ALLOWEDIPS = 1000


class wgmsg(genlmsg):
    prefix = 'WGDEVICE_A_'

    nla_map = (
        ('WGDEVICE_A_UNSPEC', 'none'),
        ('WGDEVICE_A_IFINDEX', 'uint32'),
        ('WGDEVICE_A_IFNAME', 'asciiz'),
        ('WGDEVICE_A_PRIVATE_KEY', 'parse_wg_key'),
        ('WGDEVICE_A_PUBLIC_KEY', 'parse_wg_key'),
        ('WGDEVICE_A_FLAGS', 'uint32'),
        ('WGDEVICE_A_LISTEN_PORT', 'uint16'),
        ('WGDEVICE_A_FWMARK', 'uint32'),
        ('WGDEVICE_A_PEERS', '*wgdevice_peer'),
    )

    class wgdevice_peer(nla):
        prefix = 'WGPEER_A_'

        nla_flags = NLA_F_NESTED
        nla_map = (
            ('WGPEER_A_UNSPEC', 'none'),
            ('WGPEER_A_PUBLIC_KEY', 'parse_peer_key'),
            ('WGPEER_A_PRESHARED_KEY', 'parse_peer_key'),
            ('WGPEER_A_FLAGS', 'uint32'),
            ('WGPEER_A_ENDPOINT', 'parse_endpoint'),
            ('WGPEER_A_PERSISTENT_KEEPALIVE_INTERVAL', 'uint16'),
            ('WGPEER_A_LAST_HANDSHAKE_TIME', 'parse_handshake_time'),
            ('WGPEER_A_RX_BYTES', 'uint64'),
            ('WGPEER_A_TX_BYTES', 'uint64'),
            ('WGPEER_A_ALLOWEDIPS', '*wgpeer_allowedip'),
            ('WGPEER_A_PROTOCOL_VERSION', 'uint32'),
        )

        class parse_peer_key(nla):
            fields = (('key', '32s'),)

            def decode(self):
                nla.decode(self)
                self['value'] = b64encode(self['key'])

            def encode(self):
                self['key'] = b64decode(self['value'])
                nla.encode(self)

        @staticmethod
        def parse_endpoint(nla, *argv, **kwarg):
            family = AF_INET
            if 'data' in kwarg:
                # decoding, fetch the famliy from the NLA data
                data = kwarg['data']
                offset = kwarg['offset']
                family = struct.unpack('H', data[offset + 4 : offset + 6])[0]
            elif kwarg['value']['addr'].find(':') > -1:
                # encoding, setup family from the addr format
                family = AF_INET6

            if family == AF_INET:
                return nla.endpoint_ipv4
            else:
                return nla.endpoint_ipv6

        class endpoint_ipv4(nla):
            fields = (
                ('family', 'H'),
                ('port', '>H'),
                ('addr', '4s'),
                ('__pad', '8x'),
            )

            def decode(self):
                nla.decode(self)
                self['addr'] = inet_ntop(AF_INET, self['addr'])

            def encode(self):
                self['family'] = AF_INET
                self['addr'] = inet_pton(AF_INET, self['addr'])
                nla.encode(self)

        class endpoint_ipv6(nla):
            fields = (
                ('family', 'H'),
                ('port', '>H'),
                ('flowinfo', '>I'),
                ('addr', '16s'),
                ('scope_id', '>I'),
            )

            def decode(self):
                nla.decode(self)
                self['addr'] = inet_ntop(AF_INET6, self['addr'])

            def encode(self):
                self['family'] = AF_INET6
                self['addr'] = inet_pton(AF_INET6, self['addr'])
                nla.encode(self)

        class parse_handshake_time(nla):
            fields = (('tv_sec', 'Q'), ('tv_nsec', 'Q'))

            def decode(self):
                nla.decode(self)
                self['latest handshake'] = ctime(self['tv_sec'])

        class wgpeer_allowedip(nla):
            prefix = 'WGALLOWEDIP_A_'

            nla_flags = NLA_F_NESTED
            nla_map = (
                ('WGALLOWEDIP_A_UNSPEC', 'none'),
                ('WGALLOWEDIP_A_FAMILY', 'uint16'),
                ('WGALLOWEDIP_A_IPADDR', 'hex'),
                ('WGALLOWEDIP_A_CIDR_MASK', 'uint8'),
            )

            def decode(self):
                nla.decode(self)
                family = self.get_attr('WGALLOWEDIP_A_FAMILY')
                if family is None:
                    # Prevent when decode() is called without attrs because all
                    # data transferred to 'value' entry.
                    #  {'attrs': [], 'value': [{'attrs' ...
                    return
                ipaddr = self.get_attr('WGALLOWEDIP_A_IPADDR')
                cidr = self.get_attr('WGALLOWEDIP_A_CIDR_MASK')
                self['addr'] = '{ipaddr}/{cidr}'.format(
                    ipaddr=inet_ntop(family, a2b_hex(ipaddr.replace(':', ''))),
                    cidr=cidr,
                )

    class parse_wg_key(nla):
        fields = (('key', '32s'),)

        def decode(self):
            nla.decode(self)
            self['value'] = b64encode(self['key'])

        def encode(self):
            self['key'] = b64decode(self['value'])
            nla.encode(self)


class AsyncWireGuard(AsyncGenericNetlinkSocket):

    async def setup_endpoint(self):
        if getattr(self.local, 'transport', None) is not None:
            return
        await super().setup_endpoint()
        await self.bind(WG_GENL_NAME, wgmsg)

    async def info(self, interface):
        msg = wgmsg()
        msg['cmd'] = WG_CMD_GET_DEVICE
        msg['attrs'].append(['WGDEVICE_A_IFNAME', interface])
        return await self.nlm_request(
            msg, msg_type=self.prid, msg_flags=NLM_F_REQUEST | NLM_F_DUMP
        )

    async def set(
        self,
        interface,
        listen_port=None,
        fwmark=None,
        private_key=None,
        peer=None,
    ):
        msg = wgmsg()
        msg['attrs'].append(['WGDEVICE_A_IFNAME', interface])

        if private_key is not None:
            self._wg_test_key(private_key)
            msg['attrs'].append(['WGDEVICE_A_PRIVATE_KEY', private_key])

        if listen_port is not None:
            msg['attrs'].append(['WGDEVICE_A_LISTEN_PORT', listen_port])

        if fwmark is not None:
            msg['attrs'].append(['WGDEVICE_A_FWMARK', fwmark])

        if peer is not None:
            self._wg_set_peer(msg, peer)

        # Message attributes
        msg['cmd'] = WG_CMD_SET_DEVICE
        msg['version'] = WG_GENL_VERSION
        msg['header']['type'] = self.prid
        msg['header']['flags'] = NLM_F_REQUEST | NLM_F_ACK
        msg['header']['pid'] = self.pid
        msg.encode()
        self.sendto(msg.data, (0, 0))
        (msg,) = [x async for x in self.get()]
        err = msg['header'].get('error', None)
        if err is not None:
            if hasattr(err, 'code') and err.code == errno.ENOENT:
                logging.error(
                    'Generic netlink protocol %s not found' % self.prid
                )
                logging.error('Please check if the protocol module is loaded')
            raise err
        return msg

    def _wg_test_key(self, key):
        try:
            if len(b64decode(key)) != WG_KEY_LEN:
                raise ValueError('Invalid WireGuard key length')
        except TypeError:
            raise ValueError('Failed to decode Base64 key')

    def _wg_set_peer(self, msg, peer):
        attrs = []
        wg_peer = [{'attrs': attrs}]
        if 'public_key' not in peer:
            raise ValueError('Peer Public key required')

        # Check public key validity
        public_key = peer['public_key']
        self._wg_test_key(public_key)
        attrs.append(['WGPEER_A_PUBLIC_KEY', public_key])

        # If peer removal is set to True
        if 'remove' in peer and peer['remove']:
            attrs.append(['WGPEER_A_FLAGS', WGPEER_F_REMOVE_ME])
            msg['attrs'].append(['WGDEVICE_A_PEERS', wg_peer])
            return

        # Set Endpoint
        if 'endpoint_addr' in peer and 'endpoint_port' in peer:
            attrs.append(
                [
                    'WGPEER_A_ENDPOINT',
                    {
                        'addr': peer['endpoint_addr'],
                        'port': peer['endpoint_port'],
                    },
                ]
            )

        # Set Preshared key
        if 'preshared_key' in peer:
            pkey = peer['preshared_key']
            self._wg_test_key(pkey)
            attrs.append(['WGPEER_A_PRESHARED_KEY', pkey])

        # Set Persistent Keepalive time
        if 'persistent_keepalive' in peer:
            keepalive = peer['persistent_keepalive']
            attrs.append(['WGPEER_A_PERSISTENT_KEEPALIVE_INTERVAL', keepalive])

        # Set Peer flags
        flags = 0
        if 'update_only' in peer and peer['update_only']:
            flags |= WGPEER_F_UPDATE_ONLY
        if 'replace_allowed_ips' in peer and peer['replace_allowed_ips']:
            flags |= WGPEER_F_REPLACE_ALLOWEDIPS
        attrs.append(['WGPEER_A_FLAGS', flags])

        # Set allowed IPs
        if 'allowed_ips' in peer:
            allowed_ips = self._wg_build_allowedips(peer['allowed_ips'])
            attrs.append(['WGPEER_A_ALLOWEDIPS', allowed_ips])

        msg['attrs'].append(['WGDEVICE_A_PEERS', wg_peer])

    def _wg_build_allowedips(self, allowed_ips):
        ret = []

        for index, ip in enumerate(allowed_ips):
            allowed_ip = []
            ret.append({'attrs': allowed_ip})

            if ip.find("/") == -1:
                raise ValueError('No CIDR set in allowed ip #{}'.format(index))

            addr, mask = ip.split('/')

            family = AF_INET if addr.find(":") == -1 else AF_INET6
            allowed_ip.append(['WGALLOWEDIP_A_FAMILY', family])
            allowed_ip.append(
                ['WGALLOWEDIP_A_IPADDR', inet_pton(family, addr)]
            )
            allowed_ip.append(['WGALLOWEDIP_A_CIDR_MASK', int(mask)])

        return ret


class WireGuard(GenericNetlinkSocket):
    async_class = AsyncWireGuard

    def info(self, interface):
        return self._run_sync_cleanup(self.asyncore.info, interface)

    def set(
        self,
        interface,
        listen_port=None,
        fwmark=None,
        private_key=None,
        peer=None,
    ):
        return self._run_with_cleanup(
            self.asyncore.set,
            interface,
            listen_port,
            fwmark,
            private_key,
            peer,
        )
