# Copyright (C) 2018-2020 Canonical, Ltd.
# Author: Mathieu Trudel-Lapierre <mathieu.trudel-lapierre@canonical.com>
# Author: Łukasz 'sil2100' Zemczak <lukasz.zemczak@canonical.com>
# Author: Lukas 'slyon' Märdian <lukas.maerdian@canonical.com>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 3.
#
# 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 sys
import os
import logging
import argparse
import subprocess
import fnmatch
import re
import json
from typing import Optional

from ..configmanager import ConfigurationError
from netplan import NetDefinition, NetplanException


NM_SERVICE_NAME = 'NetworkManager.service'
NM_SNAP_SERVICE_NAME = 'snap.network-manager.networkmanager.service'

OLD_RT_TABLES_PATH = '/etc/iproute2/rt_tables'
NEW_RT_TABLES_PATH = '/usr/share/iproute2/rt_tables'
RT_TABLES_DEFAULT = {0: 'unspec', 253: 'default', 254: 'main', 255: 'local',
                     'unspec': 0, 'default': 253, 'main': 254, 'local': 255}

config_errors = (ConfigurationError, NetplanException, RuntimeError)


def get_generator_path():
    return os.environ.get('NETPLAN_GENERATE_PATH', '/usr/lib/netplan/generate')


def get_configure_path():
    return os.environ.get('NETPLAN_CONFIGURE_PATH', '/usr/lib/netplan/configure')


def is_nm_snap_enabled():
    return subprocess.call(['systemctl', '--quiet', 'is-enabled', NM_SNAP_SERVICE_NAME], stderr=subprocess.DEVNULL) == 0


def nmcli(args):  # pragma: nocover (covered in autopkgtest)
    # 'nmcli' could be /usr/bin/nmcli or /snap/bin/nmcli -> /snap/bin/network-manager.nmcli
    # PATH is defined in cli/core.py
    subprocess.check_call(['nmcli'] + args, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def nmcli_out(args: list) -> str:  # pragma: nocover (covered in autopkgtest)
    # 'nmcli' could be /usr/bin/nmcli or /snap/bin/nmcli -> /snap/bin/network-manager.nmcli
    # PATH is defined in cli/core.py
    return subprocess.check_output(['nmcli'] + args, text=True)


def nm_running():  # pragma: nocover (covered in autopkgtest)
    '''Check if NetworkManager is running'''

    try:
        nmcli(['general'])
        return True
    except (OSError, subprocess.SubprocessError):
        return False


def nm_interfaces(paths, devices):
    pat = re.compile('^interface-name=(.*)$')
    interfaces = set()
    for path in paths:
        with open(path, 'r') as f:
            for line in f:
                m = pat.match(line)
                if m:
                    # Expand/match globbing of interface names, to real devices
                    interfaces.update(set(fnmatch.filter(devices, m.group(1))))
                    break  # skip to next file
    return interfaces


def nm_get_connection_for_interface(interface: str) -> str:
    output = nmcli_out(['-m', 'tabular', '-f', 'GENERAL.CONNECTION', 'device', 'show', interface])
    lines = output.strip().split('\n')
    connection = lines[1]
    return connection if connection != '--' else ''


def nm_bring_interface_up(connection: str) -> None:  # pragma: nocover (must be covered by NM autopkgtests)
    try:
        nmcli(['connection', 'up', connection])
    except subprocess.CalledProcessError:
        pass


def systemctl_network_manager(action, sync=False):
    # If the network-manager snap is installed use its service
    # name rather than the one of the deb packaged NetworkManager
    if is_nm_snap_enabled():
        return systemctl(action, [NM_SNAP_SERVICE_NAME], sync)
    return systemctl(action, [NM_SERVICE_NAME], sync)  # pragma: nocover (covered in autopkgtest)


def systemctl(action: str, services: list, sync: bool = False):
    if len(services) >= 1:
        command = ['systemctl', action]

        if not sync:
            command.append('--no-block')

        command.extend(services)

        subprocess.check_call(command)


def networkd_interfaces():
    interfaces = set()
    out = subprocess.check_output(['networkctl', '--no-pager', '--no-legend'], text=True)
    for line in out.splitlines():
        s = line.strip().split(' ')
        if s[0].isnumeric() and s[-1] not in ['unmanaged', 'linger']:
            interfaces.add(s[0])
    return interfaces


def networkctl_reload():
    subprocess.check_call(['networkctl', 'reload'])


def networkctl_reconfigure(interfaces):
    if len(interfaces) >= 1:
        subprocess.check_call(['networkctl', 'reconfigure'] + list(interfaces))


def systemctl_is_active(unit_pattern):
    '''Return True if at least one matching unit is running'''
    if subprocess.call(['systemctl', '--quiet', 'is-active', unit_pattern]) == 0:
        return True
    return False


def systemctl_is_masked(unit_pattern):
    '''Return True if output is "masked" or "masked-runtime"'''
    res = subprocess.run(['systemctl', 'is-enabled', unit_pattern],
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         text=True)
    if res.returncode > 0 and 'masked' in res.stdout:
        return True
    return False


def systemctl_is_installed(unit_pattern):
    '''Return True if returncode is other than "not-found" (4)'''
    res = subprocess.run(['systemctl', 'status', unit_pattern],
                         stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                         text=True)
    if res.returncode != 4:
        return True
    return False


def systemctl_daemon_reload():
    '''Reload systemd unit files from disk and re-calculate its dependencies'''
    subprocess.check_call(['systemctl', 'daemon-reload', '--no-ask-password'])


def ip_addr_flush(iface):
    '''Flush all IP addresses of a given interface via iproute2'''
    subprocess.check_call(['ip', 'addr', 'flush', iface], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)


def get_interface_driver_name(interface, only_down=False):  # pragma: nocover (covered in autopkgtest)
    devdir = os.path.join('/sys/class/net', interface)
    if only_down:
        try:
            with open(os.path.join(devdir, 'operstate')) as f:
                state = f.read().strip()
                if state != 'down':
                    logging.debug('device %s operstate is %s, not changing', interface, state)
                    return None
        except IOError as e:
            logging.error('Cannot determine operstate of %s: %s', interface, str(e))
            return None

    try:
        driver = os.path.realpath(os.path.join(devdir, 'device', 'driver'))
        driver_name = os.path.basename(driver)
    except IOError as e:
        logging.debug('Cannot replug %s: cannot read link %s/device: %s', interface, devdir, str(e))
        return None

    return driver_name


def _get_permanent_macaddress(interface: str) -> Optional[str]:
    mac = None
    try:
        out = subprocess.check_output(['ethtool', '-P', interface]).decode('utf-8')
        split = out.split(': ')
        if len(split) == 2 and is_valid_macaddress(split[1].strip()):
            mac = split[1].strip()
    except Exception:
        return mac

    return mac


def _get_macaddress(interface: str) -> Optional[str]:
    try:
        with open(f'/sys/class/net/{interface}/address') as f:
            return f.read().strip()
    except Exception:
        return None


def get_interfaces() -> list[str]:
    try:
        out = subprocess.check_output(['ip', '--json', 'link']).decode('utf-8')
        out_json = json.loads(out)
        return [iface['ifname'] for iface in out_json]
    except Exception:
        return []


def get_interface_macaddress(interface: str) -> Optional[str]:
    mac = _get_permanent_macaddress(interface)

    if not mac:
        mac = _get_macaddress(interface)

    return mac


def find_matching_iface(interfaces: list, netdef):
    assert isinstance(netdef, NetDefinition)
    assert netdef._has_match

    matches = list(filter(lambda itf: netdef._match_interface(
            iface_name=itf,
            iface_driver=get_interface_driver_name(itf),
            iface_mac=get_interface_macaddress(itf)), interfaces))

    # Return current name of unique matched interface, if available
    if len(matches) != 1:
        logging.info(matches)
        return None
    return matches[0]


def is_valid_macaddress(macaddress: str) -> bool:
    MAC_PATTERN = '^[a-fA-F0-9][a-fA-F0-9](:[a-fA-F0-9][a-fA-F0-9]){5}((:[a-fA-F0-9][a-fA-F0-9]){14})?$'
    return re.match(MAC_PATTERN, macaddress) is not None


def route_table_lookup() -> dict:
    lookup_table = {}
    path = NEW_RT_TABLES_PATH

    if not os.path.exists(path):
        path = OLD_RT_TABLES_PATH

    try:
        with open(path, 'r') as rt_tables:
            for line in rt_tables:
                split_line = line.split()
                if len(split_line) == 2 and split_line[0].isnumeric():
                    lookup_table[int(split_line[0])] = split_line[1]
                    lookup_table[split_line[1]] = int(split_line[0])
    except Exception:
        logging.debug(f'Cannot open \'{path}\' for reading')
        # defaults to the standard content found in the file
        return RT_TABLES_DEFAULT

    return lookup_table


class NetplanCommand(argparse.Namespace):

    def __init__(self, command_id, description, leaf=True, testing=False):
        self.command_id = command_id
        self.description = description
        self.leaf_command = leaf
        self.testing = testing
        self._args = None
        self.debug = False
        self.breakpoint = False
        self.commandclass = None
        self.subcommands = {}
        self.subcommand = None
        self.func = None
        self.try_ready_stamp = 'run/netplan/netplan-try.ready'
        self.generator_dir = '/run/systemd/generator/'
        self.generator_early_dir = '/run/systemd/generator.early/'
        self.generator_late_dir = '/run/systemd/generator.late/'

        self.parser = argparse.ArgumentParser(prog="%s %s" % (sys.argv[0], command_id),
                                              description=description,
                                              add_help=True)
        self.parser.add_argument('--debug', action='store_true',
                                 help='Enable debug messages')
        self.parser.add_argument('--breakpoint', action='store_true',
                                 help=argparse.SUPPRESS)
        if not leaf:
            self.subparsers = self.parser.add_subparsers(title='Available commands',
                                                         metavar='', dest='subcommand')
            p_help = self.subparsers.add_parser('help',
                                                description='Show this help message',
                                                help='Show this help message')
            p_help.set_defaults(func=self.print_usage)

    def update(self, args):
        self._args = args

    def parse_args(self):
        ns, self._args = self.parser.parse_known_args(args=self._args, namespace=self)

        if not self.subcommand and not self.leaf_command:
            print('You need to specify a command', file=sys.stderr)
            self.print_usage()

    def run_command(self):
        if self.commandclass:
            self.commandclass.update(self._args)

        # TODO: (cyphermox) this is actually testable in tests/cli.py; add it.
        if self.leaf_command and 'help' in self._args:  # pragma: nocover (covered in autopkgtest)
            self.print_usage()

        if self.breakpoint:  # pragma: nocover (cannot be automatically tested)
            breakpoint()
        self.func()

    def print_usage(self):
        self.parser.print_help(file=sys.stderr)
        sys.exit(os.EX_USAGE)

    def _add_subparser_from_class(self, name, commandclass):
        instance = commandclass()

        self.subcommands[name] = {}
        self.subcommands[name]['class'] = name
        self.subcommands[name]['instance'] = instance

        if instance.testing:
            if not os.environ.get('ENABLE_TEST_COMMANDS', None):
                return

        p = self.subparsers.add_parser(instance.command_id,
                                       description=instance.description,
                                       help=instance.description,
                                       add_help=False)
        p.set_defaults(func=instance.run, commandclass=instance)
        self.subcommands[name]['parser'] = p

    def _import_subcommands(self, submodules):
        import inspect
        for name, obj in inspect.getmembers(submodules):
            if inspect.isclass(obj) and issubclass(obj, NetplanCommand):
                self._add_subparser_from_class(name, obj)
