"""This module contains all programs used for visualization (except complex screen programs)."""

from __future__ import annotations

import colorsys
import errno
import logging
import math
import os
import subprocess
from typing import Tuple, List, TYPE_CHECKING, cast, Optional

from django.conf import settings

if TYPE_CHECKING:
    from core.lights.lights import Lights


class VizProgram:
    """The base class for all programs."""

    def __init__(self, lights: "Lights") -> None:
        self.lights = lights
        self.consumers = 0
        self.name = "Unknown"

    def start(self) -> None:
        """Initializes the program, allocates resources."""

    def use(self) -> None:
        """Tells the program that it is used by another consumer.
        Starts the program if this is the first usage."""
        if self.consumers == 0:
            self.start()
        self.consumers += 1

    def stop(self) -> None:
        """Stops the program, releases resources."""

    def release(self) -> None:
        """Tells the program that one consumer does not use it anymore.
        Stops the program if this was the last one."""
        self.consumers -= 1
        if self.consumers == 0:
            self.stop()


class ScreenProgram(VizProgram):
    """The base class for all screen visualization programs."""

    def draw(self) -> None:
        """Called every frame. Updates the screen."""
        raise NotImplementedError()

    def increase_resolution(self) -> None:
        """Called if there is time in the loop to spare.
        Increases the system load, but also increases quality."""

    def decrease_resolution(self) -> None:
        """Called if rendering takes too long.
        Decreases the quality and speeds up the draw call."""


class LedProgram(VizProgram):
    """The base class for all led visualization programs."""

    def compute(self) -> None:
        """Is called once per update. Computation should happen here,
        so they can be reused in the returning functions"""

    def ring_colors(self) -> List[Tuple[float, float, float]]:
        """Returns the colors for the ring, one rgb tuple for each led."""
        raise NotImplementedError()

    def strip_color(self) -> Tuple[float, float, float]:
        """Returns the rgb values for the strip."""
        raise NotImplementedError()


class Disabled(LedProgram, ScreenProgram):
    """A null class to represent inactivity."""

    def __init__(self, lights: "Lights") -> None:
        super().__init__(lights)
        self.name = "Disabled"

    def draw(self) -> None:
        raise NotImplementedError()

    def ring_colors(self) -> List[Tuple[float, float, float]]:
        raise NotImplementedError()

    def strip_color(self) -> Tuple[float, float, float]:
        raise NotImplementedError()


class Fixed(LedProgram):
    """Show one fixed color only. The color is controlled in the lights module."""

    def __init__(self, lights: "Lights") -> None:
        super().__init__(lights)
        self.name = "Fixed"

    def compute(self) -> None:
        # show a red color if the alarm is active
        alarm_factor = self.lights.alarm_program.factor
        if alarm_factor != -1.0:
            self.lights.fixed_color = (alarm_factor, 0, 0)

    def ring_colors(self) -> List[Tuple[float, float, float]]:
        return [self.lights.fixed_color for _ in range(self.lights.ring.LED_COUNT)]

    def strip_color(self) -> Tuple[float, float, float]:
        return self.lights.fixed_color


class Rainbow(LedProgram):
    """Continuously cycles through all colors. Affected by the speed setting."""

    def __init__(self, lights: "Lights") -> None:
        super().__init__(lights)
        self.name = "Rainbow"
        self.program_duration = 1
        self.time_passed = 0.0
        self.current_fraction = 0.0

    def start(self) -> None:
        self.time_passed = 0.0

    def compute(self) -> None:
        self.time_passed += self.lights.seconds_per_frame * self.lights.program_speed
        self.time_passed %= self.program_duration
        self.current_fraction = self.time_passed / self.program_duration

    def ring_colors(self) -> List[Tuple[float, float, float]]:
        return [
            colorsys.hsv_to_rgb(
                (self.current_fraction + led / self.lights.ring.LED_COUNT) % 1, 1, 1
            )
            for led in range(self.lights.ring.LED_COUNT)
        ]

    def strip_color(self) -> Tuple[float, float, float]:
        return colorsys.hsv_to_rgb(self.current_fraction, 1, 1)


class Adaptive(LedProgram):
    """Dynamically reacts to the currently played music.
    Low frequencies are represented by red, high ones by blue."""

    def __init__(self, lights: "Lights") -> None:
        super().__init__(lights)
        self.name = "Rave"
        self.cava = self.lights.cava_program

        # RING
        # map the leds to rainbow colors from red over green to blue
        # (without pink-> hue values in [0, ⅔]
        # stretch the outer regions (red and blue) and compress the inner region (green)
        self.led_count = self.lights.ring.LED_COUNT
        hues = [
            (2 / 3)
            * 1
            / (1 + math.e ** (-4 * math.e * (led / (self.led_count - 1) - 0.5)))
            for led in range(0, self.led_count)
        ]
        self.base_colors = [colorsys.hsv_to_rgb(hue, 1, 1) for hue in hues]

        # STRIP
        # distribute frequencies over the three leds. Don't use hard cuts, but smooth functions
        # the functions add up to one at every point and each functions integral is a third
        self.red_coeffs = [
            -1 / (1 + math.e ** (-6 * math.e * (led / (self.led_count - 1) - 1 / 3)))
            + 1
            for led in range(0, self.led_count)
        ]
        self.blue_coeffs = [
            1 / (1 + math.e ** (-6 * math.e * (led / (self.led_count - 1) - 2 / 3)))
            for led in range(0, self.led_count)
        ]
        self.green_coeffs = [
            1 - self.red_coeffs[led] - self.blue_coeffs[led]
            for led in range(0, self.led_count)
        ]

        self.current_frame: List[float] = []

    def start(self) -> None:
        self.cava.use()

    def compute(self) -> None:
        # aggregate the length of cavas frame into a list the length of the number of leds we have.
        # This reduces computation time.
        values_per_led = len(self.cava.current_frame) // self.led_count
        self.current_frame = []
        for led in range(self.led_count):
            self.current_frame.append(
                sum(
                    self.cava.current_frame[
                        led * values_per_led : (led + 1) * values_per_led
                    ]
                )
                / values_per_led
            )

    def ring_colors(self) -> List[Tuple[float, float, float]]:
        colors = [
            tuple(factor * val for val in color)
            for factor, color in zip(self.current_frame, self.base_colors)
        ]
        # https://github.com/python/mypy/issues/5068
        return cast(List[Tuple[float, float, float]], colors)

    def strip_color(self) -> Tuple[float, float, float]:
        red = (
            sum(coeff * val for coeff, val in zip(self.red_coeffs, self.current_frame))
            * 3
            / self.led_count
        )
        green = (
            sum(
                coeff * val for coeff, val in zip(self.green_coeffs, self.current_frame)
            )
            * 3
            / self.led_count
        )
        blue = (
            sum(coeff * val for coeff, val in zip(self.blue_coeffs, self.current_frame))
            * 3
            / self.led_count
        )
        red = min(1, red)
        green = min(1, green)
        blue = min(1, blue)
        return red, green, blue

    def stop(self) -> None:
        self.cava.release()


class Alarm(VizProgram):
    """This program makes the leds flash red in sync to the played sound.
    Only computes the brightness, does not display it."""

    def __init__(self, lights: "Lights") -> None:
        super().__init__(lights)
        self.name = "Alarm"
        self.time_passed = 0.0
        self.sound_count = 0
        self.increasing_duration = 0.45
        self.decreasing_duration = 0.8
        self.sound_duration = 2.1
        self.sound_repetition = 2.5
        self.factor = -1.0

    def start(self) -> None:
        self.time_passed = 0.0
        self.sound_count = 0
        self.factor = 0

    def compute(self) -> None:
        """If active, compute the brightness for the red color,
        depending on the time that has passed since starting the sound."""
        # do not compute if the alarm is not active
        if self.consumers == 0:
            return
        self.time_passed += self.lights.seconds_per_frame
        if self.time_passed >= self.sound_repetition:
            self.sound_count += 1
            self.time_passed %= self.sound_repetition

        if self.sound_count >= 4:
            self.factor = 0
            return
        if self.time_passed < self.increasing_duration:
            self.factor = self.time_passed / self.increasing_duration
        elif self.time_passed < self.sound_duration - self.decreasing_duration:
            self.factor = 1
        elif self.time_passed < self.sound_duration:
            self.factor = (
                1
                - (self.time_passed - (self.sound_duration - self.decreasing_duration))
                / self.decreasing_duration
            )
        else:
            self.factor = 0

    def stop(self) -> None:
        self.factor = -1.0


class Cava(VizProgram):
    """This Program manages the interaction with cava.
    It provides the current frequencies for other programs to use."""

    def __init__(self, lights: "Lights") -> None:
        super().__init__(lights)

        self.cava_fifo_path = os.path.join(settings.BASE_DIR, "config/cava_fifo")

        # Keep these configurations in sync with config/cava.config
        self.bars = 199
        self.bit_format = 8

        self.frame_length = self.bars * (self.bit_format // 8)

        self.current_frame: List[float] = []
        self.growing_frame = b""
        self.cava_process: Optional[subprocess.Popen[bytes]] = None
        self.cava_fifo = -1

    def start(self) -> None:
        self.current_frame = [0 for _ in range(self.bars)]
        self.growing_frame = b""
        try:
            # delete old contents of the pipe
            os.remove(self.cava_fifo_path)
        except FileNotFoundError:
            # the file does not exist
            pass
        try:
            os.mkfifo(self.cava_fifo_path)
        except FileExistsError:
            # the file already exists
            logging.info("%s already exists while starting", self.cava_fifo_path)

        self.cava_process = subprocess.Popen(
            ["cava", "-p", os.path.join(settings.BASE_DIR, "config/cava.config")],
            cwd=settings.BASE_DIR,
        )
        # cava_fifo = open(cava_fifo_path, 'r')
        self.cava_fifo = os.open(self.cava_fifo_path, os.O_RDONLY | os.O_NONBLOCK)

    def compute(self) -> None:
        """If active, read output from the cava program.
        Make sure that the most recent frame is always fully available,
        Stores incomplete frames for the next update."""
        # do not compute if no program uses cava
        if self.consumers == 0:
            return
        # read the fifo until we get to the current frame
        while True:
            try:
                read = os.read(
                    self.cava_fifo, self.frame_length - len(self.growing_frame)
                )
                if read == b"":
                    return
                self.growing_frame += read
            except OSError as e:
                if e.errno == errno.EAGAIN or e.errno == errno.EWOULDBLOCK:
                    # there were not enough bytes for a whole frame, keep the old frame
                    return

            # we read a whole frame, update the factors
            if len(self.growing_frame) == self.frame_length:
                # vol = max(0.01, self.lights.base.musiq.player.volume)
                # self.current_frame = [int(b) / 255 / vol for b in self.growing_frame]
                self.current_frame = [int(b) / 255 for b in self.growing_frame]
                self.growing_frame = b""

    def stop(self) -> None:
        try:
            os.close(self.cava_fifo)
        except OSError as e:
            logging.info("fifo already closed: %s", e)
        except TypeError as e:
            logging.info("fifo does not exist: %s", e)

        if self.cava_process:
            self.cava_process.terminate()

        try:
            os.remove(self.cava_fifo_path)
        except FileNotFoundError as e:
            # the file was already deleted
            logging.info("%s not found while deleting: %s", self.cava_fifo_path, e)