# show.py - Extension implementing `hg show`
#
# Copyright 2017 Gregory Szorc <gregory.szorc@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

"""unified command to show various repository information (EXPERIMENTAL)

This extension provides the :hg:`show` command, which provides a central
command for displaying commonly-accessed repository data and views of that
data.

The following config options can influence operation.

``commands``
------------

``show.aliasprefix``
   List of strings that will register aliases for views. e.g. ``s`` will
   effectively set config options ``alias.s<view> = show <view>`` for all
   views. i.e. `hg swork` would execute `hg show work`.

   Aliases that would conflict with existing registrations will not be
   performed.
"""

from __future__ import annotations

from mercurial.i18n import _
from mercurial.node import nullrev
from mercurial import (
    cmdutil,
    commands,
    destutil,
    error,
    formatter,
    graphmod,
    logcmdutil,
    phases,
    pycompat,
    registrar,
    revset,
    revsetlang,
    scmutil,
)

# Note for extension authors: ONLY specify testedwith = 'ships-with-hg-core' for
# extensions which SHIP WITH MERCURIAL. Non-mainline extensions should
# be specifying the version(s) of Mercurial they are tested with, or
# leave the attribute unspecified.
testedwith = b'ships-with-hg-core'

cmdtable = {}
command = registrar.command(cmdtable)

revsetpredicate = registrar.revsetpredicate()


class showcmdfunc(registrar._funcregistrarbase):
    """Register a function to be invoked for an `hg show <thing>`."""

    # Used by _formatdoc().
    _docformat = b'%s -- %s'

    def _extrasetup(self, name, func, fmtopic=None, csettopic=None):
        """Called with decorator arguments to register a show view.

        ``name`` is the sub-command name.

        ``func`` is the function being decorated.

        ``fmtopic`` is the topic in the style that will be rendered for
        this view.

        ``csettopic`` is the topic in the style to be used for a changeset
        printer.

        If ``fmtopic`` is specified, the view function will receive a
        formatter instance. If ``csettopic`` is specified, the view
        function will receive a changeset printer.
        """
        func._fmtopic = fmtopic
        func._csettopic = csettopic


showview = showcmdfunc()


@command(
    b'show',
    [
        # TODO: Switch this template flag to use cmdutil.formatteropts if
        # 'hg show' becomes stable before --template/-T is stable. For now,
        # we are putting it here without the '(EXPERIMENTAL)' flag because it
        # is an important part of the 'hg show' user experience and the entire
        # 'hg show' experience is experimental.
        (b'T', b'template', b'', b'display with template', _(b'TEMPLATE')),
    ],
    _(b'VIEW'),
    helpcategory=command.CATEGORY_CHANGE_NAVIGATION,
)
def show(ui, repo, view=None, template=None):
    """show various repository information

    A requested view of repository data is displayed.

    If no view is requested, the list of available views is shown and the
    command aborts.

    .. note::

       There are no backwards compatibility guarantees for the output of this
       command. Output may change in any future Mercurial release.

       Consumers wanting stable command output should specify a template via
       ``-T/--template``.

    List of available views:
    """
    if ui.plain() and not template:
        hint = _(b'invoke with -T/--template to control output format')
        raise error.Abort(
            _(b'must specify a template in plain mode'), hint=hint
        )

    views = showview._table

    if not view:
        ui.pager(b'show')
        # TODO consider using formatter here so available views can be
        # rendered to custom format.
        ui.write(_(b'available views:\n'))
        ui.write(b'\n')

        for name, func in sorted(views.items()):
            ui.write(b'%s\n' % pycompat.sysbytes(func.__doc__))

        ui.write(b'\n')
        raise error.Abort(
            _(b'no view requested'),
            hint=_(b'use "hg show VIEW" to choose a view'),
        )

    # TODO use same logic as dispatch to perform prefix matching.
    if view not in views:
        raise error.Abort(
            _(b'unknown view: %s') % view,
            hint=_(b'run "hg show" to see available views'),
        )

    template = template or b'show'

    fn = views[view]
    ui.pager(b'show')

    if fn._fmtopic:
        fmtopic = b'show%s' % fn._fmtopic
        with ui.formatter(fmtopic, {b'template': template}) as fm:
            return fn(ui, repo, fm)
    elif fn._csettopic:
        ref = b'show%s' % fn._csettopic
        spec = formatter.lookuptemplate(ui, ref, template)
        displayer = logcmdutil.changesettemplater(ui, repo, spec, buffered=True)
        return fn(ui, repo, displayer)
    else:
        return fn(ui, repo)


@showview(b'bookmarks', fmtopic=b'bookmarks')
def showbookmarks(ui, repo, fm):
    """bookmarks and their associated changeset"""
    marks = repo._bookmarks
    if not len(marks):
        # This is a bit hacky. Ideally, templates would have a way to
        # specify an empty output, but we shouldn't corrupt JSON while
        # waiting for this functionality.
        if not isinstance(fm, formatter.jsonformatter):
            ui.write(_(b'(no bookmarks set)\n'))
        return

    revs = [repo[node].rev() for node in marks.values()]
    active = repo._activebookmark
    longestname = max(len(b) for b in marks)
    nodelen = longestshortest(repo, revs)

    for bm, node in sorted(marks.items()):
        fm.startitem()
        fm.context(ctx=repo[node])
        fm.write(b'bookmark', b'%s', bm)
        fm.write(b'node', fm.hexfunc(node), fm.hexfunc(node))
        fm.data(
            active=bm == active, longestbookmarklen=longestname, nodelen=nodelen
        )


@showview(b'stack', csettopic=b'stack')
def showstack(ui, repo, displayer):
    """current line of work"""
    wdirctx = repo[b'.']
    if wdirctx.rev() == nullrev:
        raise error.Abort(
            _(
                b'stack view only available when there is a '
                b'working directory'
            )
        )

    if wdirctx.phase() == phases.public:
        ui.write(
            _(
                b'(empty stack; working directory parent is a published '
                b'changeset)\n'
            )
        )
        return

    # TODO extract "find stack" into a function to facilitate
    # customization and reuse.

    baserev = destutil.stackbase(ui, repo)
    basectx = None

    if baserev is None:
        baserev = wdirctx.rev()
        stackrevs = {wdirctx.rev()}
    else:
        stackrevs = set(repo.revs(b'%d::.', baserev))

    ctx = repo[baserev]
    if ctx.p1().rev() != nullrev:
        basectx = ctx.p1()

    # And relevant descendants.
    branchpointattip = False
    cl = repo.changelog

    for rev in cl.descendants([wdirctx.rev()]):
        ctx = repo[rev]

        # Will only happen if . is public.
        if ctx.phase() == phases.public:
            break

        stackrevs.add(ctx.rev())

        # ctx.children() within a function iterating on descandants
        # potentially has severe performance concerns because revlog.children()
        # iterates over all revisions after ctx's node. However, the number of
        # draft changesets should be a reasonably small number. So even if
        # this is quadratic, the perf impact should be minimal.
        if len(ctx.children()) > 1:
            branchpointattip = True
            break

    stackrevs = list(sorted(stackrevs, reverse=True))

    # Find likely target heads for the current stack. These are likely
    # merge or rebase targets.
    if basectx:
        # TODO make this customizable?
        newheads = set(
            repo.revs(
                b'heads(%d::) - %ld - not public()', basectx.rev(), stackrevs
            )
        )
    else:
        newheads = set()

    allrevs = set(stackrevs) | newheads | {baserev}
    nodelen = longestshortest(repo, allrevs)

    try:
        cmdutil.findcmd(b'rebase', commands.table)
        haverebase = True
    except (error.AmbiguousCommand, error.UnknownCommand):
        haverebase = False

    # TODO use templating.
    # TODO consider using graphmod. But it may not be necessary given
    # our simplicity and the customizations required.
    # TODO use proper graph symbols from graphmod

    tres = formatter.templateresources(ui, repo)
    shortesttmpl = formatter.maketemplater(
        ui, b'{shortest(node, %d)}' % nodelen, resources=tres
    )

    def shortest(ctx):
        return shortesttmpl.renderdefault({b'ctx': ctx, b'node': ctx.hex()})

    # We write out new heads to aid in DAG awareness and to help with decision
    # making on how the stack should be reconciled with commits made since the
    # branch point.
    if newheads:
        # Calculate distance from base so we can render the count and so we can
        # sort display order by commit distance.
        revdistance = {}
        for head in newheads:
            # There is some redundancy in DAG traversal here and therefore
            # room to optimize.
            ancestors = cl.ancestors([head], stoprev=basectx.rev())
            revdistance[head] = len(list(ancestors))

        sourcectx = repo[stackrevs[-1]]

        sortedheads = sorted(
            newheads, key=lambda x: revdistance[x], reverse=True
        )

        for i, rev in enumerate(sortedheads):
            ctx = repo[rev]

            if i:
                ui.write(b': ')
            else:
                ui.write(b'  ')

            ui.writenoi18n(b'o  ')
            displayer.show(ctx, nodelen=nodelen)
            displayer.flush(ctx)
            ui.write(b'\n')

            if i:
                ui.write(b':/')
            else:
                ui.write(b' /')

            ui.write(b'    (')
            ui.write(
                _(b'%d commits ahead') % revdistance[rev],
                label=b'stack.commitdistance',
            )

            if haverebase:
                # TODO may be able to omit --source in some scenarios
                ui.write(b'; ')
                ui.write(
                    (
                        b'hg rebase --source %s --dest %s'
                        % (shortest(sourcectx), shortest(ctx))
                    ),
                    label=b'stack.rebasehint',
                )

            ui.write(b')\n')

        ui.write(b':\n:    ')
        ui.write(_(b'(stack head)\n'), label=b'stack.label')

    if branchpointattip:
        ui.write(b' \\ /  ')
        ui.write(_(b'(multiple children)\n'), label=b'stack.label')
        ui.write(b'  |\n')

    for rev in stackrevs:
        ctx = repo[rev]
        symbol = b'@' if rev == wdirctx.rev() else b'o'

        if newheads:
            ui.write(b': ')
        else:
            ui.write(b'  ')

        ui.write(symbol, b'  ')
        displayer.show(ctx, nodelen=nodelen)
        displayer.flush(ctx)
        ui.write(b'\n')

    # TODO display histedit hint?

    if basectx:
        # Vertically and horizontally separate stack base from parent
        # to reinforce stack boundary.
        if newheads:
            ui.write(b':/   ')
        else:
            ui.write(b' /   ')

        ui.write(_(b'(stack base)'), b'\n', label=b'stack.label')
        ui.writenoi18n(b'o  ')

        displayer.show(basectx, nodelen=nodelen)
        displayer.flush(basectx)
        ui.write(b'\n')


@revsetpredicate(b'_underway([commitage[, headage]])')
def underwayrevset(repo, subset, x):
    args = revset.getargsdict(x, b'underway', b'commitage headage')
    if b'commitage' not in args:
        args[b'commitage'] = None
    if b'headage' not in args:
        args[b'headage'] = None

    # We assume callers of this revset add a topographical sort on the
    # result. This means there is no benefit to making the revset lazy
    # since the topographical sort needs to consume all revs.
    #
    # With this in mind, we build up the set manually instead of constructing
    # a complex revset. This enables faster execution.

    # Mutable changesets (non-public) are the most important changesets
    # to return. ``not public()`` will also pull in obsolete changesets if
    # there is a non-obsolete changeset with obsolete ancestors. This is
    # why we exclude obsolete changesets from this query.
    rs = b'not public() and not obsolete()'
    rsargs = []
    if args[b'commitage']:
        rs += b' and date(%s)'
        rsargs.append(
            revsetlang.getstring(
                args[b'commitage'], _(b'commitage requires a string')
            )
        )

    mutable = repo.revs(rs, *rsargs)
    relevant = revset.baseset(mutable)

    # Add parents of mutable changesets to provide context.
    relevant += repo.revs(b'parents(%ld)', mutable)

    # We also pull in (public) heads if they a) aren't closing a branch
    # b) are recent.
    rs = b'head() and not closed()'
    rsargs = []
    if args[b'headage']:
        rs += b' and date(%s)'
        rsargs.append(
            revsetlang.getstring(
                args[b'headage'], _(b'headage requires a string')
            )
        )

    relevant += repo.revs(rs, *rsargs)

    # Add working directory parent.
    wdirrev = repo[b'.'].rev()
    if wdirrev != nullrev:
        relevant += revset.baseset({wdirrev})

    return subset & relevant


@showview(b'work', csettopic=b'work')
def showwork(ui, repo, displayer):
    """changesets that aren't finished"""
    # TODO support date-based limiting when calling revset.
    revs = repo.revs(b'sort(_underway(), topo)')
    nodelen = longestshortest(repo, revs)

    revdag = graphmod.dagwalker(repo, revs)

    ui.setconfig(b'experimental', b'graphshorten', True)
    logcmdutil.displaygraph(
        ui,
        repo,
        revdag,
        displayer,
        graphmod.asciiedges,
        props={b'nodelen': nodelen},
    )


def extsetup(ui):
    # Alias `hg <prefix><view>` to `hg show <view>`.
    for prefix in ui.configlist(b'commands', b'show.aliasprefix'):
        for view in showview._table:
            name = b'%s%s' % (prefix, view)

            choice, allcommands = cmdutil.findpossible(
                name, commands.table, strict=True
            )

            # This alias is already a command name. Don't set it.
            if name in choice:
                continue

            # Same for aliases.
            if ui.config(b'alias', name, None):
                continue

            ui.setconfig(b'alias', name, b'show %s' % view, source=b'show')


def longestshortest(repo, revs, minlen=4):
    """Return the length of the longest shortest node to identify revisions.

    The result of this function can be used with the ``shortest()`` template
    function to ensure that a value is unique and unambiguous for a given
    set of nodes.

    The number of revisions in the repo is taken into account to prevent
    a numeric node prefix from conflicting with an integer revision number.
    If we fail to do this, a value of e.g. ``10023`` could mean either
    revision 10023 or node ``10023abc...``.
    """
    if not revs:
        return minlen
    cl = repo.changelog
    return max(
        len(scmutil.shortesthexnodeidprefix(repo, cl.node(r), minlen))
        for r in revs
    )


# Adjust the docstring of the show command so it shows all registered views.
# This is a bit hacky because it runs at the end of module load. When moved
# into core or when another extension wants to provide a view, we'll need
# to do this more robustly.
# TODO make this more robust.
def _updatedocstring():
    longest = max(map(len, showview._table.keys()))
    entries = []
    for key in sorted(showview._table.keys()):
        entries.append(
            r'%s   %s'
            % (
                pycompat.sysstr(key.ljust(longest)),
                showview._table[key]._origdoc,
            )
        )

    cmdtable[b'show'][0].__doc__ = pycompat.sysstr(b'%s\n\n%s\n    ') % (
        pycompat.cleandoc(cmdtable[b'show'][0].__doc__),
        pycompat.sysstr(b'\n\n').join(entries),
    )


_updatedocstring()
