# Copyright 2019 Mathew Odden <mathewrodden@gmail.com>
#
# 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.

import base64
import io
import logging
import re
import urllib.parse
import zipfile

import requests
import requests.auth

from redstone import auth


LOG = logging.getLogger(__name__)


class TokenAuth(requests.auth.AuthBase):

    def __init__(self, credentials):
        self._token_manager = credentials

    def __call__(self, req):
        req.headers['Authorization'] = \
            "Bearer %s" % self._token_manager.get_token()
        return req


class BaseClient(object):

    def __init__(self, region=None, service_instance_id=None,
                 iam_api_key=None, verify=True, endpoint_url=None,
                 credentials=None):

        self.session = requests.Session()
        self.session.verify = verify

        self.credentials = credentials
        # respect old path if user builds us directly with API key
        if iam_api_key:
            LOG.warn("'iam_api_key' keyword arg is deprecated. use 'credentials' instead.")
            self.credentials = auth.TokenManager(iam_api_key)

        self.session.auth = TokenAuth(self.credentials)

        self.service_instance_id = service_instance_id
        self.region = region

        if endpoint_url:
            if endpoint_url.endswith('/'):
                endpoint_url = endpoint_url[:-1]
            self.endpoint_url = endpoint_url
        else:
            self.endpoint_url = self.endpoint_for_region(region)


class IKS(BaseClient):

    names = ["iks"]

    def endpoint_for_region(self, region):
        return "https://containers.bluemix.net"

    def get_clusters(self):

        """
        GET /v1/clusters HTTP/1.1
        Host: containers.bluemix.net
        Accept: application/json
        Authorization: [PRIVATE DATA HIDDEN]
        Content-Type: application/json
        X-Region: au-syd
        """
        # returns 200 OK on success

        resp = self.session.get(
            "{0}/v1/clusters".format(self.endpoint_url),
            headers={
                "X-Region": self.region,
                "Accept": "application/json"
            }
        )

        if resp.status_code != 200:
            raise Exception("error getting clusters: code=%d body=%r" % (resp.status_code, resp.text))

        return resp.json()

    def get_workers(self, cluster):

        """
        GET /v1/clusters/<cluster_name_or_id>/workers?showDeleted=false HTTP/1.1
        Host: containers.bluemix.net
        Accept: application/json
        Authorization: [PRIVATE DATA HIDDEN]
        Content-Type: application/json
        X-Region: au-syd
        """
        # returns 200 OK on success

        resp = self.session.get(
            "{0}/v1/clusters/{1}/workers?showDeleted=false".format(self.endpoint_url, cluster),
            headers={
                "X-Region": self.region,
                "Accept": "application/json"
            }
        )

        if resp.status_code != 200:
            raise Exception("error getting workers: code=%d body=%r" % (resp.status_code, resp.text))

        return resp.json()

    def update_worker(self, cluster, worker):

        """
        PUT /v1/clusters/<cluster_name_or_id>/workers/<worker_name_or_id> HTTP/1.1
        Host: containers.bluemix.net
        Accept: application/json
        Authorization: [PRIVATE DATA HIDDEN]
        Content-Type: application/json
        X-Region: au-syd

        {"action":"update"}
        OR
        {"action":"update", "force": true}
        """
        # returns 204 No Content on success

        resp = self.session.put(
            "{0}/v1/clusters/{1}/workers/{2}".format(self.endpoint_url, cluster, worker),
            headers={
                "X-Region": self.region,
                "Accept": "application/json"
            },
            json={"action": "update"}
        )

        if resp.status_code != 204:
            raise Exception("error updating workers: code=%d body=%r" % (resp.status_code, resp.text))

    def update_master(self, cluster, version):

        resp = self.session.put(
            "{0}/v1/clusters/{1}".format(self.endpoint_url, cluster),
            headers={
                "X-Region": self.region,
                "Accept": "application/json"
            },
            json={
                "action": "update",
                "version": version
            }
        )

        if resp.status_code != 204:
            raise Exception("error updating master: code=%d body=%r" % (resp.status_code, resp.text))

    def get_kube_versions(self):
        resp = self.session.get(
            "{0}/v1/kube-versions".format(self.endpoint_url),
            headers={
                "X-Region": self.region,
                "Accept": "application/json"
            }
        )

        if resp.status_code != 200:
            raise Exception("error getting kube-versions: code=%d body=%r" % (resp.status_code, resp.text))

        return resp.json()

    def get_cluster_config(self, cluster):
        """
        Retrieve a KubeConfig that can be used with kubectl to
        interact with the given IKS cluster.

        :returns: base64 encoded file data; can be decode and written to file,
        or used with the python kubernetes client to interact in the same process
        """
        output_format = "yaml"

        # NOTE(mrodden): no idea why this one API needs the refresh token,
        # but it certainly gives an HTTP 400 when you don't have it
        # this is a pretty hacky way of getting one, but we shouldn't even
        # need it either so... idk. /shrug

        # do a get token before, to make sure we get a valid refresh token.
        _token = self.session.auth._token_manager.get_token()
        refresh_token = self.session.auth._token_manager._token_info.get('refresh_token')

        # pure yaml output was added sometime after our original code to deal with the zipfile,
        # leaving both in because maybe its useful for someone to use the zip path still
        params={}
        if output_format == "yaml":
            params["format"] = "yaml"

        path = '{0}/v1/clusters/{1}/config'
        resp = self.session.get(
            path.format(self.endpoint_url, cluster),
            params=params,
            headers={
                "X-Region": self.region,
                "X-Auth-Refresh-Token": refresh_token
            }
        )

        resp.raise_for_status()

        if output_format == "yaml":
            config_yaml = resp.content
        else:
            zip_data = io.BytesIO()
            zip_data.write(resp.content)
            zip_file = zipfile.ZipFile(zip_data)

            files = zip_file.namelist()
            for file_name in files:
                if file_name.endswith('yml'):
                    kube_yaml = zip_file.read(file_name)
                elif file_name.endswith('pem'):
                    ca_data = zip_file.read(file_name)

            ca_b64 = base64.b64encode(ca_data)

            config_yaml = re.sub(
                b'certificate-authority: .+',
                b'certificate-authority-data: ' + ca_b64,
                kube_yaml
            )

        return base64.b64encode(config_yaml)


# FIXME(mrodden): currently this thing is hardcode to create KeyProtect instances only,
# because i built all of this just for testing the keyprotect service...
class ResourceController(BaseClient):
    """
    API Docs:
      - https://console.bluemix.net/apidocs/resource-manager
      - https://console.bluemix.net/apidocs/resource-controller
    """

    names = ["rc"]

    def endpoint_for_region(self, region):
        return "https://resource-controller.bluemix.net"

    def get_default_resource_group(self):
        return next(
            filter(
                lambda x: x.get("name") == "default",
                self.resource_groups().get("resources", [])),
            None
        )

    def resource_groups(self):
        if "bluemix" in self.endpoint_url:
            netloc = self.endpoint_url.replace("controller", "manager")
        else:
            netloc = self.endpoint_url

        # apparently it doesn't complain if we drop query params,
        # didn't want to have to look up the account ID anyway, so +2
        resp = self.session.get(
            "{0}/v1/resource_groups".format(netloc),
        )

        if resp.status_code != 200:
            raise Exception("Failed to get resource groups: url=%r code=%d body=%r" % (
                resp.request.url, resp.status_code, resp.text))

        return resp.json()

    def create_instance(self, name, region=None, resource_group=None):
        """Create/provision a service instance.

        :returns: tuple of (service_GUID, service_CRN) if successful
        :raises: Exception if there is an error
        """

        if resource_group is None:
            resource_group_id = self.get_default_resource_group().get("id")
        else:
            resource_group_id = resource_group

        if not region:
            region = self.region

        # TODO(mroddon): do global catalog lookup of service name and get price plans
        # this is hardcoded to build KeyProtect instances right now... sorry :/
        resource_plan_id = "eedd3585-90c6-4c8f-be3d-062069e99fc3"  # keyprotect tiered-pricing ID

        return self._create_instance(name, region, resource_group_id, resource_plan_id)

    def _create_instance(self, name, region, resource_group_id, resource_plan_id):

        # seems like the target_crn is the region selector, and its just the price plan ID with the region stuck at the end
        target_crn = "crn:v1:bluemix:public:globalcatalog::::deployment:{0}%3A{1}".format(
            resource_plan_id,
            region
        )

        body = {
            "name": name,
            "resource_plan_id": resource_plan_id,
            "resource_group_id": resource_group_id,
            "target_crn": target_crn
        }

        resp = self.session.post(
            "{0}/v1/resource_instances".format(self.endpoint_url),
            json=body
        )

        if resp.status_code != 201:
            raise Exception("Create instance failed: code=%d body=%s" % (resp.status_code, resp.text))

        return resp.json().get("guid"), resp.json().get("id")

    def delete_instance(self, instance_crn):
        """Delete/deprovision a service instance identified by the given CRN."""

        safe_crn = urllib.parse.quote(instance_crn, "")
        resp = self.session.delete(
            "{0}/v1/resource_instances/{1}".format(self.endpoint_url, safe_crn)
        )

        if resp.status_code != 204:
            raise Exception("Delete instance failed: code=%d body=%s" % (resp.status_code, resp.text))


class KeyProtect(BaseClient):

    names = ["kms"]

    def __init__(self, *args, **kwargs):
        super(KeyProtect, self).__init__(*args, **kwargs)

        if not self.service_instance_id:
            raise ValueError("KeyProtect service requires 'service_instance_id' to be set!")

        self.session.headers['Bluemix-Instance'] = self.service_instance_id

    def endpoint_for_region(self, region):
        return "https://keyprotect.{0}.bluemix.net".format(region)

    def _validate_resp(self, resp):

        def log_resp(resp):
            resp_str = io.StringIO()
            print(u"%s %s" % (resp.status_code, resp.reason), file=resp_str)

            for k, v in resp.headers.items():
                if k.lower() == 'authorization':
                    v = 'REDACTED'
                print("%s: %s" % (k, v), file=resp_str)

            print(resp.content.decode(), end=u'', file=resp_str)
            return resp_str.getvalue()

        try:
            LOG.debug(log_resp(resp))
            resp.raise_for_status()
        except requests.HTTPError as http_err:
            http_err.raw_response = log_resp(resp)
            raise http_err

    def keys(self):
        resp = self.session.get(
            "%s/api/v2/keys" % self.endpoint_url
        )

        self._validate_resp(resp)

        return resp.json().get('resources', [])

    def get(self, key_id):
        resp = self.session.get(
            "%s/api/v2/keys/%s" % (self.endpoint_url, key_id)
        )

        self._validate_resp(resp)

        return resp.json().get('resources')[0]

    def create(self, name, payload=None, raw_payload=None, root=False):

        data = {
            "metadata": {
                "collectionType": "application/vnd.ibm.kms.key+json",
                "collectionTotal": 1},
            "resources": [
                {
                    "type": "application/vnd.ibm.kms.key+json",
                    "extractable": not root,
                    "name": name
                }
            ]
        }

        # use raw_payload if given, else assume payload needs some base64 love
        if raw_payload is not None:
            data['resources'][0]['payload'] = raw_payload
        elif payload is not None:
            data['resources'][0]['payload'] = base64.b64encode(payload).decode('utf-8')

        resp = self.session.post(
            "%s/api/v2/keys" % self.endpoint_url,
            json=data
        )
        self._validate_resp(resp)
        return resp.json().get('resources')[0]

    def delete(self, key_id):
        resp = self.session.delete(
            "%s/api/v2/keys/%s" % (self.endpoint_url, key_id)
        )
        self._validate_resp(resp)

    def _action(self, key_id, action, jsonable):
        resp = self.session.post(
            "%s/api/v2/keys/%s" % (self.endpoint_url, key_id),
            params={"action": action},
            json=jsonable
        )
        self._validate_resp(resp)
        return resp.json()

    def wrap(self, key_id, plaintext, aad=None):
        if plaintext:
            data = {'plaintext': base64.b64encode(plaintext).decode()}
        else:
            data = None

        if aad:
            data['aad'] = aad

        return self._action(key_id, "wrap", data)

    def unwrap(self, key_id, ciphertext, aad=None):
        data = {'ciphertext': ciphertext}

        if aad:
            data['aad'] = aad

        resp = self._action(key_id, "unwrap", data)
        return base64.b64decode(resp['plaintext'].encode())


class CISAuth(requests.auth.AuthBase):

    def __init__(self, credentials):
        self._token_manager = credentials

    def __call__(self, req):
        req.headers['x-auth-user-token'] = \
            "Bearer %s" % self._token_manager.get_token()
        return req


class CIS(BaseClient):

    names = ["cis"]

    def __init__(self, *args, **kwargs):
        super(CIS, self).__init__(*args, **kwargs)
        self.session.auth = CISAuth(self.credentials)
        self.safe_crn = urllib.parse.quote(self.service_instance_id, safe="")

    def endpoint_for_region(self, region):
        return "https://api.cis.cloud.ibm.com"

    def pools(self):
        path = "{0}/v1/{1}/load_balancers/pools"
        resp = self.session.get(path.format(self.endpoint_url, self.safe_crn))
        resp.raise_for_status()
        return resp.json().get("result")

    def get_pool(self, pool_id):
        path = "{0}/v1/{1}/load_balancers/pools/{2}"
        resp = self.session.get(path.format(self.endpoint_url, self.safe_crn, pool_id))
        resp.raise_for_status()
        return resp.json().get("result")

    def update_pool(self, pool):
        pool_id = pool.get('id')

        keys_to_remove = [
            'created_on',
            'modified_on',
            'healthy',
            'id'
        ]

        for key in keys_to_remove:
            try:
                del pool[key]
            except KeyError:
                pass

        path = "{0}/v1/{1}/load_balancers/pools/{2}"
        resp = self.session.put(
            path.format(self.endpoint_url, self.safe_crn, pool_id),
            json=pool)
        resp.raise_for_status()
        return resp.json().get("result")
