# storageutil.py - Storage functionality agnostic of backend implementation.
#
# Copyright 2018 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.

from __future__ import annotations

import re
import struct

from typing import (
    Iterator,
)

from ..i18n import _
from ..node import (
    bin,
    nullrev,
    sha1nodeconstants,
)
from ..interfaces.types import (
    RevisionDeltaT,
)

from ..revlogutils.constants import (
    META_MARKER,
    META_MARKER_SIZE,
)

from .. import (
    dagop,
    error,
    mdiff,
)
from ..interfaces import repository
from ..revlogutils import sidedata as sidedatamod
from ..utils import hashutil

_nullhash = hashutil.sha1(sha1nodeconstants.nullid)

# revision data contains extra metadata not part of the official digest
# Only used in changegroup >= v4.
CG_FLAG_SIDEDATA = 1


def hashrevisionsha1(text, p1, p2):
    """Compute the SHA-1 for revision data and its parents.

    This hash combines both the current file contents and its history
    in a manner that makes it easy to distinguish nodes with the same
    content in the revision graph.
    """
    # As of now, if one of the parent node is null, p2 is null
    if p2 == sha1nodeconstants.nullid:
        # deep copy of a hash is faster than creating one
        s = _nullhash.copy()
        s.update(p1)
    else:
        # none of the parent nodes are nullid
        if p1 < p2:
            a = p1
            b = p2
        else:
            a = p2
            b = p1
        s = hashutil.sha1(a)
        s.update(b)
    s.update(text)
    return s.digest()


METADATA_RE = re.compile(META_MARKER)


def parsemeta(text):
    """Parse metadata header from revision data.

    Returns a 2-tuple of (metadata, offset), where both can be None if there
    is no metadata.
    """
    # text can be buffer, so we can't use .startswith or .index
    if text[:META_MARKER_SIZE] != META_MARKER:
        return None, None
    s = METADATA_RE.search(text, META_MARKER_SIZE).start()
    mtext = text[META_MARKER_SIZE:s]
    meta = {}
    for l in mtext.splitlines():
        k, v = l.split(b': ', 1)
        meta[k] = v
    return meta, s + META_MARKER_SIZE


def packmeta(meta, text):
    """Add metadata to fulltext to produce revision text."""
    keys = sorted(meta)
    pieces = [META_MARKER]
    pieces.extend(b'%s: %s\n' % (k, meta[k]) for k in keys)
    pieces.append(META_MARKER)
    pieces.append(text)
    return b''.join(pieces)


def iscensoredtext(text):
    meta = parsemeta(text)[0]
    return meta and b'censored' in meta


def filtermetadata(text):
    """Extract just the revision data from source text.

    Returns ``text`` unless it has a metadata header, in which case we return
    a new buffer without hte metadata.
    """
    if not text.startswith(META_MARKER):
        return text

    offset = text.index(META_MARKER, 2)
    return text[offset + META_MARKER_SIZE :]


def filedataequivalent(store, node, filedata):
    """Determines whether file data is equivalent to a stored node.

    Returns True if the passed file data would hash to the same value
    as a stored revision and False otherwise.

    When a stored revision is censored, filedata must be empty to have
    equivalence.

    When a stored revision has copy metadata, it is ignored as part
    of the compare.
    """

    if filedata.startswith(META_MARKER):
        revisiontext = META_MARKER + META_MARKER + filedata
    else:
        revisiontext = filedata

    p1, p2 = store.parents(node)

    computednode = hashrevisionsha1(revisiontext, p1, p2)

    if computednode == node:
        return True

    # Censored files compare against the empty file.
    if store.iscensored(store.rev(node)):
        return filedata == b''

    # metadata (like renaming) alter the hash, so we need to compare the actual
    # content.
    #
    # XXX when checking metadata is cheap we could skip computing the hash
    if store.has_meta(node):
        return store.read(node) == filedata

    return False


def iterrevs(storelen, start=0, stop=None):
    """Iterate over revision numbers in a store."""
    step = 1

    if stop is not None:
        if start > stop:
            step = -1
        stop += step
        if stop > storelen:
            stop = storelen
    else:
        stop = storelen

    return range(start, stop, step)


def fileidlookup(store, fileid, identifier):
    """Resolve the file node for a value.

    ``store`` is an object implementing the ``ifileindex`` interface.

    ``fileid`` can be:

    * A binary node of appropiate size (e.g. 20/32 Bytes).
    * An integer revision number
    * A hex node of appropiate size (e.g. 40/64 Bytes).
    * A bytes that can be parsed as an integer representing a revision number.

    ``identifier`` is used to populate ``error.LookupError`` with an identifier
    for the store.

    Raises ``error.LookupError`` on failure.
    """
    if isinstance(fileid, int):
        try:
            return store.node(fileid)
        except IndexError:
            raise error.LookupError(
                b'%d' % fileid, identifier, _(b'no match found')
            )

    if len(fileid) == len(store.nullid):
        try:
            store.rev(fileid)
            return fileid
        except error.LookupError:
            pass

    if len(fileid) == 2 * len(store.nullid):
        try:
            rawnode = bin(fileid)
            store.rev(rawnode)
            return rawnode
        except TypeError:
            pass

    try:
        rev = int(fileid)

        if b'%d' % rev != fileid:
            raise ValueError

        try:
            return store.node(rev)
        except (IndexError, TypeError):
            pass
    except (ValueError, OverflowError):
        pass

    raise error.LookupError(fileid, identifier, _(b'no match found'))


def resolvestripinfo(minlinkrev, tiprev, headrevs, linkrevfn, parentrevsfn):
    """Resolve information needed to strip revisions.

    Finds the minimum revision number that must be stripped in order to
    strip ``minlinkrev``.

    Returns a 2-tuple of the minimum revision number to do that and a set
    of all revision numbers that have linkrevs that would be broken
    by that strip.

    ``tiprev`` is the current tip-most revision. It is ``len(store) - 1``.
    ``headrevs`` is an iterable of head revisions.
    ``linkrevfn`` is a callable that receives a revision and returns a linked
    revision.
    ``parentrevsfn`` is a callable that receives a revision number and returns
    an iterable of its parent revision numbers.
    """
    brokenrevs = set()
    strippoint = tiprev + 1

    heads = {}
    futurelargelinkrevs = set()
    for head in headrevs:
        headlinkrev = linkrevfn(head)
        heads[head] = headlinkrev
        if headlinkrev >= minlinkrev:
            futurelargelinkrevs.add(headlinkrev)

    # This algorithm involves walking down the rev graph, starting at the
    # heads. Since the revs are topologically sorted according to linkrev,
    # once all head linkrevs are below the minlink, we know there are
    # no more revs that could have a linkrev greater than minlink.
    # So we can stop walking.
    while futurelargelinkrevs:
        strippoint -= 1
        linkrev = heads.pop(strippoint)

        if linkrev < minlinkrev:
            brokenrevs.add(strippoint)
        else:
            futurelargelinkrevs.remove(linkrev)

        for p in parentrevsfn(strippoint):
            if p != nullrev:
                plinkrev = linkrevfn(p)
                heads[p] = plinkrev
                if plinkrev >= minlinkrev:
                    futurelargelinkrevs.add(plinkrev)

    return strippoint, brokenrevs


def emitrevisions(
    store,
    nodes,
    nodesorder,
    resultcls,
    deltaparentfn=None,
    candeltafn=None,
    rawsizefn=None,
    revdifffn=None,
    flagsfn=None,
    deltamode=repository.CG_DELTAMODE_STD,
    revisiondata=False,
    assumehaveparentrevisions=False,
    sidedata_helpers=None,
    debug_info=None,
    snap_lvl_fn=None,
) -> Iterator[RevisionDeltaT]:
    """Generic implementation of ifiledata.emitrevisions().

    Emitting revision data is subtly complex. This function attempts to
    encapsulate all the logic for doing so in a backend-agnostic way.

    ``store``
       Object conforming to ``ifilestorage`` interface.

    ``nodes``
       List of revision nodes whose data to emit.

    ``resultcls``
       A type implementing the ``irevisiondelta`` interface that will be
       constructed and returned.

    ``deltaparentfn`` (optional)
       Callable receiving a revision number and returning the revision number
       of a revision that the internal delta is stored against. This delta
       will be preferred over computing a new arbitrary delta.

       If not defined, a delta will always be computed from raw revision
       data.

    ``candeltafn`` (optional)
       Callable receiving a pair of revision numbers that returns a bool
       indicating whether a delta between them can be produced.

       If not defined, it is assumed that any two revisions can delta with
       each other.

    ``rawsizefn`` (optional)
       Callable receiving a revision number and returning the length of the
       ``store.rawdata(rev)``.

       If not defined, ``len(store.rawdata(rev))`` will be called.

    ``revdifffn`` (optional)
       Callable receiving a pair of revision numbers that returns a delta
       between them.

       If not defined, a delta will be computed by invoking mdiff code
       on ``store.revision()`` results.

       Defining this function allows a precomputed or stored delta to be
       used without having to compute on.

    ``flagsfn`` (optional)
       Callable receiving a revision number and returns the integer flags
       value for it. If not defined, flags value will be 0.

    ``deltamode``
       constaint on delta to be sent:
       * CG_DELTAMODE_STD  - normal mode, try to reuse storage deltas,
       * CG_DELTAMODE_PREV - only delta against "prev",
       * CG_DELTAMODE_FULL - only issue full snapshot.

       Whether to send fulltext revisions instead of deltas, if allowed.

    ``nodesorder``
    ``revisiondata``
    ``assumehaveparentrevisions``
    ``sidedata_helpers`` (optional)
        If not None, means that sidedata should be included.
        See `revlogutil.sidedata.get_sidedata_helpers`.

    ``debug_info`
        An optionnal dictionnary to gather information about the bundling
        process (if present, see config: debug.bundling.stats.
    """

    fnode = store.node
    frev = store.rev
    parents = store.parentrevs

    if nodesorder == b'nodes':
        revs = [frev(n) for n in nodes]
    elif nodesorder == b'linear':
        revs = {frev(n) for n in nodes}
        revs = dagop.linearize(revs, store.parentrevs)
    else:  # storage and default
        revs = sorted(frev(n) for n in nodes)

    prevrev = None

    if deltamode == repository.CG_DELTAMODE_PREV or assumehaveparentrevisions:
        prevrev = parents(revs[0])[0]

    # Sets of revs available to delta against.
    emitted = set()
    available = set()
    if assumehaveparentrevisions:
        common_heads = {p for r in revs for p in parents(r)}
        common_heads.difference_update(revs)
        available = store.ancestors(common_heads, inclusive=True)

    def is_usable_base(rev):
        """Is a delta against this revision usable over the wire"""
        if rev == nullrev:
            return False
        return rev in emitted or rev in available

    for rev in revs:
        if rev == nullrev:
            continue

        debug_delta_source = None
        if debug_info is not None:
            debug_info['revision-total'] += 1

        node = fnode(rev)
        p1rev, p2rev = parents(rev)

        if debug_info is not None:
            if p1rev != p2rev and p1rev != nullrev and p2rev != nullrev:
                debug_info['merge-total'] += 1

        if deltaparentfn:
            deltaparentrev = deltaparentfn(rev)
            if debug_info is not None:
                if deltaparentrev == nullrev:
                    debug_info['available-full'] += 1
                else:
                    debug_info['available-delta'] += 1

        else:
            deltaparentrev = nullrev

        # Forced delta against previous mode.
        if deltamode == repository.CG_DELTAMODE_PREV:
            if debug_info is not None:
                debug_delta_source = "prev"
            baserev = prevrev

        # We're instructed to send fulltext. Honor that.
        elif deltamode == repository.CG_DELTAMODE_FULL:
            if debug_info is not None:
                debug_delta_source = "full"
            baserev = nullrev
        # We're instructed to use p1. Honor that
        elif deltamode == repository.CG_DELTAMODE_P1:
            if debug_info is not None:
                debug_delta_source = "p1"
            baserev = p1rev

        # There is a delta in storage. We try to use that because it
        # amounts to effectively copying data from storage and is
        # therefore the fastest.
        elif is_usable_base(deltaparentrev):
            if debug_info is not None:
                debug_delta_source = "storage"
            baserev = deltaparentrev
        elif deltaparentrev == nullrev:
            if debug_info is not None:
                debug_delta_source = "storage"
            baserev = deltaparentrev
        else:
            if deltaparentrev != nullrev and debug_info is not None:
                debug_info['denied-base-not-available'] += 1
            # No guarantee the receiver has the delta parent, or Storage has a
            # fulltext revision.
            #
            # We compute a delta on the fly to send over the wire.
            #
            # We start with a try against p1, which in the common case should
            # be close to this revision content.
            #
            # note: we could optimize between p1 and p2 in merges cases.
            if is_usable_base(p1rev):
                if debug_info is not None:
                    debug_delta_source = "p1"
                baserev = p1rev
            # if p1 was not an option, try p2
            elif is_usable_base(p2rev):
                if debug_info is not None:
                    debug_delta_source = "p2"
                baserev = p2rev
            # Send delta against prev in despair
            #
            # using the closest available ancestors first might be better?
            elif prevrev is not None:
                if debug_info is not None:
                    debug_delta_source = "prev"
                baserev = prevrev
            else:
                if debug_info is not None:
                    debug_delta_source = "full"
                baserev = nullrev

        # But we can't actually use our chosen delta base for whatever
        # reason. Reset to fulltext.
        if (
            baserev != nullrev
            and candeltafn is not None
            and not candeltafn(baserev, rev)
        ):
            if debug_info is not None:
                debug_delta_source = "full"
                debug_info['denied-delta-candeltafn'] += 1
            baserev = nullrev

        revision = None
        delta = None
        baserevisionsize = None

        if revisiondata:
            if store.iscensored(baserev) or store.iscensored(rev):
                try:
                    revision = store.rawdata(node)
                except error.CensoredNodeError as e:
                    if debug_info is not None:
                        debug_delta_source = "full"
                        debug_info['denied-delta-not-available'] += 1
                    revision = e.tombstone

                if baserev != nullrev:
                    if rawsizefn:
                        baserevisionsize = rawsizefn(baserev)
                    else:
                        baserevisionsize = len(store.rawdata(baserev))

            elif (
                baserev == nullrev and deltamode != repository.CG_DELTAMODE_PREV
            ):
                if debug_info is not None:
                    debug_info['computed-delta'] += 1  # close enough
                    debug_info['delta-full'] += 1
                revision = store.rawdata(node)
                emitted.add(rev)
            else:
                if revdifffn:
                    if debug_info is not None:
                        if debug_delta_source == "full":
                            debug_info['computed-delta'] += 1
                            debug_info['delta-full'] += 1
                        elif debug_delta_source == "prev":
                            debug_info['computed-delta'] += 1
                            debug_info['delta-against-prev'] += 1
                        elif debug_delta_source == "p1":
                            debug_info['computed-delta'] += 1
                            debug_info['delta-against-p1'] += 1
                        elif debug_delta_source == "storage":
                            debug_info['reused-storage-delta'] += 1
                        else:
                            assert False, 'unreachable'

                    delta = revdifffn(baserev, rev)
                else:
                    if debug_info is not None:
                        if debug_delta_source == "full":
                            debug_info['computed-delta'] += 1
                            debug_info['delta-full'] += 1
                        elif debug_delta_source == "prev":
                            debug_info['computed-delta'] += 1
                            debug_info['delta-against-prev'] += 1
                        elif debug_delta_source == "p1":
                            debug_info['computed-delta'] += 1
                            debug_info['delta-against-p1'] += 1
                        elif debug_delta_source == "storage":
                            # seem quite unlikelry to happens
                            debug_info['computed-delta'] += 1
                            debug_info['reused-storage-delta'] += 1
                        else:
                            assert False, 'unreachable'
                    delta = mdiff.textdiff(
                        store.rawdata(baserev), store.rawdata(rev)
                    )

                emitted.add(rev)

        serialized_sidedata = None
        sidedata_flags = (0, 0)
        if sidedata_helpers:
            try:
                old_sidedata = store.sidedata(rev)
            except error.CensoredNodeError:
                # skip any potential sidedata of the censored revision
                sidedata = {}
            else:
                sidedata, sidedata_flags = sidedatamod.run_sidedata_helpers(
                    store=store,
                    sidedata_helpers=sidedata_helpers,
                    sidedata=old_sidedata,
                    rev=rev,
                )
            if sidedata:
                serialized_sidedata = sidedatamod.serialize_sidedata(sidedata)

        flags = flagsfn(rev) if flagsfn else 0
        protocol_flags = 0
        if serialized_sidedata:
            # Advertise that sidedata exists to the other side
            protocol_flags |= CG_FLAG_SIDEDATA
            # Computers and removers can return flags to add and/or remove
            flags = flags | sidedata_flags[0] & ~sidedata_flags[1]

        snap_lvl = None
        if baserev == nullrev:
            snap_lvl = 0
        elif snap_lvl is not None:
            if baserev == deltaparentrev:
                snap_lvl = snap_lvl_fn(rev)
            else:
                # we recomputed it, so it is not a snapshot
                snap_lvl = -1

        yield resultcls(
            node=node,
            p1node=fnode(p1rev),
            p2node=fnode(p2rev),
            basenode=fnode(baserev),
            flags=flags,
            baserevisionsize=baserevisionsize,
            revision=revision,
            delta=delta,
            sidedata=serialized_sidedata,
            protocol_flags=protocol_flags,
            snapshot_level=snap_lvl,
        )

        prevrev = rev


def deltaiscensored(delta, baserev, baselenfn):
    """Determine if a delta represents censored revision data.

    ``baserev`` is the base revision this delta is encoded against.
    ``baselenfn`` is a callable receiving a revision number that resolves the
    length of the revision fulltext.

    Returns a bool indicating if the result of the delta represents a censored
    revision.
    """
    # Fragile heuristic: unless new file meta keys are added alphabetically
    # preceding "censored", all censored revisions are prefixed by
    # "\1\ncensored:". A delta producing such a censored revision must be a
    # full-replacement delta, so we inspect the first and only patch in the
    # delta for this prefix.
    hlen = struct.calcsize(b">lll")
    if len(delta) <= hlen:
        return False

    oldlen = baselenfn(baserev)
    newlen = len(delta) - hlen
    if delta[:hlen] != mdiff.replacediffheader(oldlen, newlen):
        return False

    add = b"\1\ncensored:"
    addlen = len(add)
    return newlen >= addlen and delta[hlen : hlen + addlen] == add
