import json
import time
import traceback
import urllib.parse
import uuid
import requests
import logging

from requests.exceptions import ConnectionError, Timeout
from urllib3.exceptions import ProtocolError


requests.packages.urllib3.disable_warnings()
logger = logging.getLogger(__name__)  # pylint: disable=C0103


def id_gen(base, start=0):
    i = start
    while True:
        yield '%s-%d' % (base, i)
        i += 1


class APIClient(object):
    REQUEST_ID_HEADER = 'X-Request-ID'

    def __init__(
            self,
            core_interrupted,
            base_url=None,
            writer_url=None,
            network_attempts=10,
            api_attempts=10,
            maintenance_attempts=40,
            network_timeout=2,
            api_timeout=5,
            maintenance_timeout=15,
            connection_timeout=5.0,
            user_agent=None,
            api_token=None):
        self.core_interrupted = core_interrupted
        self.user_agent = user_agent
        self.connection_timeout = connection_timeout
        self._base_url = base_url
        self.writer_url = writer_url

        self.retry_timeout = 10
        self.session = requests.Session()
        self.session.verify = False
        self.session.headers.update({"User-Agent": "tank"})

        if "https" in requests.utils.getproxies():
            logger.info("Connecting via proxy %s" % requests.utils.getproxies()['https'])
            self.session.proxies = requests.utils.getproxies()
        else:
            logger.info("Proxy not set")

        self.network_attempts = network_attempts
        self.network_timeout = network_timeout
        self.api_attempts = api_attempts
        self.api_timeout = api_timeout
        self.maintenance_attempts = maintenance_attempts
        self.maintenance_timeout = maintenance_timeout
        self.params = {'api_token': api_token} if api_token else {}

    @property
    def base_url(self):
        if not self._base_url:
            raise ValueError("Base url is not set")
        else:
            return self._base_url

    @base_url.setter
    def base_url(self, url):
        self._base_url = url

    class UnderMaintenance(Exception):
        message = "API is under maintenance"

    class NotAvailable(Exception):
        desc = "API is not available"

        def __init__(self, request, response):
            self.message = "%s\n%s\n%s" % (self.desc, request, response)
            super(self.__class__, self).__init__(self.message)

    class StoppedFromOnline(Exception):
        """http code 410"""
        message = "Shooting is stopped from online"

    class JobNotCreated(Exception):
        pass

    class NetworkError(Exception):
        pass

    def set_api_timeout(self, timeout):
        self.api_timeout = float(timeout)

    def network_timeouts(self):
        return (self.network_timeout for _ in range(self.network_attempts - 1))

    def api_timeouts(self):
        return (self.api_timeout for _ in range(self.api_attempts - 1))

    def maintenance_timeouts(self):
        return (
            self.maintenance_timeout for _ in range(
                self.maintenance_attempts - 1))

    @staticmethod
    def filter_headers(headers):
        boring = ['X-Content-Security-Policy', 'Content-Security-Policy',
                  'Strict-Transport-Security', 'X-WebKit-CSP', 'Set-Cookie',
                  'X-DNS-Prefetch-Control', 'X-Frame-Options', 'P3P',
                  'X-Content-Type-Options', 'X-Download-Options',
                  'Surrogate-Control']
        for h in boring:
            if h in headers:
                del (headers[h])
        return headers

    def __send_single_request(self, request, request_id, trace=False):
        request.headers[self.REQUEST_ID_HEADER] = request_id
        p = self.session.prepare_request(request)
        if trace:
            logger.debug(self.format_request_info(p, request_id))
        resp = self.session.send(p, timeout=self.connection_timeout)
        if trace:
            logger.debug(self.format_response_info(resp, request_id))
        if resp.status_code in [500, 502, 503, 504]:
            raise self.NotAvailable(
                request=self.format_request_info(p, request_id),
                response=self.format_response_info(resp, request_id))
        elif resp.status_code == 410:
            raise self.StoppedFromOnline
        elif resp.status_code == 423:
            raise self.UnderMaintenance
        else:
            resp.raise_for_status()
            return resp

    def format_request_info(self, request, request_id):
        utf8_body = request.body.decode('utf-8') if isinstance(request.body, bytes) else request.body
        request_info = {
            'id': request_id,
            'method': request.method,
            'url': request.url,
            'headers': str(self.filter_headers(request.headers)),
            'body': utf8_body.replace('\n', '\\n') if isinstance(utf8_body, str) else utf8_body
        }
        return """Request: {}""".format(json.dumps(request_info))

    def format_response_info(self, resp, request_id):
        response_info = {
            'id': request_id,
            'elapsed_time': resp.elapsed.total_seconds(),
            'reason': resp.reason,
            'http code': resp.status_code,
            'headers': str(self.filter_headers(resp.headers)),
            'content': resp.text.replace('\n', '\\n') if isinstance(resp.text, str) else resp.text
        }
        return """Response: {}""".format(json.dumps(response_info))

    def __make_api_request(
            self,
            http_method,
            path,
            data=None,
            response_callback=lambda x: x,
            writer=False,
            interrupted_event=None,
            trace=False,
            json=None,
            maintenance_timeouts=None,
            maintenance_msg=None):
        url = urllib.parse.urljoin(self.base_url, path)
        ids = id_gen(str(uuid.uuid4()))
        if json:
            request = requests.Request(
                http_method, url, json=json, headers={'User-Agent': self.user_agent}, params=self.params)
        else:
            request = requests.Request(
                http_method, url, data=data, headers={'User-Agent': self.user_agent}, params=self.params)
        network_timeouts = self.network_timeouts()
        maintenance_timeouts = maintenance_timeouts or self.maintenance_timeouts()
        maintenance_msg = maintenance_msg or "%s is under maintenance" % (self._base_url)
        while interrupted_event is None or not interrupted_event.is_set():
            try:
                response = self.__send_single_request(request, next(ids), trace=trace)
                return response_callback(response)
            except (Timeout, ConnectionError, ProtocolError):
                logger.warn(traceback.format_exc())
                if not self.core_interrupted.is_set():
                    try:
                        timeout = next(network_timeouts)
                        logger.warning("Network error, will retry in %ss..." % timeout)
                        time.sleep(timeout)
                        continue
                    except StopIteration:
                        raise self.NetworkError()
                else:
                    break
            except self.UnderMaintenance as e:
                if not self.core_interrupted.is_set():
                    try:
                        timeout = next(maintenance_timeouts)
                        logger.warning(maintenance_msg)
                        logger.warning("Retrying in %ss..." % timeout)
                        time.sleep(timeout)
                        continue
                    except StopIteration:
                        raise e
                else:
                    break

    def __make_writer_request(
            self,
            params=None,
            json=None,
            http_method="POST",
            trace=False):
        '''
        Send request to writer service.
        '''
        request = requests.Request(
            http_method,
            self.writer_url,
            params=params,
            json=json,
            headers={
                'User-Agent': self.user_agent})
        ids = id_gen(str(uuid.uuid4()))
        network_timeouts = self.network_timeouts()
        maintenance_timeouts = self.maintenance_timeouts()
        while True:
            try:
                response = self.__send_single_request(request, next(ids), trace=trace)
                return response
            except (Timeout, ConnectionError, ProtocolError):
                logger.warning(traceback.format_exc())
                try:
                    timeout = next(network_timeouts)
                    logger.warning("Network error, will retry in %ss..." % timeout)
                    time.sleep(timeout)
                    continue
                except StopIteration:
                    raise self.NetworkError()
            except self.UnderMaintenance as e:
                try:
                    timeout = next(maintenance_timeouts)
                    logger.warning("Writer is under maintenance, will retry in %ss..." % timeout)
                    time.sleep(timeout)
                    continue
                except StopIteration:
                    raise e

    def __get(self, addr, trace=False, maintenance_timeouts=None, maintenance_msg=None):
        return self.__make_api_request(
            'GET',
            addr,
            trace=trace,
            response_callback=lambda r: json.loads(r.content.decode('utf8')),
            maintenance_timeouts=maintenance_timeouts,
            maintenance_msg=maintenance_msg
        )

    def __post_raw(self, addr, txt_data, trace=False, interrupted_event=None):
        return self.__make_api_request(
            'POST', addr, txt_data, lambda r: r.content, trace=trace, interrupted_event=interrupted_event)

    def __post(self, addr, data, interrupted_event=None, trace=False):
        return self.__make_api_request(
            'POST',
            addr,
            json=data,
            response_callback=lambda r: r.json(),
            interrupted_event=interrupted_event,
            trace=trace)

    def __put(self, addr, data, trace=False):
        return self.__make_api_request(
            'PUT',
            addr,
            json=data,
            response_callback=lambda r: r.text,
            trace=trace)

    def __patch(self, addr, data, trace=False):
        return self.__make_api_request(
            'PATCH',
            addr,
            json=data,
            response_callback=lambda r: r.text,
            trace=trace)

    def get_task_data(self, task, trace=False):
        return self.__get("api/task/" + task + "/summary.json", trace=trace)

    def new_job(
            self,
            task,
            person,
            tank,
            target_host,
            target_port,
            loadscheme=None,
            detailed_time=None,
            notify_list=None,
            trace=False):
        """
        :return: job_nr, upload_token
        :rtype: tuple
        """
        if not notify_list:
            notify_list = []
        data = {
            'task': task,
            'person': person,
            'tank': tank,
            'host': target_host,
            'port': target_port,
            'loadscheme': loadscheme,
            'detailed_time': detailed_time,
            'notify': notify_list
        }

        logger.debug("Job create request: %s", data)
        api_timeouts = self.api_timeouts()
        while True:
            try:
                response = self.__post(
                    "api/job/create.json", data, trace=trace)[0]
                # [{"upload_token": "1864a3b2547d40f19b5012eb038be6f6", "job": 904317}]
                return response['job'], response['upload_token']
            except (self.NotAvailable, self.StoppedFromOnline) as e:
                try:
                    timeout = next(api_timeouts)
                    logger.warning("API error, will retry in %ss..." % timeout)
                    time.sleep(timeout)
                    continue
                except StopIteration:
                    logger.warning('Failed to create job on lunapark')
                    raise self.JobNotCreated() from e
            except requests.HTTPError as e:
                raise self.JobNotCreated('Failed to create job on lunapark\n{}'.format(e.response.content))
            except Exception:
                logger.exception('Failed to create job on lunapark')
                raise self.JobNotCreated()

    def get_job_summary(self, jobno):
        result = self.__get('api/job/' + str(jobno) + '/summary.json')
        return result[0]

    def close_job(self, jobno, retcode, trace=False):
        params = {'exitcode': str(retcode)}

        result = self.__get('api/job/' + str(jobno) + '/close.json?'
                            + urllib.parse.urlencode(params), trace=trace)
        return result[0]['success']

    def edit_job_metainfo(
            self,
            jobno,
            job_name,
            job_dsc,
            instances,
            ammo_path,
            loop_count,
            version_tested,
            component,
            cmdline,
            is_starred,
            tank_type=0,
            trace=False):
        data = {
            'name': job_name,
            'description': job_dsc,
            'instances': str(instances),
            'ammo': ammo_path,
            'loop': loop_count,
            'version': version_tested,
            'component': component,
            'tank_type': int(tank_type),
            'command_line': cmdline,
            'starred': int(is_starred)
        }

        response = self.__post(
            'api/job/' + str(jobno) + '/edit.json',
            data,
            trace=trace)
        return response

    def set_imbalance_and_dsc(self, jobno, rps, comment, ts=0):
        data = {}
        if rps:
            data['imbalance'] = rps
        if comment:
            res = self.get_job_summary(jobno)
            data['description'] = (res['dsc'] + "\n" + comment).strip()

        response = self.__post('api/job/' + str(jobno) + '/edit.json', data)
        return response

    @staticmethod
    def second_data_to_push_item(data, stat, timestamp, overall, case):
        """
        @data: SecondAggregateDataItem
        """
        api_data = {
            'overall': overall,
            'case': case,
            'net_codes': [],
            'http_codes': [],
            'time_intervals': [],
            'trail': {
                'time': str(timestamp),
                'reqps': stat["metrics"]["reqps"],
                'resps': data["interval_real"]["len"],
                'expect': data["interval_real"]["total"] / 1000.0 / data["interval_real"]["len"],
                'disper': 0,
                'self_load':
                    0,  # TODO abs(round(100 - float(data.selfload), 2)),
                'input': data["size_in"]["total"],
                'output': data["size_out"]["total"],
                'connect_time': data["connect_time"]["total"] / 1000.0 / data["connect_time"]["len"],
                'send_time':
                    data["send_time"]["total"] / 1000.0 / data["send_time"]["len"],
                'latency':
                    data["latency"]["total"] / 1000.0 / data["latency"]["len"],
                'receive_time': data["receive_time"]["total"] / 1000.0 / data["receive_time"]["len"],
                'threads': stat["metrics"]["instances"],  # TODO
            }
        }

        for q, value in zip(data["interval_real"]["q"]["q"],
                            data["interval_real"]["q"]["value"]):
            api_data['trail']['q' + str(q)] = value / 1000.0

        for code, cnt in data["net_code"]["count"].items():
            api_data['net_codes'].append({'code': int(code),
                                          'count': int(cnt)})

        for code, cnt in data["proto_code"]["count"].items():
            api_data['http_codes'].append({'code': int(code),
                                           'count': int(cnt)})

        api_data['time_intervals'] = APIClient.convert_hist(data["interval_real"][
            "hist"])
        return api_data

    @staticmethod
    def convert_hist(hist):
        data = hist['data']
        bins = hist['bins']
        return [
            {
                "from": 0,  # deprecated
                "to": b / 1000.0,
                "count": count,
            } for b, count in zip(bins, data)
        ]

    def push_test_data(
            self,
            jobno,
            upload_token,
            data_item,
            stat_item,
            interrupted_event,
            trace=False):
        items = []
        uri = 'api/job/{0}/push_data.json?upload_token={1}'.format(
            jobno, upload_token)
        ts = data_item["ts"]
        for case_name, case_data in data_item["tagged"].items():
            if case_name == "":
                case_name = "__NOTAG__"
            push_item = self.second_data_to_push_item(case_data, stat_item, ts,
                                                      0, case_name)
            items.append(push_item)
        overall = self.second_data_to_push_item(data_item["overall"],
                                                stat_item, ts, 1, '')
        items.append(overall)

        api_timeouts = self.api_timeouts()
        while not interrupted_event.is_set():
            try:
                if self.writer_url:
                    res = self.__make_writer_request(
                        params={
                            "jobno": jobno,
                            "upload_token": upload_token,
                        },
                        json={
                            "trail": items,
                        },
                        trace=trace)
                    logger.debug("Writer response: %s", res.text)
                    return res.json()["success"]
                else:
                    res = self.__post(uri, items, interrupted_event, trace=trace)
                    logger.debug("API response: %s", res)
                    success = int(res[0]['success'])
                    return success
            except self.NotAvailable as e:
                try:
                    timeout = next(api_timeouts)
                    logger.warning("API error, will retry in %ss...", timeout)
                    time.sleep(timeout)
                    continue
                except StopIteration:
                    raise e

    def push_monitoring_data(
            self,
            jobno,
            upload_token,
            send_data,
            interrupted_event,
            trace=False):
        if send_data:
            addr = "api/monitoring/receiver/push?job_id=%s&upload_token=%s" % (
                jobno, upload_token)
            api_timeouts = self.api_timeouts()
            while not interrupted_event.is_set():
                try:
                    if self.writer_url:
                        res = self.__make_writer_request(
                            params={
                                "jobno": jobno,
                                "upload_token": upload_token,
                            },
                            json={
                                "monitoring": send_data,
                            },
                            trace=trace)
                        logger.debug("Writer response: %s", res.text)
                        return res.json()["success"]
                    else:
                        res = self.__post_raw(
                            addr, json.dumps(send_data), trace=trace, interrupted_event=interrupted_event)
                        logger.debug("API response: %s", res)
                        success = res == 'ok'
                        return success
                except self.NotAvailable as e:
                    try:
                        timeout = next(api_timeouts)
                        logger.warning("API error, will retry in %ss...", timeout)
                        time.sleep(timeout)
                        continue
                    except StopIteration:
                        raise e

    def push_events_data(self, jobno, operator, send_data):
        if send_data:
            # logger.info('send data: %s', send_data)
            for key in send_data:
                addr = "/api/job/{jobno}/event.json".format(
                    jobno=jobno,
                )
                body = dict(
                    operator=operator,
                    text=key[1],
                    timestamp=key[0]
                )
                api_timeouts = self.api_timeouts()
                while True:
                    try:
                        # logger.debug('Sending event: %s', body)
                        res = self.__post_raw(addr, body)
                        logger.debug("API response for events push: %s", res)
                        success = res == 'ok'
                        return success
                    except self.NotAvailable as e:
                        try:
                            timeout = next(api_timeouts)
                            logger.warning("API error, will retry in %ss...", timeout)
                            time.sleep(timeout)
                            continue
                        except StopIteration:
                            raise e

    def send_status(self, jobno, upload_token, status, trace=False):
        addr = "api/v2/jobs/%s/?upload_token=%s" % (jobno, upload_token)
        status_line = status.get("core", {}).get("stage", "unknown")
        if "stepper" in status:
            status_line += " %s" % status["stepper"].get("progress")
        api_timeouts = self.api_timeouts()
        while True:
            try:
                self.__patch(addr, {"status": status_line}, trace=trace)
                return
            except self.NotAvailable as e:
                try:
                    timeout = next(api_timeouts)
                    logger.warning("API error, will retry in %ss...", timeout)
                    time.sleep(timeout)
                    continue
                except StopIteration:
                    raise e

    def is_target_locked(self, target, trace=False):
        addr = "api/server/lock.json?action=check&address=%s" % target
        res = self.__get(addr, trace=trace)
        return res[0]

    def lock_target(self, target, duration, trace=False, maintenance_timeouts=None, maintenance_msg=None):
        addr = "api/server/lock.json?action=lock&" + \
               "address=%s&duration=%s&jobno=None" % \
               (target, int(duration))
        res = self.__get(addr, trace=trace, maintenance_timeouts=maintenance_timeouts, maintenance_msg=maintenance_msg)
        return res[0]

    def unlock_target(self, target):
        addr = self.get_manual_unlock_link(target)
        res = self.__get(addr)
        return res[0]

    def get_virtual_host_info(self, hostname):
        addr = "api/server/virtual_host.json?hostname=%s" % hostname
        res = self.__get(addr)
        try:
            return res[0]
        except KeyError:
            raise Exception(res['error'])

    @staticmethod
    def get_manual_unlock_link(target):
        return "api/server/lock.json?action=unlock&address=%s" % target

    def send_config(self, jobno, lp_requisites, config_content, trace=False):
        endpoint, field_name = lp_requisites
        logger.debug("Sending {} config".format(field_name))
        addr = "/api/job/%s/%s" % (jobno, endpoint)
        self.__post_raw(addr, {field_name: config_content}, trace=trace)

    def link_mobile_job(self, lp_key, mobile_key):
        addr = "/api/job/{jobno}/edit.json".format(jobno=lp_key)
        data = {
            'mobile_key': mobile_key
        }
        response = self.__post(addr, data)
        return response


class LPRequisites:
    CONFIGINFO = ('configinfo.txt', 'configinfo')
    MONITORING = ('jobmonitoringconfig.txt', 'monitoringconfig')
    CONFIGINITIAL = ('configinitial.txt', 'configinitial')


class OverloadClient(APIClient):
    """ mocks below for nonexistent backend methods """
    def send_status(self, jobno, upload_token, status, trace=False):
        return

    def lock_target(self, target, duration, trace=False, **kwargs):
        return

    def unlock_target(self, *args, **kwargs):
        return

    def link_mobile_job(self, lp_key, mobile_key):
        return

    def push_events_data(self, number, token, data):
        return

    def set_imbalance_and_dsc(self, jobno, rps, comment, ts=0):
        return
