#!/usr/bin/env python3
# -*- 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

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_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 Ppa, PpaDoesNotExist
from ppa.ppa_group import PpaGroup, PpaAlreadyExists
from ppa.result import (
    Result,
    get_results
)
from ppa.text import o2str

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


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('command',
                        nargs=1, action='store',
                        help="Command to be executed")
    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('ppa_name', metavar='ppa-name',
                        action='store',
                        help="Name of the PPA to be created")
    parser.add_argument('remainder',
                        nargs=argparse.REMAINDER)
    return parser


def create_config(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 Namespace args: The parsed args from ArgumentParser.
    :rtype: dict
    :returns: dict of configuration parameters and values
    """
    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)

    # TODO: function to convert string to ppa_name, team_name
    if args.ppa_name.startswith('ppa:'):
        if '/' not in args.ppa_name:
            die("Invalid ppa name '{}'".format(args.ppa_name))
        rem = args.ppa_name.split('ppa:', 1)[1]
        config['team_name'] = rem.split('/', 1)[0]
        config['ppa_name'] = rem.split('/', 1)[1]
    elif '/' in args.ppa_name:
        config['team_name'] = args.ppa_name.split('/', 1)[0]
        config['ppa_name'] = args.ppa_name.split('/', 1)[1]
    else:
        config['ppa_name'] = args.ppa_name
        # TODO: Set team_name to current lp_username if available
    if 'ppa_name' not in config or 'team_name' not in config:
        warn("Unknown ppa or team name")
        return None

    if args.remainder:
        config['remainder'] = args.remainder

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

    return config


def get_ppa(lp, config):
    """Load the specified PPA from Launchpad

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: Ppa
    :returns: Specified PPA as a Ppa object.
    """
    return Ppa(
        ppa_name=config.get('ppa_name', None),
        team_name=config.get('team_name', None),
        service=lp)


################
### 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:
        team_name = 'me'

    try:
        ppa_group = PpaGroup(service=lp, name=team_name)
        if not args.dry_run:
            ppa = ppa_group.create(ppa_name, ppa_description=description)
            ppa.set_architectures()
        print("PPA '{}' created for the following architectures:\n".format(ppa.ppa_name))
        print("  {}\n".format(', '.join(ppa.architectures)))
        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 %s <source.changes>" % (ppa.address))
        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('remainder', 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

    try:
        ppa_group = PpaGroup(service=lp)
        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(ppa)
        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

    # Show tests only from the current development release
    udi = UbuntuDistroInfo()
    release = udi.devel()

    try:
        ppa = get_ppa(lp, config)
        target = "%s/%s" % (ppa.team_name, ppa.name)

        # Results
        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:
            for result in get_results(response, base_results_url, arches=ARCHES_AUTOPKGTEST):
                print(f"* {result} {result.status_icon}")
                print(f"  - Triggers: " + ', '.join([str(r) for r in result.get_triggers()]))
                if result.status != 'PASS':
                    print(f"  - Status: {result.status}")
                    print(f"  - Log: {result.url}")
                    for subtest in result.get_subtests():
                        print(f"  - {subtest}")
                print()

        # Running Queue
        response = open_url(f"{URL_AUTOPKGTEST}/static/running.json", "running autopkgtests")
        if response:
            show_running(sorted(get_running(response, series=release, ppa=target),
                               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, series=release, ppa=target))

        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.
    """
    config = create_config(args)
    command = args.command[0]

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

    lp = Lp('ppa-dev-tools')

    if not config:
        parser.error("Invalid parameters")
        return 1
    dbg("Configuration:")
    dbg(config)

    try:
        func, param = COMMANDS[command]
        if param:
            return func(lp, config, param)
        return func(lp, config)
    except KeyError:
        parser.error("No such command {}".format(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)
