# -*- coding: utf-8 -*-

"""
Базовый класс для парсеров.

Основные части парсера:
  - L{DatetimeMaster} - объект, вычисляющий дату и время;
  - L{IDMap} - таблица преорбазования котировок;
  - L{Parser} - собственно парсер, который загружает таблицы преобрзования и
                прочие параметры.
"""

import sys
from datetime import datetime
from datetime import timedelta

from stocks3.core.config import Configurable, ConfigurationError, str_to_bool
from stocks3.core.exception import S3Exception
from stocks3.core.quotesconfig import QuoteError
from stocks3.core.sharedregion import SHARED_REGION
from stocks3.share.dateandtime import str_to_timedelta

__author__ = "Zasimov Alexey"
__email__ = "zasimov-a@yandex-team.ru"


class ParserError(Exception):
    pass


class S3ParserException(S3Exception):
    STAGE = "parse"


class DatetimeMaster(object):
    """
    Реализует уже очень интересный алгоритм обработки даты и времени.
    """

    def __init__(self, format, format_variants, closetime):
        self.format = format
        self.format_variants = format_variants
        self.closetime = closetime

    def _strptime_with_variants(self, dt):
        """
        Пытаемся пропарсить дату и время, используя различные форматы.
        """
        try:
            return datetime.strptime(dt, self.format)
        except ValueError as e:
            # Не подошел формат, тестируем различные варианты формата.
            for format in self.format_variants:
                try:
                    return datetime.strptime(dt, format)
                except ValueError:
                    continue
            raise e

    def _get_closetime(self):
        """
        Если closetime содержит now, то возвращается текущее время.
        """
        if self.closetime == "now":
            now = datetime.today()
            return now.strftime("%H:%M:%S")
        else:
            return self.closetime

    def strptime(self, dt, with_closetime=True):
        # Приклеиваем closetime, если оно есть и его нужно приклеить
        if with_closetime:
            if self.closetime is not None:
                dt = dt + ' ' + self._get_closetime()
        # Пытаемся пропарсить дату, используя различные варианты
        date = self._strptime_with_variants(dt)
        return date


class IDMap(object):
    """
    Карта преобразования котировок.
    """

    def __init__(self, parser, datetimeMaster, quotes_config, id_map: dict, region: int):
        assert type(id_map) == dict, "ID-Map must be map."
        assert type(region) == int, "Region must be integer."
        self._parser = parser
        self._datetimeMaster = datetimeMaster
        self._quotes_config = quotes_config
        self._id_map = id_map
        self.region = region

    def iteritems(self):
        return self._id_map.items()

    def items(self):
        return self._id_map.items()

    def values(self):
        return self._id_map.values()

    def s3status_iteritems(self):
        return self._id_map.items()

    def _get_quote_param(self, external_quote_id, param_name):
        return self._id_map[external_quote_id][param_name]

    def get_quote_param(self, external_quote_id, param_name):
        """
        Позволяет получить параметры котировки из xml конфига source'а
        """
        # print external_quote_id, external_quote_id in self._id_map, param_name,
        # param_name in self._id_map[external_quote_id], self._id_map[external_quote_id]
        if external_quote_id in self._id_map and param_name in self._id_map[external_quote_id]:
            return self._id_map[external_quote_id][param_name]
        else:
            return None

    def getQuoteName(self, external_quote_id):
        """
        По внешнему идентификатору котировки возвращает внутренний
        идентификатор.
        """
        return self._get_quote_param(external_quote_id, "to")

    def useClosetime(self, external_quote_id):
        try:
            return self._get_quote_param(external_quote_id, "use_closetime")
        except KeyError:
            return True

    def getQuoteRegion(self, quote):
        """
        Возвращает регион котировки. Редкоиспользуемая и тупая функция (используется из QInfo).
        """
        for quote_info in self._id_map.values():
            map_quote = quote_info["quote"]
            if map_quote is not None and map_quote.quote_id == quote.quote_id:
                region = quote_info["region"]
                if region is not None:
                    return region
                if self.region is not None:
                    return self.region
                return SHARED_REGION
        return SHARED_REGION

    def getActiveQuotesNames(self):
        """
        Возвращает список активных котировок, о которых знает парсер.
        """
        return map(lambda x: x["to"], filter(lambda x: x["active"], self._id_map.values()))

    def _add_timedelta_if_need(self, external_quote_id, date):
        """
        """
        add = self._get_quote_param(external_quote_id, "add")
        if add is not None:
            return date + add
        else:
            return date

    def _correct_zero_time_if_need(self, external_quote_id, date):
        """
        Корректируем нулевое время. Некоторые источники (например, финам) может
        прислать 17.08.2011 00:00:00. В реальности это значение относится к
        предыдущему дню и время должно быть изменено на 16.08.2011 23:59:59."
        """
        correct_zero_time = self._get_quote_param(external_quote_id, "correct_zero_time")
        # if correct_zero_time and date.strftime("%H%M%S") == "000000":
        if correct_zero_time and date.strftime("%M%S") == "0000":
            return date - timedelta(seconds=1)
        else:
            return date

    def _rewrite_time_if_need(self, external_quote_id, date):
        force_time = self._get_quote_param(external_quote_id, "force_time")
        if force_time is not None:
            # Перезаписываем время котировки на нужное нам.
            sdate = date.strftime("%d.%m.%Y")
            date = datetime.strptime((sdate + " " + force_time), "%d.%m.%Y %H:%M:%S")
        return date

    def strptime(self, external_quote_id, dt, use_closetime=True):
        """
        Разбираем время и дату dt.

        @param external_quote_id: Внешний идентификатор котировки.
        @param dt: Время и дата.
        @param use_closetime: Следует ли использовать closetime при формировании времени.
        """

        use_closetime = use_closetime and self._get_quote_param(external_quote_id, "use_closetime")
        date = self._datetimeMaster.strptime(dt, use_closetime)
        date = self._add_timedelta_if_need(external_quote_id, date)
        date = self._correct_zero_time_if_need(external_quote_id, date)
        date = self._rewrite_time_if_need(external_quote_id, date)
        return date

    def _getActiveQuote(self, external_quote_id):
        try:
            quote_info = self._id_map[external_quote_id]
            if not quote_info["active"]:
                return None
            name = quote_info["to"]
        except KeyError as e:
            if self._parser.skip_unknown_quotes:
                return None
            else:
                raise QuoteError("Unknown external quote %s" % str(e))
        return self._quotes_config.getQuote(name)

    def getQuoteObject(self, external_quote_id):
        return self._getActiveQuote(external_quote_id)

    def getActiveQuoteObject(self, external_quote_id):
        """
        Иногда требуется получить объект котировки по внешнему имени.

        Замечено в s3status.

        FIXME: уничтожить этот дубликат.
        """
        return self._getActiveQuote(external_quote_id)

    def _correct_price_tz(self, external_quote_id, price):
        """
        Устанавливает для текущей котировки экзотическую временную зону.
        """
        tz = self._id_map[external_quote_id]["tz"]
        if tz is not None:
            price.setTimezone(tz)
        return price

    def _correct_price_closetime(self, external_quote_id, price):
        """
        Некоторые котировки в поле время содержат вбитое нами значение. Такие
        котировки помечаются флажком.
        """
        if self._datetimeMaster.closetime is not None:
            price.set_with_closetime(self._get_quote_param(external_quote_id, "use_closetime"))

    def _correct_price_region(self, external_quote_id, price):
        # Корректируем регион, значение котировки может быть актуально только
        # для определенного региона
        item_region = self._id_map[external_quote_id]["region"]
        if item_region is not None:
            price.onlyForRegion(item_region)
        elif self.region is not None and self.region != SHARED_REGION:
            price.onlyForRegion(self.region)
        return price

    def _correct_unconfirmed(self, external_quote_id, price):
        """
        Корректируем флажок unconfirmed.
        """
        price.unconfirmed = self._id_map[external_quote_id]["unconfirmed"]
        return price

    def _normalize_if_need(self, external_quote_id, price):
        """
        FIXME: похожая функция есть у QCalculator'a.
        """
        if self._id_map[external_quote_id]["normalize"]:
            price.normalize()
        return price

    def _correct_price(self, external_quote_id, price):
        """
        Выполняет ряд корректировок:
            - корректирует значение флажка with_closetime;
            - корректирует временную зону данных;
            - корректирует регион, к которому относятся данные;
            - корректируем флажок unconfirmed.
        """
        self._correct_price_closetime(external_quote_id, price)
        price = self._correct_price_tz(external_quote_id, price)
        self._correct_price_region(external_quote_id, price)
        self._correct_unconfirmed(external_quote_id, price)
        return self._normalize_if_need(external_quote_id, price)

    def _scale_buy(self, external_quote_id, value):
        return self._id_map[external_quote_id]["buy_scale"] * value

    def _scale_sell(self, external_quote_id, value):
        return self._id_map[external_quote_id]["sell_scale"] * value

    def make_price(self, external_quote_id: str, date: datetime, value: float):
        """
        Настоятельно рекомендуется использовать именно этот метод для создания
        котировки. Учитывает exotic_tz.
        """
        assert type(date) == datetime
        assert type(value) == float
        quote = self._getActiveQuote(external_quote_id)

        if quote is None:
            return quote
        else:
            value = self._scale_buy(external_quote_id, value)
            price = quote.make_price(date, value)
            return self._correct_price(external_quote_id, price)

    def make_dual_price(self, external_quote_id, date, buy_value, sell_value):
        """
        Настоятельно рекомендуется использовать именно этот метод для создания
        двойной котировки. Учитывает exotic_tz.
        """
        assert type(date) == datetime
        assert type(buy_value) == float and type(sell_value) == float
        quote = self._getActiveQuote(external_quote_id)
        if quote is None:
            return quote
        else:
            buy_value = self._scale_buy(external_quote_id, buy_value)
            sell_value = self._scale_sell(external_quote_id, sell_value)
            price = quote.make_dual_price(date, buy_value, sell_value)
            return self._correct_price(external_quote_id, price)

    def make_price_date(self, external_quote_id: str, source_date: str, value: float, use_closetime=True):
        """
        Создает объект с данными котировки. Отличается от L{IDMap.make_price}
        тем, что принимает сырую дату.
        """
        assert type(source_date) == str
        assert type(value) == float
        assert type(source_date) == str, "For make_price_date expected string with datetime."
        # FIXME: дублируется
        quote = self._getActiveQuote(external_quote_id)
        if quote is None:
            return quote
        return self.make_price(external_quote_id,
                               self.strptime(external_quote_id, source_date, use_closetime),
                               value)

    def make_dual_price_date(self, external_quote_id, source_date, buy_value, sell_value, use_closetime=True):
        """
        Аналогична L{IDMap.make_dual_price}, но принимает сырую дату.
        """
        assert type(source_date) == str
        assert type(buy_value) == float and type(sell_value) == float
        assert type(source_date) == str, "For make_dual_price_date expected string with datetime."
        # FIXME: дублируется
        quote = self._getActiveQuote(external_quote_id)
        if quote is None:
            return quote
        return self.make_dual_price(external_quote_id,
                                    self.strptime(external_quote_id, source_date, use_closetime),
                                    buy_value, sell_value)


class Parser(Configurable):
    """
    Парсер - разбирает данные, забранные из источника.

    Основная задача этого класса - загрузить карты котировок (L{IDMap}).
    """

    DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
    # Используется из свойства default_map
    DEFAULT_ID_MAP = "default"

    places = []  # список классов мест (Places), с которыми может работать этот

    # транспорт

    def __init__(self, tree=None, root=None, source=None, config=None):
        if config is not None:
            self.source = source
            self.config = config
            self.quotes_config = self.source.default.quotes_config

            self.datetime_format = config.get('datetime-format', self.DEFAULT_DATETIME_FORMAT)
            self.skip_unknown_quotes = config.get('skip-unknown-quotes', False)
            self.closetime = config.get('closetime', None)
            # Флажок используется при экспорте для выброски unixtime из него
            self.drop_unixtime = config.get('drop-unixtime', False)
            self._id_maps = {}
            self._format_variants = []
            self._load_idmaps_json()

            # Configurable.__init__(self, None, None)
        else:
            self.source = source
            self.quotes_config = self.source.default.quotes_config
            Configurable.__init__(self, tree, root)

    def _get_default_map(self):
        return self._id_maps[self.DEFAULT_ID_MAP]

    default_map = property(_get_default_map)

    def _get_maps(self):
        return self._id_maps

    maps = property(_get_maps)

    def makeConfig(self):
        """
        Читаем стандартные параметры парсера.
        """
        self.datetimeFormat = self.readString('', "datetime-format", self.DEFAULT_DATETIME_FORMAT)
        self.skip_unknown_quotes = self.readBool('', 'skip-unknown-quotes', False)
        self.closetime = self.readString('', 'closetime', '') or None
        # Флажок используется при экспорте для выброски unixtime из него
        self.drop_unixtime = self.readBool('', 'drop-unixtime', False)
        self._id_maps = {}
        self._read_format_variants()
        self._load_id_maps()

    def _read_format_variants(self):
        """
        Некоторые источники умудряются присылать дату в разном формате (сегодня такая, а завтра другая).
        Список _format_variants содержит возможные форматы даты. Программа сама находит подходящий.
        Cписок self._format_variants передается в L{DatetimeMaster}.
        """
        self._format_variants = []
        for variant in self.node.findall('format-variants/format'):
            self._format_variants.append(variant.text.strip())

    def parse(self, place):
        """
        Проверяет, может ли парсер работать с переданным местом (place).
        Этот метод вызывается из дочерних классов для выполнения описанной
        проверки.
        """
        return []

    def run_parse(self, place, skip_cache=False):
        """
        Этот метод используется источником и в отличии от L{Parser.parse}
        заворачивает искючения в специальную обертку.
        """
        # FIXME: очень интересная ошибка. Вот что происходит, если
        # исключение просочится из генератора.
        # Если написать return self.parse(place), то исключения не будут
        # отлавливаться throw_only'ом.
        try:
            if skip_cache or place.check_new_content():
                for x in self.parse(place):
                    yield x
            else:
                self._info('skip parsing - old content')
                return []
        except Exception as e:
            # FIXME: плохой блок
            # Здесь мы отлавливаем все исключения и заворачиваем их в
            # S3ParserException.
            exc_info = sys.exc_info()
            e = S3ParserException(e, self)
            e.exc_info = exc_info
            raise e

    def _load_idmaps_json(self):
        for idmap_name, idmap in self.config.get('id-maps', {}).items():
            self._id_maps[idmap_name] = self._load_idmap_json(idmap_name, idmap)

    def _load_idmap_json(self, idmap_name, idmap_data):
        """
        Загружает словарь отображений внешних идентификаторов котировок на
        внутренние. Дело в том, что часто в данных источника котировки как-то
        идентифицируются. Внутренние же идентификаторы котировок отличаются от
        внешних. Словарь используется для преобразования идентификаторов.
        """
        id_map = {}
        map_region = int(SHARED_REGION)
        for node in idmap_data:
            id_from = node.get("from")
            id_to = node.get("to")

            # Проверяем - в карте не должна числиться одна котировка два раза
            if id_from in id_map:
                raise ConfigurationError("Duplicate entry in id-map: %s" % id_from)

            # Пытаемся найти котировку:
            try:
                quote = self.quotes_config.getQuote(id_to)
            except QuoteError:
                # NOTE: Если не находим, ничего страшного
                quote = None

            quote_info = {
                "to": id_to,
                "active": node.get("active", True),
                "tz": node.get("timezone", None),
                # Этот флажок влияет на способ формирования даты и времени (L{Parser.strpdate}).
                # Если он стоит и установлен параметр closetime, то для данной котировки
                # closetime использоваться не будет.
                "use_closetime": node.get("use-closetime", True),
                # Добавочка ко времени. Используется некоторыми парсерами для корректировки времени.
                "add": str_to_timedelta(node.get("add")) if "add" in node else None,
                "correct_zero_time": node.get("correct-zero-time", False),
                "region": node.get("region", 0),  # custom region
                "quote": quote,
                "drop_unixtime": node.get("drop-unixtime", False),  # Смотрим, надо ли сбрасывать unixtime
                "buy_scale": float(node.get("buy-scale", "1.0")),
                "sell_scale": float(node.get("sell-scale", "1.0")),
                "unconfirmed": node.get("unconfirmed", False),
                "force_time": node.get("force-time", None),  # Для котировки вбиваем время руками.
                "normalize": node.get("normalize", False),
                "use_source_lotsize": node.get("use_source_lotsize", False),  # информация о лоте в данных поставщика
            }

            id_map[id_from] = quote_info

        # Собираем и возвращаем карту импортируемых котировок.
        # Здесь DatetimeMaster - объект, управляющий преобразованием строчного
        # времени в datetime.
        # region - регион, к которому привязывается эта карта.
        return IDMap(self,
                     DatetimeMaster(self.datetime_format, self._format_variants, self.closetime),
                     self.quotes_config,
                     id_map,
                     map_region)

    def _load_id_map(self, idmap_name):
        """
        Загружает словарь отображений внешних идентификаторов котировок на
        внутренние. Дело в том, что часто в данных источника котировки как-то
        идентифицируются. Внутренние же идентификаторы котировок отличаются от
        внешних. Словарь используется для преобразования идентификаторов.
        """
        id_map = {}
        idmap_root = self._get_node_by_path("id-maps/%s" % idmap_name)

        # Читаем регион, к которому привязана карта
        # Если регион не указан считаем, что карта привязана к общему региону.
        mapRegion = int(idmap_root.attrib.get("region", SHARED_REGION))

        idmap_node = self._get_nodes_by_path("id-maps/%s/id" % idmap_name)
        for node in idmap_node:
            from_ = node.attrib["from"]
            to = node.attrib["to"]

            # Проверяем - в карте не должна числиться одна котировка два раза
            if from_ in id_map:
                raise ConfigurationError("Duplicate entry in id-map: %s" % from_)

            active = str_to_bool(node.attrib.get("active", "True"))
            # Временная зона, к которой привязана котировка.
            # Используется при коррекировке временной зоны
            # (L{IDMap._correct_price_tz}).
            tz = node.attrib.get("timezone", None)
            # Этот флажок влияет на способ формирования даты и времени
            # (L{Parser.strpdate}).
            # Если он стоит и установлен параметр closetime, то для данной
            # котировки closetime использоваться не будет.
            use_closetime = str_to_bool(node.attrib.get("use-closetime", "True"))
            # Добавочка ко времени. Используется некоторыми парсерами для
            # корректировки времени.
            add = node.attrib.get("add", "") or None
            if add is not None:
                add = str_to_timedelta(add)

            # Надо ли корректировать нули для этой котировки?
            correct_zero_time = str_to_bool(node.attrib.get("correct-zero-time", "False"))

            # Смотрим, надо ли сбрасывать unixtime при экспорте этой котировки:
            drop_unixtime = str_to_bool(node.attrib.get("drop-unixtime", False))

            # Читаем номер региона, к которому привязывается котировка.
            # Этот параметр используется при создании L{Price}
            # (L{IDMap.make_price}, например).
            region = int(node.attrib.get("region", 0)) or None

            # Флажок ставится, если это котировка левенькая, неподтвержденная
            unconfirmed = str_to_bool(node.attrib.get("unconfirmed", "False"))

            # Для котировки вбиваем время руками.
            force_time = node.attrib.get("force-time", "") or None

            # Пытаемся найти котировку:
            try:
                quote = self.quotes_config.getQuote(to)
            except QuoteError:
                # NOTE: Если не находим, ничего страшного
                quote = None

            # Масштабирование при разборе
            # Используется в make_price, make_dual_price, make_price_date, make_dual_price_date
            buy_scale = float(node.attrib.get("buy-scale", "1.0"))
            sell_scale = float(node.attrib.get("sell-scale", "1.0"))

            # Нормализация
            normalize = str_to_bool(node.attrib.get("normalize", "False"))

            use_source_lotsize = str_to_bool(node.attrib.get("use_source_lotsize", "False"))

            quote_info = {"to": to,
                          "active": active,
                          "tz": tz,
                          "use_closetime": use_closetime,
                          "add": add,
                          "correct_zero_time": correct_zero_time,
                          "region": region,
                          "quote": quote,
                          "drop_unixtime": drop_unixtime,
                          "buy_scale": buy_scale,
                          "sell_scale": sell_scale,
                          "unconfirmed": unconfirmed,
                          "force_time": force_time,
                          "normalize": normalize}

            if use_source_lotsize:
                quote_info['use_source_lotsize'] = use_source_lotsize

            id_map[from_] = quote_info

        # Собираем и возвращаем карту импортируемых котировок.
        # Здесь DatetimeMaster - объект, управляющий преобразованием строчного
        # времени в datetime.
        # region - регион, к которому привязывается эта карта.
        return IDMap(self,
                     DatetimeMaster(self.datetimeFormat, self._format_variants, self.closetime),
                     self.quotes_config,
                     id_map,
                     mapRegion)

    def _load_id_maps(self):
        """
        Загружаем карты котировок.
        """
        if self.node.find("id-maps") is None:
            raise ConfigurationError("Id-map not found. Please check configuration.")
        for map_node in self.node.find("id-maps"):
            # FIXME: ужасное условие
            if type(map_node.tag) != str or len(map_node.tag) == 0:
                continue
            map_name = map_node.tag
            self._id_maps[map_name] = self._load_id_map(map_name)

    def s3status_get_idmap(self):
        """
        Используется в s3status для построения таблицы котировок.
        NOTE: Хорошо бы избавиться от этой функции.
        """
        for mapname, mp in self._id_maps.items():
            for from_, quote_info in mp.s3status_iteritems():
                yield mapname, from_, quote_info["to"]

    def getActiveQuotesNames(self):
        """
        Используется только из Source!
        Возвращает список импортируемых этим парсером котировок.
        """
        s = set()
        for mp in self._id_maps.values():
            s = s.union(mp.getActiveQuotesNames())
        for x in s:
            yield x

    def getQuoteInfo(self, region, my_quote):
        """
        Парсер имеет дополнительную информацию о котировке (смотрим
        L{Parser._load_id_map}).
        Эта функция может использоваться где угодно и нужна для получения этой
        дополнительной информации.
        Функция не генерирует исключений, может возвращать пустой словарик.
        """
        for name, map_ in self._id_maps.items():
            if map_.region == region:
                for from_, quote_info in map_.items():
                    quote = quote_info["quote"]
                    if quote is not None and quote.quote_id == my_quote.quote_id:
                        return quote_info
        return {}
