# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright 2016 Continuum Analytics, Inc.
#
# May be copied and distributed freely only as part of an Anaconda or
# Miniconda installation.
# -----------------------------------------------------------------------------
import datetime
import json
import os
import stat
import sys
import tarfile
import time
from urllib.parse import quote_plus, urljoin, urlparse
from uuid import uuid4

import click
import requests
import requests.packages.urllib3
from requests import Session
from requests.auth import AuthBase
from requests.exceptions import ConnectionError, SSLError
from requests.utils import guess_filename

from anaconda_enterprise.cli.appdirs import AppDirs, EnvAppDirs
from anaconda_enterprise.cli.internal import (ClientError, JSONAPIError,
                                              ServerError)
from anaconda_enterprise.cli.multipart import MultipartEncoder

# Hide warnings when ssl_verify is set to false
requests.packages.urllib3.disable_warnings(
    requests.packages.urllib3.exceptions.InsecureRequestWarning)

JSON_API_MEDIA_TYPE = 'application/vnd.api+json'
EPOCH = datetime.datetime(1970, 1, 1)
HAS_FCHMOD = hasattr(os, 'fchmod')
RETRIES = 5


def _list(headers, keys, content, action=None, what=None, plural=None):
    lens = {}
    _format = []
    if content and not isinstance(content[0], dict):
        content = [dict(zip(keys, c)) for c in content]
    for header, key in zip(headers, keys):
        lens[key] = max(0, len(header), *(len(c[key]) for c in content))
        _format.append('{{{}:{}}}'.format(key, lens[key]))
    _format = '  '.join(_format)
    click.echo(_format.format(**{k: v for k, v in zip(keys, headers)}))
    click.echo(_format.format(**{k: '=' * v for k, v in lens.items()}))
    for line in content:
        click.echo(_format.format(**line))
    if what is not None:
        _file_action(len(content), action=action, what=what, plural=plural)


def _file_action(count, action=None, what=None, plural=None):
    click.echo('{} {}{} {}.'.format(count, what or 'file',
                                    '' if count == 1 else (plural or 's'),
                                    action or 'found'))


def _get_binstar_token_directory():
    if 'BINSTAR_CONFIG_DIR' in os.environ:
        return EnvAppDirs('binstar', 'ContinuumIO',
                          os.environ[str('BINSTAR_CONFIG_DIR')]).user_data_dir
    else:
        return AppDirs('binstar', 'ContinuumIO').user_data_dir


class TokenStore(object):
    def __init__(self, path):
        self.path = path
        try:
            with open(path, 'rb') as fd:
                self.data = json.loads(fd.read().decode('utf-8'))
        except (EnvironmentError, ValueError):
            self.data = {}

    def save(self):
        try:
            # exist_ok=True only exists on python3
            os.makedirs(os.path.dirname(self.path))
        except EnvironmentError:
            pass
        with open(self.path, 'wb') as fd:
            # Make sure that the file has the correct permissions
            if HAS_FCHMOD:
                os.fchmod(fd.fileno(), stat.S_IRUSR | stat.S_IWUSR)
            fd.write(
                json.dumps(self.data, indent=2,
                           sort_keys=True).encode('utf-8'))

        self._save_binstar_tokens()

    def _save_binstar_tokens(self):
        """Save tokens to conda 4.3-compatible location
        """
        binstar_dir = _get_binstar_token_directory()
        try:
            # exist_ok=True only exists on python3
            os.makedirs(binstar_dir)
        except EnvironmentError:
            pass
        for url, session in self.data.items():
            url_safe = quote_plus(url)
            try:
                token = session['bearer_token']
                path = os.path.join(binstar_dir, url_safe + '.token')
                with open(path, 'wb') as fd:
                    # Make sure that the file has the correct permissions
                    if HAS_FCHMOD:
                        os.fchmod(fd.fileno(), stat.S_IRUSR | stat.S_IWUSR)
                    fd.write(token.encode('utf-8'))
            except KeyError:
                continue

    def delete_binstar_tokens(self):
        """Remove tokens from conda 4.3-compatible location."""
        binstar_dir = _get_binstar_token_directory()
        for url, session in self.data.items():
            path = os.path.join(binstar_dir, quote_plus(url) + '.token')
            if os.path.exists(path):
                os.remove(path)

    def set_session(self, url, session):
        self.data[url] = session

    def get_session(self, url):
        session = None
        for domain in sorted(self.data, key=len, reverse=True):
            if url.startswith(domain):
                session = self.data[domain]
                if 'bearer_token' not in session:
                    del self.data[domain]
                    self.save()
                    session = None
                break

        return session


class BearerAuthentication(AuthBase):
    def __init__(self, token_store, url):
        self.token_store = token_store
        self.url = url

    def __call__(self, r):
        session = self.token_store.get_session(self.url)
        if session:
            r.headers['Authorization'] = 'Bearer {0}'.format(
                session['bearer_token'])
        return r


class JSONAPILink(object):
    def __init__(self, url, meta):
        self.url = url
        self.meta = meta

    @staticmethod
    def parse(link):
        if isinstance(link, dict):
            return JSONAPILink(link['url'], link.get('meta', {}))
        else:
            return JSONAPILink(link, {})


class ApiClient(object):
    def __init__(self, config, token_store):
        self.url = config['url'].rstrip('/')
        self.url_s = self.url + '/'
        self.token_store = token_store
        self.session = Session()
        self.session.verify = config['ssl_verify']
        self.session.auth = BearerAuthentication(token_store, self.url)
        self.session.headers['Connection'] = 'Keep-Alive'
        self.session.headers['Content-Type'] = JSON_API_MEDIA_TYPE
        self.session.headers['Accept'] = JSON_API_MEDIA_TYPE
        self.file_session = Session()
        self.file_session.verify = config['ssl_verify']
        self.file_session.headers['Connection'] = 'Keep-Alive'
        self.links = {}

    def make_absolute(self, url):
        relative = not urlparse(url).netloc
        if relative:
            url = urljoin(self.url, url)
        return url

    def _request(self, url, *args, **kwargs):
        method = kwargs.pop('method', 'GET')
        if not url:
            url = self.url
            session = self.session
        elif not ('.' in urlparse(url).netloc or 'headers' in kwargs):
            url = urljoin(self.url_s, url)
            session = self.session
        else:
            # If an external URL is used or headers are supplied
            # we do not transmit our bearer token
            session = self.file_session
        for attempt in range(RETRIES):
            try:
                response = session.request(method, url, *args, **kwargs)
                response.raise_for_status()
            except (SSLError, ConnectionError) as exc:
                what = 'SSL' if isinstance(exc, SSLError) else 'connection'
                if attempt == RETRIES - 1:
                    msg = 'Persistent {} error:\n- {} {}\n- ssl_verify: {}\n- exception: {}'
                    raise ServerError(
                        msg.format(what, method, url, self.session.verify,
                                   exc))
                if attempt == 0:
                    sys.stderr.write('- {} {}\n'.format(method, url))
                msg = 'Warning: transient {} error encountered; retrying ({}/{})\n'
                sys.stderr.write(msg.format(what, attempt + 1, RETRIES))
                time.sleep(0.5 * (attempt + 1))
                continue
            except requests.HTTPError as exc:
                msg = 'Unexpected server error {} ({})\n- {} {}'
                msg = msg.format(exc.response.status_code, exc.response.reason,
                                 method, url)
                try:
                    detail = exc.response.json()['errors'][0]['detail']
                    if detail.startswith('HTTP {}:'.format(
                            exc.response.status_code)):
                        detail = detail.split(':', 1)[1].lstrip()
                    msg += '\n' + detail.capitalize()
                except Exception:
                    pass
                raise ServerError(msg)
            except Exception as exc:
                msg = 'Unexpected operation error:\n- {} {}\n- exception: {}'
                raise ServerError(msg.format(method, url, exc))
            if attempt:
                sys.stderr.write(
                    'Retry loop succeeded; no further action needed.\n')
            return response

    def _json_api(self, url, *args, **kwargs):
        field = kwargs.pop('field', 'data')
        if 'data' in kwargs:
            kwargs['data'] = json.dumps(kwargs['data']).encode('utf-8')
        response = self._request(url, *args, **kwargs)
        return self._parse_json_api(response, field=field)

    def _parse_json_api(self, response, field='field'):
        if response.status_code in (404, 204):
            return None

        try:
            body = response.json()
        except (json.decoder.JSONDecodeError) as exc:
            msg = 'Error decoding JSON from API server:\n- exception: {}\n- url: {}'
            raise ServerError(msg.format(exc, response.url))

        if not any(x in body for x in ('data', 'errors', 'meta')):
            raise ServerError(
                'Non-conformant JSONAPI response (missing expected data, meta, or links)'
            )

        if 'errors' in body:
            raise JSONAPIError(body['errors'])

        if 'links' in body:
            try:
                body['links'] = {
                    k: JSONAPILink.parse(v)
                    for k, v in body['links'].items()
                }
            except Exception as ex:
                msg = ('Non-conformant JSONAPI response: invalid links:\n'
                       '- exception: {}\n- value: {}')
                raise ServerError(msg.format(ex, body['links']))

        if 'data' in body:
            data = body['data']
            resources = data if isinstance(data, list) else [data]
            if any('id' not in resource or 'type' not in resource
                   for resource in resources):
                msg = 'Non-conformant JSONAPI response: invalid resources:\n- value: {}'
                raise ServerError(msg.format(data))

        if field == 'body':
            return body
        if field not in body:
            raise ServerError(
                'Non-conformant JSONAPI response: missing field "{}"'.format(
                    field))
        return body[field]

    def login(self, username, password):
        url = self.links['session'].url
        meta = self._json_api(url,
                              method='POST',
                              auth=(username, password),
                              field='meta')
        session = {'bearer_token': meta['bearer_token']}
        self.token_store.set_session(self.url, session)
        self.token_store.save()

    def check(self):
        self.links = self._json_api('', field='links')

    def channels(self):
        return self._json_api('channels')

    def create_channel(self, name):
        resource = {
            "data": {
                "type": "channel",
                "attributes": {
                    "name": name,
                }
            }
        }
        return self._json_api('channels', method='POST', data=resource)

    def get_channel(self, name):
        for chan in self.channels():
            if chan['attributes']['name'] == name:
                return chan
        return None

    def get_channel_acls(self, name):
        channel = self.get_channel(name)
        if channel is not None:
            return self._json_api('channels/{id}/acl'.format(id=channel['id']))
        return None

    def share_channel(self, channel, name, namespace, permission):
        self._json_api('acls',
                       method='POST',
                       data={
                           'data': {
                               'type': 'acl',
                               'attributes': {
                                   'name': name,
                                   'namespace': namespace,
                                   'permission': permission,
                               },
                               'relationships': {
                                   'channel': {
                                       'data': {
                                           'type': 'channel',
                                           'id': channel['id'],
                                       }
                                   }
                               }
                           },
                       })

    def find_channel_acl(self, channel_name, name, namespace):
        acls = self.get_channel_acls(channel_name)
        for acl in acls:
            if acl['attributes']['name'] == name and acl['attributes'][
                    'namespace'] == namespace:
                return acl
        return None

    def remove_shared_channel(self, channel_name, name, namespace):
        channel_acl = self.find_channel_acl(channel_name, name, namespace)
        if channel_acl is None:
            raise ClientError(
                "Channel ACL not found for {channel_name}, {name}, {namespace}"
                .format(channel_name=channel_name,
                        name=name,
                        namespace=namespace))
        self._json_api('acls/{id}'.format(id=channel_acl['id']),
                       method='DELETE')

    def delete_channel(self, channel):
        self._json_api('channels/{id}'.format(id=channel['id']),
                       method='DELETE')

    def stage_file(self, channel, file_info):
        channel_id = channel['id']
        resource = {
            "data": {
                "type": "upload",
                "attributes": file_info,
                "relationships": {
                    "channel": {
                        "data": {
                            "type": "channel",
                            "id": channel_id,
                        }
                    }
                }
            }
        }
        return self._json_api('uploads', method='POST', data=resource)

    def upload_file(self, upload_info, fd):
        upload = JSONAPILink.parse(upload_info['links']['upload'])
        commit = JSONAPILink.parse(upload_info['links']['commit'])

        url = upload.url
        fields = upload.meta.get('fields', {})
        headers = upload.meta.get('headers', {})
        authorization = upload.meta.get('authorization', 'none')
        if authorization == 'bearer-token':
            headers['Authorization'] = 'Bearer {0}'.format(
                self.token_store.get_session(self.url)['bearer_token'])

        fields = list(fields.items())
        response = self._upload_file(fd, fields, headers, url)

        if 200 <= response.status_code < 300:
            return self._json_api(commit.url, method='POST')
        else:
            raise ServerError(
                'Failed to upload file (server responded with {0}:{1})'.format(
                    response.status_code,
                    response.content,
                ))

    def upload_icon(self, upload_info, pkg):
        """
        Upload package icon file to the object store if it exists.
        """
        upload = JSONAPILink.parse(upload_info['links']['icon-upload'])
        url = upload.url
        fields = upload.meta.get('fields', {})
        fields = list(fields.items())

        headers = upload.meta.get('headers', {})
        authorization = upload.meta.get('authorization', 'none')
        if authorization == 'bearer-token':
            headers['Authorization'] = 'Bearer {0}'.format(
                self.token_store.get_session(self.url)['bearer_token'])

        response = None
        with tarfile.open(pkg, mode="r|bz2") as tf:
            for info in tf:
                if info.name == 'info/icon.png':
                    icon_fd = tf.extractfile(info)
                    response = self._upload_file(
                        WrapperFile(icon_fd, info.size), fields, headers, url)

        if response and (response.status_code < 200
                         or response.status_code >= 300):
            raise ServerError(
                'Failed to upload icon (server responded with {0}:{1})'.format(
                    response.status_code,
                    response.content,
                ))

    def _upload_file(self, fd, fields, headers, url):
        """Upload file to s3."""
        filename = guess_filename(fd) or 'file'
        file_field = ('file', (filename, fd))
        fields.append(file_field)

        encoder = MultipartEncoder(fields)
        headers['Content-Type'] = encoder.content_type

        return self._request(url, method='POST', data=encoder, headers=headers)

    def list_files(self, package):
        return self._json_api('packages/{id}/files'.format(id=package['id']),
                              method='GET')

    def delete_file(self, file_id):
        return self._json_api('files/{id}'.format(id=file_id), method='DELETE')

    def get_file(self, package, subdir, filename):
        all_files = self._json_api(
            'packages/{id}/files'.format(id=package['id']), method='GET')
        # Iterate through all files inside the package and find the right one.
        matches = [
            file_ for file_ in all_files
            if (file_['attributes']['filename'] == filename
                and file_['attributes']['subdir'] == subdir)
        ]

        if not matches:
            package_name = package['attributes']['name']
            msg = 'File \'{0}\' was not found for package \'{1}\''
            raise ServerError(msg.format(filename, package_name))
        elif len(matches) > 1:
            msg = 'More than one file was found'
            raise ServerError(msg)
        else:
            return matches.pop()

    def delete_package(self, package, subdir=None, filename=None):
        if subdir and subdir not in package['attributes']['subdirs']:
            raise ServerError("Package does not have {}".format(subdir))

        if subdir and filename:
            file_ = self.get_file(package, subdir, filename)
            return self._json_api('files/{id}'.format(id=file_['id']),
                                  method='DELETE')

        return self._json_api('packages/{id}'.format(id=package['id']),
                              method='DELETE')

    def packages(self):
        return self._json_api('packages')

    def get_package(self, channel, package):
        # TODO replace this with a better search
        for pkg in self.packages():
            if pkg['attributes']['name'] == package and \
               pkg['attributes']['channel-name'] == channel:
                return pkg
        raise ServerError('Package "{}/{}" was not found'.format(
            channel, package))

    def copy_pkg_file(self, channel_src, subdir, filename, channel_dst_id):
        url = 'channels/{0}'.format(channel_dst_id)
        resource = {
            "data": {
                "source": {
                    "channel": channel_src,
                    "subdir": subdir,
                    "filename": filename,
                }
            }
        }
        return self._json_api(url, data=resource, method='PUT')

    def copy_pkg(self, channel_src, pkg_src, channel_dst_id):
        url = 'channels/{0}'.format(channel_dst_id)
        resource = {
            "data": {
                "source": {
                    "channel": channel_src,
                    "package": pkg_src,
                }
            }
        }
        return self._json_api(url, data=resource, method='PUT')

    def create_pypi_index(self, name):
        resource = {
            "data": {
                "type": "pypi_index",
                "attributes": {
                    "name": name,
                }
            }
        }
        return self._json_api('pypi_indexes', method='POST', data=resource)

    def update_pypi_index(self, pypi_index, attributes):
        resource = {
            "data": {
                "type": "pypi_index",
                "attributes": attributes,
                "id": pypi_index["id"]
            }
        }
        return self._json_api('pypi_indexes/{id}'.format(id=pypi_index['id']),
                              method='PATCH',
                              data=resource)

    def pypi_indexes(self):
        return self._json_api('pypi_indexes', method='GET')

    def get_pypi_index(self, name):
        for index in self.pypi_indexes():
            if index['attributes']['name'] == name:
                return index

        raise ClientError("PyPI index named '{}' not found".format(name))

    def get_pypi_index_acls(self, name):
        pypi_index = self.get_pypi_index(name)
        return self._json_api(
            'pypi_indexes/{id}/acl'.format(id=pypi_index['id']))

    def create_pypi_index_acl(self, pypi_index, name, namespace, permission):
        return self._json_api('pypi_acls',
                              method='POST',
                              data={
                                  'data': {
                                      'type': 'pypi_index_acl',
                                      'attributes': {
                                          'name': name,
                                          'namespace': namespace,
                                          'permission': permission,
                                      },
                                      'relationships': {
                                          'pypi-index': {
                                              'data': {
                                                  'type': 'pypi_index',
                                                  'id': pypi_index['id'],
                                              }
                                          }
                                      }
                                  },
                              })

    def get_pypi_index_acl(self, pypi_index_name, name, namespace):
        acls = self.get_pypi_index_acls(pypi_index_name)
        for acl in acls:
            if acl['attributes']['name'] == name and acl['attributes'][
                    'namespace'] == namespace:
                return acl

        raise ClientError("PyPI index ACL not found for {}, {}, {}".format(
            pypi_index_name, name, namespace))

    def get_pypi_packages(self, pypi_index=None):
        if pypi_index is None:
            url = 'pypi_packages'
        else:
            url = 'pypi_indexes/{id}/packages'.format(id=pypi_index['id'])
        return self._json_api(url)

    def delete_pypi_package(self, package):
        return self._json_api('pypi_packages/{id}'.format(id=package['id']),
                              method='DELETE')

    def delete_pypi_file(self, package_file):
        return self._json_api('pypi_files/{id}'.format(id=package_file['id']),
                              method='DELETE')

    def delete_pypi_index_acl(self, pypi_index_acl):
        return self._json_api('pypi_acls/{id}'.format(id=pypi_index_acl['id']),
                              method='DELETE')

    def delete_pypi_index(self, pypi_index):
        return self._json_api('pypi_indexes/{id}'.format(id=pypi_index['id']),
                              method='DELETE')

    def get_pypi_files(self, pypi_package=None):
        if pypi_package is None:
            url = 'pypi_files'
        else:
            url = 'pypi_packages/{id}/files'.format(id=pypi_package['id'])
        return self._json_api(url)

    def stage_pypi_file(self, pypi_index, file_info):
        resource = {
            "data": {
                "type": "pypi_package_upload",
                "attributes": file_info,
                "relationships": {
                    "pypi-index": {
                        "data": {
                            "type": "pypi_index",
                            "id": pypi_index['id'],
                        }
                    }
                }
            }
        }
        return self._json_api('pypi_uploads', method='POST', data=resource)

    def put_global_file(self, file_path, fd, metadata=None):
        root = self.links['global-files'].url
        url = root + file_path
        metadata = '{}' if metadata is None else json.dumps(metadata)
        headers = self.session.headers.copy()
        headers.update(Metadata=metadata)

        response = self._request(url,
                                 method='PUT',
                                 headers=headers,
                                 allow_redirects=False)
        if 200 <= response.status_code < 300:
            return None
        elif 300 <= response.status_code < 400:
            if 'Location' not in response.headers:
                raise ServerError(
                    'Server redirected without specifying a Location header')
            self._request(response.headers['Location'], method='PUT', data=fd)
        else:
            return self._parse_json_api(response)

    def put_public_file(self, file_path, fd, metadata=None):
        """
        Upload a file into the world-accessible special area of Storage

        :param file_path: real local file
        :param metadata: dict of data, like absolute URLs of contained packages
        :param fd: open file pointing to the
        :return: dict, like new database entry
        """
        root = self.links['public-files'].url
        url = root + file_path
        metadata = '{}' if metadata is None else json.dumps(metadata)
        headers = self.session.headers.copy()
        headers.update(Metadata=metadata)

        response = self._request(url,
                                 method='PUT',
                                 headers=headers,
                                 allow_redirects=False)
        if 200 <= response.status_code < 300:
            return None
        elif 300 <= response.status_code < 400:
            if 'Location' not in response.headers:
                raise ServerError(
                    'Server redirected without specifying a Location header')
            self._request(response.headers['Location'], method='PUT', data=fd)
        else:
            return self._parse_json_api(response)

    def stage_installer(self, file_info, env_spec_id=None):
        resource = {
            "data": {
                "type": "installer-upload",
                "attributes": file_info,
            }
        }

        if env_spec_id is not None:
            resource['data']['relationships'] = {
                "spec": {
                    "data": {
                        "type": "env_spec",
                        "id": env_spec_id
                    }
                }
            }

        return self._json_api('installer_uploads',
                              method='POST',
                              data=resource)

    def fetch_resource(self, full_url):
        return self._json_api(full_url)

    def store_env_spec_lock(self, env_spec_id, locked_env_specs, meta):
        """
        Creates env spec lock.
        """
        def create_pkg_for_wire(lock_id, pkg, platform):
            """
            Creates the structure required by the handlers/jsonserializer to parse a package
            """
            return {
                'type': 'env_spec_package',
                'id': uuid4().hex,
                'attributes': {
                    'env-spec-lock-id': lock_id,
                    'name': pkg,
                    'platform': platform,
                }
            }

        def create_lock_for_wire(name, lock_id, hash_, packages):
            return {
                'type': 'env_spec_lock',
                'id': lock_id,
                'attributes': {
                    'name': name,
                    'hash': hash_,
                    'spec-id': env_spec_id,  # define as arg of outerfunction
                    'extra-attributes': meta
                },
                'relationships': {
                    'env-spec-packages': {
                        'data': packages
                    }
                }
            }

        locks = []
        for env_spec_name, spec in locked_env_specs["env_specs"].items():
            env_spec_lock_id = uuid4().hex
            packages = []
            for platform, pkg_list in spec["packages"].items():
                for pkg in pkg_list:
                    packages.append(
                        create_pkg_for_wire(env_spec_lock_id, pkg, platform))
            hash_ = spec.get("env_spec_hash", "")
            locks.append(
                create_lock_for_wire(env_spec_name, env_spec_lock_id, hash_,
                                     packages))

        # hack because relationships are not being saved.
        relationships = locks[0].pop('relationships')
        resource = {'data': locks[0]}  # we assume only one spec
        self._json_api('env_spec_locks', method='POST', data=resource)

        resource['data']['relationships'] = relationships
        resource_id = resource['data']['id']
        self._json_api('env_spec_locks/{id}'.format(id=resource_id),
                       method='PATCH',
                       data=resource)


class WrapperFile(object):
    def __init__(self, fd, size):
        self.fd = fd
        self.size = size

    def __getattr__(self, attr):
        return getattr(self.fd, attr)

    def __len__(self):
        return self.size


def create_client(config_manager):
    try:
        site = config_manager.get_site()
    except ValueError as e:
        click.echo('Invalid site: {0}'.format(e))
        return
    config_manager.component = ['sites', site]
    token_store = TokenStore(config_manager.token_path)
    client = ApiClient(config_manager, token_store)
    client.check()
    return client


pass_client = click.make_pass_decorator(ApiClient)
