#!/usr/bin/python3
#
# Copyright (C) 2011, Stefano Rivera <stefanor@ubuntu.com>
#
# Permission to use, copy, modify, and/or distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

import argparse
import sys
from collections import defaultdict

import apt
from distro_info import UbuntuDistroInfo

from ubuntutools import getLogger
from ubuntutools.config import UDTConfig
from ubuntutools.lp.lpapicache import Distribution, Launchpad
from ubuntutools.lp.udtexceptions import PackageNotFoundException
from ubuntutools.question import EditBugReport, YesNoQuestion, confirmation_prompt
from ubuntutools.rdepends import RDependsException, query_rdepends

Logger = getLogger()


class DestinationException(Exception):
    pass


def determine_destinations(source, destination):
    ubuntu_info = UbuntuDistroInfo()
    if destination is None:
        destination = ubuntu_info.lts()

    if source not in ubuntu_info.all:
        raise DestinationException(f"Source release {source} does not exist")
    if destination not in ubuntu_info.all:
        raise DestinationException(f"Destination release {destination} does not exist")
    if destination not in ubuntu_info.supported():
        raise DestinationException(f"Destination release {destination} is not supported")

    found = False
    destinations = []
    support_gap = False
    for release in ubuntu_info.all:
        if release == destination:
            found = True
        if release == source:
            break
        if found:
            if support_gap:
                if ubuntu_info.is_lts(release):
                    support_gap = False
                else:
                    continue
            if release not in ubuntu_info.supported():
                support_gap = True
                continue
            destinations.append(release)

    assert found
    assert len(destinations) > 0

    return destinations


def disclaimer():
    print(
        "Ubuntu's backports are not for fixing bugs in stable releases, "
        "but for bringing new features to older, stable releases.\n"
        "See https://wiki.ubuntu.com/UbuntuBackports for the Ubuntu "
        "Backports policy and processes.\n"
        "See https://wiki.ubuntu.com/StableReleaseUpdates for the process "
        "for fixing bugs in stable releases."
    )
    confirmation_prompt()


def check_existing(package):
    """Search for possible existing bug reports"""
    distro = Distribution("ubuntu")
    srcpkg = distro.getSourcePackage(name=package.getPackageName())

    bugs = srcpkg.searchTasks(
        omit_duplicates=True,
        search_text="[BPO]",
        status=["Incomplete", "New", "Confirmed", "Triaged", "In Progress", "Fix Committed"],
    )
    if not bugs:
        return

    Logger.info(
        "There are existing bug reports that look similar to your "
        "request. Please check before continuing:"
    )

    for bug in sorted([bug_task.bug for bug_task in bugs], key=lambda bug: bug.id):
        Logger.info(" * LP: #%-7i: %s  %s", bug.id, bug.title, bug.web_link)

    confirmation_prompt()


def find_rdepends(releases, published_binaries):
    intermediate = defaultdict(lambda: defaultdict(list))

    # We want to display every pubilshed binary, even if it has no rdepends
    for binpkg in published_binaries:
        intermediate[binpkg]  # pylint: disable=pointless-statement

    for arch in ("any", "source"):
        for release in releases:
            for binpkg in published_binaries:
                try:
                    raw_rdeps = query_rdepends(binpkg, release, arch)
                except RDependsException:
                    # Not published? TODO: Check
                    continue
                for relationship, rdeps in raw_rdeps.items():
                    for rdep in rdeps:
                        # Ignore circular deps:
                        if rdep["Package"] in published_binaries:
                            continue
                        # arch==any queries return Reverse-Build-Deps:
                        if arch == "any" and rdep.get("Architectures", []) == ["source"]:
                            continue
                        intermediate[binpkg][rdep["Package"]].append((release, relationship))

    output = []
    for binpkg, rdeps in intermediate.items():
        output += ["", binpkg, "-" * len(binpkg)]
        for pkg, appearences in rdeps.items():
            output += [f"* {pkg}"]
            for release, relationship in appearences:
                output += [f"  [ ] {release} ({relationship})"]

    found_any = sum(len(rdeps) for rdeps in intermediate.values())
    if found_any:
        output = [
            "Reverse dependencies:",
            "=====================",
            "The following reverse-dependencies need to be tested against the "
            "new version of %(package)s. "
            "For reverse-build-dependencies (-Indep), please test that the "
            "package still builds against the new %(package)s. "
            "For reverse-dependencies, please test that the version of the "
            "package currently in the release still works with the new "
            "%(package)s installed. "
            "Reverse- Recommends, Suggests, and Enhances don't need to be "
            "tested, and are listed for completeness-sake.",
        ] + output
    else:
        output = ["No reverse dependencies"]

    return output


def locate_package(package, distribution):
    archive = Distribution("ubuntu").getArchive()
    try:
        package_spph = archive.getSourcePackage(package, distribution)
        return package_spph
    except PackageNotFoundException as e:
        try:
            apt_pkg = apt.Cache()[package]
        except KeyError:
            Logger.error(str(e))
            sys.exit(1)
        package = apt_pkg.candidate.source_name
        Logger.info(
            "Binary package specified, considering its source package instead: %s", package
        )
    return None


def request_backport(package_spph, source, destinations):
    published_binaries = set()
    for bpph in package_spph.getBinaries():
        published_binaries.add(bpph.getPackageName())

    if not published_binaries:
        Logger.error(
            "%s (%s) has no published binaries in %s. ",
            package_spph.getPackageName(),
            package_spph.getVersion(),
            source,
        )
        Logger.info(
            "Is it stuck in bin-NEW? It can't be backported until "
            "the binaries have been accepted."
        )
        sys.exit(1)

    testing = ["[Testing]", ""]
    for dest in destinations:
        testing += [f" * {dest.capitalize()}:"]
        testing += ["   [ ] Package builds without modification"]
        testing += [f"   [ ] {binary} installs cleanly and runs" for binary in published_binaries]

    subst = {
        "package": package_spph.getPackageName(),
        "version": package_spph.getVersion(),
        "component": package_spph.getComponent(),
        "source": package_spph.getSeriesAndPocket(),
        "destinations": ", ".join(destinations),
    }
    subject = "[BPO] %(package)s %(version)s to %(destinations)s" % subst
    body = (
        "\n".join(
            [
                "[Impact]",
                "",
                " * Justification for backporting the new version to the stable release.",
                "",
                "[Scope]",
                "",
                " * List the Ubuntu release you will backport from,"
                " and the specific package version.",
                "",
                " * List the Ubuntu release(s) you will backport to.",
                "",
                "[Other Info]",
                "",
                " * Anything else you think is useful to include",
                "",
            ]
            + testing
            + [""]
            + find_rdepends(destinations, published_binaries)
            + [""]
        )
        % subst
    )

    editor = EditBugReport(subject, body)
    editor.edit()
    subject, body = editor.get_report()

    Logger.info("The final report is:\nSummary: %s\nDescription:\n%s\n", subject, body)
    if YesNoQuestion().ask("Request this backport", "yes") == "no":
        sys.exit(1)

    distro = Distribution("ubuntu")
    pkgname = package_spph.getPackageName()

    bug = Launchpad.bugs.createBug(
        title=subject, description=body, target=distro.getSourcePackage(name=pkgname)
    )

    bug.subscribe(person=Launchpad.people["ubuntu-backporters"])

    for dest in destinations:
        series = distro.getSeries(dest)
        try:
            bug.addTask(target=series.getSourcePackage(name=pkgname))
        except Exception:  # pylint: disable=broad-except
            break

    Logger.info("Backport request filed as %s", bug.web_link)


def main():
    parser = argparse.ArgumentParser(usage="%(prog)s [options] package")
    parser.add_argument(
        "-d",
        "--destination",
        metavar="DEST",
        help="Backport to DEST release and necessary "
        "intermediate releases "
        "(default: current LTS release)",
    )
    parser.add_argument(
        "-s",
        "--source",
        metavar="SOURCE",
        help="Backport from SOURCE release (default: current devel release)",
    )
    parser.add_argument(
        "-l",
        "--lpinstance",
        metavar="INSTANCE",
        default=None,
        help="Launchpad instance to connect to (default: production).",
    )
    parser.add_argument(
        "--no-conf",
        action="store_true",
        dest="no_conf",
        default=False,
        help="Don't read config files or environment variables",
    )
    parser.add_argument("package", help=argparse.SUPPRESS)
    args = parser.parse_args()

    config = UDTConfig(args.no_conf)

    if args.lpinstance is None:
        args.lpinstance = config.get_value("LPINSTANCE")
    Launchpad.login(args.lpinstance)

    if args.source is None:
        args.source = Distribution("ubuntu").getDevelopmentSeries().name

    try:
        destinations = determine_destinations(args.source, args.destination)
    except DestinationException as e:
        Logger.error(str(e))
        sys.exit(1)

    disclaimer()

    package_spph = locate_package(args.package, args.source)

    check_existing(package_spph)
    request_backport(package_spph, args.source, destinations)


if __name__ == "__main__":
    main()