# -*- coding: utf-8 -*-
# Copyright (C) 2009-2011 Canonical
#
# Authors:
#  Michael Vogt
#  Didier Roche
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; version 3.
#
# 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 logging
import xapian
from gi.repository import GObject, GLib, Gtk

from gettext import gettext as _
from gettext import ngettext

import platform

from softwarecenter.enums import (NonAppVisibility,
                                  SortMethods)
from softwarecenter.utils import (
    wait_for_apt_cache_ready, utf8, ExecutionTime)
from softwarecenter.db.categories import (CategoriesParser,
                                          categories_sorted_by_name)
from softwarecenter.ui.gtk3.models.appstore2 import (
    AppTreeStore, CategoryRowReference)
from softwarecenter.ui.gtk3.widgets.menubutton import MenuButton
from softwarecenter.ui.gtk3.widgets.oneconfviews import OneConfViews
from softwarecenter.ui.gtk3.widgets.spinner import SpinnerNotebook
from softwarecenter.ui.gtk3.views.appview import AppView
from softwarecenter.ui.gtk3.panes.softwarepane import SoftwarePane
from softwarecenter.backend.oneconfhandler import get_oneconf_handler
from softwarecenter.db.appfilter import AppFilter

LOG = logging.getLogger(__name__)


def interrupt_build_and_wait(f):
    """ decorator that ensures that a build of the categorised installed apps
        is interrupted before a new build commences.
        expects self._build_in_progress and self._halt_build as properties
    """
    def wrapper(*args, **kwargs):
        self = args[0]
        if self._build_in_progress:
            LOG.debug('Waiting for build to exit...')
            self._halt_build = True
            GLib.timeout_add(200, lambda: wrapper(*args, **kwargs))
            return False
        # ready now
        self._halt_build = False
        f(*args, **kwargs)
        return False
    return wrapper


class InstalledPane(SoftwarePane, CategoriesParser):
    """Widget that represents the installed panel in software-center
       It contains a search entry and navigation buttons
    """

    class Pages():
        # page names, useful for debugging
        NAMES = ('list', 'details')
        # the actual page id's
        (LIST,
         DETAILS) = range(2)
        # the default page
        HOME = LIST

    # pages for the installed view spinner notebook
    (PAGE_SPINNER,
     PAGE_INSTALLED) = range(2)

    __gsignals__ = {'installed-pane-created': (GObject.SignalFlags.RUN_FIRST,
                                               None,
                                               ())}

    def __init__(self, cache, db, distro, icons):

        # parent
        SoftwarePane.__init__(self, cache, db, distro, icons,
                              show_ratings=False)
        CategoriesParser.__init__(self, db)

        self.current_appview_selection = None
        self.icons = icons
        self.loaded = False
        self.pane_name = _("Installed Software")

        self.installed_apps = 0
        # None is local
        self.current_hostid = None
        self.current_hostname = None
        self.oneconf_additional_pkg = set()
        self.oneconf_missing_pkg = set()

        # switches to terminate build in progress
        self._build_in_progress = False
        self._halt_build = False

        self.nonapps_visible = NonAppVisibility.NEVER_VISIBLE

        self.visible_docids = None
        self.visible_cats = {}

        self.installed_spinner_notebook = None

    def init_view(self):
        if self.view_initialized:
            return

        SoftwarePane.init_view(self)

        # show a busy cursor and display the main spinner while we build the
        # view
        window = self.get_window()
        if window:
            window.set_cursor(self.busy_cursor)
        self.show_appview_spinner()

        self.oneconf_viewpickler = OneConfViews(self.icons)
        self.oneconf_viewpickler.register_computer(None,
            _("This computer (%s)") % platform.node())
        self.oneconf_viewpickler.select_first()
        self.oneconf_viewpickler.connect('computer-changed',
            self._selected_computer_changed)
        self.oneconf_viewpickler.connect('current-inventory-refreshed',
            self._current_inventory_need_refresh)

        # Start OneConf
        self.oneconf_handler = get_oneconf_handler(self.oneconf_viewpickler)
        if self.oneconf_handler:
            self.oneconf_handler.connect('show-oneconf-changed',
                self._show_oneconf_changed)
            self.oneconf_handler.connect('last-time-sync-changed',
                self._last_time_sync_oneconf_changed)

        # OneConf pane
        self.computerpane = Gtk.Paned.new(Gtk.Orientation.HORIZONTAL)
        self.oneconfcontrol = Gtk.Box()
        self.oneconfcontrol.set_orientation(Gtk.Orientation.VERTICAL)
        self.computerpane.pack1(self.oneconfcontrol, False, False)
        # size negotiation takes everything for the first one
        self.oneconfcontrol.set_property('width-request', 200)
        self.box_app_list.pack_start(self.computerpane, True, True, 0)

        scroll = Gtk.ScrolledWindow()
        scroll.set_shadow_type(Gtk.ShadowType.IN)
        scroll.add(self.oneconf_viewpickler)
        self.oneconfcontrol.pack_start(scroll, True, True, 0)

        oneconftoolbar = Gtk.Box()
        oneconftoolbar.set_orientation(Gtk.Orientation.HORIZONTAL)
        oneconfpropertymenu = Gtk.Menu()
        self.oneconfproperty = MenuButton(oneconfpropertymenu,
            Gtk.Image.new_from_stock(Gtk.STOCK_PROPERTIES,
            Gtk.IconSize.BUTTON))
        self.stopsync_label = _(u"Stop Syncing “%s”")
        stop_oneconf_share_menuitem = Gtk.MenuItem(
            label=self.stopsync_label % platform.node())
        stop_oneconf_share_menuitem.connect("activate",
            self._on_stop_oneconf_hostshare_clicked)
        stop_oneconf_share_menuitem.show()
        oneconfpropertymenu.append(stop_oneconf_share_menuitem)
        self.oneconfcontrol.pack_start(oneconftoolbar, False, False, 1)
        self.oneconf_last_sync = Gtk.Label()
        self.oneconf_last_sync.set_line_wrap(True)
        oneconftoolbar.pack_start(self.oneconfproperty, False, False, 0)
        oneconftoolbar.pack_start(self.oneconf_last_sync, True, True, 1)

        self.notebook.append_page(self.box_app_list, Gtk.Label(label="list"))

        # details
        self.notebook.append_page(self.scroll_details,
            Gtk.Label(label="details"))
        # initial refresh
        self.state.search_term = ""

        # build models and filters
        self.base_model = AppTreeStore(self.db, self.cache, self.icons)

        self.treefilter = self.base_model.filter_new(None)
        self.treefilter.set_visible_func(self._row_visibility_func,
                                         AppTreeStore.COL_ROW_DATA)
        self.app_view.set_model(self.treefilter)
        self.app_view.tree_view.connect("row-collapsed",
            self._on_row_collapsed)

        self._all_cats = self.parse_applications_menu()
        self._all_cats = categories_sorted_by_name(self._all_cats)

        # we do not support the search aid feature in the installedview
        self.box_app_list.remove(self.search_aid)

        # remove here
        self.box_app_list.remove(self.app_view)

        # create a local spinner notebook for the installed view
        self.installed_spinner_notebook = SpinnerNotebook(self.app_view)

        self.computerpane.pack2(self.installed_spinner_notebook, True, True)
        self.show_installed_view_spinner()

        self.show_all()

        # initialize view to hide the oneconf computer selector
        self.oneconf_viewpickler.select_first()
        self.oneconfcontrol.hide()

        # hacky, hide the header
        self.app_view.header_hbox.hide()

        self.hide_appview_spinner()

        # keep track of the current view by tracking its origin
        self.current_displayed_origin = None

        # now we are initialized
        self.emit("installed-pane-created")

        self.view_initialized = True
        return False

    def show_installed_view_spinner(self):
        """ display the local spinner for the installed view panel """
        if self.installed_spinner_notebook:
            self.installed_spinner_notebook.show_spinner()

    def hide_installed_view_spinner(self):
        """ hide the local spinner for the installed view panel """
        if self.installed_spinner_notebook:
            self.installed_spinner_notebook.hide_spinner()

    def _selected_computer_changed(self, oneconf_pickler, hostid, hostname):
        if self.current_hostid == hostid:
            return
        LOG.debug("Selected computer changed to %s (%s)" % (hostid, hostname))
        self.current_hostid = hostid
        self.current_hostname = hostname
        menuitem = self.oneconfproperty.get_menu().get_children()[0]
        if self.current_hostid:
            diff = self.oneconf_handler.oneconf.diff(self.current_hostid, '')
            self.oneconf_additional_pkg, self.oneconf_missing_pkg = diff
            stopsync_hostname = self.current_hostname
            # FIXME for P: oneconf views don't support search
            if self.state.search_term:
                self._search()
        else:
            stopsync_hostname = platform.node()
            self.searchentry.show()
        menuitem.set_label(self.stopsync_label %
            stopsync_hostname.encode('utf-8'))
        self.refresh_apps()

    def _last_time_sync_oneconf_changed(self, oneconf_handler, msg):
        LOG.debug("refresh latest sync date")
        self.oneconf_last_sync.set_label(msg)

    def _show_oneconf_changed(self, oneconf_handler, oneconf_inventory_shown):
        LOG.debug('Share inventory status changed')
        if oneconf_inventory_shown:
            self.oneconfcontrol.show()
        else:
            self.oneconf_viewpickler.select_first()
            self.oneconfcontrol.hide()

    def _on_stop_oneconf_hostshare_clicked(self, widget):
        LOG.debug("Stop sharing inventory for %s" % self.current_hostname)
        self.oneconf_handler.sync_between_computers(False, self.current_hostid)
        # stop sharing another host than the local one.
        if self.current_hostid:
            self.oneconf_viewpickler.remove_computer(self.current_hostid)
            self.oneconf_viewpickler.select_first()

    def _current_inventory_need_refresh(self, oneconfviews):
        if self.current_hostid:
            diff = self.oneconf_handler.oneconf.diff(self.current_hostid, '')
            self.oneconf_additional_pkg, self.oneconf_missing_pkg = diff
        self.refresh_apps()

    def _on_row_collapsed(self, view, it, path):
        pass

    def _row_visibility_func(self, model, it, col):
        row = model.get_value(it, col)
        if self.visible_docids is None:
            if isinstance(row, CategoryRowReference):
                row.vis_count = row.pkg_count
            return True

        elif isinstance(row, CategoryRowReference):
            return row.untranslated_name in self.visible_cats.keys()

        elif row is None:
            return False

        return row.get_docid() in self.visible_docids

    def _use_category(self, cat):
        # System cat is large and slow to search, filter it in default mode

        if ('carousel-only' in cat.flags or
                ((self.nonapps_visible == NonAppVisibility.NEVER_VISIBLE)
                 and cat.untranslated_name == 'System')):
                return False

        return True

    # override its SoftwarePane._hide_nonapp_pkgs...
    def _hide_nonapp_pkgs(self):
        self.nonapps_visible = NonAppVisibility.NEVER_VISIBLE
        self.refresh_apps()
        return True

    def _save_treeview_state(self):
        # store the state
        expanded_rows = []
        self.app_view.tree_view.map_expanded_rows(
            lambda view, path, data: expanded_rows.append(path.to_string()),
            None)
        va = self.app_view.tree_view_scroll.get_vadjustment()
        if va:
            vadj = va.get_value()
        else:
            vadj = 0
        return expanded_rows, vadj

    def _restore_treeview_state(self, state):
        expanded_rows, vadj = state
        for ind in expanded_rows:
            path = Gtk.TreePath.new_from_string(ind)
            self.app_view.tree_view.expand_row(path, False)
        va = self.app_view.tree_view_scroll.get_vadjustment()
        if va:
            va.set_lower(vadj)
            va.set_value(vadj)

    #~ @interrupt_build_and_wait
    def _build_categorised_installedview(self, keep_state=False):
        LOG.debug('Rebuilding categorised installedview...')

        # display the busy cursor and a local spinner while we build the view
        window = self.get_window()
        if window:
            window.set_cursor(self.busy_cursor)
        self.show_installed_view_spinner()

        if keep_state:
            treeview_state = self._save_treeview_state()

        # disconnect the model to avoid e.g. updates of "cursor-changed"
        #  AppTreeView.expand_path while the model is in rebuild-flux
        self.app_view.set_model(None)

        model = self.base_model  # base model not treefilter
        model.clear()

        def profiled_rebuild_categorised_view():
            with ExecutionTime("rebuild_categorized_view"):
                rebuild_categorised_view()

        def rebuild_categorised_view():
            self.cat_docid_map = {}
            enq = self.enquirer

            i = 0

            while Gtk.events_pending():
                Gtk.main_iteration()

            xfilter = AppFilter(self.db, self.cache)
            xfilter.set_installed_only(True)

            for cat in self._all_cats:
                # for each category do category query and append as a new
                # node to tree_view
                if not self._use_category(cat):
                    continue
                query = self.get_query_for_cat(cat)
                LOG.debug("xfilter.installed_only: %s" %
                    xfilter.installed_only)
                enq.set_query(query,
                              sortmode=SortMethods.BY_ALPHABET,
                              nonapps_visible=self.nonapps_visible,
                              filter=xfilter,
                              nonblocking_load=False,
                              persistent_duplicate_filter=(i > 0))

                L = len(enq.matches)
                if L:
                    i += L
                    docs = enq.get_documents()
                    self.cat_docid_map[cat.untranslated_name] = \
                                        set([doc.get_docid() for doc in docs])
                    model.set_category_documents(cat, docs)

            while Gtk.events_pending():
                Gtk.main_iteration()

            # check for uncategorised pkgs
            if self.state.channel:
                self._run_channel_enquirer(persistent_duplicate_filter=(i > 0))
                L = len(enq.matches)
                if L:
                    # some foo for channels
                    # if no categorised results but in channel, then use
                    # the channel name for the category
                    channel_name = None
                    if not i and self.state.channel:
                        channel_name = self.state.channel.display_name
                    docs = enq.get_documents()
                    tag = channel_name or 'Uncategorized'
                    self.cat_docid_map[tag] = set(
                        [doc.get_docid() for doc in docs])
                    model.set_nocategory_documents(docs, untranslated_name=tag,
                                                   display_name=channel_name)
                    i += L

            if i:
                self.app_view.tree_view.set_cursor(Gtk.TreePath(),
                                                   None, False)
                if i <= 10:
                    self.app_view.tree_view.expand_all()

            # cache the installed app count
            self.installed_count = i
            self.app_view._append_appcount(self.installed_count,
                mode=AppView.INSTALLED_MODE)

            self.app_view.set_model(self.treefilter)
            if keep_state:
                self._restore_treeview_state(treeview_state)

            # hide the local spinner
            self.hide_installed_view_spinner()

            if window:
                window.set_cursor(None)

            # reapply search if needed
            if self.state.search_term:
                self._do_search(self.state.search_term)

            self.emit("app-list-changed", i)
            return

        GLib.idle_add(profiled_rebuild_categorised_view)

    def _build_oneconfview(self, keep_state=False):
        LOG.debug('Rebuilding oneconfview for %s...' % self.current_hostid)

        # display the busy cursor and the local spinner while we build the view
        window = self.get_window()
        if window:
            window.set_cursor(self.busy_cursor)
        self.show_installed_view_spinner()

        if keep_state:
            treeview_state = self._save_treeview_state()

        # disconnect the model to avoid e.g. updates of "cursor-changed"
        #  AppTreeView.expand_path while the model is in rebuild-flux
        self.app_view.set_model(None)

        model = self.base_model  # base model not treefilter
        model.clear()

        def profiled_rebuild_oneconfview():
            with ExecutionTime("rebuild_oneconfview"):
                rebuild_oneconfview()

        def rebuild_oneconfview():

            # FIXME for P: hide the search entry
            self.searchentry.hide()

            self.cat_docid_map = {}
            enq = self.enquirer
            query = xapian.Query("")
            if self.state.channel and self.state.channel.query:
                query = xapian.Query(xapian.Query.OP_AND,
                                     query,
                                     self.state.channel.query)

            i = 0

            # First search: missing apps only
            xfilter = AppFilter(self.db, self.cache)
            xfilter.set_restricted_list(self.oneconf_additional_pkg)
            xfilter.set_not_installed_only(True)

            enq.set_query(query,
                          sortmode=SortMethods.BY_ALPHABET,
                          nonapps_visible=self.nonapps_visible,
                          filter=xfilter,
                          nonblocking_load=True,  # we don't block this one for
                                                # better oneconf responsiveness
                          persistent_duplicate_filter=(i > 0))

            L = len(enq.matches)

            if L:
                cat_title = utf8(ngettext(
                    u'%(amount)s item on “%(machine)s” not on this computer',
                    u'%(amount)s items on “%(machine)s” not on this computer',
                    L)) % {'amount': L, 'machine': utf8(self.current_hostname)}
                i += L
                docs = enq.get_documents()
                self.cat_docid_map["missingpkg"] = set(
                    [doc.get_docid() for doc in docs])
                model.set_nocategory_documents(docs,
                    untranslated_name="additionalpkg", display_name=cat_title)

            # Second search: additional apps
            xfilter.set_restricted_list(self.oneconf_missing_pkg)
            xfilter.set_not_installed_only(False)
            xfilter.set_installed_only(True)
            enq.set_query(query,
                          sortmode=SortMethods.BY_ALPHABET,
                          nonapps_visible=self.nonapps_visible,
                          filter=xfilter,
                          nonblocking_load=False,
                          persistent_duplicate_filter=(i > 0))

            L = len(enq.matches)
            if L:
                cat_title = utf8(ngettext(
                    u'%(amount)s item on this computer not on “%(machine)s”',
                    '%(amount)s items on this computer not on “%(machine)s”',
                    L)) % {'amount': L, 'machine': utf8(self.current_hostname)}
                i += L
                docs = enq.get_documents()
                self.cat_docid_map["additionalpkg"] = set(
                    [doc.get_docid() for doc in docs])
                model.set_nocategory_documents(docs,
                    untranslated_name="additionalpkg", display_name=cat_title)

            if i:
                self.app_view.tree_view.set_cursor(Gtk.TreePath(),
                                                   None, False)
                if i <= 10:
                    self.app_view.tree_view.expand_all()

            # cache the installed app count
            self.installed_count = i
            self.app_view._append_appcount(self.installed_count,
                mode=AppView.DIFF_MODE)

            self.app_view.set_model(self.treefilter)
            if keep_state:
                self._restore_treeview_state(treeview_state)

            # hide the local spinner
            self.hide_installed_view_spinner()

            if window:
                window.set_cursor(None)

            self.emit("app-list-changed", i)
            return

        GLib.idle_add(profiled_rebuild_oneconfview)

    def _check_expand(self):
        it = self.treefilter.get_iter_first()
        while it:
            path = self.treefilter.get_path(it)
            if self.state.search_term:  # or path in self._user_expanded_paths:
                self.app_view.tree_view.expand_row(path, False)
            else:
                self.app_view.tree_view.collapse_row(path)

            it = self.treefilter.iter_next(it)

    def _do_search(self, terms):
        self.state.search_term = terms
        xfilter = AppFilter(self.db, self.cache)
        xfilter.set_installed_only(True)
        self.enquirer.set_query(self.get_query(),
                                nonapps_visible=self.nonapps_visible,
                                filter=xfilter,
                                nonblocking_load=True)

        self.visible_docids = self.enquirer.get_docids()
        self.visible_cats = self._get_vis_cats(self.visible_docids)
        self.treefilter.refilter()
        self.app_view.tree_view.expand_all()

    def _run_channel_enquirer(self, persistent_duplicate_filter=True):
        xfilter = AppFilter(self.db, self.cache)
        xfilter.set_installed_only(True)
        if self.state.channel:
            self.enquirer.set_query(
                self.state.channel.query,
                sortmode=SortMethods.BY_ALPHABET,
                nonapps_visible=NonAppVisibility.MAYBE_VISIBLE,
                filter=xfilter,
                nonblocking_load=False,
                persistent_duplicate_filter=persistent_duplicate_filter)

    def _search(self, terms=None):
        if not terms:
            self.visible_docids = None
            self.state.search_term = ""
            self._clear_search()
            self.treefilter.refilter()
            self._check_expand()
            # run channel enquirer to ensure that the channel specific
            # info for show/hide nonapps is actually correct
            self._run_channel_enquirer()
            # trigger update of the show/hide
            self.emit("app-list-changed", 0)
        elif self.state.search_term != terms:
            self._do_search(terms)

    def get_query(self):
        # search terms
        return self.db.get_query_list_from_search_entry(
                                        self.state.search_term)

    def get_query_for_cat(self, cat):
        LOG.debug("self.state.channel: %s" % self.state.channel)
        if self.state.channel and self.state.channel.query:
            query = xapian.Query(xapian.Query.OP_AND,
                                 cat.query,
                                 self.state.channel.query)
            return query
        return cat.query

    @wait_for_apt_cache_ready
    def refresh_apps(self, *args, **kwargs):
        """refresh the applist and update the navigation bar """
        logging.debug("installedpane refresh_apps")
        keep_state = kwargs.get("keep_state", False)
        if self.current_hostid:
            self._build_oneconfview(keep_state)
        else:
            self._build_categorised_installedview(keep_state)

    def _clear_search(self):
        # remove the details and clear the search
        self.searchentry.clear_with_no_signal()

    def on_search_terms_changed(self, searchentry, terms):
        """callback when the search entry widget changes"""
        logging.debug("on_search_terms_changed: '%s'" % terms)

        self._search(terms.strip())
        self.state.search_term = terms
        self.notebook.set_current_page(self.Pages.LIST)
        self.hide_installed_view_spinner()

    def _get_vis_cats(self, visids):
        vis_cats = {}
        appcount = 0
        visids = set(visids)
        for cat_uname, docids in self.cat_docid_map.iteritems():
            children = len(docids & visids)
            if children:
                appcount += children
                vis_cats[cat_uname] = children
        self.app_view._append_appcount(appcount, mode=AppView.DIFF_MODE)
        return vis_cats

    def _refresh_on_cache_or_db_change(self):
        self.refresh_apps(keep_state=True)
        self.app_details_view.refresh_app()

    def on_db_reopen(self, db):
        super(InstalledPane, self).on_db_reopen(db)
        self._refresh_on_cache_or_db_change()

    def on_cache_ready(self, cache):
        LOG.debug("on_cache_ready")
        self._refresh_on_cache_or_db_change()

    def on_application_selected(self, appview, app):
        """Callback for when an app is selected."""
        SoftwarePane.on_application_selected(self, appview, app)
        self.current_appview_selection = app

    def enter_page(self, page, state):
        if page == self.Pages.LIST:
            self.display_overview_page(state)
        else:
            self.display_details_page(state)

    def display_search(self):
        model = self.app_view.get_model()
        if model:
            self.emit("app-list-changed", len(model))
        self.searchentry.show()

    @wait_for_apt_cache_ready
    def display_overview_page(self, view_state):
        LOG.debug("view_state: %s" % view_state)
        if self.current_hostid:
            # FIXME for P: oneconf views don't support search
            # this one ensure that even when switching between pane, we
            # don't have the search item
            if self.state.search_term:
                self._search()
            self._build_oneconfview()
        elif (view_state and
              view_state.channel and
              view_state.channel.origin is not self.current_displayed_origin):
            # we don't need to refresh the full installed view every time it
            # is displayed, so we check to see if we are viewing the same
            # channel and if so we don't refresh the view, note that the view
            # *is* is refreshed whenever the contents change and this is
            # sufficient (see LP: #828887)
            self._build_categorised_installedview()
            self.current_displayed_origin = view_state.channel.origin

            if self.state.search_term:
                self._search(self.state.search_term)
        return True

    def get_current_app(self):
        """return the current active application object applicable
           to the context"""
        return self.current_appview_selection
