# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Rickard Lindberg, Roger Lindberg
#
# This file is part of Timeline.
#
# Timeline 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 3 of the License, or
# (at your option) any later version.
#
# Timeline 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 Timeline.  If not, see <http://www.gnu.org/licenses/>.


import contextlib
import os
import tempfile

import wx

from timelinelib.canvas.appearance import Appearance
from timelinelib.canvas.backgrounddrawers.defaultbgdrawer import DefaultBackgroundDrawer
from timelinelib.canvas.drawing import get_drawer
from timelinelib.canvas.drawing.viewproperties import ViewProperties
from timelinelib.canvas.eventboxdrawers.defaulteventboxdrawer import DefaultEventBoxDrawer
from timelinelib.canvas.events import create_timeline_redrawn_event
from timelinelib.monitoring import Monitoring
from timelinelib.wxgui.components.font import Font
from timelinelib.features.experimental.experimentalfeatures import EXTENDED_VERTICAL_SCROLL_STRATEGY
from timelinelib.canvas.highlighttimer import HighlightTimer


CHOICE_WHOLE_PERIOD = _("Whole Timeline")
CHOICE_VISIBLE_PERIOD = _("Visible Period")
CHOICES = (CHOICE_WHOLE_PERIOD, CHOICE_VISIBLE_PERIOD)

class TimelineCanvasController:

    def __init__(self, view, debug_enabled, drawer=None):
        """
        The purpose of the drawer argument is make testing easier. A test can
        mock a drawer and use the mock by sending it in the drawer argument.
        Normally the drawer is collected with the get_drawer() method.
        """
        self._debug_enabled = debug_enabled
        self._appearance = Appearance()
        self._appearance.listen_for_any(self._redraw_timeline)
        self._monitoring = Monitoring()
        self._view = view
        self._drawing_algorithm = self._set_drawing_algorithm(drawer)
        self._timeline = None
        self.set_event_box_drawer(DefaultEventBoxDrawer())
        self.set_background_drawer(DefaultBackgroundDrawer())
        self._fast_draw = False
        self._timeline = None
        self._view_properties = ViewProperties()
        self._dragscroll_timer_running = False
        self._set_colors_and_styles()
        self._set_search_choises()
        self._timer = None
        self._real_time_redraw_navigate = False
        self.redraw_time_interval_has_changed()

    @property
    def scene(self):
        return self._drawing_algorithm.scene

    @property
    def appearance(self):
        return self._appearance

    @appearance.setter
    def appearance(self, value):
        if self._appearance is not None:
            self._appearance.unlisten(self._redraw_timeline)
        if value is not None:
            self._appearance = value
            self._appearance.listen_for_any(self._redraw_timeline)
            self.redraw_timeline()

    @property
    def timeline(self):
        """The timeline to be drawn."""
        return self._timeline

    @timeline.setter
    def timeline(self, value):
        """Inform what timeline to draw."""
        self._unregister_timeline(self._timeline)
        if value is None:
            self._set_null_timeline()
        else:
            self._set_non_null_timeline(value)

    @property
    def view_properties(self):
        return self._view_properties

    @property
    def time_period(self):
        """Currently displayed time period."""
        if self._timeline is None:
            raise Exception(_("No timeline set"))
        return self._view_properties.displayed_period

    @property
    def use_extended_vertical_scrolling(self):
        return EXTENDED_VERTICAL_SCROLL_STRATEGY.enabled()

    def set_event_box_drawer(self, event_box_drawer):
        self._drawing_algorithm.set_event_box_drawer(event_box_drawer)

    def set_background_drawer(self, drawer):
        self._drawing_algorithm.set_background_drawer(drawer)

    def use_fast_draw(self, value):
        self._fast_draw = value

    def navigate(self, navigation_fn):
        old_period = self._view_properties.displayed_period
        new_period = navigation_fn(old_period)
        MIN_ZOOM_DELTA, min_zoom_error_text = self.time_type.get_min_zoom_delta()
        if new_period.delta() < MIN_ZOOM_DELTA:
            raise ValueError(min_zoom_error_text)
        min_time = self.time_type.get_min_time()
        if min_time is not None and new_period.start_time < min_time:
            raise ValueError(_("Can't scroll more to the left"))
        max_time = self.time_type.get_max_time()
        if max_time is not None and new_period.end_time > max_time:
            raise ValueError(_("Can't scroll more to the right"))
        self._view_properties.displayed_period = new_period
        self.redraw_timeline()

    def save_as_image(self, path, width, height, image_type):
        with self._preserve_scene_and_divider_position():
            image_exporter = ImageExporter.create(path, image_type)
            if height is None:
                with image_exporter.measure_dc(width, 100) as dc:
                    measurements = self._drawing_algorithm.draw_measure(
                        dc,
                        self._timeline,
                        self._view_properties,
                        self._appearance
                    )
                height = measurements["above_stretch"] + measurements["below_stretch"]
                self._view_properties.divider_position = measurements["above_stretch"] / height
            with image_exporter.paint_dc(width, height) as dc:
                self._drawing_algorithm.draw(
                    dc,
                    self._timeline,
                    self._view_properties,
                    self._appearance
                )

    @contextlib.contextmanager
    def _preserve_scene_and_divider_position(self):
        old_divider_position = self._view_properties.divider_position
        try:
            yield
        finally:
            self._view_properties.divider_position = old_divider_position
            self._redraw_timeline() # To ensure proper scene

    def redraw_timeline(self):
        self._redraw_timeline()

    def get_time(self, x):
        return self._drawing_algorithm.get_time(x)

    def event_with_rect_at(self, x, y):
        return self._drawing_algorithm.event_with_rect_at(x, y, self._view_properties)

    def event_at(self, x, y, alt_down=False):
        return self._drawing_algorithm.event_at(x, y, alt_down)

    def set_selected(self, event, is_selected):
        self._view_properties.set_selected(event, is_selected)

    def clear_selected(self):
        self._view_properties.clear_selected()

    def select_all_events(self):
        self._view_properties.select_all_events()

    def is_selected(self, event):
        return self._view_properties.is_selected(event)

    def set_hovered_event(self, event):
        self._view_properties.change_hovered_event(event)

    def get_hovered_event(self):
        return self._view_properties.hovered_event

    def set_selection_rect(self, cursor):
        self._view_properties.set_selection_rect(cursor.rect)
        self._fast_draw = True
        self.redraw_timeline()

    def remove_selection_rect(self):
        self._view_properties.set_selection_rect(None)
        self._fast_draw = True
        self.redraw_timeline()

    def get_hscroll_amount(self):
        return self._view_properties.hscroll_amount

    def set_hscroll_amount(self, amount):
        self._view_properties.hscroll_amount = amount

    def set_period_selection(self, period):
        if period is None:
            self._view_properties.period_selection = None
        else:
            self._view_properties.period_selection = (period.start_time, period.end_time)
        self._redraw_timeline()

    def select_events_in_rect(self, rect):
        self._view_properties.set_all_selected(self.get_events_in_rect(rect))

    def event_has_sticky_balloon(self, event):
        return self._view_properties.event_has_sticky_balloon(event)

    def set_event_sticky_balloon(self, event, is_sticky):
        self._view_properties.set_event_has_sticky_balloon(event, is_sticky)
        self.redraw_timeline()

    def add_highlight(self, event, clear):
        self._view_properties.add_highlight(event, clear)

    def tick_highlights(self):
        self._view_properties.tick_highlights(limit=15)

    def has_higlights(self):
        return self._view_properties.has_higlights()

    def get_period_choices(self):
        return CHOICES
        
    def filter_events(self, events, search_period):
        return self._search_choice_functions[search_period](events)
    
    def event_is_period(self, event):
        return self._drawing_algorithm.event_is_period(event.get_time_period())

    def snap(self, time):
        return self._drawing_algorithm.snap(time)

    def get_selected_events(self):
        return self._timeline.find_event_with_ids(
            self._view_properties.get_selected_event_ids()
        )

    def get_events_in_rect(self, rect):
        return self._drawing_algorithm.get_events_in_rect(rect)

    def get_hidden_event_count(self):
        return self._drawing_algorithm.get_hidden_event_count()

    def increment_font_size(self):
        font = self._drawing_algorithm.increment_font_size()
        self._appearance.set_event_font(font)
        self._redraw_timeline()
        return font

    def decrement_font_size(self):
        font = self._drawing_algorithm.decrement_font_size()
        self._appearance.set_event_font(font)
        self._redraw_timeline()
        return font

    def get_closest_overlapping_event(self, event, up):
        return self._drawing_algorithm.get_closest_overlapping_event(event, up=up)

    def balloon_at(self, cursor):
        return self._drawing_algorithm.balloon_at(*cursor.pos)

    #
    # Private functions
    #

    def _set_null_timeline(self):
        self._timeline = None
        self.time_type = None
        self._view.Disable()

    def _set_non_null_timeline(self, timeline):
        self._timeline = timeline
        self.time_type = timeline.get_time_type()
        self._timeline.register(self._timeline_changed)
        self._view_properties.unlisten(self._redraw_timeline)
        properties_loaded = self._load_view_properties()
        if properties_loaded:
            self._view_properties.listen_for_any(self._redraw_timeline)
            self._redraw_timeline()
            self._view.Enable()
            self._view.SetFocus()

    def _load_view_properties(self):
        properties_loaded = True
        try:
            self._view_properties.clear_db_specific()
            self._timeline.load_view_properties(self._view_properties)
            if self._view_properties.displayed_period is None:
                default_tp = self.time_type.get_default_time_period()
                self._view_properties.displayed_period = default_tp
            self._view_properties.hscroll_amount = 0
        except Exception:
            properties_loaded = False
        return properties_loaded

    def _unregister_timeline(self, timeline):
        if timeline is not None:
            timeline.unregister(self._timeline_changed)

    def _set_search_choises(self):
        self._search_choice_functions = {
            CHOICE_WHOLE_PERIOD: self._choose_whole_period,
            CHOICE_VISIBLE_PERIOD: self._choose_visible_period
        }

    def _choose_whole_period(self, events):
        return self._view_properties.filter_events(events)

    def _choose_visible_period(self, events):
        events = self._view_properties.filter_events(events)
        period = self._view_properties.displayed_period
        return [e for e in events if period.overlaps(e.get_time_period())]

    def _timeline_changed(self, state_change):
        self._redraw_timeline()

    def _set_colors_and_styles(self):
        """Define the look and feel of the drawing area."""
        self._view.SetBackgroundColour(wx.WHITE)
        self._view.SetBackgroundStyle(wx.BG_STYLE_CUSTOM)
        self._view.SetDefaultCursor()
        self._view.Disable()

    def _redraw_timeline(self):

        def display_monitor_result(dc):
            (width, height) = self._view.GetSize()
            redraw_time = self._monitoring.timer_elapsed_ms
            self._monitoring.count_timeline_redraw()
            dc.SetTextForeground((255, 0, 0))
            dc.SetFont(Font(12, weight=wx.FONTWEIGHT_BOLD))
            index, is_in_transaction, history = self._timeline.transactions_status()
            dc.DrawText("Undo buffer size: %d" % len(history), width - 300, height - 100)
            dc.DrawText("Undo buffer pos: %d" % index, width - 300, height - 80)
            dc.DrawText("Redraw count: %d" % self._monitoring.timeline_redraw_count, width - 300, height - 60)
            dc.DrawText("Last redraw time: %.3f ms" % redraw_time, width - 300, height - 40)

        def fn_draw(dc):
            self._monitoring.timer_start()
            self._drawing_algorithm.set_event_font(self._appearance.get_event_font())
            self._drawing_algorithm.draw(dc, self._timeline, self._view_properties, self._appearance, fast_draw=self._fast_draw)
            self._monitoring.timer_end()
            if self._debug_enabled:
                display_monitor_result(dc)
            self._fast_draw = False

        if self._timeline and self._view_properties.displayed_period:
            self._view_properties.divider_position = (float(self._view.GetDividerPosition()) / 100.0)
            self._view.RedrawSurface(fn_draw)
            self._view.PostEvent(create_timeline_redrawn_event())

    def _set_drawing_algorithm(self, drawer):
        """
        The drawer interface:
            methods:
                draw(d, t, p, a, f)
                set_event_box_drawer(d)
                set_background_drawer(d)
                get_time(x)
                event_with_rect_at(x, y, k)
                event_at(x, y, k)
                event_is_period(p)
                snap(t)
                get_events_in_rect(r)
                get_hidden_event_count()
                increment_font_size()
                decrement_font_size()
                get_closest_overlapping_event(...)
                balloon_at(c)
            properties:
                scene
        """
        return drawer or get_drawer()

    def redraw_time_interval_navigate(self):
        self._real_time_redraw_navigate = self._view.main_frame.config.real_time_redraw_interval_navigate

    def redraw_time_interval_has_changed(self):
        if hasattr(self._view, 'main_frame'):
            interval_in_seconds = self._view.main_frame.config.real_time_redraw_interval
            if self._timer:
                self._timer.Stop()
            if interval_in_seconds > 0:
                self._timer = HighlightTimer(self._real_time_tracking)
                self._timer.start_highlighting(milliseconds=interval_in_seconds * 1000)
            else:
                self._timer = None

    def _real_time_tracking(self):
        if self._real_time_redraw_navigate:
            name, function = self._view.main_frame.timeline.get_time_type().get_navigation_functions()[0]
            function(self._view.main_frame, self._view.main_frame.canvas.GetTimePeriod(),
                     self._view.main_frame.canvas.NavigateWithFunction)
        else:
            self._redraw_timeline()


class ImageExporter:

    @staticmethod
    def create(path, image_type):
        if image_type == "png":
            return PngExporter(path)
        elif image_type == "svg":
            return SvgExporter(path)
        else:
            raise ValueError(f"Unknown image type {image_type}")

    def __init__(self, path):
        self.path = path


class PngExporter(ImageExporter):

    @contextlib.contextmanager
    def measure_dc(self, width, height):
        bitmap = wx.Bitmap(width, height)
        memdc = wx.MemoryDC()
        memdc.SelectObject(bitmap)
        yield memdc

    @contextlib.contextmanager
    def paint_dc(self, width, height):
        bitmap = wx.Bitmap(width, height)
        memdc = wx.MemoryDC()
        memdc.SelectObject(bitmap)
        memdc.SetBackground(wx.Brush(wx.WHITE, wx.BRUSHSTYLE_SOLID))
        memdc.Clear()
        yield memdc
        # This line of code seems a little bit odd, but it was the only way I could
        # find to avoid that the last drawn balloon had "white" text and "white" icon.
        # Have tried Flush() and Delete() of gc, but none works.
        wx.GraphicsContext.Create(memdc)
        bitmap.ConvertToImage().SaveFile(self.path, wx.BITMAP_TYPE_PNG)


class SvgExporter(ImageExporter):

    @contextlib.contextmanager
    def measure_dc(self, width, height):
        with tempfile.TemporaryDirectory(prefix="timeline_svg_export_") as tmp:
            dc = self._svg_dc(os.path.join(tmp, "export.svg"), width, height)
            try:
                yield dc
            finally:
                dc.Destroy()
                # Destroying the DC seems to ensure that the SVG file is
                # actually closed. If the SVG file is still opened, deleting
                # the temporary folder fails (at least on Windows).

    @contextlib.contextmanager
    def paint_dc(self, width, height):
        yield self._svg_dc(self.path, width, height)

    def _svg_dc(self, path, width, height):
        return PatchedSVGFileDC(path, width=width, height=height, dpi=96)


class PatchedSVGFileDC(wx.SVGFileDC):

    def SetClippingRegion(self, *args, **kwargs):
        # This workaround was created because a possible bug in wxPython 4.1.0:
        # https://github.com/wxWidgets/Phoenix/issues/2053
        if len(args) == 1 and not kwargs:
            rect = args[0]
            wx.SVGFileDC.SetClippingRegion(self, rect.X, rect.Y, rect.Width, rect.Height)
        else:
            wx.SVGFileDC.SetClippingRegion(self, *args, **kwargs)
