from collections import deque
from functools import lru_cache, partial
from itertools import chain, islice
import locale
import os
from queue import Empty
import re
import shlex
import subprocess
import time
import threading

import sublime
from sublime_plugin import WindowCommand, TextCommand, EventListener

from . import log_graph_colorizer as colorizer, show_commit_info
from .log import GsLogCommand
from .navigate import GsNavigate
from .. import utils
from ..fns import filter_, take, unique
from ..git_command import GitCommand, GitSavvyError
from ..parse_diff import Region
from ..settings import GitSavvySettings
from ..runtime import (
    enqueue_on_ui, enqueue_on_worker,
    run_or_timeout, run_on_new_thread,
    text_command
)
from ..view import replace_view_content
from ..ui_mixins.input_panel import show_single_line_input_panel
from ..ui_mixins.quick_panel import show_branch_panel
from ...common import util
from ...common.theme_generator import XMLThemeGenerator, JSONThemeGenerator


__all__ = (
    "gs_graph",
    "gs_graph_current_file",
    "gs_log_graph_refresh",
    "gs_log_graph",
    "gs_log_graph_current_branch",
    "gs_log_graph_all_branches",
    "gs_log_graph_by_author",
    "gs_log_graph_by_branch",
    "gs_log_graph_navigate",
    "gs_log_graph_navigate_to_head",
    "gs_log_graph_edit_branches",
    "gs_log_graph_edit_filters",
    "gs_log_graph_reset_filters",
    "gs_log_graph_toggle_all_setting",
    "gs_log_graph_open_commit",
    "gs_log_graph_toggle_more_info",
    "gs_log_graph_action",
    "GsLogGraphCursorListener",
)

MYPY = False
if MYPY:
    from typing import (
        Callable, Dict, Generic, Iterable, Iterator, List, Literal, Optional, Set, Sequence, Tuple,
        TypeVar, Union
    )
    T = TypeVar('T')
    PainterState = Literal['initial', 'navigated', 'viewport_readied']


COMMIT_NODE_CHAR = "●"
COMMIT_NODE_CHAR_OPTIONS = "●*"
GRAPH_CHAR_OPTIONS = r" /_\|\-\\."
COMMIT_LINE = re.compile(
    r"^[{graph_chars}]*[{node_chars}][{graph_chars}]* "
    r"(?P<commit_hash>[a-f0-9]{{5,40}}) "
    r"(?P<decoration>\(.+?\))?"
    .format(graph_chars=GRAPH_CHAR_OPTIONS, node_chars=COMMIT_NODE_CHAR_OPTIONS)
)

DOT_SCOPE = 'git_savvy.graph.dot'
PATH_SCOPE = 'git_savvy.graph.path_char'
MATCHING_COMMIT_SCOPE = 'git_savvy.graph.matching_commit'


def compute_identifier_for_view(view):
    # type: (sublime.View) -> Optional[Tuple]
    settings = view.settings()
    return (
        settings.get('git_savvy.repo_path'),
        settings.get('git_savvy.file_path'),
        settings.get('git_savvy.log_graph_view.all_branches')
        or settings.get('git_savvy.log_graph_view.branches')
    ) if settings.get('git_savvy.log_graph_view') else None


def focus_view(view):
    window = view.window()
    if not window:
        return

    group, _ = window.get_view_index(view)
    window.focus_group(group)
    window.focus_view(view)


class gs_graph(WindowCommand, GitCommand):
    def run(
        self,
        repo_path=None,
        file_path=None,
        all=False,
        branches=None,
        author='',
        title='GRAPH',
        follow=None,
        decoration='sparse'
    ):
        if repo_path is None:
            repo_path = self.repo_path
        assert repo_path

        this_id = (
            repo_path,
            file_path,
            all or branches
        )
        for view in self.window.views():
            if compute_identifier_for_view(view) == this_id:
                settings = view.settings()
                settings.set("git_savvy.log_graph_view.all_branches", all)
                settings.set("git_savvy.log_graph_view.filter_by_author", author)
                settings.set("git_savvy.log_graph_view.branches", branches or [])
                settings.set('git_savvy.log_graph_view.follow', follow)
                settings.set('git_savvy.log_graph_view.decoration', decoration)

                if follow and follow != extract_symbol_to_follow(view):
                    if show_commit_info.panel_is_visible(self.window):
                        # Hack to force a synchronous update of the panel
                        # *as a result of* `navigate_to_symbol` (by
                        # `on_selection_modified`) since we know that
                        # "show_commit_info" will run blocking if the panel
                        # is empty (or closed).
                        panel = show_commit_info.ensure_panel(self.window)
                        replace_view_content(panel, "")
                    navigate_to_symbol(view, follow)

                focus_view(view)
                break
        else:
            view = util.view.get_scratch_view(self, "log_graph", read_only=True)
            view.set_syntax_file("Packages/GitSavvy/syntax/graph.sublime-syntax")
            view.run_command("gs_handle_vintageous")
            view.run_command("gs_handle_arrow_keys")
            run_on_new_thread(augment_color_scheme, view)

            settings = view.settings()
            settings.set("git_savvy.repo_path", repo_path)
            settings.set("git_savvy.file_path", file_path)
            settings.set("git_savvy.log_graph_view.all_branches", all)
            settings.set("git_savvy.log_graph_view.filter_by_author", author)
            settings.set("git_savvy.log_graph_view.branches", branches or [])
            settings.set('git_savvy.log_graph_view.follow', follow)
            settings.set('git_savvy.log_graph_view.decoration', decoration)
            view.set_name(title)

            # We need to ensure the panel has been created, so it appears
            # e.g. in the menu. Otherwise Sublime will not handle `show_panel`
            # events for that panel at all.
            # Note that the following is basically what `on_activated` does,
            # but `on_activated` runs synchronous when a view gets created t.i.
            # even before we can mark it as "graph_view" in the settings.
            show_commit_info.ensure_panel(self.window)
            if (
                self.savvy_settings.get("graph_show_more_commit_info")
                and not show_commit_info.panel_is_visible(self.window)
            ):
                self.window.run_command("show_panel", {"panel": "output.show_commit_info"})

            view.run_command("gs_log_graph_refresh", {"navigate_after_draw": True})


class gs_graph_current_file(WindowCommand, GitCommand):
    def run(self, **kwargs):
        file_path = self.file_path
        if file_path:
            self.window.run_command('gs_graph', dict(file_path=file_path, **kwargs))
        else:
            self.window.status_message("View has no filename to track.")


def augment_color_scheme(view):
    # type: (sublime.View) -> None
    settings = GitSavvySettings()
    colors = settings.get('colors').get('log_graph')
    if not colors:
        return

    color_scheme = view.settings().get('color_scheme')
    if color_scheme.endswith(".tmTheme"):
        themeGenerator = XMLThemeGenerator(color_scheme)
    else:
        themeGenerator = JSONThemeGenerator(color_scheme)
    themeGenerator.add_scoped_style(
        "GitSavvy Highlighted Commit Dot",
        DOT_SCOPE,
        background=colors['commit_dot_background'],
        foreground=colors['commit_dot_foreground'],
    )
    themeGenerator.add_scoped_style(
        "GitSavvy Highlighted Path Char",
        PATH_SCOPE,
        background=colors['path_background'],
        foreground=colors['path_foreground'],
    )
    themeGenerator.add_scoped_style(
        "GitSavvy Highlighted Matching Commit",
        MATCHING_COMMIT_SCOPE,
        background=colors['matching_commit_background'],
        foreground=colors['matching_commit_foreground'],
    )
    themeGenerator.apply_new_theme("log_graph_view", view)


DATE_FORMAT = 'human'
FALLBACK_DATE_FORMAT = 'format:%Y-%m-%d %H:%M'
DATE_FORMAT_STATE = 'trying'


MYPY = False
if MYPY:
    from typing import NamedTuple
    Ins = NamedTuple('Ins', [('idx', int), ('line', str)])
    Del = NamedTuple('Del', [('start', int), ('end', int)])
    Replace = NamedTuple('Replace', [('start', int), ('end', int), ('text', List[str])])
else:
    from collections import namedtuple
    Ins = namedtuple('Ins', 'idx line')
    Del = namedtuple('Del', 'start end')
    Replace = namedtuple('Replace', 'start end text')


MAX_LOOK_AHEAD = 10000
Same = object()


def diff(a, b):
    # type: (Sequence[str], Iterable[str]) -> Iterator[Union[Ins, Del]]
    a_index = 0
    b_index = -1  # init in case b is empty
    len_a = len(a)
    a_set = set(a)
    for b_index, line in enumerate(b):
        is_commit_line = re.match(FIND_COMMIT_HASH, line)
        if is_commit_line and line not in a_set:
            len_a += 1
            yield Ins(b_index, line)
            continue

        look_ahead = MAX_LOOK_AHEAD if is_commit_line else 1
        try:
            i = a.index(line, a_index, a_index + look_ahead) - a_index
        except ValueError:
            len_a += 1
            yield Ins(b_index, line)
        else:
            if i == 0:
                a_index += 1
                yield Same
            else:
                len_a -= i
                a_index += i + 1
                yield Del(b_index, b_index + i)

    if b_index < (len_a - 1):
        yield Del(b_index + 1, len_a)


def simplify(diff, max_size):
    # type: (Iterable[Union[Ins, Del]], int) -> Iterator[Union[Ins, Del, Replace]]
    previous = None  # type: Union[Ins, Del, Replace, None]
    for token in diff:
        if token is Same:
            if previous is not None:
                yield previous
                previous = None
            continue

        if previous is None:
            previous = token
            continue

        if isinstance(token, Ins):
            if isinstance(previous, Replace):
                len_previous = len(previous.text)
                if previous.start + len_previous == token.idx:
                    previous = Replace(previous.start, previous.end, previous.text + [token.line])
                    if len_previous >= max_size:
                        yield previous
                        previous = None
                    continue
            elif isinstance(previous, Ins):
                if previous.idx + 1 == token.idx:
                    previous = Replace(previous.idx, previous.idx, [previous.line, token.line])
                    continue
        elif isinstance(token, Del):
            if isinstance(previous, Ins):
                if previous.idx + 1 == token.start:
                    yield Replace(previous.idx, previous.idx + token.end - token.start, [previous.line])
                    previous = None
                    continue
            elif isinstance(previous, Replace):
                if previous.end + len(previous.text) == token.start:
                    yield Replace(previous.start, previous.end + token.end - token.start, previous.text)
                    previous = None
                    continue

        yield previous
        previous = token
        continue

    if previous is not None:
        yield previous


def normalize_tokens(tokens):
    # type: (Iterator[Union[Ins, Del, Replace]]) -> Iterator[Replace]
    for token in tokens:
        if isinstance(token, Ins):
            yield Replace(token.idx, token.idx, [token.line])
        elif isinstance(token, Del):
            yield Replace(token.start, token.end, [])
        else:
            yield token


def apply_diff(a, diff):
    # type: (List[str], Iterable[Union[Ins, Del, Replace]]) -> List[str]
    a = a[:]
    for token in diff:
        if isinstance(token, Replace):
            a[token.start:token.end] = token.text
        elif isinstance(token, Ins):
            a[token.idx:token.idx] = [token.line]
        elif isinstance(token, Del):
            a[token.start:token.end] = []
    return a


if MYPY:
    ShouldAbort = Callable[[], bool]
    Runners = Dict[sublime.BufferId, ShouldAbort]
runners_lock = threading.Lock()
REFRESH_RUNNERS = {}  # type: Runners


def make_aborter(view, store=REFRESH_RUNNERS, _lock=runners_lock):
    # type: (sublime.View, Runners, threading.Lock) -> ShouldAbort
    bid = view.buffer_id()

    def should_abort():
        # type: () -> bool
        if not view.is_valid():
            return True

        with _lock:
            if store[bid] != should_abort:
                return True
        return False

    with _lock:
        store[bid] = should_abort
    return should_abort


TheEnd = object()


def put_on_queue(queue, it):
    # type: (SimpleQueue[T], Iterable[T]) -> None
    try:
        for item in it:
            queue.put(item)
    finally:
        queue.put(TheEnd)


def wait_for_first_item(it):
    # type: (Iterable[T]) -> Iterator[T]
    iterable = iter(it)
    head = take(1, iterable)
    return chain(head, iterable)


def log_git_command(fn):
    # type: (Callable[..., Iterator[T]]) -> Callable[..., Iterator[T]]
    def decorated(self, *args, **kwargs):
        start_time = time.perf_counter()
        stderr = ''
        saved_exception = None
        try:
            yield from fn(self, *args, **kwargs)
        except GitSavvyError as e:
            stderr = e.stderr
            saved_exception = e
        finally:
            end_time = time.perf_counter()
            util.debug.log_git(args, None, "<SNIP>", stderr, end_time - start_time)
            if saved_exception:
                raise saved_exception from None
    return decorated


if MYPY:
    class SimpleQueue(Generic[T]):
        def put(self, item: T) -> None: ...  # noqa: E704
        def get(self, block=True, timeout=float) -> T: ...  # noqa: E704
else:
    class SimpleQueue:
        def __init__(self):
            self._queue = deque()
            self._count = threading.Semaphore(0)

        def put(self, item):
            self._queue.append(item)
            self._count.release()

        def get(self, block=True, timeout=None):
            if not self._count.acquire(block, timeout):
                raise Empty
            return self._queue.popleft()


def try_kill_proc(proc):
    if proc:
        utils.kill_proc(proc)


def selection_is_before_region(view, region):
    # type: (sublime.View, sublime.Region) -> bool
    try:
        return view.sel()[-1].end() <= region.end()
    except IndexError:
        return True


class gs_log_graph_refresh(TextCommand, GitCommand):

    """
    Refresh the current graph view with the latest commits.
    """

    def run(self, edit, navigate_after_draw=False):
        # type: (object, bool) -> None
        # Edge case: If you restore a workspace/project, the view might still be
        # loading and hence not ready for refresh calls.
        if self.view.is_loading():
            return
        should_abort = make_aborter(self.view)
        enqueue_on_worker(self.run_impl, should_abort, navigate_after_draw)

    def format_line(self, line):
        return re.sub(
            r'(^[{}]*)\*'.format(GRAPH_CHAR_OPTIONS),
            r'\1' + COMMIT_NODE_CHAR,
            line,
            flags=re.MULTILINE
        )

    def run_impl(self, should_abort, navigate_after_draw=False):
        prelude_text = prelude(self.view)
        initial_draw = self.view.size() == 0
        if initial_draw:
            replace_view_content(self.view, prelude_text, sublime.Region(0, 1))

        try:
            current_graph = self.view.substr(
                self.view.find_by_selector('meta.content.git_savvy.graph')[0]
            )
        except IndexError:
            current_graph = ''
        current_graph_splitted = current_graph.splitlines(keepends=True)

        token_queue = SimpleQueue()  # type: SimpleQueue[Replace]
        current_proc = None
        graph_offset = len(prelude_text)

        def remember_proc(proc):
            # type: (subprocess.Popen) -> None
            nonlocal current_proc
            current_proc = proc

        def ensure_not_aborted(fn):
            def decorated(*args, **kwargs):
                if should_abort():
                    try_kill_proc(current_proc)
                else:
                    return fn(*args, **kwargs)
            return decorated

        def reader():
            next_graph_splitted = chain(
                map(self.format_line, self.read_graph(got_proc=remember_proc)),
                ['\n']
            )
            tokens = normalize_tokens(simplify(
                diff(current_graph_splitted, next_graph_splitted),
                max_size=100
            ))
            if (
                initial_draw
                and self.view.settings().get('git_savvy.log_graph_view.decoration') == 'sparse'
            ):
                # On large repos (e.g. the "git" repo) "--sparse" can be excessive to compute
                # upfront t.i. before the first byte. For now, just race with a timeout and
                # maybe fallback.
                try:
                    tokens = run_or_timeout(lambda: wait_for_first_item(tokens), timeout=1.0)
                except TimeoutError:
                    try_kill_proc(current_proc)
                    self.view.settings().set('git_savvy.log_graph_view.decoration', None)
                    enqueue_on_worker(self.view.run_command, "gs_log_graph_refresh")
                    return
            else:
                tokens = wait_for_first_item(tokens)
            enqueue_on_ui(draw)
            put_on_queue(token_queue, tokens)

        @ensure_not_aborted
        def draw():
            sel = get_simple_selection(self.view)
            if sel is None:
                follow, col_range = None, None
            else:
                follow = self.view.settings().get('git_savvy.log_graph_view.follow')
                col_range = get_column_range(self.view, sel)
            visible_selection = is_sel_in_viewport(self.view)

            current_prelude_region = self.view.find_by_selector('meta.prelude.git_savvy.graph')[0]
            replace_view_content(self.view, prelude_text, current_prelude_region)
            drain_and_draw_queue(self.view, 'initial', follow, col_range, visible_selection)

        # Sublime will not run any event handlers until the (outermost) TextCommand exits.
        # T.i. the (inner) commands `replace_view_content` and `set_and_show_cursor` will run
        # through uninterrupted until `drain_and_draw_queue` yields. Then e.g.
        # `on_selection_modified` runs *once* even if we painted multiple times.
        @ensure_not_aborted
        @text_command
        def drain_and_draw_queue(view, painter_state, follow, col_range, visible_selection):
            # type: (sublime.View, PainterState, Optional[str], Optional[Tuple[int, int]], bool) -> None
            try_navigate_to_symbol = partial(
                navigate_to_symbol,
                view,
                follow,
                col_range=col_range,
                method=set_and_show_cursor if visible_selection else just_set_cursor
            )
            block_time = utils.timer()
            while True:
                # If only the head commits changed, and the cursor (and with it `follow`)
                # is a few lines below, the `if_before=region` will probably never catch.
                # We would block here 'til TheEnd without a timeout.
                try:
                    token = token_queue.get(
                        block=True if painter_state != 'viewport_readied' else False,
                        timeout=0.05 if painter_state != 'viewport_readied' else None
                    )
                except Empty:
                    enqueue_on_worker(
                        drain_and_draw_queue,
                        view,
                        painter_state,
                        follow,
                        col_range,
                        visible_selection,
                    )
                    return
                if token is TheEnd:
                    break

                region = apply_token(view, token, graph_offset)

                if painter_state == 'initial':
                    if follow:
                        if try_navigate_to_symbol(if_before=region):
                            painter_state = 'navigated'
                    elif navigate_after_draw:  # on init
                        view.run_command("gs_log_graph_navigate")
                        painter_state = 'navigated'
                    elif selection_is_before_region(view, region):
                        painter_state = 'navigated'

                if painter_state == 'navigated':
                    if region.end() >= view.visible_region().end():
                        painter_state = 'viewport_readied'

                if block_time.passed(13 if painter_state == 'viewport_readied' else 1000):
                    enqueue_on_worker(
                        drain_and_draw_queue,
                        view,
                        painter_state,
                        follow,
                        col_range,
                        visible_selection,
                    )
                    return

            if painter_state == 'initial':
                # If we still did not navigate the symbol is either
                # gone, or happens to be after the fold of fresh
                # content.
                if not follow or not try_navigate_to_symbol():
                    if visible_selection:
                        view.show(view.sel(), True)

        def apply_token(view, token, offset):
            # type: (sublime.View, Replace, int) -> sublime.Region
            nonlocal current_graph_splitted
            start, end, text_ = token
            text = ''.join(text_)
            computed_start = (
                sum(len(line) for line in current_graph_splitted[:start])
                + offset
            )
            computed_end = (
                sum(len(line) for line in current_graph_splitted[start:end])
                + computed_start
            )
            region = sublime.Region(computed_start, computed_end)

            current_graph_splitted = apply_diff(current_graph_splitted, [token])
            replace_view_content(view, text, region)
            occupied_space = sublime.Region(computed_start, computed_start + len(text))
            return occupied_space

        run_on_new_thread(reader)

    @log_git_command
    def git_stdout(self, *args, show_panel_on_stderr=True, throw_on_stderr=True, got_proc=None, **kwargs):
        # type: (...) -> Iterator[str]
        # Note: Can't use `self.decode_stdout` because it blocks the
        # main thread!
        decode = decoder(self.savvy_settings)
        proc = self.git(*args, just_the_proc=True, **kwargs)
        if got_proc:
            got_proc(proc)
        received_some_stdout = False
        with proc:
            while True:
                # Block size 2**14 taken from Sublime's `exec.py`. This
                # may be a hint on how much chars Sublime can draw efficiently.
                # But here we don't draw every line (except initially) but
                # a diff. So we oscillate between getting a first meaningful
                # content fast and not blocking too much here.
                # TODO: `len(lines)` could be a good indicator of how fast
                # the system currently is because it seems to vary a lot when
                # comapring rather short or long (in count of commits) repos.
                lines = proc.stdout.readlines(2**14)
                if not lines:
                    break
                elif not received_some_stdout:
                    received_some_stdout = True
                for line in lines:
                    yield decode(line)

            stderr = ''.join(map(decode, proc.stderr.readlines()))

        if throw_on_stderr and stderr:
            stdout = "<STDOUT SNIPPED>\n" if received_some_stdout else ""
            raise GitSavvyError(
                "$ {}\n\n{}".format(
                    " ".join(["git"] + list(filter(None, args))),
                    ''.join([stdout, stderr])
                ),
                cmd=proc.args,
                stdout=stdout,
                stderr=stderr,
                show_panel=show_panel_on_stderr
            )

    def read_graph(self, got_proc=None):
        # type: (Callable[[subprocess.Popen], None]) -> Iterator[str]
        global DATE_FORMAT, DATE_FORMAT_STATE

        args = self.build_git_command()
        if DATE_FORMAT_STATE == 'trying':
            try:
                yield from self.git_stdout(
                    *args,
                    throw_on_stderr=True,
                    show_status_message_on_stderr=False,
                    show_panel_on_stderr=False,
                    got_proc=got_proc
                )
            except GitSavvyError as e:
                if e.stderr and DATE_FORMAT in e.stderr:
                    DATE_FORMAT = FALLBACK_DATE_FORMAT
                    DATE_FORMAT_STATE = 'final'
                    enqueue_on_worker(self.view.run_command, "gs_log_graph_refresh")
                    return iter('')
                else:
                    raise GitSavvyError(
                        e.message,
                        cmd=e.cmd,
                        stdout=e.stdout,
                        stderr=e.stderr,
                        show_panel=True,
                    )
            else:
                DATE_FORMAT_STATE = 'final'

        else:
            yield from self.git_stdout(*args, got_proc=got_proc)

    def build_git_command(self):
        global DATE_FORMAT

        settings = self.view.settings()
        follow = self.savvy_settings.get("log_follow_rename")
        author = settings.get("git_savvy.log_graph_view.filter_by_author")
        all_branches = settings.get("git_savvy.log_graph_view.all_branches")
        fpath = self.file_path
        args = [
            'log',
            '--graph',
            '--decorate',  # set explicitly for "decorate-refs-exclude" to work
            '--date={}'.format(DATE_FORMAT),
            '--pretty=format:%h%d %<|(80,trunc)%s | %ad, %an',
            '--follow' if fpath and follow else None,
            '--author={}'.format(author) if author else None,
            '--decorate-refs-exclude=refs/remotes/origin/HEAD',  # cosmetics
            '--exclude=refs/stash',
            '--all' if all_branches else None,
        ]

        if not fpath and settings.get('git_savvy.log_graph_view.decoration') == 'sparse':
            args += ['--simplify-by-decoration', '--sparse']

        branches = settings.get("git_savvy.log_graph_view.branches")
        if branches:
            args += branches

        filters = settings.get("git_savvy.log_graph_view.filters")
        if filters:
            args += shlex.split(filters)

        if fpath:
            args += ["--", self.get_rel_path(fpath)]

        return args


locally_preferred_encoding = locale.getpreferredencoding()


def decoder(settings):
    encodings = ['utf8', locally_preferred_encoding, settings.get("fallback_encoding")]

    def decode(bytes):
        # type: (bytes) -> str
        for encoding in encodings:
            try:
                return bytes.decode(encoding)
            except UnicodeDecodeError:
                pass
        return bytes.decode('utf8', errors='replace')
    return decode


def prelude(view):
    # type: (sublime.View) -> str
    prelude = "\n"
    settings = view.settings()
    repo_path = settings.get("git_savvy.repo_path")
    file_path = settings.get("git_savvy.file_path")
    if file_path:
        rel_file_path = os.path.relpath(file_path, repo_path)
        prelude += "  FILE: {}\n".format(rel_file_path)
    elif repo_path:
        prelude += "  REPO: {}\n".format(repo_path)

    all_ = settings.get("git_savvy.log_graph_view.all_branches") or False
    branches = settings.get("git_savvy.log_graph_view.branches") or []
    filters = settings.get("git_savvy.log_graph_view.filters") or ""
    prelude += (
        "  "
        + "  ".join(filter(None, [
            '[a]ll: true' if all_ else '[a]ll: false',
            " ".join(branches),
            filters
        ]))
        + "\n"
    )
    return prelude + "\n"


class gs_log_graph(GsLogCommand):
    """
    Defines the main menu if you invoke `git: graph` or `git: graph current file`.

    Accepts `current_file: bool` or `file_path: str` as (keyword) arguments, and
    ensures that each of the defined actions/commands in `default_actions` are finally
    called with `file_path` set.
    """
    default_actions = [
        ["gs_log_graph_current_branch", "For current branch"],
        ["gs_log_graph_all_branches", "For all branches"],
        ["gs_log_graph_by_author", "Filtered by author"],
        ["gs_log_graph_by_branch", "Filtered by branch"],
    ]


class gs_log_graph_current_branch(WindowCommand, GitCommand):
    def run(self, file_path=None):
        self.window.run_command('gs_graph', {
            'file_path': file_path,
            'all': True,
            'follow': 'HEAD',
        })


class gs_log_graph_all_branches(WindowCommand, GitCommand):
    def run(self, file_path=None):
        self.window.run_command('gs_graph', {
            'file_path': file_path,
            'all': True,
        })


class gs_log_graph_by_author(WindowCommand, GitCommand):

    """
    Open a quick panel containing all committers for the active
    repository, ordered by most commits, Git name, and email.
    Once selected, display a quick panel with all commits made
    by the specified author.
    """

    def run(self, file_path=None):
        commiter_str = self.git("shortlog", "-sne", "HEAD")
        entries = []
        for line in commiter_str.split('\n'):
            m = re.search(r'\s*(\d*)\s*(.*)\s<(.*)>', line)
            if m is None:
                continue
            commit_count, author_name, author_email = m.groups()
            author_text = "{} <{}>".format(author_name, author_email)
            entries.append((commit_count, author_name, author_email, author_text))

        def on_select(index):
            if index == -1:
                return
            selected_author = entries[index][3]
            self.window.run_command(
                'gs_graph',
                {'file_path': file_path, 'author': selected_author}
            )

        email = self.git("config", "user.email").strip()
        self.window.show_quick_panel(
            [entry[3] for entry in entries],
            on_select,
            flags=sublime.MONOSPACE_FONT,
            selected_index=[line[2] for line in entries].index(email)
        )


class gs_log_graph_by_branch(WindowCommand, GitCommand):
    _selected_branch = None

    def run(self, file_path=None):
        def on_select(branch):
            if branch:
                self._selected_branch = branch  # remember last selection
                self.window.run_command('gs_graph', {
                    'file_path': file_path,
                    'all': True,
                    'branches': [branch],
                    'follow': branch,
                })

        show_branch_panel(on_select, selected_branch=self._selected_branch)


class gs_log_graph_navigate(GsNavigate):

    """
    Travel between commits. It is also used by compare_commit_view.
    """
    offset = 0

    def get_available_regions(self):
        return self.view.find_by_selector("constant.numeric.graph.commit-hash.git-savvy")


class gs_log_graph_navigate_to_head(TextCommand):

    """
    Travel to the HEAD commit.
    """

    def run(self, edit):
        try:
            head_commit = self.view.find_by_selector(
                "git-savvy.graph meta.graph.graph-line.head.git-savvy "
                "constant.numeric.graph.commit-hash.git-savvy"
            )[0]
        except IndexError:
            settings = self.view.settings()
            settings.set("git_savvy.log_graph_view.all_branches", True)
            settings.set("git_savvy.log_graph_view.follow", "HEAD")
            self.view.run_command("gs_log_graph_refresh")
        else:
            set_and_show_cursor(self.view, head_commit.begin())


class gs_log_graph_edit_branches(TextCommand):
    def run(self, edit):
        settings = self.view.settings()
        branches = settings.get("git_savvy.log_graph_view.branches", [])  # type: List[str]

        def on_done(text):
            # type: (str) -> None
            new_branches = list(filter_(text.split(' ')))
            settings.set("git_savvy.log_graph_view.branches", new_branches)
            self.view.run_command("gs_log_graph_refresh")

        show_single_line_input_panel(
            "branches", ' '.join(branches), on_done, select_text=True
        )


class gs_log_graph_edit_filters(TextCommand):
    def run(self, edit):
        settings = self.view.settings()
        filters = settings.get("git_savvy.log_graph_view.filters", "")

        def on_done(text):
            # type: (str) -> None
            settings.set("git_savvy.log_graph_view.filters", text)
            self.view.run_command("gs_log_graph_refresh")

        show_single_line_input_panel(
            "additional args", filters, on_done, select_text=True
        )


class gs_log_graph_reset_filters(TextCommand):
    def run(self, edit):
        settings = self.view.settings()
        settings.set("git_savvy.log_graph_view.filters", "")
        self.view.run_command("gs_log_graph_refresh")


class gs_log_graph_toggle_all_setting(TextCommand, GitCommand):
    def run(self, edit):
        settings = self.view.settings()
        current = settings.get("git_savvy.log_graph_view.all_branches")
        next_state = not current
        settings.set("git_savvy.log_graph_view.all_branches", next_state)
        self.view.run_command("gs_log_graph_refresh")


class gs_log_graph_open_commit(TextCommand):
    def run(self, edit):
        # type: (...) -> None
        window = self.view.window()
        if not window:
            return

        sel = get_simple_selection(self.view)
        if sel is None:
            return
        line_span = self.view.line(sel)
        line_text = self.view.substr(line_span)
        commit_hash = extract_commit_hash(line_text)
        if not commit_hash:
            return

        window.run_command("gs_show_commit", {"commit_hash": commit_hash})


class GsLogGraphCursorListener(EventListener, GitCommand):
    def is_applicable(self, view):
        # type: (sublime.View) -> bool
        return bool(view.settings().get("git_savvy.log_graph_view"))

    def on_activated(self, view):
        window = view.window()
        if not window:
            return

        if view not in window.views():
            return

        if self.is_applicable(view):
            show_commit_info.ensure_panel(window)

        panel_view = window.find_output_panel('show_commit_info')
        if not panel_view:
            return

        # Do nothing, if the user focuses the panel
        if panel_view.id() == view.id():
            return

        # Auto-hide panel if the user switches to a different buffer
        if not self.is_applicable(view) and show_commit_info.panel_is_visible(window):
            panel = PREVIOUS_OPEN_PANEL_PER_WINDOW.get(window.id(), None)
            if panel:
                window.run_command("show_panel", {"panel": panel})
            else:
                window.run_command('hide_panel')

        # Auto-show panel if the user switches back
        elif (
            self.is_applicable(view)
            and not show_commit_info.panel_is_visible(window)
            and self.savvy_settings.get("graph_show_more_commit_info")
        ):
            window.run_command("show_panel", {"panel": "output.show_commit_info"})

    # `on_selection_modified` triggers twice per mouse click
    # multiplied with the number of views into the same buffer,
    # hence it is *important* to throttle these events.
    # We do this seperately per side-effect. See the fn
    # implementations.
    def on_selection_modified(self, view):
        # type: (sublime.View) -> None
        if not self.is_applicable(view):
            return

        window = view.window()
        if window and show_commit_info.panel_is_visible(window):
            draw_info_panel(view)

        # `colorize_dots` queries the view heavily. We want that to
        # happen on the main thread (t.i. blocking) bc it is way, way
        # faster. But we still defer that task, so others can run code
        # that actually *needs* to be a sync side-effect to this event.
        enqueue_on_ui(colorize_dots, view)
        enqueue_on_ui(colorize_fixups, view)

        enqueue_on_ui(set_symbol_to_follow, view)

    def on_window_command(self, window, command_name, args):
        # type: (sublime.Window, str, Dict) -> None
        if command_name == 'hide_panel':
            view = window.active_view()
            if not view:
                return

            if window.active_panel() == "incremental_find":
                return

            # If the user hides the panel via `<ESC>` or mouse click,
            # remember the intent *if* the `active_view` is a 'log_graph'
            if self.is_applicable(view):
                self.savvy_settings.set("graph_show_more_commit_info", False)
            PREVIOUS_OPEN_PANEL_PER_WINDOW[window.id()] = None

        elif command_name == 'show_panel':
            view = window.active_view()
            if not view:
                return

            # Special case some panels. For these panels, showing them does not count
            # as intent to close the show_commit panel. It will thus reappear
            # automatically as soon as you focus the graph again. E.g. closing the
            # incremantal find panel via `<enter>` will bring the commit panel up
            # again.
            if args.get('panel') == "incremental_find":
                return

            toggle = args.get('toggle', False)
            panel = args.get('panel')
            if toggle and window.active_panel() == panel:  # <== actually *hide* panel
                # E.g. the same side-effect as in above "hide_panel" case
                if self.is_applicable(view):
                    self.savvy_settings.set("graph_show_more_commit_info", False)
                PREVIOUS_OPEN_PANEL_PER_WINDOW[window.id()] = None
            else:
                if panel == "output.show_commit_info":
                    self.savvy_settings.set("graph_show_more_commit_info", True)
                    PREVIOUS_OPEN_PANEL_PER_WINDOW[window.id()] = window.active_panel()
                    draw_info_panel(view)
                else:
                    if self.is_applicable(view):
                        self.savvy_settings.set("graph_show_more_commit_info", False)


PREVIOUS_OPEN_PANEL_PER_WINDOW = {}  # type: Dict[sublime.WindowId, Optional[str]]


def set_symbol_to_follow(view):
    # type: (sublime.View) -> None
    symbol = extract_symbol_to_follow(view)
    if symbol:
        view.settings().set('git_savvy.log_graph_view.follow', symbol)


def extract_symbol_to_follow(view):
    # type: (sublime.View) -> Optional[str]
    """Extract a symbol to follow."""
    try:
        # Intentional `b` (not `end()`!) because b is where the
        # cursor is. (If you select upwards b becomes < a.)
        cursor = [s.b for s in view.sel()][-1]
    except IndexError:
        return None

    line_span = view.line(cursor)
    line_text = view.substr(line_span)
    return _extract_symbol_to_follow(view.id(), line_text)


@lru_cache(maxsize=512)
def _extract_symbol_to_follow(vid, _line_text):
    # type: (sublime.ViewId, str) -> Optional[str]
    view = sublime.View(vid)
    try:
        # Intentional `b` (not `end()`!) because b is where the
        # cursor is. (If you select upwards b becomes < a.)
        cursor = [s.b for s in view.sel()][-1]
    except IndexError:
        return None

    line_span = view.line(cursor)
    if view.match_selector(cursor, 'meta.graph.graph-line.head.git-savvy'):
        return 'HEAD'

    symbols_on_line = [
        symbol
        for r, symbol in view.symbols()
        if line_span.contains(r)
    ]
    if symbols_on_line:
        # git always puts the remotes first so we take
        # the last one which is (then) a local branch.
        return symbols_on_line[-1]

    line_text = view.substr(line_span)
    return extract_commit_hash(line_text)


def navigate_to_symbol(
    view,            # type: sublime.View
    symbol,          # type: str
    if_before=None,  # type: sublime.Region
    col_range=None,  # type: Tuple[int, int]
    method=None,     # type: Callable[[sublime.View, Union[sublime.Region, sublime.Point]], None]
):
    # type: (...) -> bool
    jump_position = jump_position_for_symbol(view, symbol, if_before, col_range)
    if jump_position is None:
        return False

    if method is None:
        method = set_and_show_cursor
    method(view, jump_position)
    return True


def jump_position_for_symbol(
    view,            # type: sublime.View
    symbol,          # type: str
    if_before=None,  # type: Optional[sublime.Region]
    col_range=None   # type: Optional[Tuple[int, int]]
):
    # type: (...) -> Optional[Union[sublime.Region, sublime.Point]]
    region = _find_symbol(view, symbol)
    if region is None:  # explicit `None` checks bc empty regions are falsy!
        return None
    if if_before is not None and region.end() > if_before.end():
        return None

    if col_range is None:
        return region.begin()

    line_start = line_start_of_region(view, region)
    wanted_region = Region(*col_range) + line_start
    # Normalize single cursors *before* the commit hash
    # (t.i. `region.end()`) to `region.begin()`.
    if wanted_region.empty() and wanted_region.a < region.end():
        return region.begin()
    else:
        return wanted_region


def _find_symbol(view, symbol):
    # type: (sublime.View, str) -> Optional[sublime.Region]
    if symbol == 'HEAD':
        try:
            return view.find_by_selector(
                'meta.graph.graph-line.head.git-savvy '
                'constant.numeric.graph.commit-hash.git-savvy'
            )[0]
        except IndexError:
            return None

    for r, symbol_ in view.symbols():
        if symbol_ == symbol:
            line_of_symbol = view.line(r)
            return extract_comit_hash_span(view, line_of_symbol)

    r = view.find(FIND_COMMIT_HASH + re.escape(symbol), 0)
    if not r.empty():
        line_of_symbol = view.line(r)
        return extract_comit_hash_span(view, line_of_symbol)
    return None


def extract_comit_hash_span(view, line_span):
    # type: (sublime.View, sublime.Region) -> Optional[sublime.Region]
    line_text = view.substr(line_span)
    match = COMMIT_LINE.search(line_text)
    if match:
        a, b = match.span('commit_hash')
        return sublime.Region(a + line_span.a, b + line_span.a)
    return None


FIND_COMMIT_HASH = "^[{graph_chars}]*[{node_chars}][{graph_chars}]* ".format(
    graph_chars=GRAPH_CHAR_OPTIONS, node_chars=COMMIT_NODE_CHAR_OPTIONS
)


@text_command
def set_and_show_cursor(view, point_or_region):
    # type: (sublime.View, Union[sublime.Region, sublime.Point]) -> None
    just_set_cursor(view, point_or_region)
    view.show(view.sel(), True)


@text_command
def just_set_cursor(view, point_or_region):
    # type: (sublime.View, Union[sublime.Region, sublime.Point]) -> None
    sel = view.sel()
    sel.clear()
    sel.add(point_or_region)


def get_simple_selection(view):
    # type: (sublime.View) -> Optional[sublime.Region]
    sel = [s for s in view.sel()]
    if len(sel) != 1 or len(view.lines(sel[0])) != 1:
        return None

    return sel[0]


def get_column_range(view, region):
    # type: (sublime.View, sublime.Region) -> Tuple[int, int]
    line_start = line_start_of_region(view, region)
    return (region.begin() - line_start, region.end() - line_start)


def is_sel_in_viewport(view):
    # type: (sublime.View) -> bool
    viewport = view.visible_region()
    return all(viewport.contains(s) or viewport.intersects(s) for s in view.sel())


def line_start_of_region(view, region):
    # type: (sublime.View, sublime.Region) -> sublime.Point
    return view.line(region).begin()


def colorize_dots(view):
    # type: (sublime.View) -> None
    dots = tuple(find_dots(view))
    _colorize_dots(view.id(), dots)


def find_dots(view):
    # type: (sublime.View) -> Set[colorizer.Char]
    return set(_find_dots(view))


def _find_dots(view):
    # type: (sublime.View) -> Iterator[colorizer.Char]
    for s in view.sel():
        line_region = view.line(s.begin())
        line_content = view.substr(line_region)
        idx = line_content.find(COMMIT_NODE_CHAR)
        if idx > -1:
            yield colorizer.Char(view, line_region.begin() + idx)


@lru_cache(maxsize=1)
# ^- throttle side-effects
def _colorize_dots(vid, dots):
    # type: (sublime.ViewId, Tuple[colorizer.Char]) -> None
    view = sublime.View(vid)
    view.add_regions('gs_log_graph_dot', [d.region() for d in dots], scope=DOT_SCOPE)
    paths = [
        c.region()
        for path in map(colorizer.follow_path, dots)
        if len(path) > 1
        for c in path
    ]
    view.add_regions('gs_log_graph_follow_path', paths, scope=PATH_SCOPE)


def colorize_fixups(view):
    # type: (sublime.View) -> None
    dots = tuple(find_dots(view))
    _colorize_fixups(view.id(), dots)


@lru_cache(maxsize=1)
def _colorize_fixups(vid, dots):
    # type: (sublime.ViewId, Tuple[colorizer.Char]) -> None
    view = sublime.View(vid)
    message_regions = find_by_selector(view, 'meta.graph.message.git-savvy')
    extract_message = partial(
        message_from_fixup_squash_line, view.id(), message_regions=message_regions
    )
    matching_dots = list(filter_(
        find_matching_commit(view.id(), dot, message, message_regions)
        for dot, message in zip(dots, map(extract_message, dots))
        if message
    ))
    view.add_regions(
        'gs_log_graph_follow_fixups',
        [dot.region() for dot in matching_dots],
        scope=MATCHING_COMMIT_SCOPE
    )


def find_by_selector(view, selector):
    # type: (sublime.View, str) -> Tuple[Region, ...]
    # Same as `view.find_by_selector` but the result is hashable.
    return tuple(
        Region(r.a, r.b)
        for r in view.find_by_selector(selector)
    )


@lru_cache(maxsize=64)
def message_from_fixup_squash_line(vid, dot, message_regions):
    # type: (sublime.ViewId, colorizer.Char, Iterable[Region]) -> Optional[str]
    view = sublime.View(vid)
    message = commit_message_from_point(view, dot.pt, message_regions)
    if not message:
        return None
    # Truncated messages end with one or multiple "." dots which we
    # have to strip.
    if message.startswith('fixup! '):
        return message[7:].rstrip('.').strip()
    if message.startswith('squash! '):
        return message[8:].rstrip('.').strip()
    return None


def commit_message_from_point(view, pt, message_regions):
    # type: (sublime.View, int, Iterable[Region]) -> Optional[str]
    line_span = view.line(pt)
    for r in message_regions:
        if line_span.contains(r):
            return view.substr(r)
    else:
        return None


@lru_cache(maxsize=64)
def find_matching_commit(vid, dot, message, message_regions):
    # type: (sublime.ViewId, colorizer.Char, str, Iterable[Region]) -> Optional[colorizer.Char]
    view = sublime.View(vid)
    for dot in islice(follow_dots(dot), 0, 50):
        this_message = commit_message_from_point(view, dot.pt, message_regions)
        if this_message and this_message.startswith(message):
            return dot
    else:
        return None


def follow_dots(dot):
    # type: (colorizer.Char) -> Iterator[colorizer.Char]
    """Follow dot to dot omitting the path chars in between."""
    while True:
        try:
            dot = colorizer.follow_path(dot)[-1]
        except IndexError:
            break
        else:
            yield dot


def draw_info_panel(view):
    # type: (sublime.View) -> None
    """Extract line under the last cursor and draw info panel."""
    try:
        # Intentional `b` (not `end()`!) because b is where the
        # cursor is. (If you select upwards b becomes < a.)
        cursor = [s.b for s in view.sel()][-1]
    except IndexError:
        return

    line_span = view.line(cursor)
    line_text = view.substr(line_span)

    # Defer to a second fn to reduce side-effects
    draw_info_panel_for_line(view.id(), line_text)


@lru_cache(maxsize=1)
# ^- used to throttle the side-effect!
# Read: distinct until      (vid, line_text) changes
def draw_info_panel_for_line(vid, line_text):
    # type: (sublime.ViewId, str) -> None
    view = sublime.View(vid)
    window = view.window()
    if not window:
        return

    commit_hash = extract_commit_hash(line_text)
    # `gs_show_commit_info` draws a blank panel if `commit_hash`
    # is falsy.  That only looks nice iff the main graph view is
    # also blank. (Which it only ever is directly after creation.)
    # If you just move the cursor to a line not containing a
    # commit_hash, it looks better to not draw at all, t.i. the
    # information in the panel stays untouched.
    if view.size() == 0 or commit_hash:
        window.run_command("gs_show_commit_info", {"commit_hash": commit_hash})


def extract_commit_hash(line):
    match = COMMIT_LINE.search(line)
    return match.groupdict()['commit_hash'] if match else ""


class gs_log_graph_toggle_more_info(WindowCommand, GitCommand):

    """
    Toggle global `graph_show_more_commit_info` setting.
    """

    def run(self):
        if show_commit_info.panel_is_visible(self.window):
            self.window.run_command("hide_panel", {"panel": "output.show_commit_info"})
        else:
            self.window.run_command("show_panel", {"panel": "output.show_commit_info"})


if MYPY:
    from typing import Literal, TypedDict
    LineInfo = TypedDict('LineInfo', {
        'commit': str,
        'HEAD': str,
        'branches': List[str],
        'local_branches': List[str],
        'tags': List[str],
    }, total=False)
    ListItems = Literal["branches", "local_branches", "tags"]


def describe_graph_line(line, remotes):
    # type: (str, Iterable[str]) -> Optional[LineInfo]
    match = COMMIT_LINE.match(line)
    if match is None:
        return None

    commit_hash = match.group("commit_hash")
    decoration = match.group("decoration")

    rv = {"commit": commit_hash}  # type: LineInfo
    if decoration:
        decoration = decoration[1:-1]  # strip parentheses
        names = decoration.split(", ")
        if names[0].startswith("HEAD"):
            head, *names = names
            if head == "HEAD":
                rv["HEAD"] = commit_hash
            else:
                branch = head[len("HEAD -> "):]
                rv["HEAD"] = branch
                names = [branch] + names
        branches, local_branches, tags = [], [], []
        for name in names:
            if name.startswith("tag: "):
                tags.append(name[len("tag: "):])
            else:
                branches.append(name)
                if not any(name.startswith(remote + "/") for remote in remotes):
                    local_branches.append(name)
        if branches:
            rv["branches"] = branches
        if local_branches:
            rv["local_branches"] = local_branches
        if tags:
            rv["tags"] = tags

    return rv


def describe_head(view, remotes):
    # type: (sublime.View, Iterable[str]) -> Optional[LineInfo]
    try:
        region = view.find_by_selector(
            'meta.graph.graph-line.head.git-savvy '
            'constant.numeric.graph.commit-hash.git-savvy'
        )[0]
    except IndexError:
        return None

    cursor = region.b
    line_span = view.line(cursor)
    line_text = view.substr(line_span)
    return describe_graph_line(line_text, remotes)


def format_revision_list(revisions):
    # type: (Sequence[str]) -> str
    return (
        "{}".format(*revisions)
        if len(revisions) == 1
        else "{} and {}".format(*revisions)
        if len(revisions) == 2
        else "{}, {}, and {}".format(revisions[0], revisions[1], revisions[-1])
        if len(revisions) == 3
        else "{}, {} ... {}".format(revisions[0], revisions[1], revisions[-1])
    )


class gs_log_graph_action(WindowCommand, GitCommand):
    selected_index = 0

    def run(self):
        view = self.window.active_view()
        if not view:
            return

        remotes = set(self.get_remotes().keys())
        infos = list(filter_(
            describe_graph_line(line, remotes)
            for line in unique(
                view.substr(line)
                for s in view.sel()
                for line in view.lines(s)
            )
        ))
        if not infos:
            return

        actions = (
            self.actions_for_single_line(view, infos[0], remotes)
            if len(infos) == 1
            else self.actions_for_multiple_lines(view, infos, remotes)
        )
        if not actions:
            return

        def on_action_selection(index):
            if index == -1:
                return

            self.selected_index = index
            description, action = actions[index]
            action()

        self.window.show_quick_panel(
            [a[0] for a in actions],
            on_action_selection,
            flags=sublime.MONOSPACE_FONT,
            selected_index=self.selected_index,
        )

    def actions_for_multiple_lines(self, view, infos, remotes):
        # type: (sublime.View, List[LineInfo], Iterable[str]) -> List[Tuple[str, Callable[[], None]]]
        file_path = self.file_path
        actions = []  # type: List[Tuple[str, Callable[[], None]]]

        sel = view.sel()
        if all(s.empty() for s in sel) and len(sel) == 2:
            def display_name(info):
                # type: (LineInfo) -> str
                if info.get("local_branches"):
                    return info["local_branches"][0]
                branches = info.get("branches", [])
                if len(branches) == 1:
                    return branches[0]
                elif len(branches) == 0 and info.get("tags"):
                    return info["tags"][0]
                else:
                    return info["commit"]

            base_commit = display_name(infos[0])
            target_commit = display_name(infos[1])

            actions += [
                (
                    "Diff {}{}...{}".format(
                        "file " if file_path else "", base_commit, target_commit
                    ),
                    partial(self.sym_diff_two_commits, base_commit, target_commit, file_path)
                ),
                (
                    "Diff {}{}..{}".format(
                        "file " if file_path else "", base_commit, target_commit
                    ),
                    partial(self.diff_commit, base_commit, target_commit, file_path)
                ),
                (
                    "Compare {}'{}' and '{}'".format(
                        "file between " if file_path else "", base_commit, target_commit
                    ),
                    partial(self.compare_against, base_commit, target_commit, file_path)
                ),
                (
                    "Show file history from {}..{}".format(base_commit, target_commit)
                    if file_path
                    else "Show graph for {}..{}".format(base_commit, target_commit),
                    partial(self.graph_two_revisions, base_commit, target_commit, file_path)
                ),
                (
                    "Show file history from {}..{}".format(target_commit, base_commit)
                    if file_path
                    else "Show graph for {}..{}".format(target_commit, base_commit),
                    partial(self.graph_two_revisions, target_commit, base_commit, file_path)
                )

            ]

        pickable = list(reversed([
            info["commit"]
            for info in infos
            if "HEAD" not in info
        ]))
        if pickable:
            actions += [
                (
                    "Cherry-pick {}".format(format_revision_list(pickable)),
                    partial(self.cherry_pick, *pickable)
                )
            ]

        revertable = list(reversed([info["commit"] for info in infos]))
        actions += [
            (
                "Revert {}".format(format_revision_list(revertable)),
                partial(self.revert_commit, *revertable)
            )
        ]

        return actions

    def sym_diff_two_commits(self, base_commit, target_commit, file_path=None):
        self.window.run_command("gs_diff", {
            "in_cached_mode": False,
            "file_path": file_path,
            "base_commit": "{}...{}".format(base_commit, target_commit),
            "disable_stage": True
        })

    def graph_two_revisions(self, base_commit, target_commit, file_path=None):
        branches = ["{}..{}".format(base_commit, target_commit)]
        self.window.run_command("gs_graph", {
            'all': False,
            'file_path': file_path,
            'branches': branches,
            'follow': base_commit
        })

    def actions_for_single_line(self, view, info, remotes):
        # type: (sublime.View, LineInfo, Iterable[str]) -> List[Tuple[str, Callable[[], None]]]
        commit_hash = info["commit"]
        file_path = self.file_path
        actions = []  # type: List[Tuple[str, Callable[[], None]]]
        actions += [
            ("Checkout '{}'".format(branch_name), partial(self.checkout, branch_name))
            for branch_name in info.get("local_branches", [])
            if info.get("HEAD") != branch_name
        ]

        good_commit_name = (
            info["tags"][0]
            if info.get("tags")
            else commit_hash
        )
        if "HEAD" not in info or info["HEAD"] != commit_hash:
            actions += [
                (
                    "Checkout '{}' detached".format(good_commit_name),
                    partial(self.checkout, good_commit_name)
                ),
            ]

        if file_path:
            actions += [
                ("Show file at commit", partial(self.show_file_at_commit, commit_hash, file_path)),
                ("Blame file at commit", partial(self.blame_file_atcommit, commit_hash, file_path)),
                (
                    "Checkout file at commit",
                    partial(self.checkout_file_at_commit, commit_hash, file_path)
                )
            ]

        actions += [
            ("Create tag", partial(self.create_tag, commit_hash))
        ]
        actions += [
            ("Delete '{}'".format(tag_name), partial(self.delete_tag, tag_name))
            for tag_name in info.get("tags", [])
        ]

        head_info = describe_head(view, remotes)
        head_is_on_a_branch = head_info and head_info["HEAD"] != head_info["commit"]

        def get_list(info, key):
            # type: (LineInfo, ListItems) -> List[str]
            return info.get(key, [])  # type: ignore

        if not head_info or head_info["commit"] != info["commit"]:
            good_head_name = (
                "'{}'".format(head_info["HEAD"])  # type: ignore
                if head_is_on_a_branch
                else "HEAD"
            )
            get = partial(get_list, info)  # type: Callable[[ListItems], List[str]]
            good_reset_target = next(
                chain(get("local_branches"), get("branches")),
                good_commit_name
            )
            actions += [
                (
                    "Reset {} to '{}'".format(good_head_name, good_reset_target),
                    partial(self.reset_to, good_reset_target)
                )
            ]

        if head_info and head_info["commit"] != info["commit"]:
            get = partial(get_list, head_info)  # type: Callable[[ListItems], List[str]]  # type: ignore
            good_move_target = (
                head_info["HEAD"]
                if head_is_on_a_branch
                else next(
                    chain(get("local_branches"), get("branches"), get("tags")),
                    head_info["commit"]
                )
            )
            actions += [
                (
                    "Move '{}' to '{}'".format(branch_name, good_move_target),
                    partial(self.checkout_b, branch_name)
                )
                for branch_name in info.get("local_branches", [])
            ]

        actions += [
            ("Delete branch '{}'".format(branch_name), partial(self.delete_branch, branch_name))
            for branch_name in info.get("local_branches", [])
            if info.get("HEAD") != branch_name
        ]

        if "HEAD" not in info:
            actions += [
                ("Cherry-pick commit", partial(self.cherry_pick, commit_hash)),
            ]

        actions += [
            ("Revert commit", partial(self.revert_commit, commit_hash)),
            (
                "Compare {}against ...".format("file " if file_path else ""),
                partial(self.compare_against, commit_hash, file_path=file_path)
            ),
            (
                "Diff {}against workdir".format("file " if file_path else ""),
                partial(self.diff_commit, commit_hash, file_path=file_path)
            )
        ]
        return actions

    def checkout(self, commit_hash):
        self.git("checkout", commit_hash)
        util.view.refresh_gitsavvy_interfaces(self.window, refresh_sidebar=True)

    def checkout_b(self, branch_name):
        self.git("checkout", "-B", branch_name)
        util.view.refresh_gitsavvy_interfaces(self.window, refresh_sidebar=True)

    def delete_branch(self, branch_name):
        self.window.run_command("gs_delete_branch", {"branch": branch_name})

    def show_commit(self, commit_hash):
        self.window.run_command("gs_show_commit", {"commit_hash": commit_hash})

    def create_tag(self, commit_hash):
        self.window.run_command("gs_tag_create", {"target_commit": commit_hash})

    def delete_tag(self, tag_name):
        self.git("tag", "-d", tag_name)
        util.view.refresh_gitsavvy_interfaces(self.window, refresh_sidebar=True)

    def reset_to(self, commitish):
        self.window.run_command("gs_reset", {"commit_hash": commitish})

    def cherry_pick(self, *commit_hash):
        try:
            self.git("cherry-pick", *commit_hash)
        finally:
            util.view.refresh_gitsavvy_interfaces(self.window, refresh_sidebar=True)

    def revert_commit(self, *commit_hash):
        try:
            self.git("revert", *commit_hash)
        finally:
            util.view.refresh_gitsavvy_interfaces(self.window, refresh_sidebar=True)

    def compare_against(self, base_commit, target_commit=None, file_path=None):
        self.window.run_command("gs_compare_against", {
            "base_commit": base_commit,
            "target_commit": target_commit,
            "file_path": file_path
        })

    def copy_sha(self, commit_hash):
        sublime.set_clipboard(self.git("rev-parse", commit_hash).strip())

    def diff_commit(self, base_commit, target_commit=None, file_path=None):
        self.window.run_command("gs_diff", {
            "in_cached_mode": False,
            "file_path": file_path,
            "base_commit": base_commit,
            "target_commit": target_commit,
            "disable_stage": True
        })

    def show_file_at_commit(self, commit_hash, file_path):
        self.window.run_command(
            "gs_show_file_at_commit",
            {"commit_hash": commit_hash, "filepath": file_path})

    def blame_file_atcommit(self, commit_hash, file_path):
        self.window.run_command(
            "gs_blame",
            {"commit_hash": commit_hash, "file_path": file_path})

    def checkout_file_at_commit(self, commit_hash, file_path):
        self.checkout_ref(commit_hash, fpath=file_path)
        util.view.refresh_gitsavvy_interfaces(self.window, refresh_sidebar=True)