import re
import html
import sublime
import sublime_plugin
from .emmet import Abbreviation as MarkupAbbreviation, markup_abbreviation, stylesheet_abbreviation, expand
from .emmet.config import Config
from .emmet_sublime import JSX_PREFIX, expand, extract_abbreviation
from .emmet.stylesheet import CSSAbbreviationScope
from .utils import pairs, pairs_end, get_caret, replace_with_snippet
from .context import get_activation_context
from .config import get_preview_config, get_settings
from .telemetry import track_action
from . import syntax
from . import html_highlight

ABBR_REGION_ID = 'emmet-abbreviation'
ABBR_PREVIEW_ID = 'emmet-abbreviation-preview'

re_jsx_abbr_start = re.compile(r'^[a-zA-Z.#\[\(]$')
re_word_bound = re.compile(r'^[\s>;"\']?[a-zA-Z.#!@\[\(]$')
re_stylesheet_word_bound = re.compile(r'^[\s;"\']?[a-zA-Z!@]$')
re_stylesheet_preview_check = re.compile(r'/^:\s*;?$/')
re_word_start = re.compile(r'^[a-z]', re.IGNORECASE)

_cache = {}
_trackers = {}
_last_pos = {}
_forced_indicator = {}
_phantom_preview = {}
_has_popup_preview = {}


class AbbreviationTracker:
    __slots__ = ('region', 'abbreviation', 'forced', 'forced', 'offset',
                 'last_pos', 'last_length', 'config', 'simple', 'preview', 'error')
    def __init__(self, abbreviation: str, region: sublime.Region, config: Config, params: dict = None):
        self.abbreviation = abbreviation
        "Range in editor for abbreviation"

        self.region = region
        "Actual abbreviation, tracked by current tracker"

        self.config = config

        self.forced = False
        """
        Abbreviation was forced, e.g. must remain in editor even if empty or contains
        invalid abbreviation
        """

        self.offset = 0
        """
        Relative offset from range start where actual abbreviation starts.
        Used to handle prefixes in abbreviation
        """

        self.last_pos = 0
        "Last character location in editor"

        self.last_length = 0
        "Last editor size"

        if params:
            for k, v in params.items():
                if hasattr(self, k) or k in self.__slots__:
                    setattr(self, k, v)


class AbbreviationTrackerValid(AbbreviationTracker):
    __slots__ = ('simple', 'preview')

    def __init__(self, *args):
        self.simple = False
        self.preview = ''
        super().__init__(*args)

class AbbreviationTrackerError(AbbreviationTracker):
    def __init__(self, *args):
        self.error = None
        super().__init__(*args)


def get_last_pos(editor: sublime.View) -> int:
    "Returns last known location of caret in given editor"
    return _last_pos.get(editor.id())


def set_last_pos(editor: sublime.View, pos: int):
    "Sets last known caret location for given editor"
    _last_pos[editor.id()] = pos


def get_tracker(editor: sublime.View) -> AbbreviationTracker:
    "Returns abbreviation tracker for given editor, if any"
    return _trackers.get(editor.id())

def typing_abbreviation(editor: sublime.View, pos: int) -> AbbreviationTracker:
    "Detects if user is typing abbreviation at given location"
    # Start tracking only if user starts abbreviation typing: entered first
    # character at the word bound
    # NB: get last 2 characters: first should be a word bound(or empty),
    # second must be abbreviation start
    prefix = editor.substr(sublime.Region(max(0, pos - 2), pos))
    syntax_name = syntax.from_pos(editor, pos)
    start = -1
    end = pos
    offset = 0

    if syntax.is_jsx(syntax_name):
        # In JSX, abbreviations should be prefixed
        if len(prefix) == 2 and prefix[0] == JSX_PREFIX and re_jsx_abbr_start.match(prefix[1]):
            start = pos - 2
            offset = len(JSX_PREFIX)
    elif re_word_bound.match(prefix):
        start = pos - 1

    if start >= 0:
        # Check if there’s paired character
        last_ch = prefix[-1]
        if last_ch in pairs and editor.substr(sublime.Region(pos, pos + 1)) == pairs[last_ch]:
            end += 2

        config = get_activation_context(editor, pos)
        if config is not None:
            if config.type == 'stylesheet' and not re_stylesheet_word_bound.match(prefix):
                # Additional check for stylesheet abbreviation start: it’s slightly
                # differs from markup prefix, but we need activation context
                # to ensure that context under caret is CSS
                return

            tracker = start_tracking(editor, start, end, {'offset': offset, 'config': config})
            if tracker and isinstance(tracker, AbbreviationTrackerValid) and \
                get_by_key(config, 'context.name') == CSSAbbreviationScope.Section:
                # Make a silly check for section context: if user start typing
                # CSS selector at the end of file, it will be treated as property
                # name and provide unrelated completion by default.
                # We should check if captured abbreviation actually matched
                # snippet to continue. Otherwise, ignore this abbreviation.
                # By default, unresolved abbreviations are converted to CSS properties,
                # e.g. `a` → `a: ;`. If that’s the case, stop tracking
                preview = tracker.preview
                abbreviation = tracker.abbreviation

                if preview.startswith(abbreviation) and \
                    re_stylesheet_preview_check.match(preview[len(abbreviation):]):
                    stop_tracking(editor)
                    return

            if tracker:
                mark(editor, tracker)

            return tracker


def start_tracking(editor: sublime.View, start: int, pos: int, params: dict = None) -> AbbreviationTracker:
    """
    Starts abbreviation tracking for given editor
    :param start Location of abbreviation start
    :param pos Current caret position, must be greater that `start`
    """
    config = get_by_key(params, 'config') or get_activation_context(editor, start)

    tracker_params = {'config': config}
    if params:
        tracker_params.update(params)

    tracker = create_tracker(editor, sublime.Region(start, pos), tracker_params)

    if tracker:
        _trackers[editor.id()] = tracker
        mark(editor, tracker)
        return tracker

    _dispose_tracker(editor)


def stop_tracking(editor: sublime.View, params: dict = {}):
    "Stops abbreviation tracking in given editor instance"
    tracker = get_tracker(editor)
    if tracker:
        unmark(editor)

        if tracker and tracker.forced:
            edit = params.get('edit')
            if edit:
                # Contents of forced abbreviation must be removed
                editor.replace(edit, tracker.region, '')

        if params.get('force'):
            _dispose_cache_tracker(editor)
        else:
            # Store tracker in history to restore it if user continues editing
            store_tracker(editor, tracker)

        _dispose_tracker(editor)


def create_tracker(editor: sublime.View, region: sublime.Region, params: dict) -> AbbreviationTracker:
    """
    Creates abbreviation tracker for given range in editor. Parses contents
    of abbreviation in range and returns either valid abbreviation tracker,
    error tracker or `None` if abbreviation cannot be created from given range
    """
    config = get_by_key(params, 'config')
    offset = get_by_key(params, 'offset', 0)
    forced = get_by_key(params, 'forced', False)

    if region.a > region.b or (region.a == region.b and not forced):
        # Invalid range
        return

    abbreviation = editor.substr(region)
    if offset:
        abbreviation = abbreviation[offset:]

    # Basic validation: do not allow empty abbreviations
    # or newlines in abbreviations
    if (not abbreviation and not forced) or '\n' in abbreviation or '\r' in abbreviation:
        return

    tracker_params = {
        'forced': forced,
        'offset': offset,
        'last_pos': region.end(),
        'last_length': editor.size(),
    }

    try:
        tracker_params['simple'] = False

        if config.type == 'stylesheet':
            parsed_abbr = stylesheet_abbreviation(abbreviation, config)
        else:
            parsed_abbr = markup_abbreviation(abbreviation, config)
            jsx = config and syntax.is_jsx(config.syntax)
            tracker_params['simple'] = not jsx and is_simple_markup_abbreviation(parsed_abbr)

        preview_config = get_preview_config(config)
        tracker_params['preview'] = expand(parsed_abbr, preview_config)
        return AbbreviationTrackerValid(abbreviation, region, config, tracker_params)
    except Exception as err:
        tracker_params['error'] = {
            'message': err.message,
            'pos': err.pos,
            'pointer': '%s^' % ('-' * err.pos, ) if err.pos is not None else ''
        }
        return AbbreviationTrackerError(abbreviation, region, config, tracker_params)



def store_tracker(editor: sublime.View, tracker: AbbreviationTracker):
    "Stores given tracker in separate cache to restore later"
    _cache[editor.id()] = tracker


def get_stored_tracker(editor: sublime.View) -> AbbreviationTracker:
    "Returns stored tracker for given editor proxy, if any"
    return _cache.get(editor.id())


def restore_tracker(editor: sublime.View, pos: int) -> AbbreviationTracker:
    "Tries to restore abbreviation tracker for given editor at specified position"
    tracker = get_stored_tracker(editor)

    if tracker and tracker.region.contains(pos):
        r = sublime.Region(tracker.region.begin() + tracker.offset, tracker.region.end())

        if editor.substr(r) == tracker.abbreviation:
            _trackers[editor.id()] = tracker
            mark(editor, tracker)
            tracker.last_length = editor.size()
            return tracker

    return None


def suggest_abbreviation_tracker(view: sublime.View, pos: int) -> AbbreviationTracker:
    "Tries to extract abbreviation from given position and returns tracker for it, if available"
    if not allow_tracking(view, pos):
        return None

    trk = get_tracker(view)
    if trk and not trk.region.contains(pos):
        stop_tracking(view)
        trk = None

    if not trk:
        # Try to extract abbreviation from current location
        config = get_activation_context(view, pos)
        abbr = extract_abbreviation(view, pos, config)
        if abbr:
            offset = abbr.location - abbr.start
            return start_tracking(view, abbr.start, abbr.end, {'config': config, 'offset': offset})


def handle_change(editor: sublime.View, pos: int) -> AbbreviationTracker:
    "Handle content change in given editor instance"
    tracker = get_tracker(editor)
    editor_last_pos = get_last_pos(editor)
    set_last_pos(editor, pos)

    if not tracker:
        # No active tracker, check if we user is actually typing it
        if editor_last_pos is not None and editor_last_pos == pos - 1 and allow_tracking(editor, pos):
            return typing_abbreviation(editor, pos)
        return None

    last_pos = tracker.last_pos
    region = tracker.region

    if last_pos < region.begin() or last_pos > region.end():
        # Updated content outside abbreviation: reset tracker
        stop_tracking(editor)
        return None

    length = editor.size()
    delta = length - tracker.last_length
    region = sublime.Region(region.a, region.b)

    # Modify region and validate it: if it leads to invalid abbreviation, reset tracker
    update_region(region, delta, last_pos)

    # Handle edge case: empty forced abbreviation is allowed
    if region.empty() and tracker.forced:
        tracker.abbreviation = ''
        return tracker

    next_tracker = create_tracker(editor, region, tracker)

    if not next_tracker or (not tracker.forced and not is_valid_tracker(next_tracker, region, pos)):
        stop_tracking(editor)
        return

    next_tracker.last_pos = pos
    _trackers[editor.id()] = next_tracker
    mark(editor, next_tracker)

    return next_tracker


def handle_selection_change(editor: sublime.View, pos: int) -> AbbreviationTracker:
    "Handle selection (caret) change in given editor instance"
    set_last_pos(editor, pos)
    tracker = get_tracker(editor) or restore_tracker(editor, pos)
    if tracker:
        tracker.last_pos = pos
        return tracker


def dispose_editor(editor: sublime.View):
    """
    Method should be called when given editor instance will be no longer
    available to clean up cached data
    """
    stop_tracking(editor)
    _dispose_cache_tracker(editor)
    _dispose_tracker(editor)
    remove_cache_item(editor, _last_pos)


def _dispose_tracker(editor: sublime.View):
    remove_cache_item(editor, _trackers)


def _dispose_cache_tracker(editor: sublime.View):
    remove_cache_item(editor, _cache)


def remove_cache_item(editor: sublime.View, cache: dict):
    e_id = editor.id()
    if e_id in cache:
        del cache[e_id]


def get_by_key(obj, key, default_value=None):
    "A universal method for accessing deep property by dot-separated key"
    if isinstance(key, str):
        key = key.split('.')

    for k in key:
        if obj is None:
            break

        if isinstance(obj, dict):
            obj = obj.get(k)
        elif hasattr(obj, k):
            obj = getattr(obj, k)

    return obj if obj is not None else default_value


def update_region(region: sublime.Region, delta: int, last_pos: int) -> sublime.Region:
    if delta < 0:
        # Content removed
        if last_pos == region.begin():
            # Updated content at the abbreviation edge
            region.a += delta
            region.b += delta
        elif region.begin() < last_pos <= region.end():
            region.b += delta
    elif delta > 0 and region.begin() <= last_pos <= region.end():
        # Content inserted
        region.b += delta

    return region


def is_valid_tracker(tracker: AbbreviationTracker, region: sublime.Region, pos: int) -> bool:
    "Check if given tracker is in valid state for keeping it marked"
    if isinstance(tracker, AbbreviationTrackerError):
        if region.end() == pos:
            # Last entered character is invalid
            return False

        abbreviation = tracker.abbreviation
        start = region.begin()
        target_pos = region.end()
        while target_pos > start:
            ch = abbreviation[target_pos - start - 1]
            if ch in pairs_end:
                target_pos -= 1
            else:
                break

        return target_pos != pos

    return True


def is_simple_markup_abbreviation(abbr: MarkupAbbreviation) -> bool:
    if len(abbr.children) == 1 and not abbr.children[0].children:
        # Single element: might be a HTML element or text snippet
        first = abbr.children[0]
        # XXX silly check for common snippets like `!`. Should read contents
        # of expanded abbreviation instead
        return not first.name or re_word_start.match(first.name)

    return not abbr.children


def allow_tracking(editor: sublime.View, pos: int) -> bool:
    "Check if abbreviation tracking is allowed in editor at given location"
    if is_enabled(editor):
        syntax_name = syntax.from_pos(editor, pos)
        return syntax.is_supported(syntax_name) or syntax.is_jsx(syntax_name)

    return False


def is_enabled(view: sublime.View) -> bool:
    "Check if Emmet abbreviation tracking is enabled"
    return get_settings('auto_mark', False)


def mark(editor: sublime.View, tracker: AbbreviationTracker):
    "Marks tracker in given view"
    scope = get_settings('marker_scope', 'region.accent')
    mark_opt = sublime.DRAW_SOLID_UNDERLINE | sublime.DRAW_NO_FILL | sublime.DRAW_NO_OUTLINE
    editor.erase_regions(ABBR_REGION_ID)
    editor.add_regions(ABBR_REGION_ID, [tracker.region], scope, '', mark_opt)
    if isinstance(tracker, AbbreviationTrackerValid) and tracker.forced:
        phantoms = [
            sublime.Phantom(tracker.region, forced_indicator('⋮>'), sublime.LAYOUT_INLINE)
        ]

        key = editor.id()
        if key not in _forced_indicator:
            _forced_indicator[key] = sublime.PhantomSet(editor, ABBR_REGION_ID)
        _forced_indicator[key].update(phantoms)


def unmark(editor: sublime.View):
    "Remove current tracker marker from given view"
    editor.erase_regions(ABBR_REGION_ID)
    editor.erase_phantoms(ABBR_REGION_ID)
    hide_preview(editor)


def show_preview(editor: sublime.View, tracker: AbbreviationTracker):
    "Displays expanded preview of abbreviation in current tracker in given view"
    if not get_settings('abbreviation_preview', True):
        return

    key = editor.id()
    content = None
    as_phantom = tracker.config.type == 'stylesheet'

    if isinstance(tracker, AbbreviationTrackerError):
        # Display error snippet
        err = tracker.error
        snippet = html.escape( re.sub(r'\s+at\s\d+$', '', err['message']), False)
        content = '<div class="error pointer">%s</div><div class="error message">%s</div>' % (err['pointer'], snippet)
    elif isinstance(tracker, AbbreviationTrackerValid) and tracker.abbreviation and (tracker.forced or as_phantom or not tracker.simple):
        snippet = tracker.preview
        if tracker.config.type != 'stylesheet':
            if syntax.is_html(tracker.config.syntax):
                snippet = html_highlight.highlight(snippet)
            else:
                snippet = html.escape(snippet, False)
            content = '<div class="markup-preview">%s</div>' % format_snippet(snippet)
        else:
            content = format_snippet(snippet)

    if not content:
        hide_preview(editor)
        return

    if as_phantom:
        pos = tracker.region.end()
        r = sublime.Region(pos, pos)
        phantoms = [sublime.Phantom(r, preview_phantom_html(content), sublime.LAYOUT_INLINE)]

        if key not in _phantom_preview:
            _phantom_preview[key] = sublime.PhantomSet(editor, ABBR_PREVIEW_ID)
        _phantom_preview[key].update(phantoms)
    else:
        _has_popup_preview[key] = True
        editor.show_popup(
            preview_popup_html(content),
            flags=sublime.COOPERATE_WITH_AUTO_COMPLETE,
            location=tracker.region.begin(),
            max_width=400,
            max_height=300)

def hide_preview(editor: sublime.View):
    "Hides preview of current abbreviation in given view"
    key = editor.id()
    if _has_popup_preview.get(key):
        editor.hide_popup()
        del _has_popup_preview[key]
    if _phantom_preview.get(key):
        editor.erase_phantoms(ABBR_PREVIEW_ID)
        del _phantom_preview[key]

def preview_popup_html(content: str):
    style = html_highlight.styles()
    return """
    <body id="emmet-preview-popup">
        <style>
            body { line-height: 1.5rem; }
            .error { color: red }
            .error.message { font-size: 11px; line-height: 1.3rem; }
            .markup-preview { font-size: 11px; line-height: 1.3rem; }
            %s
        </style>
        <div>%s</div>
    </body>
    """ % (style, content)


def preview_phantom_html(content: str):
    return """
    <body id="emmet-preview-phantom">
        <style>
            body {
                background-color: var(--greenish);
                color: #fff;
                border-radius: 3px;
                padding: 1px 3px;
                position: relative;
            }

            .error { color: red }
        </style>
        <div class="main">%s</div>
    </body>
    """ % content


def forced_indicator(content: str):
    "Returns HTML content of forced abbreviation indicator"
    return """
        <body>
            <style>
                #emmet-forced-abbreviation {
                    background-color: var(--greenish);
                    color: #fff;
                    border-radius: 3px;
                    padding: 1px 3px;
                }
            </style>
            <div id="emmet-forced-abbreviation">%s</div>
        </body>
        """ % content


def format_snippet(text: str, class_name=None):
    class_attr = (' class="%s"' % class_name) if class_name else ''
    line_html = '<div%s style="padding-left: %dpx"><code>%s</code></div>'
    lines = [line_html % (class_attr, indent_size(line, 20), line) for line in text.splitlines()]

    return '\n'.join(lines)


def indent_size(line, width=1):
    m = re.match(r'\t+', line)
    return len(m.group(0)) * width if m else 0


def plugin_unloaded():
    for wnd in sublime.windows():
        for view in wnd.views():
            unmark(view)

def main_view(fn):
    "Method decorator for running actions in code views only"
    def wrapper(self, view):
        if not view.settings().get('is_widget'):
            fn(self, view)

    return wrapper


def expand_tracker(editor: sublime.View, edit: sublime.Edit, tracker: AbbreviationTracker):
    "Expands abbreviation from given tracker"
    if isinstance(tracker, AbbreviationTrackerValid):
        snippet = expand(tracker.abbreviation, tracker.config)
        replace_with_snippet(editor, edit, tracker.region, snippet)


class EmmetExpandAbbreviation(sublime_plugin.TextCommand):
    def run(self, edit):
        caret = get_caret(self.view)
        trk = get_tracker(self.view)

        if trk and trk.region.contains(caret):
            expand_tracker(self.view, edit, trk)
            stop_tracking(self.view)
            track_action('Expand Abbreviation', trk.config.syntax)


class EmmetEnterAbbreviation(sublime_plugin.TextCommand):
    def run(self, edit):
        trk = get_tracker(self.view)
        stop_tracking(self.view, {'force': True, 'edit': edit})
        if trk and trk.forced:
            # Already have forced abbreviation: act as toggler
            return

        primary_sel = self.view.sel()[0]
        trk = start_tracking(self.view, primary_sel.begin(), primary_sel.end(), {'forced': True})
        if trk and not primary_sel.empty():
            show_preview(self.view, trk)
            sel = self.view.sel()
            sel.clear()
            sel.add(sublime.Region(primary_sel.end(), primary_sel.end()))
            track_action('Enter Abbreviation', trk.config.syntax)


class EmmetClearAbbreviationMarker(sublime_plugin.TextCommand):
    def run(self, edit):
        stop_tracking(self.view, {'force': True, 'edit': edit})
        track_action('Clear Abbreviation')


class AbbreviationMarkerListener(sublime_plugin.EventListener):
    def __init__(self):
        self.pending_completions_request = False

    @main_view
    def on_close(self, editor: sublime.View):
        dispose_editor(editor)

    @main_view
    def on_activated(self, editor: sublime.View):
        handle_selection_change(editor, get_caret(editor))

    @main_view
    def on_selection_modified(self, editor: sublime.View):
        if not is_enabled(editor):
            return

        pos = get_caret(editor)
        trk = handle_selection_change(editor, pos)

        # print('sel modified at %d: %s' % (pos, trk))
        if trk:
            if trk.region.contains(pos):
                show_preview(editor, trk)
            else:
                hide_preview(editor)

    @main_view
    def on_modified(self, editor: sublime.View):
        handle_change(editor, get_caret(editor))
        # print('modified: %s' % trk)

    def on_query_context(self, view: sublime.View, key: str, *args):
        if key == 'emmet_abbreviation':
            # Check if caret is currently inside Emmet abbreviation
            trk = get_tracker(view)
            if trk:
                for s in view.sel():
                    if trk.region.contains(s):
                        return trk.forced or isinstance(trk, AbbreviationTrackerValid)

            return False

        if key == 'emmet_tab_expand':
            return get_settings('tab_expand', False)

        if key == 'has_emmet_abbreviation_mark':
            return bool(get_tracker(view))

        if key == 'has_emmet_forced_abbreviation_mark':
            trk = get_tracker(view)
            return trk.forced if trk else False

        return None

    def on_query_completions(self, editor: sublime.View, prefix: str, locations: list):
        pos = locations[0]
        if self.pending_completions_request:
            self.pending_completions_request = False

            tracker = suggest_abbreviation_tracker(editor, pos)
            if tracker:
                mark(editor, tracker)
                show_preview(editor, tracker)
                snippet = expand(tracker.abbreviation, tracker.config)
                return [('%s\tEmmet' % tracker.abbreviation, snippet)]

    def on_text_command(self, view: sublime.View, command_name: str, args: list):
        if command_name == 'auto_complete' and is_enabled(view):
            self.pending_completions_request = True
        elif command_name == 'commit_completion':
            stop_tracking(view)

    def on_post_text_command(self, editor: sublime.View, command_name: str, args: list):
        if command_name == 'auto_complete':
            self.pending_completions_request = False
        elif command_name == 'undo':
            # In case of undo, editor may restore previously marked range.
            # If so, restore marker from it
            trk = get_stored_tracker(editor)
            if trk and isinstance(trk, AbbreviationTrackerValid) and editor.substr(trk.region) == trk.abbreviation:
                restore_tracker(editor, get_caret(editor))