#!/usr/bin/env pythonupper
# -*- coding: utf-8 -*-

"""  

The "Artists" to plot a airfoil object on a pg.PlotItem 

"""

from PyQt6.QtGui                import QColor, QPainterPath, QTransform
from PyQt6.QtCore               import Qt, pyqtSignal, QRectF

from ..base.artist              import *
from ..base.common_utils        import *

from ..model.polar_set          import * 
from ..model.xo2_input          import (OpPoint_Definition, OpPoint_Definitions, OPT_TARGET, OPT_MAX, OPT_MIN)
from ..model.xo2_results        import OpPoint_Result, Optimization_History_Entry

from .ae_artists                import _color_airfoil, SYMBOL_TRANSITION_RIGHT


import logging
logger = logging.getLogger(__name__)
# logger.setLevel(logging.DEBUG)


# -------- helper functions ------------------------

def _size_opPoint (weighting : float, normal_size = 13) -> float:
    """ returns plot size of an opPoint (def) depending on weighting"""

    # weighting can be negative (meaning fixed weighting - no dynamic)
    return abs(weighting) ** 0.8 * normal_size 



# -------- Movable Points  ------------------------


class Movable_OpPoint_Def (Movable_Point):
    """ 
    Movable opPoint definition
    """

    name = "OpPoint Definition"

    COLOR = QColor ("darkturquoise")

    # -- special symbols for min/max optTypes 
    #
    #               .--x
    #               |
    #               y
    #

    @staticmethod
    def _symbol_opt_up (width=1.0, height1=0.3, height2=0.5):
        path = QPainterPath()

        path.addRect(QRectF(-width / 2, -height1 / 2, width, height1))
        # verticial line
        path.moveTo(0.0, -height1 / 2)
        path.lineTo(0.0, -height2 * 2)
        #triangle
        path.moveTo(-width / 2, -height2 * 2)
        path.lineTo( width / 2, -height2 * 2)
        path.lineTo(       0.0, -height2 * 2 - height2)
        path.lineTo(-width / 2, -height2 * 2)

        return path

    tr : QTransform = QTransform()
    tr.rotate(-90)                                  # because y is downward

    SYMBOL_OPT_UP    = _symbol_opt_up ()                       
    SYMBOL_OPT_LEFT  = tr.map (SYMBOL_OPT_UP)                         
    SYMBOL_OPT_DOWN  = tr.map (SYMBOL_OPT_LEFT)                        
    SYMBOL_OPT_RIGHT = tr.map (SYMBOL_OPT_DOWN)                        


    def __init__ (self, pi : pg.PlotItem, 
                    opPoint_def : OpPoint_Definition,
                    xyVars : Tuple[var, var], 
                    movable = True, 
                    on_selected = None,
                    on_delete   = None,
                    **kwargs):

        self._pi = pi
        self._opPoint_def = opPoint_def
        self._xyVars = xyVars

        self._callback_selected = on_selected if (callable(on_selected) and movable) else None
        self._callback_delete   = on_delete if (callable(on_delete) and movable) else None
        self._highlight_item    = None                       

        brush = QColor ("black")
        brush.setAlphaF (0.3) 

        size    = _size_opPoint (opPoint_def.weighting)
        xy      = self.xy_in_xyVars()                                   # get x,y coordinates in xyVars

        # sanity - opPoint_def could be not ready because seed polar has to be calculated async
        if xy == (None, None):
            return 

        movable = movable and self.is_movable_in_xyVars()               # can be moved in xyVars?

        super().__init__(xy, movable=movable, label_anchor = (0, 0.5),
                            color=QColor(self.COLOR), brush=brush,
                            symbol=self._symbol(), size=size, show_label_static = True, **kwargs)

        self.sigShiftClick.connect  (self._delete_opPoint_def)


    def is_movable_in_xyVars (self) -> bool:
        """ returns True if self can be moved in xyVars"""

        opPoint_def = self._opPoint_def

        if opPoint_def.specVar in self._xyVars and opPoint_def.optVar in self._xyVars:
            return True 
        elif opPoint_def._xyVars_are_indirect (self._xyVars):
            return True
        if opPoint_def.specVar in self._xyVars and opPoint_def.optType in [OPT_MIN, OPT_MAX]:
            return True 
        else:
            return False
    

    def _symbol (self) -> QPainterPath | str:
        """ returns the symbol for self depending on optType"""

        xVar, yVar = self._xyVars

        optVar, optType = self._opPoint_def.optVar_type_for_xyVars (self._xyVars)

        if optType == OPT_TARGET:
            s = 's'
        elif optType == OPT_MAX:
            if optVar == yVar:
                s = self.SYMBOL_OPT_UP
            else:
                s = self.SYMBOL_OPT_RIGHT
        elif optType == OPT_MIN:
            if optVar == yVar:
                s = self.SYMBOL_OPT_DOWN
            else:
                s = self.SYMBOL_OPT_LEFT
        else:                                           # either optVar nor specVar are in diagram
            s = 's'
        
        return s


    def _delete_opPoint_def (self, _):
        """ shift-click - delete me opPoint_dev"""

        if callable(self._callback_delete):
            # callback / emit signal delayed so we leave the scope of Graphics 
            QTimer() .singleShot(0, lambda: self._callback_delete (self._opPoint_def))   


    def xy_in_xyVars (self) -> tuple: 
        """ returns the x,y coordinates of self in xyVars"""

        polar_point = self._opPoint_def.polar_point ()
        if polar_point is None:
            return None, None
        
        x= polar_point.get_value(self._xyVars[0])
        y= polar_point.get_value(self._xyVars[1])     
        if x is None or y is None:
            return None, None       
        
        return x,y 



    @override
    def mouseClickEvent(self, ev):
        """ handle callback to parent when clicked with left button to select self """

        if not self.moving and ev.button() == Qt.MouseButton.LeftButton  \
                           and not (ev.modifiers() & Qt.KeyboardModifier.ShiftModifier): # shift is handled in super
            if callable(self._callback_selected):
                ev.accept()

                # callback / emit delayed so we leave the scope of Graphics 
                QTimer() .singleShot(0, lambda: self._callback_selected (self._opPoint_def))   
        else:
            super().mouseClickEvent (ev)


    @override
    def _moving (self, _):
        """ slot -point is moved"""
        self._opPoint_def.set_xyValues_for_xyVars (self._xyVars, (self.x,self.y))

        self.setPos (self.xy_in_xyVars())                      # ... if we run against limits 

        # also refresh pos of highlight item 
        if isinstance (self._highlight_item, pg.TargetItem):
            self._highlight_item.setPos (self.pos())


    @override
    def label_static (self, *_) -> str:
        """ the static label - can be overloaded """
        return f"{self._opPoint_def.iPoint}" 

    @override
    def label_moving (self, *_):
        """ label for hovering or moving"""
        return f"{self._opPoint_def.labelLong_for (self._xyVars, fix=False)}"

    @override
    def _finished (self, _):
        """ slot - point moving is finished"""

        if callable(self._callback_selected):
            # ensure self will be selected after moving 
            QTimer() .singleShot (0, lambda: self._callback_selected (self._opPoint_def)) 

        if callable(self._callback_changed):
            # callback / emit  delayed so we leave the scope of Graphics 
            QTimer() .singleShot (0, self._callback_changed )   


    def set_highlight_item (self, aItem: pg.TargetItem):
        """ a point item used to highlight current"""

        self._highlight_item = aItem
        if self._highlight_item:
            self._highlight_item.setPos (self.pos())




# -------- Artists ------------------------


class Xo2_OpPoint_Defs_Artist (Artist):
    """ Plot / Modify Xoptfoil2 operating point definitions """

    sig_opPoint_def_changed     = pyqtSignal ()                       # opPoint_def changed  
    sig_opPoint_def_selected    = pyqtSignal (OpPoint_Definition)     # opPoint_def selected 

    def __init__ (self, *args,
                  cur_opPoint_def_fn = None,   
                  isRunning_fn = None, 
                  xyVars = (var.CD, var.CL), 
                  **kwargs):

        super().__init__(*args, **kwargs)

        self._cur_opPoint_def_fn = cur_opPoint_def_fn           # method to get current opPoint definition
        self._isRunning_fn = isRunning_fn
        self._xyVars = xyVars                                   # definition of x,y axis


    @property
    def xyVars(self): return self._xyVars
    def set_xyVars (self, xyVars: Tuple[var, var]): 
        """ set new x, y variables for polar """
        self._xyVars = xyVars 
        self.refresh()


    @property
    def opPoint_defs (self) -> OpPoint_Definitions: 
        return self.data_list
    
    @property
    def cur_opPoint_def (self) -> OpPoint_Definition | None: 
        return self._cur_opPoint_def_fn() if callable (self._cur_opPoint_def_fn) else None

    @property
    def optimizer_isRunning (self) -> bool:
        """ True if optimizer is ready - definitions can be changed"""
        return self._isRunning_fn() if callable (self._isRunning_fn) else False


    def _plot (self): 

        for opPoint_def in self.opPoint_defs:

            pt = Movable_OpPoint_Def  (self._pi, opPoint_def, self.xyVars, 
                                            movable=not self.optimizer_isRunning and self.show_mouse_helper,
                                            on_changed =self.sig_opPoint_def_changed.emit,
                                            on_delete  =self._on_delete,
                                            on_selected=self.sig_opPoint_def_selected.emit)

            if pt.xy_in_xyVars() != (None, None):                   # sanity - if not None, it is in the view

                self._add (pt, name = pt.name_for_legend) 

                # highlight current opPoint def for edit with a big circle 

                if opPoint_def == self.cur_opPoint_def and not self.optimizer_isRunning:

                    brush = QColor (Movable_OpPoint_Def.COLOR)
                    brush.setAlphaF (0.3)
                    highlight_item = self._plot_point (0.02,0.2, color="black", size=60, brush=brush)

                    pt.set_highlight_item (highlight_item)


        # show mouse helper message
        msg = "OpPoint: click to select, shift-click to remove,  ctrl-click to add"
        self.set_help_message (msg)

        # make scene clickable to opPoint:def - delayed as during init scene is not yet available
        QTimer().singleShot (10, self._connect_scene_mouseClick)


    def _connect_scene_mouseClick (self): 
        """ connect mouse click in scene to slot"""           

        scene : pg.GraphicsScene = self._pi.scene()
        if scene:  scene.sigMouseClicked.connect (self._scene_clicked)


    @override
    def _remove_plots(self):
        """ overloaded to disconnect older slot when doing refresh"""

        super()._remove_plots()

        scene : pg.GraphicsScene = self._pi.scene()

        try:                            # slot could be not connected up to now
            scene.sigMouseClicked.disconnect (self._scene_clicked)
        except:
            pass


    def _scene_clicked (self, ev : MouseClickEvent):
        """ 
        slot - mouse click in scene of self - handle add opPoint_def with ctrl-click 
        """ 

        # handle only ctrl-click
        if not (ev.modifiers() & Qt.KeyboardModifier.ControlModifier): return  
       
        # was the scene click in my viewbox?
        if isinstance(ev.currentItem, pg.ViewBox):
            viewbox : pg.ViewBox = ev.currentItem
        else:
            viewbox : pg.ViewBox = ev.currentItem.getViewBox()

        if viewbox == self._pi.vb:                     # is this my view box (or of another plot item)? 
            
            # get scene coordinates of click pos and map to view box 
            ev.accept()
            pos : pg.Point = viewbox.mapSceneToView(ev.scenePos())

            # create new opPoint_def in the list of opPoint_defs 
            new_opPoint_def = self.opPoint_defs.create_in_xyVars (self.xyVars, pos.x(), pos.y())

            if new_opPoint_def is not None:
                self.sig_opPoint_def_changed.emit()
                self.sig_opPoint_def_selected.emit (new_opPoint_def)

            else: 
                self._plot_text ('New OpPoint Definition cannot be created in this diagram', color=COLOR_ERROR, itemPos=(0.5,0.5))
                QTimer().singleShot (1000, self.refresh)            # remove after 1 second


    def _on_delete (self, opPoint_def : OpPoint_Definition):
        """ callback of Movable Point to delete opPoint_def"""

        self.opPoint_defs.delete (opPoint_def)

        self.sig_opPoint_def_changed.emit ()


class Xo2_OpPoint_Artist (Artist):
    """ Plot Xoptfoil2 operating point results """

    def __init__ (self, *args, 
                  opPoint_defs_fn : Callable = None,
                  opPoint_results_fn : Callable = None,
                  prev_opPoint_results_fn : Callable = None,
                  xyVars = (var.CD, var.CL), 
                  **kwargs):

        self._xyVars = xyVars                                       # definition of x,y axis
        self._opPoint_defs_fn = opPoint_defs_fn                     # method to get opPoint definitions
        self._opPoint_results_fn = opPoint_results_fn               # method to get opPoint results
        self._prev_opPoint_results_fn = prev_opPoint_results_fn     # method to get previous opPoint results 

        super().__init__ (*args, **kwargs)

    @property
    def xyVars(self): return self._xyVars
    def set_xyVars (self, xyVars: Tuple[var, var]): 
        """ set new x, y variables for polar """
        self._xyVars = xyVars 
        self.refresh()


    def _plot (self): 
        """ plot all opPoints"""

        opPoints :      list[OpPoint_Result] = self._opPoint_results_fn() 
        if not opPoints: return 

        opPoint_defs :  list[OpPoint_Definition] = self._opPoint_defs_fn() 
        prev_opPoints : list[OpPoint_Result] = self._prev_opPoint_results_fn() [:] 

        legend_name = f"Op Point Result - Design {opPoints[0].idesign}"

        textFill = QColor ("black")
        textFill.setAlphaF (0.5)
       
        for iop, opPoint in enumerate (opPoints):

            prev_opPoint = prev_opPoints[iop] if prev_opPoints else None
            x = opPoint.get_value (self.xyVars[0])
            y = opPoint.get_value (self.xyVars[1])

            if x is None or y is None:
                logger.debug(f"OpPoint {iop} has no coordinates for this diagram - skipping")
                continue

            # get opPoint definition for this opPoint - if not available, use None
            # sanity - opPoint def could be deleted in the meantime 
            if iop < len(opPoint_defs):
                opPoint_def = opPoint_defs[iop]
                if opPoint_def.specValue != opPoint.get_value(opPoint_def.specVar):
                    # logger.warning(f"OpPoint {iop} has no definition with same specValue - skipping")
                    opPoint_def = None
            else:
                opPoint_def = None


            color  = self._opPoint_color  (opPoint, opPoint_def)
            symbol = self._opPoint_symbol (opPoint, prev_opPoint)
            label  = self._opPoint_label  (opPoint, opPoint_def, self.xyVars)
            size   = _size_opPoint (opPoint.weighting)

            textColor = QColor (color).darker(130)

            self._plot_point ((x,y), color=color, size=size, symbol=symbol, zValue=20, name=legend_name,
                              text=label, anchor=(0, 0.5), textOffset = (8,0), textColor=textColor, textFill=textFill)

            legend_name = None 

        # message if dynamic weighting of opPoints occurred 

        if any (op.is_new_weighting for op in opPoints):
            self._plot_text ('Dynamic weightings applied', color= "dimgray", fontSize=self.SIZE_HEADER, itemPos=(0.5, 1))


        
    def _opPoint_color (self, opPoint : OpPoint_Result, opPoint_def : OpPoint_Definition | None) -> QColor:
        """ color of opPoint depending on % deviation """

        # sanity 
        if opPoint_def is None: 
            return COLOR_OK

        deviation = opPoint.deviation

        optType = opPoint_def.optType
        optVar  = opPoint_def.optVar
        allow_improved = opPoint_def._myList.allow_improved_target

        if optType == OPT_TARGET:                            # targets - take deviation to target
            # Xoptfoil2 - colors for deviation 
            #
            #   if (op_spec%allow_improved_target .and. opt_type == 'target-drag' .and. dev < 0d0) then 
            #     how_good = Q_GOOD
            #   else if (op_spec%allow_improved_target .and. opt_type /= 'target-drag' .and. dev > 0d0) then 
            #     how_good = Q_GOOD
            #   else
            #     how_good = r_quality (abs(dev), 0.1d0, 2d0, 10d0)      ! in percent
            #   end if
            if allow_improved   and optVar == var.CD and deviation < 0.0:
                color = COLOR_GOOD
            elif allow_improved and optVar != var.CD and deviation > 0.0:
                color = COLOR_GOOD
            else:
                deviation = abs(deviation)
                if deviation < 0.1:
                    color = COLOR_GOOD
                elif deviation >= 10:
                    color = COLOR_ERROR
                elif deviation >= 2.0:
                    color = COLOR_WARNING
                elif deviation >= 0.1:
                    color = COLOR_OK
                else:
                    color = COLOR_OK
        else:                                               # min/max - deviation is improvement 
            # Xoptfoil2 - colors for improvement 
            #
            # if (improv <= 0d0) then 
            #     how_good = Q_BAD
            # elseif (improv < 5d0) then 
            #     how_good = Q_OK
            # elseif (improv >= 5d0) then 
            #     how_good = Q_GOOD
            # else
            #     how_good = Q_BAD
            # end if 
            if optType == OPT_MAX:                          # eg. OPT_MAX of 'glide' - more is better 
                improv = deviation
            else:                                           # eg OPT_MIN of 'cd' - less is better
                improv = deviation

            if improv <= -0.1:
                color = COLOR_WARNING
            elif improv < 2.0:
                color = COLOR_OK
            elif improv >= 2.0:
                color = COLOR_GOOD
            else:
                color = COLOR_ERROR
        return color 


    def _opPoint_symbol (self, opPoint : OpPoint_Result, prev_opPoint : OpPoint_Result | None ):
        """ a triangle in the direction of value change """

        if prev_opPoint is None: return 'o'

        x = opPoint.get_value (self.xyVars[0])
        y = opPoint.get_value (self.xyVars[1])
        prev_x = prev_opPoint.get_value (self.xyVars[0])
        prev_y = prev_opPoint.get_value (self.xyVars[1])

        if prev_x > x:
            symbol = 't3'               # triangle to left
        elif prev_x < x:
            symbol = 't2'               # triangle to right
        elif prev_y > y:
            symbol = 't'                # triangle to down
        elif prev_y < y:
            symbol = 't1'               # triangle to up
        else: 
            symbol = 'o'
        return symbol

        
    def _opPoint_label (self, opPoint : OpPoint_Result, opPoint_def : OpPoint_Definition | None, xyVars: tuple) -> str:
        """ label of opPoint depending on % deviation """

        # sanity 
        if opPoint_def is None: return None

        label   = None 
        optVar  = opPoint_def.optVar
        optType = opPoint_def.optType
        allow_improved = opPoint_def._myList.allow_improved_target

        # optVar must be in diagram to show distance as it comes from Xo2 results 

        if optVar in xyVars:  

            # set distance = 0 if 'allow_improved_target' and result (distance) is better

            distance = opPoint.distance

            if optType == OPT_TARGET:                            # targets - take deviation to target
                if allow_improved   and optVar == var.CD and distance < 0.0:
                    distance = 0.0
                elif allow_improved and optVar != var.CD and distance > 0.0:
                    distance = 0.0
                else:
                    distance = abs(distance)

            # format value for label 

            if  optType == OPT_TARGET:                              # targets - take deviation to target
                if optVar == var.CD and round_down (distance,5):
                    label = f"delta {optVar}: {distance:.5f}"
                elif optVar != var.CD and round_up (distance,2):
                    label = f"delta {optVar}: {distance:.2f}"        
                else: 
                    label = f"hit"        
            else:                                               # min/max - deviation is improvement 
                if  opPoint_def.optType == OPT_MAX:             # eg. OPT_MAX of 'glide' - more is better 
                    improv = distance
                else:                                           # eg OPT_MIN of 'cd' - less is better
                    improv = distance

                if improv: 
                    label = f"d{var.CD} {improv:.5f}" if optVar == var.CD else f"d{var.CD} {improv:.2f}"

        # add flap angle if flap optimize 

        if opPoint_def.flap_optimize:
            if label:
                label = f"{label}, F{opPoint.flap:.1f}°"
            else:
                label = f"F{opPoint.flap:.1f}°"

        return label 
        



class Xo2_Design_Radius_Artist (Artist):
    """ Plot Xoptfoil2 design radius during optimization """

    
    @property
    def steps (self) -> list[Optimization_History_Entry]: 
        """ optimization step entries up to now """
        return self.data_object

    def _plot (self): 

        radius_list = [entry.design_radius for entry in self.steps]

        if radius_list:
            p = pg.PlotDataItem  (radius_list)
            self._add (p)

            # plot last as point 
            x = len(radius_list) - 1
            y = radius_list[-1]
            back_color = QColor ("black")
            back_color.setAlphaF (0.5)

            self._plot_point (x,y, size=5, color=COLOR_OK, text = f"{y:.3f}", textFill=back_color, anchor= (1.1, 0.9))



class Xo2_Improvement_Artist (Artist):
    """ Plot Xoptfoil2 improvement during optimization """
    
    @property
    def steps (self) -> list[Optimization_History_Entry]: 
        """ optimization step entries up to now """
        return self.data_object  

    def _plot (self): 

        improvement_list = [entry.improvement for entry in self.steps]

        if improvement_list:

            p = pg.PlotDataItem  (improvement_list)
            self._add (p)

            # plot last as point 
            x = len(improvement_list) - 1
            y = improvement_list[-1]
            back_color = QColor ("black")
            back_color.setAlphaF (0.5)
            
            self._plot_point (x,y, size=5, color=COLOR_GOOD, text = f"{y:.2f}%", textFill=back_color, 
                              textOffset=(-5,0), anchor= (0.8, -0.1))




class Xo2_Transition_Artist (Artist):
    """ Plot Xoptfoil2 point of transition on design airfoil """

    def __init__ (self, *args, 
                  opPoints_result_fn : Callable = None,
                  **kwargs):

        self._opPoints_result_fn = opPoints_result_fn                  # method to get opPoints of designs

        super().__init__ (*args, **kwargs)


    def _plot (self): 
        """ plot all opPoints"""

        airfoil : Airfoil = self.data_object
        opPoints_result : list[OpPoint_Result ]= self._opPoints_result_fn() 

        if not opPoints_result or not airfoil: return 

        legend_name = "Point of Transition xtr"

        color  = _color_airfoil ([], airfoil).darker (120) 
        size   = 10
        brush  = QColor ("black")
        fill   = QColor ("black")
        fill.setAlphaF (0.5) 

        for side in [airfoil.geo.lower, airfoil.geo.upper]:     

            for iop, opPoint in enumerate (opPoints_result):

                x = opPoint.xtrt if side.isUpper else opPoint.xtrb
                y = side.yFn (x) 
                y = y + 0.008 if side.isUpper else y - 0.008

                text   = f"{iop+1}"
                anchor = (0.5, 1.2) if side.isUpper else (0.5, -0.2)

                self._plot_point (x,y, color=color, symbol=SYMBOL_TRANSITION_RIGHT, size=size, brush=brush, 
                                  text=text, textColor=color, anchor=anchor, textFill=fill, name=legend_name)

                legend_name = None                                      # only once for legend 


    @override
    def _add_legend_item (self, plot_item, name : str = None):
        """ add legend item having 'name'"""

        # non standard legend size of transition symbol 
        if self._pi.legend is not None and self.show_legend and name:

            if isinstance (plot_item,  pg.TargetItem):

                # create a dummy PlotItem as TargetItem won't appear in legend 
                size_legend = 30    
                pen    = plot_item.pen
                symbol = plot_item._path 
                brush  = plot_item.brush
                p = pg.ScatterPlotItem ([], [], pen= pen, brush=brush, symbol=symbol, size=size_legend, pxMode=True)
                self._add (p, name=name) 

            else: 

                super()._add_legend_item (plot_item, name=name)
     
