# -*- coding: utf-8 -*-
"""
Markdown popup.

Markdown tooltips and phantoms for SublimeText.

TextMate theme to CSS.

https://manual.macromates.com/en/language_grammars#naming_conventions
"""
import sublime
import markdown
import jinja2
import traceback
import time
from . import version as ver
from . import colorbox
from collections import OrderedDict
from .st_scheme_template import SchemeTemplate, POPUP, PHANTOM, SHEET
from .st_clean_css import clean_css
from .st_pygments_highlight import syntax_hl as pyg_syntax_hl
from .st_code_highlight import SublimeHighlight
from .st_mapping import lang_map
from . import imagetint
import re
import os
from . import frontmatter
try:
    import bs4
except Exception:
    bs4 = None

HTML_SHEET_SUPPORT = int(sublime.version()) >= 4074

DEFAULT_CSS = 'Packages/mdpopups/mdpopups_css/default.css'
OLD_DEFAULT_CSS = 'Packages/mdpopups/css/default.css'
DEFAULT_USER_CSS = 'Packages/User/mdpopups.css'
IDK = '''
<style>html {background-color: #333; color: red}</style>
<div><p>¯\\_(ツ)_/¯</p></div>
<div><p>
MdPopups failed to create<br>
the popup/phantom!<br><br>
Check the console to see if<br>
there are helpful errors.</p></div>
'''
HL_SETTING = 'mdpopups.use_sublime_highlighter'
STYLE_SETTING = 'mdpopups.default_style'
LEGACY_MATCHER_SETTING = 'mdpopups.legacy_color_matcher'
RE_BAD_ENTITIES = re.compile(r'(&(?!amp;|lt;|gt;|nbsp;)(?:\w+;|#\d+;))')

NODEBUG = 0
ERROR = 1
WARNING = 2
INFO = 3


def _log(msg):
    """Log."""

    print('mdpopups: %s' % str(msg))


def _debug(msg, level):
    """Debug log."""

    if int(_get_setting('mdpopups.debug', NODEBUG)) >= level:
        _log(msg)


def _get_setting(name, default=None):
    """Get the Sublime setting."""

    return sublime.load_settings('Preferences.sublime-settings').get(name, default)


def _can_show(view, location=-1):
    """
    Check if popup can be shown.

    I have seen Sublime can sometimes crash if trying
    to do a popup off screen.  Normally it should just not show,
    but sometimes it can crash.  We will check if popup
    can/should be attempted.
    """

    can_show = True
    sel = view.sel()
    if location >= 0:
        region = view.visible_region()
        if region.begin() > location or region.end() < location:
            can_show = False
    elif len(sel) >= 1:
        region = view.visible_region()
        if region.begin() > sel[0].b or region.end() < sel[0].b:
            can_show = False
    else:
        can_show = False

    return can_show


##############################
# Theme/Scheme cache management
##############################
_scheme_cache = OrderedDict()
_highlighter_cache = OrderedDict()


def _clear_cache():
    """Clear the CSS cache."""

    global _scheme_cache
    global _highlighter_cache
    _scheme_cache = OrderedDict()
    _highlighter_cache = OrderedDict()


def _is_cache_expired(cache_time):
    """Check if the cache entry is expired."""

    delta_time = _get_setting('mdpopups.cache_refresh_time', 30)
    if not isinstance(delta_time, int) or delta_time < 0:
        delta_time = 30
    return delta_time == 0 or (time.time() - cache_time) >= (delta_time * 60)


def _prune_cache():
    """Prune older items in cache (related to when they were inserted)."""

    limit = _get_setting('mdpopups.cache_limit', 10)
    if limit is None or not isinstance(limit, int) or limit <= 0:
        limit = 10
    while len(_scheme_cache) >= limit:
        _scheme_cache.popitem(last=True)
    while len(_highlighter_cache) >= limit:
        _highlighter_cache.popitem(last=True)


def _get_sublime_highlighter(view):
    """Get the `SublimeHighlighter` object."""

    scheme = view.settings().get('color_scheme')
    obj = None
    if scheme is not None:
        if scheme in _highlighter_cache:
            obj, t = _highlighter_cache[scheme]
            if _is_cache_expired(t):
                obj = None
        if obj is None:
            try:
                obj = SublimeHighlight(scheme)
                _prune_cache()
                _highlighter_cache[scheme] = (obj, time.time())
            except Exception:
                _log('Failed to get Sublime highlighter object!')
                _debug(traceback.format_exc(), ERROR)
                pass
    return obj


def _get_scheme(scheme):
    """Get the scheme object and user CSS."""

    settings = sublime.load_settings("Preferences.sublime-settings")
    obj = None
    user_css = ''
    default_css = ''
    if scheme is not None:
        if scheme in _scheme_cache:
            obj, user_css, default_css, t = _scheme_cache[scheme]
            # Check if cache expired or user changed Pygments setting.
            if (
                _is_cache_expired(t) or
                obj.use_pygments != (not settings.get(HL_SETTING, True)) or
                obj.default_style != settings.get(STYLE_SETTING, True) or
                obj.legacy_color_matcher != settings.get(LEGACY_MATCHER_SETTING, False)
            ):
                obj = None
                user_css = ''
                default_css = ''
        if obj is None:
            try:
                obj = SchemeTemplate(scheme)
                _prune_cache()
                user_css = _get_user_css()
                default_css = _get_default_css()
                _scheme_cache[scheme] = (obj, user_css, default_css, time.time())
            except Exception:
                _log('Failed to convert/retrieve scheme to CSS!')
                _debug(traceback.format_exc(), ERROR)
    return obj, user_css, default_css


def _get_default_css():
    """Get default CSS."""

    return clean_css(sublime.load_resource(DEFAULT_CSS))


def _get_user_css():
    """Get user CSS."""

    css = None

    user_css = _get_setting('mdpopups.user_css', DEFAULT_USER_CSS)
    if user_css == OLD_DEFAULT_CSS:
        user_css = DEFAULT_CSS
    try:
        css = clean_css(sublime.load_resource(user_css))
    except Exception:
        pass
    return css if css else ''


##############################
# Markdown parsing
##############################
class _MdWrapper(markdown.Markdown):
    """
    Wrapper around Python Markdown's class.

    This allows us to gracefully continue when a module doesn't load.
    """

    Meta = {}

    def __init__(self, *args, **kwargs):
        """Call original initialization."""

        if 'allow_code_wrap' in kwargs:
            self.sublime_wrap = kwargs['allow_code_wrap']
            del kwargs['allow_code_wrap']
        if 'sublime_hl' in kwargs:
            self.sublime_hl = kwargs['sublime_hl']
            del kwargs['sublime_hl']

        super(_MdWrapper, self).__init__(*args, **kwargs)

    def registerExtensions(self, extensions, configs):  # noqa
        """
        Register extensions with this instance of Markdown.

        Keyword arguments:

        * `extensions`: A list of extensions, which can either
           be strings or objects.  See the docstring on Markdown.
        * `configs`: A dictionary mapping module names to configuration options.

        """

        from markdown import util
        from markdown.extensions import Extension

        for ext in extensions:
            try:
                if isinstance(ext, util.string_type):
                    ext = self.build_extension(ext, configs.get(ext, {}))
                if isinstance(ext, Extension):
                    ext._extendMarkdown(self)
                elif ext is not None:
                    raise TypeError(
                        'Extension "%s.%s" must be of type: "markdown.Extension"'
                        % (ext.__class__.__module__, ext.__class__.__name__)
                    )
            except Exception:
                # We want to gracefully continue even if an extension fails.
                _log('Failed to load markdown module!')
                _debug(traceback.format_exc(), ERROR)

        return self


def _get_theme(view, css=None, css_type=POPUP, template_vars=None):
    """Get the theme."""

    obj, user_css, default_css = _get_scheme(view.settings().get('color_scheme'))
    try:
        return obj.apply_template(
            view,
            default_css + '\n' +
            ((clean_css(css) + '\n') if css else '') +
            user_css,
            css_type,
            template_vars
        ) if obj is not None else ''
    except Exception:
        _log('Failed to retrieve scheme CSS!')
        _debug(traceback.format_exc(), ERROR)
        return ''


def _remove_entities(text):
    """Remove unsupported HTML entities."""

    import html.parser
    html = html.parser.HTMLParser()

    def repl(m):
        """Replace entities except &, <, >, and `nbsp`."""
        return html.unescape(m.group(1))

    return RE_BAD_ENTITIES.sub(repl, text)


def _create_html(
    view, content, md=True, css=None, debug=False, css_type=POPUP,
    wrapper_class=None, template_vars=None, template_env_options=None, nl2br=True,
    allow_code_wrap=False
):
    """Create HTML from content."""

    debug = _get_setting('mdpopups.debug', NODEBUG)

    if css is None or not isinstance(css, str):
        css = ''

    style = _get_theme(view, css, css_type, template_vars)

    if debug:
        _debug('=====CSS=====', INFO)
        _debug(style, INFO)

    if md:
        content = md2html(
            view, content, template_vars=template_vars,
            template_env_options=template_env_options, nl2br=nl2br,
            allow_code_wrap=allow_code_wrap
        )
    else:
        # Strip out frontmatter if found as we don't currently
        # do anything with it when content is just HTML.
        content = _markup_template(frontmatter.get_frontmatter(content)[1], template_vars, template_env_options)

    if debug:
        _debug('=====HTML OUTPUT=====', INFO)
        if bs4:
            soup = bs4.BeautifulSoup(content, "html.parser")
            _debug('\n' + soup.prettify(), INFO)
        else:
            _debug('\n' + content, INFO)

    if wrapper_class:
        wrapper = ('<div class="mdpopups"><div class="%s">' % wrapper_class) + '%s</div></div>'
    else:
        wrapper = '<div class="mdpopups">%s</div>'

    html = "<style>%s</style>" % (style)
    html += _remove_entities(wrapper % content)
    return html


def _markup_template(markup, variables, options):
    """Template for markup."""

    if variables:
        if options is None:
            options = {}
        env = jinja2.Environment(**options)
        return env.from_string(markup).render(plugin=variables)
    return markup


##############################
# Public functions
##############################
def version():
    """Get the current version."""

    return ver.version()


def md2html(
    view, markup, template_vars=None, template_env_options=None,
    nl2br=True, allow_code_wrap=False
):
    """Convert Markdown to HTML."""

    if _get_setting('mdpopups.use_sublime_highlighter'):
        sublime_hl = (True, _get_sublime_highlighter(view))
    else:
        sublime_hl = (False, None)

    fm, markup = frontmatter.get_frontmatter(markup)

    # We always include these
    extensions = [
        "mdpopups.mdx.highlight",
        "pymdownx.inlinehilite",
        "pymdownx.superfences"
    ]

    configs = {
        "mdpopups.mdx.highlight": {
            "guess_lang": False
        },
        "pymdownx.inlinehilite": {
            "style_plain_text": True
        },
        "pymdownx.superfences": {
            "custom_fences": fm.get('custom_fences', [])
        }
    }

    # Check if plugin is overriding extensions
    md_exts = fm.get('markdown_extensions', None)
    if md_exts is None:
        # No extension override, use defaults
        extensions.extend(
            [
                "markdown.extensions.admonition",
                "markdown.extensions.attr_list",
                "markdown.extensions.def_list",
                "pymdownx.betterem",
                "pymdownx.magiclink",
                "pymdownx.extrarawhtml"
            ]
        )

        # Use legacy method to determine if `nl2b` should be used
        if nl2br:
            extensions.append('markdown.extensions.nl2br')
    else:
        for ext in md_exts:
            if isinstance(ext, (dict, OrderedDict)):
                k, v = next(iter(ext.items()))
                # We don't allow plugins to overrides the internal color
                if not k.startswith('mdpopups.'):
                    extensions.append(k)
                    if v is not None:
                        configs[k] = v
            elif isinstance(ext, str):
                if not ext.startswith('mdpopups.'):
                    extensions.append(ext)

    return _MdWrapper(
        extensions=extensions,
        extension_configs=configs,
        sublime_hl=sublime_hl,
        allow_code_wrap=fm.get('allow_code_wrap', allow_code_wrap)
    ).convert(_markup_template(markup, template_vars, template_env_options)).replace('&quot;', '"').replace('\n', '')


def color_box(
    colors, border="#000000ff", border2=None, height=32, width=32,
    border_size=1, check_size=4, max_colors=5, alpha=False, border_map=0xF
):
    """Color box."""

    return colorbox.color_box(
        colors, border, border2, height, width,
        border_size, check_size, max_colors, alpha, border_map
    )


def color_box_raw(
    colors, border="#000000ff", border2=None, height=32, width=32,
    border_size=1, check_size=4, max_colors=5, alpha=False, border_map=0xF
):
    """Color box raw."""

    return colorbox.color_box_raw(
        colors, border, border2, height, width,
        border_size, check_size, max_colors, alpha, border_map
    )


def tint(img, color, opacity=255, height=None, width=None):
    """Tint the image."""

    if isinstance(img, str):
        try:
            img = sublime.load_binary_resource(img)
        except Exception:
            _log('Could not open binary file!')
            _debug(traceback.format_exc(), ERROR)
            return ''
    return imagetint.tint(img, color, opacity, height, width)


def tint_raw(img, color, opacity=255):
    """Tint the image."""

    if isinstance(img, str):
        try:
            img = sublime.load_binary_resource(img)
        except Exception:
            _log('Could not open binary file!')
            _debug(traceback.format_exc(), ERROR)
            return ''
    return imagetint.tint_raw(img, color, opacity)


def get_language_from_view(view):
    """Guess current language from view."""

    lang = None
    user_map = sublime.load_settings('Preferences.sublime-settings').get('mdpopups.sublime_user_lang_map', {})
    syntax = os.path.splitext(view.settings().get('syntax').replace('Packages/', '', 1))[0]
    keys = set(list(lang_map.keys()) + list(user_map.keys()))
    for key in keys:
        v1 = lang_map.get(key, (tuple(), tuple()))[1]
        v2 = user_map.get(key, (tuple(), tuple()))[1]
        if syntax in (tuple(v2) + v1):
            lang = key
            break
    return lang


def syntax_highlight(view, src, language=None, inline=False, allow_code_wrap=False):
    """Syntax highlighting for code."""

    try:
        if _get_setting('mdpopups.use_sublime_highlighter'):
            highlighter = _get_sublime_highlighter(view)
            code = highlighter.syntax_highlight(
                src, language, inline=inline, code_wrap=(not inline and allow_code_wrap)
            )
        else:
            code = pyg_syntax_hl(
                src, language, inline=inline, code_wrap=(not inline and allow_code_wrap)
            )
    except Exception:
        code = src
        _log('Failed to highlight code!')
        _debug(traceback.format_exc(), ERROR)

    return code


def tabs2spaces(text, tab_size=4):
    """
    Convert tabs to spaces on tab stops.

    Does not account for char width.
    """

    return text.expandtabs(tab_size)


def scope2style(view, scope, selected=False, explicit_background=False):
    """Convert the scope to a style."""

    style = {
        'color': None,
        'background': None,
        'style': ''
    }
    obj = _get_scheme(view.settings().get('color_scheme'))[0]
    style_obj = obj.guess_style(view, scope, selected, explicit_background)
    if not obj.legacy_color_matcher:
        style['color'] = style_obj['foreground']
        style['background'] = style_obj['background']
        font = []
        if style_obj['bold']:
            font.append('bold')
        if style_obj['italic']:
            font.append('italic')
        if style_obj['underline']:
            font.append('underline')
        if style_obj['glow']:
            font.append('glow')
        style['style'] = ' '.join(font)
    else:
        style['color'] = style_obj.fg_simulated
        style['background'] = style_obj.bg_simulated
        style['style'] = style_obj.style
    return style


def clear_cache():
    """Clear cache."""

    _clear_cache()


def hide_popup(view):
    """Hide the popup."""

    view.hide_popup()


def update_popup(
    view, content, md=True, css=None, wrapper_class=None,
    template_vars=None, template_env_options=None, nl2br=True,
    allow_code_wrap=False
):
    """Update the popup."""

    disabled = _get_setting('mdpopups.disable', False)
    if disabled:
        _debug('Popups disabled', WARNING)
        return

    try:
        html = _create_html(
            view, content, md, css, css_type=POPUP, wrapper_class=wrapper_class,
            template_vars=template_vars, template_env_options=template_env_options, nl2br=nl2br,
            allow_code_wrap=allow_code_wrap
        )
    except Exception:
        _log(traceback.format_exc())
        html = IDK

    view.update_popup(html)


def show_popup(
    view, content, md=True, css=None,
    flags=0, location=-1, max_width=320, max_height=240,
    on_navigate=None, on_hide=None, wrapper_class=None,
    template_vars=None, template_env_options=None, nl2br=True,
    allow_code_wrap=False
):
    """Parse the color scheme if needed and show the styled pop-up."""

    disabled = _get_setting('mdpopups.disable', False)
    if disabled:
        _debug('Popups disabled', WARNING)
        return

    if not _can_show(view, location):
        return

    try:
        html = _create_html(
            view, content, md, css, css_type=POPUP, wrapper_class=wrapper_class,
            template_vars=template_vars, template_env_options=template_env_options,
            nl2br=nl2br, allow_code_wrap=allow_code_wrap
        )
    except Exception:
        _log(traceback.format_exc())
        html = IDK

    view.show_popup(
        html, flags=flags, location=location, max_width=max_width,
        max_height=max_height, on_navigate=on_navigate, on_hide=on_hide
    )


def is_popup_visible(view):
    """Check if popup is visible."""

    return view.is_popup_visible()


def add_phantom(
    view, key, region, content, layout, md=True,
    css=None, on_navigate=None, wrapper_class=None,
    template_vars=None, template_env_options=None, nl2br=True,
    allow_code_wrap=False
):
    """Add a phantom and return phantom id."""

    disabled = _get_setting('mdpopups.disable', False)
    if disabled:
        _debug('Phantoms disabled', WARNING)
        return

    try:
        html = _create_html(
            view, content, md, css, css_type=PHANTOM, wrapper_class=wrapper_class,
            template_vars=template_vars, template_env_options=template_env_options,
            nl2br=nl2br, allow_code_wrap=allow_code_wrap
        )
    except Exception:
        _log(traceback.format_exc())
        html = IDK

    return view.add_phantom(key, region, html, layout, on_navigate)


def erase_phantoms(view, key):
    """Erase phantoms."""

    view.erase_phantoms(key)


def erase_phantom_by_id(view, pid):
    """Erase phantom by ID."""

    view.erase_phantom_by_id(pid)


def query_phantom(view, pid):
    """Query phantom."""

    return view.query_phantom(pid)


def query_phantoms(view, pids):
    """Query phantoms."""

    return view.query_phantoms(pids)


if HTML_SHEET_SUPPORT:
    def new_html_sheet(
        window, name, contents, md=True, css=None, flags=0, group=-1,
        wrapper_class=None, template_vars=None, template_env_options=None, nl2br=False,
        allow_code_wrap=False
    ):
        """Create new HTML sheet."""

        view = window.create_output_panel('mdpopups-dummy', unlisted=True)
        try:
            html = _create_html(
                view, contents, md, css, css_type=SHEET, wrapper_class=wrapper_class,
                template_vars=template_vars, template_env_options=template_env_options, nl2br=nl2br,
                allow_code_wrap=allow_code_wrap
            )
        except Exception:
            _log(traceback.format_exc())
            html = IDK

        return window.new_html_sheet(name, html, flags, group)

    def update_html_sheet(
        sheet, contents, md=True, css=None, wrapper_class=None,
        template_vars=None, template_env_options=None, nl2br=False, allow_code_wrap=False
    ):
        """Update an HTML sheet."""

        window = sheet.window()
        view = window.create_output_panel('mdpopups-dummy', unlisted=True)

        try:
            html = _create_html(
                view, contents, md, css, css_type=SHEET, wrapper_class=wrapper_class,
                template_vars=template_vars, template_env_options=template_env_options, nl2br=nl2br,
                allow_code_wrap=allow_code_wrap
            )
        except Exception:
            _log(traceback.format_exc())
            html = IDK

        sheet.set_contents(html)


class Phantom(sublime.Phantom):
    """A phantom object."""

    def __init__(
        self, region, content, layout, md=True,
        css=None, on_navigate=None, wrapper_class=None,
        template_vars=None, template_env_options=None, nl2br=True,
        allow_code_wrap=False
    ):
        """Initialize."""

        super().__init__(region, content, layout, on_navigate)
        self.md = md
        self.css = css
        self.wrapper_class = wrapper_class
        self.template_vars = template_vars
        self.template_env_options = template_env_options
        self.nl2br = nl2br
        self.allow_code_wrap = allow_code_wrap

    def __eq__(self, rhs):
        """Check if phantoms are equal."""

        # Note that self.id is not considered
        return (
            self.region == rhs.region and self.content == rhs.content and
            self.layout == rhs.layout and self.on_navigate == rhs.on_navigate and
            self.md == rhs.md and self.css == rhs.css and self.nl2br == rhs.nl2br and
            self.wrapper_class == rhs.wrapper_class and self.template_vars == rhs.template_vars and
            self.template_env_options == rhs.template_env_options and
            self.allow_code_wrap == rhs.allow_code_wrap
        )


class PhantomSet(sublime.PhantomSet):
    """Object that allows easy updating of phantoms."""

    def __init__(self, view, key=""):
        """Initialize."""

        super().__init__(view, key)

    def __del__(self):
        """Delete phantoms."""

        for p in self.phantoms:
            erase_phantom_by_id(self.view, p.id)

    def update(self, new_phantoms):
        """Update the list of phantoms that exist in the text buffer with their current location."""

        regions = query_phantoms(self.view, [p.id for p in self.phantoms])
        for i in range(len(regions)):
            self.phantoms[i].region = regions[i]

        count = 0
        for p in new_phantoms:
            if not isinstance(p, Phantom):
                # Convert sublime.Phantom to mdpopups.Phantom
                p = Phantom(
                    p.region, p.content, p.layout,
                    md=False, css=None, on_navigate=p.on_navigate, wrapper_class=None,
                    template_vars=None, template_env_options=None, nl2br=False,
                    allow_code_wrap=False
                )
                new_phantoms[count] = p
            try:
                # Phantom already exists, copy the id from the current one
                idx = self.phantoms.index(p)
                p.id = self.phantoms[idx].id
            except ValueError:
                p.id = add_phantom(
                    self.view,
                    self.key,
                    p.region,
                    p.content,
                    p.layout,
                    p.md,
                    p.css,
                    p.on_navigate,
                    p.wrapper_class,
                    p.template_vars,
                    p.template_env_options,
                    p.nl2br,
                    p.allow_code_wrap
                )
            count += 1

        for p in self.phantoms:
            # if the region is -1, then it's already been deleted, no need to call erase
            if p not in new_phantoms and p.region != sublime.Region(-1):
                erase_phantom_by_id(self.view, p.id)

        self.phantoms = new_phantoms


def format_frontmatter(values):
    """Format values as frontmatter."""

    return frontmatter.dump_frontmatter(values)