#!/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 debian import deb822

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.lp import Lp
from ppa.ppa import Ppa
from ppa.ppa_group import PpaGroup, PpaAlreadyExists
from ppa.processes import execute, ReturnCode
from ppa.text import o2str

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

def UNIMPLEMENTED():
    from inspect import currentframe
    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 in DEFAULT_CONFIG.keys():
        config.setdefault(k, DEFAULT_CONFIG[k])

    # 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]
    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_copy(lp, config):
    """Copies contents from one PPA to another.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    """
    source_ppa = get_ppa(lp, config)
    dest_ppa = config.get('remainder', [None])[0]
    distro = None
    series = None
    arch = None

    source_packages = config.get('source-packages')
    if source_packages is None:
        # If no source packages were specified, copy everything
        source_packages = []
        for source in ppa.get_source_publications(distro, series, arch):
            source_packages.append(source.source_package_name)

    # TODO: If series specified, only copy source packages targeting that series.
    # TODO: Add option for whether to rebuild or just copy binaries

    print("Unimplemented")
    return 1

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

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    :rtype: int
    :returns: Status code 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 1

    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 0
    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 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 1

    try:
        ppa = get_ppa(lp, config)
        if config.get('dry_run', False):
            print("dry_run: Set description to '{}'".format(description))
            return 0
        return ppa.set_description(description)
    except:
        raise
        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 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 0
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1

def command_put(lp, config):
    """Uploads a .changes file to a PPA.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    """
    try:
        ppa = get_ppa(lp, config)
        changes_file = config.get('remainder', [None])[0]
        if changes_file is None:
            die("Undefined changes file")
        # TODO: Check if changes was signed; if not offer to do it
        if config.get('dry_run', True):
            return 0
        try:
            # TODO: May need to dput -f sometimes
            print(execute(['dput', ppa.address, changes_file]))

            # TODO: Make it a ppa configurable as to how the description is done
            # TODO: If the changelog entry is just "build for ppa", we should
            #       rather use the preceeding changelog entry.
            c = deb822.Changes(changes_file)
            if 'Changes' in c:
                ppa.set_description(ppa.description + c['Changes'])
            return 0
        except ReturnCode as e:
            print(e)
            return e.code

    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 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 0
    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 0 on success, non-zero on error.
    """
    try:
        ppa = get_ppa(lp, config)
        return ppa.archive is not None
    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 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 0
    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 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 0 on success, non-zero on error.
    """
    try:
        ppa = get_ppa(lp, config)
        waiting = True
        while waiting:
            if not ppa.has_packages():
                print("PPA is empty.  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 0
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1

def command_install(lp, config):
    """Enable the PPA in apt-sources.

    This is similar to apt-add-repository -ys <PPA>

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    """
    try:
        ppa = get_ppa(lp, config)
        if not config.get('dry_run'):
            return(ppa.install())
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1

def command_uninstall(lp, config):
    """Removes the PPA from apt-sources.

    :param Lp lp: The Launchpad wrapper object.
    :param dict config: Configuration param:value map.
    """
    # TODO: Do equivalent of apt-add-repository -r
    try:
        ppa = get_ppa(lp, config)
        if not config.get('dry_run'):
            # TODO: Remove the ppa from apt-sources
            # TODO: Downgrade all packages in the PPA to the ubuntu version
            pass
    except KeyboardInterrupt:
        return 2
    print("Unhandled error")
    return 1


COMMANDS = {
    'copy':       (command_copy, None),
    'create':     (command_create, None),
    'desc':       (command_desc, None),
    'destroy':    (command_destroy, None),
    'put':        (command_put, None),
    'install':    (command_install, None),
    'list':       (command_list, None),
    'show':       (command_show, None),
    'status':     (command_status, None),
    'uninstall':  (command_uninstall, 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 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)
        else:
            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()

    sys.exit(main(args))
