#!python
# -*- Mode: Python; coding: utf-8; indent-tabs-mode: nil; tab-width: 4 -*-

# Copyright (C) 2019 Bryce W. Harrington
#
# Released under GNU GPLv2 or later, read the file 'LICENSE.GPLv2+' for
# more information.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be included
# in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# Author:  Bryce Harrington <bryce@canonical.com>

'''PPA developer tools'''

__example__ = '''
Register a new PPA:
  $ ppa create my-ppa

Upload a package to the PPA:
  $ ppa put ppa:my-name/my-ppa some-package.changes

Wait until all packages in the PPA have finished building:
  $ ppa wait my-ppa

Delete the PPA:
  $ ppa destroy my-ppa

Set the public description for a PPA from a file:
  $ cat some-package/README | ppa desc ppa:my-name/my-ppa
'''

import os
import sys
import time
import argparse
from inspect import currentframe
from distro_info import UbuntuDistroInfo, DistroDataOutdated
from textwrap import indent

try:
    from ruamel import yaml
except ImportError:
    import yaml

if '__file__' in globals():
    sys.path.insert(0, os.path.realpath(
        os.path.join(os.path.dirname(os.path.realpath(__file__)), "..")))

from ppa._version import __version__
from ppa.constants import (
    ARCHES_PPA,
    ARCHES_AUTOPKGTEST,
    URL_AUTOPKGTEST,
)
from ppa.io import open_url
from ppa.job import (
    Job,
    get_waiting,
    show_waiting,
    get_running,
    show_running
)
from ppa.lp import Lp
from ppa.ppa import (
    get_ppa,
    ppa_address_split,
    Ppa,
    PpaDoesNotExist
)
from ppa.ppa_group import PpaGroup, PpaAlreadyExists
from ppa.result import (
    Result,
    get_results
)
from ppa.text import o2str, ansi_hyperlink
from ppa.trigger import Trigger

import ppa.debug
from ppa.debug import dbg, warn, error


def UNIMPLEMENTED():
    """Marks functionality that's not yet been coded"""
    warn("[UNIMPLEMENTED]: %s()" % (currentframe().f_back.f_code.co_name))


def load_yaml_as_dict(filename):
    """Returns content of yaml-formatted filename as a dictionary.

    :rtype: dict
    :returns: Content of file as a dict object.
    """
    d = dict()
    with open(filename, 'r') as f:
        for y in yaml.safe_load_all(f.read()):
            d.update(y)
        return d


def create_arg_parser():
    """Sets up the command line parser object.

    :rtype: argparse.ArgumentParser
    :returns: parser object, ready to run <parser>.parse_args().
    """
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawTextHelpFormatter,
        epilog=__example__)
    parser.add_argument('-C', '--config',
                        dest='config_filename', action='store',
                        default="~/.config/ppa-dev-tools/config.yml",
                        help="Location of config file")
    parser.add_argument('-D', '--debug',
                        dest='debug', action='store_true',
                        help="Turn on general debugging")
    parser.add_argument('-V', '--version',
                        action='version',
                        version='%(prog)s {version}'.format(version=__version__),
                        help="Version information")
    parser.add_argument('--dry-run',
                        dest='dry_run', action='store_true',
                        help="Simulate command without modifying anything")
    parser.add_argument('-v', '--verbose',
                        dest='verbose', action='store_true',
                        help="Print more information during processing")
    parser.add_argument('-q', '--quiet',
                        dest='quiet', action='store_true',
                        help="Minimize output during processing")

    subparser = parser.add_subparsers(dest='command')

    # Create Command
    create_parser = subparser.add_parser('create', help='create help')
    create_parser.add_argument('ppa_name', metavar='ppa-name',
                               action='store',
                               help="Name of the PPA to be created")
    create_parser.add_argument('-a', '--arches', '--arch', '--architectures',
                               dest="architectures", action='store',
                               help="Comma-separated list of hardware architectures to use")

    # Desc Command
    desc_parser = subparser.add_parser('desc', help='desc help')
    desc_parser.add_argument('ppa_name', metavar='ppa-name',
                             action='store',
                             help="Name of the PPA to describe")
    desc_parser.add_argument('description',
                             nargs=argparse.REMAINDER)

    # Destroy Command
    destroy_parser = subparser.add_parser('destroy', help='destroy help')
    destroy_parser.add_argument('ppa_name', metavar='ppa-name',
                                action='store',
                                help="Name of the PPA to destroy")

    # List Command
    list_parser = subparser.add_parser('list', help='list help')
    list_parser.add_argument('ppa_name', metavar='ppa-name',
                             action='store',
                             nargs='?', default='me',
                             help="Name of the PPA to list")

    # Show Command
    show_parser = subparser.add_parser('show', help='show help')
    show_parser.add_argument('ppa_name', metavar='ppa-name',
                             action='store',
                             help="Name of the PPA to show")
    show_parser.add_argument('-a', '--arches', '--arch', '--architectures',
                             dest="architectures", action='store',
                             help="Comma-separated list of hardware architectures to show")
    show_parser.add_argument('-r', '--releases', '--release',
                             dest="releases", action='store',
                             help="Comma-separated list of Ubuntu release codenames to show")
    show_parser.add_argument('-p', '--packages', '--package',
                             dest="packages", action='store',
                             help="Comma-separated list of source package names to show")

    # Status Command
    status_parser = subparser.add_parser('status', help='status help')
    status_parser.add_argument('ppa_name', metavar='ppa-name',
                               action='store',
                               help="Name of the PPA to report status of")

    # Tests Command
    tests_parser = subparser.add_parser('tests', help='tests help')
    tests_parser.add_argument('ppa_name', metavar='ppa-name',
                              action='store',
                              help="Name of the PPA to view tests")
    tests_parser.add_argument('-a', '--arches', '--arch', '--architectures',
                              dest="architectures", action='store',
                              help="Comma-separated list of hardware architectures to include in triggers")
    tests_parser.add_argument('-r', '--releases', '--release',
                              dest="releases", action='store',
                              help="Comma-separated list of Ubuntu release codenames to show triggers for")
    tests_parser.add_argument('-p', '--packages', '--package',
                              dest="packages", action='store',
                              help="Comma-separated list of source package names to show triggers for")
    tests_parser.add_argument('-L', '--show-urls',
                              dest='show_urls', action='store_true',
                              help="Display unformatted trigger action URLs")

    # Wait Command
    wait_parser = subparser.add_parser('wait', help='wait help')
    wait_parser.add_argument('ppa_name', metavar='ppa-name',
                             action='store',
                             help="Name of the PPA to wait on")

    return parser


def create_config(lp, args):
    """Creates config object by loading from file and adding args.

    This routine merges the command line parameter values with data
    loaded from the program's YAML formatted configuration file at
    ~/.config/ppa-dev-tools/config.yml (or as specified by the --config
    parameter).

    This permits setting static values in the config file(s), and using
    the command line args for variable settings and overrides.

    :param launchpadlib.service lp: The Launchpad service object.
    :param Namespace args: The parsed args from ArgumentParser.
    :rtype: dict
    :returns: dict of configuration parameters and values, or None on error
    """
    DEFAULT_CONFIG = {
        'debug': False,
        'ppa_name': None,
        'team_name': None,
        'wait_seconds': 10.0
        }
    config_path = os.path.expanduser(args.config_filename)
    try:
        config = load_yaml_as_dict(config_path)
    except FileNotFoundError:
        # Assume defaults
        dbg("Using default config since no config file found at {}".format(config_path))
        config = DEFAULT_CONFIG

    # Map all non-empty elements from argparse Namespace into config dict
    config.update({k: v for k, v in vars(args).items() if v})

    # Use defaults for any remaining parameters not yet configured
    for k, v in DEFAULT_CONFIG.items():
        config.setdefault(k, v)

    lp_username = None
    if lp.me:
        lp_username = lp.me.name
    config['team_name'], config['ppa_name'] = ppa_address_split(args.ppa_name, lp_username)
    if not config['team_name'] or not config['ppa_name']:
        warn("Invalid ppa name '{}'".format(args.ppa_name))
        return None

    if args.dry_run:
        config['dry_run'] = True

    # TODO: Each subcommand should have its own args->config parser
    # TODO: Also, loading the values from the config file will need namespaced,
    #       so e.g. create.architectures = a,b,c
    if args.command == 'create':
        if args.architectures is not None:
            if args.architectures:
                config['architectures'] = args.architectures.split(',')
            else:
                warn(f"Invalid architectures '{args.architectures}'")
                return None
    elif args.command == 'tests':
        if args.architectures is not None:
            if args.architectures:
                config['architectures'] = args.architectures.split(',')
            else:
                warn(f"Invalid architectures '{args.architectures}'")
                return None

        if args.releases is not None:
            if args.releases:
                config['releases'] = args.releases.split(',')
            else:
                warn(f"Invalid releases '{args.releases}'")
                return None

    return config


################
### Commands ###
################

def command_create(lp, config):
    """Creates a new PPA in Launchpad.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    # Take description from stdin if it's not a tty
    description = None
    if not sys.stdin.isatty():
        description = sys.stdin.read()

    ppa_name = config.get('ppa_name')
    if not ppa_name:
        warn("Could not determine PPA name")
        return os.EX_USAGE

    team_name = config.get('team_name')
    if not team_name:
        warn("Could not determine team name")
        return os.EX_USAGE

    architectures = config.get('architectures', ARCHES_PPA)

    try:
        if not config.get('dry_run', False):
            ppa_group = PpaGroup(service=lp, name=team_name)
            ppa = ppa_group.create(ppa_name, ppa_description=description)
            ppa.set_architectures(architectures)
            arch_str = ', '.join(ppa.architectures)
        else:
            ppa = Ppa(ppa_name, team_name, description)
            arch_str = ', '.join(architectures)
        if not config.get('quiet', False):
            print("PPA '{}' created for the following architectures:\n".format(ppa.ppa_name))
            print("  {}\n".format(arch_str))
            print("The PPA can be viewed at:\n")
            print("  {}\n".format(ppa.url))
            print("You can upload packages to this PPA using:\n")
            print("  dput {} <source.changes>\n".format(ppa.address))
            print("Wait for the uploads to build and publish using:\n")
            print("  ppa wait {}\n".format(ppa.address))
            print("To add the repository and to your system:\n")
            print("  sudo add-apt-repository -yus {}".format(ppa.address))
            print("  sudo apt-get install <package(s)>")
        return os.EX_OK
    except PpaAlreadyExists as e:
        warn(o2str(e.message))
        return 3
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


def command_desc(lp, config):
    """Sets the description for a PPA.

    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    if not sys.stdin.isatty():
        description = sys.stdin.read()
    else:
        description = ' '.join(config.get('description', None))

    if not description or len(description) < 3:
        warn('No description provided')
        return os.EX_USAGE

    try:
        ppa = get_ppa(lp, config)
        if config.get('dry_run', False):
            print("dry_run: Set description to '{}'".format(description))
            return os.EX_OK

        return ppa.set_description(description)
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


def command_destroy(lp, config):
    """Destroys the PPA.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    try:
        ppa = get_ppa(lp, config)
        if not config.get('dry_run'):
            # Attempt deletion of the PPA
            ppa.destroy()
        return os.EX_OK
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


def command_list(lp, config, filter_func=None):
    """Lists the PPAs for the user or team.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    # TODO: Apply filters such as:
    #  - Ones with packages for the given arch or codename
    #  - filter_not_empty: Ones with packages
    #  - filter_empty: Ones without packages
    #  - filter_obsolete: Ones with only packages that are superseded
    #  - filter_newer: Ones newer than a given date
    #  - filter_older: Ones older than a given date
    #  - Status of the PPAs
    if not lp:
        return 1

    team_name = config.get('team_name')
    if not team_name:
        if lp.me:
            team_name = lp.me.name
        else:
            warn("Could not determine team name")
            return os.EX_USAGE

    try:
        ppa_group = PpaGroup(service=lp, name=team_name)
        for ppa in ppa_group.ppas:
            print(ppa.address)
        return os.EX_OK
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


def command_exists(lp, config):
    """Checks if the named PPA exists in Launchpad.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    try:
        ppa = get_ppa(lp, config)
        if ppa.archive is not None:
            return os.EX_OK
    except KeyboardInterrupt:
        return 2
    return 1


def command_show(lp, config):
    """Displays details about the given PPA.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    distro = None
    series = None
    arch = None
    try:
        ppa = get_ppa(lp, config)
        print(f"ppa:        {ppa.name}")
        print(f"address:    {ppa.address}")
        print(f"url:        {ppa.url}")
        print(f"description:")
        print(indent(ppa.description, 4))

        print("sources:")
        for source in ppa.get_source_publications(distro, series, arch):
            print("   %s (%s) %s" % (
                source.source_package_name,
                source.source_package_version,
                source.status))
        # Only show binary details if specifically requested
        print("binaries:")
        total_downloads = 0
        for binary in ppa.get_binaries(distro, series, arch) or []:
            # Skip uninteresting binaries
            if not config.get('show-debug', False) and binary.is_debug:
                continue
            if not config.get('show-superseded', False) and binary.status == 'Superseded':
                continue
            if not config.get('show-deleted', False) and binary.status == 'Deleted':
                continue
            if not config.get('show-obsolete', False) and binary.status == 'Obsolete':
                continue

            print("    %-40s %-8s %s %s %s %6d" % (
                binary.binary_package_name + ' ' + binary.binary_package_version,
                binary.distro_arch_series.architecture_tag,
                binary.component_name,
                binary.pocket,
                binary.status,
                binary.getDownloadCount()))
            total_downloads += binary.getDownloadCount()
        print("downloads: %d" % (total_downloads))
        return os.EX_OK
    except PpaDoesNotExist as e:
        print(e)
        return 1
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


def command_status(lp, config):
    """Displays current status of the given ppa.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """

    # TODO: Allow option to limit to particular binary package
    #       Prints a two-line output showing the status of the binaries
    #       for a particular package and version.

    # TODO: Allow option to limit to particular source package
    #       Prints the status of the source for a particular
    #       package. Since it's the status of the source, this does not
    #       mean that the binaries are available.

    # TODO: Allow option to limit to particular series name

    UNIMPLEMENTED()
    return 1


def command_wait(lp, config):
    """Polls the PPA build status and block until all builds are finished and published.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    try:
        ppa = get_ppa(lp, config)
        waiting = True
        while waiting:
            if not ppa.has_packages():
                print("Nothing present in PPA.  Waiting for new package uploads...")
                # TODO: Only wait a configurable amount of time (15 min?)
                waiting = True  # config['wait_for_packages']
            else:
                waiting = ppa.has_pending_publications()
            time.sleep(config['wait_seconds'])
            print()
        return os.EX_OK
    except PpaDoesNotExist as e:
        print(e)
    except ValueError as e:
        print(f"Error: {e}")
        return os.EX_USAGE
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


def command_tests(lp, config):
    """Displays testing status for the PPA.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    if not lp:
        return 1

    releases = config.get('releases', None)
    if releases is None:
        udi = UbuntuDistroInfo()
        try:
            # Show tests only from the current development release
            releases = [ udi.devel() ]
        except DistroDataOutdated as e:
            # If no development release defined, use the current active release
            warn(f"Devel release is undefined; assuming stable release instead.")
            dbg(f"({e})", wrap=72, prefix='  - ', indent='    ')
            releases = [ udi.stable() ]

    packages = config.get('packages', None)

    ppa = get_ppa(lp, config)
    if not ppa.exists():
        error(f"PPA {ppa.name} does not exist for user {ppa.team_name}")
        return 1

    architectures = config.get('architectures', ARCHES_AUTOPKGTEST)

    try:
        # Triggers
        print("* Triggers:")
        for source_pub in ppa.get_source_publications():
            series = source_pub.distro_series.name
            if series not in releases:
                continue
            pkg = source_pub.source_package_name
            if packages and (pkg not in packages):
                continue
            ver = source_pub.source_package_version
            url = f"https://launchpad.net/ubuntu/+source/{pkg}/{ver}"
            source_hyperlink = ansi_hyperlink(url, f"{pkg}/{ver}")
            print(f"  - Source {source_hyperlink}: {source_pub.status}")
            triggers = [Trigger(pkg, ver, arch, series, ppa) for arch in architectures]

            if config.get("show_urls"):
                for trigger in triggers:
                    print(f"    + {trigger.arch}: {trigger.action_url}♻️ ")
                for trigger in triggers:
                    print(f"    + {trigger.arch}: {trigger.action_url}💍")

            else:
                for trigger in triggers:
                    pad = ' ' * (1 + abs(len('ppc64el') - len(trigger.arch)))
                    basic_trig = ansi_hyperlink(trigger.action_url, f"Trigger basic @{trigger.arch}♻️ ")
                    all_proposed_trig = ansi_hyperlink(trigger.action_url + "&all-proposed=1",
                                                       f"Trigger all-proposed @{trigger.arch}💍")
                    print(f"    + " + pad.join([basic_trig, all_proposed_trig]))

        # Results
        print()
        print("* Results:")
        for release in releases:
            base_results_fmt = f"{URL_AUTOPKGTEST}/results/autopkgtest-%s-%s-%s/"
            base_results_url = base_results_fmt % (release, ppa.team_name, ppa.name)
            url = f"{base_results_url}?format=plain"
            response = open_url(url)
            if response:
                trigger_sets = {}
                for result in get_results(response, base_results_url, arches=architectures):
                    trigger = ', '.join([str(r) for r in result.get_triggers()])
                    trigger_sets.setdefault(trigger, '')
                    if config.get("show_urls"):
                        trigger_sets[trigger] += f"    + {result.status_icon} {result}\n"
                        if result.status != 'PASS':
                            trigger_sets[trigger] += f"      • Status: {result.status}\n"
                            trigger_sets[trigger] += f"      • Log: {result.url}\n"
                            for subtest in result.get_subtests():
                                trigger_sets[trigger] += f"      • {subtest}\n"
                    else:
                        log_link = ansi_hyperlink(result.url, f"Log️ 🗒️ ")
                        trigger_sets[trigger] += f"    + {result.status_icon} {result}  {log_link}\n"
                        if result.status != 'PASS':
                            for subtest in result.get_subtests():
                                trigger_sets[trigger] += f"      • {subtest}\n"

                for trigger, result in trigger_sets.items():
                    print(f"  - {trigger}\n{result}")

        # Running Queue
        response = open_url(f"{URL_AUTOPKGTEST}/static/running.json", "running autopkgtests")
        if response:
            show_running(sorted(get_running(response, releases=releases, ppa=str(ppa)),
                                key=lambda k: str(k.submit_time)))

        # Waiting Queue
        response = open_url(f"{URL_AUTOPKGTEST}/queues.json", "waiting autopkgtests")
        if response:
            show_waiting(get_waiting(response, releases=releases, ppa=str(ppa)))

        return os.EX_OK
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


COMMANDS = {
    'create':     (command_create, None),
    'desc':       (command_desc, None),
    'destroy':    (command_destroy, None),
    'list':       (command_list, None),
    'show':       (command_show, None),
    'status':     (command_status, None),
    'tests':      (command_tests, None),
    'wait':       (command_wait, None),
    }


def main(args):
    """Main entrypoint for the command.

    :param argparse.Namespace args: Command line arguments.
    :rtype: int
    :returns: Status code OK (0) on success, non-zero on error.
    """
    lp = Lp('ppa-dev-tools')

    config = create_config(lp, args)
    if not config:
        return os.EX_CONFIG

    ppa.debug.DEBUGGING = config.get('debug', False)
    dbg("Configuration:")
    dbg(config)

    command = args.command

    ppa.debug.DEBUGGING = config.get('debug', False)

    try:
        func, param = COMMANDS[command]
        if param:
            return func(lp, config, param)
        return func(lp, config)
    except KeyError:
        parser.error(f"No such command {args.command}")
        return 1


if __name__ == "__main__":
    # Option handling
    parser = create_arg_parser()
    args = parser.parse_args()

    retval = main(args)
    if retval == os.EX_USAGE:
        print()
        parser.print_help()
    sys.exit(retval)
