import os
import jack
import numpy as np
import gi
import signal  # Import the signal module
from collections import deque

gi.require_version("Gtk", "3.0")
gi.require_version("Pango", "1.0")
from gi.repository import Gtk, Gdk, Pango, GLib

# Full Morse code map with letters, numbers, and punctuation
MORSE_CODE = {
    'A': '.-', 'B': '-...', 'C': '-.-.', 'D': '-..', 'E': '.', 'F': '..-.',
    'G': '--.', 'H': '....', 'I': '..', 'J': '.---', 'K': '-.-', 'L': '.-..',
    'M': '--', 'N': '-.', 'O': '---', 'P': '.--.', 'Q': '--.-', 'R': '.-.',
    'S': '...', 'T': '-', 'U': '..-', 'V': '...-', 'W': '.--', 'X': '-..-',
    'Y': '-.--', 'Z': '--..',
    '1': '.----', '2': '..---', '3': '...--', '4': '....-', '5': '.....',
    '6': '-....', '7': '--...', '8': '---..', '9': '----.', '0': '-----',
    '.': '.-.-.-', ',': '--..--', '?': '..--..', '/': '-..-.', '@': '.--.-.',
    '-': '-....-', '(': '-.--.', ')': '-.--.-', '&': '.-...', '=': '-...-',
    ':': '---...', ';': '-.-.-.', '+': '.-.-.', "'": '.----.', '"': '.-..-.',
    '$': '...-..-', '!': '-.-.--'
}

# Define constants for timing calculations
DIT_DURATION_CONSTANT = 1200
DAH_DURATION_MULTIPLIER = 3.00
INTER_ELEMENT_SPACE_MULTIPLIER = 1.00
INTER_CHARACTER_SPACE_MULTIPLIER = .40
WORD_SPACE_MULTIPLIER = 2.10

class MorseCodeKeyboard:
    SILENCE_CACHE = {}
    TONE_CACHE = {}

    def __init__(self):
        self.client = jack.Client("MorseCodeKeyboard")
        self.output_port = self.client.outports.register("output")
        self.sample_rate = self.client.samplerate
        self.buffer_frames = self.client.blocksize
        self.frequency = 700  # Default frequency is 700 Hz
        self.set_wpm(20)  # Initialize with default WPM
        self.buffer = deque()  # Optimized type-ahead buffer using deque
        self.current_wave = None
        self.current_frame = 0
        self.processed_chars = 0  # Track the number of processed characters

        self.client.set_process_callback(self.process)
        self.client.activate()

    def set_wpm(self, wpm):
        self.wpm = wpm
        base_dit_duration = DIT_DURATION_CONSTANT / self.wpm
        base_dah_duration = DAH_DURATION_MULTIPLIER * base_dit_duration
    
        self.dit_duration_ms = base_dit_duration - 0.00  # Added 0 milliseconds to dit duration
        self.dah_duration_ms = base_dah_duration - 1.00  # Added 0 milliseconds to dah duration
    
        self.inter_element_space_ms = INTER_ELEMENT_SPACE_MULTIPLIER * base_dit_duration
        self.inter_character_space_ms = INTER_CHARACTER_SPACE_MULTIPLIER * base_dit_duration
        self.word_space_ms = WORD_SPACE_MULTIPLIER * base_dit_duration

        # Debugging statements to verify values
        print(f"Sample rate: {self.sample_rate} Hz")
        print(f"Buffer frames: {self.buffer_frames}")
        print(f"Dit duration: {self.dit_duration_ms:.2f} ms")
        print(f"Dah duration: {self.dah_duration_ms:.2f} ms")
        print(f"Inter-element space: {self.inter_element_space_ms:.2f} ms")
        print(f"Inter-character space: {self.inter_character_space_ms:.2f} ms")
        print(f"Word space: {self.word_space_ms:.2f} ms")

        # Precompute all tones and silences
        self.precompute_tones_and_silences()

    def precompute_tones_and_silences(self):
        durations_ms = [
            self.dit_duration_ms,
            self.dah_duration_ms,
            self.inter_element_space_ms,
            self.inter_character_space_ms,
            self.word_space_ms,
            self.inter_character_space_ms * 3  # Ensure this value is precomputed
        ]
        for duration in durations_ms:
            self.TONE_CACHE[duration] = self.generate_tone(duration)
            self.SILENCE_CACHE[duration] = self.generate_silence(duration)

    def process(self, frames):
        buffer = self.output_port.get_buffer()
        output = np.zeros(frames, dtype=np.float32)  # Default to silence

        try:
            if self.current_wave is None and self.buffer:
                char = self.buffer.popleft()  # Efficiently pop the first element
                self.current_wave = self.generate_wave_for_string(char)
                self.current_frame = 0
                GLib.idle_add(self.gui.update_text_color, self.processed_chars)  # Ensure GUI updates happen in the main thread
                self.processed_chars += 1  # Increment the number of processed characters

            if self.current_wave is not None:
                remaining_samples = len(self.current_wave) - self.current_frame
                chunk = min(frames, remaining_samples)
                output[:chunk] = self.current_wave[self.current_frame:self.current_frame + chunk]
                self.current_frame += chunk

                if self.current_frame >= len(self.current_wave):
                    self.current_wave = None

            buffer[:] = output
        except Exception as e:
            print(f"Error in process callback: {e}")

    def generate_wave_for_string(self, string):
        wave_parts = []  # Use a list to gather wave parts

        for char in string:
            if char == '.':
                wave_parts.append(self.TONE_CACHE[self.dit_duration_ms])
                wave_parts.append(self.SILENCE_CACHE[self.inter_element_space_ms])
            elif char == '-':
                wave_parts.append(self.TONE_CACHE[self.dah_duration_ms])
                wave_parts.append(self.SILENCE_CACHE[self.inter_element_space_ms])
            elif char == ' ':
                wave_parts.append(self.SILENCE_CACHE[self.word_space_ms])
            elif char in MORSE_CODE:
                morse_symbol = MORSE_CODE[char]
                for symbol in morse_symbol:
                    if symbol == '.':
                        wave_parts.append(self.TONE_CACHE[self.dit_duration_ms])
                    elif symbol == '-':
                        wave_parts.append(self.TONE_CACHE[self.dah_duration_ms])
                    wave_parts.append(self.SILENCE_CACHE[self.inter_element_space_ms])
                wave_parts.append(self.SILENCE_CACHE[self.inter_character_space_ms])

        # Add silence to the end of the wave
        wave_parts.append(self.SILENCE_CACHE[self.inter_character_space_ms * 3])

        # Combine all parts into a single wave
        wave = np.concatenate(wave_parts)
        return wave

    def generate_tone(self, duration_ms):
        duration_sec = duration_ms / 1000.0
        samples = int(self.sample_rate * duration_sec)
        t = np.linspace(0, duration_sec, samples, endpoint=False)
        
        # Generate clean sine wave with proper amplitude
        tone = np.sin(2 * np.pi * self.frequency * t)
        
        # Smooth envelope to prevent clicks
        envelope_ms = 3  # Envelope duration in milliseconds
        envelope_samples = int(self.sample_rate * envelope_ms / 1000.0)
        envelope = np.ones(samples)
        
        if samples > 2 * envelope_samples:
            envelope[:envelope_samples] = np.linspace(0, 1, envelope_samples)
            envelope[-envelope_samples:] = np.linspace(1, 0, envelope_samples)
        
        return (0.3 * tone * envelope).astype(np.float32)  # Reduced amplitude to 0.3

    def generate_silence(self, duration_ms):
        duration_sec = duration_ms / 1000.0
        samples = int(self.sample_rate * duration_sec)
        return np.zeros(samples, dtype=np.float32)

    def close(self):
        try:
            self.client.deactivate()
        except jack.JackError:
            pass
        self.client.close()


class MorseCodeGUI(Gtk.Window):
    def __init__(self, morse_keyboard):
        super().__init__(title="Morse Code Keyboard")
        self.morse_keyboard = morse_keyboard
        self.morse_keyboard.gui = self  # Add reference to the GUI in the MorseCodeKeyboard

        self.set_default_size(600, 400)
        self.box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
        self.add(self.box)

        # Text entry box with word wrapping
        self.scrolled_window = Gtk.ScrolledWindow()
        self.text_view = Gtk.TextView()
        self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)

        # Set left and right margins directly in the TextView
        self.text_view.set_left_margin(20)  # Add 20px space on the left
        self.text_view.set_right_margin(10)  # Add 10px space on the right

        self.font_family = 'Comic Sans MS'
        self.font_size = 32
        self.text_color = 'yellow'
        self.background_color = '#9c9c99'
        self.sent_text_color = 'black'  # Initializing sent_text_color with a default value

        self.css_provider = Gtk.CssProvider()

        # Ensure the sent_text tag is created
        buffer = self.text_view.get_buffer()
        self.sent_text_tag = buffer.create_tag("sent_text", foreground=self.sent_text_color)

        self.update_css()

        Gtk.StyleContext.add_provider_for_screen(
            Gdk.Screen.get_default(),
            self.css_provider,
            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
        )

        self.text_view.connect("key-press-event", self.on_key_press)

        self.scrolled_window.add(self.text_view)
        self.box.pack_start(self.scrolled_window, True, True, 0)

        # Set margin to add space on left and right
        self.box.set_margin_start(10)  # Adjust left margin (in pixels)
        self.box.set_margin_end(10)    # Adjust right margin (in pixels)

        # WPM entry box
        self.wpm_entry = Gtk.Entry()
        self.wpm_entry.set_placeholder_text("Enter WPM (1-200)")
        self.wpm_entry.connect("activate", self.on_wpm_changed)
        self.box.pack_start(self.wpm_entry, False, False, 0)

        # Frequency entry box
        self.frequency_entry = Gtk.Entry()
        self.frequency_entry.set_placeholder_text("Enter Frequency (Hz)")
        self.frequency_entry.connect("activate", self.on_frequency_changed)
        self.box.pack_start(self.frequency_entry, False, False, 0)

        # Clear All Text button
        self.clear_button = Gtk.Button(label="Clear All Text")
        self.clear_button.connect("clicked", self.on_clear_clicked)
        self.box.pack_start(self.clear_button, False, False, 0)

        # Add Text Color, Sent Text Color, Background Color, and Font Chooser buttons
        self.text_color_button = Gtk.Button(label="Text Color")
        self.text_color_button.connect("clicked", self.on_text_color_clicked)
        self.box.pack_start(self.text_color_button, False, False, 0)

        self.sent_text_color_button = Gtk.Button(label="Sent Text Color")
        self.sent_text_color_button.connect("clicked", self.on_sent_text_color_clicked)
        self.box.pack_start(self.sent_text_color_button, False, False, 0)

        self.background_color_button = Gtk.Button(label="Background Color")
        self.background_color_button.connect("clicked", self.on_background_color_clicked)
        self.box.pack_start(self.background_color_button, False, False, 0)

        self.font_chooser_button = Gtk.Button(label="Font Chooser")
        self.font_chooser_button.connect("clicked", self.on_font_chooser_clicked)
        self.box.pack_start(self.font_chooser_button, False, False, 0)

        self.sent_text_color = "red"  # Default sent text color

        self.connect("destroy", self.on_destroy)

    def update_css(self):
        css = f"""
            textview {{
                font-family: '{self.font_family}';
                font-size: {self.font_size}px;
                color: {self.text_color};
                background-color: {self.background_color};
                border: 1px solid black;
                padding: 2px;
            }}
            textview text {{
                color: {self.text_color};
            }}
            .sent_text {{
                color: {self.sent_text_color};
            }}
        """
        self.css_provider.load_from_data(css.encode('utf-8'))
        style_context = self.text_view.get_style_context()
        style_context.add_provider(self.css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)

        # Directly set the background color as a fallback
        rgba = Gdk.RGBA()
        rgba.parse(self.background_color)
        self.text_view.override_background_color(Gtk.StateFlags.NORMAL, rgba)

        # Update the sent_text tag color
        self.sent_text_tag.set_property("foreground", self.sent_text_color)

    def on_wpm_changed(self, entry):
        wpm_value = self.wpm_entry.get_text()
        try:
            self.morse_keyboard.set_wpm(int(wpm_value))
        except ValueError:
            pass

    def on_frequency_changed(self, entry):
        frequency_value = self.frequency_entry.get_text()
        try:
            self.morse_keyboard.frequency = float(frequency_value)
            self.morse_keyboard.precompute_tones_and_silences()  # Recompute tones with the new frequency 
        except ValueError:
            pass

    def on_key_press(self, widget, event):
        key = chr(event.keyval)

        # Check if backspace is pressed
        if event.keyval == Gdk.KEY_BackSpace:
            buffer = self.text_view.get_buffer()
            start_iter, end_iter = buffer.get_bounds()
            if start_iter.get_offset() < end_iter.get_offset():
                end_iter.backward_char()
            buffer.delete(end_iter, buffer.get_end_iter())
            if self.morse_keyboard.buffer:
                self.morse_keyboard.buffer.pop()
            return True  # Consume event to prevent further action

        # Check if TAB is pressed to stop sending immediately
        if event.keyval == Gdk.KEY_Tab:
            self.morse_keyboard.buffer.clear()
            self.morse_keyboard.current_wave = None

            # Turn all text to sent text color
            buffer = self.text_view.get_buffer()
            start_iter, end_iter = buffer.get_bounds()
            buffer.apply_tag(self.sent_text_tag, start_iter, end_iter)

            self.morse_keyboard.processed_chars = buffer.get_char_count()  # Reset processed chars count
            return True  # Consume event to prevent further action

        # Prevent the default action of inserting the character
        if key in MORSE_CODE or key.upper() in MORSE_CODE or key == ' ':
            # Handle normal characters
            if key in MORSE_CODE:
                self.morse_keyboard.buffer.append(MORSE_CODE[key])
            elif key.upper() in MORSE_CODE:
                self.morse_keyboard.buffer.append(MORSE_CODE[key.upper()])
            elif key == ' ':
                self.morse_keyboard.buffer.append(' ')  # Ensure spaces are added to the buffer

            # Insert the character into the buffer explicitly
            buffer = self.text_view.get_buffer()
            buffer.insert(buffer.get_end_iter(), key)

            # Scroll to the end of the text view to keep the current text in view
            self.text_view.scroll_to_iter(buffer.get_end_iter(), 0.0, False, 0.0, 1.0)

            return True  # Consume the event

    def on_clear_clicked(self, widget):
        """Clear the text in the TextView."""
        buffer = self.text_view.get_buffer()
        buffer.set_text("")
        self.morse_keyboard.processed_chars = 0

    def on_destroy(self, *args):
        self.morse_keyboard.close()
        Gtk.main_quit()

    # Callback for Text Color button
    def on_text_color_clicked(self, widget):
        color_chooser = Gtk.ColorChooserDialog(title="Select Text Color", parent=self)
        response = color_chooser.run()

        if response == Gtk.ResponseType.OK:
            rgba = color_chooser.get_rgba()
            self.text_color = Gdk.RGBA.to_string(rgba)
            self.update_css()

        color_chooser.destroy()

    # Callback for Sent Text Color button
    def on_sent_text_color_clicked(self, widget):
        color_chooser = Gtk.ColorChooserDialog(title="Select Sent Text Color", parent=self)
        response = color_chooser.run()

        if response == Gtk.ResponseType.OK:
            rgba = color_chooser.get_rgba()
            self.sent_text_color = Gdk.RGBA.to_string(rgba)

            # Update the sent text tag to use the new color
            buffer = self.text_view.get_buffer()
            tag_table = buffer.get_tag_table()
            sent_text_tag = tag_table.lookup("sent_text")
            if sent_text_tag:
                sent_text_tag.set_property("foreground", self.sent_text_color)
            else:
                buffer.create_tag("sent_text", foreground=self.sent_text_color)

        color_chooser.destroy()

    # Callback for Background Color button
    def on_background_color_clicked(self, widget):
        color_chooser = Gtk.ColorChooserDialog(title="Select Background Color", parent=self)
        response = color_chooser.run()

        if response == Gtk.ResponseType.OK:
            rgba = color_chooser.get_rgba()
            self.background_color = Gdk.RGBA.to_string(rgba)
            self.update_css()

        color_chooser.destroy()

    # Callback for Font Chooser button
    def on_font_chooser_clicked(self, widget):
        font_chooser = Gtk.FontChooserDialog(title="Select Font", parent=self)
        response = font_chooser.run()

        if response == Gtk.ResponseType.OK:
            font_desc = Pango.FontDescription(font_chooser.get_font())
            self.font_family = font_desc.get_family()
            self.font_size = font_desc.get_size() // Pango.SCALE  # Pango size is in Pango units, divide by Pango.SCALE to get points
            self.update_css()

        font_chooser.destroy()

    def update_text_color(self, index):
        buffer = self.text_view.get_buffer()
        start_iter = buffer.get_start_iter()
        start_iter.set_offset(index)

        end_iter = start_iter.copy()
        end_iter.forward_char()
        
        # Clear all tags before applying new ones
        buffer.remove_all_tags(start_iter, end_iter)

        # Apply the sent_text tag to change the color of the text
        buffer.apply_tag(self.sent_text_tag, start_iter, end_iter)

def gtk_main_quit(*args):
    Gtk.main_quit()
if __name__ == "__main__":
    morse_keyboard = MorseCodeKeyboard()
    gui = MorseCodeGUI(morse_keyboard)
    gui.show_all()

    # Register the signal handler for SIGINT
    signal.signal(signal.SIGINT, gtk_main_quit)

    try:
        Gtk.main()
    except KeyboardInterrupt:
        print("Program interrupted. Cleaning up and exiting...")
    finally:
        morse_keyboard.close()



