#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Simulated Infrared (B&W film look, white foliage, cool blue sky) for GIMP 3.x

Creates ONE new layer called "Infrared" above the stack; the original
layer is untouched. Pure GEGL / GIMP API: no numpy, no OpenCV, no PIL.

Pipeline
  1. Duplicate the layer.
  2. gegl:mono-mixer     -> channel-mixer style B&W: green strongly +,
                            blue negative (white foliage, dark sky)
  3. gegl:exposure       -> push foliage towards bright white
  4. gegl:unsharp-mask   -> large-radius local contrast (keeps leaf texture)
  5. Sky                -> black layer + blue Screen layer, both masked by
                            a "blueness" mask (B - R) from the ORIGINAL photo.
                            Independent of the foliage slider.

"""

import sys
import gi

gi.require_version("Gimp", "3.0")
gi.require_version("GimpUi", "3.0")
gi.require_version("Gegl", "0.4")
from gi.repository import Gimp, GimpUi, Gegl, GObject, GLib  # noqa: E402

PROC_NAME = "python-fu-digitalinfrared"
SKY_BLUE = "#7fb0ff"
PREVIEW_DELAY_MS = 300


def apply_gegl(drawable, op_name, **props):
    """Apply a GEGL operation to a drawable and commit it immediately."""
    flt = Gimp.DrawableFilter.new(drawable, op_name, op_name)
    cfg = flt.get_config()
    for key, value in props.items():
        cfg.set_property(key.replace("_", "-"), value)
    drawable.append_filter(flt)
    drawable.merge_filters()


def new_top_layer(image, source, name):
    layer = Gimp.Layer.new_from_drawable(source, image)
    layer.set_name(name)
    image.insert_layer(layer, None, 0)
    return layer


def sky_mask_layer(image, source, name, gain, blur):
    """Layer copy of the original with a layer mask = blueness (B - R)."""
    layer = new_top_layer(image, source, name)
    # blueness = gain*B - gain*R -> 0 for greys/foliage, up to 1 for sky
    apply_gegl(layer, "gegl:mono-mixer", red=-gain, green=0.0, blue=gain)
    apply_gegl(layer, "gegl:gaussian-blur", std_dev_x=blur, std_dev_y=blur)
    mask = layer.create_mask(Gimp.AddMaskType.COPY)
    layer.add_mask(mask)
    return layer


def fill_layer(layer, color_hex):
    Gimp.context_push()
    try:
        Gimp.context_set_foreground(Gegl.Color.new(color_hex))
        layer.edit_fill(Gimp.FillType.FOREGROUND)
    finally:
        Gimp.context_pop()


def build_infrared(image, source, foliage, sky, detail):
    """Build the Infrared layer and return it in a list."""
    w, h = image.get_width(), image.get_height()
    big = float(max(w, h))

    # ---- monochrome base ------------------------------------------------
    base = new_top_layer(image, source, "Infrared")

    # Neutral greys keep roughly the same brightness (weights sum ~1.1),
    # greens get boosted, blues (sky/water) get pushed down.
    r = 0.10
    g = 0.90 + 1.30 * foliage
    b = -(1.20 * foliage)
    apply_gegl(base, "gegl:mono-mixer", red=r, green=g, blue=b)

    apply_gegl(base, "gegl:exposure", black_level=0.0,
               exposure=0.30 + 0.90 * foliage)

    if detail > 0.0:
        apply_gegl(base, "gegl:unsharp-mask",
                   std_dev=max(2.0, big * 0.004), scale=0.8 * detail)

    # ---- blue sky ---------------------------------------------------------
    # The sky is rebuilt independently of the foliage slider:
    #   (a) a black layer through a "blueness" mask makes the sky black,
    #       whatever the mixer did to it (also removes JPEG/blue-channel noise)
    #   (b) a blue Screen layer through a softer version of the same mask
    #       puts back a smooth, cool blue sky.
    if sky > 0.0:
        blur = max(1.0, big * 0.001)

        dark = sky_mask_layer(image, source, "IR sky dark", 2.0, blur)
        fill_layer(dark, "#000000")
        base = image.merge_down(dark, Gimp.MergeType.EXPAND_AS_NECESSARY)

        tint = sky_mask_layer(image, source, "IR sky tint", 1.0, blur)
        fill_layer(tint, SKY_BLUE)
        tint.set_mode(Gimp.LayerMode.SCREEN)
        tint.set_opacity(min(1.0, 0.25 + 1.1 * sky) * 100.0)
        base = image.merge_down(tint, Gimp.MergeType.EXPAND_AS_NECESSARY)

    base.set_name("Infrared")
    return [base]


def get_values(config):
    return (config.get_property("foliage"),
            config.get_property("sky"),
            config.get_property("detail"))


class InfraredSim(Gimp.PlugIn):
    def do_query_procedures(self):
        return [PROC_NAME]

    def do_set_i18n(self, name):
        return False

    def do_create_procedure(self, name):
        proc = Gimp.ImageProcedure.new(
            self, name, Gimp.PDBProcType.PLUGIN, self.run, None)
        proc.set_image_types("RGB*")
        proc.set_sensitivity_mask(Gimp.ProcedureSensitivityMask.DRAWABLE)
        proc.set_menu_label("Simulate Infrared Photo")
        proc.add_menu_path("<Image>/Bulevardi")
        proc.set_documentation("Simulated infrared photo on a new layer", name)
        proc.set_attribution("Bulevardi", "Bulevardi", "2026")

        flags = GObject.ParamFlags.READWRITE
        proc.add_double_argument(
            "foliage", "_Foliage whiteness",
            "0 = natural, 1 = brilliant white", 0.0, 1.0, 0.7, flags)
        proc.add_double_argument(
            "sky", "_Blue sky strength",
            "0 = pure monochrome sky, 1 = strong blue", 0.0, 1.0, 0.45, flags)
        proc.add_double_argument(
            "detail", "_Detail / leaf texture",
            "Local contrast to protect leaves and branches",
            0.0, 1.0, 0.5, flags)
        proc.add_boolean_argument(
            "preview", "_Preview",
            "Show the effect live on the canvas", True, flags)
        return proc

    def run(self, procedure, run_mode, image, drawables, config, run_data):
        if not drawables:
            return procedure.new_return_values(
                Gimp.PDBStatusType.CALLING_ERROR,
                GLib.Error("No layer selected"))
        source = drawables[0]

        # Filters honour the selection; work on the whole layer instead and
        # restore the user's selection at the end.
        saved_sel = None
        if not Gimp.Selection.is_empty(image):
            saved_sel = Gimp.Selection.save(image)
        Gimp.Selection.all(image)

        def restore_selection():
            if saved_sel is not None:
                image.select_item(Gimp.ChannelOps.REPLACE, saved_sel)
                image.remove_channel(saved_sel)
            else:
                Gimp.Selection.none(image)

        # ---------------- interactive dialog with live preview ------------
        if run_mode == Gimp.RunMode.INTERACTIVE:
            GimpUi.init("digitalinfrared")
            dialog = GimpUi.ProcedureDialog(procedure=procedure, config=config)
            # Horizontal sliders (with a small number entry) instead of
            # the default -/+ spin buttons.
            for prop in ("foliage", "sky", "detail"):
                try:
                    dialog.get_scale_entry(prop, 1.0)
                except Exception:  # noqa: BLE001
                    try:
                        dialog.get_spin_scale(prop, 1.0)  # older fallback
                    except Exception:  # noqa: BLE001
                        pass
            dialog.fill(None)

            state = {"layers": [], "timer": 0}

            def clear_preview():
                layers = state["layers"]
                state["layers"] = []
                if layers:
                    image.undo_disable()
                    try:
                        for layer in layers:
                            if layer.is_valid():
                                image.remove_layer(layer)
                    finally:
                        image.undo_enable()

            def rebuild():
                state["timer"] = 0
                clear_preview()
                if config.get_property("preview"):
                    image.undo_disable()
                    try:
                        state["layers"] = build_infrared(
                            image, source, *get_values(config))
                    except Exception as exc:  # noqa: BLE001
                        Gimp.message("Infrared preview failed: %s" % exc)
                    finally:
                        image.undo_enable()
                Gimp.displays_flush()
                return False  # one-shot timer

            def on_change(*_args):
                if state["timer"]:
                    GLib.source_remove(state["timer"])
                state["timer"] = GLib.timeout_add(PREVIEW_DELAY_MS, rebuild)

            config.connect("notify", on_change)
            state["timer"] = GLib.timeout_add(100, rebuild)  # first preview

            accepted = dialog.run()
            if state["timer"]:
                GLib.source_remove(state["timer"])
                state["timer"] = 0
            clear_preview()
            dialog.destroy()

            if not accepted:
                restore_selection()
                Gimp.displays_flush()
                return procedure.new_return_values(
                    Gimp.PDBStatusType.CANCEL, GLib.Error())

        # ---------------- final (undoable) build --------------------------
        image.undo_group_start()
        try:
            build_infrared(image, source, *get_values(config))
            restore_selection()
        except Exception as exc:  # noqa: BLE001
            return procedure.new_return_values(
                Gimp.PDBStatusType.EXECUTION_ERROR, GLib.Error(str(exc)))
        finally:
            image.undo_group_end()

        Gimp.displays_flush()
        return procedure.new_return_values(
            Gimp.PDBStatusType.SUCCESS, GLib.Error())


Gimp.main(InfraredSim.__gtype__, sys.argv)
