# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

"""Provide enhancements to hg.mozilla.org

Config Options
==============

hgmo.replacebookmarks
   When set, `hg pull` and other bookmark application operations will replace
   local bookmarks with incoming bookmarks instead of doing the more
   complicated default behavior, which includes creating diverged bookmarks.

hgmo.convertsource
   When set and a changeset has a ``convert_revision`` extra data attribute,
   the changeset template data will contain a link to the source revision it
   was converted from.

   Value is a relative or absolute path to the repo. e.g.
   ``/mozilla-central``.
"""

import copy
import ipaddress
import json
import os
import types

from typing import Optional, Union

from mercurial.i18n import _
from mercurial.node import bin
from mercurial.utils import (
    dateutil,
)
from mercurial import (
    bookmarks,
    commands,
    configitems,
    encoding,
    error,
    exchange,
    extensions,
    hg,
    phases,
    pycompat,
    registrar,
    revset,
    scmutil,
    templatefilters,
    templateutil,
    wireprotov1server,
)
from mercurial.hgweb import (
    webcommands,
    webutil,
)
from mercurial.hgweb.common import (
    ErrorResponse,
    HTTP_NOT_FOUND,
)

OUR_DIR = os.path.dirname(__file__)
ROOT = os.path.normpath(os.path.join(OUR_DIR, "..", ".."))
with open(os.path.join(OUR_DIR, "..", "bootstrap.py")) as f:
    exec(f.read())

import mozautomation.commitparser as commitparser
from mozhg.util import (
    import_module,
    repo_owner,
    get_backoutbynode,
)

from mercurial.wireprotoserver import httpv1protocolhandler as webproto

minimumhgversion = b"4.8"
testedwith = b"4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 5.9"

cmdtable = {}

command = registrar.command(cmdtable)
revsetpredicate = registrar.revsetpredicate()

configtable = {}
configitem = registrar.configitem(configtable)

configitem(b"mozilla", b"treeherder_repo", default=configitems.dynamicdefault)
configitem(b"mozilla", b"treestatus_base_url", default=configitems.dynamicdefault)
configitem(b"mozilla", b"git_repo_url", default=configitems.dynamicdefault)
configitem(
    b"hgmo", b"automationrelevantdraftancestors", default=configitems.dynamicdefault
)
configitem(b"hgmo", b"backoutsearchlimit", default=configitems.dynamicdefault)
configitem(b"hgmo", b"convertsource", default=None)
configitem(b"hgmo", b"headdivergencemaxnodes", default=configitems.dynamicdefault)
configitem(b"hgmo", b"mozippath", default=None)
configitem(b"hgmo", b"awsippath", default=None)
configitem(b"hgmo", b"gcpippath", default=None)
configitem(b"hgmo", b"azureippath", default=None)
configitem(b"hgmo", b"pullclonebundlesmanifest", default=configitems.dynamicdefault)
configitem(b"hgmo", b"replacebookmarks", default=configitems.dynamicdefault)
configitem(b"hgmo", b"instance-data-path", default=None)


@templatefilters.templatefilter(b"mozlink")
def mozlink(text):
    """Any text. Hyperlink to Bugzilla and other detected things."""
    return commitparser.add_hyperlinks(text)


def addmetadata(repo, ctx, d, onlycheap=False):
    """Add changeset metadata for hgweb templates."""
    description = encoding.fromlocal(ctx.description())

    def bugsgen(_context):
        """Generator for bugs list"""
        for bug in commitparser.parse_bugs(description):
            bug = pycompat.bytestr(bug)
            yield {
                b"no": bug,
                b"url": b"https://bugzilla.mozilla.org/show_bug.cgi?id=%s" % bug,
            }

    def reviewersgen(_context):
        """Generator for reviewers list"""
        for reviewer in commitparser.parse_reviewers(description):
            yield {
                b"name": reviewer,
                b"revset": b"reviewer(%s)" % reviewer,
            }

    def backoutsgen(_context):
        """Generator for backouts list"""
        backouts = commitparser.parse_backouts(description)
        if backouts:
            for node in backouts[0]:
                try:
                    bctx = scmutil.revsymbol(repo, node)
                    yield {b"node": bctx.hex()}
                except error.RepoLookupError:
                    pass

    d[b"reviewers"] = templateutil.mappinggenerator(reviewersgen)
    d[b"bugs"] = templateutil.mappinggenerator(bugsgen)
    d[b"backsoutnodes"] = templateutil.mappinggenerator(backoutsgen)

    # Repositories can define which TreeHerder repository they are associated
    # with.
    treeherder = repo.ui.config(b"mozilla", b"treeherder_repo")
    if treeherder:
        d[b"treeherderrepourl"] = (
            b"https://treeherder.mozilla.org/jobs?repo=%s" % treeherder
        )
        d[b"treeherderrepo"] = treeherder

        push = repo.pushlog.pushfromchangeset(ctx)
        # Don't print Perfherder link on non-publishing repos (like Try)
        # because the previous push likely has nothing to do with this
        # push.
        # Changeset on autoland are in the phase 'draft' until they get merged
        # to mozilla-central.
        if (
            push
            and push.nodes
            and (
                repo.ui.configbool(b"phases", b"publish", True)
                or treeherder == b"autoland"
            )
        ):
            lastpushhead = repo[push.nodes[0]].hex()
            d[b"perfherderurl"] = (
                b"https://treeherder.mozilla.org/perf.html#/compare?"
                b"originalProject=%s&"
                b"originalRevision=%s&"
                b"newProject=%s&"
                b"newRevision=%s"
            ) % (treeherder, push.nodes[-1], treeherder, lastpushhead)

    # If this changeset was converted from another one and we know which repo
    # it came from, add that metadata.
    convertrevision = ctx.extra().get(b"convert_revision")
    if convertrevision:
        sourcerepo = repo.ui.config(b"hgmo", b"convertsource")
        if sourcerepo:
            d[b"convertsourcepath"] = sourcerepo
            d[b"convertsourcenode"] = convertrevision

    # Did the push to this repo included extra data about the automated landing
    # system used?
    # We omit the key if it has no value so that the 'json' filter function in
    # the map file will return null for the key's value.  Otherwise the filter
    # will return a JSON empty string, even for False-y values like None.
    landingsystem = ctx.extra().get(b"moz-landing-system")
    if landingsystem:
        d[b"landingsystem"] = landingsystem

    git_commit = ctx.extra().get(b"git_commit")
    if git_commit:
        d[b"git_commit"] = git_commit

        git_repo_url = repo.ui.config(
            b"mozilla",
            b"git_repo_url",
            b"https://github.com/mozilla-firefox/firefox",
        )
        if git_repo_url:
            d[b"git_repo_url"] = git_repo_url

    if onlycheap:
        return

    # Obtain the Gecko/app version/milestone.
    #
    # We could probably only do this if the repo is a known app repo (by
    # looking at the initial changeset). But, path based lookup is relatively
    # fast, so just do it. However, we need this in the "onlycheap"
    # section because resolving manifests is relatively slow and resolving
    # several on changelist pages may add seconds to page load times.
    try:
        fctx = repo.filectx(b"config/milestone.txt", changeid=ctx.node())
        lines = fctx.data().splitlines()
        lines = [l for l in lines if not l.startswith(b"#") and l.strip()]

        if lines:
            d[b"milestone"] = lines[0].strip()
    except error.LookupError:
        pass

    backout_node = get_backoutbynode(b"hgmo", repo, ctx)
    if backout_node is not None:
        d[b"backedoutbynode"] = backout_node


def changesetentry(orig, web, ctx):
    """Wraps webutil.changesetentry to provide extra metadata."""
    d = orig(web, ctx)

    d = pycompat.byteskwargs(d)

    addmetadata(web.repo, ctx, d)

    return pycompat.strkwargs(d)


def changelistentry(orig, web, ctx):
    """Wraps webutil.changelistentry to provide extra metadata."""
    d = orig(web, ctx)

    addmetadata(web.repo, ctx, d, onlycheap=True)
    return d


def infowebcommand(web):
    """Get information about the specified changeset(s).

    This is a legacy API from before the days of Mercurial's built-in JSON
    API. It is used by unidentified parts of automation. Over time these
    consumers should transition to the modern/native JSON API.
    """
    req = web.req

    if b"node" not in req.qsparams:
        return web.sendtemplate(b"error", error=b"missing parameter 'node'")

    nodes = req.qsparams.getall(b"node")

    csets = []
    for node in nodes:
        ctx = scmutil.revsymbol(web.repo, node)
        csets.append(
            {
                b"rev": ctx.rev(),
                b"node": ctx.hex(),
                b"user": ctx.user(),
                b"date": ctx.date(),
                b"description": ctx.description(),
                b"branch": ctx.branch(),
                b"tags": ctx.tags(),
                b"parents": [p.hex() for p in ctx.parents()],
                b"children": [c.hex() for c in ctx.children()],
                b"files": ctx.files(),
            }
        )

    return web.sendtemplate(b"info", csets=templateutil.mappinglist(csets))


def headdivergencewebcommand(web):
    """Get information about divergence between this repo and a changeset.

    This API was invented to be used by MozReview to obtain information about
    how a repository/head has progressed/diverged since a commit was submitted
    for review.

    It is assumed that this is running on the canonical/mainline repository.
    Changes in other repositories must be rebased onto or merged into
    this repository.
    """
    req = web.req

    if b"node" not in req.qsparams:
        return web.sendtemplate(b"error", error=b"missing parameter 'node'")

    repo = web.repo

    paths = set(req.qsparams.getall(b"p"))
    basectx = scmutil.revsymbol(repo, req.qsparams[b"node"])

    # Find how much this repo has changed since the requested changeset.
    # Our heuristic is to find the descendant head with the highest revision
    # number. Most (all?) repositories we care about for this API should have
    # a single head per branch. And we assume the newest descendant head is
    # the one we care about the most. We don't care about branches because
    # if a descendant is on different branch, then the repo has likely
    # transitioned to said branch.
    #
    # If we ever consolidate Firefox repositories, we'll need to reconsider
    # this logic, especially if release repos with their extra branches/heads
    # are involved.

    # Specifying "start" only gives heads that are descendants of "start."
    headnodes = repo.changelog.heads(start=basectx.node())

    headrev = max(repo[n].rev() for n in headnodes)
    headnode = repo[headrev].node()

    betweennodes, outroots, outheads = repo.changelog.nodesbetween(
        [basectx.node()], [headnode]
    )

    # nodesbetween returns base node. So prune.
    betweennodes = betweennodes[1:]

    commitsbehind = len(betweennodes)

    # If rev 0 or a really old revision is passed in, we could DoS the server
    # by having to iterate nearly all changesets. Establish a cap for number
    # of changesets to examine.
    maxnodes = repo.ui.configint(b"hgmo", b"headdivergencemaxnodes", 1000)
    filemergesignored = False
    if len(betweennodes) > maxnodes:
        betweennodes = []
        filemergesignored = True

    filemerges = {}
    for node in betweennodes:
        ctx = repo[node]

        files = set(ctx.files())
        for p in files & paths:
            filemerges.setdefault(p, []).append(ctx.hex())

    return web.sendtemplate(
        b"headdivergence",
        commitsbehind=commitsbehind,
        filemerges=filemerges,
        filemergesignored=filemergesignored,
    )


def automationrelevancewebcommand(web):
    req = web.req

    if b"node" not in req.qsparams:
        return web.sendtemplate(b"error", error=b"missing parameter 'node'")

    repo = web.repo
    deletefields = {
        b"bookmarks",
        b"branch",
        b"branches",
        b"changelogtag",
        b"child",
        b"ctx",
        b"inbranch",
        b"instabilities",
        b"obsolete",
        b"parent",
        b"succsandmarkers",
        b"tags",
        b"whyunstable",
    }

    csets = []
    # Query an unfiltered repo because sometimes automation wants to run against
    # changesets that have since become hidden. The response exposes whether the
    # requested node is visible, so consumers can make intelligent decisions
    # about what to do if the changeset isn't visible.
    urepo = repo.unfiltered()

    revs = list(urepo.revs(b"automationrelevant(%r)", req.qsparams[b"node"]))

    # The pushlog extensions wraps webutil.commonentry and the way it is called
    # means pushlog opens a SQLite connection on every call. This is inefficient.
    # So we pre load and cache data for pushlog entries we care about.
    cl = urepo.changelog
    nodes = [cl.node(rev) for rev in revs]

    with repo.unfiltered().pushlog.cache_data_for_nodes(nodes):
        for rev in revs:
            ctx = urepo[rev]
            entry = webutil.changelistentry(web, ctx)

            if req.qsparams.get(b"backouts"):
                backout_node = get_backoutbynode(b"hgmo", repo, ctx)
                if backout_node is not None:
                    entry[b"backedoutby"] = backout_node

            # The pushnodes list is redundant with data from other changesets.
            # The amount of redundant data for pushes containing N>100
            # changesets can add up to megabytes in size.
            try:
                del entry[b"pushnodes"]
            except KeyError:
                pass

            # Some items in changelistentry are generators, which json.dumps()
            # can't handle. So we expand them.
            entrycopy = copy.copy(entry)
            for k, v in entrycopy.items():
                # "files" is a generator that attempts to call a template.
                # Don't even bother and just repopulate it.
                if k == b"files":
                    entry[b"files"] = sorted(ctx.files())
                elif k == b"allparents":
                    iterator = v(None, None).itermaps(ctx)

                    entry[b"parents"] = [p[b"node"] for p in iterator]
                    del entry[b"allparents"]
                # These aren't interesting to us, so prune them. The
                # original impetus for this was because "changelogtag"
                # isn't part of the json template and adding it is non-trivial.
                elif k in deletefields:
                    del entry[k]
                elif isinstance(v, types.GeneratorType):
                    entry[k] = list(v)

            csets.append(entry)

    # Advertise whether the requested revision is visible (non-obsolete).
    if csets:
        visible = csets[-1][b"node"] in repo
    else:
        visible = None

    data = {
        b"changesets": templateutil.mappinglist(csets),
        b"visible": visible,
    }

    return web.sendtemplate(b"automationrelevance", **pycompat.strkwargs(data))


def push_changed_files_webcommand(web):
    """Retrieve the list of files modified in a push."""
    req = web.req

    if b"node" not in req.qsparams:
        return web.sendtemplate(b"error", error=b"missing parameter 'node'")

    repo = web.repo

    # Query an unfiltered repo because sometimes automation wants to run against
    # changesets that have since become hidden. The response exposes whether the
    # requested node is visible, so consumers can make intelligent decisions
    # about what to do if the changeset isn't visible.
    urepo = repo.unfiltered()

    revs = list(urepo.revs(b"automationrelevant(%r)", req.qsparams[b"node"]))

    # The pushlog extensions wraps webutil.commonentry and the way it is called
    # means pushlog opens a SQLite connection on every call. This is inefficient.
    # So we pre load and cache data for pushlog entries we care about.
    cl = urepo.changelog
    nodes = [cl.node(rev) for rev in revs]

    files = []
    with repo.unfiltered().pushlog.cache_data_for_nodes(nodes):
        for rev in revs:
            ctx = urepo[rev]

            files.extend(ctx.files())

    # De-duplicate and sort the files.
    files = sorted(list(set(files)))

    return web.sendtemplate(b"pushchangedfiles", files=files)


def isancestorwebcommand(web):
    """Determine whether a changeset is an ancestor of another."""
    req = web.req
    for k in (b"head", b"node"):
        if k not in req.qsparams:
            raise ErrorResponse(HTTP_NOT_FOUND, b"missing parameter '%s'" % k)

    head = req.qsparams[b"head"]
    node = req.qsparams[b"node"]

    try:
        headctx = scmutil.revsingle(web.repo, head)
    except error.RepoLookupError:
        raise ErrorResponse(HTTP_NOT_FOUND, b"unknown head revision %s" % head)

    try:
        testctx = scmutil.revsingle(web.repo, node)
    except error.RepoLookupError:
        raise ErrorResponse(HTTP_NOT_FOUND, b"unknown node revision %s" % node)

    testrev = testctx.rev()
    isancestor = False

    for rev in web.repo.changelog.ancestors([headctx.rev()], inclusive=True):
        if rev == testrev:
            isancestor = True
            break

    return web.sendtemplate(
        b"isancestor",
        headnode=headctx.hex(),
        testnode=testctx.hex(),
        isancestor=isancestor,
    )


def repoinfowebcommand(web):
    group_owner = repo_owner(web.repo)
    return web.sendtemplate(
        b"repoinfo", archives=web.archivelist(b"tip"), groupowner=group_owner
    )


@revsetpredicate(b"reviewer(REVIEWER)", safe=True)
def revset_reviewer(repo, subset, x):
    """``reviewer(REVIEWER)``

    Changesets reviewed by a specific person.
    """
    l = revset.getargs(x, 1, 1, b"reviewer requires one argument")
    n = encoding.lower(revset.getstring(l[0], b"reviewer requires a string"))

    # Do not use a matcher here because regular expressions are not safe
    # for remote execution and may DoS the server.
    def hasreviewer(r):
        for reviewer in commitparser.parse_reviewers(repo[r].description()):
            if encoding.lower(reviewer) == n:
                return True

        return False

    return subset.filter(hasreviewer)


@revsetpredicate(b"automationrelevant(set)", safe=True)
def revset_automationrelevant(repo, subset, x):
    """``automationrelevant(set)``

    Changesets relevant to scheduling in automation.

    Given a revset that evaluates to a single revision, will return that
    revision and any ancestors that are part of the same push unioned with
    non-public ancestors.
    """
    s = revset.getset(repo, revset.fullreposet(repo), x)
    if len(s) > 1:
        raise error.Abort(b"can only evaluate single changeset")

    ctx = repo[s.first()]
    revs = {ctx.rev()}

    drafts = repo.ui.configbool(b"hgmo", b"automationrelevantdraftancestors", False)

    # The pushlog is used to get revisions part of the same push as
    # the requested revision.
    pushlog = getattr(repo, "pushlog", None)
    if pushlog:
        push = repo.pushlog.pushfromchangeset(ctx)
        for n in push.nodes:
            pctx = repo[n]
            if pctx.rev() <= ctx.rev() and (not drafts or pctx.phase() > phases.draft):
                revs.add(pctx.rev())

    # Union with non-public ancestors if configured. By default, we only
    # consider changesets from the push. However, on special repositories
    # (namely Try), we want changesets from previous pushes to come into
    # play too.
    if drafts:
        for rev in repo.revs(b"::%d & not public()", ctx.rev()):
            revs.add(rev)

    return subset & revset.baseset(revs)


def bmupdatefromremote(orig, ui, repo, remotemarks, path, trfunc, **kwargs):
    """Custom bookmarks applicator that overwrites with remote state.

    The default bookmarks merging code adds divergence. When replicating from
    master to mirror, a bookmark force push could result in divergence on the
    mirror during `hg pull` operations. We install our own code that replaces
    the complicated merging algorithm with a simple "remote wins" version.
    """
    if not ui.configbool(b"hgmo", b"replacebookmarks", False):
        return orig(ui, repo, remotemarks, path, trfunc, **kwargs)

    localmarks = repo._bookmarks

    if localmarks == remotemarks:
        return

    ui.status(b"remote bookmarks changed; overwriting\n")
    localmarks.clear()
    for bm, node in remotemarks.items():
        localmarks[bm] = bin(node)
    tr = trfunc()
    localmarks.recordchange(tr)


def servehgmo(orig, ui, repo, *args, **kwargs):
    """Wraps commands.serve to provide --hgmo flag."""
    if kwargs.get("hgmo", False):
        kwargs["style"] = b"gitweb_mozilla"
        kwargs["templates"] = os.path.join(pycompat.bytestr(ROOT), b"hgtemplates")

        # ui.copy() is funky. Unless we do this, extension settings get
        # lost when calling hg.repository().
        ui = ui.copy()

        def setconfig(name, paths):
            ui.setconfig(
                b"extensions",
                name,
                os.path.join(pycompat.bytestr(ROOT), b"hgext", *paths),
            )

        setconfig(b"firefoxreleases", [b"firefoxreleases"])
        setconfig(b"pushlog", [b"pushlog"])
        setconfig(b"pushlog-feed", [b"pushlog", b"feed.py"])

        ui.setconfig(b"web", b"logoimg", b"moz-logo-bw-rgb.svg")

        # Since new extensions may have been flagged for loading, we need
        # to obtain a new repo instance to a) trigger loading of these
        # extensions b) force extensions' reposetup function to run.
        repo = hg.repository(ui, repo.root)

    return orig(ui, repo, *args, **kwargs)


def pull(orig, repo, remote, *args, **kwargs):
    """Wraps exchange.pull to fetch the remote clonebundles.manifest."""
    res = orig(repo, remote, *args, **kwargs)

    if not repo.ui.configbool(b"hgmo", b"pullclonebundlesmanifest", False):
        return res

    has_clonebundles = remote.capable(b"clonebundles")
    if not has_clonebundles:
        if repo.vfs.exists(b"clonebundles.manifest"):
            repo.ui.status(_(b"deleting local clonebundles.manifest\n"))
            repo.vfs.unlink(b"clonebundles.manifest")

    has_cinnabarclone = remote.capable(b"cinnabarclone")
    if not has_cinnabarclone:
        if repo.vfs.exists(b"cinnabar.manifest"):
            repo.ui.status(_(b"deleting local cinnabar.manifest\n"))
            repo.vfs.unlink(b"cinnabar.manifest")

    if has_clonebundles or has_cinnabarclone:
        with repo.wlock():
            if has_clonebundles:
                repo.ui.status(_(b"pulling clonebundles manifest\n"))
                manifest = remote._call(b"clonebundles")
                repo.vfs.write(b"clonebundles.manifest", manifest)
            if has_cinnabarclone:
                repo.ui.status(_(b"pulling cinnabarclone manifest\n"))
                manifest = remote._call(b"cinnabarclone")
                repo.vfs.write(b"cinnabar.manifest", manifest)

    return res


PACKED_V1 = b"BUNDLESPEC=none-packed1"
PACKED_V2 = b"BUNDLESPEC=none-v2;stream=v2"


def stream_clone_key(manifest_entry):
    """Key function to prioritize stream clone bundles,
    preferring newer stream clones over older.
    """
    if PACKED_V2 in manifest_entry:
        return 0
    if PACKED_V1 in manifest_entry:
        return 1
    return 2


def filter_manifest_for_region(manifest, region):
    """Filter a clonebundles manifest by region

    The returned manifest will be sorted to prioritize clone bundles
    for the specified cloud region.
    """
    filtered = [l for l in manifest.data.splitlines() if region in l]
    # No manifest entries for this region.
    if not filtered:
        return manifest

    # We prioritize stream clone bundles to cloud clients because they are
    # the fastest way to clone and we want our automation to be fast.
    filtered = sorted(filtered, key=stream_clone_key)

    # include cdn urls as a fallback (cloud bucket may only have stream bundles)
    cdn = [l for l in manifest.data.splitlines() if b"cdn=true" in l]
    filtered.extend(cdn)

    # We got a match. Write out the filtered manifest (with a trailing newline).
    filtered.append(b"")
    return b"\n".join(filtered)


CLOUD_REGION_MAPPING = {
    "aws": b"ec2region",
    "gce": b"gceregion",
}


def cloud_region_specifier(instance_data):
    """Return the cloud region specifier that corresponds to the given
    instance data object.

    Instance data object format can be found at:
        https://cloudinit.readthedocs.io/en/latest/topics/instancedata.html
    """
    cloud_data_v1 = instance_data["v1"]

    # Some of the mirrors were spun up using an ancient version of
    # cloud-init. In case `cloud_name` isn't available, we should look
    # for `cloud-name`.
    cloud_name = cloud_data_v1.get("cloud_name")
    if not cloud_name:
        cloud_name = cloud_data_v1["cloud-name"]

    return b"%(cloud)s=%(region)s" % {
        b"cloud": CLOUD_REGION_MAPPING[cloud_name],
        b"region": pycompat.bytestr(cloud_data_v1["region"]),
    }


def get_current_azure_region(
    azure_ip_path: bytes, source_ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
) -> Optional[str]:
    """Return the current Azure region if one can be found from the source IP address."""
    with open(azure_ip_path, "rb") as f:
        azure_ips = json.load(f)

    for service in azure_ips["values"]:
        properties = service["properties"]

        # If we can't determine the region for this Azure service, skip it.
        if "region" not in properties or not properties["region"]:
            continue

        for subnet in properties["addressPrefixes"]:
            network = ipaddress.ip_network(subnet)

            if source_ip in network:
                return properties["region"]

    return None


def processbundlesmanifest(orig, repo, proto, *args, **kwargs):
    """Wraps `wireprotov1server.clonebundles` and `wireprotov1server.clonebundles_2`.

    We examine source IP addresses and advertise URLs for the same
    AWS region if the source is in AWS.
    """

    # Call original fxn wireproto.clonebundles
    manifest = orig(repo, proto, *args)

    if not isinstance(proto, webproto):
        return manifest

    # Get path for Mozilla, AWS, GCP network prefixes. Return if missing
    mozpath = repo.ui.config(b"hgmo", b"mozippath")
    awspath = repo.ui.config(b"hgmo", b"awsippath")
    gcppath = repo.ui.config(b"hgmo", b"gcpippath")
    azureippath = repo.ui.config(b"hgmo", b"azureippath")
    if not awspath and not mozpath and not gcppath and not azureippath:
        return manifest

    # Mozilla's load balancers add a X-Cluster-Client-IP header to identify the
    # actual source IP, so prefer it.  And if the request comes through Fastly,
    # prefer the Fastly-Client-IP header it adds so we take into account the
    # actual client.
    sourceip = proto._req.headers.get(
        b"Fastly-Client-IP",
        proto._req.headers.get(
            b"X-CLUSTER-CLIENT-IP", proto._req.rawenv.get(b"REMOTE_ADDR")
        ),
    )

    if not sourceip:
        return manifest
    else:
        try:
            sourceip = ipaddress.ip_address(pycompat.unicode(pycompat.sysstr(sourceip)))
        except ValueError:
            # XXX return 400?
            return manifest

    # If the request originates from a private IP address, and we are running on
    # a cloud instance, we should be serving traffic to private instances in CI.
    # Grab the region from the instance_data.json object and serve the correct
    # manifest accordingly
    instance_data_path = repo.ui.config(b"hgmo", b"instance-data-path")
    if (
        instance_data_path
        and sourceip.is_private
        and os.path.exists(instance_data_path)
    ):
        with open(instance_data_path, "rb") as fh:
            instance_data = json.load(fh)

        return filter_manifest_for_region(
            manifest, cloud_region_specifier(instance_data)
        )

    # If the AWS IP file path is set and some line in the manifest includes an ec2 region,
    # we will check if the request came from AWS to server optimized bundles.
    if awspath and b"ec2region=" in manifest.data:
        try:
            with open(awspath, "rb") as fh:
                awsdata = json.load(fh)

            for ipentry in awsdata["prefixes"]:
                network = ipaddress.IPv4Network(ipentry["ip_prefix"])

                if sourceip not in network:
                    continue

                region = ipentry["region"]

                return filter_manifest_for_region(
                    manifest, b"ec2region=%s" % pycompat.bytestr(region)
                )

        except Exception as e:
            repo.ui.log(b"hgmo", b"exception filtering AWS bundle source IPs: %s\n", e)

    # If the GCP IP file path is set and some line in the manifest includes a GCE region,
    # we will check if the request came from GCP to serve optimized bundles
    if gcppath and b"gceregion=" in manifest.data:
        try:
            with open(gcppath, "rb") as f:
                gcpdata = json.load(f)

            for ipentry in gcpdata["prefixes"]:
                if "ipv4Prefix" in ipentry:
                    network = ipaddress.IPv4Network(ipentry["ipv4Prefix"])
                elif "ipv6Prefix" in ipentry:
                    network = ipaddress.IPv6Network(ipentry["ipv6Prefix"])

                if sourceip not in network:
                    continue

                region = ipentry["scope"]

                return filter_manifest_for_region(
                    manifest, b"gceregion=%s" % pycompat.bytestr(region)
                )

        except Exception as e:
            repo.ui.log(b"hgmo", b"exception filtering GCP bundle source IPs: %s\n", e)

    if azureippath and b"azureregion=" in manifest.data:
        try:
            azure_region = get_current_azure_region(azureippath, sourceip)
            if azure_region:
                return filter_manifest_for_region(
                    manifest, b"azureregion=%s" % pycompat.bytestr(azure_region)
                )
        except Exception as e:
            repo.ui.log(
                b"hgmo", b"exception filtering Azure bundle source IPs: %s\n", e
            )

    # Determine if source IP is in a Mozilla network, as we stream results to those addresses
    if mozpath:
        try:
            with open(mozpath, "r") as fh:
                mozdata = fh.read().splitlines()

            for ipentry in mozdata:
                network = ipaddress.IPv4Network(
                    pycompat.unicode(pycompat.sysstr(ipentry))
                )

                # If the source IP is from a Mozilla network, prioritize stream bundles
                if sourceip in network:
                    origlines = sorted(manifest.data.splitlines(), key=stream_clone_key)
                    origlines.append(b"")
                    return b"\n".join(origlines)

        except Exception as e:
            repo.ui.log(b"hgmo", b"exception filtering bundle source IPs: %s\n", e)
            return manifest

    return manifest


def filelog(orig, web):
    """Wraps webcommands.filelog to provide pushlog metadata to template."""
    req = web.req
    tmpl = web.templater(req)

    # Template wrapper to add pushlog data to entries when the template is
    # evaluated.
    class tmplwrapper(tmpl.__class__):
        def __call__(self, *args, **kwargs):
            for entry in kwargs.get("entries", []):
                push = web.repo.pushlog.pushfromnode(bin(entry[b"node"]))
                if push:
                    entry[b"pushid"] = push.pushid
                    entry[b"pushdate"] = dateutil.makedate(push.when)
                else:
                    entry[b"pushid"] = None
                    entry[b"pushdate"] = None

            return super(tmplwrapper, self).__call__(*args, **kwargs)

    orig_class = tmpl.__class__
    try:
        if hasattr(web.repo, "pushlog"):
            tmpl.__class__ = tmplwrapper

        web.tmpl = tmpl
        return orig(web)
    finally:
        tmpl.__class__ = orig_class


def hgwebfastannotate(orig, req, fctx, ui):
    import hgext.fastannotate.support as fasupport

    diffopts = webutil.difffeatureopts(req, ui, b"annotate")

    return fasupport._doannotate(fctx, diffopts=diffopts)


def extsetup(ui):
    extensions.wrapfunction(exchange, "pull", pull)
    extensions.wrapfunction(webutil, "changesetentry", changesetentry)
    extensions.wrapfunction(webutil, "changelistentry", changelistentry)
    extensions.wrapfunction(bookmarks, "updatefromremote", bmupdatefromremote)
    extensions.wrapfunction(webcommands, "filelog", filelog)

    # Install IP filtering for bundle URLs.

    # Build-in command from core Mercurial.
    extensions.wrapcommand(
        wireprotov1server.commands, b"clonebundles", processbundlesmanifest
    )
    extensions.wrapcommand(
        wireprotov1server.commands, b"clonebundles_manifest", processbundlesmanifest
    )

    entry = extensions.wrapcommand(commands.table, b"serve", servehgmo)
    entry[1].append(
        (b"", b"hgmo", False, b"Run a server configured like hg.mozilla.org")
    )

    webcommands.info = webcommands.webcommand(b"info")(infowebcommand)
    webcommands.headdivergence = webcommands.webcommand(b"headdivergence")(
        headdivergencewebcommand
    )
    webcommands.automationrelevance = webcommands.webcommand(b"automationrelevance")(
        automationrelevancewebcommand
    )
    webcommands.pushchangedfiles = webcommands.webcommand(b"pushchangedfiles")(
        push_changed_files_webcommand
    )
    webcommands.isancestor = webcommands.webcommand(b"isancestor")(isancestorwebcommand)
    webcommands.repoinfo = webcommands.webcommand(b"repoinfo")(repoinfowebcommand)


def reposetup(ui, repo):
    fasupport = import_module("hgext.fastannotate.support")

    if not fasupport:
        return

    # fastannotate in Mercurial 4.8 has buggy hgweb support. We always remove
    # its monkeypatch if present.
    try:
        extensions.unwrapfunction(webutil, "annotate", fasupport._hgwebannotate)
    except ValueError:
        pass

    # And we install our own if fastannotate is enabled.
    try:
        fastannotate = extensions.find(b"fastannotate")
    except KeyError:
        fastannotate = None

    if fastannotate and b"hgweb" in ui.configlist(b"fastannotate", b"modes"):
        # Guard against recursive chaining, since we're in reposetup().
        try:
            extensions.unwrapfunction(webutil, "annotate", hgwebfastannotate)
        except ValueError:
            pass

        extensions.wrapfunction(webutil, "annotate", hgwebfastannotate)
