"""
Gather information about audio devices.
"""
import multiprocessing
import sounddevice as sd
import structlog
import numpy as np
import queue
import helpers
import time

log = structlog.get_logger("audio")


def get_audio_devices():
    """
    return list of input and output audio devices in own process to avoid crashes of portaudio on raspberry pi

    also uses a process data manager
    """
    # we need to run this on Windows for multiprocessing support
    # multiprocessing.freeze_support()
    # multiprocessing.get_context("spawn")

    # we need to reset and initialize sounddevice before running the multiprocessing part.
    # If we are not doing this at this early point, not all devices will be displayed
    #sd._terminate()
    #sd._initialize()

    # log.debug("[AUD] get_audio_devices")
    with multiprocessing.Manager() as manager:
        proxy_input_devices = manager.list()
        proxy_output_devices = manager.list()
        # print(multiprocessing.get_start_method())
        proc = multiprocessing.Process(
            target=fetch_audio_devices, args=(proxy_input_devices, proxy_output_devices)
        )
        proc.start()
        proc.join(3)

        # additional logging for audio devices
        # log.debug("[AUD] get_audio_devices: input_devices:", list=f"{proxy_input_devices}")
        # log.debug("[AUD] get_audio_devices: output_devices:", list=f"{proxy_output_devices}")
        return list(proxy_input_devices), list(proxy_output_devices)


def device_crc(device) -> str:
    """Generates a CRC16 checksum for an audio device.

    This function creates a unique identifier for an audio device based on its
    name and host API. It uses a CRC16 checksum to generate a hexadecimal
    representation of the combined device name and host API.

    Args:
        device (dict): A dictionary containing the device 'name' and 'hostapi'.

    Returns:
        str: The hexadecimal representation of the CRC16 checksum.
    """
    crc_hwid = helpers.get_crc_16(bytes(f"{device['name']}.{device['hostapi']}", encoding="utf-8"))
    crc_hwid = crc_hwid.hex()
    return crc_hwid

def fetch_audio_devices(input_devices, output_devices):
    """
    get audio devices from portaudio

    Args:
      input_devices: proxy variable for input devices
      output_devices: proxy variable for output devices

    Returns:

    """
    devices = sd.query_devices(device=None, kind=None)

    for index, device in enumerate(devices):
        # Use a try/except block because Windows doesn't have an audio device range
        try:
            name = device["name"]
            # Ignore some Flex Radio devices to make device selection simpler
            if name.startswith("DAX RESERVED") or name.startswith("DAX IQ"):
                continue

            max_output_channels = device["max_output_channels"]
            max_input_channels = device["max_input_channels"]

        except KeyError:
            continue
        except Exception as err:
            print(err)
            max_input_channels = 0
            max_output_channels = 0

        if max_input_channels > 0:
            hostapi_name = sd.query_hostapis(device['hostapi'])['name']

            new_input_device = {"id": device_crc(device), 
                                "name": device['name'], 
                                "api": hostapi_name,
                                "native_index":index}
            # check if device not in device list
            if new_input_device not in input_devices:
                input_devices.append(new_input_device)

        if max_output_channels > 0:
            hostapi_name = sd.query_hostapis(device['hostapi'])['name']
            new_output_device = {"id": device_crc(device), 
                                 "name": device['name'], 
                                 "api": hostapi_name,
                                 "native_index":index}
            # check if device not in device list
            if new_output_device not in output_devices:
                output_devices.append(new_output_device)

    return input_devices, output_devices


def get_device_index_from_crc(crc, isInput: bool):
    """Retrieves the native device index and name from the provided CRC.

    This function searches for an audio device with the given CRC and returns
    its native index and name. It handles both input and output devices based
    on the isInput flag.

    Args:
        crc (str): The CRC of the audio device.
        isInput (bool): True if searching for an input device, False for an output device.

    Returns:
        tuple: A tuple containing the native device index and name, or (None, None) if not found.
    """
    try:
        in_devices = []
        out_devices = []

        fetch_audio_devices(in_devices, out_devices)

        if isInput:
            detected_devices = in_devices
        else:
            detected_devices = out_devices

        for i, dev in enumerate(detected_devices):
            if dev['id'] == crc:
                return (dev['native_index'], dev['name'])

    except Exception as e:
        log.warning(f"Audio device {crc} not detected ", devices=detected_devices, isInput=isInput)
        return [None, None]

def test_audio_devices(input_id: str, output_id: str) -> list:
    """Tests the specified input and output audio devices.

    This function checks the validity and settings of the given input and
    output audio devices. It uses the device CRC to identify the devices
    and attempts to check their settings using sounddevice.

    Args:
        input_id (str): The CRC of the input audio device.
        output_id (str): The CRC of the output audio device.

    Returns:
        list: A list of booleans indicating the test results for input and output devices, respectively.
    """
    test_result = [False, False]
    try:
        result = get_device_index_from_crc(input_id, True)
        if result is None:
            # in_dev_index, in_dev_name = None, None
            raise ValueError(f"[Audio-Test] Invalid input device index {input_id}.")
        else:
            in_dev_index, in_dev_name = result
            sd.check_input_settings(
                device=in_dev_index,
                channels=1,
                dtype="int16",
                samplerate=48000,
            )
            test_result[0] = True
    except (sd.PortAudioError, ValueError) as e:
        log.warning(f"[Audio-Test] Input device error ({input_id}):", e=e)
        test_result[0] = False
    try:
        result = get_device_index_from_crc(output_id, False)
        if result is None:
            # out_dev_index, out_dev_name = None, None
            raise ValueError(f"[Audio-Test] Invalid output device index {output_id}.")
        else:
            out_dev_index, out_dev_name = result
            sd.check_output_settings(
                device=out_dev_index,
                channels=1,
                dtype="int16",
                samplerate=48000,
            )
            test_result[1] = True


    except (sd.PortAudioError, ValueError) as e:
        log.warning(f"[Audio-Test] Output device error ({output_id}):", e=e)
        test_result[1] = False

    sd._terminate()
    sd._initialize()
    return test_result

def set_audio_volume(datalist: np.ndarray, dB: float) -> np.ndarray:
    """
    Scale values for the provided audio samples by dB.

    :param datalist: Audio samples to scale
    :type datalist: np.ndarray
    :param dB: Decibels to scale samples, constrained to the range [-50, 50]
    :type dB: float
    :return: Scaled audio samples
    :rtype: np.ndarray
    """
    try:
        dB = float(dB)
    except ValueError as e:
        print(f"[MDM] Changing audio volume failed with error: {e}")
        dB = 0.0  # 0 dB means no change

    # Clip dB value to the range [-50, 50]
    dB = np.clip(dB, -30, 20)

    # Ensure datalist is an np.ndarray
    if not isinstance(datalist, np.ndarray):
        print("[MDM] Invalid data type for datalist. Expected np.ndarray.")
        return datalist

    # Convert dB to linear scale
    scale_factor = 10 ** (dB / 20)

    # Scale samples
    scaled_data = datalist * scale_factor

    # Clip values to int16 range and convert data type
    return np.clip(scaled_data, -32768, 32767).astype(np.int16)


def normalize_audio(datalist: np.ndarray) -> np.ndarray:
    """
    Normalize the audio samples so the loudest value reaches 95% of the maximum possible value for np.int16
    :param datalist: Audio samples to normalize
    :type datalist: np.ndarray
    :return: Normalized audio samples, clipped to the range of int16
    :rtype: np.ndarray
    """
    if not isinstance(datalist, np.ndarray):
        #print("[MDM] Invalid datalist type. Expected np.ndarray.")
        return datalist

    # Ensure datalist is not empty
    if datalist.size == 0:
        #print("[MDM] Datalist is empty. Returning unmodified.")
        return datalist

    # Find the maximum absolute value in the data
    max_value = np.max(np.abs(datalist))

    # If max_value is 0, return the datalist (avoid division by zero)
    if max_value == 0:
        #print("[MDM] Max value is zero. Cannot normalize. Returning unmodified.")
        return datalist

    # Define the target max value as 95% of the maximum for np.int16
    target_max_value = int(32767 * 0.85)

    # Compute the normalization factor
    normalization_factor = target_max_value / max_value

    # Normalize the audio data
    normalized_data = datalist * normalization_factor

    # Clip to the int16 range and cast
    normalized_data = np.clip(normalized_data, -32768, 32767).astype(np.int16)

    # Debug information: normalization factor, loudest value before, and after normalization
    loudest_before = max_value
    loudest_after = np.max(np.abs(normalized_data))
    # print(f"[AUDIO] Normalization factor: {normalization_factor:.6f}, Loudest before: {loudest_before}, Loudest after: {loudest_after}")

    return normalized_data


# Global variables to manage channel status
CHANNEL_BUSY_DELAY = 0  # Counter for channel busy delay
SLOT_DELAY = [0] * 5  # Counters for delays in each slot

# Constants for delay logic
DELAY_INCREMENT = 2  # Amount to increase delay
MAX_DELAY = 200  # Maximum allowable delay

# Predefined frequency ranges (slots) for FFT analysis
# These ranges are based on an FFT length of 800 samples
SLOT_RANGES = [
    (0, 65),  # Slot 1: Frequency range from 0 to 65
    (65, 120),  # Slot 2: Frequency range from 65 to 120
    (120, 176),  # Slot 3: Frequency range from 120 to 176
    (176, 231),  # Slot 4: Frequency range from 176 to 231
    (231, 315)  # Slot 5: Frequency range from 231 to 315
]

# Initialize a queue to store FFT results for visualization
fft_queue = queue.Queue()

# Variable to track the time of the last RMS calculation
last_rms_time = 0

def prepare_data_for_fft(data, target_length_samples=800):
    """
    Prepare the input data for FFT by ensuring it meets the required length.

    Parameters:
    - data: numpy.ndarray of type np.int16, representing the audio data.
    - target_length_samples: int, the desired length of the data in samples.

    Returns:
    - numpy.ndarray of type np.int16 with a length of target_length_samples.
    """
    # Check if the input data type is np.int16
    if data.dtype != np.int16:
        raise ValueError("Audio data must be of type np.int16")

    # If data is shorter than the target length, pad with zeros
    if len(data) < target_length_samples:
        return np.pad(data, (0, target_length_samples - len(data)), 'constant', constant_values=(0,))
    else:
        # If data is longer or equal to the target length, truncate it
        return data[:target_length_samples]

def calculate_rms_dbfs(data):
    """
    Calculate the Root Mean Square (RMS) value of the audio data and
    convert it to dBFS (decibels relative to full scale).

    Parameters:
    - data: numpy.ndarray of type np.int16, representing the audio data.

    Returns:
    - float: RMS value in dBFS. Returns -100 if the RMS value is 0.
    """
    # Compute the RMS value using int32 to prevent overflow
    rms = np.sqrt(np.mean(np.square(data, dtype=np.int32), dtype=np.float64))
    # Convert the RMS value to dBFS
    return 20 * np.log10(rms / 32768) if rms > 0 else -100


def calculate_fft(data, fft_queue, states) -> None:
    """Calculates the FFT of audio data and updates channel busy status.

    This function performs FFT on the provided audio data, identifies
    significant frequency components, and updates the channel busy status
    based on activity within predefined frequency slots. It also calculates
    and updates the RMS level of the audio data.

    Args:
        data (np.ndarray): The audio data as a NumPy array.
        fft_queue (queue.Queue): A queue to store FFT results for visualization.
        states (StateManager): The state manager object to update channel busy status.
    """
    global CHANNEL_BUSY_DELAY, last_rms_time

    try:
        # Prepare the data for FFT processing by ensuring it meets the target length
        data = prepare_data_for_fft(data)

        # Compute the real FFT of the audio data
        fftarray = np.fft.rfft(data)

        # Calculate the amplitude spectrum in decibels (dB)
        dfft = 10.0 * np.log10(np.abs(fftarray) + 1e-12)  # Adding a small constant to avoid log(0)

        # Compute the average amplitude of the spectrum
        avg_amplitude = np.mean(dfft)

        # Set the threshold for significant frequency components; adjust the offset as needed
        threshold = avg_amplitude + 13

        # Identify frequency components that exceed the threshold
        significant_frequencies = dfft > threshold

        # Check if the system is neither transmitting nor receiving
        not_transmitting = not states.isTransmitting()
        not_receiving = not states.is_receiving_codec2_signal()

        if not_transmitting:
            # Highlight significant frequencies in the dfft array
            dfft[significant_frequencies] = 100

            # Get the current time
            current_time = time.time()

            # Update the RMS value every second
            if current_time - last_rms_time >= 1.0:
                # Calculate the RMS value in dBFS
                audio_dbfs = calculate_rms_dbfs(data)

                # Update the state with the new RMS value
                states.set("audio_dbfs", audio_dbfs)

                # Update the last RMS calculation time
                last_rms_time = current_time

        # Convert the dfft array to integers for further processing
        dfft = dfft.astype(int)

        # Convert the dfft array to a list for queue insertion
        dfftlist = dfft.tolist()

        # Initialize the slot busy status list
        slotbusy = [False] * len(SLOT_RANGES)

        # Flag to determine if additional delay should be added
        addDelay = False

        # Iterate over each slot range to detect activity
        for slot, (range_start, range_end) in enumerate(SLOT_RANGES):
            # Check if any frequency in the slot exceeds the threshold
            if np.any(significant_frequencies[range_start:range_end]) and not_transmitting and not_receiving:
                # Mark that additional delay should be added
                addDelay = True

                # Set the current slot as busy
                slotbusy[slot] = True

                # Increment the slot delay, ensuring it does not exceed the maximum
                SLOT_DELAY[slot] = min(SLOT_DELAY[slot] + DELAY_INCREMENT, MAX_DELAY)
            else:
                # Decrement the slot delay, ensuring it does not go below zero
                SLOT_DELAY[slot] = max(SLOT_DELAY[slot] - 1, 0)

                # Set the slot busy status based on the current delay
                slotbusy[slot] = SLOT_DELAY[slot] > 0

        # Update the state with the current slot busy statuses
        states.set_channel_slot_busy(slotbusy)

        if addDelay:
            # Set the channel busy condition due to traffic
            states.set_channel_busy_condition_traffic(True)

            # Increment the channel busy delay, ensuring it does not exceed the maximum
            CHANNEL_BUSY_DELAY = min(CHANNEL_BUSY_DELAY + DELAY_INCREMENT, MAX_DELAY)
        else:
            # Decrement the channel busy delay, ensuring it does not go below zero
            CHANNEL_BUSY_DELAY = max(CHANNEL_BUSY_DELAY - 1, 0)

            # If the channel busy delay has reset, clear the busy condition
            if CHANNEL_BUSY_DELAY == 0:
                states.set_channel_busy_condition_traffic(False)

        # Clear any existing items in the FFT queue
        while not fft_queue.empty():
            fft_queue.get()

        # Add the processed dfft list to the FFT queue, limited to the first 315 elements
        fft_queue.put(dfftlist[:315])

    except Exception as err:
        # Log any exceptions that occur during the FFT calculation
        print(f"[MDM] calculate_fft: Exception: {err}")


def terminate():
    """Terminates the audio instance.

    This function terminates the sounddevice instance if it's initialized,
    releasing audio resources and preventing potential issues during shutdown.
    """
    log.warning("[SHUTDOWN] terminating audio instance...")
    if sd._initialized:
        sd._terminate()
