#!/usr/bin/env python3
#
# Copyright (C) 2019 VyOS maintainers and contributors
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License version 2 or later as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import os
import netifaces

from sys import exit
from copy import deepcopy
from netifaces import interfaces

from vyos.config import Config
from vyos.configdict import is_member
from vyos.ifconfig import Interface, GREIf, GRETapIf, IPIPIf, IP6GREIf, IPIP6If, IP6IP6If, SitIf, Sit6RDIf
from vyos.ifconfig.afi import IP4, IP6
from vyos.configdict import list_diff
from vyos.validate import is_ipv4, is_ipv6
from vyos import ConfigError
from vyos.dicts import FixedDict

from vyos import airbag
airbag.enable()


class ConfigurationState(object):
    """
    The current API require a dict to be generated by get_config()
    which is then consumed by verify(), generate() and apply()

    ConfiguartionState is an helper class wrapping Config and providing
    an common API to this dictionary structure

    Its to_api() function return a dictionary containing three fields,
    each a dict, called options, changes, actions.

    options:

    contains the configuration options for the dict and its value
    {'options': {'commment': 'test'}} will be set if
    'set interface dummy dum1 description test' was used and
    the key 'commment' is used to index the description info.

    changes:

    per key, let us know how the data was modified using one of the action
    a special key called 'section' is used to indicate what happened to the
    section. for example:

    'set interface dummy dum1 description test' when no interface was setup
    will result in the following changes
    {'changes': {'section': 'create', 'comment': 'create'}}

    on an existing interface, depending if there was a description
    'set interface dummy dum1 description test' will result in one of
    {'changes': {'comment': 'create'}}  (not present before)
    {'changes': {'comment': 'static'}}  (unchanged)
    {'changes': {'comment': 'modify'}}  (changed from half)

    and 'delete interface dummy dummy1 description' will result in:
    {'changes': {'comment': 'delete'}}

    actions:

    for each action list the configuration key which were changes
    in our example if we added the 'description' and added an IP we would have
    {'actions': { 'create': ['comment'], 'modify': ['addresses-add']}}

    the actions are:
        'create': it did not exist previously and was created
        'modify': it did exist previously but its content changed
        'static': it did exist and did not change
        'delete': it was present but was removed from the configuration
        'absent': it was not and is not present
    which for each field represent how it was modified since the last commit
    """

    def __init__(self, configuration, section, default):
        """
        initialise the class for a given configuration path:

        >>> conf = ConfigurationState(conf, 'interfaces ethernet eth1')
        all further references to get_value(s) and get_effective(s)
        will be for this part of the configuration (eth1)
        """
        self._conf = configuration

        self.default = deepcopy(default)
        self.options = FixedDict(**default)
        self.actions = {
            'create': [],  # the key did not exist and was added
            'static': [],  # the key exists and its value was not modfied
            'modify': [],  # the key exists and its value was modified
            'absent': [],  # the key is not present
            'delete': [],  # the key was present and was deleted
        }
        self.changes = {}
        if not self._conf.exists(section):
            self.changes['section'] = 'delete'
        elif self._conf.exists_effective(section):
            self.changes['section'] = 'modify'
        else:
            self.changes['section'] = 'create'

        self.set_level(section)

    def set_level(self, lpath):
        self.section = lpath
        self._conf.set_level(lpath)

    def _act(self, section):
        """
        Returns for a given configuration field determine what happened to it

        'create': it did not exist previously and was created
        'modify': it did exist previously but its content changed
        'static': it did exist and did not change
        'delete': it was present but was removed from the configuration
        'absent': it was not and is not present
        """
        if self._conf.exists(section):
            if self._conf.exists_effective(section):
                if self._conf.return_value(section) != self._conf.return_effective_value(section):
                    return 'modify'
                return 'static'
            return 'create'
        else:
            if self._conf.exists_effective(section):
                return 'delete'
            return 'absent'

    def _action(self, name, key):
        action = self._act(key)
        self.changes[name] = action
        self.actions[action].append(name)
        return action

    def _get(self, name, key, default, getter):
        value = getter(key)
        if not value:
            if default:
                self.options[name] = default
                return
            self.options[name] = self.default[name]
            return
        self.options[name] = value

    def get_value(self, name, key, default=None):
        """
        >>> conf.get_value('comment', 'description')
        will place the string of 'interface dummy description test'
        into the dictionnary entry 'comment' using Config.return_value
        (the data in the configuration to apply)
        """
        if self._action(name, key) in ('delete', 'absent'):
            return
        return self._get(name, key, default, self._conf.return_value)

    def get_values(self, name, key, default=None):
        """
        >>> conf.get_values('addresses', 'address')
        will place a list of the new IP present in 'interface dummy dum1 address'
        into the dictionnary entry "-add" (here 'addresses-add') using
        Config.return_values and will add the the one which were removed in into
        the entry "-del" (here addresses-del')
        """
        add_name = f'{name}-add'

        if self._action(add_name, key) in ('delete', 'absent'):
            return

        self._get(add_name, key, default, self._conf.return_values)

        # get the effective values to determine which data is no longer valid
        self.options['addresses-del'] = list_diff(
            self._conf.return_effective_values('address'),
            self.options['addresses-add']
        )

    def get_effective(self, name, key, default=None):
        """
        >>> conf.get_value('comment', 'description')
        will place the string of 'interface dummy description test'
        into the dictionnary entry 'comment' using Config.return_effective_value
        (the data in the configuration to apply)
        """
        self._action(name, key)
        return self._get(name, key, default, self._conf.return_effective_value)

    def get_effectives(self, name, key, default=None):
        """
        >>> conf.get_effectives('addresses-add', 'address')
        will place a list made of the IP present in 'interface ethernet eth1 address'
        into the dictionnary entry 'addresses-add' using Config.return_effectives_value
        (the data in the un-modified configuration)
        """
        self._action(name, key)
        return self._get(name, key, default, self._conf.return_effectives_value)

    def load(self, mapping):
        """
        load will take a dictionary defining how we wish the configuration
        to be parsed and apply this definition to set the data.

        >>> mapping = {
            'addresses-add' : ('address', True, None),
            'comment' : ('description', False, 'auto'),
        }
        >>> conf.load(mapping)

        mapping is a dictionary where each key represents the name we wish
        to have (such as 'addresses-add'), with a list a content representing
        how the data should be parsed:
            - the configuration section name
              such as 'address' under 'interface ethernet eth1'
            - boolean indicating if this data can have multiple values
              for 'address', True, as multiple IPs can be set
              for 'description', False, as it is a single string
            - default represent the default value if absent from the configuration
              'None' indicate that no default should be set if the configuration
              does not have the configuration section

        """
        for local_name, (config_name, multiple, default) in mapping.items():
            if multiple:
                self.get_values(local_name, config_name, default)
            else:
                self.get_value(local_name, config_name, default)

    def remove_default(self,*options):
        """
        remove all the values which were not changed from the default
        """
        for option in options:
            if not self._conf.exists(option):
                del self.options[option]
                continue

            if self._conf.return_value(option) == self.default[option]:
                del self.options[option]
                continue

            if self._conf.return_values(option) == self.default[option]:
                del self.options[option]
                continue

    def as_dict(self, lpath):
        l = self._conf.get_level()
        self._conf.set_level([])
        d = self._conf.get_config_dict(lpath)
        # XXX: that not what I would have expected from get_config_dict
        if lpath:
            d = d[lpath[-1]]
        # XXX: it should have provided me the content and not the key
        self._conf.set_level(l)
        return d

    def to_api(self):
        """
        provide a dictionary with the generated data for the configuration
        options: the configuration value for the key
        changes: per key how they changed from the previous configuration
        actions: per changes all the options which were changed
        """
        # as we have to use a dict() for the API for verify and apply the options
        return {
            'options': self.options,
            'changes': self.changes,
            'actions': self.actions,
        }


default_config_data = {
    # interface definition
    'vrf': '',
    'addresses-add': [],
    'addresses-del': [],
    'state': 'up',
    'dhcp-interface': '',
    'link_detect': 1,
    'ip': False,
    'ipv6': False,
    'nhrp': [],
    'arp_filter': 1,
    'arp_accept': 0,
    'arp_announce': 0,
    'arp_ignore': 0,
    'ipv6_accept_ra': 1,
    'ipv6_autoconf': 0,
    'ipv6_forwarding': 1,
    'ipv6_dad_transmits': 1,
    # internal
    'interfaces': [],
    'tunnel': {},
    'bridge': '',
    # the following names are exactly matching the name
    # for the ip command and must not be changed
    'ifname': '',
    'type': '',
    'alias': '',
    'mtu': '1476',
    'local': '',
    'remote': '',
    'dev': '',
    'multicast': 'disable',
    'allmulticast': 'disable',
    'ttl': '255',
    'tos': 'inherit',
    'key': '',
    'encaplimit': '4',
    'flowlabel': 'inherit',
    'hoplimit': '64',
    'tclass': 'inherit',
    '6rd-prefix': '',
    '6rd-relay-prefix': '',
}


# dict name -> config name, multiple values, default
mapping = {
    'type':                ('encapsulation', False, None),
    'alias':               ('description', False, None),
    'mtu':                 ('mtu', False, None),
    'local':               ('local-ip', False, None),
    'remote':              ('remote-ip', False, None),
    'multicast':           ('multicast', False, None),
    'dev':                 ('source-interface', False, None),
    'ttl':                 ('parameters ip ttl', False, None),
    'tos':                 ('parameters ip tos', False, None),
    'key':                 ('parameters ip key', False, None),
    'encaplimit':          ('parameters ipv6 encaplimit', False, None),
    'flowlabel':           ('parameters ipv6 flowlabel', False, None),
    'hoplimit':            ('parameters ipv6 hoplimit', False, None),
    'tclass':              ('parameters ipv6 tclass', False, None),
    '6rd-prefix':          ('6rd-prefix', False, None),
    '6rd-relay-prefix':    ('6rd-relay-prefix', False, None),
    'dhcp-interface':      ('dhcp-interface', False, None),
    'state':               ('disable', False, 'down'),
    'link_detect':         ('disable-link-detect', False, 2),
    'vrf':                 ('vrf', False, None),
    'addresses':           ('address', True, None),
    'arp_filter':          ('ip disable-arp-filter', False, 0),
    'arp_accept':          ('ip enable-arp-accept', False, 1),
    'arp_announce':        ('ip enable-arp-announce', False, 1),
    'arp_ignore':          ('ip enable-arp-ignore', False, 1),
    'ipv6_autoconf':       ('ipv6 address autoconf', False, 1),
    'ipv6_forwarding':     ('ipv6 disable-forwarding', False, 0),
    'ipv6_dad_transmits:': ('ipv6 dup-addr-detect-transmits', False, None)
}


def get_class (options):
    dispatch = {
        'gre': GREIf,
        'gre-bridge': GRETapIf,
        'ipip': IPIPIf,
        'ipip6': IPIP6If,
        'ip6ip6': IP6IP6If,
        'ip6gre': IP6GREIf,
        'sit': SitIf,
    }

    kls = dispatch[options['type']]
    if options['type'] == 'gre' and not options['remote'] \
        and not options['key'] and not options['multicast']:
        # will use GreTapIf on GreIf deletion but it does not matter
        return GRETapIf
    elif options['type'] == 'sit' and options['6rd-prefix']:
        # will use SitIf on Sit6RDIf deletion but it does not matter
        return Sit6RDIf
    return kls

def get_interface_ip (ifname):
    if not ifname:
        return ''
    try:
        addrs = Interface(ifname).get_addr()
        if addrs:
            return addrs[0].split('/')[0]
    except Exception:
        return ''

def get_afi (ip):
    return IP6 if is_ipv6(ip) else IP4

def ip_proto (afi):
    return 6 if afi == IP6 else 4


def get_config(config=None):
    ifname = os.environ.get('VYOS_TAGNODE_VALUE','')
    if not ifname:
        raise ConfigError('Interface not specified')

    if config:
        config = config
    else:
        config = Config()

    conf = ConfigurationState(config, ['interfaces', 'tunnel ', ifname], default_config_data)
    options = conf.options
    changes = conf.changes
    options['ifname'] = ifname

    if changes['section'] == 'delete':
        conf.get_effective('type', mapping['type'][0])
        config.set_level(['protocols', 'nhrp', 'tunnel'])
        options['nhrp'] = config.list_nodes('')
        return conf.to_api()

    # load all the configuration option according to the mapping
    conf.load(mapping)

    # remove default value if not set and not required
    afi_local = get_afi(options['local'])
    if afi_local == IP6:
        conf.remove_default('ttl', 'tos', 'key')
    if afi_local == IP4:
        conf.remove_default('encaplimit', 'flowlabel', 'hoplimit', 'tclass')

    # if the local-ip is not set, pick one from the interface !
    # hopefully there is only one, otherwise it will not be very deterministic
    # at time of writing the code currently returns ipv4 before ipv6 in the list

    # XXX: There is no way to trigger an update of the interface source IP if
    # XXX: the underlying interface IP address does change, I believe this
    # XXX: limit/issue is present in vyatta too

    if not options['local'] and options['dhcp-interface']:
        # XXX: This behaviour changes from vyatta which would return 127.0.0.1 if
        # XXX: the interface was not DHCP. As there is no easy way to find if an
        # XXX: interface is using DHCP, and using this feature to get 127.0.0.1
        # XXX: makes little sense, I feel the change in behaviour is acceptable
        picked = get_interface_ip(options['dhcp-interface'])
        if picked == '':
            picked = '127.0.0.1'
            print('Could not get an IP address from {dhcp-interface} using 127.0.0.1 instead')
        options['local'] = picked
        options['dhcp-interface'] = ''

    # to make IPv6 SLAAC and DHCPv6 work with forwarding=1,
    # accept_ra must be 2
    if options['ipv6_autoconf'] or 'dhcpv6' in options['addresses-add']:
        options['ipv6_accept_ra'] = 2

    # allmulticast fate is linked to multicast
    options['allmulticast'] = options['multicast']

    # check that per encapsulation all local-remote pairs are unique
    ct = conf.as_dict(['interfaces', 'tunnel'])
    options['tunnel'] = {}

    # check for bridges
    tmp = is_member(config, ifname, 'bridge')
    if tmp: options['bridge'] = next(iter(tmp))
    options['interfaces'] = interfaces()

    for name in ct:
        tunnel = ct[name]
        encap = tunnel.get('encapsulation', '')
        local = tunnel.get('local-ip', '')
        if not local:
            local = get_interface_ip(tunnel.get('dhcp-interface', ''))
        remote = tunnel.get('remote-ip', '<unset>')
        pair = f'{local}-{remote}'
        options['tunnel'][encap][pair] = options['tunnel'].setdefault(encap, {}).get(pair, 0) + 1

    return conf.to_api()


def verify(conf):
    options = conf['options']
    changes = conf['changes']
    actions = conf['actions']

    ifname = options['ifname']
    iftype = options['type']

    if changes['section'] == 'delete':
        if ifname in options['nhrp']:
            raise ConfigError((
                f'Cannot delete interface tunnel {iftype} {ifname}, '
                'it is used by NHRP'))

        if options['bridge']:
             raise ConfigError((
                f'Cannot delete interface "{options["ifname"]}" as it is a '
                f'member of bridge "{options["bridge"]}"!'))

        # done, bail out early
        return None

    # tunnel encapsulation checks

    if not iftype:
        raise ConfigError(f'Must provide an "encapsulation" for tunnel {iftype} {ifname}')

    if changes['type'] in ('modify', 'delete'):
        # TODO: we could now deal with encapsulation modification by deleting / recreating
        raise ConfigError(f'Encapsulation can only be set at tunnel creation for tunnel {iftype} {ifname}')

    if iftype != 'sit' and options['6rd-prefix']:
        # XXX: should be able to remove this and let the definition catch it
        print(f'6RD can only be configured for sit interfaces not tunnel {iftype} {ifname}')

    # what are the tunnel options we can set / modified / deleted

    kls = get_class(options)
    valid = kls.updates + ['alias', 'addresses-add', 'addresses-del', 'vrf', 'state']
    valid += ['arp_filter', 'arp_accept', 'arp_announce', 'arp_ignore']
    valid += ['ipv6_accept_ra', 'ipv6_autoconf', 'ipv6_forwarding', 'ipv6_dad_transmits']

    if changes['section'] == 'create':
        valid.extend(['type',])
        valid.extend([o for o in kls.options if o not in kls.updates])

    for create in actions['create']:
        if create not in valid:
            raise ConfigError(f'Can not set "{create}" for tunnel {iftype} {ifname} at tunnel creation')

    for modify in actions['modify']:
        if modify not in valid:
            raise ConfigError(f'Can not modify "{modify}" for tunnel {iftype} {ifname}. it must be set at tunnel creation')

    for delete in actions['delete']:
        if delete in kls.required:
            raise ConfigError(f'Can not remove "{delete}", it is an mandatory option for tunnel {iftype} {ifname}')

    # tunnel information

    tun_local = options['local']
    afi_local = get_afi(tun_local)
    tun_remote = options['remote'] or tun_local
    afi_remote = get_afi(tun_remote)
    tun_ismgre = iftype == 'gre' and not options['remote']
    tun_is6rd = iftype == 'sit' and options['6rd-prefix']
    tun_dev = options['dev']

    # incompatible options

    if not tun_local and not options['dhcp-interface'] and not tun_is6rd:
        raise ConfigError(f'Must configure either local-ip or dhcp-interface for tunnel {iftype} {ifname}')

    if tun_local and options['dhcp-interface']:
        raise ConfigError(f'Must configure only one of local-ip or dhcp-interface for tunnel {iftype} {ifname}')

    if tun_dev and iftype in ('gre-bridge', 'sit'):
        raise ConfigError(f'source interface can not be used with {iftype} {ifname}')

    # tunnel endpoint

    if afi_local != afi_remote:
        raise ConfigError(f'IPv4/IPv6 mismatch between local-ip and remote-ip for tunnel {iftype} {ifname}')

    if afi_local != kls.tunnel:
        version = 4 if tun_local == IP4 else 6
        raise ConfigError(f'Invalid IPv{version} local-ip for tunnel {iftype} {ifname}')

    ipv4_count = len([ip for ip in options['addresses-add'] if is_ipv4(ip)])
    ipv6_count = len([ip for ip in options['addresses-add'] if is_ipv6(ip)])

    if tun_ismgre and afi_local == IP6:
        raise ConfigError(f'Using an IPv6 address is forbidden for mGRE tunnels such as tunnel {iftype} {ifname}')

    # check address family use
    # checks are not enforced (but ip command failing) for backward compatibility

    if ipv4_count and not IP4 in kls.ip:
        print(f'Should not use IPv4 addresses on tunnel {iftype} {ifname}')

    if ipv6_count and not IP6 in kls.ip:
        print(f'Should not use IPv6 addresses on tunnel {iftype} {ifname}')

    # vrf check
    if options['vrf']:
        if options['vrf'] not in options['interfaces']:
            raise ConfigError(f'VRF "{options["vrf"]}" does not exist')

        if options['bridge']:
            raise ConfigError((
                f'Interface "{options["ifname"]}" cannot be member of VRF '
                f'"{options["vrf"]}" and bridge {options["bridge"]} '
                f'at the same time!'))

    # bridge and address check
    if ( options['bridge']
            and ( options['addresses-add']
                or options['ipv6_autoconf'] ) ):
        raise ConfigError((
            f'Cannot assign address to interface "{options["name"]}" '
            f'as it is a member of bridge "{options["bridge"]}"!'))

    # source-interface check

    if tun_dev and tun_dev not in options['interfaces']:
        raise ConfigError(f'device "{tun_dev}" does not exist')

    # tunnel encapsulation check

    convert = {
        (6, 4, 'gre'):  'ip6gre',
        (6, 6, 'gre'):  'ip6gre',
        (4, 6, 'ipip'): 'ipip6',
        (6, 6, 'ipip'): 'ip6ip6',
    }

    iprotos = []
    if ipv4_count:
        iprotos.append(4)
    if ipv6_count:
        iprotos.append(6)

    for iproto in iprotos:
        replace  = convert.get((kls.tunnel, iproto, iftype), '')
        if replace:
            raise ConfigError(
                f'Using IPv6 address in local-ip or remote-ip is not possible with "encapsulation {iftype}". ' +
                f'Use "encapsulation {replace}" for tunnel {iftype} {ifname} instead.'
            )

    # tunnel options

    incompatible = []
    if afi_local == IP6:
        incompatible.extend(['ttl', 'tos', 'key',])
    if afi_local == IP4:
        incompatible.extend(['encaplimit', 'flowlabel', 'hoplimit', 'tclass'])

    for option in incompatible:
        if option in options:
            # TODO: raise converted to print as not enforced by vyatta
            # raise ConfigError(f'{option} is not valid for tunnel {iftype} {ifname}')
            print(f'Using "{option}" is invalid for tunnel {iftype} {ifname}')

    # duplicate tunnel pairs

    pair = '{}-{}'.format(options['local'], options['remote'])
    if options['tunnel'].get(iftype, {}).get(pair, 0) > 1:
        raise ConfigError(f'More than one tunnel configured for with the same encapulation and IPs for tunnel {iftype} {ifname}')

    return None


def generate(gre):
    return None

def apply(conf):
    options = conf['options']
    changes = conf['changes']
    actions = conf['actions']
    kls = get_class(options)

    # extract ifname as otherwise it is duplicated on the interface creation
    ifname = options.pop('ifname')

    # only the valid keys for creation of a Interface
    config = dict((k, options[k]) for k in kls.options if options[k])

    # setup or create the tunnel interface if it does not exist
    tunnel = kls(ifname, **config)

    if changes['section'] == 'delete':
        tunnel.remove()
        # The perl code was calling/opt/vyatta/sbin/vyatta-tunnel-cleanup
        # which identified tunnels type which were not used anymore to remove them
        # (ie: gre0, gretap0, etc.) The perl code did however nothing
        # This feature is also not implemented yet
        return

    # A GRE interface without remote will be mGRE
    # if the interface does not suppor the option, it skips the change
    for option in tunnel.updates:
        if changes['section'] in 'create' and option in tunnel.options:
            # it was setup at creation
            continue
        if not options[option]:
            # remote can be set to '' and it would generate an invalide command
            continue
        tunnel.set_interface(option, options[option])

    # set other interface properties
    for option in ('alias', 'mtu', 'link_detect', 'multicast', 'allmulticast',
                   'arp_accept', 'arp_filter', 'arp_announce', 'arp_ignore',
                   'ipv6_accept_ra', 'ipv6_autoconf', 'ipv6_forwarding', 'ipv6_dad_transmits'):
        if not options[option]:
            # should never happen but better safe
            continue
        tunnel.set_interface(option, options[option])

    # assign/remove VRF (ONLY when not a member of a bridge,
    # otherwise 'nomaster' removes it from it)
    if not options['bridge']:
        tunnel.set_vrf(options['vrf'])

    # Configure interface address(es)
    for addr in options['addresses-del']:
        tunnel.del_addr(addr)
    for addr in options['addresses-add']:
        tunnel.add_addr(addr)

    # now bring it up (or not)
    tunnel.set_admin_state(options['state'])


if __name__ == '__main__':
    try:
        c = get_config()
        verify(c)
        generate(c)
        apply(c)
    except ConfigError as e:
        print(e)
        exit(1)