import csv
import json
import random
import re
import socket
import string
import tempfile
from base64 import b64decode, b64encode
from pathlib import Path
import ipaddress
from subprocess import check_output, CalledProcessError, TimeoutExpired
from yaml import safe_load

from charmhelpers.core import hookenv
from charmhelpers.core.templating import render
from charmhelpers.core import unitdata
from charmhelpers.fetch import apt_install
from charms.reactive import endpoint_from_flag, is_flag_set
from charms.layer import kubernetes_common


AUTH_BACKUP_EXT = 'pre-secrets'
AUTH_BASIC_FILE = '/root/cdk/basic_auth.csv'
AUTH_SECRET_NS = 'kube-system'
AUTH_SECRET_TYPE = 'juju.is/token-auth'
AUTH_TOKENS_FILE = '/root/cdk/known_tokens.csv'
STANDARD_API_PORT = 6443
CEPH_CONF_DIR = Path('/etc/ceph')
CEPH_CONF = CEPH_CONF_DIR / 'ceph.conf'
CEPH_KEYRING = CEPH_CONF_DIR / 'ceph.client.admin.keyring'

db = unitdata.kv()


def get_external_lb_endpoints():
    """
    Return a list of any external API load-balancer endpoints that have
    been manually configured.
    """
    ha_connected = is_flag_set('ha.connected')
    forced_lb_ips = hookenv.config('loadbalancer-ips').split()
    vips = hookenv.config('ha-cluster-vip').split()
    dns_record = hookenv.config('ha-cluster-dns')
    if forced_lb_ips:
        # if the user gave us IPs for the load balancer, assume
        # they know what they are talking about and use that
        # instead of our information.
        return [(address, STANDARD_API_PORT) for address in forced_lb_ips]
    elif ha_connected and vips:
        return [(vip, STANDARD_API_PORT) for vip in vips]
    elif ha_connected and dns_record:
        return [(dns_record, STANDARD_API_PORT)]
    else:
        return []


def get_lb_endpoints():
    """
    Return all load-balancer endpoints, whether from manual config or via
    relation.
    """
    external_lb_endpoints = get_external_lb_endpoints()
    loadbalancer = endpoint_from_flag('loadbalancer.available')

    if external_lb_endpoints:
        return external_lb_endpoints
    elif loadbalancer:
        lb_addresses = loadbalancer.get_addresses_ports()
        return [(host.get('public-address'), host.get('port'))
                for host in lb_addresses]
    else:
        return []


def get_api_endpoint(relation=None):
    """
    Determine the best endpoint for a client to connect to.

    If a relation is given, it will take that into account when choosing an
    endpoint.
    """
    endpoints = get_lb_endpoints()
    if endpoints:
        # select a single endpoint based on our local unit number
        return endpoints[kubernetes_common.get_unit_number() % len(endpoints)]
    elif relation:
        ingress_address = hookenv.ingress_address(relation.relation_id,
                                                  hookenv.local_unit())
        return (ingress_address, STANDARD_API_PORT)
    else:
        return (hookenv.unit_public_ip(), STANDARD_API_PORT)


def install_ceph_common():
    """Install ceph-common tools.

    :return: None
    """
    ceph_admin = endpoint_from_flag('ceph-storage.available')

    ceph_context = {
        'mon_hosts': ceph_admin.mon_hosts(),
        'fsid': ceph_admin.fsid(),
        'auth_supported': ceph_admin.auth(),
        'use_syslog': 'true',
        'ceph_public_network': '',
        'ceph_cluster_network': '',
        'loglevel': 1,
        'hostname': socket.gethostname(),
    }
    # Install the ceph common utilities.
    apt_install(['ceph-common'], fatal=True)

    CEPH_CONF_DIR.mkdir(exist_ok=True, parents=True)
    # Render the ceph configuration from the ceph conf template.
    render('ceph.conf', str(CEPH_CONF), ceph_context)

    # The key can rotate independently of other ceph config, so validate it.
    try:
        with open(str(CEPH_KEYRING), 'w') as key_file:
            key_file.write("[client.admin]\n\tkey = {}\n".format(
                ceph_admin.key()))
    except IOError as err:
        hookenv.log("IOError writing admin.keyring: {}".format(err))


def query_cephfs_enabled():
    install_ceph_common()
    try:
        out = check_output(['ceph', 'mds', 'versions',
                            '-c', str(CEPH_CONF)], timeout=60)
        return bool(json.loads(out.decode()))
    except CalledProcessError:
        hookenv.log('Unable to determine if CephFS is enabled', 'ERROR')
        return False
    except TimeoutExpired:
        hookenv.log('Timeout attempting to determine if CephFS is enabled', "ERROR")
        return False


def get_cephfs_fsname():
    install_ceph_common()
    try:
        data = json.loads(check_output(['ceph', 'fs', 'ls', '-f', 'json'], timeout=60))
    except TimeoutExpired:
        hookenv.log('Timeout attempting to determine fsname', "ERROR")
        return None
    for fs in data:
        if 'ceph-fs_data' in fs['data_pools']:
            return fs['name']


def deprecate_auth_file(auth_file):
    """
    In 1.19+, file-based authentication was deprecated in favor of webhook
    auth. Write out generic files that inform the user of this.
    """
    csv_file = Path(auth_file)
    csv_file.parent.mkdir(exist_ok=True)

    csv_backup = Path('{}.{}'.format(csv_file, AUTH_BACKUP_EXT))
    if csv_file.exists() and not csv_backup.exists():
        csv_file.rename(csv_backup)
    with csv_file.open('w') as f:
        f.write('# File-based authentication was removed in Charmed Kubernetes 1.19\n')


def migrate_auth_file(filename):
    '''Create secrets or known tokens depending on what file is being migrated.'''
    with open(str(filename), 'r') as f:
        rows = list(csv.reader(f))

    for row in rows:
        try:
            if row[0].startswith('#'):
                continue
            else:
                if filename == AUTH_BASIC_FILE:
                    create_known_token(*row)
                elif filename == AUTH_TOKENS_FILE:
                    create_secret(*row)
                else:
                    # log and return if we don't recognize the auth file
                    hookenv.log('Unknown auth file: {}'.format(filename))
                    return False
        except IndexError:
            pass
    deprecate_auth_file(filename)
    return True


def generate_rfc1123(length=10):
    '''Generate a random string compliant with RFC 1123.

    https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#dns-subdomain-names

    param: length - the length of the string to generate
    '''
    length = 253 if length > 253 else length
    first_last_opts = string.ascii_lowercase + string.digits
    middle_opts = first_last_opts + '-' + '.'

    # ensure first and last chars are alphanum
    length -= 2
    rand_str = (
        random.SystemRandom().choice(first_last_opts) +
        ''.join(random.SystemRandom().choice(middle_opts) for _ in range(length)) +
        random.SystemRandom().choice(first_last_opts)
    )
    return rand_str


def token_generator(length=32):
    '''Generate a random token for use in account tokens.

    param: length - the length of the token to generate
    '''
    alpha = string.ascii_letters + string.digits
    token = ''.join(random.SystemRandom().choice(alpha) for _ in range(length))
    return token


def create_known_token(token, username, user, groups=None):
    known_tokens = Path(AUTH_TOKENS_FILE)
    known_tokens.parent.mkdir(exist_ok=True)
    csv_fields = ['token', 'username', 'user', 'groups']

    try:
        with known_tokens.open('r') as f:
            tokens_by_user = {r['user']: r for r in csv.DictReader(f, csv_fields)}
    except FileNotFoundError:
        tokens_by_user = {}
    tokens_by_username = {r['username']: r for r in tokens_by_user.values()}

    if user in tokens_by_user:
        record = tokens_by_user[user]
    elif username in tokens_by_username:
        record = tokens_by_username[username]
    else:
        record = tokens_by_user[user] = {}
    record.update({
        'token': token,
        'username': username,
        'user': user,
        'groups': groups,
    })

    if not record['groups']:
        del record['groups']

    with known_tokens.open('w') as f:
        csv.DictWriter(f, csv_fields, lineterminator='\n').writerows(
            tokens_by_user.values())


def create_secret(token, username, user, groups=None):
    # secret IDs must be unique and rfc1123 compliant
    sani_name = re.sub('[^0-9a-z.-]+', '-', user.lower())
    secret_id = 'auth-{}-{}'.format(sani_name, generate_rfc1123(10))
    # The authenticator expects tokens to be in the form user::token
    token_delim = '::'
    if token_delim not in token:
        token = '{}::{}'.format(user, token)

    context = {
        'type': AUTH_SECRET_TYPE,
        'secret_name': secret_id,
        'secret_namespace': AUTH_SECRET_NS,
        'user': b64encode(user.encode('UTF-8')).decode('utf-8'),
        'username': b64encode(username.encode('UTF-8')).decode('utf-8'),
        'password': b64encode(token.encode('UTF-8')).decode('utf-8'),
        'groups': b64encode(groups.encode('UTF-8')).decode('utf-8') if groups else ''
    }
    with tempfile.NamedTemporaryFile() as tmp_manifest:
        render(
            'cdk.master.auth-webhook-secret.yaml', tmp_manifest.name, context=context)

        if kubernetes_common.kubectl_manifest('apply', tmp_manifest.name):
            hookenv.log("Created secret for {}".format(username))
            return True
        else:
            hookenv.log("WARN: Unable to create secret for {}".format(username))
            return False


def delete_secret(secret_id):
    '''Delete a given secret id.'''
    # If this fails, it's most likely because we're trying to delete a secret
    # that doesn't exist. Let the caller decide if failure is a problem.
    return kubernetes_common.kubectl_success(
        'delete', 'secret', '-n', AUTH_SECRET_NS, secret_id)


def get_csv_password(csv_fname, user):
    """Get the password for the given user within the csv file provided."""
    root_cdk = '/root/cdk'
    tokens_fname = Path(root_cdk) / csv_fname
    if not tokens_fname.is_file():
        return None
    with tokens_fname.open('r') as stream:
        for line in stream:
            record = line.split(',')
            try:
                if record[1] == user:
                    return record[0]
            except IndexError:
                # probably a blank line or comment; move on
                continue
    return None


def get_secret_password(username):
    """Get the password for the given user from the secret that CK created."""
    try:
        output = kubernetes_common.kubectl(
            'get', 'secrets', '-n', AUTH_SECRET_NS,
            '--field-selector', 'type={}'.format(AUTH_SECRET_TYPE),
            '-o', 'json').decode('UTF-8')
    except CalledProcessError:
        # NB: apiserver probably isn't up. This can happen on boostrap or upgrade
        # while trying to build kubeconfig files. If we need the 'admin' token during
        # this time, pull it directly out of the kubeconfig file if possible.
        token = None
        if username == 'admin':
            admin_kubeconfig = Path('/root/.kube/config')
            if admin_kubeconfig.exists():
                with admin_kubeconfig.open('r') as f:
                    data = safe_load(f)
                    try:
                        token = data['users'][0]['user']['token']
                    except (KeyError, ValueError):
                        pass
        return token
    except FileNotFoundError:
        # New deployments may ask for a token before the kubectl snap is installed.
        # Give them nothing!
        return None

    secrets = json.loads(output)
    if 'items' in secrets:
        for secret in secrets['items']:
            try:
                data_b64 = secret['data']
                password_b64 = data_b64['password'].encode('UTF-8')
                username_b64 = data_b64['username'].encode('UTF-8')
            except (KeyError, TypeError):
                # CK authn secrets will have populated 'data', but not all secrets do
                continue

            password = b64decode(password_b64).decode('UTF-8')
            secret_user = b64decode(username_b64).decode('UTF-8')
            if username == secret_user:
                return password
    return None


try:
    ipaddress.IPv4Network.subnet_of
except AttributeError:
    # Returns True if a is subnet of b
    # This method is copied from cpython as it is available only from
    # python 3.7
    # https://github.com/python/cpython/blob/3.7/Lib/ipaddress.py#L1000
    def _is_subnet_of(a, b):
        try:
            # Always false if one is v4 and the other is v6.
            if a._version != b._version:
                raise TypeError("{} and {} are not of the same version".format(
                    a, b))
            return (b.network_address <= a.network_address and
                    b.broadcast_address >= a.broadcast_address)
        except AttributeError:
            raise TypeError("Unable to test subnet containment "
                            "between {} and {}".format(a, b))
    ipaddress.IPv4Network.subnet_of = _is_subnet_of
    ipaddress.IPv6Network.subnet_of = _is_subnet_of


def is_service_cidr_expansion():
    service_cidr_from_db = db.get('kubernetes-master.service-cidr')
    service_cidr_from_config = hookenv.config('service-cidr')
    if not service_cidr_from_db:
        return False

    # Do not consider as expansion if both old and new service cidr are same
    if service_cidr_from_db == service_cidr_from_config:
        return False

    current_networks = kubernetes_common.get_networks(service_cidr_from_db)
    new_networks = kubernetes_common.get_networks(service_cidr_from_config)
    if len(current_networks) != len(new_networks) or \
       not all(cur.subnet_of(new) for cur, new in zip(current_networks,
                                                      new_networks)):
        hookenv.log("WARN: New k8s service cidr not superset of old one")
        return False

    return True


def service_cidr():
    ''' Return the charm's service-cidr config'''
    frozen_cidr = db.get('kubernetes-master.service-cidr')
    return frozen_cidr or hookenv.config('service-cidr')


def freeze_service_cidr():
    ''' Freeze the service CIDR. Once the apiserver has started, we can no
    longer safely change this value. '''
    frozen_service_cidr = db.get('kubernetes-master.service-cidr')
    if not frozen_service_cidr or is_service_cidr_expansion():
        db.set('kubernetes-master.service-cidr', hookenv.config(
            'service-cidr'))


def get_preferred_service_network(service_cidrs):
    '''Get the network preferred for cluster service, preferring IPv4'''
    net_ipv4 = kubernetes_common.get_ipv4_network(service_cidrs)
    net_ipv6 = kubernetes_common.get_ipv6_network(service_cidrs)
    return net_ipv4 or net_ipv6


def get_dns_ip():
    return kubernetes_common.get_service_ip('kube-dns',
                                            namespace='kube-system')


def get_kubernetes_service_ips():
    '''Get the IP address(es) for the kubernetes service based on the cidr.'''
    return [next(network.hosts()).exploded
            for network in kubernetes_common.get_networks(service_cidr())]


def get_snap_revs(snaps):
    '''Get a dict of snap revisions for a given list of snaps.'''
    channel = hookenv.config('channel')
    rev_info = {}
    for s in sorted(snaps):
        try:
            # valid info should looke like:
            #  ...
            #  channels:
            #    latest/stable:    1.18.8         2020-08-27 (1595) 22MB classic
            #    latest/candidate: 1.18.8         2020-08-27 (1595) 22MB classic
            #  ...
            info = check_output(['snap', 'info', s]).decode('utf8', errors='ignore')
        except CalledProcessError:
            # If 'snap info' fails for whatever reason, just empty the info
            info = ''
        snap_rev = None
        yaml_data = safe_load(info)
        if yaml_data and 'channels' in yaml_data:
            try:
                # valid data should look like:
                #  ['1.18.8', '2020-08-27', '(1604)', '21MB', 'classic']
                d = yaml_data['channels'][channel].split()
                snap_rev = d[2].strip("()")
            except (KeyError, IndexError):
                hookenv.log('Could not determine revision for snap: {}'.format(s),
                            level=hookenv.WARNING)
        rev_info[s] = snap_rev
    return rev_info
