"""Text editor applet with file operations and navigation features.

This module provides a comprehensive text editor for Winston with support for
file operations, undo/redo, find/replace, word counting, and position history.
The editor is designed for accessible text editing with screen reader support.

Key Features:
    - File loading, saving, and auto-backup
    - Multi-line text editing with TextElement-based lines
    - Undo/redo with position history stack
    - Find and replace functionality
    - Word and character counting
    - Line navigation (goto line number)
    - Clipboard integration (copy/paste)
    - Breakpoint integration for debugging
    - Dirty flag tracking for unsaved changes
    - Save As with unique filename generation

Classes:
    - Source: Represents a file location (filename, line, column)
    - Position: Represents cursor position (cursor line, caret column)
    - PositionStack: Stack for undo/redo position tracking
    - EditDialog: Main editor dialog with full editing capabilities
    - ViewDialog: Read-only viewer extending EditDialog

Navigation:
    - Control+G: Goto line number
    - Alt+Left/Right: Navigate undo/redo history
    - Control+F: Find text
    - Control+H: Find and replace

Usage:
    Launch editor with optional Source object or filename to edit specific file.
    Empty launch creates new file with unique name based on template.
"""
from typing import Any, Generator, TextIO
import os.path
import pyperclip
from ..key_handler import KeyHandler
from ..elements import Element
from ..form_elements import ListElement, TextElement, SingleTextElement, NumberElement, ButtonElement
from ..dialog import Dialog
from ..subdialogs import Applet
from ..framework import backup, beep, load
from .. import debugging
from bdb import Breakpoint
import tokenize
import linecache
import io
import re
import math
import threading

try:
    import numpy as np  # type: ignore[import]
    import simpleaudio as sa  # type: ignore[import]
    _HAVE_SA = True
except Exception:
    _HAVE_SA = False

# Words must start & end with an alphanumeric;
# hyphen, apostrophe or quote may appear inside.
WORD_RE = re.compile(r"[A-Za-z0-9]+(?:[A-Za-z0-9'\-\"]*[A-Za-z0-9])?")

# Cache for keyboard click sound
_CLICK_SOUND_CACHE: Any = None


def keyboard_click() -> None:
    """Play a keyboard click sound.

    Creates a short percussive sound with:
    - 30ms duration
    - Mix of 2500Hz and 3500Hz frequencies
    - Sharp attack and quick decay envelope
    - White noise component for realism
    """
    global _CLICK_SOUND_CACHE

    def _play() -> None:
        """Generate and play click sound in background thread."""
        if True:  # Temporary: force simple beep path until numpy sound caching issue resolved
            # Fallback to simple beep if simpleaudio not available
            beep(freq=300, duration=50, volume=0.5)
            return

        try:
            # Use cached audio if available
            global _CLICK_SOUND_CACHE
            if _CLICK_SOUND_CACHE is None:
                # Generate the click sound
                sr = 44100
                duration_ms = 30
                duration_s = duration_ms / 1000.0
                num_samples = int(sr * duration_s)
                t = np.linspace(0, duration_s, num_samples, False)

                # Exponential decay envelope for sharp attack
                envelope = np.exp(-t * 150)

                # Mix of two frequencies (2500Hz and 3500Hz) for richer sound
                tone1 = np.sin(2 * np.pi * 2500 * t) * 0.4
                tone2 = np.sin(2 * np.pi * 3500 * t) * 0.3

                # Add white noise component for texture
                noise = np.random.uniform(-0.1, 0.1, num_samples)

                # Combine with envelope (very quiet: 0.02 = ~3% of original 0.7 volume)
                signal = (tone1 + tone2 + noise) * envelope * 0.02

                # Convert to 16-bit integer
                _CLICK_SOUND_CACHE = (signal * (2**15 - 1)).astype(np.int16)

            # Play the sound (non-blocking)
            sa.play_buffer(_CLICK_SOUND_CACHE, 1, 2, sr)
        except Exception:
            # Fallback to simple beep on any error
            beep(freq=2500, duration=30, volume=0.5)

    # Play in background thread to avoid blocking
    threading.Thread(target=_play, daemon=True).start()


def count_words(text: str) -> int:
    """Return the number of words in *text* per custom rules."""
    return sum(1 for _ in WORD_RE.finditer(text))


class Source:
    """Represents a source file location with filename, line, and column."""

    def __init__(self, filename: str, line: int, column: int) -> None:
        self.filename: str = filename
        self.line: int = line
        self.column: int = column


class Position:
    """Represents a cursor position in an editor."""

    def __init__(self, cursor: int, caret: int) -> None:
        self.cursor: int = cursor
        self.caret: int = caret


class PositionStack(list[Position]):
    """Stack of Position() objects for undo/redo."""
    pass


class EditDialog(Dialog):
    """Text editor dialog with file operations and undo/redo.

    Provides:
    - File loading and saving
    - Multi-line text editing
    - Find/replace
    - Undo/redo navigation
    - Word count
    - Line navigation (goto)
    - Block folding (in subclasses)
    """

    dirty: bool
    dirty_blocks: bool
    selecting: bool
    history: PositionStack
    redo_stack: PositionStack

    @classmethod
    def instance_name(cls, app: Any, name_seed: str | Source | None) -> str:
        """
        class method because it is often called before the particular instance has been constructed. 
        It is used to determine whether to construct a new instance or simply activate the existing instance.
        If the name returned by this method  based upon the name_seed  argument matches the name of any existing instance of a particular AppletDialog subclass then the existing instance is set by the framework to be the current applet.
        Otherwise a new instance is created and set as current.  
        For editor subclasses launched from the root launcher, the name_seed passed to this method is none and this method returns a unique name that does not match any existing file in the current working directory nor any currently running instance of the subclass that has not been saved 
        So a new instance of the editor subclass will be launched
        """
        if not name_seed:
            name_seed=cls.get_unique_name(app,os.getcwd(),cls.new_template())
        elif isinstance(name_seed, Source):
            name_seed = name_seed.filename
        return name_seed

    def __init__(self, parent: Dialog, name_seed: str, element_type: type[TextElement] = TextElement, filler: Source | str | list[str] | None = None, revert_to: Any = None) -> None:
        if not filler:
            cls = self.__class__
            unique_name = cls.get_unique_name(parent.app, path=os.getcwd(), template=cls.new_template())
            self.filler = Source(unique_name, 0, 0)
        elif isinstance(filler, str):
            filler = Source(filler, 0, 0)
        self.set_dirty(False)
        self.selecting = False
        self.history = PositionStack()
        self.redo_stack = PositionStack()
        super().__init__(parent, name_seed, element_type=element_type, filler=filler, revert_to=revert_to)

    def __str__(self) -> str:
        return os.path.basename(super().__str__())

    @classmethod
    def new_template(cls) -> str:
        return 'new file.txt'

    def generate_content(self, filler: Source | list[str] | tuple[str, ...] | None, element_type: type[TextElement]) -> Generator[Element, None, None]:  # type: ignore[override]
        if not filler:
            yield from self.contents_from_file(None,element_type=element_type)
        elif isinstance(filler,(list,tuple)):
                    for l in filler:   
                        #naming the Element as 'blank line' means that when the value is empty the line is spoken as 'blank line'  
                        yield element_type(self,'blank line',l)   
        elif not isinstance(filler,Source):
            yield Element(self,'invalid content',f'{filler.__class__.__name__} is not a currently editable or viewable object class')             
        elif ''.join(['<', filler.filename[1:-1],'>'])==filler.filename:
            yield Element(self,'invalid filename',f'{filler.filename} is a source object not currently editable or viewable') 
        elif not os.path.exists(filler.filename):
            yield Element(self,'invalid filename',f'{filler.filename} does not exist')                     
        elif not os.path.isfile(filler.filename):
            yield Element(self,'invalid filename',f'{filler.filename} exists but is not a file:  may be a folder,drive or symbolic link')                                 
        else:
            encoding = 'utf-8'
            with open(filler.filename,encoding=encoding) as f:
                yield from self.contents_from_file(f,element_type=element_type) 
            if not len(self):
            #put an empty element in dialog if the file is empty to allow editing 
                yield element_type(self,'blank line',"")
            self.move(filler.line,filler.column,shift=False)
            #self.say_current()
            #try suppressing speech altogether here as will speak the filename in inner_run() and leads to glitchy speech if doing block tree build for large file
            self.say_strings('')

    def contents_from_file(self, open_file: TextIO | None, element_type: type[TextElement]) -> Generator[TextElement, None, None]:
        if not open_file:
            yield element_type(self, 'blank line', "")
        else:
            for l in open_file:
                #naming the Element as 'blank line' means that when the value is empty the line is spoken as 'blank line'
                yield element_type(self, 'blank line', l.rstrip('\n'))

    def file_from_contents(self, open_file: TextIO) -> None:
        pass

    @classmethod
    def get_unique_name(cls, app: Any, path: str, template: str) -> str:
        #find a file name that does not already exist 
        i = 1
        base,ext = os.path.splitext(template)
        f_name=os.path.join(path,f'{base}{i}{ext}')
        while os.path.exists(f_name) or (f_name.lower() in [applet.name.lower() for applet in app.applets]):
            i += 1
            f_name = os.path.join(path, f'{base}{i}{ext}')
        return f_name

    def move(self, *args: Any, record: bool = False, **kwargs: Any) -> bool:
        if record:
            self.history.append(Position(self.cursor, self.current().caret))
            del self.history[:-50]  #limit the history to 50 items
        return super().move(*args, **kwargs)

    def can_wrap_forward(self) -> bool:
        """Determines behaviour when Right arrow key pressed at end of line."""
        rc = True
        #do not permit wrap at last line of content
        if self.cursor == len(self) - 1:
            rc = False
        return rc

    def can_wrap_backward(self) -> bool:
        """Determines behaviour when Left arrow key pressed at start of line."""
        rc = True
        if self.cursor == 0:
            rc = False
        return rc

    def supports_element_editing(self) -> bool:
        """Indicate that support replace on find command."""
        return True

    def join_next(self) -> None:
        nxt = self.cursor + 1
        self.current().value += self[nxt].value
        self.pop(nxt)
        self.say_current()

    def set_dirty(self, dirty: bool = True) -> None:
        self.dirty = dirty
        if dirty:
            self.dirty_blocks = True

    def on_close(self, result: Any) -> Any:
        if not self.dirty:
            return result
        else:
            user_result = self.message(title='Save changes?', buttons=['Yes', 'No', 'Cancel'])
            if user_result in ['Yes', 'No']:
                if user_result == 'Yes':
                    self.command_save(modified_name='')
                return result
            else:
                self.say_current()
                return None

    @KeyHandler.hotkey("Control+Back")
    def alternative_exit(self, modified_name: str) -> int:
        """Mimicks approximate behaviour of Control+Back when invoked during InspectionDialog."""
        return self.get_result()

    def get_result(self) -> int:  # type: ignore[override]
        return self.cursor

    def ok(self, modified_name: str) -> bool | None:
        if not getattr(self.current(), "is_splittable",lambda:False)():
            beep()
            return False
        else:
            self.set_dirty(True)
            say_last_word = self.current().previous_word()
            insert_value = self.current().value[:self.current().caret]
            block=self.current().block  # type: ignore[attr-defined]
            self.insert(self.cursor,TextElement(self,'blank line',value=insert_value,block=block))
            spaces = len(self.current().value)-len(self.current().value.lstrip())
            self.cursor+=1
            self.current().value= spaces*' '+self.current().value[self.current().caret:]
            self.current().caret=spaces
            self.say_strings([say_last_word, 'enter'])
            #no idea why this line is here as forces save changes dialog
            #return True

    @KeyHandler.hotkey("Alt+W")
    def command_word_count(self, modified_name: str, block: Any = None) -> None:
        wc = sum([len(e.value.split()) for e in self])
        self.say_strings(f'total {wc} words')

    @KeyHandler.hotkey("Alt+G")
    def command_move_undo_redo(self, modified_name: str, block: Any = None) -> None:
        if 'Shift' not in modified_name:
            if not self.history:
                beep()
            else:
                position  =self.history.pop()
                # Save current state for redo functionality
                self.redo_stack.append(Position(self.cursor,self.current().caret))
                del self.redo_stack[:-50] #limit the history to 50 items                 
                self.move(position.cursor,position.caret,False,record=False)
        else: #redo earlier move 
            if not self.redo_stack:
                beep()
            else:
                position  = self.redo_stack.pop()
                self.move(position.cursor,position.caret,False,record=True)
        #self.say_strings([f'line {self.cursor+1} column {self.current().caret+1}',str(self.current())])
        self.say_current()

    @KeyHandler.hotkey("Alt+H")
    def command_selection_start_end(self, modified_name: str) -> None:
        """
        convenience method for selecting chunks of text without holding shift key 
        by default selects whole lines
        Shift key indicates to start and end chunk selection at actual caret position rather than start and end of line respectively 
        """
        if not 'Shift' in modified_name:
            self.current().home("Home") if not self.selection.is_marking else self.current().end("End")  
        if not self.selection.is_marking:
            start_position = f'line {self.cursor}, offset {self.current().caret}'  
            self.say_strings(f'started selection at {start_position}') 
        else:
            self.say_selection()
        self.selection.is_marking = not self.selection.is_marking

    @KeyHandler.hotkey("Up")
    def up(self, modified_name: str) -> None:
        """Handle Up arrow key, skipping blank lines with Shift and making click sound."""
        if not 'Shift' in modified_name:
            original_cursor = self.cursor
            # Get indent of current line before moving
            current_indent = len(self[original_cursor].value) - len(self[original_cursor].value.lstrip())

            cursor = self.cursor - 1
            # Skip blank lines going upward (stop at 0, don't go negative)
            while cursor > 0 and not len(self[cursor].value):
                cursor -= 1

            # Ensure cursor is within valid range
            if cursor < 0:
                cursor = 0

            # Make keyboard click sound if we skipped any lines
            lines_skipped = original_cursor - cursor - 1
            if lines_skipped > 0:
                keyboard_click()

            # Check if target line has same indent as original line
            target_indent = len(self[cursor].value) - len(self[cursor].value.lstrip())
            suppress_indent = (current_indent == target_indent)

            self.move(cursor, self.current().caret, False, suppress_indent_speech=suppress_indent)
        else:
            super().up(modified_name)

    @KeyHandler.hotkey("Down")
    def down(self, modified_name: str) -> None:
        """Handle Down arrow key, skipping blank lines with Shift and making click sound."""
        if not 'Shift' in modified_name:
            original_cursor = self.cursor
            # Get indent of current line before moving
            current_indent = len(self[original_cursor].value) - len(self[original_cursor].value.lstrip())

            cursor = self.cursor + 1
            # Skip blank lines going downward (stop before end)
            while cursor < len(self) and not len(self[cursor].value):
                cursor += 1

            # Ensure cursor is within valid range
            if cursor >= len(self):
                cursor = len(self) - 1

            # Make keyboard click sound if we skipped any lines
            lines_skipped = cursor - original_cursor - 1
            if lines_skipped > 0:
                keyboard_click()

            # Check if target line has same indent as original line
            target_indent = len(self[cursor].value) - len(self[cursor].value.lstrip())
            suppress_indent = (current_indent == target_indent)

            self.move(cursor, self.current().caret, False, suppress_indent_speech=suppress_indent)
        else:
            super().down(modified_name)
    @KeyHandler.hotkey("Control+G")
    def command_goto(self, modified_name: str) -> None:
        if not modified_name.startswith('Shift'):
            #set default to current line which is cursor+1 due to zero-indexing 
            goto_line   = self.input_number(prompt='Enter line number: ', default=self.cursor+1,title=f'go to line in range 1 to {len(self)} ',min=1, max =len(self))
            if goto_line   is False:
                self.say_strings('cancelled goto')
                return 
            else:
                if not (1<=int(goto_line)<=len(self)):
                    raise ValueError(f'one-indexed line number {goto_line} returned from goto not in range 1 - {len(self)}')
                goto_line = int(goto_line)
                self.cursor = goto_line-1   #fix for 0-based python index vs 1-based indexing in user prompt 
                self.say_current()
        else:  #Shift+G for goto definition of item under cursor
            beep()  #goto definition implemented for python files in subclass of EditorDialog so just beep for all other file types

    def move_to_found(self, cursor: int, *args: Any, **kwargs: Any) -> None:
        """
        unfolds all containing blocks for the target of a find or goto jump
        overrides method in Dialog that is called by Dialog.find_or_replace()
        Also called by EditDialog.command_goto  
        """
        if hasattr(self[cursor], 'block') and self[cursor].block is not None:
            self[cursor].block.unfold()
        self.move(cursor, *args, **kwargs)

    @KeyHandler.hotkey("Control+S")
    def command_save(self, modified_name: str, silent: bool = False) -> None:
        assert self.app is not None, "app must be set"
        # Convert to absolute path to handle files in subdirectories like applets/
        path = os.path.abspath(self.name)
        if modified_name.startswith('Shift')  or not os.path.exists(path):
            folder=os.path.dirname(path)
            filename=os.path.basename(path)
            folder=folder if len(folder) else os.getcwd()
            filename_result=self.file_selection(button='save as', folder=folder,filename=filename)
            if not filename_result:
                self.say_strings('cancelled save',False)
                return
            filename = filename_result  # Now filename is guaranteed to be str
            if os.path.exists(filename) and not (filename==self.name):
                if not (self.message(title=f'{filename} already exists. Would you like to replace it?')=='Yes'):
                    self.say_strings('cancelled save',False)
                    return
            #either not already exists or happy to replace
            self.name = filename 
        #everything checks out so now saving after backing up any existing file irrespective of whether it was the current file or one being replaced by a Save As 
        if os.path.exists(self.name):
            #only take backup if config application section indicates backup=True (otherwise assume file system is taking care of backups, e.g. Windows OneDrive  
            if self.app.backup:
                backup(self.name)        
        self.write(self.name)
        self.set_dirty(False)
        if not silent:
            self.say_strings(f'saved {os.path.basename(self.name)}', False)

    def write(self, filename: str) -> None:
        """Write dialog contents to file."""
        #can be overridden by e.g. NotebookDialog, but preferred approach is to override file_from_contents
        with open(filename, 'w', encoding='utf-8') as f:
            self.file_from_contents(f)

    def file_from_contents(self, open_file: TextIO) -> None:  # type: ignore[no-redef]
        open_file.write('\n'.join([e.value for e in self]))

    def insert_multi_line(self, s: str) -> None:
        self.set_dirty(True)
        lines = s.split('\n')
        caret = len(lines[-1])
        lines[-1] = lines[-1] + self.current().value[self.current().caret:]
        self.current().value = self.current().value[:self.current().caret] + lines[0]
        element_type = self.current().__class__
        #below sets block to None if not present on current element
        block = getattr(self.current(), 'block', None)
        for l in lines[1:]:
            self.cursor += 1
            self.insert(self.cursor, element_type(self, 'blank line', l, block=block))  # type: ignore[call-arg]
        self.refresh_blocks()
        self.move(self.cursor, caret, False)

    @KeyHandler.hotkey("Control+X")
    def command_cut(self, modified_name: str) -> None:
        """Copy command checks for modified name is control+X rather than control+C and then does the excise as well as the copy."""
        self.command_copy(modified_name)

    @KeyHandler.hotkey("Control+V")
    def command_paste(self, modified_name: str) -> None:
        s = str(pyperclip.paste())  #not sure if this will make sense for some objects on clipboard such as excel cell ranges, so maybe need to test for validity, but would still like to paste e.g. name of a file object by usj = str
        if self.insert_text(s):
            self.say_short_action(s, 'inserted')

    @KeyHandler.hotkey("Control+Delete")
    def command_remove_blank_lines(self, modified_name: str) -> None:
        if self.message(title='Remove all blank lines?') != 'Yes':
            self.say_strings('cancelled remove blank lines')
        else:
            blanks = [e for e in self if not len(e.value.strip())]
            count = len(blanks)
            if count == len(self):
                count -= 1
                blanks.pop()
            if count:
                self.set_dirty(True)
            for e in blanks:
                self.remove(e)
            #ensure that cursor is not left overhanging end of element list
            self.cursor = min(self.cursor, len(self) - 1)
            self.refresh_blocks()
            self.say_strings(f'removed {count} blank lines')

    def refresh_blocks(self) -> None:
        """Dummy for overriding in subclasses that implement block folding."""
        pass


class EditorDialog(Applet, EditDialog):  # type: ignore[misc]
    """Editor applet combining Applet and EditDialog."""
    pass


class ViewDialog(EditDialog):
    """Read-only dialog which still supports various editor features such as goto."""

    def element_attributes(self) -> set[str]:
        return super().element_attributes()

    def generate_content(self, filler: Source | list[str] | None, element_type: type[Element] = Element) -> Generator[Element, None, None]:  # type: ignore[override]
        yield from super().generate_content(filler, element_type=Element)  # type: ignore[arg-type]

    def ok(self, modified_name: str) -> Any:
        """Re-allocate Return key back to original OK purpose."""
        return Dialog.ok(self, modified_name)