"""
Core enDAQ Cloud communication API
"""
from datetime import datetime, timedelta
from typing import Optional, Union

from idelib.dataset import Dataset
import numpy as np
from pandas import DataFrame
import pandas as pd
import requests
import json
import re
import urllib.request
import shutil

# ==============================================================================
#
# ==============================================================================

ENV_PRODUCTION = "https://qvthkmtukh.execute-api.us-west-2.amazonaws.com/master"
ENV_STAGING = "https://p377cock71.execute-api.us-west-2.amazonaws.com/staging"
ENV_DEVELOP = "https://mnsz98xs64.execute-api.us-west-2.amazonaws.com/develop"

# ==============================================================================
#
# ==============================================================================


class EndaqCloud:
    """
    A representation of a connection to an enDAQ Cloud account, providing a
    high-level interface for accessing its contents.
    """

    def __init__(self,
                 api_key: Optional[str] = None,
                 env: Optional[str] = None,
                 test: bool = True):
        """
        Constructor for an `EndaqCloud` object, which provides access to an
        enDAQ Cloud account.

        :param api_key: The Endaq Cloud API associated with your cloud.endaq.com account.
         If you do not have one created yet, they can be created on the following web page:
         https://cloud.endaq.com/account/api-keys
        :param env: The cloud environment to connect to, which can be production, staging, or development.
         These can be easily accessed with the variables ENV_PRODUCTION, ENV_STAGING, and ENV_DEVELOP
        :param test: If `True` (default), the connection to enDAQ Cloud will
            be tested before being returned. A failed test will generate a
            meaningful error message describing the problem.
        """
        self.api_key = api_key
        self.domain = env or ENV_PRODUCTION

        self.file_table = None

        self._account_id = self._account_email = None
        if test:
            info = self.get_account_info()
            if not info.get('id') or not info.get('email'):
                # TODO: change this exception; it's placeholder.
                raise RuntimeError("Failed to connect to enDAQ Cloud: response was {!r}".format(info))


    def get_account_info(self) -> dict:
        """
        Get information about the connected account. Sets or updates the
        values of `account_id` and `account_email`.

        :return: If successful, a dictionary containing (at minimum) the keys
            `email` and `id`.
        """
        response = requests.get(self.domain + "/api/v1/account/info",
                                headers={"x-api-key": self.api_key}).json()
        # Cache the ID and email. Don't clobber if the request failed
        # (just in case - it's unlikely).
        self._account_id = response.get('id', self._account_id)
        self._account_email = response.get('email', self._account_email)
        return response


    @property
    def account_id(self) -> Union[str, None]:
        """ The enDAQ Cloud account's unique ID. """
        if self._account_id is None:
            self.get_account_info()
        return self._account_id


    @property
    def account_email(self) -> Union[str, None]:
        """ The email address associated with the enDAQ Cloud account. """
        if self._account_email is None:
            self.get_account_info()
        return self._account_email


    def get_file(self,
                 file_id: Union[int, str],
                 local_name: Optional[str] = None) -> Dataset:
        """
        Download the specified file to local_name if provided, use the file
        name from the cloud if no local name is provided.
        TODO: This should be made to match `endaq.ide.get_doc()`

        :param file_id: The file's cloud ID.
        :param local_name:
        :return: The imported file, as an `idelib.Dataset`.
        """
        file_url = self.domain + "/api/v1/files/download/" + file_id
        response = requests.get(file_url, headers={"x-api-key": self.api_key}).json()
        download_url = response['url']
        if local_name is None:
            local_name = response['file_name']

        with urllib.request.urlopen(download_url) as response, open(local_name, 'wb') as out_file:
            shutil.copyfileobj(response, out_file)

        f = open(local_name, 'rb')

        return Dataset(f)



    def get_file_table(self,
                       attributes: Union[list, str] = "all",
                       limit: int = 100) -> DataFrame:
        """
        Get a table of the data that would be similar to that you'd get doing
        the CSV export on the my recordings page, up to the first `limit`
        files with attributes matching `attributes`.

        :param limit: The maximum number of files to return.
        :param attributes: A list of attribute strings (or a single
            comma-delimited string of attributes) to match.
        :return: A `DataFrame` of file IDs and relevant information.
        """
        if isinstance(attributes, str):
            attributes = attributes.split(',')
        attributes = [str(a).strip() for a in attributes]
        params = {'limit': limit, 'attributes': attributes}
        response = requests.get(self.domain + "/api/v1/files",
                                params=params,
                                headers={"x-api-key": self.api_key})
        try:
            files_json_data = response.json()['data']
        except KeyError:
            raise KeyError("the 'data' attribute was not present in the json response from the cloud.")

        self.file_table = json_table_to_df(files_json_data)

        return self.file_table

    def get_devices(self, limit: int = 100) -> DataFrame:
        """
        Get dataframe of devices and associated attributes (part_number,
        description, etc.) attached to the account.

        :param limit: The maximum number of files to return.
        :return: A `DataFrame` of recorder information.
        """
        response = requests.get(self.domain + "/api/v1/files",
                                params={'limit': limit},
                                headers={"x-api-key": self.api_key})

        try:
            files_json_data = response.json()['data']
        except KeyError:
            raise KeyError("the 'data' attribute was not present in the json response from the cloud.")

        devices = {}
        for f_data in files_json_data:
            if f_data['device'] is not None and len(f_data['device']) > 0:
                if f_data['device']['serial_number_id'] not in devices:
                    devices[f_data['device']['serial_number_id']] = f_data['device']

        df = pd.DataFrame(devices).T

        if len(df.columns):
            df.set_index('serial_number_id')

        return df






    def set_attributes(self,
                       file_id: Union[int, str],
                       attributes: list) -> list:
        """
        Set the 'attributes' (name/value metadata) of a file.

        :param file_id: The file's cloud ID.
        :param attributes: A list of dictionaries of the following structure:
         [{
             "name": "attr_31",
             "type" : "float",
             "value" : 3.3,
         }]
        :return: The list of the file's new attributes.

        """
        # NOTE: This was called `post_attributes()` in the Confluence docs.
        #  'post' referred to the fact it is a POST request, which is
        #  really an internal detail; 'set' is more appropriate for an API.

        # IDEAS:
        #   * Use `**kwargs` instead of an `attributes` dict?
        #   * Automatically assume type, unless value is a tuple containing (value, type)
        for attrib in attributes:
            attrib['file_id'] = file_id

        response = requests.post(
            self.domain + "/api/v1/attributes",
            headers={"x-api-key": self.api_key},
            json={'attributes': attributes},
        )

        return response.json()



# ==============================================================================
#
# ==============================================================================


def count_tags(df: DataFrame) -> DataFrame:
    """
    Given the dataframe returned by `EndaqCloud.get_file_table()`, provide
    some info on the tags of the files in that account.

    :param df: A `DataFrame` of file information, as returned by
        `EndaqCloud.get_file_table()`.
    :return: A `DataFrame` summarizing the tags in `df`.
    """
    # NOTE: Called `tags_count()` in Confluence docs. Function names should
    #   generally be verbs or verb phrases.
    # IDEAS:
    #   * Make this a @classmethod to make EndaqCloud the primary means of access?

    tags = {}
    for index, row in df.iterrows():
        for cur_tag in row['tags']:
            if cur_tag in tags:
                tags[cur_tag].append(index)
            else:
                tags[cur_tag] = [index]

    for tag in tags:
        tags[tag] = [len(tags[tag]), ''.join(["'", "','".join(tags[tag]), "'"])]

    return pd.DataFrame(tags, index=pd.Index(['count', 'files'], name='tag')).T



def json_table_to_df(data: list) -> DataFrame:
    """
    Convert JSON parsed from a custom report to a more user-friendly
    `pandas.DataFrame`.

    :param data: A `list` of data from a custom report's JSON.
    :return: A formatted `DataFrame`
    """
    # NOTE: Steve wanted this as a separate function.
    #  Also: is this already implemented as `endaq.cloud.utilities.convert_file_data_to_dataframe()`?
    # IDEAS:
    #   * Make this a @classmethod to make EndaqCloud class and/or instances the primary means of access?
    df = pd.DataFrame(data)
    df['attributes'] = df['attributes'].map(lambda x: {attribs['name']: attribs for attribs in x})

    unique_attributes_and_types = df['attributes'].map(lambda x: [(k, v['type']) for k, v in x.items()]).values
    unique_attributes_and_types = set(pair for file_info in unique_attributes_and_types for pair in file_info)

    for attrib_name, attrib_type_str in unique_attributes_and_types:
        if attrib_type_str == 'float':
            df[attrib_name] = df['attributes'].map(
                lambda x: None if len(x) == 0 or attrib_name not in x else float(x[attrib_name]['value']))
        elif attrib_type_str == 'string':
            try:  # Try and parse the JSON string into an array of floats
                df[attrib_name] = df['attributes'].map(
                    lambda x: [] if len(x) == 0 or attrib_name not in x else np.array(
                        json.loads(re.sub(r'\bnan\b', 'NaN', x[attrib_name]['value'])), dtype=np.float32))
            except json.JSONDecodeError:  # Save it as a String if it can't be converted to a float array
                df[attrib_name] = df['attributes'].map(
                    lambda x: "" if len(x) == 0 or attrib_name not in x else x[attrib_name]['value'])

    # Convert the columns which represent times to pandas datetime type
    for time_col_name in ['recording_ts', 'created_ts', 'modified_ts', 'archived_ts']:
        df[time_col_name] = pd.to_datetime(df[time_col_name], unit='s')

    # Add the GPS coordinates as 2 seperate latitude and longitude columns
    gps_coord_series = df['gpsLocationFull'].map(
        lambda x: np.array(x.split(','), dtype=np.float32) if len(x) else np.array(2 * [np.nan],
                                                                                   dtype=np.float32))

    df['latitudes'] = gps_coord_series.map(lambda x: x[0])
    df['longitudes'] = gps_coord_series.map(lambda x: x[1])

    df = df.set_index('file_name')

    return df
