#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys

sys.path.insert(0, '/opt/direct-py/startrek-python-client-sni-fix')
from startrek_client import Startrek

import os
import subprocess
import re
import yaml
import argparse
import logging
import time
import requests
import dateutil.parser

import direct_juggler.juggler as dj
from kazoo.client import KazooClient
from functools import wraps
from kazoo.exceptions import NoNodeError

logging.getLogger('startrek_client').setLevel(logging.CRITICAL)

pathlist = ["/usr/local/sbin", "/usr/local/bin", "/usr/sbin", "/usr/bin", "/sbin", "/bin", "/usr/games", "/usr/local/games"]
os.environ["PATH"] = os.pathsep.join(pathlist)

HOSTNAME = subprocess.check_output(['hostname', '-f']).strip()
SCRIPT_NAME = os.path.basename(__file__)
SIGN = u"----\nСкрипт %s с машины %s" % (SCRIPT_NAME, HOSTNAME)
SIGN_RECOGNIZER = u"\n" + SIGN[:12]

zk_servers = ['ppcback01f.yandex.ru:2181', 'ppcback01e.yandex.ru:2181', 'ppcback01i.yandex.ru:2181']
zkh = None

STARTREK_TOKEN_FILE = '/etc/direct-tokens/startrek'
with open(STARTREK_TOKEN_FILE) as st_fd:
    startrek_token = st_fd.readline().strip()
startrek_client = Startrek(token=startrek_token, useragent=SCRIPT_NAME)

APPS_CONFIG_FILE = '/etc/yandex-direct/direct-apps.conf.yaml'
with open(APPS_CONFIG_FILE, "r") as apps_fd:
    APPS_CONF = yaml.load(apps_fd)

CONDUCTOR_API_URL = 'http://c.yandex-team.ru/api'
COMMON_ZK_PREFIX = '/direct/versions_current'

ALERT_ISSUE_SUMMARY = "Неизвестная версия на серверах приложения %s"
EXCEPTION_MESSAGE = "unexpected error"
NO_VERSION_VALUE = u"нет версии"
DELIMITER = u": "
DESCRIPTION_INTRODUCTION = u"На следующих серверах неожиданные версии:"
COMMENT_INTRODUCTION = u"Поменялось описание тикета!"
FINISH_TAG = u"можно_закрывать"
SERVICE_NAME = "%s%s" % ("scripts.%s" % SCRIPT_NAME, ".%s.working")
IGNORE_FEATURE_NAME = "DIRECTALERTS_version"


def zk_sync_init(hosts):                         
    """
    Подключаемся к zookeeper. Если подключение удалось, то выставляем (0, <zk connect>),
    если нет - (2, <error message>).
    """
    try:
        zk = KazooClient(
            hosts=hosts,
            read_only=False,
            timeout=1.0,
            connection_retry=3,
            command_retry=3,
            logger=logger
        )
        zk.start()
        return zk
    except Exception as err:
        logger.critical("can't connect to zookeeper")
        sys.exit(2)


def retry(ExceptionToCheck=Exception, tries=3, delay=1):
    """
    декоратор ретраев
    """

    def deco_retry(f):
        @wraps(f)
        def f_retry(*args, **kwargs):
            for i in xrange(tries):
               try:
                  return f(*args, **kwargs)
               except ExceptionToCheck as e:
                  msg = "%s, Retrying in %s seconds..." % (str(e), delay)
                  logger.debug(str(msg))
                  if tries == i + 1:
                      raise
                  time.sleep(delay)
            return f(*args, **kwargs)
        return f_retry
    return deco_retry


def set_logger(debug=False):
    log_level = logging.DEBUG if debug else logging.ERROR
    logger = logging.getLogger('ConsoleLogging')
    logger.setLevel(logging.DEBUG)

    ch = logging.StreamHandler()
    ch.setFormatter(logging.Formatter('%(levelname)-8s [%(asctime)s] %(message)s'))
    ch.setLevel(log_level)
    logger.addHandler(ch)

    return logger


def parse_options():
    parser = argparse.ArgumentParser(
        description="Проверяет наличие установленной версии пакета-приложения в startrek",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="Пример использования:\n\t %(prog)s --depth 5 --queue DIRECTALERTS"
    )

    parser.add_argument('--debug', action='store_true',
        dest="debug", help="debug режим (default: False)")
    parser.add_argument('-d', '--depth', type=int, default=10,
        dest="depth", help="количество закрытых релизов, в которых нужно искать версию (default: 10)")
    parser.add_argument('-q', '--queue', type=str, default="DIRECTALERTS",
        dest="queue", help="очередь в startrek для создания тикетов (default: DIRECTALERTS)")

    opts, extra = parser.parse_known_args()

    if len(extra) > 0:
        parser.error("There are unknown parameters")

    if opts.depth < 1:
        parser.error("depth should be more than 0")

    return opts


@retry()
def get_groups2hosts(groups):
    """
    получить хосты кондукторных групп
    """
    req = requests.get("%s/%s/%s" % (CONDUCTOR_API_URL, 'groups2hosts', ",".join(groups)))
    return [host for host in req.content.split("\n") if host]


@retry()
def get_version_from_zk(app, host):
    """
    получить текущую версию для заданного приложения и хоста
    """

    try:
        content = zkh.get("%s/%s/%s/dpkg" % (COMMON_ZK_PREFIX, app, host))[0]
        return content[:content.find("\n")]
    except NoNodeError:
        return NO_VERSION_VALUE


def get_hosts2versions(app, hosts):
    """
    получить словарь для данного приложения: хост -> версия
    """
    return {host: get_version_from_zk(app, host) for host in hosts}


def get_version_from_summary(summary):
    """
    достаем версию из названия тикета
    """
    version = re.search(r'[0-9]+\..+$', summary)
    if version:
        return version.group(0)
    else:
        return ""


def get_hosts2versions_from_ticket(ticket_description):
    """
    получаем структуру следующего вида:
    {"host": {"version": "1.23445-1", "actual": True}}
    actual - проблема до сих пор не решена (в тикете строка не зачеркнута)
    """
    hosts2versions = {}
    for line in ticket_description.split("\n"):
        actual = True
        if line.startswith('--') and line.endswith('--'):
            actual = False
            line = line[2:-2]

        pair = line.split(DELIMITER)
        if len(pair) == 2:
            hosts2versions[pair[0]] = {'version': pair[1], 'actual': actual}

    return hosts2versions


def build_description(hosts2versions):
    """
    построить описание тикета
    """
    description = u"%s\n" % DESCRIPTION_INTRODUCTION
    entries = sorted(
        (host, hosts2versions[host]['version'], hosts2versions[host]['actual'])
        for host in hosts2versions
    )
    description += u"\n".join(
        (u"%s%s%s" if entry[2] else u"--%s%s%s--") % (entry[0], DELIMITER, entry[1])
        for entry in entries
    )

    return description


def process_hosts2versions(ticket_hosts2versions, hosts2versions, versions):
    """
    обновляем ticket_hosts2versions, основываясь на текущих
    данных в hosts2versions и versions
    """

    # хосты, которые есть в тикете, но которых уже нет в кондукторной группе
    # и мы узнали про это в-первые (чтобы в последующих комментариях не писать про это
    absent_hosts = []

    for host in ticket_hosts2versions:
        if host in hosts2versions and hosts2versions[host] in versions:
            ticket_hosts2versions[host]['version'] = hosts2versions[host]
            ticket_hosts2versions[host]['actual'] = True
        else:
            if host not in hosts2versions and ticket_hosts2versions[host]['actual']:
                absent_hosts.append(host)

            ticket_hosts2versions[host]['actual'] = False

    for host in hosts2versions:
        if hosts2versions[host] in versions and host not in ticket_hosts2versions:
            ticket_hosts2versions[host] = {'version': hosts2versions[host], 'actual': True}

    return ticket_hosts2versions, absent_hosts


def find_bad_versions(versions, app, depth):
    """
    из полного списка versions оставляем только отсутствующие версии
    """
    releases = startrek_client.issues.find(
        'Queue: DIRECT Type: Release Components: "%s" "Sort by": key desc' % APPS_CONF['apps'][app]["tracker-component"]
    )
    releases = list(releases)[:depth]

    # проверяем только по названиям релизов
    for release in releases:
        summary_version = get_version_from_summary(startrek_client.issues[release.key].summary)

        if startrek_client.issues[release.key].status.key in ['closed', 'readyToDeploy'] and summary_version in versions:
            versions.remove(summary_version)

    # проверяем полностью по всей истории
    for release in releases:
        if not versions:
            break

        # находим момент окончания тестрования релиза, то есть когда статус стал rmAcceptance
        testing_done_dt = None

        # версия на момент, когда релиз перешёл в rmAcceptance
        version_when_tested = None

        changelog = startrek_client.issues[release.key].changelog.get_all(field=('status', 'summary'))
        ticket_summary_at_time = None
        summary_changed_dt = None
        for change in changelog:
            for field in change['fields']:
                try:
                    if testing_done_dt is None and field['field'].id == 'status' and field['to'].key == 'rmAcceptance':
                        testing_done_dt = dateutil.parser.parse(change.updatedAt)
                    if field['field'].id == 'summary':
                        ticket_summary_at_time = field['from']
                        # т. к. любые исключения поймаются, и цикл перейдёт на следующую итерацию (yukaba@: правда ли это нужно?), дополнительно фиксируем время изменения, чтобы быть уверенными, что при вычислении version_when_tested мы будем иметь заголовок по состоянию на время окончания тестирования
                        # что может произойти иначе: ticket_summary_at_time вычисляется успешно до testing_done_dt, выкидывает исключение после вычисления testing_done_dt, в результате в ticket_summary_at_time получаем раннюю (непроверенную) версию
                        summary_changed_dt = dateutil.parser.parse(change.updatedAt)
                    if testing_done_dt and ticket_summary_at_time:
                        break
                except:
                    logger.exception(EXCEPTION_MESSAGE)
            if testing_done_dt and ticket_summary_at_time and summary_changed_dt > testing_done_dt and not version_when_tested:
                version_when_tested = get_version_from_summary(ticket_summary_at_time)
                break

        # релиз не протестирован
        if not testing_done_dt:
            continue
        # не смогли найти версию по изменениям заголовка -- значит, заголовок не менялся
        if not version_when_tested:
            version_when_tested = get_version_from_summary(release.summary)
        if version_when_tested in versions:
            versions.remove(version_when_tested)

        # по комментариям идем от новых к старым; так проще вовремя остановиться
        # yukaba@: это ^^ имело смысл для логики "заглянуть в один комментарий раньше testing_done_dt", но после DIRECT-147530 большого смысла не имеет, можно идти в прямом порядке. Но переделывать не стал, чтобы патч был поменьше
        # yukaba@: возможно, от разбора комментариев вообще стоит отказаться и отслеживать только изменения summary
        comments = startrek_client.issues[release.key].comments.get_all()
        for comment in list(reversed(list(comments))):
            try:
                if dateutil.parser.parse(comment.createdAt) < testing_done_dt:
                    break

                found_versions = []
                search_result = re.search(u'^(?:Добавлены хотфиксы, версия: .+? --> (.+)|Собран пакет [^ ]+ версии +([^ ]+)|Добавлен хотфикс версии +([^ ]+))$', comment.text, re.U | re.M)
                if search_result:
                    found_versions = [ v for v in search_result.groups() if v is not None]

                # в found_versions сложили новую версию из комментария 
                # Если комментарий сделан после окончания тестирования релиза -- это хорошая версия, допустимая на продакшене

                if not found_versions:
                    continue

                for v in found_versions:
                    if v in versions:
                        versions.remove(v)

            except:
                logger.exception(EXCEPTION_MESSAGE)


def run():
    global logger, zkh

    options = parse_options()
    logger = set_logger(options.debug)
    zkh = zk_sync_init(",".join(zk_servers))

    for app in APPS_CONF['apps']:

        if 'sox' not in APPS_CONF['apps'][app]:
            continue

        if APPS_CONF['apps'][app]['sox'] != 1:
            continue

        try:
            # игнорирование проверки в конфиге приложений
            if IGNORE_FEATURE_NAME in APPS_CONF['apps'][app].get("ignore-features", []):
                continue

            # получаем список хостов данного приложения
            hosts = get_groups2hosts(APPS_CONF['apps'][app]['conductor_groups'])
            # получаем версии с данных хостов
            hosts2versions = get_hosts2versions(app, hosts)

            # версии с лимтеста нам не нужны
            if app == 'direct':
                for key in ["limtest1-direct.yandex.ru", "limtest2-direct.yandex.ru"]:
                    try:
                        del hosts2versions[key]
                    except:
                        pass

            versions = set(hosts2versions.values())

            # оставляем из всех версий только те, которых нет в стартреке
            find_bad_versions(versions, app, options.depth)

            # находим открытый тикет с описанием различий версий
            alert_issues = startrek_client.issues.find(
                'Queue: %s Summary: "%s" Status: !closed "Sort By": Key DESC' % (options.queue, ALERT_ISSUE_SUMMARY % app)
            )

            alert_issue = None
            if alert_issues:
                alert_issue = alert_issues[0]

            # если уже есть тикет для данного приложения, редактируем его
            if alert_issue:
                ticket_description = startrek_client.issues[alert_issue.key].description

                # description может быть None
                if not ticket_description:
                    ticket_description = ""

                # обрабатываем описание тикета
                ticket_hosts2versions = get_hosts2versions_from_ticket(ticket_description)
                # изменяем список из тикета, используя текущие данные
                # заодно получаем список отсутствующих хостов
                ticket_hosts2versions, absent_hosts = process_hosts2versions(
                    ticket_hosts2versions,
                    hosts2versions,
                    versions
                )

                description = build_description(ticket_hosts2versions)

                # сравниваем описания до начала подписи, так как машины могут быть разными
                if ticket_description[:ticket_description.find(SIGN_RECOGNIZER)] != description:
                    comment = COMMENT_INTRODUCTION

                    if absent_hosts:
                        comment += u"\n%s\n%s" % (
                            u"Следующие хосты больше не входят в кондукторную группу:",
                            u", ".join(sorted(absent_hosts))
                        )

                    # если ни одна из проблем не актуальна, сообщаем об этом и проставляем тэг
                    if not any(ticket_hosts2versions[host]['actual'] for host in ticket_hosts2versions):
                        comment += u"\n%s" % (u"**Проблем не осталось**")
                        startrek_client.issues[alert_issue.key].update(
                            tags=startrek_client.issues[alert_issue.key].tags + [FINISH_TAG]
                        )
                    else:
                        tags = startrek_client.issues[alert_issue.key].tags
                        if FINISH_TAG in tags:
                            tags.remove(FINISH_TAG)
                        startrek_client.issues[alert_issue.key].update(tags=tags)

                    startrek_client.issues[alert_issue.key].update(description=u"%s\n%s" % (description, SIGN))
                    startrek_client.issues[alert_issue.key].comments.create(text=u"%s\n%s" % (comment, SIGN))

            elif versions:
                description = u"%s\n" % DESCRIPTION_INTRODUCTION
                description += u"\n".join(
                    sorted(
                        "%s%s%s" % (host, DELIMITER, hosts2versions[host])
                        for host in hosts2versions if hosts2versions[host] in versions
                    )
                )

                new_alert_issue = startrek_client.issues.create(
                    queue=options.queue,
                    summary=ALERT_ISSUE_SUMMARY % app,
                    type={'name': 'Bug'},
                    description=u"%s\n%s" % (description, SIGN)
                )

            dj.queue_events([{'service': SERVICE_NAME % app, 'status': 'OK', 'description': 'OK'}])
        except:
            dj.queue_events([{'service': SERVICE_NAME % app, 'status': 'CRIT', 'description': 'exception occured'}])
            logger.exception(EXCEPTION_MESSAGE)

    if zkh is not None:
        zkh.stop()
        zkh.close()

    return


if __name__ == '__main__':
    run()

