#!/usr/bin/python3
from collections import Counter

import asyncio
import asyncpool
import filecmp
import logging
import os
import re
import shutil
import threading
import time
import traceback
from datetime import datetime, timedelta
from logzero import logger as log
from pyrogram.api.functions.contacts import Search
from pyrogram.api.functions.messages import DeleteHistory
from pyrogram.api.functions.users import GetUsers
from pyrogram.api.types import InputPeerUser
from pyrogram.api.types.contacts import ResolvedPeer
from pyrogram.errors import *
from telegram import Bot as TelegramBot, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.error import BadRequest
from tgintegration import InlineResultContainer, InteractionClientAsync, Response
from typing import Union

import captions
import helpers
import settings
import util
from const import CallbackActions
from helpers import make_sticker
from models import Bot, Bot as BotModel, Keyword

logging.getLogger().setLevel(logging.WARNING)

ZERO_CHAR1 = u"\u200C"  # ZERO-WIDTH-NON-JOINER
ZERO_CHAR2 = u"\u200B"  # ZERO-WIDTH-SPACE
botbuilder_pattern = re.compile('|'.join(settings.BOTBUILDER_DETERMINERS), re.IGNORECASE)
offline_pattern = re.compile('|'.join(settings.OFFLINE_DETERMINERS), re.IGNORECASE)

TMP_DIR = os.path.join(settings.BOT_THUMBNAIL_DIR, "tmp")
if os.path.exists(TMP_DIR):
    shutil.rmtree(TMP_DIR)
os.makedirs(TMP_DIR)


# class Stats:
#     def __init__(self, filepath):
#         _counter_file = Path(filepath).expanduser()
#         os.makedirs(_counter_file.parent, exist_ok=True)
#         _counter_file.touch()
#
#         self.store = shelve.open(str(_counter_file), writeback=True)
#
#         self.store.setdefault("sent_messages", 0)
#         self.store.setdefault("tracking_start", datetime.now())
#
#     def incr_count(self):
#         sent_messages = self.store["sent_messages"]
#         if sent_messages > 8000:
#             raise StopBotchecker
#         self.store["sent_messages"] = sent_messages + 1
#
#
# class StopBotchecker(Exception):
#     """ Raised when botcheckerworker is over limit """
#
#
# stats = Stats('~/.run/botlistbot/botcheckerworker-stats.json')

def zero_width_encoding(encoded_string):
    if not encoded_string:
        return None
    result = ''
    for c in encoded_string:
        if c in (ZERO_CHAR1, ZERO_CHAR2):
            result += c
        else:
            return result
    return None


class BotChecker(InteractionClientAsync):
    def __init__(self, event_loop, session_name, api_id, api_hash, phone_number, workdir=None):

        self.event_loop = event_loop
        self.username_flood_until = None
        self._message_intervals = {}
        self._last_ping = None
        self.__photos_lock = asyncio.Lock(loop=self.event_loop)

        super(BotChecker, self).__init__(
            session_name,
            api_id,
            api_hash,
            workers=4,
            phone_number=phone_number,
            workdir=workdir
        )
        self.logger.setLevel(logging.WARNING)

    async def schedule_conversation_deletion(self, peer, delay=5):
        await asyncio.sleep(delay)
        self.send(DeleteHistory(await self.resolve_peer(peer), max_id=999999999, just_clear=True))
        log.debug("Deleted conversation with {}".format(peer))

    # def delete_all_conversations(self):
    #     all_peers = [utils.resolve_id(x[0]) for x in self.session.entities.get_input_list()]
    #     for peer in all_peers:
    #         log.debug("Deleting conversation with {}...".format(peer))
    #         try:
    #             input_entity = self.client.session.entities.get_input_entity(peer[0])
    #             self.client(DeleteHistoryRequest(input_entity, max_id=9999999999999999))
    #         except:
    #             log.error("Couldn't find {}".format(peer[0]))

    def update_bot_details(self, to_check: BotModel, peer):
        """
        Set basic properties of the bot
        """
        if isinstance(peer, ResolvedPeer):
            peer = self.resolve_peer(peer.peer.user_id)
        elif isinstance(peer, InputPeerUser):
            pass
        else:
            peer = self.resolve_peer(peer.id)

        try:
            user = self.send(GetUsers([peer]))[0]
        except:
            traceback.print_exc()
            print("this peer does not work for GetUsers:")
            print(type(peer))
            print(peer)
            return None

        if hasattr(user, 'bot') and user.bot is True:
            # Regular bot
            to_check.official = bool(user.verified)
            to_check.inlinequeries = bool(user.bot_inline_placeholder)
            to_check.name = user.first_name
            to_check.bot_info_version = user.bot_info_version
        else:
            # Userbot
            to_check.userbot = True
            to_check.name = helpers.format_name(user)

        # In any case
        to_check.chat_id = int(user.id)
        to_check.username = '@' + str(user.username)

    # def send_message(self, *args, **kwargs):
    #     timeout_between_sends = 0.5
    #     if self._last_ping:
    #         diff = timeout_between_sends - (time.time() - self._last_ping)
    #         if diff > 0:
    #             time.sleep(diff)
    #
    #     result = super(BotChecker, self).send_message(*args, **kwargs)
    #     self._last_ping = time.time()
    #     return result

    async def get_ping_response(
            self,
            to_check: Bot,
            timeout=30,
            try_inline=True
    ) -> Union[Response, InlineResultContainer]:
        response = await self.ping_bot(
            to_check.chat_id,
            override_messages=settings.PING_MESSAGES,
            max_wait_response=timeout,
            raise_=False
        )
        if response.empty:
            if try_inline and to_check.inlinequeries:
                for q in settings.PING_INLINEQUERIES:
                    try:
                        return self.get_inline_bot_results(to_check.username, q)
                    except UnknownError as e:
                        if "timeout" in e.MESSAGE.lower():
                            continue
                        else:
                            raise e
                return False

        # Evaluate WJClub's ParkMeBot flags
        reserved_username = ZERO_CHAR1 + ZERO_CHAR1 + ZERO_CHAR1 + ZERO_CHAR1
        parked = ZERO_CHAR1 + ZERO_CHAR1 + ZERO_CHAR1 + ZERO_CHAR2
        maintenance = ZERO_CHAR1 + ZERO_CHAR1 + ZERO_CHAR2 + ZERO_CHAR1

        full_text = response.full_text
        if zero_width_encoding(full_text) in (reserved_username, parked, maintenance):
            return False
        if offline_pattern.search(full_text):
            return False

        return response

    def resolve_bot(self, bot: BotModel):
        if bot.chat_id:
            try:
                return self.resolve_peer(bot.chat_id)
            except:
                pass

        try:
            results = self.send(Search(bot.username, limit=3))
            if results.users:
                try:
                    return next(
                        s for s in results.users if s.username.lower() == bot.username[1:].lower())
                except StopIteration:
                    pass
        except QueryTooShort:
            pass

        if self.username_flood_until:
            if self.username_flood_until < datetime.now():
                self.username_flood_until = None
        else:
            try:
                return self.resolve_peer(bot.username)
            except FloodWait as e:
                self.username_flood_until = datetime.now() + timedelta(
                    seconds=e.x)
                log.warning("Flood wait for ResolveUsername: {}s (until {})".format(
                    e.x, self.username_flood_until))
            except UsernameInvalid as e:
                log.error(e)  # TODO

        return None

    async def download_profile_photo(self, bot: BotModel, photo_path):
        tmp_file = os.path.join(TMP_DIR, bot.username.replace('@', '') + '.jpg')
        photos = self.get_user_profile_photos(bot.chat_id).photos
        if photos:
            photo_size_object = photos[0][-1]

            await self.__photos_lock.acquire()
            try:
                try:
                    self.download_media(
                        photo_size_object,
                        file_name=tmp_file,
                        block=True
                    )
                except FloodWait as e:
                    # TODO: as the error happens inside of the update worker, this won't work (yet)
                    # Leaving it in as the default behavior should be to raise the FloodWait
                    # when block=True
                    log.debug(f"FloodWait for downloading media ({e.x})")

                if os.path.exists(tmp_file):
                    try:
                        similar = filecmp.cmp(tmp_file, photo_path, shallow=False)
                    except FileNotFoundError:
                        similar = False

                    if not similar:
                        shutil.copy(tmp_file, photo_path)
            finally:
                self.__photos_lock.release()


async def check_bot(
        bot,
        bot_checker: BotChecker,
        to_check: BotModel,
        result_queue: asyncio.Queue
):
    log.debug("Checking bot {}...".format(to_check.username))

    try:
        peer = bot_checker.resolve_bot(to_check)
    except UsernameNotOccupied:
        markup = InlineKeyboardMarkup([[
            InlineKeyboardButton(captions.EDIT_BOT, callback_data=util.callback_for_action(
                CallbackActions.EDIT_BOT,
                dict(id=to_check.id)
            ))
        ]])
        text = "{} does not exist (anymore). Please resolve this " \
               "issue manually!".format(to_check.username)
        try:
            bot.send_message(settings.BLSF_ID, text, reply_markup=markup)
        except BadRequest:
            bot.send_notification(text)
        return await result_queue.put('not found')

    if not peer:
        return await result_queue.put('skipped')

    bot_checker.update_bot_details(to_check, peer=peer)

    # Check online state
    try:
        response = await bot_checker.get_ping_response(
            to_check,
            timeout=30,
            try_inline=to_check.inlinequeries)
    except UnknownError as e:
        await result_queue.put(e.MESSAGE)
        return
    except Exception as e:
        log.exception(e)
        await result_queue.put(str(e))
        return

    for _ in range(2):
        await result_queue.put('messages sent')

    was_offline = to_check.offline
    is_offline = response.empty if isinstance(response, Response) else not bool(response)

    now = datetime.now()
    to_check.last_ping = now
    if not is_offline:
        to_check.last_response = now

    if was_offline != is_offline:
        bot.send_message(settings.BOTLIST_NOTIFICATIONS_ID, '{} went {}.'.format(
            to_check.str_no_md,
            'offline' if to_check.offline else 'online'
        ), timeout=40)

    await add_keywords(bot, response, to_check)

    # Download profile picture
    if settings.DOWNLOAD_PROFILE_PICTURES:
        await download_profile_picture(bot, bot_checker, to_check)

    to_check.save()

    if settings.DELETE_CONVERSATION_AFTER_PING:
        await bot_checker.schedule_conversation_deletion(to_check.chat_id, 10)

    await disable_decider(bot, to_check)

    await result_queue.put('offline' if to_check.offline else 'online')


async def download_profile_picture(bot, bot_checker, to_check):
    photo_file = to_check.thumbnail_file
    sticker_file = os.path.join(settings.BOT_THUMBNAIL_DIR, '_sticker_tmp.webp')
    await bot_checker.download_profile_photo(to_check, photo_file)
    if settings.NOTIFY_NEW_PROFILE_PICTURE:
        make_sticker(photo_file, sticker_file)
        bot.send_notification("New profile picture of {}:".format(to_check.username))
        bot.send_sticker(settings.BOTLIST_NOTIFICATIONS_ID,
                         open(photo_file, 'rb'), timeout=360)


async def add_keywords(bot, response, to_check):
    if not isinstance(response, Response) or response.empty:
        return

    full_text = response.full_text.lower()
    # Search for botbuilder pattern to see if this bot is a Manybot/Chatfuelbot/etc.
    if botbuilder_pattern.search(full_text):
        to_check.botbuilder = True

    # Search /start and /help response for global list of keywords
    to_add = []
    for name in Keyword.get_distinct_names(exclude_from_bot=to_check):
        if re.search(r'\b{}\b'.format(name), full_text, re.IGNORECASE):
            to_add.append(name)

    to_add = [x for x in to_add if x not in settings.FORBIDDEN_KEYWORDS]

    if to_add:
        Keyword.insert_many([dict(name=k, entity=to_check) for k in to_add]).execute()
        msg = 'New keyword{}: {} for {}.'.format(
            's' if len(to_add) > 1 else '',
            ', '.join(['#' + k for k in to_add]),
            to_check.str_no_md)
        bot.send_message(settings.BOTLIST_NOTIFICATIONS_ID, msg, timeout=40)
        log.info(msg)


async def result_reader(queue) -> Counter:
    stats = Counter()
    while True:
        value = await queue.get()
        if value is None:
            break
        stats.update([value])
    return stats


async def run(telegram_bot, bot_checker, bots, stop_event: threading.Event = None) -> Counter:
    result_queue = asyncio.Queue()
    loop = bot_checker.event_loop
    reader_future = asyncio.ensure_future(result_reader(result_queue), loop=loop)

    # TODO: check correct order of bots concerning pings etc.

    async with asyncpool.AsyncPool(
            loop,
            num_workers=settings.BOTCHECKER_CONCURRENT_COUNT,
            name="BotChecker",
            logger=log,
            worker_co=check_bot,
            max_task_time=300,
            log_every_n=settings.BOTCHECKER_CONCURRENT_COUNT,
            expected_total=len(bots),
    ) as pool:
        for to_check in bots:
            # TODO: implement properly
            if stop_event and stop_event.is_set():
                print('JOINING')
                pool.join()
            else:
                await pool.push(telegram_bot, bot_checker, to_check, result_queue)

    await result_queue.put(None)
    return await reader_future


def ping_bots_job(bot, job):
    bot_checker: BotChecker = job.context.get('checker')
    stop_event: threading.Event = job.context.get('stop')
    loop = bot_checker.event_loop

    all_bots = BotModel.select(BotModel).where(
        (BotModel.approved == True)
        &
        ((BotModel.disabled_reason == BotModel.DisabledReason.offline) |
         BotModel.disabled_reason.is_null())
    ).order_by(
        BotModel.last_ping.asc()
    )

    start = time.time()
    result = loop.run_until_complete(run(bot, bot_checker, all_bots, stop_event))  # type: Counter
    end = time.time()

    if not result:
        msg = "šŸ‘Ž BotChecker encountered problems."
    else:
        msg = "ā„¹ļø BotChecker completed in {}s:\n".format(round(end - start))
        for k, v in result.items():
            msg += "\nā— {} {}".format(v, k)
    bot.send_message(settings.BOTLIST_NOTIFICATIONS_ID, msg)
    log.info(msg)


async def disable_decider(bot: TelegramBot, to_check: BotModel):
    assert to_check.disabled_reason != BotModel.DisabledReason.banned

    if (
            to_check.offline and
            to_check.offline_for > settings.DISABLE_BOT_INACTIVITY_DELTA and
            to_check.disabled_reason != BotModel.DisabledReason.offline
    ):
        # Disable if the bot has been offline for too long
        if to_check.disable(to_check.DisabledReason.offline):
            to_check.save()

            if to_check.last_response:
                reason = "its last response was " + helpers.slang_datetime(to_check.last_response)
            else:
                reason = "it's been offline for.. like... ever"

            msg = "āŒ {} disabled as {}.".format(to_check, reason)
            log.info(msg)
            bot.send_message(settings.BOTLIST_NOTIFICATIONS_ID, msg, timeout=30,
                             parse_mode='markdown')
        else:
            log.info("huhwtf")
    elif (
            to_check.online and
            to_check.disabled_reason == BotModel.DisabledReason.offline
    ):
        # Re-enable if the bot is disabled and came back online
        if to_check.enable():
            to_check.save()
            msg = "{} was included in the @BotList again as it came back online.".format(to_check)
            log.info(msg)
            bot.send_message(settings.BOTLIST_NOTIFICATIONS_ID, msg, timeout=30,
                             parse_mode='markdown')
        else:
            log.info("huhwtf")