# vim: tabstop=4 shiftwidth=4 softtabstop=4

# Copyright 2014 IBM Corporation
# Copyright 2015-2017 Lenovo
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# Various utility functions that do not neatly fit into one category or another
import asyncio
import base64
import confluent.exceptions as cexc
import confluent.log as log
import glob
import hashlib
import ipaddress
try:
    import psutil
except ImportError:
    psutil = None
    import netifaces
import os
import re
import socket
import ssl
import struct
import subprocess
import cryptography.x509 as x509
try:
    import cryptography.x509.verification as verification
except ImportError:
    verification = None

import confluent.tasks as tasks


def mkdirp(path, mode=0o777):
    try:
        os.makedirs(path, mode)
    except OSError as e:
        if e.errno != 17:
            raise


async def check_call(*cmd, **kwargs):
    if len(cmd) == 1 and isinstance(cmd[0], (list, tuple)):
        cmd = cmd[0]
    subproc = await asyncio.create_subprocess_exec(*cmd, **kwargs)
    rc = await subproc.wait()
    if rc != 0:
        raise subprocess.CalledProcessError(rc, cmd)





async def check_output(*cmd, input=None):
    if len(cmd) == 1 and isinstance(cmd[0], (list, tuple)):
        cmd = cmd[0]
    process = await asyncio.create_subprocess_exec(
        *cmd, stdin=asyncio.subprocess.PIPE if input is not None else None,
        stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
    stdout, stderr = await process.communicate(input)
    retcode = process.returncode
    if retcode:
        raise subprocess.CalledProcessError(
            retcode, cmd,
            output=stdout, stderr=stderr)
    return stdout, stderr


def stringify(instr):
    # Normalize unicode and bytes to 'str', correcting for
    # current python version
    if isinstance(instr, bytes) and not isinstance(instr, str):
        return instr.decode('utf-8', errors='replace')
    elif not isinstance(instr, bytes) and not isinstance(instr, str):
        return instr.encode('utf-8')
    return instr

def list_interface_indexes():
    # Getting the interface indexes in a portable manner
    # would be better, but there's difficulty from a python perspective.
    # For now be linux specific
    try:
        for iface in os.listdir('/sys/class/net/'):
            if not os.path.exists('/sys/class/net/{0}/ifindex'.format(iface)):
                continue
            if os.path.exists('/sys/class/net/{0}/bonding_slave'.format(iface)):
                continue
            ifile = open('/sys/class/net/{0}/ifindex'.format(iface), 'r')
            intidx = int(ifile.read())
            ifile.close()
            yield intidx
    except (IOError, OSError):
        # Probably situation is non-Linux, just do limited support for
        # such platforms until other people come along
        for iface in netifaces.interfaces():
            addrinfo = netifaces.ifaddresses(iface).get(socket.AF_INET6, [])
            for addr in addrinfo:
                v6addr = addr.get('addr', '').partition('%')[2]
                if v6addr:
                    yield(int(v6addr))
                    break
        return


def get_bmc_subject_san(configmanager, nodename, addnames=()):
    bmc_san = []
    subject = ''
    ipas = set([])
    dnsnames = set([])
    for addname in addnames:
        try:
            ipaddress.ip_address(addname)
            ipas.add(addname)
        except Exception:
            dnsnames.add(addname)
    nodecfg = configmanager.get_node_attributes(nodename,
                                             ('dns.domain', 'hardwaremanagement.manager', 'hardwaremanagement.manager_tls_name'))
    bmcaddr = nodecfg.get(nodename, {}).get('hardwaremanagement.manager', {}).get('value', '')
    domain = nodecfg.get(nodename, {}).get('dns.domain', {}).get('value', '')
    isipv4 = False
    if bmcaddr:
        bmcaddr = bmcaddr.split('/', 1)[0]
        bmcaddr = bmcaddr.split('%', 1)[0]
        dnsnames.add(bmcaddr)
        subject = bmcaddr
        if ':' in bmcaddr:
            ipas.add(bmcaddr)
            dnsnames.add('{0}.ipv6-literal.net'.format(bmcaddr.replace(':', '-')))
        else:
            try:
                socket.inet_aton(bmcaddr)
                isipv4 = True
                ipas.add(bmcaddr)
            except socket.error:
                pass
            if not isipv4: # neither ipv6 nor ipv4, should be a name
                if domain and domain not in bmcaddr:
                    dnsnames.add('{0}.{1}'.format(bmcaddr, domain))
    bmcname = nodecfg.get(nodename, {}).get('hardwaremanagement.manager_tls_name', {}).get('value', '')
    if bmcname:
        subject = bmcname
        dnsnames.add(bmcname)
        if domain and domain not in bmcname:
            dnsnames.add('{0}.{1}'.format(bmcname, domain))
    for dns in dnsnames:
        bmc_san.append('DNS:{0}'.format(dns))
    for ip in ipas:
        bmc_san.append('IP:{0}'.format(ip))
    return subject, ','.join(bmc_san)


def list_ips():
    # Used for getting addresses to indicate the multicast address
    # as well as getting all the broadcast addresses
    if psutil:
        ifas = psutil.net_if_addrs()
        for intf in ifas:
            for addr in ifas[intf]:
                if addr.family == socket.AF_INET and addr.broadcast:
                    yield {'broadcast': addr.broadcast, 'addr': addr.address}
    else:
        for iface in netifaces.interfaces():
            addrs = netifaces.ifaddresses(iface)
            if netifaces.AF_INET in addrs:
                for addr in addrs[netifaces.AF_INET]:
                    yield addr

def randomstring(length=20):
    """Generate a random string of requested length

    :param length: The number of characters to produce, defaults to 20
    """
    chunksize = length // 4
    if length % 4 > 0:
        chunksize += 1
    strval = base64.urlsafe_b64encode(os.urandom(chunksize * 3))
    return stringify(strval[0:length])


def securerandomnumber(low=0, high=4294967295):
    """Return a random number within requested range

    Note that this function will not return smaller than 0 nor larger
    than 2^32-1 no matter what is requested.
    The python random number facility does not provide characteristics
    appropriate for secure rng, go to os.urandom

    :param low: Smallest number to return (defaults to 0)
    :param high: largest number to return (defaults to 2^32-1)
    """
    number = -1
    while number < low or number > high:
        number = struct.unpack("I", os.urandom(4))[0]
    return number


def monotonic_time():
    """Return a monotoc time value

    In scenarios like timeouts and such, monotonic timing is preferred.
    """
    # for now, just support POSIX systems
    return os.times()[4]


def get_certificate_from_file(certfile):
    cert = open(certfile, 'r').read()
    inpemcert = False
    prunedcert = ''
    for line in cert.split('\n'):
        if '-----BEGIN CERTIFICATE-----' in line:
            inpemcert = True
        if inpemcert:
            prunedcert += line
        if '-----END CERTIFICATE-----' in line:
            break
    return ssl.PEM_cert_to_DER_cert(prunedcert)


def get_fingerprint(certificate, algo='sha512'):
    if algo == 'sha256':
        return 'sha256$' + hashlib.sha256(certificate).hexdigest()
    elif algo == 'sha512':
        return 'sha512$' + hashlib.sha512(certificate).hexdigest()
    elif algo == 'sha384':
        return 'sha384$' + hashlib.sha384(certificate).hexdigest()
    raise Exception('Unsupported fingerprint algorithm ' + algo)


hashlens = {
    48: hashlib.sha384,
    64: hashlib.sha512,
    32: hashlib.sha256
}

def cert_matches(fingerprint, certificate):
    if not fingerprint or not certificate:
        return False
    if '$' not in fingerprint:
        fingerprint = base64.b64decode(fingerprint)
        algo = hashlens[len(fingerprint)]
        return algo(certificate).digest() == fingerprint
    algo, _, fp = fingerprint.partition('$')
    newfp = None
    if algo in ('sha512', 'sha256', 'sha384'):
        newfp = get_fingerprint(certificate, algo)
    return newfp and fingerprint == newfp


_polbuilder = None


class TLSCertVerifier(object):
    def __init__(self, configmanager, node, fieldname, subject=None):
        self.cfm = configmanager
        self.node = node
        self.fieldname = fieldname
        self.subject = subject

    def verify_by_ca(self, certificate):
        global _polbuilder
        _polbuilder = None
        if not _polbuilder:
            certs = []
            for cert in glob.glob('/var/lib/confluent/public/site/tls/*.pem'):
                with open(cert, 'rb') as certfile:
                    certs.extend(x509.load_pem_x509_certificates(certfile.read()))
            if not certs:
                return False
            castore = verification.Store(certs)
            _polbuilder = verification.PolicyBuilder().store(castore)
        try:
            addr = ipaddress.ip_address(self.subject)
            subject = x509.IPAddress(addr)
        except ValueError:
            subject = x509.DNSName(self.subject)
        cert = x509.load_der_x509_certificate(certificate)
        _polbuilder.build_server_verifier(subject).verify(cert, [])
        return True
        


    def verify_cert(self, certificate):
        storedprint = self.cfm.get_node_attributes(self.node, (self.fieldname,)
                                                   )
        storedprint = storedprint.get(self.node, {}).get(self.fieldname, {}).get('value', '')
        newpolicy = self.cfm.get_node_attributes(self.node, ('pubkeys.addpolicy',))
        newpolicy = newpolicy.get(self.node, {}).get('pubkeys.addpolicy', {}).get('value', '')
        if (not storedprint):
            # no stored value, check policy for next action
            if newpolicy == 'manual':
                # manual policy means always raise unless a match is set
                # manually
                fingerprint = get_fingerprint(certificate, 'sha256')
                raise cexc.PubkeyInvalid('New certificate detected',
                                         certificate, fingerprint,
                                         self.fieldname, 'newkey')
            if newpolicy in ('', 'tofu'):
                fingerprint = get_fingerprint(certificate, 'sha256')
                auditlog = log.Logger('audit')
                auditlog.log({'node': self.node, 'event': 'certautoadd',
                            'fingerprint': fingerprint})
                tasks.spawn(self.cfm.set_node_attributes(
                    {self.node: {self.fieldname: fingerprint}}))
                return True
        elif cert_matches(storedprint, certificate) and newpolicy != 'ca-only':
            return True
        fingerprint = get_fingerprint(certificate, 'sha256')
        # No pinned certificate match
        if self.subject:
            try:
                if verification and self.verify_by_ca(certificate):
                    auditlog = log.Logger('audit')
                    auditlog.log({'node': self.node, 'event': 'certautoupdate',
                                  'fingerprint': fingerprint})
                    tasks.spawn(self.cfm.set_node_attributes(
                        {self.node: {self.fieldname: fingerprint}}))
                    return True
            except Exception:
                if newpolicy == 'ca-only':
                    raise
        raise cexc.PubkeyInvalid(
            'Mismatched certificate detected', certificate, fingerprint,
            self.fieldname, 'mismatch')

numregex = re.compile('([0-9]+)')

def naturalize_string(key):
    """Analyzes string in a human way to enable natural sort

    :param nodename: The node name to analyze
    :returns: A structure that can be consumed by 'sorted'
    """
    return [int(text) if text.isdigit() else text.lower()
            for text in re.split(numregex, key)]

def natural_sort(iterable):
    """Return a sort using natural sort if possible

    :param iterable:
    :return:
    """
    try:
        return sorted(iterable, key=naturalize_string)
    except TypeError:
        # The natural sort attempt failed, fallback to ascii sort
        return sorted(iterable)
