import contextlib
import os
import uuid
import webbrowser
from io import BytesIO
from typing import Any, Iterable, Iterator, Optional, Tuple, Union
from urllib.parse import urljoin

import pybloqs.htmlconv as htmlconv
from pybloqs.config import user_config
from pybloqs.email import send_html_report
from pybloqs.html import append_to, id_generator, js_elem, render, root
from pybloqs.static import Css, DependencyTracker, register_interactive, script_block_core, script_inflate
from pybloqs.util import Cfg, cfg_to_css_string

default_css_main = Css(os.path.join("css", "pybloqs_default", "main"))
register_interactive(default_css_main)


class BaseBlock:
    """
    Base class for all blocks. Provides infrastructure for rendering the block
    in an IPython Notebook or saving it to disk in HTML, PDF, PNG or JPG format.
    """

    container_tag = "div"
    resource_deps = ()

    def __init__(
        self,
        title: Optional[str] = None,
        title_level: int = 3,
        title_wrap: bool = False,
        width=None,
        height=None,
        inherit_cfg: bool = True,
        styles=None,
        classes: Union[str, Iterable[str]] = (),
        anchor=None,
        id_: Optional[str] = None,
        **kwargs,
    ) -> None:
        self._settings = Cfg(
            title=title,
            title_level=title_level,
            title_wrap=title_wrap,
            cascading_cfg=Cfg(**kwargs).override(styles or Cfg()),
            default_cfg=Cfg(),
            inherit_cfg=inherit_cfg,
            width=width,
            height=height,
            classes=["pybloqs"] + ([classes] if isinstance(classes, str) else list(classes)),
        )
        # Anchor should not be inherited, so keep outside of Cfg
        self._anchor = anchor
        self._id = id_ or uuid.uuid4().hex

    def render_html(
        self,
        pretty: bool = True,
        static_output: bool = False,
        header_block: Optional["BaseBlock"] = None,
        footer_block: Optional["BaseBlock"] = None,
        permit_compression: bool = True,
    ) -> str:
        """
        Returns html output of the block

        :param pretty: Toggles pretty printing of the resulting HTML. Not applicable for non-HTML output.
        :param static_output: Passed down to _write_block. Will render static version of blocks which support this.
        :param header_block: If not None, header_block is inlined into a HTML body as table.
        :param footer_block: If not None, footer_block is inlined into a HTML body as table.
        :param permit_compression: If set, resources will be embedded as base64 gzipped files
        :return html-code of the block
        """
        # Render the contents
        html = root("html", doctype="html")
        head = append_to(html, "head")
        append_to(head, "meta", charset="utf-8")

        body = append_to(html, "body")

        # Make sure that the main style sheet is always included
        resource_deps = DependencyTracker(default_css_main)

        # If header or footer are passed into this function, inline them in the following structure:
        #
        # <body>
        # <table>
        #    <thead><tr><td>Header html</td></tr></thead>
        #    <tfoot><tr><td>Footer html</td></tr></tfoot>
        #    <tbody><tr><td>Body html</td></tr></tbody>
        # </table>
        # </body>
        if header_block is not None or footer_block is not None:
            content_table = append_to(body, "table")
            if header_block is not None:
                header_thead = append_to(content_table, "thead")
                header_tr = append_to(header_thead, "tr")
                header_td = append_to(header_tr, "th")
                header_block._write_block(
                    header_td, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output
                )

            if footer_block is not None:
                footer_tfoot = append_to(content_table, "tfoot", id="footer")
                footer_tr = append_to(footer_tfoot, "tr")
                footer_td = append_to(footer_tr, "td")
                footer_block._write_block(
                    footer_td, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output
                )

            body_tbody = append_to(content_table, "tbody")
            body_tr = append_to(body_tbody, "tr")
            body_td = append_to(body_tr, "td")
            self._write_block(body_td, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output)
        else:
            self._write_block(body, Cfg(), id_generator(), resource_deps=resource_deps, static_output=static_output)

        if permit_compression:
            script_inflate.write(head)
        script_block_core.write(head)

        if static_output:
            # Add the load wait poller if there are any JS resources
            js_elem(body, "var loadWaitPoller=runWaitPoller();")

        # Write out resources
        for res in resource_deps:
            res.write(head, permit_compression=permit_compression)

        # Render the whole document (the parent of the html tag)
        content = render(html.parent, pretty=pretty)
        return content

    def save(
        self,
        filename: Optional[str] = None,
        fmt: Optional[str] = None,
        pdf_zoom: float = 1,
        pdf_page_size: str = htmlconv.html_converter.A4,
        pdf_auto_shrink: bool = True,
        orientation: str = htmlconv.html_converter.PORTRAIT,
        header_block: Optional["BaseBlock"] = None,
        header_spacing: Union[str, float] = 5,
        footer_block: Optional["BaseBlock"] = None,
        footer_spacing: Union[str, float] = 5,
        **kwargs,
    ) -> str:
        """
        Render and save the block. Depending on whether the filename or the format is
        provided, the content will either be written out to a file or returned as a string.

        :param filename: Format will be based on the file extension.
                         The following formats are supported:
                         - HTML
                         - PDF
                         - PNG
                         - JPG
        :param fmt: Specifies the format of a temporary output file. When supplied, the filename
                    parameter must be omitted.
        :param pdf_zoom: The zooming to apply when rendering the page.
        :param pdf_page_size: The page size to use when rendering the page to PDF.
        :param pdf_auto_shrink: Toggles auto-shrinking content to fit the desired page size (wkhtmltopdf only)
        :param orientation: Either html_converter.PORTRAIT or html_converter.LANDSCAPE
        :param header_block: Block to be used as header (and repeated on every page). Only used for PDF output.
        :param header_spacing: Size of header block. Numbers are in mm. HTML sizes (e.g. '5cm') in chrome_headless only.
        :param footer_block: Block to be used as footer (and repeated on every page). Only used for PDF output.
        :param footer_spacing: Size of header block. Numbers are in mm. HTML sizes (e.g. '5cm') in chrome_headless only.
        :return: html filename
        """
        # Ensure that exactly one of filename or fmt is provided
        if filename is None and fmt is None:
            raise ValueError("One of `filename` or `fmt` must be provided.")

        tempdir = user_config["tmp_html_dir"]

        if filename:
            _, fmt_from_name = os.path.splitext(filename)
            # Exclude the dot from the extension, gosh darn it!
            fmt_from_name = fmt_from_name[1:]
            if fmt is None:
                if fmt_from_name == "":
                    raise ValueError("If fmt is not specified, filename must contain extension")
                fmt = fmt_from_name
            else:
                if fmt != fmt_from_name:
                    filename += "." + fmt
        else:
            name = self._id[: user_config["id_precision"]] + "." + fmt
            filename = os.path.join(tempdir, name)

        # Force extension to be lower case so format checks are easier later
        fmt = fmt.lower()

        is_html = "htm" in fmt

        if is_html:
            content = self.render_html(static_output=False, header_block=header_block, footer_block=footer_block)
            with open(filename, "w", encoding="utf-8") as f:
                f.write(content)
        else:
            converter = htmlconv.get_converter(fmt)
            converter.htmlconv(
                self,
                filename,
                header_block=header_block,
                header_spacing=header_spacing,
                footer_block=footer_block,
                footer_spacing=footer_spacing,
                pdf_page_size=pdf_page_size,
                orientation=orientation,
                pdf_auto_shrink=pdf_auto_shrink,
                pdf_zoom=pdf_zoom,
                **kwargs,
            )
        return filename

    def publish(self, name: str, *args, **kwargs) -> str:
        """
        Publish the block so that others can access it.

        :param name: Name to publish under. Can be a filename or a relative path.
        :param args: Arguments to pass to `Block.save`.
        :param kwargs: Keyword arguments to pass to `Block.save`.
        :return: Path to the published block file.
        """
        full_path = os.path.join(user_config["public_dir"], name)
        full_path = os.path.expanduser(full_path)

        base_dir = os.path.dirname(full_path)

        with contextlib.suppress(OSError):  # Ignore if directory already exists
            os.makedirs(base_dir)

        self.save(full_path, *args, **kwargs)

        return full_path

    def show(
        self, fmt: str = "html", header_block: Optional["BaseBlock"] = None, footer_block: Optional["BaseBlock"] = None
    ) -> str:
        """
        Show the block in a browser.

        :param fmt: The format of the saved block. Supports the same output as `Block.save`
        :return: Path to the block file.
        """
        file_name = self._id[: user_config["id_precision"]] + "." + fmt
        file_path = self.publish(
            os.path.expanduser(os.path.join(user_config["tmp_html_dir"], file_name)),
            header_block=header_block,
            footer_block=footer_block,
        )

        try:
            url_base = user_config["public_dir"]
        except KeyError:
            path = os.path.expanduser(file_path)
        else:
            path = urljoin(url_base, os.path.expanduser(user_config["tmp_html_dir"] + "/" + file_name))

        webbrowser.open_new_tab(path)

        return path

    def email(
        self,
        title: str = "",
        recipients: Tuple[str, ...] = (user_config["user_email_address"],),
        header_block: Optional["BaseBlock"] = None,
        footer_block: Optional["BaseBlock"] = None,
        from_address: Optional[str] = None,
        cc: Optional[str] = None,
        bcc: Optional[str] = None,
        attachments=None,
        convert_to_ascii: bool = True,
        **kwargs,
    ) -> None:
        """
        Send the rendered blocks as email. Each output format chosen will be added as an
        attachment.

        :param title: title of the email
        :param recipients: recipient of the email
        :param fmt: One or more output formats that should be included as attachments.
                    The following formats are supported:
                    - HTML
                    - PDF
                    - PNG
                    - JPG
        :param body_block: The block to use as the email body. The default behavior is
                          to use the current block.
        :param from_address: sender of the message. Defaults to user name.
            Can be overwritten in .pybloqs.cfg with yaml format: 'user_email_address: a@b.com'
        :param cc: cc recipient
        :param bcc: bcc recipient
        :param convert_to_ascii: bool to control convertion of html email to ascii or to leave in current format
        :param kwargs: Optional arguments to pass to `Block.render_html()`
        """
        if from_address is None:
            from_address = user_config["user_email_address"]

        # The email body needs to be static without any dynamic elements.
        email_html = self.render_html(header_block=header_block, footer_block=footer_block, **kwargs)

        send_html_report(
            email_html,
            recipients,
            subject=title,
            attachments=attachments,
            From=from_address,
            Cc=cc,
            Bcc=bcc,
            convert_to_ascii=convert_to_ascii,
        )

    def to_static(self) -> "BaseBlock":
        return self._visit(lambda block: block._to_static())

    def _to_static(self) -> "BaseBlock":
        """
        Subclasses can override this method to provide a static content version.
        """
        return self

    def _visit(self, visitor) -> Any:
        """
        Calls the supplied visitor function on this block and any sub-blocks
        :param visitor: Visitor function
        :return: Return value of the visitor
        """
        return visitor(self)

    def _provide_default_cfg(self, defaults) -> None:
        """
        Makes the supplied config to be part of the defaults for the block.
        :param defaults: The default parameters that should be inherited.
        """
        self._settings.default_cfg = self._settings.default_cfg.inherit(defaults)

    def _combine_parent_cfg(self, parent_cfg) -> Cfg:
        """from pybloqs.config import user_config
        Combine the supplied parent and the current Block's config.

        :param parent_cfg: Parent config to inherit from.
        :return: Combined config.
        """
        # Combine parameters only if inheritance is turned on
        if self._settings.inherit_cfg:
            actual_cfg = self._settings.cascading_cfg.inherit(parent_cfg)
        else:
            actual_cfg = self._settings.cascading_cfg

        # Any undefined settings will use the defaults
        actual_cfg = actual_cfg.inherit(self._settings.default_cfg)

        return actual_cfg

    def _get_styles_string(self, styles_cfg) -> str:
        """
        Converts the styles configuration to a CSS styles string.

        :param styles_cfg: The configuration object to convert.
        :return: CSS string
        """
        sizing_cfg = Cfg()

        if self._settings.width is not None:
            sizing_cfg["width"] = self._settings.width

        if self._settings.height is not None:
            sizing_cfg["height"] = self._settings.height

        # Replace `_` with `-` and make values lowercase to get valid CSS names
        return cfg_to_css_string(styles_cfg.override(sizing_cfg))

    def _write_block(self, parent, parent_cfg, id_gen, resource_deps=None, static_output: bool = False) -> None:
        """
        Writes out the block into the supplied stream, inheriting the parent_parameters.

        :param parent: Parent element
        :param parent_cfg: Parent parameters to inherit.
        :param id_gen: Unique ID generator.
        :param resource_deps: Object used to register resource dependencies.
        :param static_output: A value of True signals to blocks that the final output will
                              be a static format. Certain dynamic content will render with
                              alternate options.

        """
        if resource_deps is not None:
            for res in self.resource_deps:
                resource_deps.add(res)

        actual_cfg = self._combine_parent_cfg(parent_cfg)

        if self.container_tag is not None:
            container = append_to(parent, self.container_tag)
            self._write_container_attrs(container, actual_cfg)
        else:
            container = parent

        self._write_anchor(container)
        self._write_title(container)
        self._write_contents(container, actual_cfg, id_gen, resource_deps=resource_deps, static_output=static_output)

    def _write_container_attrs(self, container, actual_cfg) -> None:
        """
        Writes out the container attributes (styles, class, etc...).
        Note that this method will only be called if the container tag is not `None`.

        :param container: Container element.
        :param actual_cfg: Actual parameters to use.
        """
        styles = self._get_styles_string(actual_cfg)
        if len(styles) > 0:
            container["style"] = styles

        container["class"] = self._settings.classes

    def _write_title(self, container) -> None:
        """
        Write out the title (if there is any).

        :param container: Container element.
        """
        if self._settings.title is not None and (self._settings.title != ""):
            title = append_to(
                container,
                f"H{self._settings.title_level}",
                style="white-space: %s" % ("normal" if self._settings.title_wrap else "nowrap"),
            )
            title.string = self._settings.title

    def _write_anchor(self, container) -> None:
        """
        Write HTML anchor for linking within page

        :param container: Container element.
        """
        if self._anchor is not None:
            append_to(container, "a", name=self._anchor)

    def _write_contents(
        self, container, actual_cfg, id_gen: Iterator[str], resource_deps=None, static_output: Optional[bool] = None
    ) -> None:
        """
        Write out the actual contents of the block. Deriving classes must override
        this method.

        :param container: Container element.
        :param actual_cfg: Actual parameters to use.
        :param id_gen: Unique ID generator.
        :param resource_deps: Object used to register resource dependencies.
        :param static_output: A value of True signals to blocks that the final output will
                              be a static format. Certain dynamic content will render with
                              alternate options.
        """
        raise NotImplementedError("_write_contents")

    def _repr_html_(self, *_) -> str:
        """
        Function required to support interactive IPython plopping and plotting.

        Should not be used directly.

        :return: Data to be displayed
        """
        return self.data.decode()

    @property
    def data(self) -> bytes:
        """
        Function required to support interactive IPython plotting.

        Should not be used directly.

        :return: Data to be displayed
        """
        container = root("div")
        resource_deps = DependencyTracker(default_css_main)
        self._write_block(container, Cfg(), id_generator(), resource_deps=resource_deps)
        for resource in resource_deps:
            resource.write(container, permit_compression=False)

        # Write children into the output
        output = BytesIO()

        for child in container.children:
            output.write(render(child).encode("utf-8"))

        return output.getvalue()


class HRule(BaseBlock):
    """
    Draws a horizontal divider line.
    """

    def _write_block(self, parent, *_args, **_kwargs) -> None:
        # Add a `hr` element to the parent
        append_to(parent, "hr")
