#!/usr/bin/python
# -*- coding: utf-8 -*-

# Copyright (C) 2013 Canonical Ltd.
# Author: Stéphane Graber <stgraber@ubuntu.com>

# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; version 3 of the License.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.

import json
import os
import sys
sys.path.insert(0, os.path.join(sys.path[0], os.pardir, "lib"))

from systemimage import config, generators, tools, tree

import argparse
import fcntl
import logging
import datetime
import requests

PUSH_BROADCAST_URL = 'https://push.ubports.com/broadcast'

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description="image copier")
    parser.add_argument("source_channel", metavar="SOURCE-CHANNEL")
    parser.add_argument("destination_channel", metavar="DESTINATION-CHANNEL")
    parser.add_argument("device", metavar="DEVICE")
    parser.add_argument("-r", "--version", type=int)
    parser.add_argument("-o", "--offset", type=int, help="Version offset")
    parser.add_argument("-k", "--keep-version", action="store_true",
                        help="Keep the original version number")
    parser.add_argument("-p", "--phased-percentage", type=int,
                        help="Set the phased percentage for the copied image",
                        default=100)
    parser.add_argument("-t", "--tag", type=str,
                        help="Set a version tag on the new image")
    parser.add_argument("--verbose", "-v", action="count", default=0)
    parser.add_argument("-q", "--push-notify", action="store_true", default=False)

    args = parser.parse_args()

    # Setup logging
    formatter = logging.Formatter(
        "%(asctime)s %(levelname)s %(message)s")

    levels = {1: logging.ERROR,
              2: logging.WARNING,
              3: logging.INFO,
              4: logging.DEBUG}

    if args.verbose > 0:
        stdoutlogger = logging.StreamHandler(sys.stdout)
        stdoutlogger.setFormatter(formatter)
        logging.root.setLevel(levels[min(4, args.verbose)])
        logging.root.addHandler(stdoutlogger)
    else:
        logging.root.addHandler(logging.NullHandler())

    # Load the configuration
    conf = config.Config()

    # Try to acquire a global lock
    lock_file = os.path.join(conf.state_path, "global.lock")
    lock_fd = open(lock_file, 'w')

    try:
        fcntl.lockf(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
    except IOError:
        print("Something else holds the global lock. exiting.")
        sys.exit(0)

    # Load the tree
    pub = tree.Tree(conf)

    # Do some checks
    channels = pub.list_channels()
    if args.source_channel not in channels:
        parser.error("Invalid source channel: %s" % args.source_channel)

    if args.destination_channel not in channels:
        parser.error("Invalid destination channel: %s" %
                     args.destination_channel)

    if args.device not in channels[args.source_channel]['devices']:
        parser.error("Invalid device for source channel: %s" %
                     args.device)

    if args.device not in \
            channels[args.destination_channel]['devices']:
        parser.error("Invalid device for destination channel: %s" %
                     args.device)

    if "alias" in channels[args.source_channel] and \
            channels[args.source_channel]['alias'] \
            != args.source_channel:
        parser.error("Source channel is an alias.")

    if "alias" in channels[args.destination_channel] and \
            channels[args.destination_channel]['alias'] \
            != args.destination_channel:
        parser.error("Destination channel is an alias.")

    if "redirect" in channels[args.source_channel]:
        parser.error("Source channel is a redirect.")

    if "redirect" in channels[args.destination_channel]:
        parser.error("Destination channel is a redirect.")

    src_device = channels[args.source_channel]['devices'][args.device]
    if "redirect" in src_device:
        parser.error("Source device is a redirect. Use original channel "
                     "%s instead." % src_device['redirect'])

    dest_device = channels[args.destination_channel]['devices'][args.device]
    if "redirect" in dest_device:
        parser.error("Destination device is a redirect. Use original channel "
                     "%s instead." % dest_device['redirect'])

    if args.tag and "," in args.tag:
        parser.error("Image tag cannot include the character ','.")

    source_device = pub.get_device(args.source_channel, args.device)
    destination_device = pub.get_device(args.destination_channel, args.device)

    if args.keep_version:
        images = [image for image in destination_device.list_images()
                  if image['version'] == args.version]
        if images:
            parser.error("Version number is already used: %s" % args.version)

    # Assign a new version number
    new_version = args.version
    if not args.keep_version:
        # Find the next available version
        new_version = 1
        for image in destination_device.list_images():
            if image['version'] >= new_version:
                new_version = image['version'] + 1
    logging.debug("Version for next image: %s" % new_version)

    if not args.version:
        args.version = 1
        for image in source_device.list_images():
            if image['version'] >= args.version:
                args.version = image['version']

    if args.offset:
        logging.debug("Offsetting version by %s versions" % args.offset)
        args.version -= args.offset

    logging.debug("Using version %s for next image" % args.version)


    # Extract the build we want to copy
    images = [image for image in source_device.list_images()
              if image['type'] == "full" and image['version'] == args.version]
    if not images:
        parser.error("Can't find version: %s" % args.version)
    source_image = images[0]

    # Extract the list of existing full images
    full_images = {image['version']: image
                   for image in destination_device.list_images()
                   if image['type'] == "full"}

    # Check that the last full and the new image aren't one and the same
    source_files = [entry['path'].split("/")[-1]
                    for entry in source_image['files']
                    if not entry['path'].split("/")[-1].startswith("version-")]
    destination_files = []
    if full_images:
        latest_full = sorted(full_images.values(),
                             key=lambda image: image['version'])[-1]
        destination_files = [entry['path'].split("/")[-1]
                             for entry in latest_full['files']
                             if not entry['path'].split(
                                 "/")[-1].startswith("version-")]
    if source_files == destination_files:
        parser.error("Source image is already latest full in "
                     "destination channel.")

    # Generate a list of required deltas
    delta_base = tools.get_required_deltas(
        conf, pub, args.destination_channel, args.device)

    # Create new empty entries
    new_images = {'full': {'files': []}}
    for delta in delta_base:
        new_images['delta_%s' % delta['version']] = {'files': []}

    # Extract current version_detail and files
    version_detail = tools.extract_files_and_version(
        conf, source_image['files'], args.version, new_images['full']['files'])

    # Generate new version tarball
    environment = {}
    environment['channel_name'] = args.destination_channel
    environment['device'] = destination_device
    environment['device_name'] = args.device
    environment['version'] = new_version
    environment['version_detail'] = [entry
                                     for entry in version_detail.split(",")
                                     if not entry.startswith("version=")]
    environment['new_files'] = new_images['full']['files']

    # Add new tag if requested
    if args.tag:
        tools.set_tag_on_version_detail(
            environment['version_detail'], args.tag)
        logging.info("Setting tag for image to '%s'" % args.tag)

    logging.info("Generating new version tarball for '%s' (%s)"
                 % (new_version, ",".join(environment['version_detail'])))
    version_path = generators.generate_file(conf, "version", [], environment)
    if version_path:
        new_images['full']['files'].append(version_path)

    # Generate deltas
    for abspath in new_images['full']['files']:
        prefix = abspath.split("/")[-1].rsplit("-", 1)[0]
        for delta in delta_base:
            # Extract the source
            src_path = None
            for file_dict in delta['files']:
                if (file_dict['path'].split("/")[-1]
                        .startswith(prefix)):
                    src_path = "%s/%s" % (conf.publish_path,
                                          file_dict['path'])
                    break

            # Check that it's not the current file
            if src_path:
                src_path = os.path.realpath(src_path)

                # FIXME: the keyring- is a big hack...
                if src_path == abspath and "keyring-" not in src_path:
                    continue

                # Generators are allowed to return None when no delta
                # exists at all.
                logging.info("Generating delta from '%s' for '%s'" %
                             (delta['version'],
                              prefix))
                delta_path = generators.generate_delta(conf, src_path,
                                                       abspath)
            else:
                delta_path = abspath

            if not delta_path:
                continue

            # Get the full and relative paths
            delta_abspath, delta_relpath = tools.expand_path(
                delta_path, conf.publish_path)

            new_images['delta_%s' % delta['version']]['files'] \
                .append(delta_abspath)

    # Add full image
    logging.info("Publishing new image '%s' (%s) with %s files."
                 % (new_version, ",".join(environment['version_detail']),
                    len(new_images['full']['files'])))
    destination_device.create_image(
        "full", new_version, ",".join(environment['version_detail']),
        new_images['full']['files'],
        version_detail=",".join(environment['version_detail']))

    # Add delta images
    for delta in delta_base:
        files = new_images['delta_%s' % delta['version']]['files']
        logging.info("Publishing new delta from '%s' (%s)"
                     " to '%s' (%s) with %s files" %
                     (delta['version'], delta.get("description", ""),
                      new_version, ",".join(environment['version_detail']),
                      len(files)))

        destination_device.create_image(
            "delta", new_version,
            ",".join(environment['version_detail']),
            files,
            base=delta['version'],
            version_detail=",".join(environment['version_detail']))

    # Set the phased percentage value for the new image
    logging.info("Setting phased percentage of the new image to %s%%" %
                 args.phased_percentage)
    destination_device.set_phased_percentage(
        new_version, args.phased_percentage)

    if args.push_notify:
        logging.info("Sending broadcast push notification for device '{}' on channel '{}' and version '{}'".format(args.device, args.destination_channel, new_version))
        expiresTime = datetime.datetime.utcnow() + datetime.timedelta(days=1)
        identifier = "{}/{}".format(args.destination_channel, args.device)
        pushData = {
            "channel": "system",
            "expire_on": expiresTime.replace(microsecond=0).isoformat()+"Z",
            "data": {
                identifier: [new_version, '']
            }
        }

        r = requests.post(
            PUSH_BROADCAST_URL,
            data=json.dumps(pushData),
            headers={'content-type': 'application/json'})
        if r.status_code != 200:
            logging.error("WARNING: Push notification failed with: \nHTTP {}\n{}\n".format(r.status_code, r.text))

    # Expire images
    if args.destination_channel in conf.channels:
        if conf.channels[args.destination_channel].fullcount > 0:
            logging.info("Expiring old images")
            destination_device.expire_images(
                conf.channels[args.destination_channel].fullcount)

    # Sync all channel aliases
    logging.info("Syncing any existing alias")
    pub.sync_aliases(args.destination_channel)

    # Remove any orphaned file
    logging.info("Removing orphaned files from the pool")
    pub.cleanup_tree()

    # Sync the mirrors
    logging.info("Triggering a mirror sync")
    tools.sync_mirrors(conf)
