#!/usr/bin/python3
#
# Polychromatic is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Polychromatic is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Polychromatic. If not, see <http://www.gnu.org/licenses/>.
#
# Copyright (C) 2020-2024 Luke Horwell <code@horwell.me>
#

"""
A multipurpose "helper" process that runs in the background to control separate
operations of Polychromatic, such as:

- Playing a "software" effect for a particular device.
- Autostart the tray applet and resume previous software settings.
- Monitor and process automatic rules (triggers).

The helper intends to be as lightweight and minimal as possible as there could
be multple helper processes running simultaneously. It also is designed to be
'terminated' without fuss as Polychromatic will record and validate PIDs to
track alive or dead processes.
"""
import argparse
import glob
import setproctitle
import os
import json
import time
import importlib
import signal
import sys

from polychromatic.base import PolychromaticBase
import polychromatic.common as common
import polychromatic.effects as effects
import polychromatic.preferences as preferences
import polychromatic.procpid as procpid

VERSION = "0.8.8"


class PolychromaticHelper(PolychromaticBase):
    """
    Processes the request from the command line and summons the relevant
    process or code within the helper.
    """
    def __init__(self):
        self.init_base(__file__, sys.argv)
        self.args = self._parse_parameters()
        self.preferences = preferences.load_file(self.paths.preferences)
        self.middleman.init()

    def start(self):
        if self.args.autostart:
            self.autostart()

        elif self.args.monitor_triggers:
            self.monitor_triggers()

        elif self.args.run_fx and self.args.device_serial or self.args.device_name:
            self.run_fx(self.args.run_fx, self.args.device_name, self.args.device_serial)

        else:
            self.dbg.stdout("This executable is intended to be invoked by another Polychromatic process.", self.dbg.warning)
            sys.exit(1)

    def _parse_parameters(self):
        """
        Parse the parameters of what this helper has been summoned to do.
        Intended to be inputed by a computer, not a human (except verbose/version)
        """
        parser = argparse.ArgumentParser(add_help=False)
        parser.add_argument("-v", "--verbose", action="store_true")
        parser.add_argument("--version", action="store_true")

        # Operations
        parser.add_argument("--autostart", action="store_true")
        parser.add_argument("--monitor-triggers", action="store_true")
        parser.add_argument("--run-fx", action="store")

        # Custom effects only
        parser.add_argument("-n", "--device-name", action="store")
        parser.add_argument("-s", "--device-serial", action="store")

        args = parser.parse_args()

        if args.version:
            app_version, git_commit, py_version = common.get_versions(VERSION)
            print("Polychromatic", app_version)
            if git_commit:
                print("Commit:", git_commit)
            print("Python:", py_version)
            sys.exit(0)

        if args.verbose:
            self.dbg.verbose_level = 1
            self.dbg.stdout("Verbose enabled", self.dbg.action, 1)

        try:
            if os.environ["POLYCHROMATIC_DEV_CFG"] == "true":
                self.dbg.verbose_level = 1
                self.dbg.stdout("Verbose enabled (development mode)", self.dbg.action, 1)
        except KeyError:
            pass

        return args

    def autostart(self):
        """
        Run once when the user's session starts. This will make sure the backends
        have started before spawning additional processes:

        - If enabled, start the tray applet.
        - If a 'login' preset is set, activate that.
            - If not, apply any devices previously in an software effect state.
        - If triggers are set up, start a process to monitor processes.

        This instance will exit as soon as the checks have completed.
        """
        # If backend(s) haven't initialised already, wait for them.
        timeout = 10
        while len(self.middleman.backends) == 0 and timeout > 0:
            self.dbg.stdout("Still waiting for backends to be ready...", self.dbg.warning, 1)
            time.sleep(2)
            timeout = timeout - 1
            self.middleman.init()

        if len(self.middleman.backends) == 0:
            self.dbg.stdout("Timed out waiting for backends to load, or they are unavailable.", self.dbg.error)

        # Determine what to do for devices upon login.
        # TODO: Refactor into functions
        login_trigger_set = False
        state_files = glob.glob(os.path.join(self.paths.states, "*.json"))

        # -- Clear the preset states
        #    There is no guarantee the hardware matched the previous preset
        for device_json in state_files:
            serial = os.path.basename(device_json).replace(".json", "")
            state = procpid.DeviceSoftwareState(serial)
            state.clear_preset()

        # -- Activate the login preset (if enabled)
        if login_trigger_set:
            self.dbg.stdout("Activating login trigger...", self.dbg.action, 1)
            print("stub:login trigger")

        # -- Resume effect states (if any)
        if not login_trigger_set:
            procmgr = procpid.ProcessManager("helper")
            for device_json in state_files:
                serial = os.path.basename(device_json).replace(".json", "")
                state = procpid.DeviceSoftwareState(serial)
                effect = state.get_effect()
                if effect:
                    self.dbg.stdout("Resuming effect '{0}' on device serial '{1}'.".format(effect["name"], serial), self.dbg.action, 1)
                    procmgr.start_component(["--run-fx", effect["path"], "--device-serial", serial])

        # Start Tray Applet
        if self.preferences["tray"]["autostart"]:
            delay = self.preferences["tray"]["autostart_delay"]
            process = procpid.ProcessManager("tray-applet")

            if process.is_component_installed("tray-applet"):
                if delay > 0:
                    self.dbg.stdout("Starting tray applet in {0} second(s)...".format(delay), self.dbg.action, 1)
                    time.sleep(delay)
                process.start_component()
            else:
                self.dbg.stdout("Tray applet not installed. Skipping.", self.dbg.warning, 1)

    def monitor_triggers(self):
        """
        Triggers may monitor different entities (e.g. time, a file or event)
        and this process will switch to a different preset when the conditions
        match.

        This process should always be running when there is at least one trigger set.
        """
        print("stub:Helpers.monitor_triggers")

    def run_fx(self, path, name, serial):
        """
        Playback a custom effect by sending frames to the specified device
        (either by device serial or name, the latter takes priority)

        This process should be running until the custom effect reaches the end,
        or if it's looped, indefinity until interrupted.
        """
        # Load device
        if serial:
            device = self.middleman.get_device_by_serial(serial)
            if not device:
                self.dbg.stdout(f"{serial}: Device not found. Cannot play effect!", self.dbg.error)
                sys.exit(1)
        else:
            device = self.middleman.get_device_by_name(name)
            if not device:
                self.dbg.stdout(f"{name}: Device not found. Cannot play effect!", self.dbg.error)
                sys.exit(1)
            serial = device.serial

        if not device.matrix:
            self.dbg.stdout(f"{device.name}: Custom effects unsupported!", self.dbg.error)
            sys.exit(1)

        # Prepare objects (PID, state and effect data)
        process = procpid.ProcessManager(serial)
        state = procpid.DeviceSoftwareState(serial)
        filemgr = effects.EffectFileManagement()

        effect_data = filemgr.get_item(path)
        if isinstance(effect_data, int):
            self.dbg.stdout(f"{device.name}: Skipping unreadable effect file. Perhaps file renamed?", self.dbg.warning)
            state.clear_effect()
            return
        effect_name = effect_data["parsed"]["name"]
        effect_icon = effect_data["parsed"]["icon"]
        effect_type = effect_data["type"]

        # Update PID assignment
        process.set_component_pid()
        state.set_effect(effect_name, effect_icon, path)

        self.dbg.stdout(f"{device.name}: Starting playback: {effect_name}", self.dbg.success, 1)
        playback = EffectPlayback(device, device.matrix, effect_data)

        if effect_type == effects.TYPE_LAYERED:
            raise NotImplementedError()
        elif effect_type == effects.TYPE_SCRIPTED:
            raise NotImplementedError()
        elif effect_type == effects.TYPE_SEQUENCE:
            playback.play_sequence()
        else:
            self.dbg.stdout("Unknown effect type!", self.dbg.error)
            sys.exit(1)


class EffectPlayback(PolychromaticBase):
    """
    Handles the background processing of Polychromatic's formats of effects.

    - 'Sequence' is a very simple series of frames.
    - 'Scripted' have control over the fx object for more complex processing.
    - 'Layered' is a computed set of scripted effects processed on a layer basis.
    """
    def __init__(self, device, matrix, data):
        """
        Params:
            device      Backend.DeviceItem() object
            matrix      Backend.DeviceItem.Matrix() object
            data        Contents of the effect's JSON to run.
        """
        self.device = device
        self.matrix = matrix
        self.data = data

    def play_sequence(self):
        frames = self.data["frames"]
        total_frames = len(frames) - 1
        looped = self.data["loop"]
        fps = self.data["fps"]
        current = -1

        # Showtime!
        while True:
            current = current + 1
            frame = frames[current]
            self.matrix.clear()

            for x in frame.keys():
                for y in frame[x].keys():
                    try:
                        rgb = common.hex_to_rgb(frame[str(x)][str(y)])
                        self.matrix.set(int(x), int(y), rgb[0], rgb[1], rgb[2])
                    except KeyError:
                        # Expected, no data stored for this position
                        continue

            self.matrix.draw()
            time.sleep(1 / fps)

            if current == total_frames:
                if looped:
                    current = -1
                else:
                    sys.exit(0)


if __name__ == "__main__":
    # Appear as its own process.
    setproctitle.setproctitle("polychromatic-helper")

    # CTRL+C to terminate the process
    signal.signal(signal.SIGINT, signal.SIG_DFL)

    helper = PolychromaticHelper()
    helper.start()
