#
# Library that attempts to make it easier/less verbose to define Gimp plugins
#   v0.0.0: 2024-11-19: First published version
#   v0.0.1: 2024-11-21: Make undo and exception handling optional
#                       Return error as Gimp/GLib error
#   v0.0.2: 2024-11-28: Add getArgValue() method and getArgValues() function
#   v0.0.3: 2024-11-28: Fix initialisation of Choice values
#   v0.0.4: 2024-11-28: Use less cutting-edge calls in getIcon()
#   v0.0.5: 2024-12-01: Add context push/pop,
#   v0.0.6: 2024-12-01: Add callProcedure
#                       Change GIMP_HELPERS_DEBUG to GIMPHELPERS_TRACE
#   v0.0.7: 2024-12-02  Add GIMPHELPERS_PROCEDURES to narrow down debugging
#                       (automatic addition to <Image>/Test for the time being)
#   v0.0.8: 2204-12-02  Skip showing dialog when no arguments
#                       Add procedure-level tracing

__version__ = (0, 0, 8)  # Make it easier to check with code

#   © Copyright Ofnuts, 2024

#   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., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.

import gi

gi.require_version('Gimp', '3.0')
from gi.repository import Gimp
gi.require_version('GimpUi', '3.0')
from gi.repository import GimpUi
gi.require_version('Gegl', '0.4')
from gi.repository import Gegl
from gi.repository import GObject
from gi.repository import GLib
from gi.repository import Gio

import os
import traceback
from dataclasses import dataclass, field
from typing import Callable, Any

debug = 'GIMPHELPERS_TRACE' in os.environ

def trace(fmt, *args):
    if debug:
        print(fmt.format(*args) if args else fmt)

def isTraceEnabledFor(name: str):
    if 'GIMPHELPERS_PROCEDURES' in os.environ:
        return name in os.environ['GIMPHELPERS_PROCEDURES'].split(':')
    else:
        return None

def enableTraceFor(name: str):
    global debug
    state=isTraceEnabledFor(name)
    if state is not None: # something set at procedure level
        debug=state
        print(f'Debug for {name}: {debug}')

# Makes it simpler to issue a translatable message
# (f-strings don't work for this)
def message(fmt, *args):
    Gimp.message(fmt.format(*args) if args else fmt)

#
# Simple-minded GEGL helper for the time being
#
# Sample function to apply a GEGL operation on one drawable
def applyGeglOnBuffers(bufferIn,bufferOut,opName,**kwargs):
    graph=Gegl.Node()
    source=graph.create_child("gegl:buffer-source")
    source.set_property('buffer',bufferIn)
    sink=graph.create_child("gegl:write-buffer")
    sink.set_property('buffer',bufferOut)
    operation=graph.create_child(opName)
    # Iterate named arguments, converting names_with_underscores to names-with-dashes
    for name,value in kwargs.items():
        operation.set_property(name.replace('_','-'),value)
    source.link(operation)
    operation.link(sink)
    sink.process()
    bufferOut.flush()

# Domain is root name of plugin
def getDomain(file):
    return os.path.splitext(os.path.basename(file))[0]

# Icon assumed to be in same directory with same name as plugin .py:
def getIcon(file):
    return Gio.File.new_for_path(os.path.join(os.path.dirname(file), getDomain(file)+'.png'))

#
# Closure to wrap provided function into one that handles dialog, exception, and undo
#

# Short function to determine if it's worth showing the argument dialog
# Experimentally it seems that without arguments there is a single 'procedure'
# argument in the config.
# An alternative implementation would be to count the args and generate
# an alternative wrapper
def hasArgumentsInConfig(config: Gimp.ProcedureConfig) -> bool:
    return len(GObject.list_properties(config)) > 1

def wrap(implementation: Callable, beforeDialog: Callable, afterDialog: Callable, catchErrors: bool):
    implementationName = str(implementation)
    trace(f'Wrapping {implementationName}')

    def wrapped(procedure: Gimp.ImageProcedure,
                 run_mode: Gimp.RunMode,
                 image: Gimp.Image,
                 drawables: list[Gimp.Drawable],
                 config: Gimp.ProcedureConfig, data: Any):
        enableTraceFor(procedure.get_name())
        trace(f'entering wrapped({procedure=!r}, {run_mode=!r}, {image=!r}, {drawables=!r}, {config=!r}, {data=!r})')
        if beforeDialog:
            beforeDialog(procedure, run_mode, image, drawables, config, data)
        if run_mode == Gimp.RunMode.INTERACTIVE and hasArgumentsInConfig(config):
            GimpUi.init('AutoGeneratedDialog')
            dialog = GimpUi.ProcedureDialog(procedure=procedure, config=config)
            dialog.fill(None)
            if not dialog.run():
                dialog.destroy()
                return procedure.new_return_values(Gimp.PDBStatusType.CANCEL, GLib.Error())
            else:
                dialog.destroy()
        if afterDialog:
            afterDialog(procedure, run_mode, image, drawables, config, data)
        trace(f'Returned from dialog in {procedure.get_name()}')
        if catchErrors:
            Gimp.context_push()
            if image:
                image.undo_group_start()
            try:
                trace(f'executing wrapped({procedure=!r}, {run_mode=!r}, {image=!r}, {drawables=!r}, {config=!r}, {data=!r})')
                result=implementation(procedure, run_mode, image, drawables, config, data)
            except Exception as e:
                traceback.print_exc()
                result=procedure.new_return_values(Gimp.PDBStatusType.EXECUTION_ERROR, GLib.Error(e.args[0]))
            if image:
                image.undo_group_end()
            Gimp.context_pop()
            return result
        else: # Simplified
            return implementation(procedure, run_mode, image, drawables, config, data)

    return wrapped

#
# Plugin argument description classes
#
# Not implemented so far:
# * All 'aux' arguments
# * All 'return' arguments
# * No support for GUI dialog in current Gimp:
#   * bytes
#   * core_object_array
#   * display
#   * double_array
#   * group_layer
#   * int32_array
#   * item
#   * layer_mask
#   * param
#   * parasite
#   * scaledColorPath
#   * resource
#   * selection
#   * string_array
#   * text_layer
#   * uint
# * color_from_string: what is the point since it shows a palette like plain color?
#   Color can already be initialized with Gegl.Color('red')

# Base class, do not use directly!
@dataclass
class __BaseArg__:
    name: str
    label: str
    blurb: str

    # Extract value directly from config object
    # The benefit is that the implementation code doesn't need
    # to know the name of the variable, and we replace a string
    # (that cannot be checked by the IDE) by a variable name.
    # Subclasses can also do their own thing (see Choice)
    def getArgValue(self,config: Gimp.ProcedureConfig):
        configValue,=config.get_properties(self.name)
        return configValue

@dataclass
class Boolean(__BaseArg__):
    value: bool
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Brush(__BaseArg__):
    value: str
    noneOK: bool = False
    defaultToContext: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Channel(__BaseArg__):
    noneOK: bool = True
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

# Slightly more complicated class to handle Choice because we want to be able
# to store some non-UI data as well
@dataclass
class Option:
    name: str
    label: str
    blurb: str = ''
    value: Any = None
    sensitive: bool = True

class Choice(__BaseArg__):
    def __init__(self, name: str, label: str, blurb: str, value: str,
             options: list[Option],
             flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE):
        self.name=name
        self.label=label
        self.value=value
        self.options=options
        self.blurb=blurb
        self.flags=flags
        self.values=dict()

        #  careful because value can be something "falsy" like "False" or empty string
        # This forbids "None" from being an explicit value, but well???
        for option in self.options:
            if option.value is not None: #
                trace(f'{self.name}: Adding "{option.name}": {option.value}')
                self.values[option.name]=option.value

    def __getitem__(self,name):
        return self.values[name]

    # Extract value directly from config object
    # The benefit is that the implementation code doesn't
    # need to know the name of the choice variable nor the names of the choices
    def getArgValue(self,config: Gimp.ProcedureConfig):
        option,=config.get_properties(self.name)
        trace(f'Value for choice {self.name}: {option=}, {self.values=}')
        return self.values[option] if option in self.values else option

@dataclass
class Color(__BaseArg__):
    value: Gegl.Color
    hasAlpha: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Color(__BaseArg__):
    value: Gegl.Color
    hasAlpha: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Double(__BaseArg__):
    valueMin: float
    valueMax: float
    valueDefault: float
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Drawable(__BaseArg__):
    noneOK: bool = True
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class File(__BaseArg__):
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Font(__BaseArg__):
    value: Gimp.Font
    noneOK: bool = False
    defaultToContext: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Gradient(__BaseArg__):
    value: Gimp.Gradient
    noneOK: bool = False
    defaultToContext: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

# @dataclass
# class GroupLayer(__BaseArg__):
#     value: bool
#     noneOK: bool = True
#     flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Image(__BaseArg__):
    noneOK: bool = True
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Int(__BaseArg__):
    valueMin: int
    valueMax: int
    valueDefault: int
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Layer(__BaseArg__):
    noneOK: bool = True
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

# @dataclass
# class LayerMask(__BaseArg__):
#     value: bool
#     noneOK: bool = True
#     flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Palette(__BaseArg__):
    value: Gimp.Palette
    noneOK: bool = False
    defaultToContext: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class Pattern(__BaseArg__):
    value: Gimp.Pattern
    noneOK: bool = False
    defaultToContext: bool = False
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

@dataclass
class String(__BaseArg__):
    value: str = ''
    flags: GObject.ParamFlags = GObject.ParamFlags.READWRITE

#
# Mapping of argument declaration type to add_{}_argument calls
# Could possibly just use add_{}_argument(dataclasses.astuple(argDesc))
def traceResourceArg(name: str, value: Gimp.Resource):
    if value:
        trace(f'++++ Adding {type(value)}: {value.get_name()}')
    else:
        trace(f'+*** {name} not defined!!!!!!!!!!')

def addBooleanArg(procedure: Gimp.Procedure,argDesc: Boolean):
    procedure.add_boolean_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.value,
                                    argDesc.flags)

def addBrushArg(procedure: Gimp.Procedure,argDesc: Brush):
    brush=Gimp.Brush.get_by_name(argDesc.value) if argDesc.value else None
    traceResourceArg(argDesc.label,brush)
    trace(f'Flags: {argDesc.noneOK=}, {argDesc.defaultToContext=}')
    procedure.add_brush_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    brush,
                                    argDesc.defaultToContext,
                                    argDesc.flags)

def addChannelArg(procedure: Gimp.Procedure,argDesc: Channel):
    procedure.add_channel_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    argDesc.flags)

def addChoiceArg(procedure: Gimp.Procedure, argDesc: Choice):
    choice = Gimp.Choice.new()
    for index,option in enumerate(argDesc.options):
        choice.add(option.name, index, option.label, option.blurb)
        choice.set_sensitive(option.name,option.sensitive)
    procedure.add_choice_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    choice,
                                    argDesc.value,
                                    argDesc.flags)

def addColorArg(procedure: Gimp.Procedure, argDesc: Color):
    procedure.add_color_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.hasAlpha, argDesc.value,
                                    argDesc.flags)

def addDoubleArg(procedure: Gimp.Procedure, argDesc: Double):
    procedure.add_double_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.valueMin, argDesc.valueMax, argDesc.valueDefault,
                                    argDesc.flags)

def addDrawableArg(procedure: Gimp.Procedure,argDesc: Drawable):
    procedure.add_drawable_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    argDesc.flags)

def addFileArg(procedure: Gimp.Procedure,argDesc: File):
    procedure.add_file_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.flags)

def addFontArg(procedure: Gimp.Procedure,argDesc: Font):
    font=Gimp.Font.get_by_name(argDesc.value) if argDesc.value else None
    traceResourceArg('Font',font)
    procedure.add_font_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    font,
                                    argDesc.defaultToContext,
                                    argDesc.flags)

def addGradientArg(procedure: Gimp.Procedure,argDesc: Gradient):
    gradient=Gimp.Gradient.get_by_name(argDesc.value) if argDesc.value else None
    traceResourceArg('Gradient',gradient)
    procedure.add_gradient_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    gradient,
                                    argDesc.defaultToContext,
                                    argDesc.flags)

# def addGroupLayerArg(procedure: Gimp.Procedure,argDesc: GroupLayer):
#     procedure.add_group_layer_argument(argDesc.name, argDesc.label, argDesc.blurb,
#                                     argDesc.noneOK,
#                                     argDesc.flags)

def addImageArg(procedure: Gimp.Procedure,argDesc: Image):
    procedure.add_image_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    argDesc.flags)

def addIntArg(procedure: Gimp.Procedure,argDesc: Int):
    procedure.add_int_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.valueMin, argDesc.valueMax, argDesc.valueDefault,
                                    argDesc.flags)

def addLayerArg(procedure: Gimp.Procedure,argDesc: Layer):
    procedure.add_layer_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    argDesc.flags)

# def addLayerMaskArg(procedure: Gimp.Procedure,argDesc: LayerMask):
#     procedure.add_layer_mask_argument(argDesc.name, argDesc.label, argDesc.blurb,
#                                     argDesc.noneOK,
#                                     argDesc.flags)

def addPaletteArg(procedure: Gimp.Procedure,argDesc: Palette):
    palette = Gimp.Palette.get_by_name(argDesc.value) if argDesc.value else None
    traceResourceArg('Palette',palette)
    procedure.add_palette_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    palette,
                                    argDesc.defaultToContext,
                                    argDesc.flags)

def addPatternArg(procedure: Gimp.Procedure,argDesc: Pattern):
    pattern = Gimp.Pattern.get_by_name(argDesc.value) if argDesc.value else None
    traceResourceArg('Pattern',pattern)
    procedure.add_pattern_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.noneOK,
                                    pattern,
                                    argDesc.defaultToContext,
                                    argDesc.flags)

def addStringArg(procedure: Gimp.Procedure, argDesc: String):
    procedure.add_string_argument(argDesc.name, argDesc.label, argDesc.blurb,
                                    argDesc.value,
                                    argDesc.flags)

# Dictionary of type -> function because this author hates long
# if/elif (or even "match" in Gimp3)
addArgTypes={
                Boolean:addBooleanArg,
                Brush: addBrushArg,
                Channel: addChannelArg,
                Choice: addChoiceArg,
                Color: addColorArg,
                Double: addDoubleArg,
                Drawable: addDrawableArg,
                File: addFileArg,
                Font: addFontArg,
                Gradient: addGradientArg,
#                GroupLayer: addGroupLayerArg,
                Image: addImageArg,
                Int:addIntArg,
                Layer: addLayerArg,
#                LayerMask: addLayerMaskArg,
                Palette: addPaletteArg,
                Pattern: addPatternArg,
                String:addStringArg,
             }

#
# Plugin procedure description
#
@dataclass
class ProcedureDescription:
    id: str
    imageTypes: str
    sensitivity: Gimp.ProcedureSensitivityMask
    implementation: Callable[[Gimp.ImageProcedure,Gimp.RunMode,Gimp.Image,list[Gimp.Drawable],Gimp.ProcedureConfig,Any],Any]
    menuLabel: str = None
    menuPath: str | list [str] = None
    descShort: str = None
    descLong: str = None
    args: list[__BaseArg__] = field(default_factory=lambda: [])
    returns: list[__BaseArg__] = field(default_factory=lambda: [])
    run_data: Any = None
    beforeDialog: Callable[[Gimp.ImageProcedure,Gimp.RunMode,Gimp.Image,list[Gimp.Drawable],Gimp.ProcedureConfig,Any],None] = None
    afterDialog: Callable[[Gimp.ImageProcedure,Gimp.RunMode,Gimp.Image,list[Gimp.Drawable],Gimp.ProcedureConfig,Any],None] = None
    catchErrors: bool=True

class HelpedPlugin(Gimp.PlugIn):

    # These class data are replaced by the data set in the derived plugin
    procedures : list[ProcedureDescription]=[]
    icon: Gio.File =None
    author: str = ''
    copyright: str = ''
    year: str = ''
    domain: str = None

# Methods defined by the Gimp interface
    def do_set_i18n(self, procname):
        trace(f'** do_set_i18n({procname=!r}) **')
        return (True, self.domain, 'locale') if self.domain else (False, None, None)

    def do_query_procedures(self):
        trace(f'** do_query_procedures() **')
        return [procDesc.id for procDesc in self.procedures]  # convert to list

    def do_create_procedure(self, procName):
        trace(f'Creating procedure {procName}')
        matches = [procDesc for procDesc in self.procedures if procDesc.id==procName]
        if not matches or len(matches) > 1:
            return None
        procDesc: ProcedureDescription = matches[0]
        procedure: Gimp.Procedure = Gimp.ImageProcedure.new(self, procName,
                                            Gimp.PDBProcType.PLUGIN,
                                            wrap(procDesc.implementation,
                                                 procDesc.beforeDialog,
                                                 procDesc.afterDialog,
                                                 procDesc.catchErrors),
                                            procDesc.run_data)
        procedure.set_image_types(procDesc.imageTypes)
        procedure.set_sensitivity_mask(procDesc.sensitivity)
        if procDesc.menuLabel:
            procedure.set_menu_label(procDesc.menuLabel)
            if isinstance(procDesc.menuPath,list):
                for p in procDesc.menuPath:
                    procedure.add_menu_path(p)
            else:
                procedure.add_menu_path(procDesc.menuPath)
            # To <Image>/Test for quick access when debugging
            if isTraceEnabledFor(procName):
                procedure.add_menu_path('<Image>/Test')
        if procDesc.descShort:
            procedure.set_documentation(procDesc.descShort, procDesc.descLong,procName)
        if self.icon:
            procedure.set_icon_file(self.icon)
        if self.author:
            procedure.set_attribution(self.author,self.copyright,self.year)

        for arg in procDesc.args:
            #trace(f'Adding {arg!r}: {type(arg)!s}')
            addArgTypes[type(arg)](procedure,arg)

        trace(f'End of creation for procedure {procName}')
        return procedure

# Get arguments from config object
def getConfigArgs(config: Gimp.ProcedureConfig, *argNames: str):
    return [config.get_properties(arg)[0] for arg in argNames ]

# Get arguments from config object
def getArgsValues(config: Gimp.ProcedureConfig, *args: __BaseArg__):
    return [arg.getArgValue(config) for arg in args ]

def callProcedure(name: str,run_mode=Gimp.RunMode.NONINTERACTIVE,**kwargs):
    procedure=Gimp.get_pdb().lookup_procedure(name)
    if procedure is None:
        raise Exception('Procedure "{name}" no found')
    config: Gimp.ProcedureConfig = procedure.create_config()
    config.set_property('run-mode',run_mode)
    for name,value in kwargs.items():
        if isinstance(value,list):
            config.set_core_object_array(name,value)
        else:
            config.set_property(name.replace('_','-'),value)
    result=procedure.run(config)
    return result




