import json as _json_io
import textwrap
import typing
from datetime import timedelta

import cauldron as _cd
from cauldron import environ
from cauldron import render
from cauldron.render import plots as render_plots
from cauldron.render import texts as render_texts
from cauldron.session import report


def _get_report() -> 'report.Report':
    """Fetches the report associated with the currently running step."""
    return _cd.project.get_internal_project().current_step.report


def inspect(source: dict):
    """
    Inspects the data and structure of the source dictionary object and
    adds the results to the display for viewing.

    :param source:
        A dictionary object to be inspected.
    :return:
    """
    r = _get_report()
    r.append_body(render.inspect(source))


def header(header_text: str, level: int = 1, expand_full: bool = False):
    """
    Adds a text header to the display with the specified level.

    :param header_text:
        The text to display in the header.
    :param level:
        The level of the header, which corresponds to the html header
        levels, such as <h1>, <h2>, ...
    :param expand_full:
        Whether or not the header will expand to fill the width of the entire
        notebook page, or be constrained by automatic maximum page width. The
        default value of False lines the header up with text displays.
    """
    r = _get_report()
    r.append_body(render.header(
        header_text,
        level=level,
        expand_full=expand_full
    ))


def text(value: str, preformatted: bool = False):
    """
    Adds text to the display. If the text is not preformatted, it will be
    displayed in paragraph format. Preformatted text will be displayed
    inside a pre tag with a monospace font.

    :param value:
        The text to display.
    :param preformatted:
        Whether or not to preserve the whitespace display of the text.
    """
    if preformatted:
        result = render_texts.preformatted_text(value)
    else:
        result = render_texts.text(value)
    r = _get_report()
    r.append_body(result)
    r.stdout_interceptor.write_source(
        '{}\n'.format(textwrap.dedent(value))
    )


def markdown(
        source: str = None,
        source_path: str = None,
        preserve_lines: bool = False,
        font_size: float = None,
        **kwargs
):
    """
    Renders the specified source string or source file using markdown and
    adds the resulting HTML to the notebook display.

    :param source:
        A markdown formatted string.
    :param source_path:
        A file containing markdown text.
    :param preserve_lines:
        If True, all line breaks will be treated as hard breaks. Use this
        for pre-formatted markdown text where newlines should be retained
        during rendering.
    :param font_size:
        Specifies a relative font size adjustment. The default value is 1.0,
        which preserves the inherited font size values. Set it to a value
        below 1.0 for smaller font-size rendering and greater than 1.0 for
        larger font size rendering.
    :param kwargs:
        Any variable replacements to make within the string using Jinja2
        templating syntax.
    """
    r = _get_report()

    result = render_texts.markdown(
        source=source,
        source_path=source_path,
        preserve_lines=preserve_lines,
        font_size=font_size,
        **kwargs
    )
    r.library_includes += result['library_includes']

    r.append_body(result['body'])
    r.stdout_interceptor.write_source(
        '{}\n'.format(textwrap.dedent(result['rendered']))
    )


def json(**kwargs):
    """
    Adds the specified data to the the output display window with the
    specified key. This allows the user to make available arbitrary
    JSON-compatible data to the display for runtime use.

    :param kwargs:
        Each keyword argument is added to the CD.data object with the
        specified key and value.
    """
    r = _get_report()
    r.append_body(render.json(**kwargs))
    r.stdout_interceptor.write_source(
        '{}\n'.format(_json_io.dumps(kwargs, indent=2))
    )


def plotly(
        data: typing.Union[dict, list, typing.Any] = None,
        layout: typing.Union[dict, typing.Any] = None,
        scale: float = 0.5,
        figure: typing.Union[dict, typing.Any] = None,
        static: bool = False
):
    """
    Creates a Plotly plot in the display with the specified data and
    layout.

    :param data:
        The Plotly trace data to be plotted.
    :param layout:
        The layout data used for the plot.
    :param scale:
        The display scale with units of fractional screen height. A value
        of 0.5 constrains the output to a maximum height equal to half the
        height of browser window when viewed. Values below 1.0 are usually
        recommended so the entire output can be viewed without scrolling.
    :param figure:
        In cases where you need to create a figure instead of separate data
        and layout information, you can pass the figure here and leave the
        data and layout values as None.
    :param static:
        If true, the plot will be created without interactivity.
        This is useful if you have a lot of plots in your notebook.
    """
    r = _get_report()

    if not figure and not isinstance(data, (list, tuple)):
        data = [data]

    if 'plotly' not in r.library_includes:
        r.library_includes.append('plotly')

    r.append_body(render.plotly(
        data=data,
        layout=layout,
        scale=scale,
        figure=figure,
        static=static
    ))
    r.stdout_interceptor.write_source('[ADDED] Plotly plot\n')


def table(
        data_frame,
        scale: float = 0.7,
        include_index: bool = False,
        max_rows: int = 500,
        sample_rows: typing.Optional[int] = None,
        formats: typing.Union[
            str,
            typing.Callable[[typing.Any], str],
            typing.Dict[
                str,
                typing.Union[str, typing.Callable[[typing.Any], str]]
            ]
        ] = None
):
    """
    Adds the specified data frame to the display in a nicely formatted
    scrolling table.

    :param data_frame:
        The pandas data frame to be rendered to a table.
    :param scale:
        The display scale with units of fractional screen height. A value
        of 0.5 constrains the output to a maximum height equal to half the
        height of browser window when viewed. Values below 1.0 are usually
        recommended so the entire output can be viewed without scrolling.
    :param include_index:
        Whether or not the index column should be included in the displayed
        output. The index column is not included by default because it is
        often unnecessary extra information in the display of the data.
    :param max_rows:
        This argument exists to prevent accidentally writing very large data
        frames to a table, which can cause the notebook display to become
        sluggish or unresponsive. If you want to display large tables, you need
        only increase the value of this argument.
    :param sample_rows:
        When set to a positive integer value, the DataFrame will be randomly
        sampled to the specified number of rows when displayed in the table.
        If the value here is larger than the number of rows in the DataFrame,
        the sampling will have no effect and the entire DataFrame will be
        displayed instead.
    :param formats:
        An optional dictionary that, when specified, should contain a mapping
        between column names and formatting strings to apply to that column
        for display purposes. For example, ``{'foo': '{:,.2f}%'}`` would
        transform a column ``foo = [12.2121, 34.987123, 42.72839]`` to
        display as ``foo = [12.21%, 34.99%, 42.73%]``. The formatters should
        follow the standard Python string formatting guidelines the same as
        the ``str.format()`` command having the value of the column as the only
        positional argument in the format arguments. A string value can also
        be specified for uniform formatting of all columns (or if displaying
        a series with only a single value).
    """
    r = _get_report()
    r.append_body(render.table(
        data_frame=data_frame,
        scale=scale,
        include_index=include_index,
        max_rows=max_rows,
        sample_rows=sample_rows,
        formats=formats
    ))
    r.stdout_interceptor.write_source('[ADDED] Table\n')


def svg(svg_dom: str, filename: str = None):
    """
    Adds the specified SVG string to the display. If a filename is
    included, the SVG data will also be saved to that filename within the
    project results folder.

    :param svg_dom:
        The SVG string data to add to the display.
    :param filename:
        An optional filename where the SVG data should be saved within
        the project results folder.
    """
    r = _get_report()
    r.append_body(render.svg(svg_dom))
    r.stdout_interceptor.write_source('[ADDED] SVG\n')

    if not filename:
        return

    if not filename.endswith('.svg'):
        filename += '.svg'

    r.files[filename] = svg_dom


def jinja(path: str, **kwargs):
    """
    Renders the specified Jinja2 template to HTML and adds the output to the
    display.

    :param path:
        The fully-qualified path to the template to be rendered.
    :param kwargs:
        Any keyword arguments that will be use as variable replacements within
        the template.
    """
    r = _get_report()
    r.append_body(render.jinja(path, **kwargs))
    r.stdout_interceptor.write_source('[ADDED] Jinja2 rendered HTML\n')


def whitespace(lines: float = 1.0):
    """
    Adds the specified number of lines of whitespace.

    :param lines:
        The number of lines of whitespace to show.
    """
    r = _get_report()
    r.append_body(render.whitespace(lines))
    r.stdout_interceptor.write_source('\n')


def image(
        filename: str,
        width: int = None,
        height: int = None,
        justify: str = 'left'
):
    """
    Adds an image to the display. The image must be located within the
    assets directory of the Cauldron notebook's folder.

    :param filename:
        Name of the file within the assets directory,
    :param width:
        Optional width in pixels for the image.
    :param height:
        Optional height in pixels for the image.
    :param justify:
        One of 'left', 'center' or 'right', which specifies how the image
        is horizontally justified within the notebook display.
    """
    r = _get_report()
    path = '/'.join(['reports', r.project.uuid, 'latest', 'assets', filename])
    r.append_body(render.image(path, width, height, justify))
    r.stdout_interceptor.write_source('[ADDED] Image\n')


def html(dom: str):
    """
    A string containing a valid HTML snippet.

    :param dom:
        The HTML string to add to the display.
    """
    r = _get_report()
    r.append_body(render.html(dom))
    r.stdout_interceptor.write_source('[ADDED] HTML\n')


def workspace(show_values: bool = True, show_types: bool = True):
    """
    Adds a list of the shared variables currently stored in the project
    workspace.

    :param show_values:
        When true the values for each variable will be shown in addition to
        their name.
    :param show_types:
        When true the data types for each shared variable will be shown in
        addition to their name.
    """
    r = _get_report()

    data = {}
    for key, value in r.project.shared.fetch(None).items():
        if key.startswith('__cauldron_'):
            continue
        data[key] = value

    r.append_body(render.status(data, values=show_values, types=show_types))


def pyplot(
        figure=None,
        scale: float = 0.8,
        clear: bool = True,
        aspect_ratio: typing.Union[list, tuple] = None
):
    """
    Creates a matplotlib plot in the display for the specified figure. The size
    of the plot is determined automatically to best fit the notebook.

    :param figure:
        The matplotlib figure to plot. If omitted, the currently active
        figure will be used.
    :param scale:
        The display scale with units of fractional screen height. A value
        of 0.5 constrains the output to a maximum height equal to half the
        height of browser window when viewed. Values below 1.0 are usually
        recommended so the entire output can be viewed without scrolling.
    :param clear:
        Clears the figure after it has been rendered. This is useful to
        prevent persisting old plot data between repeated runs of the
        project files. This can be disabled if the plot is going to be
        used later in the project files.
    :param aspect_ratio:
        The aspect ratio for the displayed plot as a two-element list or
        tuple. The first element is the width and the second element the
        height. The units are "inches," which is an important consideration
        for the display of text within the figure. If no aspect ratio is
        specified, the currently assigned values to the plot will be used
        instead.
    """
    r = _get_report()
    r.append_body(render_plots.pyplot(
        figure,
        scale=scale,
        clear=clear,
        aspect_ratio=aspect_ratio
    ))
    r.stdout_interceptor.write_source('[ADDED] PyPlot plot\n')


def bokeh(model, scale: float = 0.7, responsive: bool = True):
    """
    Adds a Bokeh plot object to the notebook display.

    :param model:
        The plot object to be added to the notebook display.
    :param scale:
        How tall the plot should be in the notebook as a fraction of screen
        height. A number between 0.1 and 1.0. The default value is 0.7.
    :param responsive:
        Whether or not the plot should responsively scale to fill the width
        of the notebook. The default is True.
    """
    r = _get_report()

    if 'bokeh' not in r.library_includes:
        r.library_includes.append('bokeh')

    r.append_body(render_plots.bokeh_plot(
        model=model,
        scale=scale,
        responsive=responsive
    ))
    r.stdout_interceptor.write_source('[ADDED] Bokeh plot\n')


def listing(
        source: list,
        ordered: bool = False,
        expand_full: bool = False
):
    """
    An unordered or ordered list of the specified *source* iterable where
    each element is converted to a string representation for display.

    :param source:
        The iterable to display as a list.
    :param ordered:
        Whether or not the list should be ordered. If False, which is the
        default, an unordered bulleted list is created.
    :param expand_full:
        Whether or not the list should expand to fill the screen horizontally.
        When defaulted to False, the list is constrained to the center view
        area of the screen along with other text. This can be useful to keep
        lists aligned with the text flow.
    """
    r = _get_report()
    r.append_body(render.listing(
        source=source,
        ordered=ordered,
        expand_full=expand_full
    ))
    r.stdout_interceptor.write_source('[ADDED] Listing\n')


def list_grid(
        source: list,
        expand_full: bool = False,
        column_count: int = 2,
        row_spacing: float = 1.0
):
    """
    An multi-column list of the specified *source* iterable where
    each element is converted to a string representation for display.

    :param source:
        The iterable to display as a list.
    :param expand_full:
        Whether or not the list should expand to fill the screen horizontally.
        When defaulted to False, the list is constrained to the center view
        area of the screen along with other text. This can be useful to keep
        lists aligned with the text flow.
    :param column_count:
        The number of columns to display. The specified count is applicable to
        high-definition screens. For Lower definition screens the actual count
        displayed may be fewer as the layout responds to less available
        horizontal screen space.
    :param row_spacing:
        The number of lines of whitespace to include between each row in the
        grid. Set this to 0 for tightly displayed lists.
    """
    r = _get_report()
    r.append_body(render.list_grid(
        source=source,
        expand_full=expand_full,
        column_count=column_count,
        row_spacing=row_spacing
    ))
    r.stdout_interceptor.write_source('[ADDED] List grid\n')


def latex(source: str):
    """
    Add a mathematical equation in latex math-mode syntax to the display.
    Instead of the traditional backslash escape character, the @ character is
    used instead to prevent backslash conflicts with Python strings. For
    example, \\delta would be @delta.

    :param source:
        The string representing the latex equation to be rendered.
    """
    r = _get_report()
    if 'katex' not in r.library_includes:
        r.library_includes.append('katex')

    r.append_body(render_texts.latex(source.replace('@', '\\')))
    r.stdout_interceptor.write_source('[ADDED] Latex equation\n')


def head(source, count: int = 5):
    """
    Displays a specified number of elements in a source object of many
    different possible types.

    :param source:
        DataFrames will show *count* rows of that DataFrame. A list, tuple or
        other iterable, will show the first *count* rows. Dictionaries will
        show *count* keys from the dictionary, which will be randomly selected
        unless you are using an OrderedDict. Strings will show the first
        *count* characters.
    :param count:
        The number of elements to show from the source.
    """
    r = _get_report()
    r.append_body(render_texts.head(source, count=count))
    r.stdout_interceptor.write_source('[ADDED] Head\n')


def tail(source, count: int = 5):
    """
    The opposite of the head function. Displays the last *count* elements of
    the *source* object.

    :param source:
        DataFrames will show the last *count* rows of that DataFrame. A list,
        tuple or other iterable, will show the last *count* rows. Dictionaries
        will show *count* keys from the dictionary, which will be randomly
        selected unless you are using an OrderedDict. Strings will show the
        last *count* characters.
    :param count:
        The number of elements to show from the source.
    """
    r = _get_report()
    r.append_body(render_texts.tail(source, count=count))
    r.stdout_interceptor.write_source('[ADDED] Tail\n')


def status(
        message: str = None,
        progress: float = None,
        section_message: str = None,
        section_progress: float = None,
):
    """
    Updates the status display, which is only visible while a step is running.
    This is useful for providing feedback and information during long-running
    steps.

    A section progress is also available for cases where long running tasks
    consist of multiple tasks and you want to display sub-progress messages
    within the context of the larger status.

    Note: this is only supported when running in the Cauldron desktop
    application.

    :param message:
        The status message you want to display. If left blank the previously
        set status message will be retained. Should you desire to remove an
        existing message, specify a blank string for this argument.
    :param progress:
        A number between zero and one that indicates the overall progress for
        the current status. If no value is specified, the previously assigned
        progress will be retained.
    :param section_message:
        The status message you want to display for a particular task within a
        long-running step. If left blank the previously set section message
        will be retained. Should you desire to remove an existing message,
        specify a blank string for this argument.
    :param section_progress:
        A number between zero and one that indicates the progress for the
        current section status. If no value is specified, the previously
        assigned section progress value will be retained.
    """
    environ.abort_thread()
    r = _get_report()
    step = _cd.project.get_internal_project().current_step

    changes = 0
    has_changed = step.progress_message != message
    if message is not None and has_changed:
        changes += 1
        step.progress_message = message

    has_changed = step.progress_message != max(0, min(1, progress or 0))
    if progress is not None and has_changed:
        changes += 1
        step.progress = max(0.0, min(1.0, progress))

    has_changed = step.sub_progress_message != section_message
    if section_message is not None and has_changed:
        changes += 1
        step.sub_progress_message = section_message

    has_changed = step.sub_progress != max(0, min(1, section_progress or 0))
    if section_progress is not None and has_changed:
        changes += 1
        step.sub_progress = section_progress

    if changes > 0:
        # update the timestamp to inform rendering that a status
        # has changed and should be re-rendered into the step.
        r.update_last_modified()


def code_block(
        code: str = None,
        path: str = None,
        language_id: str = None,
        title: str = None,
        caption: str = None
):
    """
    Adds a block of syntax highlighted code to the display from either
    the supplied code argument, or from the code file specified
    by the path argument.

    :param code:
        A string containing the code to be added to the display
    :param path:
        A path to a file containing code to be added to the display
    :param language_id:
        The language identifier that indicates what language should
        be used by the syntax highlighter. Valid values are any of the
        languages supported by the Pygments highlighter.
    :param title:
        If specified, the code block will include a title bar with the
        value of this argument
    :param caption:
        If specified, the code block will include a caption box below the code
        that contains the value of this argument
    """
    environ.abort_thread()
    r = _get_report()
    r.append_body(render.code_block(
        block=code,
        path=path,
        language=language_id,
        title=title,
        caption=caption
    ))
    r.stdout_interceptor.write_source('{}\n'.format(code))


def elapsed():
    """
    Displays the elapsed time since the step started running.
    """
    environ.abort_thread()
    step = _cd.project.get_internal_project().current_step
    r = _get_report()
    r.append_body(render.elapsed_time(step.elapsed_time))

    result = '[ELAPSED]: {}\n'.format(timedelta(seconds=step.elapsed_time))
    r.stdout_interceptor.write_source(result)
