"""Contains the SpeechToKeys class."""

import logging
from datetime import datetime, timedelta, timezone
from queue import Empty, Queue
from threading import Thread
from time import sleep
from typing import Optional

import numpy as np
import torch
import whisper
from pynput.keyboard import Controller as KeyboardController
from pynput.keyboard import Key
from pynput.keyboard import Listener as KeyboardListener
from pynput.mouse import Listener as MouseListener

from .audio_capture import AudioCapture


# pylint: disable=too-many-instance-attributes
class SpeechToKeys:
    """
    Class to convert speech to keyboard input.
    """

    # pylint: disable=too-many-arguments,too-many-positional-arguments
    def __init__(
        self,
        model_name: str = "turbo",
        device: Optional[str | int] = None,
        ambient_duration: float = 1.0,
        activity_ambient_multiplier: float = 1.5,
        phrase_timeout: float = 3.0,
    ):
        self.phrase_timeout = timedelta(seconds=phrase_timeout)
        self.data_queue = Queue[np.ndarray]()
        self.float_samples: np.ndarray = np.array([], dtype=np.float32)
        self.phrase_time = None
        self.buffer = ""
        self.is_first_phrase = True
        self.dictation_active = False
        self.keyboard = KeyboardController()
        self._keyboard_listener = None
        self._mouse_listener = None
        self._is_programmatic_typing = False

        self.audio_capture = AudioCapture(
            on_audio=self._on_audio,
            device=device,
            ambient_duration=ambient_duration,
            activity_ambient_multiplier=activity_ambient_multiplier,
        )

        logging.info("Loading Whisper model: %s", model_name)
        self.audio_model = whisper.load_model(model_name)
        logging.info("Whisper model loaded successfully.")

        audio_thread = Thread(
            target=self._process_audio, daemon=True, name="AudioThread"
        )
        audio_thread.start()
        logging.info("Audio processing thread started.")

    @staticmethod
    def query_devices():
        """
        Lists the available input audio devices using sounddevice.
        """
        print(AudioCapture.query_devices())

    def shutdown(self):
        """
        Shuts down the speech to keys.
        """
        self.enabled = False

        while not self.data_queue.empty():
            try:
                self.data_queue.get_nowait()
            except Empty:
                break
        logging.info("SpeechToKeys shutdown complete.")

    def _reset(self):
        self.float_samples = np.array([], dtype=np.float32)
        self.phrase_time = None
        self.buffer = ""
        self.is_first_phrase = True
        while not self.data_queue.empty():
            try:
                self.data_queue.get_nowait()
            except Empty:
                break

    def _on_audio(self, audio: np.ndarray) -> None:
        """
        Threaded callback function to receive audio data when recordings finish.
        audio: An AudioData containing the recorded bytes.
        """
        if self.dictation_active:
            logging.debug("Recording callback received and dictation is active.")
            self.data_queue.put(audio)
        else:
            logging.warning("Recording callback received but dictation is not active.")

    def _on_press(self, key):
        """Callback for keyboard listener."""
        if self._is_programmatic_typing:
            return  # Ignore key presses generated by the program itself

        # We don't need to know which key, just that a press occurred.
        logging.debug("User key press detected. %s", key)
        self._reset()

    def _on_click(self, x, y, button, _pressed):
        """Callback for mouse listener."""
        # We don't need to know which button, just that a click occurred.
        logging.debug("User mouse click detected. x=%s, y=%s, button=%s", x, y, button)
        self._reset()

    def _start_keyboard_listener(self):
        if self._keyboard_listener is None:
            logging.info("Starting keyboard listener.")
            self._keyboard_listener = KeyboardListener(on_press=self._on_press)
            self._keyboard_listener.start()
            logging.info("Keyboard listener started.")
        else:
            logging.info("Keyboard listener already running.")

    def _stop_keyboard_listener(self):
        if self._keyboard_listener is not None:
            logging.info("Stopping keyboard listener.")
            self._keyboard_listener.stop()
            self._keyboard_listener = None
            logging.info("Keyboard listener stopped.")
        else:
            logging.info("Keyboard listener not running or already stopped.")

    def _start_mouse_listener(self):
        if self._mouse_listener is None:
            logging.info("Starting mouse listener.")
            self._mouse_listener = MouseListener(on_click=self._on_click)
            self._mouse_listener.start()
            logging.info("Mouse listener started.")
        else:
            logging.info("Mouse listener already running.")

    def _stop_mouse_listener(self):
        if self._mouse_listener is not None:
            logging.info("Stopping mouse listener.")
            self._mouse_listener.stop()
            self._mouse_listener = None
            logging.info("Mouse listener stopped.")
        else:
            logging.info("Mouse listener not running or already stopped.")

    def _process_audio(self):
        """Processes audio from the queue and performs transcription."""
        while True:
            if not self.dictation_active:
                logging.debug("_process_audio running but dictation is not active.")
                sleep(0.1)
                continue

            now = datetime.now(timezone.utc)
            if not self.data_queue.empty():
                logging.debug("Processing audio from queue.")
                previous_phrase_done = False
                if (
                    self.phrase_time is not None
                    and now - self.phrase_time > self.phrase_timeout
                ):
                    self.float_samples = np.array([], dtype=np.float32)
                    previous_phrase_done = True
                    logging.info("The previous phrase ended")
                self.phrase_time = now

                temp = []
                while not self.data_queue.empty():
                    try:
                        temp.append(self.data_queue.get_nowait())
                    except Empty:
                        break

                new_samples = np.concatenate(temp).astype(np.float32) / 32768.0
                new_samples = new_samples.flatten()
                self.float_samples = np.concatenate((self.float_samples, new_samples))

                self._transcribe(previous_phrase_done, self.float_samples)
            else:
                sleep(0.1)

    def _transcribe(self, previous_phrase_done, audio_data):
        logging.debug("Transcribing audio.")
        try:
            result = self.audio_model.transcribe(
                audio_data, fp16=torch.cuda.is_available()
            )
            text = result["text"]
            if self.is_first_phrase:
                # There was no previous phrase, so remove any leading whitespace that
                # the transcription might have added.
                text = text.lstrip()
            logging.info("Transcribed text: '%s'", text)

            if text:
                self._is_programmatic_typing = True
                if previous_phrase_done:
                    # Sometimes the transciption misses ending punctuation if it had
                    # thought more words would come, but did not.
                    if self.buffer and self.buffer.rstrip()[-1] not in [".", "!", "?"]:
                        self.keyboard.type(".")
                    self.keyboard.type(text)
                    self.buffer = text
                    self.is_first_phrase = False
                else:
                    if self.buffer:
                        # find the first index where the text and buffer differ
                        index = next(
                            (
                                i
                                for i, (t, b) in enumerate(zip(text, self.buffer))
                                if t != b
                            ),
                            len(self.buffer),
                        )
                        for _ in range(index, len(self.buffer)):
                            self.keyboard.press(Key.backspace)
                            # Web pages sometimes struggle with fast keystrokes.
                            sleep(0.001)
                            self.keyboard.release(Key.backspace)
                            sleep(0.001)
                    else:
                        index = 0

                    for i in range(index, len(text)):
                        self.keyboard.type(text[i])
                    self.buffer = text
        finally:
            self._is_programmatic_typing = False

    @property
    def enabled(self) -> bool:
        """
        Returns the enabled state of the speech to keys.
        """
        return self.dictation_active

    @enabled.setter
    def enabled(self, value: bool):
        if value == self.dictation_active:
            logging.info(
                "SpeechToKeys.enabled changing from %s to %s (no change)",
                self.dictation_active,
                value,
            )
            return

        logging.info(
            "SpeechToKeys.enabled changing from %s to %s",
            self.dictation_active,
            value,
        )

        if value:
            self._start_keyboard_listener()
            self._start_mouse_listener()

            self._reset()
            self.dictation_active = True

            try:
                self.audio_capture.start()
                logging.info("Background audio listener started.")
            except (OSError, AttributeError, RuntimeError) as e:
                logging.error(
                    "Error starting background audio listener: %s", e, exc_info=True
                )
                self._stop_keyboard_listener()
                self._stop_mouse_listener()
                return

        else:
            self.dictation_active = False  # Set this first
            self._stop_keyboard_listener()  # Stop keyboard listener
            self._stop_mouse_listener()  # Stop mouse listener
            self.audio_capture.stop()

            self._reset()
