#!/usr/bin/python3

## Copyright (C) 2007, 2008, 2009, 2010, 2012, 2013, 2014 Red Hat, Inc.
## Copyright (C) 2008 Novell, Inc.
## Authors: Tim Waugh <twaugh@redhat.com>, Vincent Untz

## 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; either version 2 of the License, or
## (at your option) any later version.

## 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, write to the Free Software
## Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

import cups
import dbus
from functools import reduce
try:
    import gi
    gi.require_version('Gdk', '3.0')
    from gi.repository import Gdk
    gi.require_version('Gtk', '3.0')
    from gi.repository import Gtk
except:
    pass
import os
import sys
import tempfile
import xml.etree.ElementTree

import asyncipp
from debug import *
import debug

CUPS_PK_NAME  = 'org.opensuse.CupsPkHelper.Mechanism'
CUPS_PK_PATH  = '/'
CUPS_PK_IFACE = 'org.opensuse.CupsPkHelper.Mechanism'
CUPS_PK_NEED_AUTH = 'org.opensuse.CupsPkHelper.Mechanism.NotPrivileged'

######
###### A polkit-1 based asynchronous CupsPkHelper interface made to
###### look just like a normal IPPAuthConnection class.  For method
###### calls that have no equivalent in the CupsPkHelper API, IPP
###### authentication is used over a CUPS connection in a separate
###### thread.
######

_DevicesGet_uses_new_api = None

###
### A class to handle an asynchronous method call.
###
class _PK1AsyncMethodCall:
    def __init__ (self, bus, conn, pk_method_name, pk_args,
                  reply_handler, error_handler, unpack_fn,
                  fallback_fn, args, kwds):
        self._bus = bus
        self._conn = conn
        self._pk_method_name = pk_method_name
        self._pk_args = pk_args
        self._client_reply_handler = reply_handler
        self._client_error_handler = error_handler
        self._unpack_fn = unpack_fn
        self._fallback_fn = fallback_fn
        self._fallback_args = args
        self._fallback_kwds = kwds
        self._destroyed = False
        debugprint ("+_PK1AsyncMethodCall: %s" % self)

    def __del__ (self):
        debug.debugprint ("-_PK1AsyncMethodCall: %s" % self)

    def call (self):
        object = self._bus.get_object(CUPS_PK_NAME, CUPS_PK_PATH)
        proxy = dbus.Interface (object, CUPS_PK_IFACE)
        pk_method = proxy.get_dbus_method (self._pk_method_name)

        try:
            debugprint ("%s: calling %s" % (self, pk_method))
            pk_method (*self._pk_args,
                        reply_handler=self._pk_reply_handler,
                        error_handler=self._pk_error_handler,
                        timeout=3600)
        except TypeError as e:
            debugprint ("Type error in PK call: %s" % repr (e))
            self.call_fallback_fn ()

    def _destroy (self):
        debugprint ("DESTROY: %s" % self)
        self._destroyed = True
        del self._bus
        del self._conn
        del self._pk_method_name
        del self._pk_args
        del self._client_reply_handler
        del self._client_error_handler
        del self._unpack_fn
        del self._fallback_fn
        del self._fallback_args
        del self._fallback_kwds

    def _pk_reply_handler (self, error, *args):
        if self._destroyed:
            return

        if str (error) == '':
            try:
                Gdk.threads_enter ()
            except:
                pass
            debugprint ("%s: no error, calling reply handler %s" %
                        (self, self._client_reply_handler))
            self._client_reply_handler (self._conn, self._unpack_fn (*args))
            try:
                Gdk.threads_leave ()
            except:
                pass
            self._destroy ()
            return

        debugprint ("PolicyKit method failed with: %s" % repr (error))
        self.call_fallback_fn ()

    def _pk_error_handler (self, exc):
        if self._destroyed:
            return

        if exc.get_dbus_name () == CUPS_PK_NEED_AUTH:
            exc = cups.IPPError (cups.IPP_NOT_AUTHORIZED, 'pkcancel')
            try:
                Gdk.threads_enter ()
            except:
                pass
            debugprint ("%s: no auth, calling error handler %s" %
                        (self, self._client_error_handler))
            self._client_error_handler (self._conn, exc)
            try:
                Gdk.threads_leave ()
            except:
                pass
            self._destroy ()
            return

        debugprint ("PolicyKit call to %s did not work: %s" %
                    (self._pk_method_name, repr (exc)))
        self.call_fallback_fn ()

    def call_fallback_fn (self):
        # Make the 'connection' parameter consistent with PK callbacks.
        self._fallback_kwds["reply_handler"] = self._ipp_reply_handler
        self._fallback_kwds["error_handler"] = self._ipp_error_handler
        debugprint ("%s: calling %s" % (self, self._fallback_fn))
        self._fallback_fn (*self._fallback_args, **self._fallback_kwds)

    def _ipp_reply_handler (self, conn, *args):
        if self._destroyed:
            return

        debugprint ("%s: chaining up to %s" % (self,
                                               self._client_reply_handler))
        self._client_reply_handler (self._conn, *args)
        self._destroy ()

    def _ipp_error_handler (self, conn, *args):
        if self._destroyed:
            return

        debugprint ("%s: chaining up to %s" % (self,
                                               self._client_error_handler))
        self._client_error_handler (self._conn, *args)
        self._destroy ()

###
### A class for handling FileGet when a temporary file is needed.
###
class _WriteToTmpFile:
    def __init__ (self, kwds, reply_handler, error_handler):
        self._reply_handler = reply_handler
        self._error_handler = error_handler

        # Create the temporary file in /tmp to ensure that
        # cups-pk-helper-mechanism is able to write to it.
        (tmpfd, tmpfname) = tempfile.mkstemp (dir="/tmp")
        os.close (tmpfd)
        self._filename = tmpfname
        debugprint ("Created tempfile %s" % tmpfname)
        self._kwds = kwds

    def __del__ (self):
        try:
            os.unlink (self._filename)
            debug.debugprint ("Removed tempfile %s" % self._filename)
        except:
            debug.debugprint ("No tempfile to remove")

    def get_filename (self):
        return self._filename

    def reply_handler (self, conn, none):
        tmpfd = os.open (self._filename, os.O_RDONLY)
        tmpfile = os.fdopen (tmpfd, 'rt')
        if "fd" in self._kwds:
            fd = self._kwds["fd"]
            os.lseek (fd, 0, os.SEEK_SET)
            line = tmpfile.readline ()
            while line != '':
                os.write (fd, line.encode('UTF-8'))
                line = tmpfile.readline ()
        else:
            file_object = self._kwds["file"]
            file_object.seek (0)
            line = tmpfile.readline ()
            while line != '':
                file_object.write (line.encode('UTF-8'))
                line = tmpfile.readline ()

        tmpfile.close ()
        self._reply_handler (conn, none)

    def error_handler (self, conn, exc):
        self._error_handler (conn, exc)

###
### The user-visible class.
###
class PK1Connection:
    def __init__(self, reply_handler=None, error_handler=None,
                 host=None, port=None, encryption=None, parent=None):
        self._conn = asyncipp.IPPAuthConnection  (reply_handler=reply_handler,
                                                  error_handler=error_handler,
                                                  host=host, port=port,
                                                  encryption=encryption,
                                                  parent=parent)

        try:
            self._system_bus = dbus.SystemBus()
        except (dbus.exceptions.DBusException, AttributeError):
            # No system D-Bus.
            self._system_bus = None

        global _DevicesGet_uses_new_api
        if _DevicesGet_uses_new_api is None and self._system_bus:
            try:
                obj = self._system_bus.get_object(CUPS_PK_NAME, CUPS_PK_PATH)
                proxy = dbus.Interface (obj, dbus.INTROSPECTABLE_IFACE)
                api = proxy.Introspect ()
                top = xml.etree.ElementTree.XML (api)
                for interface in top.findall ("interface"):
                    if interface.attrib.get ("name") != CUPS_PK_IFACE:
                        continue

                    for method in interface.findall ("method"):
                        if method.attrib.get ("name") != "DevicesGet":
                            continue

                        num_args = 0
                        for arg in method.findall ("arg"):
                            direction = arg.attrib.get ("direction")
                            if direction != "in":
                                continue

                            num_args += 1

                        _DevicesGet_uses_new_api = num_args == 4
                        debugprint ("DevicesGet new API: %s" % (num_args == 4))
                        break

                    break

            except Exception as e:
                debugprint ("Exception assessing DevicesGet API: %s" % repr (e))

        methodtype = type (self._conn.getPrinters)
        bindings = []
        for fname in dir (self._conn):
            if fname.startswith ('_'):
                continue
            fn = getattr (self._conn, fname)
            if type (fn) != methodtype:
                continue
            if not hasattr (self, fname):
                setattr (self, fname, self._make_binding (fn))
                bindings.append (fname)

        self._bindings = bindings
        debugprint ("+%s" % self)

    def __del__ (self):
        debug.debugprint ("-%s" % self)

    def _make_binding (self, fn):
        def binding (*args, **kwds):
            op = _PK1AsyncMethodCall (None, self, None, None,
                                      kwds.get ("reply_handler"),
                                      kwds.get ("error_handler"),
                                      None, fn, args, kwds)
            op.call_fallback_fn ()

        return binding

    def destroy (self):
        debugprint ("DESTROY: %s" % self)
        self._conn.destroy ()

        for binding in self._bindings:
            delattr (self, binding)

    def _coerce (self, typ, val):
        return typ (val)

    def _args_kwds_to_tuple (self, types, params, args, kwds):
        """Collapse args and kwds into a single tuple."""
        leftover_kwds = kwds.copy ()
        reply_handler = leftover_kwds.get ("reply_handler")
        error_handler = leftover_kwds.get ("error_handler")
        if "reply_handler" in leftover_kwds:
            del leftover_kwds["reply_handler"]
        if "error_handler" in leftover_kwds:
            del leftover_kwds["error_handler"]
        if "auth_handler" in leftover_kwds:
            del leftover_kwds["auth_handler"]

        result = [True, reply_handler, error_handler, ()]
        if self._system_bus is None:
            return result

        tup = []
        argindex = 0
        for arg in args:
            try:
                val = self._coerce (types[argindex], arg)
            except IndexError:
                # More args than types.
                kw, default = params[argindex]
                if default != arg:
                    return result

                # It's OK, this is the default value anyway and can be
                # ignored.  Skip to the next one.
                argindex += 1
                continue
            except TypeError as e:
                debugprint ("Error converting %s to %s" %
                            (repr (arg), types[argindex]))
                return result

            tup.append (val)
            argindex += 1

        for kw, default in params[argindex:]:
            if kw in leftover_kwds:
                try:
                    val = self._coerce (types[argindex], leftover_kwds[kw])
                except TypeError as e:
                    debugprint ("Error converting %s to %s" %
                                (repr (leftover_kwds[kw]), types[argindex]))
                    return result

                tup.append (val)
                del leftover_kwds[kw]
            else:
                tup.append (default)

            argindex += 1

        if leftover_kwds:
            debugprint ("Leftover keywords: %s" % repr (list(leftover_kwds.keys ())))
            return result

        result[0] = False
        result[3] = tuple (tup)
        debugprint ("Converted %s/%s to %s" % (args, kwds, tuple (tup)))
        return result

    def _call_with_pk (self, use_pycups, pk_method_name, pk_args,
                       reply_handler, error_handler, unpack_fn,
                       fallback_fn, args, kwds):
        asyncmethodcall = _PK1AsyncMethodCall (self._system_bus, self,
                                               pk_method_name, pk_args,
                                               reply_handler,
                                               error_handler,
                                               unpack_fn, fallback_fn,
                                               args, kwds)

        if not use_pycups:
            try:
                debugprint ("Calling PK method %s" % pk_method_name)
                asyncmethodcall.call ()
            except dbus.DBusException as e:
                debugprint ("D-Bus call failed: %s" % repr (e))
                use_pycups = True

        if use_pycups:
            return asyncmethodcall.call_fallback_fn ()

    def _nothing_to_unpack (self):
        return None

    def getDevices (self, *args, **kwds):
        global _DevicesGet_uses_new_api
        if _DevicesGet_uses_new_api:
            (use_pycups, reply_handler, error_handler,
             tup) = self._args_kwds_to_tuple ([int, int, list, list],
                                              [("timeout", 0),
                                               ("limit", 0),
                                               ("include_schemes", []),
                                               ("exclude_schemes", [])],
                                              args, kwds)
        else:
            (use_pycups, reply_handler, error_handler,
             tup) = self._args_kwds_to_tuple ([int, list, list],
                                              [("limit", 0),
                                               ("include_schemes", []),
                                               ("exclude_schemes", [])],
                                              args, kwds)

            if not use_pycups:
                # Special handling for include_schemes/exclude_schemes.
                # Convert from list to ","-separated string.
                newtup = list (tup)
                for paramindex in [1, 2]:
                    if len (newtup[paramindex]) > 0:
                        newtup[paramindex] = reduce (lambda x, y:
                                                         x + "," + y,
                                                     newtup[paramindex])
                    else:
                        newtup[paramindex] = ""

                tup = tuple (newtup)

        self._call_with_pk (use_pycups,
                            'DevicesGet', tup, reply_handler, error_handler,
                            self._unpack_getDevices_reply,
                            self._conn.getDevices, args, kwds)

    def _unpack_getDevices_reply (self, dbusdict):
        result_str = dict()
        for key, value in dbusdict.items ():
            if type (key) == dbus.String:
                result_str[str (key)] = str (value)
            else:
                result_str[key] = value

        # cups-pk-helper returns all devices in one dictionary.
        # Keys of different devices are distinguished by ':n' postfix.

        devices = dict()
        n = 0
        affix = ':' + str (n)
        device_keys = [x for x in result_str.keys () if x.endswith (affix)]
        while len (device_keys) > 0:
            device_uri = None
            device_dict = dict()
            for keywithaffix in device_keys:
                key = keywithaffix[:len (keywithaffix) - len (affix)]
                if key != 'device-uri':
                    device_dict[key] = result_str[keywithaffix]
                else:
                    device_uri = result_str[keywithaffix]

            if device_uri is not None:
                devices[device_uri] = device_dict

            n += 1
            affix = ':' + str (n)
            device_keys = [x for x in result_str.keys () if x.endswith (affix)]

        return devices

    def cancelJob (self, *args, **kwds):
        (use_pycups, reply_handler, error_handler,
         tup) = self._args_kwds_to_tuple ([int, bool],
                                          [(None, None),
                                           (None, False)], # purge_job
                                          args, kwds)

        self._call_with_pk (use_pycups,
                            'JobCancelPurge', tup, reply_handler, error_handler,
                            self._nothing_to_unpack,
                            self._conn.cancelJob, args, kwds)

    def setJobHoldUntil (self, *args, **kwds):
        (use_pycups, reply_handler, error_handler,
         tup) = self._args_kwds_to_tuple ([int, str],
                                          [(None, None),
                                           (None, None)],
                                          args, kwds)

        self._call_with_pk (use_pycups,
                            'JobSetHoldUntil', tup, reply_handler,
                            error_handler, self._nothing_to_unpack,
                            self._conn.setJobHoldUntil, args, kwds)

    def restartJob (self, *args, **kwds):
        (use_pycups, reply_handler, error_handler,
         tup) = self._args_kwds_to_tuple ([int],
                                          [(None, None)],
                                          args, kwds)

        self._call_with_pk (use_pycups,
                            'JobRestart', tup, reply_handler,
                            error_handler, self._nothing_to_unpack,
                            self._conn.restartJob, args, kwds)

    def getFile (self, *args, **kwds):
        (use_pycups, reply_handler, error_handler,
         tup) = self._args_kwds_to_tuple ([str, str],
                                          [("resource", None),
                                           ("filename", None)],
                                          args, kwds)

        # getFile(resource, filename=None, fd=-1, file=None) -> None
        if use_pycups:
            if ((len (args) == 0 and 'resource' in kwds) or
                (len (args) == 1)):
                can_use_tempfile = True
                for each in kwds.keys ():
                    if each not in ['resource', 'fd', 'file',
                                    'reply_handler', 'error_handler']:
                        can_use_tempfile = False
                        break

                if can_use_tempfile:
                    # We can still use PackageKit for this.
                    if len (args) == 0:
                        resource = kwds["resource"]
                    else:
                        resource = args[0]

                    wrapper = _WriteToTmpFile (kwds,
                                               reply_handler,
                                               error_handler)
                    self._call_with_pk (False,
                                        'FileGet',
                                        (resource, wrapper.get_filename ()),
                                        wrapper.reply_handler,
                                        wrapper.error_handler,
                                        self._nothing_to_unpack,
                                        self._conn.getFile, args, kwds)
                    return

        self._call_with_pk (use_pycups,
                            'FileGet', tup, reply_handler,
                            error_handler, self._nothing_to_unpack,
                            self._conn.getFile, args, kwds)

    ## etc
    ## Still to implement:
    ## putFile
    ## addPrinter
    ## setPrinterDevice
    ## setPrinterInfo
    ## setPrinterLocation
    ## setPrinterShared
    ## setPrinterJobSheets
    ## setPrinterErrorPolicy
    ## setPrinterOpPolicy
    ## setPrinterUsersAllowed
    ## setPrinterUsersDenied
    ## addPrinterOptionDefault
    ## deletePrinterOptionDefault
    ## deletePrinter
    ## addPrinterToClass
    ## deletePrinterFromClass
    ## deleteClass
    ## setDefault
    ## enablePrinter
    ## disablePrinter
    ## acceptJobs
    ## rejectJobs
    ## adminGetServerSettings
    ## adminSetServerSettings
    ## ...

if __name__ == '__main__':
    from gi.repository import GObject
    from debug import set_debugging
    set_debugging (True)
    class UI:
        def __init__ (self):
            w = Gtk.Window ()
            v = Gtk.VBox ()
            w.add (v)
            b = Gtk.Button.new_with_label ("Go")
            v.pack_start (b, False, False, 0)
            b.connect ("clicked", self.button_clicked)
            b = Gtk.Button.new_with_label ("Fetch")
            v.pack_start (b, False, False, 0)
            b.connect ("clicked", self.fetch_clicked)
            b.set_sensitive (False)
            self.fetch_button = b
            b = Gtk.Button.new_with_label ("Cancel job")
            v.pack_start (b, False, False, 0)
            b.connect ("clicked", self.cancel_clicked)
            b.set_sensitive (False)
            self.cancel_button = b
            b = Gtk.Button.new_with_label ("Get file")
            v.pack_start (b, False, False, 0)
            b.connect ("clicked", self.get_file_clicked)
            b.set_sensitive (False)
            self.get_file_button = b
            b = Gtk.Button.new_with_label ("Something harmless")
            v.pack_start (b, False, False, 0)
            b.connect ("clicked", self.harmless_clicked)
            b.set_sensitive (False)
            self.harmless_button = b
            w.connect ("destroy", self.destroy)
            w.show_all ()
            self.conn = None
            debugprint ("+%s" % self)

        def __del__ (self):
            debug.debugprint ("-%s" % self)

        def destroy (self, window):
            debugprint ("DESTROY: %s" % self)
            try:
                self.conn.destroy ()
                del self.conn
            except AttributeError:
                pass

            Gtk.main_quit ()

        def button_clicked (self, button):
            if self.conn:
                self.conn.destroy ()

            self.conn = PK1Connection (reply_handler=self.connected,
                                       error_handler=self.connection_error)

        def connected (self, conn, result):
            print("Connected")
            self.fetch_button.set_sensitive (True)
            self.cancel_button.set_sensitive (True)
            self.get_file_button.set_sensitive (True)
            self.harmless_button.set_sensitive (True)

        def connection_error (self, conn, error):
            print("Failed to connect")
            raise error

        def fetch_clicked (self, button):
            print ("fetch devices...")
            self.conn.getDevices (reply_handler=self.got_devices,
                                  error_handler=self.get_devices_error)

        def got_devices (self, conn, devices):
            if conn != self.conn:
                print("Ignoring stale reply")
                return

            print("got devices: %s" % devices)

        def get_devices_error (self, conn, exc):
            if conn != self.conn:
                print("Ignoring stale error")
                return

            print("devices error: %s" % repr (exc))

        def cancel_clicked (self, button):
            print("Cancel job...")
            self.conn.cancelJob (1,
                                 reply_handler=self.job_canceled,
                                 error_handler=self.cancel_job_error)

        def job_canceled (self, conn, none):
            if conn != self.conn:
                print("Ignoring stale reply for %s" % conn)
                return

            print("Job canceled")

        def cancel_job_error (self, conn, exc):
            if conn != self.conn:
                print("Ignoring stale error for %s" % conn)
                return

            print("cancel error: %s" % repr (exc))

        def get_file_clicked (self, button):
            self.my_file = open ("cupsd.conf", "w")
            self.conn.getFile ("/admin/conf/cupsd.conf", file=self.my_file,
                               reply_handler=self.got_file,
                               error_handler=self.get_file_error)

        def got_file (self, conn, none):
            if conn != self.conn:
                print("Ignoring stale reply for %s" % conn)
                return

            print("Got file")

        def get_file_error (self, conn, exc):
            if conn != self.conn:
                print("Ignoring stale error")
                return

            print("get file error: %s" % repr (exc))

        def harmless_clicked (self, button):
            self.conn.getJobs (reply_handler=self.got_jobs,
                               error_handler=self.get_jobs_error)

        def got_jobs (self, conn, result):
            if conn != self.conn:
                print("Ignoring stale reply from %s" % repr (conn))
                return
            print(result)

        def get_jobs_error (self, exc):
            print("get jobs error: %s" % repr (exc))

    UI ()
    from dbus.mainloop.glib import DBusGMainLoop
    DBusGMainLoop (set_as_default=True)
    Gtk.main ()
