# -*- coding: utf-8 -*-
#
# Copyright (c) 2017, F5 Networks Inc.
# GNU General Public License v3.0 (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)

from __future__ import absolute_import, division, print_function
__metaclass__ = type


import os

try:
    from StringIO import StringIO
except ImportError:
    from io import StringIO

try:
    from BytesIO import BytesIO
except ImportError:
    from io import BytesIO

from ansible.module_utils.urls import urlparse
from ansible.module_utils.urls import generic_urlparse
from ansible.module_utils.urls import Request

try:
    import json as _json
except ImportError:
    import simplejson as _json

from .common import F5ModuleError


"""An F5 REST API URI handler.

Use this module to make calls to an F5 REST server. It is influenced by the same
API that the Python ``requests`` tool uses, but the two are not the same, as the
library here is **much** more simple and targeted specifically to F5's needs.

The ``requests`` design was chosen due to familiarity with the tool. Internally,
the classes contained herein use Ansible native libraries.

The means by which you should use it are similar to ``requests`` basic usage.

Authentication is not handled for you automatically by this library, however it *is*
handled automatically for you in the supporting F5 module_utils code; specifically the
different product module_util files (bigip.py, bigiq.py, etc).

Internal (non-module) usage of this library looks like this.

```
# Create a session instance
mgmt = iControlRestSession()
mgmt.verify = False

server = '1.1.1.1'
port = 443

# Payload used for getting an initial authentication token
payload = {
  'username': 'admin',
  'password': 'secret',
  'loginProviderName': 'tmos'
}

# Create URL to call, injecting server and port
url = f"https://{server}:{port}/mgmt/shared/authn/login"

# Call the API
resp = session.post(url, json=payload)

# View the response
print(resp.json())

# Update the session with the authentication token
session.headers['X-F5-Auth-Token'] = resp.json()['token']['token']

# Create another URL to call, injecting server and port
url = f"https://{server}:{port}/mgmt/tm/ltm/virtual/~Common~virtual1"

# Call the API
resp = session.get(url)

# View the details of a virtual payload
print(resp.json())
```
"""

from ansible.module_utils.six.moves.urllib.error import HTTPError

from .constants import (
    LOGOUT, BASE_HEADERS
)


class Response(object):
    def __init__(self):
        self._content = None
        self.status = None
        self.headers = dict()
        self.url = None
        self.reason = None
        self.request = None
        self.msg = None

    @property
    def content(self):
        return self._content

    @property
    def raw_content(self):
        return self._content

    def json(self):
        return _json.loads(self._content or 'null')

    @property
    def ok(self):
        if self.status is not None and int(self.status) > 400:
            return False
        try:
            response = self.json()
            if 'code' in response and response['code'] > 400:
                return False
        except ValueError:
            pass
        return True


class iControlRestSession(object):
    """Represents a session that communicates with a BigIP.

    This acts as a loose wrapper around Ansible's ``Request`` class. We're doing
    this as interim work until we move to the httpapi connector.
    """
    def __init__(self, headers=None, use_proxy=True, force=False, timeout=120,
                 validate_certs=True, url_username=None, url_password=None,
                 http_agent=None, force_basic_auth=False, follow_redirects='urllib2',
                 client_cert=None, client_key=None, cookies=None):
        self.request = Request(
            headers=headers,
            use_proxy=use_proxy,
            force=force,
            timeout=timeout,
            validate_certs=validate_certs,
            url_username=url_username,
            url_password=url_password,
            http_agent=http_agent,
            force_basic_auth=force_basic_auth,
            follow_redirects=follow_redirects,
            client_cert=client_cert,
            client_key=client_key,
            cookies=cookies
        )
        self.last_url = None

    def get_headers(self, result):
        try:
            return dict(result.getheaders())
        except AttributeError:
            return result.headers

    def update_response(self, response, result):
        response.headers = self.get_headers(result)
        response._content = result.read()
        response.status = result.getcode()
        response.url = result.geturl()
        response.msg = "OK (%s bytes)" % response.headers.get('Content-Length', 'unknown')

    def send(self, method, url, **kwargs):
        response = Response()

        # Set the last_url called
        #
        # This is used by the object destructor to erase the token when the
        # ModuleManager exits and destroys the iControlRestSession object
        self.last_url = url

        body = None
        data = kwargs.pop('data', None)
        json = kwargs.pop('json', None)

        if not data and json is not None:
            self.request.headers.update(BASE_HEADERS)
            body = _json.dumps(json, ensure_ascii=False)
            if not isinstance(body, bytes):
                body = body.encode('utf-8')
        if data:
            body = data
        if body:
            kwargs['data'] = body

        try:
            result = self.request.open(method, url, **kwargs)
        except HTTPError as e:
            # Catch HTTPError delivered from Ansible
            #
            # The structure of this object, in Ansible 2.8 is
            #
            # HttpError {
            #   args
            #   characters_written
            #   close
            #   code
            #   delete
            #   errno
            #   file
            #   filename
            #   filename2
            #   fp
            #   getcode
            #   geturl
            #   hdrs
            #   headers
            #   info
            #   msg
            #   name
            #   reason
            #   strerror
            #   url
            #   with_traceback
            # }
            self.update_response(response, e)
            return response

        self.update_response(response, result)
        return response

    def delete(self, url, **kwargs):
        return self.send('DELETE', url, **kwargs)

    def get(self, url, **kwargs):
        return self.send('GET', url, **kwargs)

    def patch(self, url, data=None, **kwargs):
        return self.send('PATCH', url, data=data, **kwargs)

    def post(self, url, data=None, **kwargs):
        return self.send('POST', url, data=data, **kwargs)

    def put(self, url, data=None, **kwargs):
        return self.send('PUT', url, data=data, **kwargs)

    def __del__(self):
        if self.last_url is None:
            return
        token = self.request.headers.get('X-F5-Auth-Token', None)
        if not token:
            return
        try:
            p = generic_urlparse(urlparse(self.last_url))
            uri = "https://{0}:{1}{2}{3}".format(
                p['hostname'], p['port'], LOGOUT, token
            )
            self.delete(uri)
        except ValueError:
            pass


class TransactionContextManager(object):
    def __init__(self, client, validate_only=False):
        self.client = client
        self.validate_only = validate_only
        self.transid = None

    def __enter__(self):
        uri = "https://{0}:{1}/mgmt/tm/transaction/".format(
            self.client.provider['server'],
            self.client.provider['server_port']
        )
        resp = self.client.api.post(uri, json={})
        if resp.status not in [200]:
            try:
                response = resp.json()
                if 'message' in response:
                    raise F5ModuleError(
                        'Failed to create transaction: {0}'.format(response['message'])
                    )
            except ValueError:
                pass
            raise F5ModuleError(
                'Failed to create transaction, status: {0}, contents: {1}'.format(
                    resp.status, resp.content
                )
            )
        try:
            response = resp.json()
        except ValueError as ex:
            raise F5ModuleError(str(ex))

        self.transid = response['transId']
        self.client.api.request.headers['X-F5-REST-Coordination-Id'] = self.transid
        return self.client

    def __exit__(self, exc_type, exc_value, exc_tb):
        self.client.api.request.headers.pop('X-F5-REST-Coordination-Id')
        if exc_tb is None:
            uri = "https://{0}:{1}/mgmt/tm/transaction/{2}".format(
                self.client.provider['server'],
                self.client.provider['server_port'],
                self.transid
            )
            params = dict(
                state="VALIDATING",
                validateOnly=self.validate_only
            )
            resp = self.client.api.patch(uri, json=params)
            if resp.status not in [200]:
                try:
                    response = resp.json()
                    if 'message' in response:
                        raise F5ModuleError(
                            'Failed to commit transaction {0}: {1}'.format(
                                self.transid, response['message']
                            )
                        )
                except ValueError:
                    pass
                raise F5ModuleError(
                    'Failed to commit transaction {0}, status: {1}, contents: {2}'.format(
                        self.transid, resp.status, resp.content
                    )
                )


def download_asm_file(client, url, dest, file_size):
    """Download a large ASM file from the remote device

    This method handles issues with ASM file endpoints that allow
    downloads of ASM objects on the BIG-IP, as well as handles
    chunking of large files.

    Arguments:
        client (object): The F5RestClient connection object.
        url (string): The URL to download.
        dest (string): The location on (Ansible controller) disk to store the file.
        file_size (integer): The size of the remote file.

    Returns:
        bool: No response on success. Fail otherwise.
    """

    with open(dest, 'wb') as fileobj:
        chunk_size = 512 * 1024
        start = 0
        end = chunk_size - 1
        size = file_size
        # current_bytes = 0

        while True:
            content_range = "%s-%s/%s" % (start, end, size)
            headers = {
                'Content-Range': content_range,
                'Content-Type': 'application/json'
            }
            data = {
                'headers': headers,
                'verify': False,
                'stream': False
            }

            response = client.api.get(url, headers=headers, json=data)
            if response.status == 200:
                if 'Content-Length' not in response.headers:
                    error_message = "The Content-Length header is not present."
                    raise F5ModuleError(error_message)
                length = response.headers['Content-Length']
                if int(length) > 0:
                    fileobj.write(response.content)
                else:
                    error = "Invalid Content-Length value returned: %s ," \
                            "the value should be greater than 0" % length
                    raise F5ModuleError(error)
                # fileobj.write(response.raw_content)
            if end == size:
                break
            start += chunk_size
            if start >= size:
                break
            if (end + chunk_size) > size:
                end = size - 1
            else:
                end = start + chunk_size - 1


def download_file(client, url, dest):
    """Download a file from the remote device

    This method handles the chunking needed to download a file from
    a given URL on the BIG-IP.

    Arguments:
        client (object): The F5RestClient connection object.
        url (string): The URL to download.
        dest (string): The location on (Ansible controller) disk to store the file.

    Returns:
        bool: True on success. False otherwise.
    """
    with open(dest, 'wb') as fileobj:
        chunk_size = 512 * 1024
        start = 0
        end = chunk_size - 1
        size = 0
        current_bytes = 0

        while True:
            content_range = "%s-%s/%s" % (start, end, size)
            headers = {
                'Content-Range': content_range,
                'Content-Type': 'application/octet-stream'
            }
            data = {
                'headers': headers,
                'verify': False,
                'stream': False
            }
            response = client.api.get(url, headers=headers, json=data)
            if response.status == 200:
                # If the size is zero, then this is the first time through
                # the loop and we don't want to write data because we
                # haven't yet figured out the total size of the file.
                if size > 0:
                    current_bytes += chunk_size
                    fileobj.write(response.raw_content)
            # Once we've downloaded the entire file, we can break out of
            # the loop
            if end == size:
                break
            crange = response.headers['Content-Range']
            # Determine the total number of bytes to read.
            if size == 0:
                size = int(crange.split('/')[-1]) - 1
                # If the file is smaller than the chunk_size, the BigIP
                # will return an HTTP 400. Adjust the chunk_size down to
                # the total file size...
                if chunk_size > size:
                    end = size
                # ...and pass on the rest of the code.
                continue
            start += chunk_size
            if (current_bytes + chunk_size) > size:
                end = size
            else:
                end = start + chunk_size - 1
    return True


def upload_file(client, url, src, dest=None):
    """Upload a file to an arbitrary URL.

    This method is responsible for correctly chunking an upload request to an
    arbitrary file worker URL.

    Arguments:
        client (object): The F5RestClient connection object.
        url (string): The URL to upload a file to.
        src (string): The file to be uploaded.
        dest (string): The file name to create on the remote device.

    Examples:
        The ``dest`` may be either an absolute or relative path. The basename
        of the path is used as the remote file name upon upload. For instance,
        in the example below, ``BIGIP-13.1.0.8-0.0.3.iso`` would be the name
        of the remote file.

        The specified URL should be the full URL to where you want to upload a
        file. BIG-IP has many different URLs that can be used to handle different
        types of files. This is why a full URL is required.

        >>> from ansible_collections.f5networks.f5_modules.plugins.module_utils.icontrol import upload_client
        >>> url = 'https://{0}:{1}/mgmt/cm/autodeploy/software-image-uploads'.format(
        ...   self.client.provider['server'],
        ...   self.client.provider['server_port']
        ... )
        >>> dest = '/path/to/BIGIP-13.1.0.8-0.0.3.iso'
        >>> upload_file(self.client, url, dest)
        True

    Returns:
        bool: True on success. False otherwise.

    Raises:
        F5ModuleError: Raised if ``retries`` limit is exceeded.
    """
    if isinstance(src, StringIO) or isinstance(src, BytesIO):
        fileobj = src
    else:
        fileobj = open(src, 'rb')

    try:
        size = os.stat(src).st_size
        is_file = True
    except TypeError:
        src.seek(0, os.SEEK_END)
        size = src.tell()
        src.seek(0)
        is_file = False

    # This appears to be the largest chunk size that iControlREST can handle.
    #
    # The trade-off you are making by choosing a chunk size is speed, over size of
    # transmission. A lower chunk size will be slower because a smaller amount of
    # data is read from disk and sent via HTTP. Lots of disk reads are slower and
    # There is overhead in sending the request to the BIG-IP.
    #
    # Larger chunk sizes are faster because more data is read from disk in one
    # go, and therefore more data is transmitted to the BIG-IP in one HTTP request.
    #
    # If you are transmitting over a slow link though, it may be more reliable to
    # transmit many small chunks that fewer large chunks. It will clearly take
    # longer, but it may be more robust.
    chunk_size = 1024 * 7168
    start = 0
    retries = 0
    if dest is None and is_file:
        basename = os.path.basename(src)
    else:
        basename = dest
    url = '{0}/{1}'.format(url.rstrip('/'), basename)

    while True:
        if retries == 3:
            # Retries are used here to allow the REST API to recover if you kill
            # an upload mid-transfer.
            #
            # There exists a case where retrying a new upload will result in the
            # API returning the POSTed payload (in bytes) with a non-200 response
            # code.
            #
            # Retrying (after seeking back to 0) seems to resolve this problem.
            raise F5ModuleError(
                "Failed to upload file too many times."
            )
        try:
            file_slice = fileobj.read(chunk_size)
            if not file_slice:
                break

            current_bytes = len(file_slice)
            if current_bytes < chunk_size:
                end = size
            else:
                end = start + current_bytes
            headers = {
                'Content-Range': '%s-%s/%s' % (start, end - 1, size),
                'Content-Type': 'application/octet-stream'
            }

            # Data should always be sent using the ``data`` keyword and not the
            # ``json`` keyword. This allows bytes to be sent (such as in the case
            # of uploading ISO files.
            response = client.api.post(url, headers=headers, data=file_slice)

            if response.status != 200:
                # When this fails, the output is usually the body of whatever you
                # POSTed. This is almost always unreadable because it is a series
                # of bytes.
                #
                # Therefore, we only inform on the returned HTTP error code.
                raise F5ModuleError('Error during upload, http error code: {0}'.format(str(response.status)))
            start += current_bytes
        except F5ModuleError:
            # You must seek back to the beginning of the file upon exception.
            #
            # If this is not done, then you risk uploading a partial file.
            fileobj.seek(0)
            retries += 1
    return True


def tmos_version(client):
    uri = "https://{0}:{1}/mgmt/tm/sys/".format(
        client.provider['server'],
        client.provider['server_port'],
    )
    resp = client.api.get(uri)

    try:
        response = resp.json()
    except ValueError as ex:
        raise F5ModuleError(str(ex))

    if 'code' in response and response['code'] in [400, 403]:
        if 'message' in response:
            raise F5ModuleError(response['message'])
        else:
            raise F5ModuleError(resp.content)

    to_parse = urlparse(response['selfLink'])
    query = to_parse.query
    version = query.split('=')[1]
    return version


def bigiq_version(client):
    uri = "https://{0}:{1}/mgmt/shared/resolver/device-groups/cm-shared-all-big-iqs/devices".format(
        client.provider['server'],
        client.provider['server_port'],
    )
    query = "?$select=version"

    resp = client.api.get(uri + query)

    try:
        response = resp.json()
    except ValueError as ex:
        raise F5ModuleError(str(ex))

    if 'code' in response and response['code'] in [400, 403]:
        if 'message' in response:
            raise F5ModuleError(response['message'])
        else:
            raise F5ModuleError(resp.content)

    if 'items' in response:
        version = response['items'][0]['version']
        return version

    raise F5ModuleError(
        'Failed to retrieve BIG-IQ version information.'
    )


def module_provisioned(client, module_name):
    provisioned = modules_provisioned(client)
    if module_name in provisioned:
        return True
    return False


def package_installed(client, package_name):
    provisioned = packages_installed(client)
    if package_name in provisioned:
        return True
    return False


def modules_provisioned(client):
    """Returns a list of all provisioned modules

    Args:
        client: Client connection to the BIG-IP

    Returns:
        A list of provisioned modules in their short name for.
        For example, ['afm', 'asm', 'ltm']
    """
    uri = "https://{0}:{1}/mgmt/tm/sys/provision".format(
        client.provider['server'],
        client.provider['server_port']
    )
    resp = client.api.get(uri)

    try:
        response = resp.json()
    except ValueError as ex:
        raise F5ModuleError(str(ex))

    if resp.status not in [200, 201] or 'code' in response and response['code'] not in [200, 201]:
        raise F5ModuleError(resp.content)

    if 'items' not in response:
        return []
    return [x['name'] for x in response['items'] if x['level'] != 'none']


def packages_installed(client):
    """Returns a list of installed ATC packages

    Args:
        client: Client connection to the BIG-IP

    Returns:
        A list of installed packages in their short name for.
        For example, ['as3', 'do', 'ts']
    """
    packages = {
        "f5-declarative-onboarding": "do",
        "f5-appsvcs": "as3",
        "f5-appsvcs-templates": "fast",
        "f5-cloud-failover": "cfe",
        "f5-telemetry": "ts",
        "f5-service-discovery": "sd"

    }

    uri = "https://{0}:{1}/mgmt/shared/iapp/global-installed-packages".format(
        client.provider['server'],
        client.provider['server_port']
    )
    resp = client.api.get(uri)

    try:
        response = resp.json()
    except ValueError as ex:
        raise F5ModuleError(str(ex))

    if 'code' in response and response['code'] == 404:
        return []
    if resp.status not in [200, 201] or 'code' in response and response['code'] not in [200, 201]:
        raise F5ModuleError(resp.content)
    if 'items' not in response:
        return []

    result = [packages[x['appName']] for x in response['items'] if x['appName'] in packages.keys()]
    return result
