# coding=utf-8
# TODO: make the next two lines unnecessary
# pylint: disable=line-too-long
# pylint: disable=missing-docstring
import logging
import os
import pwd
import re
import sys
import time
import datetime
import yaml
from urllib.parse import urljoin

from queue import Empty, Queue
from builtins import str
import threading

from ...common.interfaces import AbstractPlugin, \
    MonitoringDataListener, AggregateResultListener, AbstractInfoWidget
from ...common.util import expand_to_seconds
from ..Autostop import Plugin as AutostopPlugin
from ..Console import Plugin as ConsolePlugin
from .client import APIClient, LPRequisites, CloudGRPCClient
from ...common.util import FileScanner

from netort.data_processing import Drain

LOGGER = logging.getLogger(__name__)  # pylint: disable=C0103


def chop(data_list, chunk_size):
    if sys.getsizeof(str(data_list)) <= chunk_size:
        return [data_list]
    elif len(data_list) == 1:
        LOGGER.info("Too large piece of Telegraf data. Might experience upload problems.")
        return [data_list]
    else:
        mid = len(data_list) / 2
        return chop(data_list[:mid], chunk_size) + chop(data_list[mid:], chunk_size)


class Plugin(AbstractPlugin, AggregateResultListener,
             MonitoringDataListener):
    RC_STOP_FROM_WEB = 8
    VERSION = '3.0'
    SECTION = 'uploader'

    def __init__(self, core, cfg, name):
        AbstractPlugin.__init__(self, core, cfg, name)
        self.data_queue = Queue()
        self.monitoring_queue = Queue()
        if self.core.error_log:
            self.events_queue = Queue()
            self.events_reader = EventsReader(self.core.error_log)
            self.events_processing = Drain(self.events_reader, self.events_queue)
            self.add_cleanup(self.stop_events_processing)
            self.events_processing.start()
            self.events = threading.Thread(target=self.__events_uploader)
            self.events.daemon = True

        self.retcode = -1
        self._target = None
        self.task_name = ''
        self.token_file = None
        self.version_tested = None
        self.send_status_period = 10

        self.status_sender = threading.Thread(target=self.__send_status)
        self.status_sender.daemon = True

        self.upload = threading.Thread(target=self.__data_uploader)
        self.upload.daemon = True

        self.monitoring = threading.Thread(target=self.__monitoring_uploader)
        self.monitoring.daemon = True

        self._is_telegraf = None
        self._task = None
        self._api_token = None
        self._lp_job = None
        self._lock_duration = None
        self._info = None
        self.locked_targets = []
        self.web_link = None
        self.finished = False

    def set_option(self, option, value):
        self.cfg.setdefault('meta', {})[option] = value
        self.core.publish(self.SECTION, 'meta.{}'.format(option), value)

    @staticmethod
    def get_key():
        return __file__

    @property
    def lock_duration(self):
        if self._lock_duration is None:
            info = self.get_generator_info()
            self._lock_duration = info.duration if info.duration else \
                expand_to_seconds(self.get_option("target_lock_duration"))
        return self._lock_duration

    def get_available_options(self):
        opts = [
            "api_address",
            "writer_endpoint",
            "task",
            "job_name",
            "job_dsc",
            "notify",
            "ver", "component",
            "operator",
            "jobno_file",
            "ignore_target_lock",
            "target_lock_duration",
            "lock_targets",
            "jobno",
            "upload_token",
            'connection_timeout',
            'network_attempts',
            'api_attempts',
            'maintenance_attempts',
            'network_timeout',
            'api_timeout',
            'maintenance_timeout',
            'strict_lock',
            'send_status_period',
            'log_data_requests',
            'log_monitoring_requests',
            'log_status_requests',
            'log_other_requests',
            'threads_timeout',
            'chunk_size'
        ]
        return opts

    def configure(self):
        self.core.publish(self.SECTION, 'component', self.get_option('component'))
        self.core.publish(self.SECTION, 'task', self.get_option('task'))
        self.core.publish(self.SECTION, 'job_name', self.get_option('job_name'))

    def check_task_is_open(self):
        return

    @staticmethod
    def search_task_from_cwd(cwd):
        issue = re.compile("^([A-Za-z]+-[0-9]+)(-.*)?")
        while cwd:
            LOGGER.debug("Checking if dir is named like JIRA issue: %s", cwd)
            if issue.match(os.path.basename(cwd)):
                res = re.search(issue, os.path.basename(cwd))
                return res.group(1).upper()

            newdir = os.path.abspath(os.path.join(cwd, os.path.pardir))
            if newdir == cwd:
                break
            else:
                cwd = newdir

        raise RuntimeError(
            "task=dir requested, but no JIRA issue name in cwd: %s" %
            os.getcwd())

    def prepare_test(self):
        info = self.get_generator_info()
        port = info.port
        instances = info.instances
        if info.ammo_file is not None:
            if info.ammo_file.startswith("http://") or info.ammo_file.startswith("https://"):
                ammo_path = info.ammo_file
            else:
                ammo_path = os.path.realpath(info.ammo_file)
        else:
            LOGGER.warning('Failed to get info about ammo path')
            ammo_path = 'Undefined'
        loop_count = int(info.loop_count)

        try:
            lp_job = self.lp_job
            self.add_cleanup(self.unlock_targets)
            self.locked_targets = self.check_and_lock_targets(strict=self.get_option('strict_lock'),
                                                              ignore=self.get_option('ignore_target_lock'))
            if lp_job._number:
                self.make_symlink(lp_job._number)
                self.check_task_is_open()
            else:
                self.check_task_is_open()
                lp_job.create()
                self.make_symlink(lp_job.number)
            self.publish('job_no', lp_job.number)
        except (APIClient.JobNotCreated, APIClient.NotAvailable, APIClient.NetworkError) as e:
            LOGGER.error(e)
            LOGGER.error(
                'Failed to connect to Lunapark, disabling CloudUploader')
            self.start_test = lambda *a, **kw: None
            self.post_process = lambda *a, **kw: None
            self.on_aggregated_data = lambda *a, **kw: None
            self.monitoring_data = lambda *a, **kw: None
            return

        cmdline = ' '.join(sys.argv)
        lp_job.edit_metainfo(
            instances=instances,
            ammo_path=ammo_path,
            loop_count=loop_count,
            regression_component=self.get_option("component"),
            cmdline=cmdline,
        )

        self.core.job.subscribe_plugin(self)

        try:
            console = self.core.get_plugin_of_type(ConsolePlugin)
        except KeyError as ex:
            LOGGER.debug(ex)
            console = None

        if console:
            console.add_info_widget(JobInfoWidget(self))

        self.set_option('target_host', self.target)
        self.set_option('target_port', port)
        self.set_option('cmdline', cmdline)
        self.set_option('ammo_path', ammo_path)
        self.set_option('loop_count', loop_count)
        self.__save_conf()

    def start_test(self):
        self.add_cleanup(self.join_threads)
        self.status_sender.start()
        self.upload.start()
        self.monitoring.start()
        if self.core.error_log:
            self.events.start()

        self.web_link = urljoin(self.lp_job.api_client.base_url, str(self.lp_job.number))
        LOGGER.info("Web link: %s", self.web_link)

        self.publish("jobno", self.lp_job.number)
        self.publish("web_link", self.web_link)

        jobno_file = self.get_option("jobno_file", '')
        if jobno_file:
            LOGGER.debug("Saving jobno to: %s", jobno_file)
            with open(jobno_file, 'w') as fdes:
                fdes.write(str(self.lp_job.number))
            self.core.add_artifact_file(jobno_file)
        self.__save_conf()

    def is_test_finished(self):
        return self.retcode

    def end_test(self, retcode):
        if retcode != 0:
            self.lp_job.interrupted.set()
        self.__save_conf()
        self.unlock_targets()
        return retcode

    def close_job(self):
        self.lp_job.close(self.retcode)

    def join_threads(self):
        self.lp_job.interrupted.set()
        if self.monitoring.is_alive():
            self.monitoring.join()
        if self.upload.is_alive():
            self.upload.join()

    def stop_events_processing(self):
        self.events_queue.put(None)
        self.events_reader.close()
        self.events_processing.close()
        if self.events_processing.is_alive():
            self.events_processing.join()
        if self.events.is_alive():
            self.lp_job.interrupted.set()
            self.events.join()

    def post_process(self, rc):
        self.retcode = rc
        self.monitoring_queue.put(None)
        self.data_queue.put(None)
        if self.core.error_log:
            self.events_queue.put(None)
            self.events_reader.close()
            self.events_processing.close()
            self.events.join()
        LOGGER.info("Waiting for sender threads to join.")
        if self.monitoring.is_alive():
            self.monitoring.join()
        if self.upload.is_alive():
            self.upload.join()
        self.finished = True
        LOGGER.info(
            "Web link: %s", self.web_link)
        autostop = None
        try:
            autostop = self.core.get_plugin_of_type(AutostopPlugin)
        except KeyError as ex:
            LOGGER.debug(ex)

        if autostop and autostop.cause_criterion:
            timestamp = 0
            if autostop.cause_criterion.cause_second:
                timestamp = autostop.cause_criterion.cause_second[0].get("ts", 0)
            self.lp_job.set_imbalance_and_dsc(
                autostop.imbalance_rps,
                autostop.cause_criterion.explain(),
                timestamp
            )

        else:
            LOGGER.debug("No autostop cause detected")
        self.__save_conf()
        return rc

    def on_aggregated_data(self, data, stats):
        """
        @data: aggregated data
        @stats: stats about gun
        """
        if not self.lp_job.interrupted.is_set():
            self.data_queue.put((data, stats))

    def monitoring_data(self, data_list):
        if not self.lp_job.interrupted.is_set():
            if len(data_list) > 0:
                [self.monitoring_queue.put(chunk) for chunk in chop(data_list, self.get_option("chunk_size"))]

    def __send_status(self):
        LOGGER.info('Status sender thread started')
        lp_job = self.lp_job
        while not lp_job.interrupted.is_set():
            try:
                self.lp_job.send_status(self.core.info.get_info_dict())
                time.sleep(self.get_option('send_status_period'))
            except (APIClient.NetworkError, APIClient.NotAvailable) as e:
                LOGGER.warn('Failed to send status')
                LOGGER.debug(e)
                break
            except APIClient.StoppedFromOnline:
                LOGGER.info("Test stopped from Lunapark")
                self.retcode = self.RC_STOP_FROM_WEB
                break
            if self.finished:
                break
        LOGGER.info("Closed Status sender thread")

    def __uploader(self, queue, sender_method, name='Uploader'):
        LOGGER.info('{} thread started'.format(name))
        while not self.lp_job.interrupted.is_set():
            try:
                entry = queue.get(timeout=1)
                if entry is None:
                    LOGGER.info("{} queue returned None".format(name))
                    break
                sender_method(entry)
            except Empty:
                continue
            except APIClient.StoppedFromOnline:
                LOGGER.warning("Lunapark is rejecting {} data".format(name))
                break
            except (APIClient.NetworkError, APIClient.NotAvailable, APIClient.UnderMaintenance) as e:
                LOGGER.warn('Failed to push {} data'.format(name))
                LOGGER.warn(e)
                self.lp_job.interrupted.set()
            except Exception:
                exc_type, exc_value, exc_traceback = sys.exc_info()
                LOGGER.error("Mysterious exception:\n%s\n%s\n%s", (exc_type, exc_value, exc_traceback))
                break
        # purge queue
        while not queue.empty():
            if queue.get_nowait() is None:
                break
        LOGGER.info("Closing {} thread".format(name))

    def __data_uploader(self):
        self.__uploader(self.data_queue,
                        lambda entry: self.lp_job.push_test_data(*entry),
                        'Cloud Data Uploader')

    def __monitoring_uploader(self):
        self.__uploader(self.monitoring_queue,
                        self.lp_job.push_monitoring_data,
                        'Monitoring Uploader')

    def __events_uploader(self):
        self.__uploader(self.events_queue,
                        self.lp_job.push_events_data,
                        'Events Uploader')

    # TODO: why we do it here? should be in core
    def __save_conf(self):
        for requisites, content in self.core.artifacts_to_send:
            self.lp_job.send_config(requisites, content)

    def parse_lock_targets(self):
        # prepare target lock list
        locks_list_cfg = self.get_option('lock_targets', 'auto')

        def no_target():
            logging.warn("Target lock set to 'auto', but no target info available")
            return {}

        locks_set = {self.target} or no_target() if locks_list_cfg == 'auto' else set(locks_list_cfg)
        targets_to_lock = [host for host in locks_set if host]
        return targets_to_lock

    def lock_targets(self, targets_to_lock, ignore, strict):
        locked_targets = [target for target in targets_to_lock
                          if self.lp_job.lock_target(target, self.lock_duration, ignore, strict)]
        return locked_targets

    def unlock_targets(self):
        LOGGER.info("Unlocking targets: %s", self.locked_targets)
        for target in self.locked_targets:
            LOGGER.info(target)
            self.lp_job.api_client.unlock_target(target)

    def check_and_lock_targets(self, strict, ignore):
        targets_list = self.parse_lock_targets()
        LOGGER.info('Locking targets: %s', targets_list)
        locked_targets = self.lock_targets(targets_list, ignore=ignore, strict=strict)
        LOGGER.info('Locked targets: %s', locked_targets)
        return locked_targets

    def make_symlink(self, name):
        PLUGIN_DIR = os.path.join(self.core.artifacts_base_dir, 'lunapark')
        if not os.path.exists(PLUGIN_DIR):
            os.makedirs(PLUGIN_DIR)
        try:
            os.symlink(
                os.path.relpath(
                    self.core.artifacts_dir,
                    PLUGIN_DIR),
                os.path.join(
                    PLUGIN_DIR,
                    str(name)))
        # this exception catch for filesystems w/o symlinks
        except OSError:
            LOGGER.warning('Unable to create symlink for artifact: %s', name)

    def _get_user_agent(self):
        plugin_agent = 'Uploader/{}'.format(self.VERSION)
        return ' '.join((plugin_agent,
                         self.core.get_user_agent()))

    def __get_operator(self):
        try:
            return self.get_option(
                'operator') or pwd.getpwuid(
                os.geteuid())[0]
        except:  # noqa: E722
            LOGGER.error(
                "Couldn't get username from the OS. Please, set the 'meta.operator' option explicitly in your config "
                "file.")
            raise

    def __get_api_client(self):
        return CloudGRPCClient(core_interrupted=self.interrupted,
                               base_url=self.get_option('api_address'),
                               api_attempts=self.get_option('api_attempts'),
                               connection_timeout=self.get_option('connection_timeout'))

    @property
    def lp_job(self):
        """

        :rtype: CloudLoadTestingJob
        """
        if self._lp_job is None:
            self._lp_job = self.__get_lp_job()
            self.core.publish(self.SECTION, 'job_no', self._lp_job.number)
            self.core.publish(self.SECTION, 'web_link', self._lp_job.web_link)
            self.core.publish(self.SECTION, 'job_name', self._lp_job.name)
            self.core.publish(self.SECTION, 'job_dsc', self._lp_job.description)
            self.core.publish(self.SECTION, 'person', self._lp_job.person)
            self.core.publish(self.SECTION, 'task', self._lp_job.task)
            self.core.publish(self.SECTION, 'version', self._lp_job.version)
            self.core.publish(self.SECTION, 'component', self.get_option('component'))
            self.core.publish(self.SECTION, 'meta', self.cfg.get('meta', {}))
        return self._lp_job

    def __get_lp_job(self):
        """

        :rtype: CloudLoadTestingJob
        """
        api_client = self.__get_api_client()

        info = self.get_generator_info()
        port = info.port
        loadscheme = [] if isinstance(info.rps_schedule, (str, dict)) else info.rps_schedule

        lp_job = CloudLoadTestingJob(
            client=api_client,
            target_host=self.target,
            target_port=port,
            number=self.cfg.get('jobno', self.core.test_id),
            token=self.get_option('upload_token', default_value='mocktoken'),
            person=self.__get_operator(),
            task=self.task,
            name=self.get_option('job_name', 'untitled'),
            description=self.get_option('job_dsc'),
            tank=self.core.job.tank,
            notify_list=self.get_option("notify"),
            load_scheme=loadscheme,
            version=self.get_option('ver'),
            log_data_requests=self.get_option('log_data_requests'),
            log_monitoring_requests=self.get_option('log_monitoring_requests'),
            log_status_requests=self.get_option('log_status_requests'),
            log_other_requests=self.get_option('log_other_requests'),
            add_cleanup=lambda: self.add_cleanup(self.close_job))
        lp_job.send_config(LPRequisites.CONFIGINITIAL, yaml.dump(self.core.configinitial))
        return lp_job

    @property
    def task(self):
        if self._task is None:
            task = self.get_option('task')
            if task == 'dir':
                task = self.search_task_from_cwd(os.getcwd())
            self._task = task
        return self._task

    @property
    def api_token(self):
        return

    @staticmethod
    def read_token(filename):
        if filename:
            LOGGER.debug("Trying to read token from %s", filename)
            try:
                with open(filename, 'r') as handle:
                    data = handle.read().strip()
                    LOGGER.info(
                        "Read authentication token from %s, "
                        "token length is %d bytes", filename, len(str(data)))
            except IOError:
                LOGGER.error(
                    "Failed to read Overload API token from %s", filename)
                LOGGER.info(
                    "Get your Overload API token from https://overload.yandex.net and provide it via 'overload.token_file' parameter"
                )
                raise RuntimeError("API token error")
            return data
        else:
            LOGGER.error("Overload API token filename is not defined")
            LOGGER.info(
                "Get your Overload API token from https://overload.yandex.net and provide it via 'overload.token_file' parameter"
            )
            raise RuntimeError("API token error")

    def get_generator_info(self):
        return self.core.job.generator_plugin.get_info()

    @property
    def target(self):
        if self._target is None:
            self._target = self.get_generator_info().address
            LOGGER.info("Detected target: %s", self.target)
        return self._target


class JobInfoWidget(AbstractInfoWidget):
    def __init__(self, sender):
        # type: (Plugin) -> object
        AbstractInfoWidget.__init__(self)
        self.owner = sender

    def get_index(self):
        return 1

    def render(self, screen):
        template = "Author: " + screen.markup.RED + "%s" + \
                   screen.markup.RESET + \
                   "%s\n   Job: %s %s\n  Task: %s %s\n   Web: %s%s"
        data = (self.owner.lp_job.person[:1], self.owner.lp_job.person[1:],
                self.owner.lp_job.number, self.owner.lp_job.name, self.owner.lp_job.task,
                # todo: task_name from api_client.get_task_data()
                self.owner.lp_job.task, self.owner.lp_job.api_client.base_url,
                self.owner.lp_job.number)

        return template % data


class CloudLoadTestingJob(object):
    def __init__(
        self,
        client,
        target_host,
        target_port,
        person,
        task,
        name,
        description,
        tank,
        log_data_requests=False,
        log_other_requests=False,
        log_status_requests=False,
        log_monitoring_requests=False,
        number=None,
        token=None,
        notify_list=None,
        version=None,
        detailed_time=None,
        load_scheme=None,
        add_cleanup=lambda: None
    ):
        """
        :param client: APIClient
        :param log_data_requests: bool
        :param log_other_request: bool
        :param log_status_requests: bool
        :param log_monitoring_requests: bool
        """
        assert bool(number) == bool(
            token), 'Job number and upload token should come together'
        self.log_other_requests = log_other_requests
        self.log_data_requests = log_data_requests
        self.log_status_requests = log_status_requests
        self.log_monitoring_requests = log_monitoring_requests
        self.name = name
        self.tank = tank
        self.target_host = target_host
        self.target_port = target_port
        self.person = person
        self.task = task
        self.interrupted = threading.Event()
        self._number = number
        self._token = token
        self.api_client = client
        self.notify_list = notify_list
        self.description = description
        self.version = version
        self.detailed_time = detailed_time
        self.load_scheme = load_scheme
        self.is_finished = False
        self.web_link = ''
        self.add_cleanup = add_cleanup
        if self._number:
            self.add_cleanup()

    def push_test_data(self, data, stats):
        if not self.interrupted.is_set():
            try:
                self.api_client.push_test_data(
                    data, stats, self.interrupted)
            except (CloudGRPCClient.NotAvailable, RuntimeError):
                LOGGER.warn('Failed to push test data')
                self.interrupted.set()

    def edit_metainfo(self, *args, **kwargs):
        LOGGER.info('Cloud service has already setted metainfo')

    @property
    def number(self):
        if not self._number:
            raise self.UnknownJobNumber('Job number is unknown')
        return self._number

    def close(self, *args, **kwargs):
        LOGGER.debug('Cannot close job in the cloud mode')

    def create(self, *args, **kwargs):
        LOGGER.debug('Job was created on the cloud side')

    def send_status(self, *args, **kwargs):
        LOGGER.debug('Tank client is sending the status')

    def get_task_data(self, *args, **kwargs):
        pass

    def send_config(self, *args, **kwargs):
        LOGGER.debug('Do not send config to the cloud service')

    def push_monitoring_data(self, *args, **kwargs):
        pass

    def push_events_data(self, *args, **kwargs):
        pass

    def lock_target(self, *args, **kwargs):
        LOGGER.debug('Cannot lock a cloud target')

    def set_imbalance_and_dsc(self, rps, comment, timestamp):
        return self.api_client.set_imbalance_and_dsc(self.number, rps, comment, timestamp)

    def is_target_locked(self, *args, **kwargs):
        pass


class EventsReader(FileScanner):
    """
    Parse lines and return stats
    """

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

    def _read_data(self, lines):
        results = []
        for line in lines:
            # 2018-03-30 13:40:50,541\tCan't get monitoring config
            data = line.split("\t")
            if len(data) > 1:
                timestamp, message = data[0], data[1]
                dt = datetime.datetime.strptime(timestamp, '%Y-%m-%d %H:%M:%S,%f')
                unix_ts = int(time.mktime(dt.timetuple()))
                results.append([unix_ts, message])
        return results
