# ruff: noqa: D100, D101, D102, D103
from __future__ import annotations

import errno
import math
import threading
import time
from typing import TYPE_CHECKING, Literal, cast

import board
from tenacity import retry, retry_if_exception, stop_after_attempt, wait_fixed

from ubo_app.logger import logger
from ubo_app.store.services.audio import AudioDevice, AudioSetMuteStatusAction
from ubo_app.store.services.keypad import (
    Key,
    KeypadKeyHoldAction,
    KeypadKeyPressAction,
    KeypadKeyReleaseAction,
    KeypadKeyUnholdAction,
)
from ubo_app.utils import IS_RPI
from ubo_app.utils.eeprom import get_eeprom_data

if TYPE_CHECKING:
    import busio
    from adafruit_aw9523 import AW9523
    from adafruit_bus_device import i2c_device
    from adafruit_register.i2c_struct import UnaryStruct

INT_EXPANDER = 5  # GPIO PIN index that receives interrupt from AW9523

ButtonStatus = Literal['pressed', 'released']


class KeypadError(Exception): ...


KEY_INDEX = {
    0: Key.L1,
    1: Key.L2,
    2: Key.L3,
    3: Key.UP,
    4: Key.DOWN,
    5: Key.BACK,
    6: Key.HOME,
}
MIC_INDEX = 7
BUS_ADDRESS = 0x58


class Keypad:
    """Class to handle keypad events."""

    previous_inputs: int
    aw: AW9523 | None
    inputs: UnaryStruct

    def __init__(self: Keypad) -> None:
        """Initialize a Keypad.

        Initializes various parameters including
        loggers and button names.
        """
        self.logger = logger
        self.logger.info('Initialising keypad...')
        self.previous_inputs = 0
        self.aw = None
        self.held_buttons: set[int] = set()
        self.button_release_events: dict[int, threading.Event] = {
            index: threading.Event() for index in KEY_INDEX
        }
        self.init_i2c()

    @staticmethod
    def clear_interrupt_flags(i2c: i2c_device.I2CDevice) -> None:
        # Write to both registers to reset the interrupt flag
        buffer = bytearray(2)
        buffer[0] = 0x00
        buffer[1] = 0x00
        i2c.write(buffer)
        i2c.write_then_readinto(buffer, buffer, out_end=1, in_start=1)

        time.sleep(0.1)
        buffer[0] = 0x01
        buffer[1] = 0x00
        i2c.write(buffer)
        i2c.write_then_readinto(buffer, buffer, out_end=1, in_start=1)
        time.sleep(0.1)

    @staticmethod
    def disable_interrupt_for_higher_bits(i2c: i2c_device.I2CDevice) -> None:
        # disable interrupt for higher bits
        buffer = bytearray(2)
        buffer[0] = 0x06
        buffer[1] = 0x00
        i2c.write(buffer)
        i2c.write_then_readinto(buffer, buffer, out_end=1, in_start=1)

        buffer[0] = 0x07
        buffer[1] = 0xFF
        i2c.write(buffer)
        i2c.write_then_readinto(buffer, buffer, out_end=1, in_start=1)

    @retry(
        stop=stop_after_attempt(5),
        wait=wait_fixed(1),
        retry=retry_if_exception(
            lambda e: isinstance(e, OSError) and e.errno == errno.EIO,
        ),
    )
    def _initialize_i2c(
        self: Keypad,
        i2c: busio.I2C,
    ) -> tuple[AW9523, i2c_device.I2CDevice]:
        import adafruit_aw9523

        # Search for the GPIO expander address on the I2C bus
        aw = adafruit_aw9523.AW9523(i2c, BUS_ADDRESS)
        return aw, aw.i2c_device

    def init_i2c(self: Keypad) -> None:
        if not IS_RPI:
            return
        # Use GPIO to receive interrupt from the GPIO expander
        from gpiozero import Button

        i2c = board.I2C()
        # Set this to the GPIO of the interrupt:
        button = Button(INT_EXPANDER)

        try:
            self.aw, new_i2c = self._initialize_i2c(i2c)
        except Exception as e:
            self.logger.exception(
                'Failed to initialize I2C Bus on address',
                extra={'bus_address': BUS_ADDRESS},
            )
            msg = f'Failed to initialize I2C Bus on address {BUS_ADDRESS}'
            raise KeypadError(msg) from e

        # Perform soft reset of the expander
        self.aw.reset()
        # Set first 8 low significant bits (register 1) to input
        self.aw.directions = 0xFF00
        time.sleep(1)

        # The code below, accessing the GPIO expander registers directly via i2c
        # was created as a workaround of the reset the interrupt flag.
        self.clear_interrupt_flags(new_i2c)
        self.disable_interrupt_for_higher_bits(new_i2c)
        # reset interrupts again
        self.clear_interrupt_flags(new_i2c)

        # read register values
        inputs = cast('int', self.aw.inputs)
        self.logger.debug(
            'Initializing inputs',
            extra={'inputs': f'{inputs:016b}'},
        )
        self.previous_inputs = inputs
        time.sleep(0.3)

        # Interrupt callback when any button is pressed
        button.when_pressed = self.key_press_cb

        is_mic_active = inputs & 1 << MIC_INDEX != 0
        self.mute_button_event(
            status='released' if is_mic_active else 'pressed',
        )

        # This should always be the last line of this method
        self.clear_interrupt_flags(new_i2c)

    def start_button_press_lifecycle(self, index: int) -> None:
        if index in KEY_INDEX:
            from ubo_app.store.main import store

            store.dispatch(
                KeypadKeyPressAction(
                    key=KEY_INDEX[index],
                    held_keys={KEY_INDEX[i] for i in self.held_buttons},
                    pressed_keys={KEY_INDEX[i] for i in self.pressed_buttons},
                ),
            )

            if not self.button_release_events[index].wait(timeout=0.5):
                self.held_buttons.add(index)
                store.dispatch(
                    KeypadKeyHoldAction(
                        key=KEY_INDEX[index],
                        held_keys={KEY_INDEX[i] for i in self.held_buttons},
                        pressed_keys={KEY_INDEX[i] for i in self.pressed_buttons},
                    ),
                )
                self.button_release_events[index].wait()
                self.held_buttons.remove(index)
                store.dispatch(
                    KeypadKeyUnholdAction(
                        key=KEY_INDEX[index],
                        held_keys={KEY_INDEX[i] for i in self.held_buttons},
                        pressed_keys={KEY_INDEX[i] for i in self.pressed_buttons},
                    ),
                )

            store.dispatch(
                KeypadKeyReleaseAction(
                    key=KEY_INDEX[index],
                    held_keys={KEY_INDEX[i] for i in self.held_buttons},
                    pressed_keys={KEY_INDEX[i] for i in self.pressed_buttons},
                ),
            )

    def key_press_cb(self: Keypad, _: object) -> None:
        """Handle key press dispatched by GPIO interrupt.

            This is callback function that gets triggers
         if any change is detected on keypad buttons
         states.

         In this callback, we look at the state
         change to see which button was pressed or leased.

        Parameters
        ----------
        _channel: int
            GPIO channel that triggered the callback
            NOT USED currently

        """
        if self.aw is None:
            return
        # read register values
        inputs = cast('int', self.aw.inputs)
        # append the event to the queue. The queue has a depth of 2 and
        # keeps the current and last event.
        self.logger.debug('Current Inputs', extra={'inputs': f'{inputs:016b}'})
        self.logger.debug(
            'Previous Inputs',
            extra={'inputs': f'{self.previous_inputs:016b}'},
        )
        # XOR the last recorded input values with the current input values
        # to see which bits have changed. Technically there can only be one
        # bit change in every callback
        change_mask = self.previous_inputs ^ inputs
        self.logger.debug('Change', extra={'change_mask': f'{change_mask:016b}'})
        if change_mask == 0:
            return
        # use the change mask to see if the button was the change was
        # falling (1->0) indicating a pressed action
        # or risign (0->1) indicating a release action
        index = (int)(math.log2(change_mask))
        self.logger.info('button index', extra={'button_index': index})

        # Check for multiple button presses
        self.pressed_buttons = {
            i for i in range(8) if i in KEY_INDEX and inputs & 1 << i == 0
        }

        if index == MIC_INDEX:
            is_mic_active = inputs & 1 << MIC_INDEX != 0
            logger.info(
                'Mic status:',
                extra={'is_mic_active': is_mic_active},
            )
            self.mute_button_event(
                status='released' if is_mic_active else 'pressed',
            )

        if index in KEY_INDEX:
            # Check for rising edge or falling edge action (press or release)
            if (self.previous_inputs & change_mask) == 0:
                self.logger.info(
                    'Button released',
                    extra={
                        'button': str(index),
                        'pressed_buttons': self.pressed_buttons,
                    },
                )
                self.button_release_events[index].set()
            else:
                self.logger.info(
                    'Button pressed',
                    extra={
                        'button': str(index),
                        'pressed_buttons': self.pressed_buttons,
                    },
                )
                self.button_release_events[index].clear()
                threading.Thread(
                    target=self.start_button_press_lifecycle,
                    args=(index,),
                ).start()

        self.previous_inputs = inputs

    @staticmethod
    def mute_button_event(
        *,
        status: ButtonStatus,
    ) -> None:
        from ubo_app.store.main import store

        store.dispatch(
            AudioSetMuteStatusAction(
                device=AudioDevice.INPUT,
                is_mute=status == 'pressed',
            ),
        )


def init_service() -> None:
    if not IS_RPI:
        logger.debug('Not a Raspberry Pi.')
        return
    eeprom_data = get_eeprom_data()
    if (keypad := eeprom_data.get('keypad')) and keypad.get('model') == 'aw9523':
        logger.debug('Physical keypad found.')
        Keypad()
    else:
        logger.debug('Physical keypad not found.')
