"""File system explorer applet for browsing and managing files/folders.

This module provides a comprehensive file manager for Winston with support for
navigation, file operations, clipboard integration, and launching files/folders.
The explorer uses a table-based interface for accessible navigation.

Key Features:
    - Directory browsing with parent navigation (..)
    - File and folder operations (copy, move, delete, rename)
    - Windows clipboard integration (CF_HDROP format)
    - Shortcut (.lnk) resolution via pylnk3
    - Send to trash (send2trash) with undo support
    - File size and modification date display
    - Sorting by name, size, date, or extension
    - Multi-selection for batch operations
    - Launch files with associated applications
    - Create new files and folders

Classes:
    - FileManager: Handles file operations and clipboard management
    - ExplorerDialog: Main file browser extending Applet and TableDialog
    - PathInputDialog: Input dialog for path navigation

Architecture:
    - ValuesElement rows with (name, size, date, extension) columns
    - FileManager tracks operations (copy/move) and clipboard state
    - Windows API integration for native clipboard file handling
    - TableDialog provides sorting and multi-column display

File Operations:
    - Control+C: Copy files to clipboard
    - Control+X: Cut files (move mode)
    - Control+V: Paste files from clipboard
    - Delete: Send to trash
    - F2: Rename file/folder
    - Control+N: Create new file/folder

Usage:
    Launch explorer to browse current directory. Navigate with arrow keys,
    Return to enter directories, Backspace to go up. Use hotkeys for operations.
"""
from typing import Any, Generator, cast
import os
import shutil
import pyperclip
import win32clipboard
import struct
import win32con
from ..framework import beep
from ..key_handler import KeyHandler
from ..elements import ValuesElement
from ..form_elements import SingleTextElement, BooleanElement, ListElement, ButtonElement
from ..subdialogs import Applet, TableDialog, HeteroDialog
from .editor import Source
from .. import debugging
import datetime
import re
from send2trash import send2trash
import pylnk3

# Test class for generator re-fetching
class TestGeneratorHolder:
    """Test class with a generator attribute to verify re-fetching works."""

    @property
    def test_generator(self) -> Generator[str, None, None]:
        """Generator that yields 3 items."""
        yield "First item"
        yield "Second item"
        yield "Third item"


def find_classes_not_inheriting_interface(code: str) -> list[str]:
    # pattern to correctly match class names ending with 'Element' and ensure the first base class
    # specified in the inheritance list does not end with 'Interface'.
    # The pattern includes:
    # - Capturing class names ending with 'Element'.
    # - Ensuring the first base class does not end with 'Interface' using a negative lookbehind in the correct context.
    class_pattern = r'class\s+([A-Za-z]+Element)\s*\(\s*([A-Za-z]+(?<!Interface))\s*(?:,|\))'
    matches = re.finditer(class_pattern, code, re.MULTILINE)
    non_compliant_classes = [match.group(1) for match in matches]
    return non_compliant_classes


def sortable_date(file_path: str) -> float:
    """Get modification time for sorting."""
    try:
        key = os.path.getmtime(file_path)
    except:
        key = float(0)
    return key


def sortable_size(value: str) -> float:
    """Convert size string to float for sorting."""
    result: float
    try:
        size_str = value.split(' ')[0]
        if size_str.upper().endswith('K'):
            result = float(size_str[:-1])*1000
        elif size_str.upper().endswith('M'):
            result = float(size_str[:-1])*1000000
        else:
            result = float(size_str)
    except Exception as e:
        print(f'could not convert value to float: {value}')
        result = -1
    return result


def path_and_name(path: str) -> tuple[str, str]:
    """Resolve path and return (full_path, display_name) tuple."""
    if os.path.splitext(path)[-1].lower() == '.lnk':
        try:
            path = pylnk3.parse(path).path
            name = os.path.basename(path) + ' shortcut'
        except Exception as e:
            print(f"Error resolving shortcut: {e}")
            name = os.path.basename(path)
    else:
        name = os.path.basename(path)
    return (path, name)


def resolved_names(dir_name: str) -> list[tuple[str, str]]:
    """ 
    returns a list of full_path and descriptive name tuples for each entry in a directory  
    the directory is normally simply specified by the path in the dir_name argument, unless that path is a symbolic link which is resolved by os.path.realpath into the actual directory path   
    the full_path is the dir_name plus the item base_name for all items except .lnk itmes
    the full_path for .lnk items is the full_path of the target of the shortcut  
    the descriptive name is the item  basename except for .lnk items where it is the basename of the target file followed by ' shortcut'
    need to test handling of symbolic names which point to files not folders 
    """
    #dir_name=os.path.realpath(dir_name)
    name_tuples = [path_and_name(os.path.join(dir_name, name)) for name in sorted(os.listdir(dir_name), key=str.lower)]
    return name_tuples


class FileManager:
    """Manages file operations: copy, move, delete, rename, clipboard."""

    def __init__(self, dialog: 'ExplorerDialog') -> None:
        self.dialog: ExplorerDialog = dialog
        self.func: Any = self.copy  #will be set to either self.move() or self.copy() method by self.set_files
        self.file_list: list[str] = []

    def is_valid(self, name: str, basename: bool = True) -> bool:
        """
        Check if the full path or basename of a Windows filename contains any invalid characters.
        Invalid characters include: < > : " / \ | ? *
        Args:
        - name (str): The filename to be checked.
        basename: boolean indicating whether this is just a basename or a full path 
        Returns:
        - bool: True if the name is valid, False otherwise.
        """
        if basename:
            invalid_chars = set('<>:"/\\|?*')
        else:
            invalid_chars = set('<>"/|?*')
        return not any(char in invalid_chars for char in name)

    def rename(self, dir_name: str, current_name: str, new_name: str, replace: bool = False) -> bool:
        current_name = os.path.join(dir_name, current_name)
        new_name = os.path.join(dir_name, new_name)
        # Check if current_name exists
        if not os.path.exists(current_name):
            raise ValueError(f'{current_name} is not a file system object name')
        # Check if new_name already exists
        if os.path.exists(new_name):
            if replace:
                self.delete(new_name)
            else:
                return False
        os.rename(current_name, new_name)
        return True

    def delete(self, target: str) -> bool:
        if os.path.isfile(target):
            os.remove(target)
        elif os.path.isdir(target):
            shutil.rmtree(target)
        else:
            self.dialog.say_strings(f'{target} is not a file or folder')
            return False
        return True

    def get_files(self) -> list[str]:
        win32clipboard.OpenClipboard()
        if win32clipboard.IsClipboardFormatAvailable(win32con.CF_HDROP):
            self.file_list = list(win32clipboard.GetClipboardData(win32con.CF_HDROP))
        else:
            self.file_list = []
        win32clipboard.CloseClipboard()
        return self.file_list

    def set_files(self, file_paths: list[str], move: bool = False) -> None:
        if len(file_paths):
            # Initial offset for DROPFILES structure pointing to where the file list starts
            offset = 20  # The size of the DROPFILES structure itself
            # Create the DROPFILES structure
            dropfiles = struct.pack("IiiII", offset, 0, 0, 0, 1)  # Struct format: (offset, pt.x, pt.y, fNC, fWide)
            # The dropfiles structure is immediately followed by the file paths
            # Encode each path in UTF-16LE and add it to the dropfiles buffer
            for path in file_paths:
                dropfiles += path.encode('utf-16le') + b'\x00\x00'  # Add null terminator to each path
            dropfiles += b'\x00\x00'  # Add final double null terminator to indicate the end of the list
            win32clipboard.OpenClipboard()
            win32clipboard.EmptyClipboard()
            win32clipboard.SetClipboardData(win32con.CF_HDROP, dropfiles)
            win32clipboard.CloseClipboard()
            self.func = self.move if move else self.copy
            what = 'move' if move else 'copy'         
            files = f'{len(file_paths)} files or folders' if len(file_paths)>1 else self.dialog.current().name             
            action = f'{what} {files}'
        else:
            action = 'nothing selected'
        self.dialog.say_strings(action)

    def move_or_copy(self, dir_name: str) -> str | None:
        all_action: str | None = None
        file_count = folder_count = 0
        for index,f in enumerate(self.file_list): 
            old_dir_name = os.path.dirname(f)
            basename = os.path.basename(f)
            new_name=os.path.join(dir_name, basename)
            if not (os.path.isdir(f) or os.path.isfile(f)):
                print(f'{f} is not a file or folder - ignored')
                self.file_list[index]='<ignored>'
                continue
            if os.path.exists(new_name):
                if all_action:
                    action=all_action
                else:
                    action_result = self.dialog.on_exists(basename,multiple=(len(self.file_list)>1),replace_allowed=(old_dir_name!=dir_name))
                    if action_result in [False,'cancel']:
                        #delete remaining entries in file_list so new_position_name does not get set to one of the files which are not processed
                        self.file_list=self.file_list[:index]
                        break
                    # mypy doesn't track narrowing, so cast to str
                    action = cast(str, action_result)
                    if action.endswith(' all'):
                        action = action.replace(' all','')
                        all_action = action 
                if action=='skip':
                    self.file_list[index]='<ignored>'
                    continue
                elif action=='rename':
                    new_name = self.unique_name(new_name)
                    self.file_list[index] = new_name 
                elif action=='replace':
                    self.delete(new_name)                    
            #func is set by self.set_files()  to either move or copy 
            result = self.func(f,new_name)
            if result == 1:
                file_count+=1
            else:
                folder_count+=1
        what = 'moved' if (self.func is self.move) else 'copied'
        self.func = self.copy 
        new_position_name: str | None = None
        for f in self.file_list:
            if not f == '<ignored>':
                new_position_name = f
                break
        self.file_list = []
        self.dialog.say_strings(f'{what} {file_count} files and {folder_count} folders')
        return new_position_name

    def copy(self, src: str, dst: str) -> int:
        """Copy a file or folder and return 1 if file or -1 if folder."""
        if os.path.isfile(src):
            shutil.copy2(src, dst)
            return 1
        elif os.path.isdir(src):
            shutil.copytree(src, dst)
            return -1
        else:
            raise ValueError(f'{src} is not a file or folder')

    def unique_name(self, full_name: str) -> str:
        directory, filename = os.path.split(full_name)
        name, ext = os.path.splitext(filename)
        copy_number = 1
        new_filename = f"{name}_{copy_number}{ext}"
        new_file_path = os.path.join(directory, new_filename)
        while os.path.exists(new_file_path):
            copy_number += 1
            new_filename = f"{name}_{copy_number}{ext}"
            new_file_path = os.path.join(directory, new_filename)
        return new_file_path

    def move(self, src: str, dst: str) -> int | bool:
        try:
            shutil.move(src, dst)
        except Exception as e:
            print(f"An error occurred: {e}")
            return False
        return 1 if os.path.isfile(src) else -1

    def cleanup(self, directory: str, cutoff_date: str) -> str:
        """
        Move files older than cutoff_date in the specified directory to the Recycle Bin.
        :param directory: Path to the directory
        :param cutoff_date: Cutoff date in 'DD/MM/YYYY' format
        """
        # Convert cutoff_date string to a datetime object
        cutoff_datetime: datetime.datetime = datetime.datetime.strptime(cutoff_date, "%d/%m/%Y")
        # Iterate over files in the directory
        trashed_count = 0
        total_count=0
        for filename in os.listdir(directory):
            total_count+=1
            file_path = os.path.join(directory, filename)
            # Check if it's a file and not a directory
            if os.path.isfile(file_path):
                # Get the file's creation time
                modified_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
                # Check if the file is older than the cutoff date
                if modified_time < cutoff_datetime:
                    send2trash(file_path)
                    trashed_count += 1
        return f'moved {trashed_count} out of {total_count} files to the bin'


class FileOrFolderElement(ValuesElement):
    """Base element for files and folders with path tracking."""

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        self._path: str | None = None
        super().__init__(*args, **kwargs)

    @property
    def path(self) -> str | None:
        return self._path

    @path.setter
    def path(self, val: str) -> None:
        self._path = val


class FolderElement(FileOrFolderElement):
    """Element representing a folder that can be opened on Return."""

    @KeyHandler.hotkey("Return")
    def open(self, modified_name: str) -> None:
        self.dialog.refill(self.name)
        self.dialog.say_strings(['%name', '%current'])


class FileElement(FileOrFolderElement):
    """Element representing a file that can be opened on Return."""

    @KeyHandler.hotkey("Return")
    def open(self, modified_name: str) -> None:
        filename = self.name
        self.dialog.open_file(filename, safe='Shift' in modified_name)  # type: ignore[attr-defined]

    @KeyHandler.hotkey("Control+R")
    def command_repair(self, modified_name: str) -> None:
#result = detect(raw_data)
        # Convert file from Windows-1252 to UTF-8
        input_file = self.name
        output_file = self.name
        # Open the file in Windows-1252 encoding
        with open(input_file, 'r', encoding='windows-1252') as infile:
            content = infile.read()
        # Write the content back as UTF-8
        with open(output_file, 'w', encoding='utf-8') as outfile:
            outfile.write(content)


class ExplorerDialog(Applet, TableDialog):  # type: ignore[misc]
    """File explorer dialog with folder navigation and file operations.

    Provides:
    - Folder navigation (Return on folders, Back for parent)
    - File opening with appropriate applets
    - Copy/Move/Delete/Rename operations
    - Clipboard integration
    - Search in files
    - Sorting by name/date/size
    """

    @classmethod
    def instance_name(cls, app: Any, name_seed: str) -> str:  # type: ignore[override]
        """Return folder name for singleton instance naming."""
        #defined as a class method because often called before an instance has been constructed, used to determine whether to construct a new instance or simply activate the existing instance
        return os.path.basename(os.getcwd()) + ' folder'  #os.path.basename(os.path.realpath(os.getcwd()))+' folder'

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        self.manager: FileManager = FileManager(self)
        # Add test generator holder for testing re-fetch behavior
        self.test_gen_holder: TestGeneratorHolder = TestGeneratorHolder()
        self.full_name: str = ""
        with debugging.Timer() as timer:
            super().__init__(*args, **kwargs)
        #print(f'initialisation time for {self.full_name} dialog  is {timer.elapsed:.2f} seconds')

    def __repr__(self) -> str:
        return f"{self}, {self.full_name}"

    def element_attributes(self) -> set[str]:
        return super().element_attributes().union({'open'})

    def generate_content(self, filler: str, element_type: type[FileOrFolderElement] | None = None) -> Generator[FileOrFolderElement, None, None]:  # type: ignore[override]
        self.full_name=filler if filler else os.getcwd() #os.path.realpath(os.getcwd())   
        self.name = os.path.basename(self.full_name)+' folder'
        #yield folders first then files so elements are correctly sorted  
        for e_type,condition in [(FolderElement,True),(FileElement,False)]:
            for path,f in resolved_names(self.full_name):  
                if os.path.isdir(path)==condition:
                    try:
                        modified_time = datetime.datetime.fromtimestamp(os.path.getmtime(path)).strftime('%d/%m/%y %H:%M') 
                    except:
                        modified_time='not available'
                    try:
                        size_bytes = os.path.getsize(path)
                        size_display: float | int = size_bytes
                        units  = ' bytes'
                        if size_display >=1000:
                            size_display, units = round(size_display/1000,1),'K bytes'
                        if size_display >=1000:
                            size_display, units = round(size_display/1000,1),'M bytes'
                        size = str(size_display)+units
                    except:
                        size='not available'
                    yield e_type(self, path, f, values=[f, modified_time, size])

    def empty_message(self) -> str:
        return "empty folder"

    def get_headers(self) -> list[str]:
        return ['name', 'date modified', 'size']

    def sort_key(self, element: FileOrFolderElement) -> tuple[bool, Any]:  # type: ignore[override]
        column = element.dialog.sort_column  # type: ignore[attr-defined]
        value = element.values[column]
        if column==0:
            value=value.lower()
        elif column==1:
            value = sortable_date(element.name)  
        elif column==2:
            value = sortable_size(value)
        is_dir = os.path.isdir(element.name)                                 
        #False sorts before True so the first item in the tuple ensures that folders precede files, unless whole list is reversed by sorting on already  selected column 
        #print (f'sort key for {element} is {value}')
        return (not is_dir, value)

    @KeyHandler.hotkey("Back")
    def parent_directory(self, modified_name: str) -> None:
        parent_dir = os.path.dirname(self.full_name)
        if parent_dir == self.full_name:
            #at root
            beep()
        else:
            self.refill(parent_dir)
            self.say_strings(['%name', '%current'])

    def open_file(self, filename: str, safe: bool = False) -> None:
        file_type = os.path.splitext(filename)[-1].lower() 
        app_name = None 
        if safe:
            result =self.message(title='open with safe editor or console?',buttons=['safe editor','console'])
            if result ==False or not isinstance(result, str):
                self.say_strings('cancelled')
                return
            else:
                app_name=result.replace(' ','_')
        elif file_type  in ['.txt','.log','.ini','.md','.ahk']:
            app_name = 'editor'
        elif file_type  in ['.py']:
            app_name = 'pycoder'
        elif file_type  in ['.html','.htm']:
            app_name = 'souper'
        elif file_type  in ['.json']:
            app_name = 'jsonic'
        elif file_type  in ['.ipynb']:
            app_name = 'notebook'
        elif file_type  in ['.exp']:
            app_name = 'expenses'
        elif file_type in ['.doc', '.docx']: 
            app_name = 'word'
        if app_name:
            self.app.launch_applet(app_name,filler=filename,revert_to=self)        
        else:
            # Use os.startfile to open the file with its Windows default application
            try:
                os.startfile(filename)
            except Exception as e:
                print(f"unable to open {filename}, {e}")

    @KeyHandler.hotkey("Control+O")
    def command_open_folder(self, modified_name: str) -> None:
        folder=self.file_selection(button='open',folder=self.full_name)
        if folder in (False,'cancel'):
            self.say_strings('cancelled')
        else:
                self.refill(folder)
    @KeyHandler.hotkey("Delete")
    def command_delete(self, modified_name: str) -> None:
        file_list =                     self.selected_names() 
        if not len(file_list):
            self.say_strings('nothing to delete')
            return
        elif len(file_list)==1:
            what =os.path.basename(file_list[0])
        else:
            what = f'{len(file_list)} items'
        if 'Shift' in modified_name:
            confirm = self.message(title=f'are you sure you want to permanently delete {what}?', buttons = ['No','Yes'])
            if confirm in (False,'No'):
                self.say_strings('cancelled delete')
                return 
            else:
                action = 'deleted'
                for file_path in file_list: 
                    self.manager.delete(file_path)
        else:
            action = 'moved to trash'
            for file_path in file_list:
                try:
                    send2trash(file_path)
                except Exception as e:
                    print(f'failed to trash {os.path.basename(file_path)} with exception {e}')
        new_cursor= self.selection.start 
        self.refill(self.full_name,position=(new_cursor,0))
        #if len(self):
            #new_cursor= min(new_cursor,len(self)-1)
            #self.move(new_cursor,0,False)
        self.say_strings(f'{len(file_list)} items {action}')             
    @KeyHandler.hotkey("F2")
    def command_rename(self, modified_name: str) -> None:
        if not len(self):
            self.say_strings('empty folder; nothing to rename')
            return
        old_name=os.path.basename(self.current().name)
        new_name = self.input_text(title='rename', prompt = 'new name',default=old_name)
        if new_name in [False,'',old_name] or not isinstance(new_name, str):
            result = False
            self.say_strings('cancelled rename')
        elif not self.manager.is_valid(new_name,basename=True):
            self.say_strings('{new_name} is not a valid file name')
            result = False
        else:
            result = self.manager.rename(self.full_name,old_name,new_name)
            if not result:
                if self.message(title=f'{new_name} already exists. Would you like to replace it?')=='Yes':
                    result = self.manager.rename(self.full_name,old_name,new_name,replace=True)
        if result:
            self.current().name=os.path.join(self.full_name,new_name)  # type: ignore[arg-type]
            self.refill(self.full_name)
            self.set_from_value(new_name,match_case=False,match_type='equals')  # type: ignore[arg-type]
        else:
            self.say_current()
    @KeyHandler.hotkey("Control+X")
    def command_move(self, modified_name: str) -> None:
        self.manager.set_files(self.selected_names(),move=True)
    @KeyHandler.hotkey("Control+C")
    def command_copy(self, modified_name: str) -> None:
        self.manager.set_files(self.selected_names(),move=False)
    def selected_names(self) -> list[str]:
        if not len(self):
            return []
        else:
            if self.selection.is_empty():
                return [os.path.join(self.full_name, self.current().name)]
            else:
                return [os.path.join(self.full_name, self[i].name) for i in range(self.selection.start, self.selection.end + 1)]

    @KeyHandler.hotkey("Control+V")
    def command_paste(self, modified_name: str) -> None:
        if not self.manager.get_files():
            self.say_strings('no files selected to move or copy')
        else:
            first_name = self.manager.move_or_copy(dir_name=self.full_name)
            self.refill(self.full_name)
            if first_name is not None:
                first_name = os.path.basename(first_name)
                self.set_from_value(first_name, match_case=False, match_type='equals')

    def on_exists(self, name: str, multiple: bool = False, replace_allowed: bool = True) -> str | bool:
        buttons = ['skip','rename']
        if replace_allowed:
            buttons+=['replace']
        if multiple:
            buttons += ['skip all','rename all']
            if replace_allowed:
                buttons += ['replace all']
        buttons += ['cancel']
        return self.message(buttons=buttons, title=f'{name} already exists in this folder')

    @KeyHandler.hotkey("Control+Y")
    def command_copy_as_path(self, modified_name: str) -> None:
        #not sure if this is bad practice to implement an Element command on the Dialog assuming that all elements have a name which is the path of the Element
        if len(self):
            pyperclip.copy(self.current().name)
            self.say_strings(self.current().name + ' copied as path')
        else:
            self.say_strings('no file or folder  path to copy as this is an empty folder')
    @KeyHandler.hotkey("Control+A")
    def command_select_all(self, modified_name: str, silent: bool = False) -> None:
        super().command_select_all(modified_name,silent=silent)
        if not silent:
            self.say_strings(f'{len(self)} items selected')
    @KeyHandler.hotkey("Control+N")
    def command_new_folder(self, modified_name: str) -> None:
        new_folder_path = os.path.join(self.full_name, 'new folder')
        os.makedirs(new_folder_path, exist_ok=True)
        self.refill(self.full_name)
        self.set_from_value('new folder',match_case=False,match_type='equals')        
    @KeyHandler.hotkey("Control+F")
    def command_find(self, modified_name: str) -> None:
        if 'Shift' in modified_name:
            fields = [
            [SingleTextElement,'find',""],
            [BooleanElement,'match case','No',['Yes','No']]
            ]
            results = self.input_values(title=' search for  string in folder?',fields=fields)
            if not results or not isinstance(results, dict):
                self.say_strings('cancelled or empty find string')
                return
            if results['find']=="":
                self.say_strings('cancelled or empty find string')
                return
            search_string = results['find']
            match_case = (results['match case']=='Yes')
            with debugging.Timer() as timer:
                matches = self.find_in_files(search_string,match_case)
            print(f'search time for {search_string} in {self.full_name} = {timer.elapsed:.2f}')
            if not len(matches):
                self.say_strings(f'no matches found for {search_string} in {self.full_name}  folder')
            else:
                frame = debugging.get_first_frame(self)
                self.context = debugging.Context('search results', self.home_dialog, frame, None)  # type: ignore[arg-type]
                debugging.inspect_object(dialog=self, object=matches, context=self.context, name='Search Results')
        else:
            super().command_find(modified_name)

    def find_in_files(self, search_string: str, case_sensitive: bool = False) -> debugging.ValueObjectList:
        matches = []
        pattern_flags = 0 if case_sensitive else re.IGNORECASE
        pattern = re.compile(re.escape(search_string), pattern_flags)
        for file in os.listdir(self.full_name):
            file_path = os.path.join(self.full_name, file)
            if os.path.isfile(file_path):
                with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                    for line_num, line in enumerate(f):
                        if pattern.search(line):
                            matches.append((f"{os.path.basename(file_path)}: Line {line_num+1}, {line.strip()}",Source(file_path, line_num,0)))
        count = len(matches)
        if count:
            matches.insert(0,(f'{count} matches found for {search_string} in this folder',''))  # type: ignore[arg-type]
        return debugging.ValueObjectList(matches)
    @KeyHandler.hotkey("Alt+Z")
    def command_cleanup(self, modified_name: str) -> None:
        if not self.full_name.endswith('backup'):
            self.say_strings(f'cleanup only permitted on backup folders')
            return
        else:
            before  = self.input_text(title='delete old files', prompt = 'created before  DD/MM/YYYY',default='01/06/2023')
            if before  in [False,''] or not isinstance(before, str):
                self.say_strings('cancelled rename')
                return
        result = self.manager.cleanup(self.full_name, before)
        print(result)


class FolderContentElement(ListElement):
    """ListElement for folder contents with navigation."""

    path: str

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        # Extract initial folder path from values parameter
        # When created from FileSelectionDialog, values will be a string path
        initial_path = kwargs.get('values', os.getcwd())
        if isinstance(initial_path, str):
            self.path = initial_path
            # Replace values with empty list - we'll populate it in refill
            kwargs['values'] = []
        super().__init__(*args, **kwargs)
        # Now populate with actual folder contents
        folders_only = kwargs.get('content_cookie', False)
        self._populate_folder(value=None, folders_only=folders_only, silent=True)

    def _populate_folder(self, value: str | None, folders_only: bool, silent: bool = False) -> None:
        """Populate the list with folder/file contents."""
        # Save and restore filename if needed
        if not folders_only and hasattr(self.dialog, 'filename_element'):
            filename = self.dialog.filename_element.value  # type: ignore[attr-defined]

        # Build list of items
        items = []
        conditions = [True] if folders_only else [True, False]
        for condition in conditions:
            for full_f, f in resolved_names(self.path):
                if os.path.isdir(full_f) == condition:
                    items.append(f)

        # Call parent's refill with the list of items
        super().refill(items, value, folders_only, silent=silent)  # type: ignore[arg-type]

        # Restore filename if needed
        if not folders_only and hasattr(self.dialog, 'filename_element'):
            self.dialog.filename_element.value = filename  # type: ignore[attr-defined]

    def rotate(self, increment: int, wrap: bool = False, interrupt: bool = True, silent: bool = False) -> None:  # type: ignore[override]
        super().rotate(increment, wrap=False, interrupt=interrupt, silent=silent)
        if os.path.isfile(self.value):
            self.dialog.filename_element.initialize_value(os.path.basename(self.value))  # type: ignore[attr-defined]

    @KeyHandler.hotkey("Back")
    def backward(self, modified_name: str) -> None:
        parent_dir = os.path.dirname(self.path)
        if parent_dir == self.path:
            #at root
            beep()
        else:
            self.refill(folder_name=parent_dir, value=self.path, folders_only=not hasattr(self.dialog, 'filename_element'))

    @KeyHandler.hotkey("Return")
    def forward(self, modified_name: str) -> Any:
        name=os.path.join(self.path,self.value)
        if os.path.isdir(name):
            self.refill(folder_name=self.value,value=None,folders_only=not hasattr(self.dialog,'filename_element'))
        else:
            return self.dialog.ok(modified_name)

    def refill(self, folder_name: str, value: str | None, folders_only: bool, silent: bool = False) -> None:  # type: ignore[override]
        # Update path based on folder_name
        if not folder_name:
            folder_name = os.getcwd()

        # If folder_name is relative, join with current path
        if not os.path.isabs(folder_name):
            self.path = os.path.join(self.path, folder_name)
        else:
            # Absolute path - use it directly
            self.path = folder_name

        # Use _populate_folder helper to rebuild list
        self._populate_folder(value=value, folders_only=folders_only, silent=silent)


class FileSelectionDialog(HeteroDialog):
    """Dialog for selecting files or folders with navigation."""

    folder_path_element: SingleTextElement
    folder_content_element: FolderContentElement
    filename_element: SingleTextElement

    def generate_content(self, filler: tuple[str, str | None, str], element_type: Any = None) -> Generator[Any, None, None]:  # type: ignore[override]
        folders_only =         (filler[1] is None)
        if not folders_only:
            yield SingleTextElement(self,'filename',filler[1])  # type: ignore[arg-type]
            self.filename_element = self[-1]
        yield SingleTextElement(self,'folder path',filler[0])
        self.folder_path_element = self[-1]
        yield FolderContentElement(self, name='folder content', default=None, values=filler[0], content_cookie=folders_only)  # type: ignore[arg-type]
        self.folder_content_element = self[-1]
        yield ButtonElement(self, name=filler[2])
        yield ButtonElement(self, name='cancel')

    def validate_dialog(self) -> bool:
        folder = self.folder_path_element.value
        if not os.path.exists(folder):
            print(f'{folder} does not exist')
            return False
        elif not os.path.isdir(folder):
            print(f'{folder} is  not a folder')
            return False
        return True

    @KeyHandler.hotkey("Tab")
    def move_focus(self, modified_name: str) -> None:
        if (self.current() is self.folder_path_element) and not (self.folder_path_element.value==self.folder_content_element.path):
            if not self.validate_dialog():
                return
            else:
                folder=str(self.folder_path_element)
                self.folder_content_element.refill(folder_name=folder,value=None,folders_only=not hasattr(self,'filename_element'))
        elif self.current() is self.folder_content_element:
            self.folder_path_element.initialize_value(self.current().path)  # type: ignore[attr-defined] 
        super().move_focus(modified_name)        