"""Python code editor with block-structured navigation and Jedi integration.

This module provides an advanced Python editor built on EditDialog with syntax-aware
features including block folding, indentation-based navigation, and Jedi-powered
code intelligence. The editor understands Python's block structure and provides
accessible navigation through classes, functions, and control structures.

Key Features:
    - Block tree construction via tokenize module
    - Fold/unfold blocks by indentation level (classes, functions, if/else, loops)
    - Navigate between blocks (Alt+Up/Down for same level, Alt+Left/Right for nesting)
    - Jedi integration for code completion and goto definition
    - Multi-line indent/unindent with Tab/Shift+Tab
    - Block-aware copy/paste operations
    - Syntax error detection and reporting
    - Breakpoint integration with debugger
    - Auto-refresh block tree on save

Classes:
    - BlockNode: Represents Python code block (function, class, if/else, loop, etc.)
    - PycodeDialog: Main Python editor extending EditDialog with block features

Block Tree Structure:
    - Root block (level -1): Contains all top-level code
    - Child blocks: Indented code following owner line (def, class, if, etc.)
    - Folded blocks: Hidden child blocks showing only owner line
    - Continuation blocks: else, elif, except, finally, case

Navigation Hotkeys:
    - Alt+Up/Down: Previous/next block at same level
    - Alt+Left/Right: Exit/enter nested blocks
    - Control+J: Goto definition (Jedi)
    - Alt+F: Fold/unfold current block
    - Alt+1-9: Fold to specific indentation level

Jedi Integration:
    - Code completion via Jedi script API
    - Goto definition for symbols
    - Works with tokenized source for accurate context

Usage:
    Launch pycoder with .py file to edit with block structure support.
    Fold large blocks to navigate complex files more efficiently.
"""
from typing import Any, Callable, Generator
import os.path
import re
import importlib
import subprocess
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 .editor import EditDialog, Source
from ..framework import backup, beep, load
from .. import debugging
from .. import punctuator
from ..format_mypy import format_mypy_output
from bdb import Breakpoint
import tokenize
import linecache
import io
import jedi
from functools import wraps


def set_block(method: Callable[..., Any]) -> Callable[..., Any]:
    """Decorator that extracts block from current element and passes to method."""
    @wraps(method)
    def wrapper(self: 'PycodeDialog', modified_name: str, *args: Any, **block_kwargs: Any) -> Any:
        if hasattr(self.current(), 'block') and isinstance(self.current().block, BlockNode):  # type: ignore[attr-defined]
            block = self.current().block  # type: ignore[attr-defined]
        else:
            self.say_strings('current element has no block attribute')
            return
        return method(self, modified_name, *args, block=block, **block_kwargs)
    return wrapper


def startswith_continuation_keyword(owner: Dialog | Element) -> bool:
    if isinstance(owner, Dialog):
        return False  #children of root block cannot be continuation blocks
    else:
        return startswith_keywords(owner, ['else', 'elif', 'except', 'finally', 'case'])


def startswith_keywords(element: Element, keywords: list[str]) -> bool:
    return any(startswith_keyword(element, keyword) for keyword in keywords)


def startswith_keyword(element: Element, keyword: str) -> bool:
    return isinstance(element, Element) and element.value.lstrip().startswith(keyword) 
class BlockNode:
    """
    Represents a sequence of indented lines immediately following the  owning line  for the indented block (e.g. an if, elif, else, while, try, except, finally, class, def, with, match or case statement)
    Except in the root block, owner is an Element object whose value string is the owning line 
    dialog.block is the root block 
    In the root block, owner is the dialog object, so dialog.block.owner is dialog  in  the same way that element.block.owner  is element 
    level is -1 for the root and increments as nesting increases  
    children is the list of all immediate children of block. 
    length  is the number of lines in the block and all it's descendent blocks 
    is_continuation indicates whether this is a block (other than the first block)  in a compound statement such as if...else. walking back through this block's siblings, the first non_continuation block is the one that immediately follows the opening line of the construct 
    pseudo is not currently used but may be used in future to denote a block that contains a sequence of lines in a block between two adjacent child nodes (i.e. the lines such as assignments, function calls, import statements, etc that are not themselves block owners and therefore do not currently appear in the block tree)
    For every element in the dialog element.block references the most deeply nested block containing element.value
    """

    children: list['BlockNode']
    owner: Dialog | Element
    length: int
    level: int
    pseudo: bool
    dialog: 'PycodeDialog'
    is_continuation: bool
    id: int
    folded: bool

    def __init__(self, owner: Dialog | Element, length: int | None = None, dialog: 'PycodeDialog | None' = None, level: int | None = None, is_continuation: bool = False, pseudo: bool = False) -> None:
        self.children = []
        self.owner = owner
        self.length = length  # type: ignore
        self.level = level  # type: ignore
        self.pseudo = pseudo
        self.dialog = dialog  # type: ignore
        self.is_continuation = is_continuation
        self.dialog.block_count += 1
        self.id = self.dialog.block_count
        self.folded = False

    def start_index(self) -> int:
        return 0 if isinstance(self.owner, Dialog) else self.dialog.index(self.owner) + 1

    def end_index(self) -> int:
        return self.start_index() + self.length - 1

    def fold(self) -> None:
        if not isinstance(self.owner, Dialog):
            #do not fold root block
            self.folded = True
        return

    def unfold(self) -> None:
        self.folded = False
        if not isinstance(self.owner, Dialog):
            #forces recursive unfolding of outer blocks if this is a  programmatic unfold e.g. resulting from goto or find
            #must go all the way to the top as there may be interim unfolded blocks but still need to unfold any enclosing folded blocks
            self.owner.block.unfold()  # type: ignore[attr-defined]

    def fold_levels(self, stop_level: int, unfold: bool = False) -> None:
        for child in self.children:
            child.fold_levels(stop_level, unfold)
        if unfold:
            #level 0 is special as it means unfold everything, not just unfold everything at or above this level
            if (self.level <= stop_level) or (stop_level == 0):
                self.unfold()
        else:
            if self.level > stop_level:
                self.fold()

    def __str__(self) -> str:
        return f"Block(start: {self.owner}, length: {self.length})"


def line_generator(dialog: 'PycodeDialog') -> Generator[str, None, None]:
    for e in dialog:
        yield e.value + '\n'


class PycodeDialog(EditDialog):
    """Python code editor with syntax-aware block folding and Jedi integration.

    Provides:
    - Block folding/unfolding by indentation level
    - Jedi-powered code completion and goto definition
    - Block navigation (next/previous class/function)
    - Multi-line indent/unindent
    - Syntax-aware operations
    """

    block: BlockNode
    block_count: int
    dirty_blocks: bool

    def __init__(self, *args: Any, **kwargs: Any) -> None:
        super().__init__(*args, **kwargs)
        self.dirty_blocks = False
        self.refresh_blocks()
        self.say_strings(['%name', '%current'])

    @classmethod
    def new_template(cls) -> str:
        return 'new script.py'

    def update_cursor(self, cursor: int, downwards: bool = True) -> int:
        #ideally should clean up to use @set_block decorator but need to either adjust that so that can do something other than say_strings (in this case return the cursor arg unmodified) or change below and calls to update_cursor to deal with None return signifying no update 
        #first do bounding as per Dialog.update_cursor - could do via super() but need to take copy of downwards to avoid it being affected by super() call 
        cursor = min(max(0,cursor),len(self)-1)
        """
                During dialog initialisation the block tree may not yet exist so assume everything visible at that stage
        Otherwise, firstly move to the owner element  of any folded blocks containing this cursor's line,
        traverse up the block tree so end up either on the target line if no folded blocks or otherwise on the owner element of the most senior folded block 
        But when moving down the dialog this may leave us at the start of the folded block where we already were, so need to jump past this block
        so move to first line beyond this block if any are visible  
        """
        new_target_e=target_e = e=self.get_element(cursor)
        if hasattr(e,'block') and isinstance(e.block,BlockNode):
            while not isinstance(e.block.owner,Dialog):  # type: ignore[attr-defined]
                if e.block.folded:  # type: ignore[attr-defined]
                    new_target_e = e.block.owner  # type: ignore[attr-defined]
                e = e.block.owner  # type: ignore[attr-defined]
            cursor=self.index(new_target_e)
            if downwards and (new_target_e is not target_e):
                #move past the indented block unless this would take past last line of file, in which case we must already be on the last visible line
                if (cursor+new_target_e.child_block.length)+1<len(self):  # type: ignore[attr-defined]
                    cursor = cursor+new_target_e.child_block.length+1  # type: ignore[attr-defined]
        return cursor

    def say_current(self, *args: Any, **kwargs: Any) -> None:
        if not(hasattr(self.current(), 'child_block') and self.current().child_block.folded):  # type: ignore[attr-defined]
            super().say_current(*args, **kwargs)
        else:
            lineno = f'line {self.index(self)+1} ' if self.say_line_numbers else ''
            #the following line should arguably say the child_block level (i.e. the level of the block that is folded, but that is confusing because the indent level of this statement is one less than the folded block level, so currently saying the level of the block that contains this owner rather than the level of it's child
            self.say_strings([lineno, 'folded', str(self.current().block.level), self.current().value])  # type: ignore[attr-defined]

    def refresh_blocks(self) -> None:
        """Rebuilds the block tree by tokenizing source and fixes up block references in all elements."""
        self.block = self.get_blocks()
        self.update_block_refs(self.block)
        self.dirty_blocks = False  #this is set to True by self.set_dirty(True) and could be tested when moving which forces a block refresh if True. Can optimize if this causes performance issues

    def update_block_refs(self, block: BlockNode) -> None:
        """Recursively writes block references into elements."""
        #this for statement runs through the lines in the indented block immediately following the owner line, or for all lines in the file for the root block
        for e in self[block.start_index():block.end_index()+1]:
            e.block = block  # type: ignore[attr-defined]
        #now recursively walk down  the descendents tree re-updating the refs for each element in each child, then the grandchildren of that child, etc
        for child in block.children:
            self.update_block_refs(child)

    def ok(self, modified_name: str) -> bool | None:
        if super().ok(modified_name):
            self.refresh_blocks()
        return None

    @KeyHandler.hotkey("Alt+S")
    def command_breakpoint(self, modified_name: str) -> None:
        if 'Shift' in modified_name:
            filename, lineno = self.name, self.cursor + 1
            self.app.debugger.set_break(filename, lineno)
            self.say_strings(f'breakpoint set on line {lineno} in {filename}')
        else:
            debugging.BreakpointDialog(self, name_seed='breakpoint manager').run()  # type: ignore[attr-defined]

    @KeyHandler.hotkey("Control+Down")
    def complete(self, modified_name: str) -> None:
        source = '\n'.join([e.value for e in self[:self.cursor]]+[self.current().value[:self.current().caret]])
        script =jedi.Script(source,path = self.name)
        completions = script.complete(line=self.cursor+1, column=self.current().caret)
        if completions:
            name = self.message(title=" ", buttons=[c.name for c in completions])
            if name:
                complete = [c.complete for c in completions if c.name==name][0] 
                self.insert_text(complete)

    @KeyHandler.hotkey("Control+T")
    def command_indent(self, modified_name: str) -> None:
        if self.selection.is_empty():
            start = self.cursor 
            line_count=1
        else:
            start=self.selection.start
            line_count = self.selection.line_count()         
        if not 'Shift' in modified_name:
            action = 'indented'        
            for cursor in range(line_count):
                e = self[start+cursor] 
                e.value=4*' '+e.value
        else:
            action = 'unindented'
            for cursor in range(line_count):
                e = self[start+cursor] 
                if len(e.value.strip()) and not e.value.startswith(4*' '):
                    self.say_strings(f'cannot unindent because line {self.index(e)+1} does not start with an indent')
                    return 
            for cursor in range(line_count):
                e = self[start+cursor] 
                e.value= e.value[4:]
                e.caret=max(e.caret-4,0)
        self.set_dirty(True) 
        self.say_strings(f'{action} {line_count} lines')
    @KeyHandler.hotkey("Control+G")
    def command_goto(self, modified_name: str) -> None:
        if not 'Shift' in modified_name:
            super().command_goto(modified_name)
        else: #goto the definition of the object under the cursor 
            source_string = '\n'.join([e.value for e in self])
            script =jedi.Script(source_string,path = self.name)
            paths = script.infer(line=self.cursor+1, column=self.current().caret)
            if not paths:
                self.say_strings('definition not found')
                return
            else:
                definition = paths[0]
                #print(dir(definition)) 
                if (definition.module_path is None) or (definition.line is None):
                    self.say_strings(f'source code not available for {definition.module_name}')
                else:
                    if str(definition.module_path)!=self.name:
                        path=str(definition.module_path)
                        filler=Source(path,int(definition.line)-1,0)
                        PycodeDialog(self,element_type=TextElement,name_seed=path,filler=filler).run(speak=['%name','%current'])                         
                        self.say_current()
                    else:
                        goto_line=definition.line 
                        self.move_to_found(goto_line-1,0,False,record=True)
    def unmatched_triple(self) -> int:
        text = '\n'.join([e.value for e in self])
        triples = list(re.finditer(r"('''|\"\"\")", text))
        stack: list[tuple[str, int]] = []
        for match in triples:
            token = match.group()
            pos = match.start()
            if stack and stack[-1][0] == token:
                stack.pop()  # matched close
            else:
                stack.append((token, pos))
        if stack:
            token, pos = stack[0]
            line = text.count("\n", 0, pos)+1
            return line
        else:
            return 0
    @KeyHandler.hotkey("Control+U")
    def command_find_unmatched_triple(self, modified_name: str) -> None:
        """
        supposed to find the beginning of an unmatched triple but finds the end just like the compiler so no use
        """
        unmatched =self.unmatched_triple()
        if unmatched:
            #move is zero indexed but line no is one indexed so decrement here
            self.move(unmatched-1,0,False,silent=True)
            self.say_strings(f"unmatched triple on line {unmatched}")
    @KeyHandler.hotkey("Control+F5")
    def command_load(self, modified_name: str) -> None:
        self.command_save(modified_name,silent=True)
        filename=self.name
        split_name = os.path.splitext(filename)
        if split_name[-1] == '.py':
            rc=False
            try:
                source_code = '\n'.join([e.value for e in self])
                compiled_code = compile(source_code, filename, 'exec')

                # Run mypy type checker on the file
                try:
                    # Determine working directory and module path for mypy
                    abs_path = os.path.abspath(filename)
                    path_parts = abs_path.split(os.sep)

                    # Run from parent directory on winston package to handle relative imports
                    if 'winston' in path_parts:
                        # Find LAST occurrence of 'winston' (the package dir, not repo dir)
                        winston_indices = [i for i, part in enumerate(path_parts) if part == 'winston']
                        winston_idx = winston_indices[-1] if winston_indices else path_parts.index('winston')
                        parent_dir = os.sep.join(path_parts[:winston_idx])
                        # Build relative path from parent directory
                        rel_path = os.sep.join(path_parts[winston_idx:])

                        result = subprocess.run(
                            ['python', '-m', 'mypy', rel_path, f'--config-file=winston{os.sep}mypy.ini'],
                            cwd=parent_dir,
                            capture_output=True,
                            text=True,
                            timeout=30
                        )
                    else:
                        # Standalone file outside winston package
                        result = subprocess.run(
                            ['python', '-m', 'mypy', filename],
                            capture_output=True,
                            text=True,
                            timeout=30
                        )

                    # Format mypy output
                    output_lines = (result.stdout + result.stderr).splitlines()
                    formatted_errors, summary_info = format_mypy_output(output_lines)

                    if formatted_errors:
                        print(*formatted_errors,summary_info,sep='\n')
                        # Parse first error to navigate to it
                        # Format: "module.py, context, line 123: error message"
                        first_error = formatted_errors[0]
                        match = re.match(r'^[^,]+,\s*[^,]+,\s*line\s+(\d+):\s*(.+)$', first_error)
                        if match:
                            lineno = int(match.group(1))-1 #convert to zero index   
                            error_msg = match.group(2)
                            self.move(lineno, 0, shift=False)
                            self.say_strings([f'line {lineno + 1}', error_msg])
                            return  # Don't proceed with reload if mypy errors
                except subprocess.TimeoutExpired:
                    self.say_strings("Mypy check timed out, proceeding with reload")
                except FileNotFoundError:
                    # Mypy not installed, just proceed with reload
                    pass
                except Exception as e:
                    # Any other mypy error, log it and proceed with reload
                    print(f"Mypy check failed with exception: {e}")
                    pass

                # No mypy errors or mypy not available - proceed with module reload
                mod=os.path.basename(split_name[0])
                # Determine full module name based on file path
                abs_path = os.path.abspath(filename)
                path_parts = abs_path.split(os.sep)
                if 'applets' in path_parts:
                    full_mod_name = f'winston.applets.{mod}'
                elif 'winston' in path_parts:
                    # Core winston module (debugging, dialogs, framework, etc.)
                    full_mod_name = f'winston.{mod}'
                else:
                    # Standalone module outside winston package
                    full_mod_name = mod
                require_restarts=['dialogs','debugging','framework','churchill','clem','google_api','punctuator','people']
                if mod in require_restarts:
                    self.say_strings(f"successfully compiled {mod} requires restart to take effect")
                else:
                    importlib.invalidate_caches()
                    load(full_mod_name)
                    self.say_strings('reloaded')
            except (SyntaxError, OverflowError) as e:
                msg = debugging.exception_msg(e)
                if not hasattr(e,'lineno'):
                    lineno = 0
                    lineno_txt = ''
                else:
                    lineno = int(e.lineno)-1  # type: ignore[arg-type]  # adjust for 0-indexing in dialog
                    lineno_txt = f'line {lineno}'
                if not hasattr(e,'offset'):
                    offset = 0
                    offset_txt = ''
                else:
                    offset = int(e.offset)-1  # type: ignore[arg-type]  # adjust for 0-indexing in dialog
                    offset_txt = f'offset {offset}'
                self.move(lineno,offset,shift=False)
                self.say_strings([lineno_txt,offset_txt,punctuator.substitute(msg,punctuator.most|{"'":' '})])
        else:
            beep(tag=f'cannot load file of type {split_name[-1]}')
    @KeyHandler.hotkey("Control+Down")
    def complete_duplicate(self, modified_name: str) -> None:  # Duplicate of complete() at line 253
        source = '\n'.join([e.value for e in self[:self.cursor]]+[self.current().value[:self.current().caret]])
        script =jedi.Script(source,path = self.name)
        completions = script.complete(line=self.cursor+1, column=self.current().caret)
        if completions:
            name = self.message(title=" ", buttons=[c.name for c in completions])
            if name:
                complete = [c.complete for c in completions if c.name==name][0]
                self.insert_text(complete)
    def join_next(self) -> None:
        super().join_next()
        self.refresh_blocks()
        self.say_current()
    def get_blocks(self) -> BlockNode:
        """Build block tree by tokenizing source."""
        #block_count is used in BlockNode.__init__ to set a unique id for the block under construction
        self.block_count = 0
        root_block = BlockNode(owner=self, length=len(self), dialog=self, level=0)
        line_gen = line_generator(self)
        tokens: Any = []
        try:
            tokens = tokenize.generate_tokens(lambda: next(line_gen, b''))  # type: ignore[arg-type, return-value]
            self.add_children(root_block, tokens)
        except (IndentationError, tokenize.TokenError) as e:
            #print(e.__class__.__name__,e,'proceeding with flat structure until next refresh')
            root_block = BlockNode(owner=self, length=len(self), dialog=self, level=0)
        #insert pseudo blocks to contain all the lines of code that are in a block but not in any of it's children
        #root_block.infill()
        return root_block

    def add_children(self, root_block: BlockNode, tokens: Any) -> None:
        block_stack = [root_block]
        for token in tokens:
            if token.type == tokenize.INDENT:
                """
                set the owner to the line immediately prior to the start of the indented block 
                special case opening # lines and blank lines which do not trigger an indent but should be included in the child block
                in contrast triple quoted lines do trigger the indent 
                so skip back over all # comment lines and blank lines until find true start line  
                """
                #firstly adjust start line number to 0-index
                index = token.start[0]-1
                if index>0:
                    index-=1
                # Skip back over blank lines and comments
                while (index>0) and (not len(self[index].value.strip()) or startswith_keyword(self[index],'#')):
                    index-=1
                # Now skip back over continuation lines (multi-line statements) to find the actual statement start
                # Look for lines that start with def, class, if, while, for, with, try, match, etc.
                while index > 0:
                    line_stripped = self[index].value.lstrip()
                    # Check if this line starts with a block-starting keyword
                    if (line_stripped.startswith('def ') or line_stripped.startswith('class ') or
                        line_stripped.startswith('if ') or line_stripped.startswith('elif ') or
                        line_stripped.startswith('else:') or line_stripped.startswith('while ') or
                        line_stripped.startswith('for ') or line_stripped.startswith('with ') or
                        line_stripped.startswith('try:') or line_stripped.startswith('except ') or
                        line_stripped.startswith('finally:') or line_stripped.startswith('match ') or
                        line_stripped.startswith('case ') or line_stripped.startswith('@')):
                        # Found the statement start
                        break
                    index -= 1
                owner=self[index] if (index!=0) else self                
                new_block = BlockNode(owner=owner,dialog=self,level=block_stack[-1].level+1,is_continuation=startswith_continuation_keyword(owner))
                block_stack[-1].children.append(new_block)
                if not isinstance(owner,Dialog):
                    owner.child_block=new_block  # type: ignore[attr-defined]                 
                block_stack.append(new_block)
            elif token.type == tokenize.DEDENT:
                    ending_block = block_stack.pop()
                    #add 1 to the index of the owner to get the index of the first element in the indented block 
                    #convert from 1-index in token to 0-index and then step back one line to get from the dedent line to the last line in the indented block
                    end_index = (token.start[0] - 1)-1 
                    ending_block.length = end_index-ending_block.start_index()+1
        #fix end_line for all nested blocks which end at the end of the source file 
        while block_stack:
            ending_block = block_stack.pop()
            ending_block.length = len(self) - ending_block.start_index()
        return

    @set_block
    @KeyHandler.hotkey("Alt+B")
    def command_block(self, modified_name: str, block: BlockNode | None = None) -> None:
        if block is None:
            return
        if 'Shift' in modified_name:
            self.move(block.start_index(), 0, False)
            self.move(block.end_index(), len(self[block.end_index()].value), True)
            self.say_strings(f'{self.selection.line_count()} lines selected')
        else:
            start = block.start_index()
            lines=[e.value for e in self[start:start+block.length]]
            line = self.current().value
            fields = (
                [ListElement,'command',None,['Select','Indent', 'Unindent', 'Goto Block Start','Next Child','Previous Child','Syntactic Block', 'Fold', 'Unfold', 'Fold All', 'Unfold All']],
                [ListElement,'source lines',line,lines]
                )            
            result =self.input_values(title='block command:',fields=fields,say_labels=False)
            if not result or not isinstance(result, dict):
                self.say_strings('cancelled')
                return
            command = result['command']
            match command:
                case 'Select':
                    self.command_block('Shift')
                case 'Indent':
                    self.select_block()  # type: ignore[attr-defined]
                    self.command_indent("")
                case 'Unindent':
                    self.select_block()  # type: ignore[attr-defined]
                    self.command_indent("Shift")
                case 'Goto Block Start':
                    self.command_goto_block_start('')  # type: ignore[attr-defined]
                case 'Next Child':
                    self.command_next_child('')
                case 'Previous Child':
                    self.command_next_child('Shift')
                case 'Select Syntactic Block':
                    self.say_strings(f'block command {command} not supported')
                case 'Fold':
                    self.command_fold('')
                case 'Unfold':
                    self.command_fold('Shift')
                case 'Fold All':
                    self.command_fold_all('')
                case 'Unfold All':
                    self.command_fold_all('Shift')
                case '_':
                    self.say_strings(f'block command {command} not recognised')
    @set_block
    @KeyHandler.hotkey("Alt+F")
    def command_fold(self, modified_name: str, block: BlockNode | None = None) -> None:
        if not hasattr(self.current(),'child_block'):
            self.say_strings('no child block associated with this line')
            return
        if 'Shift' in modified_name:
            self.current().child_block.unfold()  # type: ignore[attr-defined]
        else:
            self.current().child_block.fold()  # type: ignore[attr-defined]
        self.say_current()
    @set_block
    @KeyHandler.hotkey("Alt+M")
    def command_goto_owned(self, modified_name: str, block: BlockNode | None = None) -> None:
        if block is None:
            return
        if 'Shift' in modified_name:
            if self.cursor==0:
                beep() #cannot go any further back
                return
            index = self.index(block.owner) if not (block.level==0) else 0
            while not startswith_keywords(block.owner,['class','def']):  # type: ignore[arg-type]
                if block.level==0:
                    index=0
                    break
                else:
                    block=block.owner.block  # type: ignore[union-attr]
                    index = self.index(block.owner)
            self.move(index,0,False,record=True,suppress_indent_speech=True)
        else:
            if not hasattr(self.current(),'child_block'):
                beep()
            else:
                for child in self.current().child_block.children:  # type: ignore[attr-defined]
                    if startswith_keywords(child.owner,['class','def']):  # type: ignore[arg-type]
                        self.move_to_found(self.index(child.owner),0,False,record=True,suppress_indent_speech=True)  # type: ignore[arg-type]
                        return
                beep() #no children class or def blocks
    @set_block
    @KeyHandler.hotkey("Alt+0")
    def command_fold_all(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(0, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+1")
    def command_fold_level_1(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(1, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+2")
    def command_fold_level_2(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(2, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+3")
    def command_fold_level_3(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(3, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+4")
    def command_fold_level_4(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(4, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+5")
    def command_fold_level_5(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(5, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+6")
    def command_fold_level_6(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(6, unfold= 'Shift'in modified_name)
    @set_block
    @KeyHandler.hotkey("Alt+7")
    def command_fold_level_7(self, modified_name: str, block: BlockNode | None = None) -> None:
        self.fold_levels(7, unfold= 'Shift'in modified_name)
    def fold_levels(self, stop_level: int, unfold: bool = False) -> None:
        self.block.fold_levels(stop_level,unfold=unfold)
        level = f'level {stop_level}' if stop_level>0 else 'all'  
        action = 'unfolded' if unfold else 'folded'
        self.say_strings([action, level])

    @KeyHandler.hotkey("Control+A")
    def command_select_block(self, modified_name: str) -> None:
        """Select all or select current block with related compound statement blocks.

        Control+A: Select all (calls parent implementation)
        Control+Shift+A: Select current block including owner line and related blocks
                        (e.g., try/except/finally, if/elif/else as a unit)
        """
        if 'Shift' not in modified_name:
            # Standard select all behavior
            super().command_select_all(modified_name)
            return

        # Determine which block to work with
        # If cursor is on a block owner line (has child_block), select that owned block
        # Otherwise, select the block containing the current line
        current_element = self.current()
        owner_element: Dialog | Element

        if hasattr(current_element, 'child_block'):
            # Cursor is on a block owner line (def, class, if, etc.)
            # We need to select from this owner line through all related blocks
            owner_element = current_element
            target_block: BlockNode = current_element.child_block  # type: ignore[attr-defined]
        elif hasattr(current_element, 'block'):
            # Cursor is inside a block - select the containing block
            target_block = current_element.block  # type: ignore[attr-defined]
            owner_element = target_block.owner
        else:
            self.say_strings('no block found')
            return

        # If owner is the Dialog itself, we're at root level - select entire file
        if isinstance(owner_element, Dialog):
            self.move(0, 0, False)
            self.move(len(self) - 1, len(self[-1].value), True)
            self.say_strings(f'{self.selection.line_count()} lines selected')
            return

        # Get the parent block (contains the owner line)
        parent_block = owner_element.block  # type: ignore[attr-defined]

        # Check if parent is root AND this is NOT a continuation block
        # If so, just select this block without looking for siblings
        if isinstance(parent_block.owner, Dialog) and not target_block.is_continuation:
            # Owner is at root level and not part of a compound statement
            # Just select owner + its body
            start_index = self.index(owner_element)  # type: ignore[arg-type]
            end_index = target_block.end_index()

            # Trim trailing blank lines and comments
            while end_index > start_index:
                line_value = self[end_index].value
                if len(line_value.strip()) > 0 and not line_value.lstrip().startswith('#'):
                    break
                end_index -= 1

            self.move(start_index, 0, False)
            self.move(end_index, len(self[end_index].value), True)
            self.say_strings(f'{self.selection.line_count()} lines selected')
            return

        # Find the target_block in parent's children
        # target_block is the child_block of owner_element
        try:
            target_index = parent_block.children.index(target_block)
        except (ValueError, IndexError):
            # Fallback: just select owner + body
            start_index = self.index(owner_element)  # type: ignore[arg-type]
            end_index = target_block.end_index()

            # Trim trailing blank lines and comments
            while end_index > start_index:
                line_value = self[end_index].value
                if len(line_value.strip()) > 0 and not line_value.lstrip().startswith('#'):
                    break
                end_index -= 1

            self.move(start_index, 0, False)
            self.move(end_index, len(self[end_index].value), True)
            self.say_strings(f'{self.selection.line_count()} lines selected')
            return

        # Walk backwards to find the first non-continuation block
        first_index = target_index
        if target_block.is_continuation:
            # Walk backward through continuation blocks to find the first block
            while first_index > 0:
                prev_block = parent_block.children[first_index - 1]
                if not prev_block.is_continuation:
                    # Found the first non-continuation block
                    first_index = first_index - 1
                    break
                first_index -= 1

        # Walk forward to find the last continuation block
        last_index = first_index
        while last_index < len(parent_block.children) - 1:
            next_block = parent_block.children[last_index + 1]
            if next_block.is_continuation:
                last_index += 1
            else:
                break

        # Get start and end blocks
        start_block = parent_block.children[first_index]
        end_block = parent_block.children[last_index]

        # Calculate line indices
        start_index = self.index(start_block.owner)  # type: ignore[arg-type]
        end_index = end_block.end_index()

        # Trim trailing blank lines and comments from end_index
        while end_index > start_index:
            line_value = self[end_index].value
            if len(line_value.strip()) > 0 and not line_value.lstrip().startswith('#'):
                # Found a non-blank, non-comment line
                break
            end_index -= 1

        # Make the selection
        self.move(start_index, 0, False)
        self.move(end_index, len(self[end_index].value), True)
        self.say_strings(f'{self.selection.line_count()} lines selected')

    @KeyHandler.hotkey("Alt+N")
    def command_next_class_or_function(self, modified_name: str) -> None:
        self.next_keywords(['class', 'def'], going_previous=('Shift' in modified_name))

    @set_block
    def next_keywords(self, keywords: list[str], going_previous: bool, block: BlockNode | None = None) -> None:
        if block is None:
            return
        current_line_index = self.cursor
        for child in block.children:
            child_owner_index = self.index(child.owner)  # type: ignore[arg-type]
            if going_previous:
                found = (child_owner_index <current_line_index) and startswith_keywords(child.owner,keywords)  # type: ignore[arg-type]
            else:
                found=(child_owner_index >current_line_index) and startswith_keywords(child.owner,keywords)  # type: ignore[arg-type]
            if found:
                self.move(child_owner_index,0,False,suppress_indent_speech=True)
                return
        self.say_strings(f'no more {keywords} blocks in this level {block.level} block')

    def command_next_child(self, modified_name: str, block: BlockNode | None = None) -> None:
        if block is None:
            return
        going_previous = ('Shift' in modified_name)
        new_cursor=self.cursor
        if not hasattr(self.current(),'child_block'):
            if isinstance(block.owner,Dialog):
                beep()
                return
            else:
                new_cursor = self.index(block.owner)  # type: ignore[arg-type]
        current_child = self.get_element(new_cursor).child_block  # type: ignore[attr-defined]
        block=        current_child.owner.block  # type: ignore[attr-defined]
        if not (going_previous and (new_cursor!=self.cursor)):
            current_child_index = block.children.index(current_child)
            if current_child_index == (0 if going_previous else (len(block.children)-1)):
                beep()
                return
            else:
                new_child_index =current_child_index+(1 if not going_previous  else -1)
                new_block = block.children[new_child_index]
                new_cursor = self.index(new_block.owner)  # type: ignore[arg-type]
        self.move(new_cursor, 0, False, suppress_indent_speech=True)


class PycoderDialog(Applet, PycodeDialog):  # type: ignore[misc]
    """Pycoder applet combining Applet and PycodeDialog."""
    pass