/* Copyright 2009-2012 Yorba Foundation
 *
 * This software is licensed under the GNU Lesser General Public License
 * (version 2.1 or later).  See the COPYING file in this distribution. 
 */
using Publishing.Extras;

public class PiwigoService : Object, Spit.Pluggable, Spit.Publishing.Service {
    private const string ICON_FILENAME = "piwigo.png";

    private static Gdk.Pixbuf[] icon_pixbuf_set = null;
    
    public PiwigoService(GLib.File resource_directory) {
        if (icon_pixbuf_set == null)
            icon_pixbuf_set = Resources.load_icon_set(resource_directory.get_child(ICON_FILENAME));
    }
    
    public int get_pluggable_interface(int min_host_interface, int max_host_interface) {
        return Spit.negotiate_interfaces(min_host_interface, max_host_interface,
            Spit.Publishing.CURRENT_INTERFACE);
    }
    
    public unowned string get_id() {
        return "org.yorba.shotwell.publishing.piwigo";
    }
    
    public unowned string get_pluggable_name() {
        return "Piwigo";
    }
    
    public void get_info(ref Spit.PluggableInfo info) {
        info.authors = "Bruno Girin";
        info.copyright = _("Copyright 2009-2012 Yorba Foundation");
        info.translators = Resources.TRANSLATORS;
        info.version = _VERSION;
        info.website_name = Resources.WEBSITE_NAME;
        info.website_url = Resources.WEBSITE_URL;
        info.is_license_wordwrapped = false;
        info.license = Resources.LICENSE;
        info.icons = icon_pixbuf_set;
    }

    public void activation(bool enabled) {
    }

    public Spit.Publishing.Publisher create_publisher(Spit.Publishing.PluginHost host) {
        return new Publishing.Piwigo.PiwigoPublisher(this, host);
    }
    
    public Spit.Publishing.Publisher.MediaType get_supported_media() {
        return (Spit.Publishing.Publisher.MediaType.PHOTO);
    }
}

namespace Publishing.Piwigo {

internal const string SERVICE_NAME = "Piwigo";
internal const string PIWIGO_WS = "ws.php";
internal const string SERVICE_WELCOME_MESSAGE = 
    _("You are not currently logged into your Piwigo photo library.\n\nPlease login in order to publish your photos to your library.");
internal const string ERROR_MESSAGE_401 =
    _("Access denied to your Piwigo library: please make sure that the user you have configured in Shotwell has the correct privileges to upload pictures.");
// TODO: make the service smarter so that it can do API version negociation
// and handle older version of the Piwigo API
internal const string ERROR_MESSAGE_501 =
    _("The Piwigo service you specified does not recognise the method requested by Shotwell. This can happen if the Piwigo service you are trying to publish to is an old version. Please check that your Piwigo service is version 2.1 or newer.");
internal const string ERROR_MESSAGE_GENERIC =
    _("An error message occured when publishing to Piwigo. Please try again.");
internal const int ORIGINAL_SIZE = -1;

internal class Category {
    public int id;
    public string name;
    public static const int NO_ID = -1;

    public Category(int id, string name) {
        this.id = id;
        this.name = name;
    }
    
    public Category.local(string name) {
        this.id = NO_ID;
        this.name = name;
    }
    
    public bool is_local() {
        return this.id == NO_ID;
    }
}

internal class PermissionLevel {
    public int id;
    public string name;

    public PermissionLevel(int id, string name) {
        this.id = id;
        this.name = name;
    }
}

internal class SizeEntry {
    public int id;
    public string name;

    public SizeEntry(int id, string name) {
        this.id = id;
        this.name = name;
    }
}

internal class PublishingParameters {
    public Category category = null;
    public PermissionLevel perm_level = null;
    public SizeEntry photo_size = null;

    public PublishingParameters() {
    }
}

public class PiwigoPublisher : Spit.Publishing.Publisher, GLib.Object {
    private Spit.Publishing.Service service;
    private Spit.Publishing.PluginHost host;
    private bool running = false;
    private Session session;
    private Category[] categories = null;
    private PublishingParameters parameters = null;
    private Spit.Publishing.ProgressCallback progress_reporter = null;

    public PiwigoPublisher(Spit.Publishing.Service service,
        Spit.Publishing.PluginHost host) {
        debug("PiwigoPublisher instantiated.");
        this.service = service;
        this.host = host;
        session = new Session();
    }
    
    // Publisher interface implementation
    
    public Spit.Publishing.Service get_service() {
        return service;
    }
    
    public Spit.Publishing.PluginHost get_host() {
        return host;
    }

    public bool is_running() {
        return running;
    }
    
    public void start() {
        if (is_running())
            return;
        
        debug("PiwigoPublisher: starting interaction.");
        
        running = true;
        
        if (session.is_authenticated()) {
            debug("PiwigoPublisher: session is authenticated.");
            do_fetch_categories();
        } else {
            debug("PiwigoPublisher: session is not authenticated.");
            string? persistent_url = get_persistent_url();
            string? persistent_username = get_persistent_username();
            string? persistent_password = get_persistent_password();
            if (persistent_url != null && persistent_username != null && persistent_password != null)
                do_network_login(persistent_url, persistent_username,
                    persistent_password, get_remember_password());
            else
                do_show_authentication_pane();
        }
    }
    
    public void stop() {
        running = false;
    }
    
    // Session and persistent data
    
    public string? get_persistent_url() {
        return host.get_config_string("url", null);
    }
    
    private void set_persistent_url(string url) {
        host.set_config_string("url", url);
    }
    
    public string? get_persistent_username() {
        return host.get_config_string("username", null);
    }
    
    private void set_persistent_username(string username) {
        host.set_config_string("username", username);
    }
    
    public string? get_persistent_password() {
        return host.get_config_string("password", null);
    }
    
    private void set_persistent_password(string? password) {
        host.set_config_string("password", password);
    }
    
    public bool get_remember_password() {
        return host.get_config_bool("remember-password", false);
    }
    
    private void set_remember_password(bool remember_password) {
        host.set_config_bool("remember-password", remember_password);
    }
    
    public int get_last_category() {
        return host.get_config_int("last-category", -1);
    }
    
    private void set_last_category(int last_category) {
        host.set_config_int("last-category", last_category);
    }
    
    public int get_last_permission_level() {
        return host.get_config_int("last-permission-level", -1);
    }
    
    private void set_last_permission_level(int last_permission_level) {
        host.set_config_int("last-permission-level", last_permission_level);
    }
    
    public int get_last_photo_size() {
        return host.get_config_int("last-photo-size", -1);
    }
    
    private void set_last_photo_size(int last_photo_size) {
        host.set_config_int("last-photo-size", last_photo_size);
    }
    
    // Actions and events implementation
    
    /**
     * Action that shows the authentication pane.
     *
     * This action method shows the authentication pane. It is shown at the
     * very beginning of the interaction when no persistent parameters are found
     * or after a failed login attempt using persisted parameters. It can be
     * given a mode flag to specify whether it should be displayed in initial
     * mode or in any of the error modes that it supports.
     *
     * @param mode the mode for the authentication pane
     */
    private void do_show_authentication_pane(AuthenticationPane.Mode mode = AuthenticationPane.Mode.INTRO) {
        debug("ACTION: installing authentication pane");

        host.set_service_locked(false);
        AuthenticationPane authentication_pane =
            new AuthenticationPane(this, mode);
        authentication_pane.login.connect(on_authentication_pane_login_clicked);
        host.install_dialog_pane(authentication_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE);
        host.set_dialog_default_widget(authentication_pane.get_default_widget());
    }

    /**
     * Event triggered when the login button in the authentication panel is
     * clicked.
     *
     * This event is triggered when the login button in the authentication
     * panel is clicked. It then triggers a network login interaction.
     *
     * @param url the URL of the Piwigo service as entered in the dialog
     * @param username the name of the Piwigo user as entered in the dialog
     * @param password the password of the Piwigo as entered in the dialog
     */
    private void on_authentication_pane_login_clicked(
        string url, string username, string password, bool remember_password
    ) {
        debug("EVENT: on_authentication_pane_login_clicked");
        if (!running)
            return;

        do_network_login(url, username, password, remember_password);
    }
    
    /**
     * Action to perform a network login to a Piwigo service.
     *
     * This action performs a network login a Piwigo service specified by a
     * URL and using the given user name and password as credentials.
     *
     * @param url the URL of the Piwigo service; this URL will be normalised
     *     before being used
     * @param username the name of the Piwigo user used to login
     * @param password the password of the Piwigo user used to login
     */
    private void do_network_login(string url, string username, string password, bool remember_password) {
        debug("ACTION: logging in");
        host.set_service_locked(true);
        host.install_login_wait_pane();
        
        set_remember_password(remember_password);
        if (remember_password)
            set_persistent_password(password);
        else
            set_persistent_password(null);

        SessionLoginTransaction login_trans = new SessionLoginTransaction(
            session, normalise_url(url), username, password);
        login_trans.network_error.connect(on_login_network_error);
        login_trans.completed.connect(on_login_network_complete);

        try {
            login_trans.execute();
        } catch (Spit.Publishing.PublishingError err) {
            debug("ERROR: do_network_login");
            do_show_error(err);
        }
    }
    
    public static string normalise_url(string url) {
        string norm_url = url;

        if(!norm_url.has_suffix(".php")) {
            if(!norm_url.has_suffix("/")) {
                norm_url = norm_url + "/";
            }
            norm_url = norm_url + PIWIGO_WS;
        }

        if(!norm_url.has_prefix("http://") && !norm_url.has_prefix("https://")) {
            norm_url = "http://" + norm_url;
        }
        
        return norm_url;
    }
    
    /**
     * Event triggered when the network login action is complete and successful.
     *
     * This event is triggered on successful completion of a network login.
     * Calling this event implies that the URL, user name and password provided
     * in the authentication pane are valid and that the transaction should
     * contain a Set-Cookie header that includes the value pwg_id for that
     * user. As a result, this event will also authenticate the session and
     * persist all values so that they can be re-used during the next publishing
     * interaction.
     *
     * @param txn the received REST transaction
     */
    private void on_login_network_complete(Publishing.RESTSupport.Transaction txn) {
        debug("EVENT: on_login_network_complete");
        txn.completed.disconnect(on_login_network_complete);
        txn.network_error.disconnect(on_login_network_error);
        
        try {
            Publishing.RESTSupport.XmlDocument.parse_string(
                txn.get_response(), Transaction.validate_xml);
        } catch (Spit.Publishing.PublishingError err) {
            // Get error code first
            try {
                Publishing.RESTSupport.XmlDocument.parse_string(
                    txn.get_response(), Transaction.get_error_code);
            } catch (Spit.Publishing.PublishingError code) {
                int code_int = int.parse(code.message);
                if (code_int == 999) {
                    debug("ERROR: on_login_network_complete, code 999");
                    do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_USER);
                } else {
                    debug("ERROR: on_login_network_complete");
                    do_show_error(err);
                }
            }
            return;
        }
        // Get session ID and authenticate the session
        string endpoint_url = txn.get_endpoint_url(); 
        debug("Setting endpoint URL to %s", endpoint_url);
        string pwg_id = get_pwg_id_from_transaction(txn);
        debug("Setting session pwg_id to %s", pwg_id);
        session = new Session();
        session.set_pwg_id(pwg_id);

        do_fetch_session_status(endpoint_url, pwg_id);
    }
    
    /**
     * Event triggered when a network login action fails due to a network error.
     *
     * This event triggered as a result of a network error during the login
     * transaction. As a result, it assumes that the service URL entered in the
     * authentication dialog is incorrect and re-presents the authentication
     * dialog with FAILED_RETRY_URL mode.
     *
     * @param bad_txn the received REST transaction
     * @param err the received error
     */
    private void on_login_network_error(
        Publishing.RESTSupport.Transaction bad_txn,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_login_network_error");
        bad_txn.completed.disconnect(on_login_network_complete);
        bad_txn.network_error.disconnect(on_login_network_error);

        if (session.is_authenticated()) // ignore these events if the session is already auth'd
            return;

        do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_URL);
    }
    
    /**
     * Action to fetch the session status for a known Piwigo user.
     *
     * This action fetches the session status for a Piwigo user for whom the
     * pwg_id is known. If triggered after a network login, it should just
     * confirm that the session is OK. It can also be triggered as the first
     * action of the interaction for users for who the pwg_id was previously
     * persisted. In this case, it will log the user in and confirm the
     * identity.
     */
    private void do_fetch_session_status(string url = "", string pwg_id = "") {
        debug("ACTION: fetching session status");
        host.set_service_locked(true);
        host.install_account_fetch_wait_pane();
        
        if (!session.is_authenticated()) {
            SessionGetStatusTransaction status_txn = new SessionGetStatusTransaction.unauthenticated(session, url, pwg_id);
            status_txn.network_error.connect(on_session_get_status_error);
            status_txn.completed.connect(on_session_get_status_complete);

            try {
                status_txn.execute();
            } catch (Spit.Publishing.PublishingError err) {
                debug("ERROR: do_fetch_session_status, not authenticated");
                do_show_error(err);
            }
        } else {
            SessionGetStatusTransaction status_txn = new SessionGetStatusTransaction(session);
            status_txn.network_error.connect(on_session_get_status_error);
            status_txn.completed.connect(on_session_get_status_complete);

            try {
                status_txn.execute();
            } catch (Spit.Publishing.PublishingError err) {
                debug("ERROR: do_fetch_session_status, authenticated");
                do_show_error(err);
            }
        }
    }
    
    /**
     * Event triggered when the get session status action completes successfully.
     *
     * This event being triggered confirms that the session is valid and can be
     * used. If the session is not fully authenticated yet, this event finalises
     * session authentication. It then triggers the fetch categories action.
     */
    private void on_session_get_status_complete(Publishing.RESTSupport.Transaction txn) {
        debug("EVENT: on_session_get_status_complete");
        txn.completed.disconnect(on_session_get_status_complete);
        txn.network_error.disconnect(on_session_get_status_error);

        if (!session.is_authenticated()) {
            string endpoint_url = txn.get_endpoint_url();
            string pwg_id = session.get_pwg_id();
            debug("Fetching session status for pwg_id %s", pwg_id);
            // Parse the response
            try {
                Publishing.RESTSupport.XmlDocument doc =
                    Publishing.RESTSupport.XmlDocument.parse_string(
                        txn.get_response(), Transaction.validate_xml);
                Xml.Node* root = doc.get_root_node();
                Xml.Node* username_node;
                try {
                    username_node = doc.get_named_child(root, "username");
                    string username = username_node->get_content();
                    debug("Returned username is %s", username);
                    session.authenticate(endpoint_url, username, pwg_id);
                    set_persistent_url(session.get_pwg_url());
                    set_persistent_username(session.get_username());
                    do_fetch_categories();
                } catch (Spit.Publishing.PublishingError err2) {
                    debug("ERROR: on_session_get_status_complete, inner");
                    do_show_error(err2);
                    return;
                }
            } catch (Spit.Publishing.PublishingError err) {
                debug("ERROR: on_session_get_status_complete, outer");
                do_show_error(err);
                return;
            }
        } else {
            // This should never happen as the session should not be
            // authenticated at that point so this call is a safeguard
            // against the interaction not happening properly.
            do_fetch_categories();
        }
    }
    
    /**
     * Event triggered when the get session status fails due to a network error.
     */
    private void on_session_get_status_error(
        Publishing.RESTSupport.Transaction bad_txn,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_session_get_status_error");
        bad_txn.completed.disconnect(on_session_get_status_complete);
        bad_txn.network_error.disconnect(on_session_get_status_error);
        on_network_error(bad_txn, err);
    }

    /**
     * Action that fetches all available categories from the Piwigo service.
     *
     * This action fetches all categories from the Piwigo service in order
     * to populate the publishing pane presented to the user.
     */
    private void do_fetch_categories() {
        debug("ACTION: fetching categories");
        host.set_service_locked(true);
        host.install_account_fetch_wait_pane();

        CategoriesGetListTransaction cat_trans = new CategoriesGetListTransaction(session);
        cat_trans.network_error.connect(on_category_fetch_error);
        cat_trans.completed.connect(on_category_fetch_complete);
        
        try {
            cat_trans.execute();
        } catch (Spit.Publishing.PublishingError err) {
            debug("ERROR: do_fetch_categories");
            do_show_error(err);
        }
    }
    
    /**
     * Event triggered when the fetch categories action completes successfully.
     *
     * This event retrieves all categories from the received transaction and
     * populates the categories list. It then triggers the display of the
     * publishing options pane.
     */
    private void on_category_fetch_complete(Publishing.RESTSupport.Transaction txn) {
        debug("EVENT: on_category_fetch_complete");
        txn.completed.disconnect(on_category_fetch_complete);
        txn.network_error.disconnect(on_category_fetch_error);
        debug("PiwigoConnector: list of categories: %s", txn.get_response());
        // Empty the categories
        if (categories != null) {
            categories = null;
        }
        // Parse the response
        try {
            Publishing.RESTSupport.XmlDocument doc =
                Publishing.RESTSupport.XmlDocument.parse_string(
                    txn.get_response(), Transaction.validate_xml);
            Xml.Node* root = doc.get_root_node();
            Xml.Node* categories_node = root->first_element_child();
            Xml.Node* category_node_iter = categories_node->children;
            Xml.Node* name_node;
            string name = "";
            string id_string = "";
            for ( ; category_node_iter != null; category_node_iter = category_node_iter->next) {
                name_node = doc.get_named_child(category_node_iter, "name");
                name = name_node->get_content();
                id_string = category_node_iter->get_prop("id");
                if (categories == null) {
                    categories = new Category[0];
                }
                categories += new Category(int.parse(id_string), name);
            }
        } catch (Spit.Publishing.PublishingError err) {
            debug("ERROR: on_category_fetch_complete");
            do_show_error(err);
            return;
        }

        do_show_publishing_options_pane();
    }
    
    /**
     * Event triggered when the fetch categories transaction fails due to a
     * network error.
     */
    private void on_category_fetch_error(
        Publishing.RESTSupport.Transaction bad_txn,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_category_fetch_error");
        bad_txn.completed.disconnect(on_category_fetch_complete);
        bad_txn.network_error.disconnect(on_category_fetch_error);
        on_network_error(bad_txn, err);
    }
    
    /**
     * Action that shows the publishing options pane.
     *
     * This action method shows the publishing options pane.
     */
    private void do_show_publishing_options_pane() {
        debug("ACTION: installing publishing options pane");

        host.set_service_locked(false);
        PublishingOptionsPane opts_pane = new PublishingOptionsPane(
            this, categories, get_last_category(), get_last_permission_level(), get_last_photo_size()
        );
        opts_pane.logout.connect(on_publishing_options_pane_logout_clicked);
        opts_pane.publish.connect(on_publishing_options_pane_publish_clicked);
        host.install_dialog_pane(opts_pane, Spit.Publishing.PluginHost.ButtonMode.CLOSE);
        host.set_dialog_default_widget(opts_pane.get_default_widget());
    }
    
    /**
     * Event triggered when the user clicks logout in the publishing options pane.
     */
    private void on_publishing_options_pane_logout_clicked() {
        debug("EVENT: on_publishing_options_pane_logout_clicked");
        SessionLogoutTransaction logout_trans = new SessionLogoutTransaction(session);
        logout_trans.network_error.connect(on_logout_network_error);
        logout_trans.completed.connect(on_logout_network_complete);

        try {
            logout_trans.execute();
        } catch (Spit.Publishing.PublishingError err) {
            debug("ERROR: on_publishing_options_pane_logout_clicked");
            do_show_error(err);
        }
    }
    
    /**
     * Event triggered when the logout action completes successfully.
     *
     * This event de-authenticates the session and shows the authentication
     * pane again.
     */
    private void on_logout_network_complete(Publishing.RESTSupport.Transaction txn) {
        debug("EVENT: on_logout_network_complete");
        txn.completed.disconnect(on_logout_network_complete);
        txn.network_error.disconnect(on_logout_network_error);

        session.deauthenticate();

        do_show_authentication_pane(AuthenticationPane.Mode.INTRO);
    }
    
    /**
     * Event triggered when the logout action fails due to a network error.
     */
    private void on_logout_network_error(
        Publishing.RESTSupport.Transaction bad_txn,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_logout_network_error");
        bad_txn.completed.disconnect(on_logout_network_complete);
        bad_txn.network_error.disconnect(on_logout_network_error);
        on_network_error(bad_txn, err);
    }
    
    /**
     * Event triggered when the user clicks publish in the publishing options pane.
     *
     * This event first saves the parameters so that they can re-used later.
     * If the publishing parameters indicate that the user wants to create a new
     * category, the create category action is called. Otherwise, the upload
     * action is called.
     *
     * @param parameters the publishing parameters
     */
    private void on_publishing_options_pane_publish_clicked(PublishingParameters parameters) {
        debug("EVENT: on_publishing_options_pane_publish_clicked");
        this.parameters = parameters;

        if (parameters.category.is_local()) {
            do_create_category(parameters.category);
        } else {
            do_upload();
        }
    }
    
    /**
     * Action that creates a new category in the Piwigo library.
     *
     * This actions runs a REST transaction to create a new category in the
     * Piwigo library. It displays a wait pane with an information message
     * while the transaction is running. This action should only be called with
     * a local cateogory, i.e. one that does not exist on the server and does
     * not yet have an ID.
     *
     * @param category the new category to create on the server
     */
    private void do_create_category(Category category) {
        debug("ACTION: creating a new category: %s".printf(category.name));
        assert(category.is_local());

        host.set_service_locked(true);
        host.install_static_message_pane(_("Creating album %s...".printf(category.name)));

        CategoriesAddTransaction creation_trans = new CategoriesAddTransaction(
            session, category.name.strip());
        creation_trans.network_error.connect(on_category_add_error);
        creation_trans.completed.connect(on_category_add_complete);
        
        try {
            creation_trans.execute();
        } catch (Spit.Publishing.PublishingError err) {
            debug("ERROR: do_create_category");
            do_show_error(err);
        }
    }
    
    /**
     * Event triggered when the add category action completes successfully.
     *
     * This event parses the ID assigned to new category out of the received
     * transaction and assigns that ID to the category currently held in
     * the publishing parameters. It then calls the upload action.
     */
    private void on_category_add_complete(Publishing.RESTSupport.Transaction txn) {
        debug("EVENT: on_category_add_complete");
        txn.completed.disconnect(on_category_add_complete);
        txn.network_error.disconnect(on_category_add_error);

        // Parse the response
        try {
            Publishing.RESTSupport.XmlDocument doc =
                Publishing.RESTSupport.XmlDocument.parse_string(
                    txn.get_response(), Transaction.validate_xml);
            Xml.Node* rsp = doc.get_root_node();
            Xml.Node* id_node;
            id_node = doc.get_named_child(rsp, "id");
            string id_string = id_node->get_content();
            int id = int.parse(id_string);
            parameters.category.id = id;
            do_upload();
        } catch (Spit.Publishing.PublishingError err) {
            debug("ERROR: on_category_add_complete");
            do_show_error(err);
        }
    }
    
    /**
     * Event triggered when the add category action fails due to a network error.
     */
    private void on_category_add_error(
        Publishing.RESTSupport.Transaction bad_txn,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_category_add_error");
        bad_txn.completed.disconnect(on_category_add_complete);
        bad_txn.network_error.disconnect(on_category_add_error);
        on_network_error(bad_txn, err);
    }
    
    /**
     * Upload action: the big one, the one we've been waiting for!
     */
    private void do_upload() {
        debug("ACTION: uploading pictures");
        
        host.set_service_locked(true);
        // Save last category, permission level and size for next use
        set_last_category(parameters.category.id);
        set_last_permission_level(parameters.perm_level.id);
        set_last_photo_size(parameters.photo_size.id);
        
        progress_reporter = host.serialize_publishables(parameters.photo_size.id);
        Spit.Publishing.Publishable[] publishables = host.get_publishables();
        
        Uploader uploader = new Uploader(session, publishables, parameters);
        uploader.upload_complete.connect(on_upload_complete);
        uploader.upload_error.connect(on_upload_error);
        uploader.upload(on_upload_status_updated);
    }
    
    /**
     * Event triggered when the batch uploader reports that at least one of the
     * network transactions encapsulating uploads has completed successfully
     */
    private void on_upload_complete(Publishing.RESTSupport.BatchUploader uploader, int num_published) {
        debug("EVENT: on_upload_complete");
        uploader.upload_complete.disconnect(on_upload_complete);
        uploader.upload_error.disconnect(on_upload_error);
        
        // TODO: should a message be displayed to the user if num_published is zero?

        do_show_success_pane();
    }
    
    /**
     * Event triggered when the batch uploader reports that at least one of the
     * network transactions encapsulating uploads has caused a network error
     */
    private void on_upload_error(
        Publishing.RESTSupport.BatchUploader uploader,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_upload_error");
        uploader.upload_complete.disconnect(on_upload_complete);
        uploader.upload_error.disconnect(on_upload_error);

        do_show_error(err);
    }
    
    /**
     * Event triggered when upload progresses and the status needs to be updated.
     */
    private void on_upload_status_updated(int file_number, double completed_fraction) {
        if (!is_running())
            return;

        debug("EVENT: uploader reports upload %.2f percent complete.", 100.0 * completed_fraction);

        assert(progress_reporter != null);

        progress_reporter(file_number, completed_fraction);
    }
    
    /**
     * Action to display the success pane in the publishing dialog.
     */
    private void do_show_success_pane() {
        debug("ACTION: installing success pane");

        host.set_service_locked(false);
        host.install_success_pane();
    }
    
    /**
     * Helper event to handle network errors.
     */
    private void on_network_error(
        Publishing.RESTSupport.Transaction bad_txn,
        Spit.Publishing.PublishingError err
    ) {
        debug("EVENT: on_network_error");
        do_show_error(err);
    }
    
    /**
     * Action to display an error to the user.
     */
    private void do_show_error(Spit.Publishing.PublishingError e) {
        debug("ACTION: do_show_error");
        string error_type = "UNKNOWN";
        if (e is Spit.Publishing.PublishingError.NO_ANSWER) {
            do_show_authentication_pane(AuthenticationPane.Mode.FAILED_RETRY_URL);
            return;
        } else if(e is Spit.Publishing.PublishingError.COMMUNICATION_FAILED) {
            error_type = "COMMUNICATION_FAILED";
        } else if(e is Spit.Publishing.PublishingError.PROTOCOL_ERROR) {
            error_type = "PROTOCOL_ERROR";
        } else if(e is Spit.Publishing.PublishingError.SERVICE_ERROR) {
            error_type = "SERVICE_ERROR";
        } else if(e is Spit.Publishing.PublishingError.MALFORMED_RESPONSE) {
            error_type = "MALFORMED_RESPONSE";
        } else if(e is Spit.Publishing.PublishingError.LOCAL_FILE_ERROR) {
            error_type = "LOCAL_FILE_ERROR";
        } else if(e is Spit.Publishing.PublishingError.EXPIRED_SESSION) {
            error_type = "EXPIRED_SESSION";
        }
        
        debug("Unhandled error: type=%s; message='%s'".printf(error_type, e.message));
        do_show_error_message(ERROR_MESSAGE_GENERIC);
    }
    
    /**
     * Action to display an error message to the user.
     */
    private void do_show_error_message(string message) {
        debug("ACTION: do_show_error_message");
        host.install_static_message_pane(message,
                Spit.Publishing.PluginHost.ButtonMode.CLOSE);
    }
    
    // Helper methods
    
    /**
     * Retrieves session ID from a REST Transaction received
     *
     * This helper method extracts the pwg_id out of the Set-Cookie header if
     * present in the received transaction.
     *
     * @param txn the received transaction
     * @return the value of pwg_id if present or null if not found
     */
    private new string? get_pwg_id_from_transaction(Publishing.RESTSupport.Transaction txn) {
        string cookie = txn.get_response_headers().get_list("Set-Cookie");
        string pwg_id = null;
        debug("Full cookie string: %s".printf(cookie));
        if (!is_string_empty(cookie)) {
            string[] cookie_segments = cookie.split(";");
            debug("Split full string into %d individual segments".printf(cookie_segments.length));
            foreach(string cookie_segment in cookie_segments) {
                debug("Individual cookie segment: %s".printf(cookie_segment));
                string[] cookie_sub_segments = cookie_segment.split(",");
                debug("Split segment into %d individual sub-segments".printf(cookie_sub_segments.length));
                foreach(string cookie_sub_segment in cookie_sub_segments) {
                    debug("Individual cookie sub-segment: %s".printf(cookie_sub_segment));
                    string[] cookie_kv = cookie_sub_segment.split("=");
                    debug("Split sub-segment into %d chunks".printf(cookie_kv.length));
                    if (cookie_kv.length > 1 && cookie_kv[0].strip() == "pwg_id") {
                        debug("Found pwg_id: %s".printf(cookie_kv[1].strip()));
                        pwg_id = cookie_kv[1].strip();
                    }
                }
            }
        }
        
        return pwg_id;
    }
}

// The uploader

internal class Uploader : Publishing.RESTSupport.BatchUploader {
    private PublishingParameters parameters;

    public Uploader(Session session, Spit.Publishing.Publishable[] publishables,
        PublishingParameters parameters) {
        base(session, publishables);
        
        this.parameters = parameters;
    }

    protected override Publishing.RESTSupport.Transaction create_transaction(
        Spit.Publishing.Publishable publishable) {
        return new ImagesAddTransaction((Session) get_session(), parameters,
            publishable);
    }
}

// UI elements

/**
 * The authentication pane used when asking service URL, user name and password
 * from the user.
 */
internal class AuthenticationPane : Spit.Publishing.DialogPane, Object {
    public enum Mode {
        INTRO,
        FAILED_RETRY_URL,
        FAILED_RETRY_USER
    }
    private const string INTRO_MESSAGE = _("Enter the URL of your Piwigo photo library as well as the username and password associated with your Piwigo account for that library.");
    private const string FAILED_RETRY_URL_MESSAGE = _("Shotwell cannot contact your Piwigo photo library. Please verify the URL you entered");
    private const string FAILED_RETRY_USER_MESSAGE = _("Username and/or password invalid. Please try again");

    private Gtk.Box pane_widget = null;
    private Gtk.Builder builder;
    private Gtk.Entry url_entry;
    private Gtk.Entry username_entry;
    private Gtk.Entry password_entry;
    private Gtk.CheckButton remember_password_checkbutton;
    private Gtk.Button login_button;

    public signal void login(string url, string user, string password, bool remember_password);

    public AuthenticationPane(PiwigoPublisher publisher, Mode mode = Mode.INTRO) {
        this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);

        File ui_file = publisher.get_host().get_module_file().get_parent().
            get_child("piwigo_authentication_pane.glade");
        
        try {
            builder = new Gtk.Builder();
            builder.set_translation_domain(DOMAIN_NAME);
            builder.add_from_file(ui_file.get_path());
            builder.connect_signals(null);
            Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment;
            
            Gtk.Label message_label = builder.get_object("message_label") as Gtk.Label;
            switch (mode) {
                case Mode.INTRO:
                    message_label.set_text(INTRO_MESSAGE);
                    break;

                case Mode.FAILED_RETRY_URL:
                    message_label.set_markup("<b>%s</b>\n\n%s".printf(_(
                        "Invalid URL"), FAILED_RETRY_URL_MESSAGE));
                    break;

                case Mode.FAILED_RETRY_USER:
                    message_label.set_markup("<b>%s</b>\n\n%s".printf(_(
                        "Invalid User Name or Password"), FAILED_RETRY_USER_MESSAGE));
                    break;
            }

            url_entry = builder.get_object ("url_entry") as Gtk.Entry;
            string? persistent_url = publisher.get_persistent_url();
            if (persistent_url != null) {
                url_entry.set_text(persistent_url);
            }
            username_entry = builder.get_object ("username_entry") as Gtk.Entry;
            string? persistent_username = publisher.get_persistent_username();
            if (persistent_username != null) {
                username_entry.set_text(persistent_username);
            }
            password_entry = builder.get_object ("password_entry") as Gtk.Entry;
            string? persistent_password = publisher.get_persistent_password();
            if (persistent_password != null) {
                password_entry.set_text(persistent_password);
            }
            remember_password_checkbutton =
                builder.get_object ("remember_password_checkbutton") as Gtk.CheckButton;
            remember_password_checkbutton.set_active(publisher.get_remember_password());

            login_button = builder.get_object("login_button") as Gtk.Button;

            username_entry.changed.connect(on_user_changed);
            url_entry.changed.connect(on_url_changed);
            password_entry.changed.connect(on_password_changed);
            login_button.clicked.connect(on_login_button_clicked);

            align.reparent(pane_widget);
            publisher.get_host().set_dialog_default_widget(login_button);
        } catch (Error e) {
            warning("Could not load UI: %s", e.message);
        }
    }
    
    public Gtk.Widget get_default_widget() {
        return login_button;
    }

    private void on_login_button_clicked() {
        login(url_entry.get_text(), username_entry.get_text(),
            password_entry.get_text(), remember_password_checkbutton.get_active());
    }

    private void on_url_changed() {
        update_login_button_sensitivity();
    }

    private void on_user_changed() {
        update_login_button_sensitivity();
    }

    private void on_password_changed() {
        update_login_button_sensitivity();
    }
    
    private void update_login_button_sensitivity() {
        login_button.set_sensitive(
            !is_string_empty(url_entry.get_text()) &&
            !is_string_empty(username_entry.get_text()) &&
            !is_string_empty(password_entry.get_text())
        );
    }
    
    public Gtk.Widget get_widget() {
        return pane_widget;
    }
    
    public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
        return Spit.Publishing.DialogPane.GeometryOptions.NONE;
    }
    
    public void on_pane_installed() {
        url_entry.grab_focus();
        password_entry.set_activates_default(true);
        login_button.can_default = true;
        update_login_button_sensitivity();
    }
    
    public void on_pane_uninstalled() {
    }
}

/**
 * The publishing options pane.
 */
internal class PublishingOptionsPane : Spit.Publishing.DialogPane, Object {

    private const string DEFAULT_CATEGORY_NAME = _("Shotwell Connect");

    private Gtk.Box pane_widget = null;
    private Gtk.Builder builder;
    private Gtk.RadioButton use_existing_radio;
    private Gtk.RadioButton create_new_radio;
    private Gtk.ComboBoxText existing_categories_combo;
    private Gtk.Entry new_category_entry;
    private Gtk.ComboBoxText perms_combo;
    private Gtk.ComboBoxText size_combo;
    private Gtk.Button logout_button;
    private Gtk.Button publish_button;
    
    private Category[] existing_categories;
    private PermissionLevel[] perm_levels;
    private SizeEntry[] photo_sizes;
    
    private int last_category;
    private int last_permission_level;
    private int last_photo_size;

    public signal void publish(PublishingParameters parameters);
    public signal void logout();

    public PublishingOptionsPane(
        PiwigoPublisher publisher, Category[] categories,
        int last_category, int last_permission_level, int last_photo_size
    ) {
        this.pane_widget = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
        this.last_category = last_category;
        this.last_permission_level = last_permission_level;
        this.last_photo_size = last_photo_size;

        File ui_file = publisher.get_host().get_module_file().get_parent().
            get_child("piwigo_publishing_options_pane.glade");
        
        try {
            builder = new Gtk.Builder();
            builder.set_translation_domain(DOMAIN_NAME);
            builder.add_from_file(ui_file.get_path());
            builder.connect_signals(null);
            Gtk.Alignment align = builder.get_object("alignment") as Gtk.Alignment;
            
            use_existing_radio = builder.get_object("use_existing_radio") as Gtk.RadioButton;
            create_new_radio = builder.get_object("create_new_radio") as Gtk.RadioButton;
            existing_categories_combo = builder.get_object("existing_categories_combo") as Gtk.ComboBoxText;
            new_category_entry = builder.get_object ("new_category_entry") as Gtk.Entry;
            perms_combo = builder.get_object("perms_combo") as Gtk.ComboBoxText;
            size_combo = builder.get_object("size_combo") as Gtk.ComboBoxText;

            logout_button = builder.get_object("logout_button") as Gtk.Button;
            logout_button.clicked.connect(on_logout_button_clicked);

            publish_button = builder.get_object("publish_button") as Gtk.Button;
            publish_button.clicked.connect(on_publish_button_clicked);
            
            use_existing_radio.clicked.connect(on_use_existing_radio_clicked);
            create_new_radio.clicked.connect(on_create_new_radio_clicked);
            new_category_entry.changed.connect(on_new_category_entry_changed);

            align.reparent(pane_widget);
			pane_widget.set_child_packing(align, true, true, 0, Gtk.PackType.START);
        } catch (Error e) {
            warning("Could not load UI: %s", e.message);
        }
        
        this.existing_categories = categories;
        this.perm_levels = create_perm_levels();
        this.photo_sizes = create_sizes();
    }
    
    public Gtk.Widget get_default_widget() {
        return publish_button;
    }
    
    private PermissionLevel[] create_perm_levels() {
        PermissionLevel[] result = new PermissionLevel[0];

        result += new PermissionLevel(0, _("Everyone"));
        result += new PermissionLevel(1, _("Admins, Friends, Family, Contacts"));
        result += new PermissionLevel(2, _("Admins, Family, Friends"));
        result += new PermissionLevel(4, _("Admins, Family"));
        result += new PermissionLevel(8, _("Admins"));

        return result;
    }

    private SizeEntry[] create_sizes() {
        SizeEntry[] result = new SizeEntry[0];

        result += new SizeEntry(500, _("500 x 375 pixels"));
        result += new SizeEntry(1024, _("1024 x 768 pixels"));
        result += new SizeEntry(2048, _("2048 x 1536 pixels"));
        result += new SizeEntry(4096, _("4096 x 3072 pixels"));
        result += new SizeEntry(ORIGINAL_SIZE, _("Original size"));

        return result;
    }

    private void on_logout_button_clicked() {
        logout();
    }

    private void on_publish_button_clicked() {
        PublishingParameters params = new PublishingParameters();
        params.perm_level = perm_levels[perms_combo.get_active()];
        params.photo_size = photo_sizes[size_combo.get_active()];
        if (create_new_radio.get_active()) {
            params.category = new Category.local(new_category_entry.get_text());
        } else {
            params.category = existing_categories[existing_categories_combo.get_active()];
        }
        publish(params);
    }
    
    // UI interaction
    private void on_use_existing_radio_clicked() {
        existing_categories_combo.set_sensitive(true);
        new_category_entry.set_sensitive(false);
        existing_categories_combo.grab_focus();
        update_publish_button_sensitivity();
    }

    private void on_create_new_radio_clicked() {
        new_category_entry.set_sensitive(true);
        existing_categories_combo.set_sensitive(false);
        new_category_entry.grab_focus();
        update_publish_button_sensitivity();
    }

    private void on_new_category_entry_changed() {
        update_publish_button_sensitivity();
    }

    private void update_publish_button_sensitivity() {
        string category_name = new_category_entry.get_text().strip();
        publish_button.set_sensitive(
            !(
                create_new_radio.get_active() &&
                (
                    is_string_empty(category_name) ||
                    category_already_exists(category_name)
                )
            )
        );
    }
    
    public Gtk.Widget get_widget() {
        return pane_widget;
    }
    
    public Spit.Publishing.DialogPane.GeometryOptions get_preferred_geometry() {
        return Spit.Publishing.DialogPane.GeometryOptions.NONE;
    }
    
    public void on_pane_installed() {
    	create_categories_combo();
	    create_permissions_combo();
	    create_size_combo();
        //
        publish_button.can_default = true;
        update_publish_button_sensitivity();
    }
    
    private void create_categories_combo() {
        foreach (Category cat in existing_categories) {
            existing_categories_combo.append_text(cat.name);
        }
        if (existing_categories.length == 0) {
            // if no existing categories, disable the option to choose one
            existing_categories_combo.set_sensitive(false);
            use_existing_radio.set_sensitive(false);
            create_new_radio.set_active(true);
            new_category_entry.grab_focus();
        } else {
            int last_category_index = find_category_index(last_category);
            if (last_category_index < 0) {
                existing_categories_combo.set_active(0);
            } else {
                existing_categories_combo.set_active(last_category_index);
            }
            new_category_entry.set_sensitive(false);
        }
        if (!category_already_exists(DEFAULT_CATEGORY_NAME))
            new_category_entry.set_text(DEFAULT_CATEGORY_NAME);
    }
    
    private void create_permissions_combo() {
        foreach (PermissionLevel perm in perm_levels) {
            perms_combo.append_text(perm.name);
        }
        int last_permission_level_index = find_permission_level_index(last_permission_level);
        if (last_permission_level_index < 0) {
            perms_combo.set_active(0);
        } else {
            perms_combo.set_active(last_permission_level_index);
        }
    }
    
    private void create_size_combo() {
        foreach (SizeEntry size in photo_sizes) {
            size_combo.append_text(size.name);
        }
        int last_size_index = find_size_index(last_photo_size);
        if (last_size_index < 0) {
            size_combo.set_active(find_size_index(ORIGINAL_SIZE));
        } else {
            size_combo.set_active(last_size_index);
        }
    }
    
    public void on_pane_uninstalled() {
    }
    
    private int find_category_index(int category_id) {
        int result = -1;
        for(int i = 0; i < existing_categories.length; i++) {
            if (existing_categories[i].id == category_id) {
                result = i;
                break;
            }
        }
        return result;
    }
    
    private int find_permission_level_index(int permission_level_id) {
        int result = -1;
        for(int i = 0; i < perm_levels.length; i++) {
            if (perm_levels[i].id == permission_level_id) {
                result = i;
                break;
            }
        }
        return result;
    }
    
    private int find_size_index(int size_id) {
        int result = -1;
        for(int i = 0; i < photo_sizes.length; i++) {
            if (photo_sizes[i].id == size_id) {
                result = i;
                break;
            }
        }
        return result;
    }
    
    private bool category_already_exists(string category_name) {
        bool result = false;
        foreach(Category category in existing_categories) {
            if (category.name == category_name) {
                result = true;
                break;
            }
        }
        return result;
    }
}

// REST support classes

/**
 * Session class that keeps track of the authentication status and of the
 * user token pwg_id.
 */
internal class Session : Publishing.RESTSupport.Session {
    private string? pwg_url = null;
    private string? pwg_id = null;
    private string? username = null;

    public Session() {
        base("");
    }

    public override bool is_authenticated() {
        return (pwg_id != null && pwg_url != null && username != null);
    }

    public void authenticate(string url, string username, string id) {
        this.pwg_url = url;
        this.username = username;
        this.pwg_id = id;
    }

    public void deauthenticate() {
        pwg_url = null;
        pwg_id = null;
        username = null;
    }
    
    public string get_username() {
        return username;
    }

    public string get_pwg_url() {
        return pwg_url;
    }

    public string get_pwg_id() {
        return pwg_id;
    }

    public void set_pwg_id(string id) {
        pwg_id = id;
    }
}

/**
 * Generic REST transaction class.
 *
 * This class implements the generic logic for all REST transactions used
 * by the Piwigo publishing plugin. In particular, it ensures that if the
 * session has been authenticated, the pwg_id token is included in the
 * transaction header.
 */
internal class Transaction : Publishing.RESTSupport.Transaction {
    public Transaction(Session session) {
        base(session);
        if (session.is_authenticated()) {
            add_header("Cookie", "pwg_id=".concat(session.get_pwg_id()));
        }
    }

    public Transaction.authenticated(Session session) {
        base.with_endpoint_url(session, session.get_pwg_url());
        add_header("Cookie", "pwg_id=".concat(session.get_pwg_id()));
    }

    public static string? validate_xml(Publishing.RESTSupport.XmlDocument doc) {
        Xml.Node* root = doc.get_root_node();
        string? status = root->get_prop("stat");
        
        // treat malformed root as an error condition
        if (status == null)
            return "No status property in root node";
        
        if (status == "ok")
            return null;
        
        Xml.Node* errcode;
        try {
            errcode = doc.get_named_child(root, "err");
        } catch (Spit.Publishing.PublishingError err) {
            return "No error code specified";
        }
        
        return "%s (error code %s)".printf(errcode->get_prop("msg"), errcode->get_prop("code"));
    }

    public static new string? get_error_code(Publishing.RESTSupport.XmlDocument doc) {
        Xml.Node* root = doc.get_root_node();
        Xml.Node* errcode;
        try {
            errcode = doc.get_named_child(root, "err");
        } catch (Spit.Publishing.PublishingError err) {
            return "0";
        }
        return errcode->get_prop("code");
    }
}

/**
 * Transaction used to implement the network login interaction.
 */
internal class SessionLoginTransaction : Transaction {
    public SessionLoginTransaction(Session session, string url, string username, string password) {
        base.with_endpoint_url(session, url);

        add_argument("method", "pwg.session.login");
        add_argument("username", username);
        add_argument("password", password);
    }
}

/**
 * Transaction used to implement the get status interaction.
 */
internal class SessionGetStatusTransaction : Transaction {
    public SessionGetStatusTransaction.unauthenticated(Session session, string url, string pwg_id) {
        base.with_endpoint_url(session, url);
        add_header("Cookie", "pwg_id=".concat(session.get_pwg_id()));

        add_argument("method", "pwg.session.getStatus");
    }

    public SessionGetStatusTransaction(Session session) {
        base.authenticated(session);

        add_argument("method", "pwg.session.getStatus");
    }
}

/**
 * Transaction used to implement the fetch categories interaction.
 */
private class CategoriesGetListTransaction : Transaction {
    public CategoriesGetListTransaction(Session session) {
        base.authenticated(session);
        
        add_argument("method", "pwg.categories.getList");
    }
}

private class SessionLogoutTransaction : Transaction {
    public SessionLogoutTransaction(Session session) {
        base.authenticated(session);
      
        add_argument("method", "pwg.session.logout");
    }
}

private class CategoriesAddTransaction : Transaction {
    public CategoriesAddTransaction(Session session, string category, int parent_id = 0) {
        base.authenticated(session);

        add_argument("method", "pwg.categories.add");
        add_argument("name", category);

        if (parent_id != 0) {
            add_argument("parent", parent_id.to_string());
        }
    }
}

private class ImagesAddTransaction : Publishing.RESTSupport.UploadTransaction {
    private PublishingParameters parameters = null;

    public ImagesAddTransaction(Session session, PublishingParameters parameters, Spit.Publishing.Publishable publishable) {
        base.with_endpoint_url(session, publishable, session.get_pwg_url());
        if (session.is_authenticated()) {
            add_header("Cookie", "pwg_id=".concat(session.get_pwg_id()));
        }
        this.parameters = parameters;

        string[] keywords = publishable.get_publishing_keywords();
        string tags = "";
        if (keywords != null) {
            foreach (string tag in keywords) {
                if (!is_string_empty(tags)) {
                    tags += ",";
                }
                tags += tag;
            }
        }
        
        debug("PiwigoConnector: Uploading photo %s to category id %d with perm level %d",
            publishable.get_serialized_file().get_basename(),
            parameters.category.id, parameters.perm_level.id);
        string name = publishable.get_publishing_name();
        if (is_string_empty(name)) {
            name = publishable.get_param_string(
                Spit.Publishing.Publishable.PARAM_STRING_BASENAME);
        }
        add_argument("method", "pwg.images.addSimple");
        add_argument("category", parameters.category.id.to_string());
        add_argument("name", name);
        add_argument("level", parameters.perm_level.id.to_string());
        if (!is_string_empty(tags))
            add_argument("tags", tags);
        // TODO: update the Publishable interface so that it gives access to
        // the image's meta-data where the author (artist) is kept
        /*if (!is_string_empty(author))
            add_argument("author", author);*/
        
        // TODO: implement description in APIGlue
        /*if (!is_string_empty(publishable.get_publishing_description()))
            add_argument("comment", publishable.get_publishing_description());*/

        GLib.HashTable<string, string> disposition_table =
            new GLib.HashTable<string, string>(GLib.str_hash, GLib.str_equal);
        disposition_table.insert("filename",  Soup.URI.encode(
            publishable.get_param_string(
                Spit.Publishing.Publishable.PARAM_STRING_BASENAME), null));
        disposition_table.insert("name", "image");

        set_binary_disposition_table(disposition_table);
    }
}

} // namespace

