# SPDX-FileCopyrightText: Copyright 2013 Daniel Dunn
# SPDX-License-Identifier: GPL-3.0-or-later

# NOTICE: A LOT OF LOCKS ARE USED IN THIS FILE. WHEN TWO LOCKS ARE USED,
# ALWAYS GET _event_list_lock LAST
# IF WE ALWAYS USE THE SAME ORDER THE CHANCE OF DEADLOCKS IS REDUCED.

from __future__ import annotations

import ast
import base64
import copy
import datetime
import gc
import os
import random
import re
import textwrap
import threading
import time
import traceback
import types
import typing
import weakref
from collections import defaultdict
from collections.abc import Callable

import beartype
import pytz
import structlog
import yaml
from scullery import scheduling, snake_compat
from scullery.scheduling import scheduler

from kaithem.api.web import has_permission, render_jinja_template
from kaithem.api.web.dialogs import SimpleDialog
from kaithem.src import (
    devices,
    messagebus,
    modules_state,
    settings_overrides,
    unitsofmeasure,
    util,
    workers,
)
from kaithem.src.config import config

ctime = time.time
do = workers.do


# Ratelimiter for calling gc.collect automatically when we get OSErrors
_lastGC = 0

# Use this lock whenever you access _events_by_module_resource in any way.
# Most of the time it should be held by the event manager
# that continually iterates it.
_event_list_lock = threading.RLock()

# Let us now have a way to get at active event objects by
# means of their origin (module, resource) tuple.
_events_by_module_resource: dict[tuple[str, str], BaseEvent] = {}

logger = structlog.get_logger(__name__)

# The autogenerated python module, not the module/resource
eventsByModuleName: dict[str, BaseEvent] = weakref.WeakValueDictionary()


load_order: list[weakref.ref[BaseEvent]] = []


class EventInterface:
    def __init__(self, ev) -> None:
        self.__ev = ev


def handle_function_error(f: Callable):
    """If f came from a user defined function, handle the error by logging it to
    the event it came from.
    """
    if hasattr(f, "__module__"):
        if f.__module__ in eventsByModuleName:
            eventsByModuleName[f.__module__].handle_exception()


def get_time(ev):
    try:
        if not _events_by_module_resource[ev].nextruntime:
            return 0
        return dt_to_ts(_events_by_module_resource[ev].nextruntime or 0)
    except Exception:
        return -1


def get_next_run(name: str, i) -> str:
    xyz = get_time((name, i))
    unitsofmeasure.strftime()
    if xyz == 0:
        xyz = "<b>Not Scheduled to Run</b>"
    elif xyz == -1:
        xyz = "Error getting next run time, try refreshing page again."
    else:
        xyz = unitsofmeasure.strftime(xyz)

    return xyz


def run_in_thread(f: typing.Callable, name: str):
    t = threading.Thread(target=f, name=f"nostartstoplog.{name}")
    t.start()


def makePrintFunction(ev):
    """For some unknown reason, new_print is involved in a
    garbage cycle that was preventing event GC
     Here we implement it as a closuse that only
      weakly references the actual event object.
    """
    printID = id(ev)

    ev = weakref.ref(ev)

    def new_print(*args, title="msg", **kwargs):
        # No, we cannot just do print(*args), because it breaks on python2
        if "local" in kwargs and kwargs["local"]:
            local = True
        else:
            local = False

        if len(args) == 1:
            if not local:
                print(args[0])
            x = f"{str(args[0])}\n"
        else:
            if not local:
                print(args)
            x = f"{str(args)}\n"
        ev2 = ev()
        if not id(ev2) == printID:
            w = "FROM OLD DELETED EVENT"
        else:
            w = ""
        del ev2

        "Print a message to the Device's management page"
        t = textwrap.fill(str(x), 120)
        tm = unitsofmeasure.strftime(time.time())

        # Can't use a def here, wouldn't want it to possibly capture more than just a string,
        # And keep stuff from GCIng for too long
        workers.do(makeBackgroundPrintFunction(t, tm, title + w, ev))

    return new_print


def manualRun(event):
    "Run an event manually"
    return _events_by_module_resource[event].manualRun()


def getPrintOutput(event):
    """Given a tuple of (module, resource),  return
    the doc string of an event if it exists, else return ''"""
    try:
        return _events_by_module_resource[event].printoutput
    except Exception as e:
        return f"Err getting output:{str(e)}"


def getEventInfo(event):
    """Given a tuple of (module, resource),  return the doc
    string of an event if it exists, else return ''"""
    return (
        _events_by_module_resource[event].__doc__
        if event in _events_by_module_resource
        and _events_by_module_resource[event].__doc__
        else ""
    )


def getEventErrors(module, event):
    "Return a list of errors for a given event. Uses _event_list_lock"
    with _event_list_lock:
        try:
            return _events_by_module_resource[module, event].errors
        except (KeyError, AttributeError) as e:
            return [
                [
                    "0",
                    f"Event does not exist or was not properly initialized:{str(e)}",
                ]
            ]


def fastGetEventErrors(module, event):
    """This version might not always be accurate,
    but will never modify anything or return an error. Does not  use a lock."""
    try:
        return _events_by_module_resource[module, event].errors
    except Exception as e:
        return [["0", f"{str(e)}Try refreshing page? "]]


# Given two functions, execute the action when the trigger is true.
# Trigger takes no arguments and returns a boolean


def when(trigger, action, priority="interactive"):
    """
    Create a one time event that deletes itself after firing.

    Args:
        trigger(function): The event occurs when this goes true
        action(function): This function is called when the event fires
        priority(string): One of realtime, interactive, low, etc. determines how often to poll.
    """

    module = "<OneTimeEvents>"
    resource = (
        trigger.__name__
        + ">"
        + action.__name__
        + " "
        + "set at "
        + str(time.time())
        + "by thread: "
        + str(threading.current_thread().ident)
        + " id="
        + str(base64.b64encode(os.urandom(16)))
    )

    # This is a hacky flag to let us turn the thing off immediately
    enable = [True]

    # We cannot remove the event from within itself because of the lock that
    # We do not want to make into an RLock for speed. So we do it in a different thread.
    def rm_slf():
        removeOneEvent(module, resource)

    def f():
        if not enable:
            return
        action()
        enable.pop()
        workers.do(rm_slf)

    e = PolledInternalSystemEvent(
        lambda: False if not enable else trigger(),
        f,
        priority=priority,
        m=module,
        r=resource,
    )
    e.module = module
    e.resource = resource
    _events_by_module_resource[module, resource] = e
    e.register()


# Given two functions, execute the action after delay.
# Trigger takes no arguments and returns a boolean


def after(delay, action, priority="interactive"):
    # If the time is in the future, then we use the scheduler.
    scheduling.scheduler.schedule(action, time.time() + delay)
    return


def getEventLastRan(module, event):
    with _event_list_lock:
        try:
            return _events_by_module_resource[module, event].lastexecuted
        except Exception:
            return 0


def getEventCompleted(m, r):
    try:
        return (
            _events_by_module_resource[m, r].lastcompleted
            > _events_by_module_resource[m, r].lastexecuted
        )
    except Exception:
        return False


def countEvents():
    # Why bother with the lock. The event count is not critical at all.
    return len(_events_by_module_resource)


# Used for interpreter shutdown
run = [True]


def STOP(*a):
    global run
    global load_order
    if run:
        run.pop()

    # Shut down in opposite order we started in
    with _event_list_lock:
        for i in reversed(load_order):
            x = i()
            if x:
                try:
                    x.unregister()
                    x.cleanup()
                except Exception:
                    logger.exception("Error in shutdown cleanup")


messagebus.subscribe("/system/shutdown", STOP)

t = 0


def stim():
    global t
    t = time.time()
    print("000000000")


def ptim():
    print(time.time() - t)


# In a background thread, we use the worker pool to check all threads


class EventSchedulerObject(scheduling.RepeatingEvent):
    def __init__(
        self,
        function,
        interval,
        phase=0,
        module=None,
        resource=None,
    ):
        scheduling.BaseEvent.__init__(self)
        self.f = function
        self.interval = float(interval)
        self.scheduled = False
        self.errored = False
        self.lock = threading.Lock()
        self.lastrun = None
        self.phaseoffset = (phase % 1) * interval
        self.module = module
        self.resource = resource

    def __repr__(self):
        return (
            "<EventSchedulerObject object for event at "
            + str((self.module, self.resource))
            + "with id "
            + str(id(self))
            + ">"
        )

    def run(self):
        do(self._run)

    def _run(self):
        # We must have been pulled out of the event queue
        # or we wouldn't be running
        # So we can reschedule ourself.
        self.scheduled = False
        if self.stop:
            return
        if self.lock.acquire(False):
            self.lastrun = ctime()
            try:
                self.f()
                self._schedule()
            except Exception:
                logger.exception("Error in event scheduler")
                raise
            finally:
                self.lock.release()


insert_phase = 0


def parseTrigger(when):
    """
    Parse a trigger expression into a tokenized form
    """
    output = []

    # Split on spaces, but take into account multipla spaces by ignoring empty strings.
    for i in when.strip().split(" "):
        if not i == "":
            output.append(i)

    # Take into account normal python expression triggers and return a similar format
    if output[0].startswith("!"):
        return output
    else:
        return ["!edgetrigger", when]


# Factory function that examines the type of trigger and chooses a class to handle it.
def Event(
    when="False",
    do="pass",
    continual=False,
    ratelimit=0,
    setup=None,
    priority=1,
    dummy=0,
    **kwargs,
):
    if dummy:
        when = "False"
        do = "pass"
        setup = "pass"

    trigger = parseTrigger(when)

    if trigger[0] == "!onmsg":
        return MessageEvent(
            when, do, continual, ratelimit, setup, priority, **kwargs
        )

    elif trigger[0] == "!onchange":
        return ChangedEvalEvent(
            when, do, continual, ratelimit, setup, priority, **kwargs
        )

    elif trigger[0] == "!edgetrigger":
        if priority == "realtime":
            return ThreadPolledEvalEvent(
                when, do, continual, ratelimit, setup, priority, **kwargs
            )
        else:
            return PolledEvalEvent(
                when, do, continual, ratelimit, setup, priority, **kwargs
            )

    elif trigger[0] == "!time":
        return RecurringEvent(
            " ".join(trigger[1:]),
            do,
            continual,
            ratelimit,
            setup,
            priority,
            **kwargs,
        )
    else:
        # Defensive programming, raise error on nonsense event type
        raise RuntimeError(
            f"Invalid trigger expression that begins with {str(trigger[0])}"
        )


# A brief rundown on how these classes work. You have the BaseEvent,
#  which handles registering and unregistering
# From polling lists, exeptions, and locking.

# Derived classes must do three things:
# Set self.polled to True if this event needs polling,
# or False if it is not(interrups, callbacks,etc)
# Define a _check() function that does
# polling and calls _on_trigger() if the event condition is true
# Do something with the setup variable if applicable
# define a _do_action() method that actually
#  carries out the appropriate action
# call the init of the base class properly.

# The BaseEvent wraps the _check function
# in such a way that only one event will be polled at a time
# And errors in _check will be logged.


class PersistentData:
    "Used to persist small amounts of data that remain when an event is re-saved"


fps = config["max_frame_rate"]


def makeBackgroundPrintFunction(p, t, title, self):
    def f():
        self().logWindow.write(f"<b>{title} at {t}</b><br>{p}")

    return f


def makeBackgroundErrorFunction(t, time, self):
    # Don't block everything up
    def f():
        self.logWindow.write(
            f'<div class="danger"><b>Error at {time}</b><br><pre>{t}</pre></div>'
        )

    return f


class BaseEvent:
    """Base Class representing one event.

    setupr,when and do are some representation of an action, the specifics of which are defined by derived classes.
    optional params:
    ratelimit: Do not do the action more often than every X seconds
    continual: Execute as often as possible while condition remains true

    """

    def __init__(
        self,
        when: str | Callable,
        do: str | Callable,
        continual=False,
        ratelimit=0,
        setup: str | None = None,
        priority=2,
        m: str | None = None,
        r: str | None = None,
    ):
        # Later we can use this to store performance info
        self.timeTakenToLoad = 0.0

        # Some normally polled event types
        # can turn off pollng if config doesn't need it
        self.polled = False

        # Copy in the data from args
        self.evt_persistant_data = PersistentData()
        self._prevstate = False
        self.ratelimit = ratelimit
        self.continual = continual
        self.countdown = 0
        self.printoutput = ""
        self.active = False
        # Although we usually disable events by removing them from polling/subscriptions
        # This seems like duplicated effort with the new stop flag in the scheduling though.
        # Might be worth a closer look, or might be better to be defensive and have two stop flags.
        self.disable = False
        # symbolic prioity os a rd like high,realtime, etc
        # Actual priority is a number that causes polling to occur every nth frame
        # Legacy events have numeric priorities
        self.symbolicpriority = priority
        # realtime is always every frame even for legacy
        if self.symbolicpriority == 1:
            self.symbolicpriority = "realtime"

        # try to look up the numeric priority from the symbolic
        try:
            self.poll_interval = config["priority_response"][priority]
        except KeyError:
            # Should that fail, attempt to use the priority directly
            try:
                self.poll_interval = fps / int(priority)
            # If even that fails, use interactive priority.
            except ValueError:
                self.poll_interval = config["priority_response"]["interactive"]
        self.runTimes = []
        self.module = m if m else "<unknown>"
        self.resource = r if r else str(util.unique_number())
        self.pymodule = types.ModuleType(
            str(f"Event_{self.module}_{self.resource}")
        )
        self.pymodule.__file__ = str(f"Event_{self.module}_{self.resource}")
        self.pymoduleName = f"Event_{self.module}_{self.resource}"

        eventsByModuleName[self.pymoduleName] = self
        # This lock makes sure that only one copy of the event executes at once.
        self.lock = threading.Lock()

        # This is a lock used for making modifications to the event itself,
        # Like registering and unregistering.
        # We use a separate lock so the event can start and stop itself, without having
        # To use an RLock for the main lock.
        self.register_lock = threading.RLock()

        # This keeps track of the last time the event was triggered  so we can rate limit
        self.lastexecuted = 0
        # Keep track of the last time the event finished running. Used to detect if it's still
        # going and how long it took
        self.lastcompleted = 0

        self.history: list[tuple[float, float]] = []
        self.backoff_until = 0

        # A place to put errors
        self.errors: list[tuple[float, str]] = []

        from kaithem.src import widgets

        self.logWindow = widgets.ScrollingWindow(2500)

        with _event_list_lock:
            global load_order
            load_order.append(weakref.ref(self))
            load_order = load_order[-1000:]

    def __repr__(self):
        try:
            return (
                "<"
                + type(self).__name__
                + "object at"
                + hex(id(self))
                + " for module,resource "
                + repr((self.module, self.resource))
                + ">"
            )
        except Exception:
            return f"<error in repr for event object at {hex(id(self))}>"

    def manualRun(self):
        # J.F. Sebastian of stackoverflow's post was helpful for this
        if not self.lock.acquire(False):
            time.sleep(0.1)
            if not self.lock.acquire(False):
                time.sleep(0.7)
                if not self.lock.acquire(False):
                    raise RuntimeError(
                        "Could not acquire lock while event already running or polling. Trying again may work."
                    )
        try:
            self._on_trigger()
        finally:
            self.lock.release()

    def _do_action(self):
        pass

    def cleanup(self):
        try:
            with self.lock:
                try:
                    if hasattr(self, "pymodule"):
                        if "__del__" in self.pymodule.__dict__:
                            self.pymodule.__dict__["__del__"]()
                            del self.pymodule.__dict__["__del__"]
                except Exception:
                    logger.exception("Error in delete function")
                if hasattr(self, "pymodule"):
                    self.pymodule.__dict__.clear()
                    del self.pymodule
        except Exception:
            raise

    def _on_trigger(self):
        # This function gets called when whatever the event's trigger condition is.
        # it provides common stuff to all trigger types like logging and rate limiting

        # Check the current time minus the last time against the rate limit
        # Don't execute more often than ratelimit

        if time.time() - self.lastexecuted > self.ratelimit:
            # Set the varible so we know when the last time the body actually ran
            self.lastexecuted = time.time()
            try:
                # Action could be any number of things, so this method mut be implemented by
                # A derived class or inherited from a mixin.
                self._do_action()
                self.lastcompleted = time.time()
                self.history.append((self.lastexecuted, self.lastcompleted))
                if len(self.history) > 250:
                    self.history.pop(0)
                # messagebus.post_message('/system/events/ran',[self.module, self.resource])
            except Exception as e:
                # This is not a child of system
                logger.exception(
                    f"Error running event {self.resource} of {self.module}"
                )
                self.handle_exception(e)

    def handle_exception(self, e: Exception = None, tb: str = None) -> None:
        global _lastGC
        if tb is None:
            tb = traceback.format_exc(chain=True)

        # TODO: Get rid of legacy error stuff
        # When an error happens, log it and save the time
        # Note that we are logging to the compiled event object
        self.errors.append(
            [
                time.strftime(
                    settings_overrides.get_val("core/strftime_string")
                ),
                tb,
            ]
        )
        # Keep only the most recent errors
        self.errors = self.errors[-50:]

        workers.do(
            makeBackgroundErrorFunction(
                textwrap.fill(tb, 120),
                unitsofmeasure.strftime(time.time()),
                self,
            )
        )

        try:
            messagebus.post_message(
                f"/system/errors/events/{self.module}/{self.resource}", str(tb)
            )
        except Exception:
            logger.exception("Error pushing error to messagebus")

        # Catch legacy number based priorities that are realtime
        if self.symbolicpriority == 1:
            backoff = config["error_backoff"]["realtime"]
        else:
            try:
                backoff = config["error_backoff"][self.symbolicpriority]
            except KeyError:
                backoff = config["error_backoff"]["interactive"]

        # Randomize backoff intervals in case there's an error that can
        # Be fixed by changing the order of events
        self.backoff_until = time.time() + (
            backoff * ((random.random() / 10) + 0.95)
        )

        # Try to fix the error by garbage collecting
        # If there's too many open files
        if isinstance(e, OSError):
            if time.time() - _lastGC > 240:
                _lastGC = time.time()
                gc.collect()

        # If this is the first error since th module was last saved raise a notification
        if len(self.errors) == 1:
            logger.exception(
                f"Error running event {self.resource} of {self.module}"
            )
            messagebus.post_message(
                "/system/notifications/errors",
                'Event "'
                + self.resource
                + '" of module "'
                + self.module
                + '" may need attention',
            )

    def end_polling(self):
        with self.register_lock:
            if hasattr(self, "schedulerobj"):
                self.schedulerobj.unregister()
                del self.schedulerobj

    def begin_polling(self):
        # Note that we spread out the intervals by 0.15% to make them not all bunch up at once constantly.
        global insert_phase

        if not self.polled:
            return

        # Ensure we don't have 2 objects going.
        self.end_polling()

        self._prevstate = False

        self.schedulerobj = EventSchedulerObject(
            self.check,
            config["priority_response"].get(self.symbolicpriority, 0.08)
            + (insert_phase * 0.03)
            - 0.015,
        )
        try:
            self.schedulerobj.module = self.module
            self.schedulerobj.resource = self.resource
        except Exception:
            # I have no idea what this was for
            logger.exception("????????????????")
        # Basically we want to spread them out in the
        # phase space from 0 to 1 in a deterministic ish way.
        # There might be a better algorithm of better constant to use,
        # but this one should be decent.
        # The phase of this wave determines the frequency offset applied
        insert_phase += 0.555555
        insert_phase = insert_phase % 1
        self.schedulerobj.register()
        self.schedulerobj.schedule()

    def register(self):
        # Note: The whole self.disabled thing is
        # really laregly a hack to get instant response
        # To things if an event is based on some external thing
        # with a callback that takes time to unregister.
        self.disable = False
        self.begin_polling()

    def unpause(self):
        self.register()

    def pause(self):
        self.disable = True
        self.end_polling()

    def unregister(self):
        self.disable = True
        self.end_polling()

    def _check(self):
        raise NotImplementedError()

    def check(self):
        """This is the function that the polling system calls to poll the event.
        It calls a _check() function which must be defined by a subclass."""
        # Should another thread already be polling this, We actually don't want to
        # just sit around and wait. That would mean one slow event could queue up many
        # copies of itself and cause odd performance issues.
        # so, if another thread is already handling this, just return and move on.

        # Easy way of doing error backoffs
        if self.disable:
            return
        if time.time() < self.backoff_until:
            return
        if not self.lock.acquire(False):
            return

        try:
            try:
                self._check()
            except Exception as e:
                try:
                    logger.exception(
                        f"Error in event {self.resource} of {self.module}"
                    )
                    self.handle_exception(e)
                except Exception:
                    logger.exception("Error handling exception in event")
        finally:
            self.lock.release()


def test_compile(setup, action):
    # Compile the action and run the initializer
    if setup is None:
        setup = "pass"

    # initialize the module scope with the kaithem object and the module thing.
    compile(setup, "TestCompileSetup", "exec")

    body = "def _event_action():\n"
    for line in action.split("\n"):
        body += f"    {line}\n"
    body = compile(body, "TestCompile", "exec")


class DummyModuleScope:
    pass


class UnrecoverableEventInitError(RuntimeError):
    pass


class CompileCodeStringsMixin(BaseEvent):
    "This mixin lets a class take strings of code for its setup and action"

    def _init_setup_and_action(self, setup, action, params={}):
        # Compile the action and run the initializer
        if setup is None:
            setup = "pass"

        # initialize the module scope with the kaithem object and the module thing.
        initializer = compile(
            setup, f"Event_{self.module}_{self.resource}", "exec"
        )

        try:
            self.pymodule.__dict__["module"] = (
                modules_state.scopes[self.module]
                if self.module in modules_state.scopes
                else DummyModuleScope()
            )
            try:
                # To avoid a garbage cycle, the function is a closure
                # That only weak references the object
                self.pymodule.__dict__["print"] = makePrintFunction(self)
            except Exception:
                logger.exception(
                    "Failed to activate event print output functionality"
                )
            self.pymodule.__dict__.update(params)
        except KeyError as e:
            raise e
        fooLock = threading.Lock()
        flag: list[int] = []
        err: list[Exception] = []

        def runInit():
            with fooLock:
                # Just a marker so we know it got called
                flag.append(0)
                try:
                    exec(initializer, self.pymodule.__dict__)
                except Exception as e:
                    logger.exception(
                        f"Error in event code for {self.module}:{self.resource}"
                    )
                    err.append(e)

        # For reasons I don't yet understand, this blocked for a long time
        # when it used workers.do.
        # TODO what happened?
        run_in_thread(runInit, f"init {self.module}:{self.resource}")

        try:
            # Wait for it to get the lock
            while (len(flag)) == 0:
                time.sleep(0.001)

            t = time.monotonic()
            # Now wait for it to release it
            while not fooLock.acquire(timeout=0.1):
                if time.monotonic() - t > 15:
                    raise UnrecoverableEventInitError(
                        "event initializer stuck in loop, and may still be running. Undefined behavior? "
                    )
        finally:
            pass

        if err:
            raise err[0]

        body = "def _event_action():\n"
        for line in action.split("\n"):
            body += f"    {line}\n"
        body = compile(body, f"Event_{self.module}_{self.resource}", "exec")
        exec(body, self.pymodule.__dict__)
        self.__doc__ = self.pymodule.__doc__
        # This is one of the weirder line of code I've ever writter
        # Apperently for some reason we have to manually tell it where to go for global variables.

    def _do_action(self):
        if hasattr(self.pymodule, "_event_action"):
            self.pymodule._event_action()
        else:
            raise RuntimeError(f"{self.resource} has no _event_action.")


class DirectFunctionsMixin:
    def _init_setup_and_action(self, setup, action):
        self._do_action = action


class MessageEvent(CompileCodeStringsMixin):
    def __init__(
        self,
        when,
        do,
        continual=False,
        ratelimit=0,
        setup: str | None = "pass",
        *args,
        **kwargs,
    ):
        # This event type is not polled. Note that it doesn't even have a check() method.
        self.polled = False
        BaseEvent.__init__(
            self, when, do, continual, ratelimit, setup, *args, **kwargs
        )
        self.lastran = 0

        # Handle whatever stupid whitespace someone puts in
        # What this does is to eliminate leading whitespace, split on first space,
        # Then get rid of any extra spaces in between the command and argument.
        t = when.strip().split(" ", 1)[1].strip()
        self.topic = t

        # No idea why this is done last, actually,
        # but I'm not changing it if it works without looking into it first.
        self._init_setup_and_action(setup, do)

    def register(self):
        if hasattr(self, "action_wrapper_because_we_need_to_keep_a_reference"):
            return

        def action_wrapper(topic, message):
            # Since we aren't under the BaseEvent.check() lock, we need to get it ourselves.
            if self.disable:
                return
            with self.lock:
                if self.ratelimit > time.time() - self.lastran:
                    return
                self.lastran = time.time()
                # These two lines were an old fix for a circular reference buf that made message events not go away.
                # It is still here just in case another circular reference bug pops up.
                if (
                    self.module,
                    self.resource,
                ) not in _events_by_module_resource:
                    return

                # setup environment
                self.pymodule.__dict__["__topic"] = topic
                self.pymodule.__dict__["__message"] = message
                # We delegate the actual execution of the body to the on_trigger
                self._on_trigger()

        # When the object is deleted so will this reference and the message bus's auto unsubscribe will handle it,
        # even if we don't do an unregister call, which we should.
        self.action_wrapper_because_we_need_to_keep_a_reference = action_wrapper
        # Subscribe our new function to the topic we want
        messagebus.subscribe(self.topic, action_wrapper)
        self.disable = False

    # This is the solution for the circular reference nonsense, until the messagebus has a real unsubscribe feature.
    def unregister(self):
        if hasattr(self, "action_wrapper_because_we_need_to_keep_a_reference"):
            del self.action_wrapper_because_we_need_to_keep_a_reference
        self.disable = True


class ChangedEvalEvent(CompileCodeStringsMixin):
    def __init__(
        self,
        when,
        do,
        continual=False,
        ratelimit=0,
        setup: str | None = "pass",
        *args,
        **kwargs,
    ):
        # If the user tries to use the !onchanged trigger expression,
        # what we do is to make a function that does the actual checking and always returns false
        # This means it will be called every frame but the usual trigger method(which is edge triggered)
        # Is bypassed. Instead, we directly call self._on_trigger and return false

        # Handle whatever stupid whitespace someone puts in
        # What this does is to eliminate leading whitespace, split on first space,
        # Then get rid of any extra spaces in between the command and argument.
        f = when.strip().split(" ", 1)[1].strip()
        BaseEvent.__init__(
            self, when, do, continual, ratelimit, setup, *args, **kwargs
        )
        self._init_setup_and_action(setup, do)

        x = compile(
            f"def _event_trigger():\n    return {f}",
            f"Event_{self.module}_{self.resource}",
            "exec",
        )
        exec(x, self.pymodule.__dict__)

        # This flag indicates that we have never had a reading
        self.at_least_one_reading = False
        self.polled = True

    def _check(self):
        if self.disable:
            return

        # Evaluate the function that gives us the values we are looking for changes in
        self.latest = self.pymodule._event_trigger()

        # If this is the very first reading,
        if not self.at_least_one_reading:
            # make a fake previous reading the same as the last one
            self.old = self.latest
            self.at_least_one_reading = True

        # If the most recent reading differs from the last one
        if not self.old == self.latest:
            # Update the value of the last reading for next time
            self.old = self.latest
            # Set it up so user code will have access to the value
            self.pymodule.__value = self.latest  # type: ignore
            self._on_trigger()


class PolledEvalEvent(CompileCodeStringsMixin):
    def __init__(
        self,
        when,
        do,
        continual=False,
        ratelimit=0,
        setup: str | None = "pass",
        *args,
        **kwargs,
    ):
        BaseEvent.__init__(
            self, when, do, continual, ratelimit, setup, *args, **kwargs
        )
        self.polled = True

        # Sometimes an event is used for its setup action and never runs.
        # If the trigger is False, it will never trigger, so we don't poll it.
        if str(when).strip() in ("False", "None", "0"):
            self.polled = False
        # Compile the trigger
        x = compile(
            f"def _event_trigger():\n    return {when}",
            f"Event_{self.module}_{self.resource}",
            "exec",
        )
        exec(x, self.pymodule.__dict__)

        self._init_setup_and_action(setup, do)
        self.ev_trig = self.pymodule._event_trigger

    def _check(self):
        """Check if the trigger is true and if so do the action."""
        # Eval the condition in the local event scope
        if self.disable:
            return
        if self.ev_trig():
            # Only execute once on false to true change unless continual was set
            if self.continual or self._prevstate is False:
                self._prevstate = True
                self._on_trigger()
        else:
            # The eval was false, so the previous state was False
            self._prevstate = False


class ThreadPolledEvalEvent(CompileCodeStringsMixin):
    def __init__(
        self,
        when,
        do,
        continual=False,
        ratelimit=0,
        setup: str | None = "pass",
        *args,
        **kwargs,
    ):
        BaseEvent.__init__(
            self, when, do, continual, ratelimit, setup, *args, **kwargs
        )
        self.runthread = True
        self.lock = threading.RLock()
        self.pauseflag = threading.Event()
        self.pauseflag.set()

        def f():
            d = config["priority_response"].get(self.symbolicpriority, 1 / 60.0)
            # Run is the global run flag used to shutdown
            while run and self.runthread:
                # The sleep comes before the check of the condition because
                # we want the fastest response when turning the event back on.
                time.sleep(d)

                # We want to wait if paused. There may be performance
                # Issues on python2 using this lock,
                # But otherwise a paused event would just wait
                # and not be deleted.
                while not self.pauseflag.wait(5.0):
                    if not (run and self.runthread):
                        return
                with self.lock:
                    try:
                        self.check()
                    except Exception as e:
                        if not (run and self.runthread):
                            return
                        logger.exception(
                            f"Error in event {self.resource} of {self.module}"
                        )
                        self.handle_exception(e)
                        time.sleep(
                            config["error_backoff"].get(
                                self.symbolicpriority, 5
                            )
                        )

        self.loop = f

        # Sometimes an event is used for its setup action and never runs.
        # If the trigger is False, it will never trigger, so we don't poll it.
        if when == "False":
            self.polled = False
        else:
            self.polled = True
        # Compile the trigger
        x = compile(
            f"def _event_trigger():\n    return {when}",
            self.pymoduleName,
            "exec",
        )
        exec(x, self.pymodule.__dict__)

        self._init_setup_and_action(setup, do)

    # Because of the not so perfect register/unregister mechanism here
    # We have a separate pause and unpause feature.

    def pause(self):
        self.pauseflag.clear()

    def unpause(self):
        self.pauseflag.set()

    def register(self):
        # Our entire thing runs under self.lock,
        # so that keeps us from accidentally starting 2 threads
        # However, the self.runthread = True run regardless of the lock.
        # A previous call to unregister could have set it False,
        # But the thread might not notice for maybe minutes or seconds.
        # In that case, if a register() call happens in that time,
        # we want to prevent the thread stopping in the first place.

        # Note that this is not 100% threadsafe. Calling register()
        # 0.1ms after unregister()
        # could still result in a stopped thread in theory.

        # This is because the process of stopping may take a bit of time.
        # Calls to register() during the stopping time
        # Will 99.9% of the time prevent the stopping from happening,
        #  but 0.001% of the time might do nothing.
        # No matter what, calling them in any order from any number
        # of threads should not deadlock.
        if not self.polled:
            return
        self.unpause()

        self.runthread = True
        if self.lock.acquire(False):
            try:
                self.thread = threading.Thread(
                    target=self.loop,
                    name=f"Event_{self.module}_{self.resource}",
                )
                self.thread.start()
            finally:
                self.lock.release()
        else:
            # Try again. This is to catch it in the "stopping" state.

            time.sleep(0.001)
            if self.lock.acquire(False):
                try:
                    self.thread = threading.Thread(
                        target=self.loop,
                        name=f"Event_{self.module}_{self.resource}",
                    )

                    self.thread.start()
                finally:
                    self.lock.release()
        self.disable = False

    def unregister(self):
        with self.lock:
            self.runthread = False
            self.disable = True
            self.pauseflag.clear()
            time.sleep(1 / 60.0)

    def _check(self):
        """Check if the trigger is true and if so do the action."""
        # Eval the condition in the local event scope
        if self.pymodule._event_trigger():
            # Only execute once on false to true change unless continual was set
            if self.continual or not self._prevstate:
                self._prevstate = True
                self._on_trigger()
        else:
            # The eval was false, so the previous state was False
            self._prevstate = False


class PolledInternalSystemEvent(BaseEvent, DirectFunctionsMixin):
    def __init__(
        self,
        when: Callable,
        do: Callable,
        continual=False,
        ratelimit=0,
        setup="pass",
        *args,
        **kwargs,
    ):
        BaseEvent.__init__(
            self, when, do, continual, ratelimit, setup, *args, **kwargs
        )
        self.polled = True
        # Compile the trigger
        self.trigger = when
        self._init_setup_and_action(setup, do)
        self._prevstate

    def _check(self):
        """Check if the trigger is true and if so do the action."""
        # Eval the condition in the local event scope
        if self.trigger():
            # Only execute once on false to true change unless continual was set
            if self.continual or self._prevstate is False:
                self._prevstate = True
                self._on_trigger()
        else:
            # The eval was false, so the previous state was False
            self._prevstate = False


def dt_to_ts(dt, tz=None):
    "Given a datetime in tz, return unix timestamp"
    if tz:
        utc = pytz.timezone("UTC")
        return (
            tz.localize(dt.replace(tzinfo=None))
            - datetime.datetime(1970, 1, 1, tzinfo=utc)
        ) / datetime.timedelta(seconds=1)

    else:
        # Local Time
        ts = time.time()
        offset = (
            datetime.datetime.fromtimestamp(ts)
            - datetime.datetime.utcfromtimestamp(ts)
        ).total_seconds()
        return (
            (dt - datetime.datetime(1970, 1, 1)) / datetime.timedelta(seconds=1)
        ) - offset


class RecurringEvent(CompileCodeStringsMixin):
    "This represents an event that happens on a schedule"

    def __init__(
        self,
        when,
        do,
        continual=False,
        ratelimit=0,
        setup: str | None = "pass",
        *args,
        **kwargs,
    ):
        self.polled = False
        self.trigger = when
        self.register_lock = threading.Lock()
        BaseEvent.__init__(
            self, when, do, continual, ratelimit, setup, *args, **kwargs
        )
        self._init_setup_and_action(setup, do)
        # Bound methods aren't enough to stop GC
        # TODO, Maybe this method should be asyncified?
        self.handler = self._handler
        self.exact = self.get_exact()

        ref = datetime.datetime.now()
        selector = util.get_rrule_selector(when, ref)

        self.selector = selector

        self.nextruntime = None
        self.next = None

    def get_exact(self):
        r = re.match(r"exact( ([0-9]*\.?[0-9]))?", self.trigger)
        if not r:
            return False
        gr = r.groups()
        if gr:
            return float(gr[1])
        else:
            return 3

    # Recalculate the next time at which the event should run,
    # for cases in which the time was set incorrectly
    # And has now been changed. Not well tested,
    # work in progress, might cause a missed event or something.
    def recalc_time(self):
        try:
            if self.next:
                self.next.unregister()
        except AttributeError:
            pass
        if not self.nextruntime:
            return

        self.nextruntime = self.selector.after(self.nextruntime, False)

        if self.nextruntime is None:
            return
        self.next = scheduler.schedule(self.handler, self.nextruntime, False)

    def _handler(self):
        if "allow_overlap" not in self.trigger:
            # If already running, just schedule the next one and go home.
            if not self.lock.acquire(False):
                self.nextruntime = self.selector.after(self.nextruntime, False)

                self.next = scheduler.schedule(
                    self.handler, dt_to_ts(self.nextruntime), False
                )
                return
        try:
            # If the scheduler misses it and we have exact configured, then we just don't do the
            # Actual action.
            def f():
                # Make linter happy
                if not self.nextruntime:
                    return
                if not (
                    self.exact
                    and (time.time() > (self.nextruntime + self.exact))
                ):
                    self._on_trigger()

            workers.do(f)

        finally:
            try:
                self.lock.release()
            except Exception:
                logger.exception("Failed to release lock")
            self.nextruntime = self.selector.after(self.nextruntime, False)

            if self.nextruntime is None:
                return

            if self.nextruntime:
                self.next = scheduler.schedule(
                    self.handler, dt_to_ts(self.nextruntime), False
                )
                return
            print(
                "Caught event trying to return None for get next run, time is:",
                time.time(),
                " expr is ",
                self.trigger,
                " last ran ",
                self.lastexecuted,
                "retrying",
            )
            # A random number unlikely to sync up with anything
            time.sleep(0.179)

            if self.nextruntime:
                self.next = scheduler.schedule(
                    self.handler, self.nextruntime, False
                )
                return
            print(
                """Caught event trying to return None for get next run
                (might be an event that only runs for a period that
                already expired), and retry 1 failed time is:""",
                time.time(),
                " expr is ",
                self.trigger,
                " last ran ",
                self.lastexecuted,
                "retrying",
            )
            # A random number unlikely to sync up with anything
            time.sleep(1.353)

            if self.nextruntime:
                self.next = scheduler.schedule(
                    self.handler, dt_to_ts(self.nextruntime), False
                )
                return
            print(
                """Caught event trying to return None for get next run
                (might be an event that only runs for a period that already expired), and retry 1 failed time is:""",
                time.time(),
                " expr is ",
                self.trigger,
                " last ran ",
                self.lastexecuted,
                "NOT retrying",
            )

    def __del__(self):
        try:
            if self.next:
                self.next.unregister()
        except AttributeError:
            pass

    def register(self):
        logger.debug("registered")
        with self.register_lock:
            if self.nextruntime:
                return
            self.nextruntime = self.selector.after(
                datetime.datetime.now(), False
            )
            if self.nextruntime is None:
                return

            self.next = scheduler.schedule(
                self.handler, dt_to_ts(self.nextruntime), False
            )

            self.disable = False

    def unregister(self):
        self.nextruntime = None
        try:
            if self.next:
                self.next.unregister()
        except AttributeError:
            pass
        self.disable = True


# If the system time has been set, we may want to recalculate all of the events.
# Work in progress


def recalc_schedule():
    with _event_list_lock:
        for i in _events_by_module_resource:
            if isinstance(_events_by_module_resource[i], RecurringEvent):
                _events_by_module_resource[i].recalc_time()


# BORING BOOKEEPING BELOW


# Delete a event from the cache by module and resource
def removeOneEvent(module, resource):
    with _event_list_lock:
        if (module, resource) in _events_by_module_resource:
            _events_by_module_resource[module, resource].unregister()
            _events_by_module_resource[module, resource].cleanup()
            del _events_by_module_resource[module, resource]
    gc.collect(0)
    gc.collect(1)
    gc.collect(2)


# Delete all _events in a module from the cache
def removeModuleEvents(module):
    with _event_list_lock:
        for i in _events_by_module_resource.copy():
            if i[0] == module:
                # delete both the event and the reference to it
                _events_by_module_resource[i].unregister()
                _events_by_module_resource[i].cleanup()
                del _events_by_module_resource[i]
        gc.collect(0)
        gc.collect(1)
        gc.collect(2)


# This piece of code will update the actual event object based on the event resource definition in the module
# Also can add a new event.
# Now if you already have an event object, like from a test compile, you can just use that.


def updateOneEvent(module: str, resource: str, o=None):
    # This is one of those places that uses two different locks(!)
    with modules_state.modulesLock:
        try:
            # Get either a reference to the old version or None
            if (module, resource) in _events_by_module_resource:
                old = _events_by_module_resource[module, resource]
            else:
                old = None

            if old:
                # Unregister first, then clean up.
                old.unregister()
                # Now we clean it up and delete any references the user code might have had to things
                old.cleanup()
                # Really we should wait a bit longer but this is a compromise,
                # we wait so any cleanup effects can propagate.
                # 120ms is better than nothing I guess. And we garbage collect before and after,
                # Because we want all the __del__ stuff to get a chance to take effect.
                gc.collect(0)
                gc.collect(1)
                gc.collect(2)

                time.sleep(0.120)
                gc.collect(0)
                gc.collect(1)
                gc.collect(2)
            if not o:
                # Now we make the event
                x = make_event_from_resource(module, resource)
            else:
                x = o

            if old:
                x.evt_persistant_data = old.evt_persistant_data

            # Here is the other lock(!)
            with _event_list_lock:  # Make sure nobody is iterating the eventlist
                # Add new event
                x.register()
                # Update index
                _events_by_module_resource[module, resource] = x

            data = event_resources[module, resource]

            del old

            # One last run.
            gc.collect()

        except Exception as e:
            d = makeDummyEvent(module, resource)
            d.handle_exception(e)

        from kaithem.src import codechecks

        data = toPyFile(data)
        errs = codechecks.check(data, resource + ".py")
        data = data.splitlines()
        if errs:
            for i in errs.splitlines():
                line = re.search(r":(\d*):", i)
                if line:
                    line = line.group(1)
                else:
                    line = "Line number could not be decoded"

                try:
                    line = data[int(line) - 1]
                except Exception:
                    line = "Context not found"

                x.pymodule.__dict__["print"](
                    f"{i}\r\n{line}", title="Pyflakes warning"
                )


# makes a dummy event for when there is an error loading and puts it in the right place
# The dummy does nothing but is in the right place


def makeDummyEvent(module, resource):
    # This is one of those places that uses two different locks(!)
    with modules_state.modulesLock:
        x = Event(m=module, r=resource)
        # Here is the other lock(!)
        with _event_list_lock:  # Make sure nobody is iterating the eventlist
            if (module, resource) in _events_by_module_resource:
                _events_by_module_resource[module, resource].unregister()

            # Add new event
            x.register()
            # Update index
            _events_by_module_resource[module, resource] = x

        return x


event_resources: dict[tuple[str, str], modules_state.ResourceDictType] = {}


# look in the modules and compile all the event code
# if only is supplied, must be a set and will only look in those modules
def getEventsFromModules(only: str | None = None):
    toLoad = []

    # Closures were acting weird. This class is to be like a non wierd closure.
    class needstobeloaded:
        def __init__(self, module, resource):
            self.module = module
            self.resource = resource
            self.loadingTraceback = None

        def f(self):
            x = make_event_from_resource(self.module, self.resource)
            x.register()
            # Now we update the references
            globals()["_events_by_module_resource"][
                self.module, self.resource
            ] = x
            self.evt = x

    with modules_state.modulesLock:
        with _event_list_lock:
            for module, resource in event_resources:
                # now we loop over all the resources of the module to see which ones are events
                if only is None or (module in only):
                    x = event_resources[module, resource]
                    if x["resource_type"] == "event":
                        # For every resource that is an event, we make an event object based on it
                        # And put it in the event referenced thing.
                        # However, we do this indirectly, for each event we create a function representing
                        # the actions to set it up
                        f = needstobeloaded(module, resource)
                        toLoad.append(f)
                        f.module = module
                        f.resource = resource

            # This sorting means root folder stuff loads before child folder stuff.
            toLoad = sorted(toLoad, key=lambda x: (x.module, x.resource))
            nextRound = []
            # for each allowed loading attempt, we loop over
            # the events and try to set them up. If this fails,
            # add to the retry list and retry next round. This means they will be attempted again
            # up to the maximum number of tries. The important part is that we don't
            # retry immediately, but only after trying the remaining list of events.
            # This is because inter-event dependancies are the most common reason for failure.

            attempts = max(1, config["max_load_attempts"])
            for baz in range(attempts):
                if not toLoad:
                    break
                logger.debug(
                    f"Event initialization resolution round {str(baz)}"
                )
                for i in toLoad:
                    try:
                        logger.debug(f"Loading {i.module}:{i.resource}")
                        slt = time.time()
                        i.f()

                        messagebus.post_message(
                            "/system/events/loaded", [i.module, i.resource]
                        )
                        logger.debug(
                            "Loaded "
                            + i.module
                            + ":"
                            + i.resource
                            + " in "
                            + str(round(time.time() - slt, 2))
                            + "s"
                        )
                        time.sleep(0.005)

                    except (SyntaxError, UnrecoverableEventInitError):
                        i.loadingTraceback = traceback.format_exc(chain=True)
                        i.error = traceback.format_exc(chain=True)
                        logger.exception(
                            f"Could not load {i.module}:{i.resource}"
                        )

                    # If there is an error, add it t the list of things to be retried.
                    except Exception:
                        i.error = traceback.format_exc(chain=True)
                        if baz == attempts - 1:
                            i.loadingTraceback = traceback.format_exc()

                        nextRound.append(i)
                        logger.debug(
                            "Could not load "
                            + i.module
                            + ":"
                            + i.resource
                            + " in this round, deferring to next round\n"
                            + "failed after"
                            + str(round(time.time() - slt, 2))
                            + "s\n"
                            + traceback.format_exc(chain=True)
                        )
                        gc.collect()
                toLoad = nextRound
                nextRound = []
            # Iterate over the failures after trying the max number of times to fix them
            # and make the dummy events and notifications
            for i in toLoad:
                d = makeDummyEvent(i.module, i.resource)
                d.handle_exception(tb=i.error)
                # Add the reason for the error to the actual object so it shows up on the page.
                _events_by_module_resource[i.module, i.resource].errors.append(
                    [
                        time.strftime(
                            settings_overrides.get_val("core/strftime_string")
                        ),
                        str(i.error),
                    ]
                )
                messagebus.post_message(
                    "/system/notifications/errors",
                    "Failed to load event resource: "
                    + i.resource
                    + " module: "
                    + i.module
                    + "\n"
                    + str(i.error)
                    + "\n"
                    + "please edit and reload.",
                )
    try:
        # todo i think this is ancient
        devices.warnAboutUnsupportedDevices()
    except Exception:
        logger.info("Error checking validity of device instances")
    logger.info("Created events from modules")


def make_event_from_resource(
    module: str,
    resource: str,
    subst: modules_state.ResourceDictType | None = None,
):
    """Returns an event object when given a module and resource name pointing to an event resource.
    Also, if subst is a dict, will use the dict given in subst instead of looking it up.

    The reason for this is so that you can try test compiling without having to actually change the resource.
    """
    t = time.time()
    if not subst:
        r = event_resources[module, resource]
    else:
        r = subst

    assert isinstance(r, dict)

    # Add defaults for legacy events that do not have setup, rate limit, etc.
    if "setup" in r:
        setupcode = str(r["setup"])
    else:
        setupcode = "pass"

    if "rate_limit" in r:
        ratelimit = float(r["rate_limit"])
    else:
        ratelimit = 0

    if "continual" in r:
        continual = r["continual"]
    else:
        continual = False

    assert isinstance(continual, bool)

    if "priority" in r:
        priority = r["priority"]
    else:
        priority = 1
    try:
        if "enable" in r:
            if not r["enable"]:
                # TODO: What's going on here?
                if not parseTrigger(r["trigger"][0]) == "!function":
                    e = Event(m=module, r=resource)
                else:
                    e = Event(
                        r["trigger"],
                        r["action"],
                        setup=setupcode,
                        continual=continual,
                        ratelimit=ratelimit,
                        priority=priority,
                        m=module,
                        r=resource,
                        dummy=True,
                    )

                e.disable = True
                return e

        x = Event(
            r["trigger"],
            r["action"],
            setup=setupcode,
            continual=continual,
            ratelimit=ratelimit,
            priority=priority,
            m=module,
            r=resource,
        )

    except Exception as e:
        if (module, resource) not in _events_by_module_resource:
            d = makeDummyEvent(module, resource)
            d.handle_exception(e)
        raise

    # findCapitalizationIssues(setupcode+" \n "+r['trigger']+ "\n "+r['action'], x)
    x.timeTakenToLoad = time.time() - t
    return x


init_done = False


def getInitialWhitespace(s):
    t = ""
    for i in s:
        if i in "\t ":
            t += i
        else:
            break
    return t


def readToplevelBlock(p, heading):
    """Given code and a heading like an if or a def, read everything under it.
    return tuple of the code we read, and the code WITHOUT that stuff
    """
    x = p.split("\n")
    state = "outside"
    indent = ""
    lines = []
    outside_lines = []
    firstline = ""
    heading = heading.strip()
    # Eliminate space, this is probably not the best way
    heading = heading.replace(" ", "").replace('"', "'")
    for i in x:
        if state == "outside":
            if i.replace(" ", "").replace('"', "'").strip().startswith(heading):
                state = "firstline"
                firstline = i
            else:
                outside_lines.append(i)
        elif state == "firstline":
            indent = getInitialWhitespace(i)
            if not indent:
                raise ValueError(f"Expected indented block after {firstline}")
            lines.append(i[len(indent) :])
            state = "inside"
        elif state == "inside":
            if not len(indent) <= len(getInitialWhitespace(i)):
                if i.split("#")[0].strip():
                    state = "outside"
            lines.append(i[len(indent) :])
    if not lines:
        if state == "outside":
            raise ValueError("No such block")
    return ("\n".join(lines), "\n".join(outside_lines))


def readStringFromSource(s: str, var: str) -> None | str:
    "Without executing it, get a string var from source code"
    a = ast.parse(s)
    b = a.body
    for i in b:
        if isinstance(i, ast.Assign):
            for t in i.targets:
                if t.id == var:
                    return str(i.value.s)


def indent(s, prefix="    "):
    s = [prefix + i for i in s.split("\n")]
    return "\n".join(s)


def toPyFile(r) -> str:
    r = copy.deepcopy(r)
    "Encode an event resource as a python file"
    s = r["setup"]
    del r["setup"]
    a = r["action"]
    del r["action"]
    t = r["trigger"]
    del r["trigger"]
    d = "# Code outside the data string, setup and action blocks is ignored\n"
    d += "# If manually editing, delete resource timestamp and restart kaithem.\n"

    d += '__data__="""\n'
    d += (
        yaml.dump(r).replace("\\", "\\\\").replace('"""', r'\"""') + '\n"""\n\n'
    )

    # Autoselect what quote to use
    if "'" not in t:
        d += (
            "__trigger__='"
            + t.replace("\\", "\\\\").replace("'", r"\'")
            + "'\n\n"
        )
    else:
        d += (
            '__trigger__="'
            + t.replace("\\", "\\\\").replace('"', r"\"")
            + '"\n\n'
        )

    d += "if __name__=='__setup__':\n"
    d += indent(s)
    d += "\n\n"

    d += "def event_action():\n"
    d += indent(a)
    d += "\n"
    return d


def rsc_from_py_file(fn: str):
    # This regex is meant to handle any combination of cr, lf, and trailing whitespaces
    # We don't do anything with more that 3 sections yet, so limit just in case there's ----
    # in a markdown file

    with open(fn) as f:
        d = f.read()

    # Get the two code blocks, then remove  them before further processing
    if "\ndef eventAction():" in d:
        action, restofthecode = readToplevelBlock(d, "def eventAction():")
    else:
        action, restofthecode = readToplevelBlock(d, "def event_action():")

    setup, restofthecode = readToplevelBlock(
        restofthecode, "if __name__ == '__setup__':"
    )
    # Restofthecode doesn't have those blocks, we should be able to AST parse with less fear of
    # A syntax error preventing reading the data at all
    yaml_data = readStringFromSource(restofthecode, "__data__")
    if yaml_data is None:
        raise RuntimeError("Could not get code")

    data = yaml.load(
        yaml_data,
        Loader=yaml.SafeLoader,
    )
    data = snake_compat.snakify_dict_keys(data)

    data["trigger"] = readStringFromSource(restofthecode, "__trigger__")
    data["setup"] = setup.strip()
    data["action"] = action.strip()
    return data


class EventType(modules_state.ResourceType):
    def to_files(
        self,
        name: str,
        resource: dict[
            str,
            str
            | list
            | int
            | float
            | bool
            | dict[str, dict | list | int | float | str | bool | None]
            | None,
        ],
    ) -> dict[str, str]:
        name = name.split("/")[-1]
        return {f"{name}.py": toPyFile(resource)}

    def scan_dir(
        self, dir: str
    ) -> dict[
        str,
        dict[
            str,
            str
            | list
            | int
            | float
            | bool
            | dict[str, dict | list | int | float | str | bool | None]
            | None,
        ],
    ]:
        r = {}

        for i in os.listdir(dir):
            if i.split(".", 1)[-1] == "py":
                try:
                    r[i[:-3]] = rsc_from_py_file(os.path.join(dir, i))
                except Exception:
                    logger.exception("Error loading resource %s", i)
                    messagebus.post_message(
                        "/system/notifications/errors",
                        f"Error loading resource {i}",
                    )

        return r

    def blurb(self, module, resource, data):
        return render_jinja_template(
            os.path.join(
                os.path.dirname(__file__), "html", "event_blurb.j2.html"
            ),
            unitsofmeasure=unitsofmeasure,
            evt_obj=_events_by_module_resource[module, resource],
            lastran=getEventLastRan(module, resource),
            docstring=getEventInfo((module, resource)),
            resource_obj=event_resources[module, resource],
            get_next_run=get_next_run,
            module=module,
            resource=resource,
            time=time,
            getEventCompleted=getEventCompleted,
            round=round,
        )

    def on_finished_loading(self, module: str | None):
        with _event_list_lock:
            global init_done
            if module is None:
                init_done = True
                getEventsFromModules()
            elif init_done:
                getEventsFromModules(module)

    @beartype.beartype
    def on_load(
        self,
        module: str,
        resourcename: str,
        value: modules_state.ResourceDictType,
    ):
        global init_done
        with _event_list_lock:
            event_resources[module, resourcename] = copy.deepcopy(value)
            if init_done:
                updateOneEvent(module, resourcename)

    def on_move(self, module, resource, to_module, to_resource, resourceobj):
        with _event_list_lock:
            x = event_resources.pop((module, resource), None)
            if x:
                removeOneEvent(module, resource)
                event_resources[to_module, to_resource] = x
                updateOneEvent(to_module, to_resource)

    def on_update(self, module, resource, obj):
        self.on_load(module, resource, obj)

    def on_unload(self, module, name, value):
        del event_resources[module, name]
        removeOneEvent(module, name)

    def on_create_request(self, module, name, kwargs):
        d = {
            "resource_type": "event",
            "setup": "# This code runs once when the event loads.\n__doc__=''",
            "trigger": "False",
            "action": "pass",
            "once": True,
            "enable": True,
        }

        return d

    def on_update_request(self, module, resource, resourceobj, kwargs):
        with _event_list_lock:
            # Test compile, throw error on fail.

            actioncode = kwargs["action"].replace("\t", "    ")
            setupcode = kwargs["setup"].replace("\t", "    ")

            if "enable" in kwargs:
                try:
                    # Make a copy of the old resource object and modify it
                    r2 = resourceobj.copy()
                    r2["trigger"] = kwargs["trigger"]
                    r2["action"] = actioncode
                    r2["setup"] = setupcode
                    r2["priority"] = kwargs["priority"]
                    r2["continual"] = "continual" in kwargs
                    r2["rate_limit"] = float(kwargs["ratelimit"])
                    r2["enable"] = "enable" in kwargs

                    # Test for syntax errors at least, before we do anything more
                    test_compile(setupcode, actioncode)

                    # Remove the old event even before we even do a test run of setup.
                    # If we can't do the new version just put the old one back.
                    # Todo actually put old one back
                    removeOneEvent(module, resource)
                    # Leave a delay so that effects of cleanup can fully propagate.
                    time.sleep(0.08)
                    # Make event from resource, but use our substitute modified dict

                    # TODO was this just here for validation? Seems useless
                    # because everything happens in on_update...
                    # compiled_object = make_event_from_resource(
                    #     module, resource, r2
                    # )  # noqa

                except Exception:
                    messagebus.post_message(
                        "system/errors/misc/failedeventupdate",
                        f"In: {module} {resource}\n{traceback.format_exc(4)}",
                    )
                    raise
                return r2
            # Save but don't enable
            else:
                # Make a copy of the old resource object and modify it
                r2 = resourceobj.copy()
                r2["trigger"] = kwargs["trigger"]
                r2["action"] = actioncode
                r2["setup"] = setupcode
                r2["priority"] = kwargs["priority"]
                r2["continual"] = "continual" in kwargs
                r2["rate_limit"] = float(kwargs["ratelimit"])
                r2["enable"] = False

                # Remove the old event even before we do a test compile.
                # If we can't do the new version just put the old one back.
                removeOneEvent(module, resource)
                # Leave a delay so that effects of cleanup can fully propagate.
                time.sleep(0.08)

                return r2

    def create_page(self, module, path):
        d = SimpleDialog(f"New {self.type.capitalize()} in {module}")
        d.text_input("name")
        d.submit_button("Create")
        return d.render(self.get_create_target(module, path))

    def edit_page(self, module, resource, event):
        x = 0
        c = 0
        avg_time = 0
        if (module, resource) in _events_by_module_resource:
            for i in _events_by_module_resource[module, resource].history:
                x += i[1] - i[0]
                c += 1
            if c:
                avg_time = x / c

        priority = defaultdict(str)
        if "priority" in event:
            if event["priority"] in [
                "realtime",
                "interactive",
                "high",
                "medium",
                "low",
                "verylow",
            ]:
                priority[event["priority"]] = 'selected="selected"'
        else:
            priority["interactive"] = 'selected="selected"'
        try:
            timetaken = round(
                _events_by_module_resource[module, resource].timeTakenToLoad, 2
            )
            disabled = _events_by_module_resource[module, resource].disable
            prev = _events_by_module_resource[module, resource]._prevstate
        except Exception as e:
            print(e)
            disabled = True
            timetaken = -1
            prev = ""

        def formatnextrun():
            try:
                return unitsofmeasure.strftime(
                    dt_to_ts(
                        _events_by_module_resource[module, resource].nextruntime
                    )
                )
            except Exception as e:
                return str(e)

        return render_jinja_template(
            os.path.join(os.path.dirname(__file__), "html", "event.j2.html"),
            module=module,
            name=resource,
            event=event,
            priority=priority,
            url=util.url,
            format_time_interval=unitsofmeasure.format_time_interval,
            strftime=unitsofmeasure.strftime,
            can_edit=has_permission("system_admin"),
            EventReferences=_events_by_module_resource,
            resource_obj=_events_by_module_resource[module, resource],
            getEventLastRan=getEventLastRan,
            dt_to_ts=dt_to_ts,
            getEventErrors=getEventErrors,
            time=time,
            timetaken=timetaken,
            disabled=disabled,
            prevstate=prev,
            formatnextrun=formatnextrun,
            avg_time=avg_time,
            round=round,
        )


rt = EventType("event", mdi_icon="flag")
modules_state.resource_types["event"] = rt
