#!/usr/bin/python3
#
# Clonse channels by a particular date
#
# Copyright (c) 2008--2015 Red Hat, Inc.
#
#
# This software is licensed to you under the GNU General Public License,
# version 2 (GPLv2). There is NO WARRANTY for this software, express or
# implied, including the implied warranties of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. You should have received a copy of GPLv2
# along with this software; if not, see
# http://www.gnu.org/licenses/old-licenses/gpl-2.0.txt.
#
# Red Hat trademarks are not licensed under GPLv2. No permission is
# granted to use or replicate Red Hat trademarks that are incorporated
# in this software or its documentation.
#

import re
import sys
import datetime
import getpass
import os
from optparse import OptionParser
import simplejson as json
import socket

try:
    # python 2
    from StringIO import StringIO
except ImportError:
    from io import StringIO

_LIBPATH = "/usr/share/rhn"
if _LIBPATH not in sys.path:
    sys.path.append(_LIBPATH)

from utils import cloneByDate
from utils.cloneByDate import UserError


SAMPLE_CONFIG = """
{
 "username":"admin",
 "password":"redhat",
 "assumeyes":true,
 "to_date": "2011-10-01",
 "skip_depsolve":false,
 "skip_errata_depsolve":false,
 "security_only":false,
 "use_update_date":false,
 "no_errata_sync":false,
 "dry_run":false,
 "errata": ["RHSA-2014:0043", "RHBA-2014:0085"],
 "blacklist": {
                 "ALL":["sendmail"],
                 "my-rhel5-x86_64-clone":["emacs"],
                 "my-rhel5-i386-clone":["vi", "postfix.*"]
              },
 "removelist": {
                 "ALL":["compiz", "compiz-gnome"],
                 "my-rhel5-x86_64-clone":["boost.*"]
              },
 "channels":[
             {
                "rhel-x86_64-server-5": {
                    "label": "my-rhel5-x86_64-clone",
                    "existing-parent-do-not-modify": true
                },
                "rhn-tools-rhel-x86_64-server-5": {
                    "label": "my-tools-5-x86_64-clone",
                    "name": "My Clone's Name",
                    "summary": "This is my channel's summary",
                    "description": "This is my channel's description"
                }
             },
            {
                "rhel-i386-server-5": "my-rhel5-i386-clone"
             }
           ]
}
"""


def merge_config(options):
    if options.channels:
        options.channels = transform_arg_channels(options.channels)
        return options
    elif not options.config:
        return options

    if not os.path.isfile(options.config):
        raise UserError("%s does not exist." % options.config)

    try:
        config_file = open(options.config).read()
        config = json.load(StringIO(config_file))
    except:
        raise UserError("Configuration file is invalid, please check syntax.")

    #if soemthing is in the config and not passed in as an argument
    #   add it to options
    overwrite = ["username", "password", "blacklist", "removelist", "channels",
                 "server", "assumeyes", "to_date", "skip_depsolve", "skip_errata_depsolve",
                 "security_only", "use_update_date", "no_errata_sync",
                 "errata", 'dry_run']
    for key in overwrite:
        if key in config and not getattr(options, key):
            setattr(options, key, config[key])

    # If from the command line there is only one channel tree. Transform single
    # channel tree to a list of channel trees, which is what the rest of the
    # code expects
    if type(options.channels) == dict:
        options.channels = [options.channels]

    for channel_dict in options.channels:
        for key in list(channel_dict.keys()):
            # handle the just-the-lable case
            if type(channel_dict[key]) == type(""):
                channel_dict[key] = [channel_dict[key]]

    if options.blacklist:
        validate_list_dict("blacklist", options.blacklist)
    if options.removelist:
        validate_list_dict("removelist", options.removelist)

    return options


def validate_list_dict(name, pkg_dict):
    """
        Validates a removelist or blacklist to be map with lists as values
    """
    if type(pkg_dict) != type({}):
        raise UserError("%s  is not formatted correctly" % name)
    for key, value in list(pkg_dict.items()):
        if type(value) != type([]):
            raise UserError("Channel %s in %s packages not formatted correctly" % (key, name))

# Using --channels as an argument only supports a single channel 'tree'
#  So we need to convert a list of lists of channel options into a list of
#  hashes. ex:
# [
#   ["rhel-i386-servr-5", "my-rhel-clone"],
#   ['rhel-child', 'clone-child', 'clone name', 'clone summary', 'clone description']
# ]
#    should become
# [{
#  "rhel-i386-servr-5" : ["my-rhel-clone"],
#  'rhel-child': ['clone-child', 'clone name', 'clone summary', 'clone description']
#  }]


def transform_arg_channels(chan_list):
    to_ret = {}
    for channel in chan_list:
        to_ret[channel[0]] = channel[1:]
    return [to_ret]

# This hack is required because callback option types do not allow you
# to have set an explicit value, like '--channels=src_label dest_label'.
# This has always worked before, and in fact the man page says that's what
# you should do, so we can't let it not work. Instead we'll have to transform
# the option to be '--channels src_label dest_label' and then pass that on
# back to optparse, which will process correctly. Life will be much easier
# when we no longer support RHEL 5 and can migrate to argparse.


class HackedOptionParser(OptionParser):

    def _process_long_opt(self, rargs, values):
        if '=' in rargs[0]:
            arg = rargs.pop(0)
            (opt, next_arg) = arg.split("=", 1)
            rargs.insert(0, next_arg)
            rargs.insert(0, opt)
        OptionParser._process_long_opt(self, rargs, values)


def vararg_callback(option, opt_str, value, parser):
    assert value is None
    value = []

    for arg in parser.rargs:
        # stop on --foo like options
        if arg[:2] == "--" and len(arg) > 2:
            break
        # stop on -a
        if arg[:1] == "-" and len(arg) > 1:
            break
        value.append(arg)

    del parser.rargs[:len(value)]
    curr_value = getattr(parser.values, option.dest, None)
    if not curr_value:
        setattr(parser.values, option.dest, [value])
    else:
        curr_value.append(value)


def get_localhost_fqdn():
    """
    Get FQDN of the current machine.

    :return:
    """
    fqdn = None
    try:
        for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(
                socket.gethostname(), 0, 0, 0, 0, socket.AI_CANONNAME):
            if canonname:
                fqdn = canonname
                break
    except socket.gaierror as exc:
        pass  # Silence here
    except Exception as exc:
        print("Unhandled exception occurred while getting FQDN:", exc)

    return fqdn or socket.getfqdn()  # Fall-back to the /etc/hosts's FQDN



def parse_args():
    parser = HackedOptionParser()
    parser.add_option("-a", "--parents", dest="parents", action='callback',
                      callback=vararg_callback, help="Already existing channel that "
                      + "will be used as parent of child channels cloned this session. "
                      + "No changes will be made to this channel unless dependency "
                      + "resolution requires it. Source parent is optional, will "
                      + "be looked up if not provided (eg. --parents [src_parent] "
                      + "dest_parent)")
    parser.add_option("-b", "--blacklist", dest="blacklist",
                      help="Comma separated list of package names (or regular "
                      + "expressions) to exclude from cloned errata (Only added "
                      + "packages will be considered).")
    parser.add_option("-c", "--config", dest="config",
                      help="Config file specifying options")
    parser.add_option("-d", "--to_date", dest="to_date",
                      help="Clone errata to the specified date (YYYY-MM-DD). "
                      + "If omitted will assume no errata.")
    parser.add_option("-e", "--errata", dest='errata', action='store',
                      help="Only clone errata in this comma separated list (and "
                      + "dependencies unless paired with --skip_depsolve) (e.g. "
                      + "--errata=RHSA-2014:0043,RHBA-2014:0085).")
    parser.add_option("-g", "--background", dest='background',
                      action='store_true', help="DEPRECATED: does nothing.")
    parser.add_option("-j", "--dry-run", dest="dry_run", action='store_true',
                      help="Creates a file for each pair of channels in the working "
                      + "directory that comprises the list of erratas that are to be cloned. "
                      + "No actual errata cloning takes place. "
                      + "Warning: If some of the destination channels do not exist, "
                      + "they will be created with the original package set.")
    parser.add_option("-k", "--skip_depsolve", dest='skip_depsolve',
                      action='store_true',
                      help="Skip all dependency solving (Not recommended).")
    parser.add_option("-l", "--channels", dest="channels", action="callback",
                      callback=vararg_callback, help="Original channel and clone "
                      + "channel labels space separated, with optional channel name and "
                      + "summary following (e.g. --channels=rhel-i386-server-5 "
                      + "myCloneLabel [myName [mySummary [myDescription]]]).  Can be specified "
                      + "multiple times.")
    parser.add_option("-m", "--sample-config", dest='sample',
                      action='store_true',
                      help="Print a sample full configuration file and exit.")
    parser.add_option("-n", "--no-errata-sync", dest="no_errata_sync",
                      action='store_true', help="Do not automatically sychronize the "
                      + "package list of cloned errata with their originals. This may "
                      + "make spacewalk-clone-by-date have unexpected results if the "
                      + "original errata have been updated (e.g.: syncing another "
                      + "architecture for a channel) since the cloned errata were "
                      + "created. If omitted we will synchronize the cloned errata "
                      + "with the originals to ensure the expected packages are "
                      + "included (default).")
    parser.add_option("-o", "--security_only", dest='security_only',
                      action='store_true',
                      help="Only clone security errata (and their dependencies).")
    parser.add_option("-p", "--password", dest="password", help="Password")
    parser.add_option("-r", "--removelist", dest="removelist",
                      help="Comma separated list of package names (or regular "
                      + "expressions) to remove from destination channel (All packages "
                      + "are available for removal).")
    parser.add_option("-s", "--server", dest="server",
                      help="Server URL to use for api connections (defaults to %default)",
                      default="https://" + get_localhost_fqdn() + "/rpc/api")
    parser.add_option("-u", "--username", dest="username", help="Username")
    parser.add_option("-v", "--validate", dest='validate', action='store_true',
                      help="Run repoclosure on the set of specified repositories.")
    parser.add_option("-y", "--assumeyes", dest='assumeyes',
                      action='store_true',
                      help="Assume yes for any prompts (unattended).")
    parser.add_option("-x", "--skip-errata-depsolve", dest="skip_errata_depsolve",
                       action='store_true', help="When pulling in an erratum to satisfy "
                       + "dependency-resolution, DO NOT add that erratum's packages to the "
                       + "list of packages to do dependency-resolution against. This will "
                       + "result in fewer RPMs/errata being included for "
                       + "dependency-resolution (sometimes MANY fewer) at the possible "
                       + "expense of a cloned channel that is not dependency-complete. If "
                       + "ommitted, we will add an erratum's RPMs to the list required for "
                       + "dependency-resolution and recurse on the list (default).")
    parser.add_option("-z", "--use-update-date", dest="use_update_date",
                      action='store_true', help="While cloning errata by date, clone "
                      + "all errata that have last been updated on or before the date "
                      + "provided by to_date. If omitted will use issue date of errata "
                      + "(default).")

    (options, args) = parser.parse_args()

    if options.parents != None:
        # vararg_callback was designed for use with --channels, fix
        options.parents = options.parents[0]
        parent_len = len(options.parents)
        if (parent_len != 1 and parent_len != 2):
            raise UserError("The -a / --parents option requires an argument")

    # have to check this option before we merge with the config file to
    # ensure that optparse is parsing the args correctly. We have to
    # check it again after the config file merge to make sure we have
    # channels.
    if options.channels != None:
        for channel_group in options.channels:
            if (len(channel_group) < 2 or len(channel_group) > 5):
                raise UserError("The -l / --channels option requires two to "
                                + "five arguments")

    if options.sample:
        print(SAMPLE_CONFIG)
        sys.exit(0)

    if options.config and options.channels:
        raise UserError("Cannot specify both --channels and --config.")

    if options.config and options.parents:
        raise UserError("Cannot specify both --parents and --config.")

    if options.blacklist:
        options.blacklist = {"ALL": options.blacklist.split(",")}

    if options.removelist:
        options.removelist = {"ALL": options.removelist.split(",")}

    if options.errata:
        options.errata = options.errata.split(',')

    options = merge_config(options)

    if options.errata and options.to_date:
        raise UserError("Cannot specify both --to_date and --errata.")

    if options.errata and options.security_only:
        raise UserError("Cannot specify both --security_only and --errata.")

    if options.channels == None or len(options.channels) == 0:
        raise UserError("No channels specified. See --help for details.")

    if not options.username:
        raise UserError("Username not specified")

    if not options.validate:
        if options.to_date:
            options.to_date = parse_time(options.to_date.strip())

    if not options.password:
        options.password = getpass.getpass()

    # Remove whitespace for bug 885782. Since bug 830609 we can no longer
    # just remove all whitespace from the config file, may have spaces in
    # channel name or description.
    options.username = options.username.strip()
    options.password = options.password.strip()
    options.server = options.server.strip()
    if options.errata:
        errata_list = []
        for errata in options.errata:
            errata_list.append(errata.strip())
        options.errata = errata_list
    for option in [getattr(options, 'blacklist', None),
                   getattr(options, 'removelist', None)]:
        if option:
            for key in list(option.keys())[:]:
                if key != key.strip():
                    option[key.strip()] = option[key]
                    del option[key]
                    key = key.strip()
                my_list = []
                for element in option[key]:
                    my_list.append(element.strip())
                option[key] = my_list
    for channel_tree in options.channels:
        for channel in list(channel_tree.keys())[:]:
            if channel != channel.strip():
                channel_tree[channel.strip()] = channel_tree[channel]
                del channel_tree[channel]
                channel = channel.strip()
            if type(channel_tree[channel]) == list:
                my_list = []
                for element in channel_tree[channel]:
                    my_list.append(element.strip())
                channel_tree[channel] = my_list
            elif type(channel_tree[channel]) == dict:
                my_dict = {}
                for key in list(channel_tree[channel].keys()):
                    if type(channel_tree[channel][key]) == str:
                        my_dict[key.strip()] = channel_tree[channel][key].strip()
                    else:
                        my_dict[key.strip()] = channel_tree[channel][key]
                channel_tree[channel] = my_dict

    return options


def parse_time(time_str):
    """
     We need to use datetime, but python 2.4 does not support strptime(),
     so we have to parse ourselves
    """
    if re.match('[0-9]{4}-[0-9]{2}-[0-9]{2}', time_str):
        try:
            split = time_str.split("-")
            date = datetime.datetime(int(split[0]), int(split[1]),
                                     int(split[2]))
        except:
            raise UserError("Invalid date (%s)" % time_str)
        return date
    else:
        raise UserError("Invalid date format (%s), expected YYYY-MM-DD" %
                        time_str)


def systemExit(code, msgs=None):
    """
     Exit with a code and optional message(s). Saved a few lines of code.
    """
    if msgs:
        if type(msgs) not in [type([]), type(())]:
            msgs = (msgs, )
        for msg in msgs:
            sys.stderr.write(str(msg) + '\n')
    sys.exit(code)


def main():
    try:
        args = parse_args()
        return cloneByDate.main(args)
    except KeyboardInterrupt:
        systemExit(0, "\nUser interrupted process.")
    except UserError as error:
        systemExit(-1, "\n%s" % error)
    return 0


if __name__ == '__main__':
    try:
        sys.exit(abs(main() or 0))
    except KeyboardInterrupt:
        systemExit(0, "\nUser interrupted process.")
