import os
import logging
import urllib
import urllib.parse
import pathlib
import asyncio
import aiohttp
import aiofiles
import secrets
import random
import json

from datetime import datetime, timedelta
from time import time
from tardis_client.handy import get_slice_cache_path

logger = logging.getLogger(__name__)


async def fetch_data_to_replay(exchange, from_date, to_date, filters, endpoint, cache_dir, api_key):
    timeout = aiohttp.ClientTimeout(total=60)
    headers = {"Authorization": f"Bearer {api_key}" if api_key else ""}

    minutes_diff = int(round((to_date - from_date).total_seconds() / 60))
    offset = 0
    FET_DATA_CONCURRENCY_LIMIT = 60
    fetch_data_tasks = set()

    start_time = time()

    logger.debug(
        "fetch data started for '%s' exchange from: %s, to: %s, filters: %s",
        exchange,
        from_date.isoformat(),
        to_date.isoformat(),
        filters,
    )

    async with aiohttp.ClientSession(auto_decompress=False, timeout=timeout, headers=headers) as session:
        # loop below will fetch data slices if not cached already concurrently up to the conecurrency limit
        while offset < minutes_diff:
            if len(fetch_data_tasks) >= FET_DATA_CONCURRENCY_LIMIT:
                # if there are going to be more pending fetch data downloads than concurrency limit
                # wait before adding another one
                done, fetch_data_tasks = await asyncio.wait(fetch_data_tasks, return_when=asyncio.FIRST_COMPLETED)
                # need to check the result that may throw if task finished with an error
                done.pop().result()

            fetch_data_tasks.add(
                asyncio.create_task(
                    _fetch_data_if_not_cached(session, endpoint, cache_dir, exchange, from_date, offset, filters)
                )
            )
            offset += 1

        # finally wait for the remaining fetch data download tasks
        await asyncio.gather(*fetch_data_tasks)

        end_time = time()

    logger.debug(
        "fetch data finished for '%s' exchange - from: %s, to: %s, filters: %s, total time: %s seconds",
        exchange,
        from_date.isoformat(),
        to_date.isoformat(),
        filters,
        end_time - start_time,
    )


async def _fetch_data_if_not_cached(session, endpoint, cache_dir, exchange, from_date, offset, filters):
    slice_date = from_date + timedelta(seconds=offset * 60)
    cache_path = get_slice_cache_path(cache_dir, exchange, slice_date, filters)

    # fetch and cache slice only if it's not cached already
    if os.path.isfile(cache_path) == False:
        await _reliably_fetch_and_cache_slice(session, endpoint, exchange, from_date, offset, filters, cache_path)
        logger.debug("fetched data slice for date %s from the API and cached - %s", slice_date, cache_path)
    else:
        logger.debug("data slice for date %s already in local cache - %s", slice_date, cache_path)


async def _reliably_fetch_and_cache_slice(session, endpoint, exchange, from_date, offset, filters, cache_path):
    fetch_url = f"{endpoint}/v1/data-feeds/{exchange}?from={from_date.isoformat()}&offset={offset}"

    if filters is not None and len(filters) > 0:
        # convert fitlers to dictionary so can be json serialized (use names required by the API: channel, symbols)
        filters_serializable = [{"channel": filter.name, "symbols": filter.symbols} for filter in filters]
        filters_serialized = json.dumps(filters_serializable, separators=(",", ":"))
        filters_url_encoded = urllib.parse.quote(filters_serialized, safe="~()*!.'")
        fetch_url += f"&filters={filters_url_encoded}"

    MAX_ATTEMPTS = 5
    attempts = 0

    while True:
        attempts += 1
        too_many_requests = False
        try:
            await _fetch_and_cache_slice(session, url=fetch_url, cache_path=cache_path)
            break

        except asyncio.CancelledError:
            break

        except Exception as ex:
            if attempts == MAX_ATTEMPTS or isinstance(ex, RuntimeError):
                raise ex

            if isinstance(ex, urllib.error.HTTPError):
                # do not retry when we've got bad or unauthorized request or enough attempts
                if ex.code == 400 or ex.code == 401:
                    raise ex
                if ex.code == 429:
                    too_many_requests = True

            random_ingridient = random.random()
            attempts_delay = 2 ** attempts
            next_attempts_delay = random_ingridient + attempts_delay

            if too_many_requests:
                # when too many requests error received wait longer than normal
                next_attempts_delay += 3 * attempts
            logger.debug(
                "_fetch_and_cache_slice error: %s, next attempt delay: %is, path: %s", ex, next_attempts_delay, cache_path
            )

            await asyncio.sleep(next_attempts_delay)


async def _fetch_and_cache_slice(session, url, cache_path):
    async with session.get(url) as response:
        if response.status != 200:
            error_text = await response.text()
            raise urllib.error.HTTPError(url, code=response.status, msg=error_text, hdrs=None, fp=None)

        # ensure that directory where we want to cache data slice exists
        pathlib.Path(cache_path).parent.mkdir(parents=True, exist_ok=True)
        temp_cache_path = f"{cache_path}{secrets.token_hex(8)}.unconfirmed"
        # write response stream to unconfirmed temp file
        async with aiofiles.open(temp_cache_path, "wb") as temp_file:
            async for data in response.content.iter_any():
                await temp_file.write(data)

        # rename temp file to desired name only if file has been fully and successfully saved
        # it there is an error during renaming file it means that target file aready exists
        # and we're fine as only successfully save files exist
        try:
            os.rename(temp_cache_path, cache_path)
        except Exception as ex:
            logger.debug("_fetch_and_cache_slice rename error: %s", ex)
