# Copyright (C) 2006-2007 Red Hat, Inc.
#
# 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 St, Fifth Floor, Boston, MA  02110-1301  USA

import colorsys
from gettext import gettext as _
import logging
import math
import os

import hippo
import gobject
import gtk

from sugar.graphics.icon import CanvasIcon
from sugar.graphics.menuitem import MenuItem
from sugar.graphics.palette import Palette
from sugar.graphics import style
from sugar.graphics import xocolor
from sugar import profile
from proc_smaps import ProcSmaps

# TODO: rgb_to_html and html_to_rgb are useful elsewhere 
#       we should put this in a common module 
def rgb_to_html(r, g, b):
    """ (r, g, b) tuple (in float format) -> #RRGGBB """
    return '#%02x%02x%02x' % (int(r * 255), int(g * 255), int(b * 255))

def html_to_rgb(html_color):
    """ #RRGGBB -> (r, g, b) tuple (in float format) """
    html_color = html_color.strip()
    if html_color[0] == '#':
        html_color = html_color[1:]
    if len(html_color) != 6:
        raise ValueError, "input #%s is not in #RRGGBB format" % html_color
    r, g, b = html_color[:2], html_color[2:4], html_color[4:]
    r, g, b = [int(n, 16) for n in (r, g, b)]
    r, g, b = (r / 255.0, g / 255.0, b / 255.0)
    return (r, g, b)

_MAX_ACTIVITIES = 10
_MIN_WEDGE_SIZE = 1.0 / _MAX_ACTIVITIES

class ActivityIcon(CanvasIcon):
    _INTERVAL = 250

    __gsignals__ = {
        'resume': (gobject.SIGNAL_RUN_FIRST,
                   gobject.TYPE_NONE, ([])),
        'stop': (gobject.SIGNAL_RUN_FIRST,
                 gobject.TYPE_NONE, ([]))
    }

    def __init__(self, activity):
        icon_name = activity.get_icon_name()
        self._orig_color = activity.get_icon_color()
        self._icon_colors = self._compute_icon_colors()

        self._direction = 0
        self._level_max = len(self._icon_colors) - 1
        self._level = self._level_max
        color = self._icon_colors[self._level]

        CanvasIcon.__init__(self, file_name=icon_name, xo_color=color, 
                            size=style.MEDIUM_ICON_SIZE, cache=True)

        self._activity = activity
        self._pulse_id = 0

        self.size = _MIN_WEDGE_SIZE

        palette = Palette(_('Starting...'))
        self.set_palette(palette)

        activity.connect('notify::launching', self._launching_changed_cb)
        if activity.props.launching:
            self._start_pulsing()
        else:
            self._setup_palette()

    def _setup_palette(self):
        palette = self.get_palette()

        palette.set_primary_text(self._activity.get_title())

        resume_menu_item = MenuItem(_('Resume'), 'zoom-activity')
        resume_menu_item.connect('activate', self._resume_activate_cb)
        palette.menu.append(resume_menu_item)
        resume_menu_item.show()

        # FIXME: kludge
        if self._activity.get_type() != "org.laptop.JournalActivity":
            stop_menu_item = MenuItem(_('Stop'), 'activity-stop')
            stop_menu_item.connect('activate', self._stop_activate_cb)
            palette.menu.append(stop_menu_item)
            stop_menu_item.show()

    def _launching_changed_cb(self, activity, pspec):
        if not activity.props.launching:
            self._stop_pulsing()
            self._setup_palette()

    def __del__(self):
        self._cleanup()

    def _cleanup(self):
        if self._pulse_id:
            gobject.source_remove(self._pulse_id)
        self._pulse_id = 0

    def _compute_icon_colors(self):
        _LEVEL_MAX = 1.6
        _LEVEL_STEP = 0.16
        _LEVEL_MIN = 0.0
        icon_colors = {}
        level = _LEVEL_MIN
        for i in range(0, int(_LEVEL_MAX / _LEVEL_STEP)):
            icon_colors[i] = self._get_icon_color_for_level(level)
            level += _LEVEL_STEP
        return icon_colors

    def _get_icon_color_for_level(self, level):
        factor = math.sin(level)
        h, s, v = colorsys.rgb_to_hsv(*html_to_rgb(self._orig_color.get_fill_color()))
        new_fill = rgb_to_html(*colorsys.hsv_to_rgb(h, s * factor, v))
        h, s, v = colorsys.rgb_to_hsv(*html_to_rgb(self._orig_color.get_stroke_color()))
        new_stroke = rgb_to_html(*colorsys.hsv_to_rgb(h, s * factor, v))
        return xocolor.XoColor("%s,%s" % (new_stroke, new_fill))

    def _pulse_cb(self):
        if self._direction == 1:
            self._level += 1
            if self._level > self._level_max:
                self._direction = 0
                self._level = self._level_max
        elif self._direction == 0:
            self._level -= 1
            if self._level <= 0:
                self._direction = 1
                self._level = 0

        self.props.xo_color = self._icon_colors[self._level]
        self.emit_paint_needed(0, 0, -1, -1)
        return True

    def _start_pulsing(self):
        if self._pulse_id:
            return

        self._pulse_id = gobject.timeout_add(self._INTERVAL, self._pulse_cb)

    def _stop_pulsing(self):
        if not self._pulse_id:
            return

        self._cleanup()
        self._level = 100.0
        self.props.xo_color = self._orig_color

    def _resume_activate_cb(self, menuitem):
        self.emit('resume')

    def _stop_activate_cb(self, menuitem):
        self.emit('stop')

    def get_activity(self):
        return self._activity

class ActivitiesDonut(hippo.CanvasBox, hippo.CanvasItem):
    __gtype_name__ = 'SugarActivitiesDonut'
    def __init__(self, shell, **kwargs):
        hippo.CanvasBox.__init__(self, **kwargs)

        self._activities = []
        self._shell = shell
        self._angles = []

        self._model = shell.get_model().get_home()
        self._model.connect('activity-added', self._activity_added_cb)
        self._model.connect('activity-removed', self._activity_removed_cb)
        self._model.connect('pending-activity-changed', self._activity_changed_cb)

        self.connect('button-release-event', self._button_release_event_cb)

    def _get_icon_from_activity(self, activity):
        for icon in self._activities:
            if icon.get_activity().equals(activity):
                return icon

    def _activity_added_cb(self, model, activity):
        self._add_activity(activity)

    def _activity_removed_cb(self, model, activity):
        self._remove_activity(activity)
    
    def _activity_changed_cb(self, model, activity):
        self.emit_paint_needed(0, 0, -1, -1)

    def _remove_activity(self, activity):
        icon = self._get_icon_from_activity(activity)
        if icon:
            self.remove(icon)
            icon._cleanup()
        self._activities.remove(icon)
        self._compute_angles()

    def _add_activity(self, activity):
        icon = ActivityIcon(activity)
        icon.connect('resume', self._activity_icon_resumed_cb)
        icon.connect('stop', self._activity_icon_stop_cb)
        self.append(icon, hippo.PACK_FIXED)

        self._activities.append(icon)
        self._compute_angles()

    def _activity_icon_resumed_cb(self, icon):
        activity = icon.get_activity()
        activity_host = self._shell.get_activity(activity.get_activity_id())
        if activity_host:
            activity_host.present()
        else:
            logging.error("Could not find ActivityHost for activity %s" %
                          activity.get_activity_id())

    def _activity_icon_stop_cb(self, icon):
        activity = icon.get_activity()
        activity_host = self._shell.get_activity(activity.get_activity_id())
        if activity_host:
            activity_host.close()
        else:
            logging.error("Could not find ActivityHost for activity %s" %
                          activity.get_activity_id())

    def _get_activity(self, x, y):
        # Compute the distance from the center.
        [width, height] = self.get_allocation()
        x -= width / 2
        y -= height / 2
        r = math.hypot(x, y)

        # Ignore the click if it's not inside the donut
        if r < self._get_inner_radius() or r > self._get_radius():
            return None

        # Now figure out where in the donut the click was.
        angle = math.atan2(-y, -x) + math.pi

        # Unfortunately, _get_angles() doesn't count from 0 to 2pi, it
        # counts from roughly pi/2 to roughly 5pi/2. So we have to
        # compare its return values against both angle and angle+2pi
        high_angle = angle + 2 * math.pi

        for index, activity in enumerate(self._model):
            [angle_start, angle_end] = self._get_angles(index)
            if angle_start < angle and angle_end > angle:
                return activity
            elif angle_start < high_angle and angle_end > high_angle:
                return activity

        return None

    def _button_release_event_cb(self, item, event):
        activity = self._get_activity(event.x, event.y)
        if activity is None:
            return False

        activity_host = self._shell.get_activity(activity.get_activity_id())
        if activity_host:
            activity_host.present()
        return True

    def _update_activity_sizes(self):
        # First, get the shell's memory mappings; this memory won't be
        # counted against the memory used by activities, since it
        # would still be in use even if all activities exited.
        shell_mappings = {}
        try:
            shell_smaps = ProcSmaps(os.getpid())
            for mapping in shell_smaps.mappings:
                if mapping.shared_clean > 0 or mapping.shared_dirty > 0:
                    shell_mappings[mapping.name] = mapping
        except Exception, e:
            logging.warn('ActivitiesDonut: could not read own smaps: %r' % e)

        # Get the memory mappings of each process that hosts an
        # activity, and count how many activity instances each
        # activity process hosts, and how many processes are mapping
        # each shared library, etc
        process_smaps = {}
        num_activities = {}
        num_mappings = {}
        unknown_size_activities = 0
        for activity in self._model:
            pid = activity.get_pid()
            if not pid:
                # Still starting up, hasn't opened a window yet
                unknown_size_activities += 1
                continue

            if num_activities.has_key(pid):
                num_activities[pid] += 1
                continue

            try:
                smaps = ProcSmaps(pid)
                self._subtract_mappings(smaps, shell_mappings)
                for mapping in smaps.mappings:
                    if mapping.shared_clean > 0 or mapping.shared_dirty > 0:
                        if num_mappings.has_key(mapping.name):
                            num_mappings[mapping.name] += 1
                        else:
                            num_mappings[mapping.name] = 1
                process_smaps[pid] = smaps
                num_activities[pid] = 1
            except Exception, e:
                logging.warn('ActivitiesDonut: could not read /proc/%s/smaps: %r'
                             % (pid, e))

        # Compute total memory used per process
        process_size = {}
        total_activity_size = 0
        for activity in self._model:
            pid = activity.get_pid()
            if not process_smaps.has_key(pid):
                continue

            smaps = process_smaps[pid]
            size = 0
            for mapping in smaps.mappings:
                size += mapping.private_clean + mapping.private_dirty
                if mapping.shared_clean + mapping.shared_dirty > 0:
                    num = num_mappings[mapping.name]
                    size += (mapping.shared_clean + mapping.shared_dirty) / num
            process_size[pid] = size
            total_activity_size += size / num_activities[pid]

        # Now, see how much free memory is left.
        free_memory = 0
        try:
            meminfo = open('/proc/meminfo')
            for line in meminfo.readlines():
                if line.startswith('MemFree:') or line.startswith('SwapFree:'):
                    free_memory += int(line[9:-3])
            meminfo.close()
        except IOError:
            logging.warn('ActivitiesDonut: could not read /proc/meminfo')
        except (IndexError, ValueError):
            logging.warn('ActivitiesDonut: /proc/meminfo was not in ' +
                         'expected format')

        total_memory = float(total_activity_size + free_memory)

        # Each activity has an ideal size of:
        #   process_size[pid] / num_activities[pid] / total_memory
        # (And the free memory wedge is ideally free_memory /
        # total_memory) However, no activity wedge is allowed to be
        # smaller than _MIN_WEDGE_SIZE. This means the small
        # activities will use up extra space, which would make the
        # ring overflow. We fix that by reducing the large activities
        # and the free space proportionately. If there are activities
        # of unknown size, they are simply carved out of the free
        # space.

        free_percent = free_memory / total_memory
        activity_sizes = []
        overflow = 0.0
        reducible = free_percent
        for icon in self._activities:
            pid = icon.get_activity().get_pid()
            if process_size.has_key(pid):
                icon.size = (process_size[pid] / num_activities[pid] /
                             total_memory)
                if icon.size < _MIN_WEDGE_SIZE:
                    overflow += _MIN_WEDGE_SIZE - icon.size
                    icon.size = _MIN_WEDGE_SIZE
                else:
                    reducible += icon.size - _MIN_WEDGE_SIZE
            else:
                icon.size = _MIN_WEDGE_SIZE

        if reducible > 0.0:
            reduction = overflow / reducible
            if unknown_size_activities > 0:
                unknown_percent = _MIN_WEDGE_SIZE * unknown_size_activities
                if (free_percent * (1 - reduction) < unknown_percent):
                    # The free wedge won't be large enough to fit the
                    # unknown-size activities. So adjust things
                    overflow += unknown_percent - free_percent
                    reducible -= free_percent
                    reduction = overflow / reducible

            if reduction > 0.0:
                for icon in self._activities:
                    if icon.size > _MIN_WEDGE_SIZE:
                        icon.size -= (icon.size - _MIN_WEDGE_SIZE) * reduction

    def _subtract_mappings(self, smaps, mappings_to_remove):
        for mapping in smaps.mappings:
            if mappings_to_remove.has_key(mapping.name):
                mapping.shared_clean = 0
                mapping.shared_dirty = 0

    def _compute_angles(self):
        self._angles = []
        if len(self._activities) == 0:
            return

        # Normally we don't _update_activity_sizes() when launching a
        # new activity; but if the new wedge would overflow the ring
        # then we have no choice.
        total = reduce(lambda s1,s2: s1 + s2,
                       [icon.size for icon in self._activities])
        if total > 1.0:
            self._update_activity_sizes()

        # The first wedge (Journal) should be centered at 6 o'clock
        size = self._activities[0].size or _MIN_WEDGE_SIZE
        angle = (math.pi - size * 2 * math.pi) / 2
        self._angles.append(angle)

        for icon in self._activities:
            size = icon.size or _MIN_WEDGE_SIZE
            self._angles.append(self._angles[-1] + size * 2 * math.pi)

    def redraw(self):
        self._update_activity_sizes()
        self._compute_angles()
        self.emit_request_changed()

    def _get_angles(self, index):
        return [self._angles[index],
                self._angles[(index + 1) % len(self._angles)]]

    def _get_radius(self):
        [width, height] = self.get_allocation()
        return min(width, height) / 2

    def _get_inner_radius(self):
        return self._get_radius() * 0.5

    def do_paint_below_children(self, cr, damaged_box):
        [width, height] = self.get_allocation()

        cr.translate(width / 2, height / 2)

        radius = self._get_radius()

        # Outer Ring
        cr.set_source_rgb(0xf1 / 255.0, 0xf1 / 255.0, 0xf1 / 255.0)
        cr.arc(0, 0, radius, 0, 2 * math.pi)
        cr.fill()

        # Selected Wedge
        current_activity = self._model.get_pending_activity()
        if current_activity is not None:
            selected_index = self._model.index(current_activity)    
            [angle_start, angle_end] = self._get_angles(selected_index)
        
            cr.new_path()   
            cr.move_to(0, 0)
            cr.line_to(radius * math.cos(angle_start),
                       radius * math.sin(angle_start))
            cr.arc(0, 0, radius, angle_start, angle_end)
            cr.line_to(0, 0)
            cr.set_source_rgb(1, 1, 1)
            cr.fill()        

        # Edges
        if len(self._model):   
            n_edges = len(self._model) + 1
        else:
            n_edges = 0
            
        for i in range(0, n_edges):
            cr.new_path()
            cr.move_to(0, 0)
            [angle, unused_angle] = self._get_angles(i)
            cr.line_to(radius * math.cos(angle),
                        radius * math.sin(angle))
            
            cr.set_source_rgb(0xe2 / 255.0, 0xe2 / 255.0, 0xe2 / 255.0)
            cr.set_line_width(4)
            cr.stroke_preserve()
             
        # Inner Ring    
        cr.new_path()
        cr.arc(0, 0, self._get_inner_radius(), 0, 2 * math.pi)
        cr.set_source_rgb(0xe2 / 255.0, 0xe2 / 255.0, 0xe2 / 255.0)
        cr.fill()

    def do_allocate(self, width, height, origin_changed):
        hippo.CanvasBox.do_allocate(self, width, height, origin_changed)

        radius = (self._get_inner_radius() + self._get_radius()) / 2

        for i, icon in enumerate(self._activities):
            [angle_start, angle_end] = self._get_angles(i)
            angle = angle_start + (angle_end - angle_start) / 2

            [icon_width, icon_height] = icon.get_allocation()

            x = int(radius * math.cos(angle)) - icon_width / 2
            y = int(radius * math.sin(angle)) - icon_height / 2
            self.set_position(icon, x + width / 2, y + height / 2)