#!/usr/bin/python2

"""
Upload result bundles to the server
"""

from __future__ import division

import os

UPLOAD_URL='http://fed-laptoptest.sipsolutions.net/upload'

STATEDIR = '/var/lib/fedora-laptop-testing/'
BUNDLEDIR = os.path.join(STATEDIR, 'result-bundles')

import sys
import threading
import datetime
import logging
import gi
import zipfile
import pycurl
gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, GLib, GObject, Gio


import signal
def ign(*args):
    pass
signal.signal(signal.SIGUSR1, ign)

# Dummies
class RunnerQueue:
    def put(*args):
        pass

class DummyName:
    name = 'dummy name'

class Dummy:
    """All tests have been finished. Results should be uploaded now!"""
    log = logging
    runner_queue = RunnerQueue()
    ui_class = None

    name = DummyName()

    def recv(*args):
        pass

if len(sys.argv) == 2 and sys.argv[1] == '--run-session':
    from avocado.core import settings
    settings.settings.process_config_path('/etc/avocado/fedora-laptop-testing.conf')

    import fed_laptoptest.session as session

    dummy = Dummy()

    with session.Session(dummy, start_hidden=True) as sess:
        path = sys.argv[0]

        if not os.path.exists(sys.argv[0]):
            path = '/usr/sbin/fedora-laptop-testing-upload'
        proc = sess.spawn_process(lambda : os.execl(path, path))
        proc.join()

    sys.exit(0)

class MemFile:
    def __init__(self, name, data=''):
        self.name = name
        self.data = data

class ZipOutput:
    def __init__(self):
        self._discarded = 0
        self._buf_pos = 0
        self._buffer = ''

    def write(self, data):
        length = len(data)
        if self._buf_pos == len(self._buffer):
            self._buffer += data
        else:
            self._buffer = self._buffer[:self._buf_pos] + data + self._buffer[self._buf_pos + length:]

        self._buf_pos += len(data)

    def seek(self, pos, whence):
        if whence == 0:
            self._buf_pos = pos - self._discarded
        elif whence == 1:
            self._buf_pos += pos
        elif whence == 2:
            self._buf_pos = len(self._buffer) + pos

        if self._buf_pos > len(self._buffer):
            self._buffer += '\0' * (self._buf_pos - len(self._buffer))

        assert self._buf_pos >= 0
        assert self._buf_pos <= len(self._buffer)

    def flush(self):
        pass

    def read(self):
        raise NotImplemented

    def open(self):
        raise NotImplemented

    def close(self):
        raise NotImplemented

    def tell(self):
        return self._discarded + self._buf_pos

    def consume_data(self):
        buf = self._buffer
        self._buffer = buffer('')
        self._discarded += len(buf)
        self._buf_pos -= len(buf)
        assert self._buf_pos == 0
        return buf

    def __nonzero__(self):
        # This file is never "closed"
        return True

class GZipWriter(GObject.Object):
    def __init__(self, output_stream, basedir, compression=zipfile.ZIP_DEFLATED):
        GObject.Object.__init__(self)
        self.stream = output_stream
        self._basedir = basedir
        self._buffer = ZipOutput()
        self._final = False
        self._files = []
        self._compressed = []
        self._cancellable = Gio.Cancellable()
        self._writing = False
        self.bytes_written = 0

        self._zip = zipfile.ZipFile(self._buffer, "w", compression=compression)

    def cancel(self):
        self._cancellable.cancel()
        self._zip = None
        self._files = []
        self._final = True
        self._buffer = None

        self.done.emit()

    def is_cancelled(self):
        return self._cancellable.is_cancelled()

    def add_files(self, files):
        assert not self._final
        self._files.extend(list(files))
        self._ensure_writing()

    def add_file(self, file):
        assert not self._final
        self._files.append(file)
        self._ensure_writing()

    def finalize(self):
        self._files.append('__finalize_zip__')
        self._final = True

    def _add_data(self):
        # Nothing to add?
        if not self._files:
            return False

        f = self._files.pop(0)
        if f == '__finalize_zip__':
            self._zip.close()
        elif isinstance(f, MemFile):
            self._zip.writestr(f.name, f.data)
        else:
            self._zip.write(os.path.join(self._basedir, f), f)

        return True

    def _writing_cb(self, source, res, data):
        try:
            bytes_written = source.write_finish(res)
        except Exception as e:
            print(e)
            self._writing = False
            self.stream.close()
            return

        if self._cancellable.is_cancelled():
            self._writing = False
            self.stream.close()

        self.bytes_written += bytes_written
        self.emit('progress', self.bytes_written)

        # Queue again if not everything was written
        data = data[bytes_written:]
        if len(data) > 0:
            self.stream.write_async(data, GLib.PRIORITY_LOW,
                                    cancellable=self._cancellable,
                                    callback=self._writing_cb, user_data=data)
        else:
            self._writing = False
            self._ensure_writing()

    def _idle_ensure_writing(self):
        self._writing = False
        self._ensure_writing()
        return False

    def _ensure_writing(self):
        if self._writing:
            return

        # Try to read data, if none is there try adding more to the zipfile
        data = self._buffer.consume_data()
        while len(data) == 0:
            # Nothing more to add?
            if not self._add_data():
                break
            data = self._buffer.consume_data()

        # If there no data at this point then we cannot add more files anymore.
        # In this case emit "done" if the zip file should be finalized by now.
        if len(data) == 0:
            if self._final:
                if self.stream is not None:
                    self.stream.close()
                self.done.emit()
            return

        self._writing = True
        if self.stream is not None:
            self.stream.write_async(data, GLib.PRIORITY_LOW,
                                    cancellable=self._cancellable,
                                    callback=self._writing_cb, user_data=data)
        else:
            self.bytes_written += len(data)
            GLib.idle_add(self._idle_ensure_writing)

    @GObject.Signal
    def done(self):
        pass

    @GObject.Signal(arg_types=[int])
    def progress(self, bytes_written):
        pass

class Bundle(GObject.Object):
    def __init__(self, directory):
        GObject.Object.__init__(self)
        self.dir = directory
        assert os.path.isdir(self.dir)
        self.basename = os.path.basename(directory)
        self.unmanaged = os.path.exists(os.path.join(self.dir, 'unmanaged'))
        self._zips = []

        tmp = self.basename
        tmp = tmp.split('-', 1)[1]
        tmp = tmp.rsplit('-', 1)[0]

        self.time = datetime.datetime.strptime(tmp, '%Y-%m-%dT%H.%M')
        now = datetime.datetime.now()
        self.age = now - self.time

        self._size = -1
        self._estimated_size = -1
        GLib.idle_add(self._stat, priority=GLib.PRIORITY_LOW)

        self._note = None
        self._estimator = None

    @GObject.property
    def descr(self):
        time = ''
        if self.age.days > 0:
            time += '{:d} days '.format(self.age.days)
        if self.age.seconds > 3600:
            time += '{:d} hours '.format(self.age.seconds // 3600)

        if self.age.days == 0:
            time += '{:d} minutes '.format((self.age.seconds // 60) % 60)

        descr = 'Test started {:s}ago ({:s}{:s})'.format(time, self.time.isoformat(), ', unmanaged' if self.unmanaged else '')

        return descr

    def _estimator_done_cb(self, z):
        self._estimated_size = self._estimator.bytes_written
        self._estimator = None

        self.notify('estimated-size')

    def start_size_estimate(self):
        if self.estimated_size >= 0 or self._estimator:
            return

        # Use a None writer, it means the data is discarded again
        self._estimator = self.get_zip(None)
        self._estimator.connect('done', self._estimator_done_cb)

    def get_note(self):
        return self._note

    def set_note(self, value):
        self._note = value

    note = GObject.Property(get_note, set_note, type=str, default=None)

    @GObject.property
    def size(self):
        return self._size

    @GObject.property
    def estimated_size(self):
        return self._estimated_size

    def _stat(self):
        self._size = 0
        self._files = []
        for dirpath, dirnames, filenames in os.walk(self.dir):
            for f in filenames:
                full_path = os.path.join(dirpath, f)
                rel_path = os.path.relpath(full_path, self.dir)

                try:
                    # Only append if we can stat it properly
                    self._size += os.stat(full_path).st_size
                    self._files.append(rel_path)
                except OSError:
                    sys.stderr.write('Could not stat {:s}\n', full_path)

        for z in self._zips:
            z.add_files(self._files)
            z.finalize()

        self.notify('size')

    def _zip_done_cb(self, z):
        self._zips.remove(z)

    def get_zip(self, target, compression=zipfile.ZIP_DEFLATED):
        z = GZipWriter(target, self.dir, compression)

        comment_file = MemFile('comment')
        if self.note is not None:
            comment_file.data = self.note
        z.add_file(comment_file)

        if self._size != -1:
            z.add_files(self._files)
            z.finalize()

        self._zips.append(z)
        z.connect('done', self._zip_done_cb)

        return z

# Find all bundle directories and create wrapper objects
bundles = []
for d in sorted(os.listdir(BUNDLEDIR), reverse=True):
    try:
        bundle = Bundle(os.path.join(BUNDLEDIR, d))
        bundle.start_size_estimate()
        bundles.append(bundle)
    except Exception as e:
        pass


class Upload:
    def __init__(self, ui, bundle):
        self.ui = ui
        self.bundle = bundle
        self.dialog = Gtk.MessageDialog(title='Bundle Upload')
        self.dialog.set_transient_for(self.ui.win)
        self.dialog.set_markup('Uploading bundle now')

        msg_area = self.dialog.get_message_area()

        self.pbar = Gtk.ProgressBar()
        self.pbar.props.fraction = 0.0
        msg_area.add(self.pbar)

        self.dialog.add_button("_Cancel", Gtk.ResponseType.CANCEL)

        self.dialog.connect('response', self._upload_response_cb)

        self.dialog.show_all()

        self.fdread, self.fdwrite = os.pipe()
        self.stream = Gio.UnixOutputStream(fd=self.fdwrite, close_fd=True)

        GLib.unix_set_fd_nonblocking(self.fdwrite, True)

        # The writer zipwriter
        self.zipwriter = bundle.get_zip(self.stream)
        self.zipwriter.connect('progress', self._upload_progress_cb)

        # The progress zipwriter
        zipwritersize = bundle.get_zip(None)
        zipwritersize.connect('done', self._zipwritersize_done_cb)

    def _zipwritersize_done_cb(self, writer):
        self._upload_file_size = writer.bytes_written

        # Already canceled?
        if self.zipwriter.is_cancelled():
            self.cleanup()
            return

        def curl_read(bytes):
            if self.zipwriter.is_cancelled():
                return pycurl.READFUNC_ABORT

            data = os.read(self.fdread, bytes)
            sys.stdout.flush()

            return data

        c = pycurl.Curl()
        c.setopt(pycurl.URL, UPLOAD_URL)
        c.setopt(pycurl.UPLOAD, 1)
        c.setopt(pycurl.READFUNCTION, curl_read)
        c.setopt(pycurl.INFILESIZE, self._upload_file_size)
        # Very slow upload for testing purposes
        #c.setopt(pycurl.MAX_SEND_SPEED_LARGE, 100*1024)

        def curl_runner():
            try:
                c.perform()
                response = c.getinfo(c.RESPONSE_CODE)
            except Exception, e:
                print(e)
                response = -1
            c.close()

            if response // 100 != 2:
                if not self.zipwriter.is_cancelled():
                    GLib.idle_add(self.show_error, response)

            GLib.idle_add(self.cleanup)

        self.thread = threading.Thread(target=curl_runner)
        self.thread.start()

    def show_error(self, response):
        error = Gtk.MessageDialog(title='Upload Error')
        error.set_transient_for(self.ui.win)
        if response == 409:
            error.set_markup('The uploaded dataset already existed on the server!')
        elif response == 500:
            error.set_markup('Server side error. Maybe the bundle is corrupted?')
        else:
            error.set_markup('Error uploading the bundle ({:d})!'.format(response))
        error.add_button("_OK", Gtk.ResponseType.CANCEL)
        error.connect("response", lambda dialog, resp : dialog.destroy())
        error.show_all()

    def cleanup(self):
        self.dialog.destroy()

    def _upload_response_cb(self, dialog, response_id):
        if response_id == Gtk.ResponseType.NONE:
            return

        self.zipwriter.cancel()
        self.cleanup()

    def _upload_progress_cb(self, zipwriter, bytes_written):
        if self.bundle.estimated_size < 0:
            # No size estimation yet, so just pulse the progress bar
            self.pbar.pulse()
        self.pbar.set_fraction(bytes_written / self.bundle.estimated_size)


class UI:

    def __init__(self):
        self.win = Gtk.Window()

        hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL)
        self.win.add(hbox)

        self._selected_bundle = None

        self.listbox = Gtk.ListBox()
        listscroll = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
        listscroll.add(self.listbox)
        listscroll.set_size_request(-1, 500)
        hbox.add(listscroll)
        for bundle in bundles:
            label = Gtk.Label(xalign=0)
            label.set_markup('')
            self.listbox.add(label)
            bundle.label = label
            label.bundle = bundle

            self.update_label(bundle)
            bundle.connect('notify::size', lambda bundle, *args: self.update_label(bundle))
            bundle.connect('notify::estimated-size', lambda bundle, *args: self.update_label(bundle))


        detailbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
        hbox.pack_end(detailbox, expand=True, fill=True, padding=5)

        label = Gtk.Label(xalign=0, label='<b>Notes for this Test</b>', use_markup=True)
        detailbox.pack_start(label, expand=False, fill=True, padding=5)

        textscroll = Gtk.ScrolledWindow(hscrollbar_policy=Gtk.PolicyType.NEVER)
        detailbox.pack_start(textscroll, expand=True, fill=True, padding=0)
        self.textview = Gtk.TextView(wrap_mode=Gtk.WrapMode.WORD_CHAR)
        textscroll.add(self.textview)


        hbox = Gtk.Box(Gtk.Orientation.HORIZONTAL)
        detailbox.pack_end(hbox, expand=False, fill=True, padding=5)

        upload_warning = Gtk.Label(xalign=0.0, wrap=True)
        upload_warning.set_markup('<b>Warning</b>\n' +
            'The bundle contains information which uniquely identifies your computer '
            'and by uploading it you agree to the data to be shared <i>publically</i>.\n\n'
            'This data allows developers and users to get a good idea of the '
            'hardware that is built into your computer model. This is valuable '
            'information to understand and fix hardware issues that you may be '
            'experiencing. By collecting all this information from the system '
            'some information (such as <i>disk partition names</i>, '
            '<i>network adapter MAC address</i>, '
            '<i>laptop/monitor serial numbers</i>, ) '
            'are also included and will be available to anyone who cares to look.')
        # Otherwise the minimum height of the surrounding box will be huge
        upload_warning.set_width_chars(80)
        upload_warning.set_max_width_chars(80)

        hbox.pack_start(upload_warning, expand=True, fill=True, padding=5)

        bbox = Gtk.ButtonBox()
        self.upload_btn = Gtk.Button('Upload')
        self.upload_btn.connect('clicked', self.upload_clicked_cb)
        self.upload_btn.set_sensitive(False)
        bbox.add(self.upload_btn)
        hbox.pack_end(bbox, expand=False, fill=True, padding=5)


        self.listbox.connect('row-selected', self.bundle_selected)

        self.win.connect('destroy', lambda *args : Gtk.main_quit())
        self.win.show_all()

    def bundle_selected(self, listbox, row):
        widget = row.get_child()
        buf = self.textview.get_buffer()

        if self._selected_bundle:
            start_iter = buf.get_start_iter()
            end_iter = buf.get_end_iter()
            self._selected_bundle.note = buf.get_text(start_iter, end_iter, False)

        self.upload_btn.set_sensitive(True)
        self._selected_bundle = widget.bundle

        note = widget.bundle.note
        if note is None:
            note = ''

        buf.set_text(note)

    def update_label(self, bundle):
        if bundle.estimated_size > 0:
            size_str = '{:s} to upload'.format(GLib.format_size(bundle.estimated_size))
        elif bundle.size > 0:
            size_str = '{:s} uncompressed'.format(GLib.format_size(bundle.size))
        else:
            size_str = 'Unknown Size'

        bundle.label.set_markup(
            '''<b>{:s}</b>\n{:s}'''.format(
                GLib.markup_escape_text(bundle.descr), size_str)
            )

    def upload_clicked_cb(self, btn, *args):
        Upload(self, self._selected_bundle)


ui = UI()

Gtk.main()


