from escher.urls import get_url, root_directory
from escher.util import b64dump
from escher.version import __version__
from escher import rc

import cobra
from cobra import Model
import pandas as pd
import ipywidgets as widgets
from traitlets import Unicode, Int, Instance, Any, observe, validate, default
import os
from os.path import join, isfile, expanduser
from warnings import warn
from urllib.request import urlopen
from urllib.error import URLError
from urllib.parse import quote as url_escape
import json
import shutil
import re
from jinja2 import Environment, PackageLoader, Template
import codecs
import random
import string
import shutil
from typing import Optional

# set up jinja2 template location
env = Environment(loader=PackageLoader('escher', 'templates'))


# server management

def server_index():
    url = get_url('server_index')
    try:
        download = urlopen(url)
    except URLError:
        raise URLError('Could not contact Escher server')
    data = _decode_response(download)
    index = json.loads(data)
    return index


def list_available_maps():
    """Return a list of all maps available on the server"""
    return server_index()['maps']


def list_available_models():
    """Return a list of all models available on the server"""
    return server_index()['models']


# download maps and models

def _json_for_name(name: str, kind: str):
    # check the name
    name = name.replace('.json', '')

    def match_in_index(name, index, kind):
        return [(obj['organism'], obj[kind + '_name'])
                for obj in index[kind + 's']
                if obj[kind + '_name'] == name]

    try:
        index = server_index()
    except URLError:
        raise Exception('Could not connect to the Escher server')
    match = match_in_index(name, server_index(), kind)
    if len(match) == 0:
        raise Exception(f'Could not find the {kind} {name} on the server')
    org, name = match[0]
    url = (
        get_url(kind + '_download') +
        '/'.join([url_escape(x) for x in [org, name + '.json']])
    )
    print('Downloading %s from %s' % (kind.title(), url))
    try:
        download = urlopen(url)
    except URLError:
        raise ValueError('No %s found in at %s' % (kind, url))
    data = _decode_response(download)
    return data


def model_json_for_name(model_name):
    return _json_for_name(model_name, 'model')


def map_json_for_name(map_name):
    return _json_for_name(map_name, 'map')


# helper functions


def _decode_response(download):
    """Decode the urllib.response.addinfourl response."""
    data = download.read()
    try:                    # Python 2
        encoding = download.headers.getparam('charset')
    except AttributeError:  # Python 3
        encoding = download.headers.get_param('charset')
    if encoding:
        data = data.decode(encoding)
    else:
        data = data.decode('utf-8')
    return data


def _load_resource(resource, name):
    """Load a resource that could be a file, URL, or json string."""
    # if it's a url, download it
    if resource.startswith('http://') or resource.startswith('https://'):
        try:
            download = urlopen(resource)
        except URLError as err:
            raise err
        else:
            return _decode_response(download)
    # If it's a filepath, load it
    try:
        is_file = isfile(resource)
    except ValueError:
        # check for error with long filepath (or URL) on Windows
        is_file = False
    if is_file:
        try:
            with open(resource, 'rb') as f:
                loaded_resource = f.read().decode('utf-8')
            _ = json.loads(loaded_resource)
        except ValueError as err:
            raise ValueError('%s not a valid json file' % name)
        else:
            return loaded_resource
    # try to validate the json
    try:
        _ = json.loads(resource)
    except ValueError as err:
        raise ValueError('Could not load %s. Not valid json, url, or filepath'
                         % name)
    else:
        return resource
    raise Exception('Could not load %s.' % name)


def convert_data(data):
    if type(data) is pd.Series:
        return dict(data)
    elif type(data) is pd.DataFrame:
        return list(dict(x.dropna()) for _, x in data.T.iterrows())
    elif type(data) is dict or type(data) is list or data is None:
        return data
    raise Exception


class Builder(widgets.DOMWidget):
    """A Python wrapper for the Escher metabolic map.

    This map will also show data on reactions, metabolites, or genes.

    The Builder is a Jupyter widget that can be viewed in a Jupyter notebook or
    in Jupyter Lab. It can also be used to create a standalone HTML file for
    the map with the save_html() function.

    Maps are downloaded from the Escher website if found by name.

    :param int height:

        The height of the Escher Jupyter widget in pixels.

    :param str map_name:

        A string specifying a map to be downloaded from the Escher website.

    :param str map_json:

        A JSON string, or a file path to a JSON file, or a URL specifying a
        JSON file to be downloaded.

    :param model:

        A COBRApy model.

    :param model_name:

        A string specifying a model to be downloaded from the Escher web
        server.

    :param model_json:

        A JSON string, or a file path to a JSON file, or a URL specifying a
        JSON file to be downloaded.

    :param embedded_css:

        The CSS (as a string) to be embedded with the Escher SVG. In Jupyter,
        if you change embedded_css on an existing builder instance, the Builder
        must be restarted for this to take effect (e.g. by re-evaluating the
        widget in a cell). You can use the default embedded css as a starting
        point:

        https://github.com/zakandrewking/escher/blob/master/src/Builder-embed.css

    :param reaction_data:

        A dictionary with keys that correspond to reaction IDs and values that
        will be mapped to reaction arrows and labels.

    :param metabolite_data:

        A dictionary with keys that correspond to metabolite IDs and values
        that will be mapped to metabolite nodes and labels.

    :param gene_data:

        A dictionary with keys that correspond to gene IDs and values that will
        be mapped to corresponding reactions.

    **Keyword Arguments**

    You can also pass in any of the following options as keyword arguments. The
    details on each of these are provided in the JavaScript API documentation:

        - use_3d_transform
        - menu
        - scroll_behavior
        - use_3d_transform
        - enable_editing
        - enable_keys
        - enable_search
        - zoom_to_element
        - full_screen_button
        - disabled_buttons
        - semantic_zoom
        - starting_reaction
        - never_ask_before_quit
        - primary_metabolite_radius
        - secondary_metabolite_radius
        - marker_radius
        - gene_font_size
        - hide_secondary_metabolites
        - show_gene_reaction_rules
        - hide_all_labels
        - canvas_size_and_loc
        - reaction_data
        - reaction_styles
        - reaction_compare_style
        - reaction_scale
        - reaction_no_data_color
        - reaction_no_data_size
        - gene_data
        - and_method_in_gene_reaction_rule
        - metabolite_data
        - metabolite_styles
        - metabolite_compare_style
        - metabolite_scale
        - metabolite_no_data_color
        - metabolite_no_data_size
        - identifiers_on_map
        - highlight_missing
        - allow_building_duplicate_reactions
        - cofactors
        - enable_tooltips
        - enable_keys_with_tooltip
        - reaction_scale_preset
        - metabolite_scale_preset
        - primary_metabolite_radius
        - secondary_metabolite_radius
        - marker_radius
        - gene_font_size
        - reaction_no_data_size
        - metabolite_no_data_size

    If any of these is set to None, the default (or most-recent) value is used.
    To turn off a setting, use False instead.

    All arguments can also be set by assigning the property of an an existing
    Builder object, e.g.:

    .. code:: python

        my_builder.map_name = 'iJO1366.Central metabolism'

    """

    # widget info traitlets

    _view_name = Unicode('EscherMapView').tag(sync=True)
    _model_name = Unicode('EscherMapModel').tag(sync=True)
    _view_module = Unicode('escher').tag(sync=True)
    _model_module = Unicode('escher').tag(sync=True)
    _view_module_version = Unicode(__version__).tag(sync=True)
    _model_module_version = Unicode(__version__).tag(sync=True)

    # Python package options

    height = Int(500).tag(sync=True)

    embedded_css = Unicode(None, allow_none=True).tag(sync=True)

    @validate('embedded_css')
    def _validate_embedded_css(self, proposal):
        css = proposal['value']
        if css:
            return css.replace('\n', '')
        else:
            return None

    # synced data

    _loaded_map_json = Unicode(None, allow_none=True).tag(sync=True)

    @observe('_loaded_map_json')
    def _observe_loaded_map_json(self, change):
        # if map is cleared, then clear these
        if not change.new:
            self.map_name = None
            self.map_json = None

    _loaded_model_json = Unicode(None, allow_none=True).tag(sync=True)

    @observe('_loaded_model_json')
    def _observe_loaded_model_json(self, change):
        # if model is cleared, then clear these
        if not change.new:
            self.model = None
            self.model_name = None
            self.model_json = None

    # Python options that are indirectly synced to the widget

    map_name = Unicode(None, allow_none=True)

    @observe('map_name')
    def _observe_map_name(self, change):
        if change.new:
            self._loaded_map_json = map_json_for_name(change.new)
        else:
            self._loaded_map_json = None

    map_json = Unicode(None, allow_none=True)

    @observe('map_json')
    def _observe_map_json(self, change):
        if change.new:
            self._loaded_map_json = _load_resource(change.new, 'map_json')
        else:
            self._loaded_map_json = None

    model = Instance(Model, allow_none=True)

    @observe('model')
    def _observe_model(self, change):
        if change.new:
            self._loaded_model_json = cobra.io.to_json(change.new)
        else:
            self._loaded_model_json = None

    model_name = Unicode(None, allow_none=True)

    @observe('model_name')
    def _observe_model_name(self, change):
        if change.new:
            self._loaded_model_json = model_json_for_name(change.new)
        else:
            self._loaded_model_json = None

    model_json = Unicode(None, allow_none=True)

    @observe('model_json')
    def _observe_model_json(self, change):
        if change.new:
            self._loaded_model_json = _load_resource(change.new, 'model_json')
        else:
            self._loaded_model_json = None

    # Synced options passed as an object to JavaScript Builder

    menu = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    scroll_behavior = Any('none', allow_none=False)\
        .tag(sync=True, option=True)
    use_3d_transform = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    enable_editing = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    enable_keys = Any(False, allow_none=True)\
        .tag(sync=True, option=True)
    enable_search = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    zoom_to_element = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    full_screen_button_default = {
        'enable_keys': True,
        'scroll_behavior': 'pan',
        'enable_editing': True,
        'menu': 'all',
        'enable_tooltips': ['label']
    }
    full_screen_button = Any(full_screen_button_default, allow_none=True)\
        .tag(sync=True, option=True)

    disabled_buttons = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    semantic_zoom = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    starting_reaction = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    # This option can be set globally with escher.rc['never_ask_before_quit']
    never_ask_before_quit = Any(allow_none=True).tag(sync=True, option=True)

    @default('never_ask_before_quit')
    def _never_ask_before_quit(self):
        return rc.get('never_ask_before_quit', None)

    primary_metabolite_radius = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    secondary_metabolite_radius = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    marker_radius = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    gene_font_size = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    hide_secondary_metabolites = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    show_gene_reaction_rules = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    hide_all_labels = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    canvas_size_and_loc = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    reaction_data = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    @validate('reaction_data')
    def _validate_reaction_data(self, proposal):
        try:
            return convert_data(proposal['value'])
        except Exception:
            raise Exception("""Invalid data for reaction_data. Must be pandas
                            Series, pandas DataFrame, dict, list, or None""")

    reaction_styles = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    reaction_compare_style = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    reaction_scale = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    reaction_no_data_color = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    reaction_no_data_size = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    gene_data = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    @validate('gene_data')
    def _validate_gene_data(self, proposal):
        try:
            return convert_data(proposal['value'])
        except Exception:
            raise Exception("""Invalid data for gene_data. Must be pandas
                            Series, pandas DataFrame, dict, list, or None""")

    and_method_in_gene_reaction_rule = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    metabolite_data = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    @validate('metabolite_data')
    def _validate_metabolite_data(self, proposal):
        try:
            return convert_data(proposal['value'])
        except Exception:
            raise Exception("""Invalid data for metabolite_data. Must be pandas
                            Series, pandas DataFrame, dict, list, or None""")

    metabolite_styles = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    metabolite_compare_style = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    metabolite_scale = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    metabolite_no_data_color = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    metabolite_no_data_size = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    identifiers_on_map = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    highlight_missing = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    allow_building_duplicate_reactions = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    cofactors = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    enable_tooltips = Any(False, allow_none=True)\
        .tag(sync=True, option=True)
    enable_keys_with_tooltip = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    reaction_scale_preset = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    metabolite_scale_preset = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    primary_metabolite_radius = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    secondary_metabolite_radius = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    marker_radius = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    gene_font_size = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    reaction_no_data_size = Any(None, allow_none=True)\
        .tag(sync=True, option=True)
    metabolite_no_data_size = Any(None, allow_none=True)\
        .tag(sync=True, option=True)

    def __init__(
            self,
            map_name: str = None,
            map_json: str = None,
            model: Model = None,
            model_name: str = None,
            model_json: str = None,
            **kwargs,
    ) -> None:
        # kwargs will instantiate the traitlets
        super().__init__(**kwargs)

        if map_json:
            if map_name:
                warn('map_json overrides map_name')
            self.map_json = map_json
        else:
            self.map_name = map_name

        if model:
            if model_name:
                warn('model overrides model_name')
            if model_json:
                warn('model overrides model_json')
            self.model = model
        elif model_json:
            if model_name:
                warn('model_json overrides model_name')
            self.model_json = model_json
        else:
            self.model_name = model_name

        unavailable_options = {
            'fill_screen': """The fill_option screen is set automatically by
            the Escher Python package""",
            'tooltip_component': """The tooltip_component cannot be customized
          with the Python API""",
            'first_load_callback': """The first_load_callback cannot be
          customized with the Python API""",
            'unique_map_id': """The option unique_map_id is deprecated""",
            'ignore_bootstrap': """The option unique_map_id is deprecated""",
        }

        for key, val in kwargs.items():
            if key in unavailable_options:
                warn(val)

    def display_in_notebook(self, *args, **kwargs):
        """Deprecated.

        The Builder is now a Jupyter Widget, so you can return the Builder
        object from a cell to display it, or you can manually call the IPython
        display function:

        from IPython.display import display
        from escher import Builder
        b = Builder(...)
        display(b)

        """
        raise Exception(('display_in_notebook is deprecated. The Builder is '
                         'now a Jupyter Widget, so you can return the '
                         'Builder in a cell to see it, or use the IPython '
                         'display function (see Escher docs for details)'))

    def display_in_browser(self, *args, **kwargs):
        """Deprecated.

        We recommend using the Jupyter Widget (which now supports all Escher
        features) or the save_html option to generate a standalone HTML file
        that loads the map.

        """
        raise Exception(('display_in_browser is deprecated. We recommend using'
                         'the Jupyter Widget (which now supports all Escher'
                         'features) or the save_html option to generate a'
                         'standalone HTML file that loads the map.'))

    def save_html(self, filepath):
        """Save an HTML file containing the map.

        :param string filepath:

            The name of the HTML file.

        TODO apply options from self

        """

        #     options = transform(self.options)
        # get options
        options = {}
        for key in self.traits(option=True):
            val = getattr(self, key)
            if val is not None:
                options[key] = val
        options_json = json.dumps(options)

        template = env.get_template('standalone.html')
        embedded_css_b64 = (b64dump(self.embedded_css)
                            if self.embedded_css is not None else None)
        html = template.render(
            escher_url=get_url('escher_min'),
            embedded_css_b64=embedded_css_b64,
            map_data_json_b64=b64dump(self._loaded_map_json),
            model_data_json_b64=b64dump(self._loaded_model_json),
            options_json_b64=b64dump(options_json),
        )

        with open(expanduser(filepath), 'wb') as f:
            f.write(html.encode('utf-8'))
