# SPDX-License-Identifier: GPL-2.0-or-later

"""
Even though this is in a package this can run as a stand alone scripts.

# --- example usage
blender --python release/scripts/addons/system_demo_mode/demo_mode.py

looks for demo.py textblock or file in the same path as the blend:
# --- example
config = [
    dict(anim_cycles=1, anim_render=False, anim_screen_switch=0.0, anim_time_max=10.0, anim_time_min=4.0, mode='AUTO', display_render=4.0, file='/l/19534_simplest_mesh_2.blend'),
    dict(anim_cycles=1, anim_render=False, anim_screen_switch=0.0, anim_time_max=10.0, anim_time_min=4.0, mode='AUTO', display_render=4.0, file='/l/252_pivotConstraint_01.blend'),
    ]
# ---
/data/src/blender/lib/tests/rendering/
"""

import bpy
import time
import os

DEMO_CFG = "demo.py"

# populate from script
global_config_files = []

global_config = dict(
    anim_cycles=1,
    anim_render=False,
    anim_screen_switch=0.0,
    anim_time_max=60.0,
    anim_time_min=4.0,
    mode='AUTO',
    display_render=4.0,
)

# switch to the next file in 2 sec.
global_config_fallback = dict(
    anim_cycles=1,
    anim_render=False,
    anim_screen_switch=0.0,
    anim_time_max=60.0,
    anim_time_min=4.0,
    mode='AUTO',
    display_render=4.0,
)

global_state = {
    "init_time": 0.0,
    "last_switch": 0.0,
    "reset_anim": False,
    "anim_cycles": 0,  # count how many times we played the anim
    "last_frame": -1,
    "is_render": False,
    "render_time": "",  # time render was finished.
    "timer": None,
    "basedir": "",  # demo.py is stored here
    "demo_index": 0,
    "exit": False,
}


# -----------------------------------------------------------------------------
# render handler - maintain "is_render"

def handle_render_clear():
    for ls in (bpy.app.handlers.render_complete, bpy.app.handlers.render_cancel):
        while handle_render_done_cb in ls:
            ls.remove(handle_render_done_cb)


def handle_render_done_cb(self):
    global_state["is_render"] = True


def handle_render_init():
    handle_render_clear()
    bpy.app.handlers.render_complete.append(handle_render_done_cb)
    bpy.app.handlers.render_cancel.append(handle_render_done_cb)
    global_state["is_render"] = False


def demo_mode_auto_select():

    play_area = 0
    render_area = 0

    totimg = 0

    for area in bpy.context.window.screen.areas:
        size = area.width * area.height
        if area.type in {'VIEW_3D', 'GRAPH_EDITOR', 'DOPESHEET_EDITOR', 'NLA_EDITOR', 'TIMELINE'}:
            play_area += size
        elif area.type in {'IMAGE_EDITOR', 'SEQUENCE_EDITOR', 'NODE_EDITOR'}:
            render_area += size

        if area.type == 'IMAGE_EDITOR':
            totimg += 1

    # since our test files have this as defacto standard
    scene = bpy.context.scene
    if totimg >= 2 and (scene.camera or scene.render.use_sequencer):
        mode = 'RENDER'
    else:
        if play_area >= render_area:
            mode = 'PLAY'
        else:
            mode = 'RENDER'

    if 0:
        return 'PLAY'

    return mode


def demo_mode_next_file(step=1):

    # support for temp
    if global_config_files[global_state["demo_index"]].get("is_tmp"):
        del global_config_files[global_state["demo_index"]]
        global_state["demo_index"] -= 1

    print(global_state["demo_index"])
    demo_index_next = (global_state["demo_index"] + step) % len(global_config_files)

    if global_state["exit"] and step > 0:
        # check if we cycled
        if demo_index_next < global_state["demo_index"]:
            import sys
            sys.exit(0)

    global_state["demo_index"] = demo_index_next
    print(global_state["demo_index"], "....")
    print("func:demo_mode_next_file", global_state["demo_index"])
    filepath = global_config_files[global_state["demo_index"]]["file"]
    bpy.ops.wm.open_mainfile(filepath=filepath)


def demo_mode_timer_add():
    global_state["timer"] = bpy.context.window_manager.event_timer_add(0.8, window=bpy.context.window)


def demo_mode_timer_remove():
    if global_state["timer"]:
        bpy.context.window_manager.event_timer_remove(global_state["timer"])
        global_state["timer"] = None


def demo_mode_load_file():
    """ Take care, this can only do limited functions since its running
        before the file is fully loaded.
        Some operators will crash like playing an animation.
    """
    print("func:demo_mode_load_file")
    DemoMode.first_run = True
    bpy.ops.wm.demo_mode('EXEC_DEFAULT')


def demo_mode_temp_file():
    """ Initialize a temp config for the duration of the play time.
        Use this so we can initialize the demo intro screen but not show again.
    """
    assert(global_state["demo_index"] == 0)

    temp_config = global_config_fallback.copy()
    temp_config["anim_time_min"] = 0.0
    temp_config["anim_time_max"] = 60.0
    temp_config["anim_cycles"] = 0  # ensures we switch when hitting the end
    temp_config["mode"] = 'PLAY'
    temp_config["is_tmp"] = True

    global_config_files.insert(0, temp_config)


def demo_mode_init():
    print("func:demo_mode_init")
    DemoKeepAlive.ensure()

    if 1:
        global_config.clear()
        global_config.update(global_config_files[global_state["demo_index"]])

    print(global_config)

    demo_mode_timer_add()

    if global_config["mode"] == 'AUTO':
        global_config["mode"] = demo_mode_auto_select()

    if global_config["mode"] == 'PLAY':
        global_state["last_frame"] = -1
        global_state["anim_cycles"] = 0
        bpy.ops.screen.animation_play()

    elif global_config["mode"] == 'RENDER':
        print("  render")

        # setup scene.
        scene = bpy.context.scene
        scene.render.filepath = "TEMP_RENDER"
        scene.render.image_settings.file_format = 'AVI_JPEG' if global_config["anim_render"] else 'PNG'
        scene.render.use_file_extension = False
        scene.render.use_placeholder = False
        try:
            # XXX - without this rendering will crash because of a bug in blender!
            bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1)
            if global_config["anim_render"]:
                bpy.ops.render.render('INVOKE_DEFAULT', animation=True)
            else:
                bpy.ops.render.render('INVOKE_DEFAULT')  # write_still=True, no need to write now.

                handle_render_init()

        except RuntimeError:  # no camera for eg:
            import traceback
            traceback.print_exc()

    else:
        raise Exception("Unsupported mode %r" % global_config["mode"])

    global_state["init_time"] = global_state["last_switch"] = time.time()
    global_state["render_time"] = -1.0


def demo_mode_update():
    time_current = time.time()
    time_delta = time_current - global_state["last_switch"]
    time_total = time_current - global_state["init_time"]

    # --------------------------------------------------------------------------
    # ANIMATE MODE
    if global_config["mode"] == 'PLAY':
        frame = bpy.context.scene.frame_current
        # check for exit
        if time_total > global_config["anim_time_max"]:
            demo_mode_next_file()
            return
        # above cycles and minimum display time
        if (
                (time_total > global_config["anim_time_min"]) and
                (global_state["anim_cycles"] > global_config["anim_cycles"])
        ):
            # looped enough now.
            demo_mode_next_file()
            return

        # run update funcs
        if global_state["reset_anim"]:
            global_state["reset_anim"] = False
            bpy.ops.screen.animation_cancel(restore_frame=False)
            bpy.ops.screen.animation_play()

        # warning, switching the screen can switch the scene
        # and mess with our last-frame/cycles counting.
        if global_config["anim_screen_switch"]:
            # print(time_delta, 1)
            if time_delta > global_config["anim_screen_switch"]:
                window = bpy.context.window
                scene = window.scene
                workspace = window.workspace
                index = bpy.data.workspaces.keys().index(workspace.name)
                workspace_new = bpy.data.workspaces[(index + 1) % len(bpy.data.workspaces)]
                window.workspace = workspace_new

                global_state["last_switch"] = time_current

                # If we also switch scenes then reset last frame
                # otherwise it could mess up cycle calculation.
                if scene != window.scene:
                    global_state["last_frame"] = -1

                #if global_config["mode"] == 'PLAY':
                if 1:
                    global_state["reset_anim"] = True

        # did we loop?
        if global_state["last_frame"] > frame:
            print("Cycle!")
            global_state["anim_cycles"] += 1

        global_state["last_frame"] = frame

    # --------------------------------------------------------------------------
    # RENDER MODE
    elif global_config["mode"] == 'RENDER':
        if global_state["is_render"]:
            # wait until the time has passed
            # XXX, todo, if rendering an anim we need some way to check its done.
            if global_state["render_time"] == -1.0:
                global_state["render_time"] = time.time()
            else:
                if time.time() - global_state["render_time"] > global_config["display_render"]:
                    handle_render_clear()
                    demo_mode_next_file()
                    return
    else:
        raise Exception("Unsupported mode %r" % global_config["mode"])

# -----------------------------------------------------------------------------
# modal operator


class DemoKeepAlive:
    secret_attr = "_keepalive"

    @staticmethod
    def ensure():
        if DemoKeepAlive.secret_attr not in bpy.app.driver_namespace:
            bpy.app.driver_namespace[DemoKeepAlive.secret_attr] = DemoKeepAlive()

    @staticmethod
    def remove():
        if DemoKeepAlive.secret_attr in bpy.app.driver_namespace:
            del bpy.app.driver_namespace[DemoKeepAlive.secret_attr]

    def __del__(self):
        """ Hack, when the file is loaded the drivers namespace is cleared.
        """
        if DemoMode.enabled:
            demo_mode_load_file()


class DemoMode(bpy.types.Operator):
    bl_idname = "wm.demo_mode"
    bl_label = "Demo"

    enabled = False
    first_run = True

    def cleanup(self, disable=False):
        demo_mode_timer_remove()
        DemoMode.first_run = True

        if disable:
            DemoMode.enabled = False
            DemoKeepAlive.remove()

    def modal(self, context, event):
        # print("DemoMode.modal", global_state["anim_cycles"])
        if not DemoMode.enabled:
            self.cleanup(disable=True)
            return {'CANCELLED'}

        if event.type == 'ESC':
            self.cleanup(disable=True)
            # disable here and not in cleanup because this is a user level disable.
            # which should stay disabled until explicitly enabled again.
            return {'CANCELLED'}

        # print(event.type)
        if DemoMode.first_run:
            DemoMode.first_run = False

            demo_mode_init()
        else:
            demo_mode_update()

        return {'PASS_THROUGH'}

    def execute(self, context):
        print("func:DemoMode.execute:", len(global_config_files), "files")

        use_temp = False

        # load config if not loaded
        if not global_config_files:
            load_config()
            use_temp = True

        if not global_config_files:
            self.report({'INFO'}, "No configuration found with text or file: %s. Run File -> Demo Mode Setup" % DEMO_CFG)
            return {'CANCELLED'}

        if use_temp:
            demo_mode_temp_file()  # play this once through then never again

        # toggle
        if DemoMode.enabled and DemoMode.first_run is False:
            # this actually cancells the previous running instance
            # should never happen now, DemoModeControl is for this.
            return {'CANCELLED'}
        else:
            DemoMode.enabled = True

            context.window_manager.modal_handler_add(self)
            return {'RUNNING_MODAL'}

    def cancel(self, context):
        print("func:DemoMode.cancel")
        # disable here means no running on file-load.
        self.cleanup()

    # call from DemoModeControl
    @classmethod
    def disable(cls):
        if cls.enabled and cls.first_run is False:
            # this actually cancells the previous running instance
            # should never happen now, DemoModeControl is for this.
            cls.enabled = False


class DemoModeControl(bpy.types.Operator):
    bl_idname = "wm.demo_mode_control"
    bl_label = "Control"

    mode: bpy.props.EnumProperty(
        items=(('PREV', "Prev", ""),
               ('PAUSE', "Pause", ""),
               ('NEXT', "Next", "")),
        name="Mode"
    )

    def execute(self, context):
        mode = self.mode
        if mode == 'PREV':
            demo_mode_next_file(-1)
        elif mode == 'NEXT':
            demo_mode_next_file(1)
        else:  # pause
            DemoMode.disable()
        return {'FINISHED'}


def menu_func(self, context):
    # print("func:menu_func - DemoMode.enabled:", DemoMode.enabled, "bpy.app.driver_namespace:", DemoKeepAlive.secret_attr not in bpy.app.driver_namespace, 'global_state["timer"]:', global_state["timer"])
    layout = self.layout
    layout.operator_context = 'EXEC_DEFAULT'
    row = layout.row(align=True)
    row.label(text="Demo Mode:")
    if not DemoMode.enabled:
        row.operator("wm.demo_mode", icon='PLAY', text="")
    else:
        row.operator("wm.demo_mode_control", icon='REW', text="").mode = 'PREV'
        row.operator("wm.demo_mode_control", icon='PAUSE', text="").mode = 'PAUSE'
        row.operator("wm.demo_mode_control", icon='FF', text="").mode = 'NEXT'


def register():
    bpy.utils.register_class(DemoMode)
    bpy.utils.register_class(DemoModeControl)
    bpy.types.INFO_HT_header.append(menu_func)


def unregister():
    bpy.utils.unregister_class(DemoMode)
    bpy.utils.unregister_class(DemoModeControl)
    bpy.types.INFO_HT_header.remove(menu_func)


# -----------------------------------------------------------------------------
# parse args

def load_config(cfg_name=DEMO_CFG):
    namespace = {}
    del global_config_files[:]
    basedir = os.path.dirname(bpy.data.filepath)

    text = bpy.data.texts.get(cfg_name)
    if text is None:
        demo_path = os.path.join(basedir, cfg_name)
        if os.path.exists(demo_path):
            print("Using config file: %r" % demo_path)
            demo_file = open(demo_path, "r")
            demo_data = demo_file.read()
            demo_file.close()
        else:
            demo_data = ""
    else:
        print("Using config textblock: %r" % cfg_name)
        demo_data = text.as_string()
        demo_path = os.path.join(bpy.data.filepath, cfg_name)  # fake

    if not demo_data:
        print("Could not find %r textblock or %r file." % (DEMO_CFG, demo_path))
        return False

    namespace["__file__"] = demo_path

    exec(demo_data, namespace, namespace)

    demo_config = namespace["config"]
    demo_search_path = namespace.get("search_path")
    global_state["exit"] = namespace.get("exit", False)

    if demo_search_path is None:
        print("reading: %r, no search_path found, missing files wont be searched." % demo_path)
    if demo_search_path.startswith("//"):
        demo_search_path = bpy.path.abspath(demo_search_path)
    if not os.path.exists(demo_search_path):
        print("reading: %r, search_path %r does not exist." % (demo_path, demo_search_path))
        demo_search_path = None

    blend_lookup = {}
    # initialize once, case insensitive dict

    def lookup_file(filepath):
        filename = os.path.basename(filepath).lower()

        if not blend_lookup:
            # ensure only ever run once.
            blend_lookup[None] = None

            def blend_dict_items(path):
                for dirpath, dirnames, filenames in os.walk(path):
                    # skip '.git'
                    dirnames[:] = [d for d in dirnames if not d.startswith(".")]
                    for filename in filenames:
                        if filename.lower().endswith(".blend"):
                            filepath = os.path.join(dirpath, filename)
                            yield (filename.lower(), filepath)

            blend_lookup.update(dict(blend_dict_items(demo_search_path)))

        # fallback to original file
        return blend_lookup.get(filename, filepath)
    # done with search lookup

    for filecfg in demo_config:
        filepath_test = filecfg["file"]
        if not os.path.exists(filepath_test):
            filepath_test = os.path.join(basedir, filecfg["file"])
        if not os.path.exists(filepath_test):
            filepath_test = lookup_file(filepath_test)  # attempt to get from searchpath
        if not os.path.exists(filepath_test):
            print("Can't find %r or %r, skipping!")
            continue

        filecfg["file"] = os.path.normpath(filepath_test)

        # sanitize
        filecfg["file"] = os.path.abspath(filecfg["file"])
        filecfg["file"] = os.path.normpath(filecfg["file"])
        print("  Adding: %r" % filecfg["file"])
        global_config_files.append(filecfg)

    print("found %d files" % len(global_config_files))

    global_state["basedir"] = basedir

    return bool(global_config_files)


# support direct execution
if __name__ == "__main__":
    register()

    demo_mode_load_file()  # kick starts the modal operator
