#!/usr/bin/python
#
# Clonse channels by a particular date
#
# Copyright (c) 2008--2012 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
import StringIO
from optparse import OptionParser
import simplejson as json

_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,
 "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.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",
                 "security_only", "use_update_date", "no_errata_sync",
                 "errata", 'dry_run']
    for key in overwrite:
        if config.has_key(key) 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 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 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 parse_args():
    parser = HackedOptionParser()
    parser.add_option("-c", "--config", dest="config",
            help="Config file specifying options")
    parser.add_option("-u", "--username", dest="username", help="Username")
    parser.add_option("-p", "--password", dest="password", help="Password")
    parser.add_option("-s", "--server", dest="server",
            help="Server URL to use for api connections (defaults to "
            + "https://localhost/rpc/api)",
            default="https://localhost/rpc/api")
    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("-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("-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("-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("-y", "--assumeyes", dest='assumeyes',
            action='store_true',
            help="Assume yes for any prompts (unattended).")
    parser.add_option("-m", "--sample-config", dest='sample',
            action='store_true',
            help="Print a sample full configuration file and exit.")
    parser.add_option("-k", "--skip_depsolve", dest='skip_depsolve',
            action='store_true',
            help="Skip all dependency solving (Not recommended).")
    parser.add_option("-v", "--validate", dest='validate', action='store_true',
            help="Run repoclosure on the set of specified repositories.")
    parser.add_option("-g", "--background", dest='background',
            action='store_true', help="Clone the errata in the background. "
            + "Prompt will return quicker; before cloning is finished.")
    parser.add_option("-o", "--security_only", dest='security_only',
            action='store_true',
            help="Only clone security errata (and their dependencies).")
    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("-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("-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).")
    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("-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.")

    (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 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 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 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, 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.")
