"""Debugger implementation for Winston.

This module provides the core debugger using Python's bdb module, along with
the debugger UI dialogs.

Key Classes:
    Winbugger: Debugger implementation extending bdb.Bdb
    DebuggerDialog: Main debugger UI combining Applet and InspectionDialog
    BreakpointDialog: Dialog for managing breakpoints
    Context: Debug context tracking current frame and exception

The debugger integrates with Winston's threading model and provides step,
next, return, continue, and quit operations.
"""

from __future__ import annotations
from typing import Any, TYPE_CHECKING, cast
import sys
import os
import types
import bdb
import linecache
import ast
from .key_handler import KeyHandler
from .elements import Element
from .form_elements import SingleTextElement, NumberElement, ListElement, ButtonElement
from .subdialogs import Applet, AppletDialog, HeteroDialog
from .inspection import InspectionDialog, Inspector, InspectionElement
from .debugging import Context, FrameAndLineno, where

if TYPE_CHECKING:
    from .dialog import Dialog
    from .applets.editor import Source, ViewDialog


# BreakpointDialog class
class BreakpointDialog(HeteroDialog):
    """Dialog for managing debugger breakpoints.

    Allows adding, updating, and deleting breakpoints with conditions,
    ignore counts, and exec strings. Exec strings are executed by
    Winbugger.user_line when breakpoint is hit.

    Example exec_string (without quotes):
        "print(f'break at line {frame.f_lineno}'); self.set_continue()"

    To continue execution after exec string, end with ';self.set_continue()'.
    """

    def __init__(
        self,
        parent: Dialog,
        name_seed: str,
        filler: Any = None,
        **kwargs: Any
    ) -> None:
        """Initialize breakpoint dialog.

        Args:
            parent: Parent dialog.
            name_seed: Base name for dialog.
            filler: Unused (fields hardcoded).
            **kwargs: Additional arguments passed to HeteroDialog.
        """
        fields = [
        [ListElement,'breakpoint',None,[],self.focus_breakpoint],
        [SingleTextElement,'condition',"",self.focus_condition],
        [NumberElement,'ignore count',0,0,1000000,self.focus_ignore],            
        [SingleTextElement,'exec string',"",self.focus_exec_string],            
        [ButtonElement,'Add',self.add],            
        [ButtonElement,'Update',self.update],
        [ButtonElement,'Delete',self.delete],            
        [ButtonElement,'OK'],                        
        ]
        super().__init__(parent,name_seed,filler=fields,empty_value='None')

    def get_bp(self) -> bdb.Breakpoint | None:
        """Get currently selected breakpoint.

        Returns:
            Selected Breakpoint object, or None if none selected.
        """
        bp_info  =self.fields()['breakpoint']
        if bp_info:
            bp_num=int(bp_info.split(':')[0])
            return bdb.Breakpoint.bpbynumber[bp_num]
        else:
            return None

    def bp_info(self, number: int, filename: str, line: int) -> str:
        """Format breakpoint info string.

        Args:
            number: Breakpoint number.
            filename: Source file path.
            line: Line number.

        Returns:
            Formatted info string: "number: basename, line N; source_line".
        """
        source_line = linecache.getline(filename, line).strip()
        return f"{number}: {os.path.basename(filename)}, line {line}; {source_line}"

    @KeyHandler.hotkey("Alt+U")
    def command_update(self, modified_name: str) -> None:
        """Hotkey handler for Alt+U: update breakpoint."""
        self.update()

    def update(self) -> None:
        """Update selected breakpoint with dialog field values."""
        bp=self.get_bp()
        if not bp:
            self.say_strings('No breakpoint to update')
            return
        fields=self.fields()
        bp.cond = fields['condition']
        bp.ignore = int(fields['ignore count'])
        bp.action=fields['exec string']  # type: ignore[attr-defined]
        source_line = linecache.getline(bp.file, bp.line).strip()
        self.say_strings(f"updated {bp.number} {os.path.basename(bp.file)}, {bp.line}; {source_line}")

    @KeyHandler.hotkey("Alt+A")
    def command_add(self, modified_name: str) -> Any:
        """Hotkey handler for Alt+A: add breakpoint."""
        return self.add()

    def add(self) -> None:
        """Add new breakpoint at user-selected file and line."""
        assert self.app is not None, "app must be set"
        from .applets.editor import Source, ViewDialog
        filename=self.file_selection(button='Open', folder=os.getcwd(),filename='dialogs.py')
        if not filename:
            self.say_strings('cancelled file selection',False)
            return
        else:
            index =ViewDialog(self,name_seed=filename,filler=Source(filename,0,0)).run()
            if index is False:
                self.say_strings('Cancelled add')
                return
            self.app.debugger.set_break(filename,index+1)
            info=self.bp_info(bdb.Breakpoint.next-1,filename,index+1)
            self[0].add(info)
            self.say_strings(f'added breakpoint {info}')

    @KeyHandler.hotkey("Alt+D")
    def command_delete(self, modified_name: str) -> None:
        """Hotkey handler for Alt+D: delete breakpoint."""
        if len(self[0].values) and (self.message(title=f'delete breakpoint {self[0]}?')=='Yes'):
            self.delete()
        else:
            self.say_strings('no breakpoint  deleted')

    def delete(self) -> None:
        """Delete selected breakpoint."""
        assert self.app is not None, "app must be set"
        bp=self.get_bp()
        if not bp:
            self.say_strings('no breakpoint to delete')
            return
        source_line = linecache.getline(bp.file, bp.line).strip()
        msg = f"deleted {bp.number} {os.path.basename(bp.file)}, {bp.line}; {source_line}"
        self.app.debugger.clear_bpbynumber(bp.number)
        self.say_strings(msg)

    def focus_breakpoint(self) -> None:
        """Populate breakpoint list when element gains focus."""
        linecache.clearcache()
        bp_list  =[]
        if bdb.Breakpoint.next:
            for bp in [bp for bp in bdb.Breakpoint.bpbynumber if bp is not None] :
                bp_list.append(self.bp_info(bp.number, bp.file,bp.line))
        self.current().refill(bp_list,None,None)  # type: ignore[attr-defined]

    def focus_condition(self) -> None:
        """Populate condition field when element gains focus."""
        bp=self.get_bp()
        if bp and hasattr(bp,'cond') and (bp.cond is not None):
            self.current().value = str(bp.cond)

    def focus_ignore(self) -> None:
        """Populate ignore count field when element gains focus."""
        bp=self.get_bp()
        if bp and hasattr(bp,'ignore') and (bp.ignore is not None):
            self.current().value = str(bp.ignore)

    def focus_exec_string(self) -> None:
        """Populate exec string field when element gains focus."""
        bp=self.get_bp()
        if bp and hasattr(bp,'action') and (bp.action is not None):
            self.current().value = str(bp.action)

# DebuggerDialog class
class DebuggerDialog(Applet, InspectionDialog):  # type: ignore[misc]
    """Main debugger UI combining Applet and InspectionDialog.

    Receives Context objects from Winbugger and displays them in an
    inspection dialog. Provides step, next, return, continue, and quit
    commands.

    Attributes:
        debugging_stack: Stack of Context objects for nested debugging.
    """

    def __init__(
        self,
        parent: Dialog,
        name: str,
        element_type: type[Element] = Element,
        filler: list[Any] | Inspector | None = None,
        revert_to: Dialog | None = None
    ) -> None:
        """Initialize debugger dialog.

        Args:
            parent: Parent dialog.
            name: Dialog name.
            element_type: Element type for content (defaults to Element).
            filler: Initial content or None for empty.
            revert_to: Dialog to revert to on close.
        """
        if filler is None:
            filler=[]
        #should try to work out how to use super(inspection, self).__init__(*args,**kwargs) as may be safer in some subclassing scenarios, but from a quick test couldn't make this work
        InspectionDialog.__init__(self,parent,name,element_type=element_type,filler=filler,context=None,revert_to=revert_to)
        assert self.app is not None, "app must be set"
        self.app.debugger.set_interface(self)

    def run(self, speak: str | None = None) -> Any:  # type: ignore[override]
        """Run dialog without speaking name on initial launch.

        Args:
            speak: Unused - always suppressed.

        Returns:
            Dialog result.
        """
        #suppress speaking of dialog name on initial launch of this UI
        return super().run(speak="")  # type: ignore[arg-type]

    def consume_adult_content(self, res: Any) -> None:
        """Process Context from debugger.

        Args:
            res: Context object with frame and exception info.

        Raises:
            TypeError: If res is not a Context object.
        """
        if isinstance(res,Context):
            if hasattr(self,'context') and (self.context is not None) and (res.applet is not self.context.applet):
                self.push_stack()
            self.context=res
            self.refill(Inspector(res,res.trap_type))
        else:
            raise TypeError(f'Unsupported adult content of type {res.__class__.__name__}')

    def on_close(self, close_reason: str) -> bool | None:
        """Handle close request.

        Args:
            close_reason: Reason for closing ('hide ui' or other).

        Returns:
            False to prevent close, None to allow.
        """
        if close_reason=='hide ui':
            return False
        else:
            user_result = self.message(title='Dismiss debugger UI?')
            if user_result=='Yes':
                return False
            else:
                self.say_current()
                return None

    def closing(self) -> None:
        """Clean up on dialog close."""
        assert self.app is not None, "app must be set"
        self.command_quit('closing')
        self.app.debugger.ui = None

    @staticmethod
    def find_subclasses_of_applet_dialog(filename: str) -> list[str]:
        """Find AppletDialog subclasses in file using AST.

        Args:
            filename: Python source file path.

        Returns:
            List of class names inheriting from AppletDialog.
        """
        with open(filename, 'r') as f:
            node = ast.parse(f.read())
        subclasses = []
        for item in node.body:
            if isinstance(item, ast.ClassDef):
                for base in item.bases:
                    if isinstance(base, ast.Name) and base.id == 'AppletDialog':
                        subclasses.append(item.name)
        return subclasses

    def find_applet_classes(self) -> list[str]:
        """Find all Applet classes in globals.

        Returns:
            Sorted list of applet names (without 'Dialog' suffix).
        """
        applets = []
        for name, obj in globals().items():
            if isinstance(obj, type):
                if any([base.__name__ == 'Applet'  for base  in obj.__mro__]):
                    applets.append(name.replace('Dialog',''))
        applets.sort()
        return applets
    @KeyHandler.hotkey("Control+Oem_5") #Control+backslash - convenient for one handed stepping repeatedly
    def command_step_or_return(self, modified_name: str) -> None:
        """Hotkey for Control+Backslash: step or return.

        Without Shift: step into next line.
        With Shift: return from current function.

        Args:
            modified_name: Key name with modifiers.
        """
        assert self.app is not None, "app must be set"
        if 'Shift' in modified_name:
            self.release_applet(self.app.debugger.set_return,self.context.frame)
        else:
            self.release_applet(self.app.debugger.set_step)

    @KeyHandler.hotkey("Alt+Oem_5")
    def command_set_commands(self, modified_name: str) -> None:
        """Hotkey for Alt+Backslash: show command menu or next.

        Without Shift: show menu of debugger commands.
        With Shift: step over (next line in this function).

        Args:
            modified_name: Key name with modifiers.
        """
        assert self.app is not None, "app must be set"
        if 'Shift' in modified_name:
            #skip over any calls in this line and stop at next line in current frame or calling frame
            self.release_applet(self.app.debugger.set_next,self.context.frame)
        else:
            commands = [
                'step: next line',
                'next: next line in this function (skip over call',
                'return:next line in caller',
                'continue: run to next break',
                'quit: terminate applet under inspection',
                'cancel'
                ]           
            command = self.message(title='debugger command?',buttons=commands)
            if command in ['cancel',False]:
                self.say_strings('cancelled')
            else:
                # mypy doesn't track narrowing, so cast to str
                command='set_'+cast(str, command).split(':')[0].strip().lower()
                func=eval(f"self.app.debugger.{command}")
                if command in ['set_return','set_next']:
                    args = [self.context.frame]
                else:
                    args=[]
                self.release_applet(func,*args)

    @KeyHandler.hotkey("Control+Q")
    def command_quit(self, modified_name: str) -> bool | None:
        """Hotkey for Control+Q: quit debugging.

        Args:
            modified_name: Key name with modifiers.

        Returns:
            False to close dialog if not in nested debugging.
        """
        assert self.app is not None, "app must be set"
        if self.app.debugger.applet and self.app.debugger.applet.is_paused:
            #print(f'releasing {self.app.debugger.applet} in command_quit')
            self.release_applet(self.app.debugger.set_quit)
        if self.debugging_stack and not (modified_name == 'closing'):
            self.context=self.pop_stack()
            self.refill(Inspector(self.context,self.context.trap_type))
        else:
            #was not in debugger ui before so close the ui
            return False

    def push_stack(self) -> None:
        """Push current Context onto debugging stack."""
        self.debugging_stack.append(self.context)

    def pop_stack(self) -> Context:
        """Pop Context from debugging stack.

        Returns:
            Previous Context from stack.
        """
        #context entry on debug stack comprises trap_type,applet, frame,exc
        return  self.debugging_stack.pop()

    @KeyHandler.hotkey("F5")
    def command_continue(self, modified_name: str) -> str | None:
        """Hotkey for F5: continue execution.

        Args:
            modified_name: Key name with modifiers.

        Returns:
            'hide ui' to hide dialog on successful continue.
        """
        assert self.app is not None, "app must be set"
        if self.release_applet(self.app.debugger.set_continue):
            return 'hide ui'
        return None

    def release_applet(self, set_func: Any, *args: Any) -> bool:
        """Release paused applet and execute debugger command.

        Args:
            set_func: Debugger method to call (set_step, set_next, etc.).
            *args: Arguments to pass to set_func.

        Returns:
            True if release successful, False otherwise.
        """
        assert self.app is not None, "app must be set"
        applet = self.app.debugger.applet
        #print(f'{applet} is not None :{applet is not None} and startup complete is {self.app.debugger.startup_complete}')
        if not (self.app.debugger.startup_complete and (applet is not None)): 
            self.say_strings('not currently debugging any thread',False)            
        elif not applet.is_paused and not ( set_func is self.app.debugger.set_quit):
            self.say_strings(f'{set_func.__name__} not valid while applet under inspection, {self.app.debugger.applet}, is not waiting for debugger input',False)  
        elif self.context.exc and not (set_func  is self.app.debugger.set_quit):
            self.say_strings(f'{set_func.__name__} not valid during post mortem debugging')  
        elif applet is self:
            self.say_strings(f'{set_func.__name__} not valid  when inspecting debugger_ui dialog itself')
        else:
            applet.pause_or_resume(pause=False) #must do this before calling set_current otherwise will fail to set to the paused app 
            self.app.applets.set_current_applet(applet,silent=True) #REDIRECT THE INPUT QUEUE BACK TO THE DEBUGGED APPLET            
            applet.key_q.put('release')             
            try: 
                set_func(*args)
            except Exception as e:
                self.app.tracer.log.write(f'exception {e} when releasing {applet}')                
            return True 
        return False 

# Winbugger debugger implementation
class Winbugger(bdb.Bdb):
    """Winston debugger extending Python's bdb.Bdb.

    Integrates with Winston's threading model to debug applets.
    Transfers control to DebuggerDialog UI when breakpoints hit
    or on step/next/return commands.

    Attributes:
        app: Winston application instance.
        applet: Currently debugged applet, or None.
        ui: DebuggerDialog instance, or None.
        startup_complete: True after first user_line call.
        skip_functions: Function names to skip (no transfer to UI).
        paused_suffix: Suffix for applet name when paused.
        start_and_continue: If True, continue after first user_line.
        explicit_trap_type: Override trap type for first break.
    """

    def __init__(
        self,
        app: Any,
        skip: list[str] | None = None,
        applet: AppletDialog | None = None
    ) -> None:
        """Initialize Winbugger.

        Args:
            app: Winston application instance.
            skip: Module names to skip (passed to bdb.Bdb).
            applet: Initial applet to debug, or None.
        """
        super().__init__(skip)
        self.startup_complete: bool = False
        self.skip_functions: list[str] = ['speak','get_input']
        self.paused_suffix: str = ' awaiting debugger UI commands'
        self.app = app
        self.applet = applet
        self.ui: DebuggerDialog | None = None #fixed up by call to self.set_interface(Dialog from DebuggerDialog thread during it's __init__ and therefore before any other threads can have breaks set and hence issue a set_trace which kicks off possible invocations of user___ methods here which in turn transfer control to self.DebuggerDialog
        self.start_and_continue: bool = True
        self.explicit_trap_type: str | None = None
        #self.reset(complete=True)

    def set_interface(self, ui_dialog: DebuggerDialog) -> None:
        """Set debugger UI dialog.

        Args:
            ui_dialog: DebuggerDialog instance.
        """
        self.ui = ui_dialog

    def transfer_to_ui(
        self,
        trap_type: str,
        applet: AppletDialog,
        frame: types.FrameType,
        exc: BaseException | None = None,
        return_value: Any = None,
        arguments_list: list[Any] | None = None
    ) -> None:
        """Transfer control to debugger UI.

        Pauses applet, launches/switches to DebuggerDialog, sends Context,
        and waits for 'release' or 'quit_now' from user.

        Args:
            trap_type: Trap type ('break', 'paused', 'exception', etc.).
            applet: Applet being debugged.
            frame: Current frame.
            exc: Exception if trap_type is exception.
            return_value: Return value if trap_type is return.
            arguments_list: Arguments if trap_type is call.

        Raises:
            ValueError: If applet is None.
        """
        if applet is None: 
            raise ValueError('Unexpected None value for applet argument to transfer_to_ui')
        log = self.app.tracer.log
        log.write(f'{where(frame,applet)}\n') #.f_code.co_qualname}\n')
        log.flush()
        #do not transfer if any thread (probably this one)  already holds Carousel.lock or if the function is in skip list, e.g. for applets.set_xxx which causes Carousel.lock deadlock when UI tries to transfer back
        if applet.app.applets.lock._is_owned() or (frame.f_code.co_qualname in self.skip_functions) or (frame.f_code.co_name in self.skip_functions):  # type: ignore[attr-defined]
            log.write(f'skipping {where(frame,applet)}\n') #.f_code.co_qualname}\n')
            log.flush()
            return 
        self.applet = applet
        if (self.ui is None) or not self.ui in self.app.applets:
            # the debugger_ui constructor will set winbugger.ui to itself 
            self.app.launch(DebuggerDialog,element_type=InspectionElement,name_seed='debugger user interface',filler=[],revert_to=applet)
        else:
            #do not speak the debugger UI name when toggling to UI during debugging
            self.app.applets.set_current_applet(self.app.debugger.ui,silent=True)
        #now send the actual info to display
        assert self.ui is not None, "ui must be set after launch"
        self.ui.key_q.put(Context(trap_type, applet, frame,exc,arguments_list,return_value))
        applet.pause_or_resume(pause=True)
        #discard any keys from the input queue until either get a 'release' event or a 'quit_now' sentinel 
        #not sure if any will ever appear, but may be a race where keys make it through before the 'release' 
        while (inp:=applet.get_input())!='release':
            if inp is False:
                #exit this wait loop if get 'quit_now' or 'quit_child'
                break
        applet.pause_or_resume(pause=False)

    def set_trace(  # type: ignore[override]
        self,
        applet: AppletDialog,
        frame: types.FrameType | None = None,
        start_and_continue: bool = True,
        trap_type: str | None = None
    ) -> None:
        """Start tracing applet.

        Args:
            applet: Applet to debug.
            frame: Starting frame, or None for caller's frame.
            start_and_continue: If True, continue after first user_line.
            trap_type: Override trap type for first break.
        """
        self.applet = applet
        self.start_and_continue = start_and_continue
        self.startup_complete=False
        self.explicit_trap_type=trap_type
        super().set_trace(frame)

    def set_quit(self) -> None:
        """Quit debugging and reset state."""
        #print(f'in set_quit')
        self.startup_complete=False
        if self.applet:
            self.applet.under_inspection = False
        self.applet = None
        super().set_quit()

    def user_line(self, frame: types.FrameType) -> None:
        """Called on each line when stepping or on breakpoint.

        Invoked when:
        - Breakpoint is hit and ignore count reached
        - After set_continue() with breakpoints set
        - After step/next/return commands

        Note: If breakpoint set after set_continue() with no prior breakpoints,
        it may not trigger as bdb unhooks itself when no breakpoints exist.

        Transfers to UI unless this is first call with start_and_continue.

        Args:
            frame: Current frame.
        """
        #print('entered user_line in winbugger')
        if not self.startup_complete:
            self.startup_complete = True
            if self.start_and_continue:
                self.set_continue()
                return
        if self.break_here(frame):
            print(f'breaking on line {frame.f_lineno}')
            trap_type = 'break'
            #Bdb.currentbp is set by the above call to Bdb.break_here()
            bp = self.get_bpbynumber(self.currentbp)  # type: ignore[attr-defined]
            if hasattr(bp,'action') and isinstance(bp.action,str) and len(bp.action):
                print(f'{bp}: action: {bp.action}')
                exec(bp.action, frame.f_globals, frame.f_locals)
        else:
            if self.explicit_trap_type:
                trap_type = self.explicit_trap_type
                self.explicit_trap_type=None
            else:
                trap_type = 'paused'
        assert self.applet is not None, "applet must be set in set_trace"
        self.transfer_to_ui(trap_type,self.applet,frame)

    def user_call(self, frame: types.FrameType, arguments_list: list[Any]) -> None:  # type: ignore[override]
        """Called on function call (not used).

        Not invoked for all calls. Skipping implemented in transfer_to_ui instead.

        Args:
            frame: Called frame.
            arguments_list: Function arguments.
        """
        # does not get called for all function calls so not sure what purpose is. Implemented skipping in transfer_to_ui  instead
        pass

    def user_return(self, frame: types.FrameType, return_value: Any) -> None:
        """Called on function return (not used).

        Args:
            frame: Returning frame.
            return_value: Return value.
        """
        #self.transfer_to_ui('Return from',frame,return_value=return_value)
        pass

    def user_exception(
        self,
        frame: types.FrameType,
        exc_info: tuple[type[BaseException], BaseException, types.TracebackType]
    ) -> None:
        """Called when exception raised.

        Transfers to UI for Exception subclasses only (ignores BaseException
        like GeneratorExit which are not errors).

        Args:
            frame: Frame where exception occurred.
            exc_info: Exception info tuple (type, value, traceback).
        """
        exc_type, exc_value, exc_traceback = exc_info
        # ignore BaseExceptions such as GeneratorExit  which are not indicative of an error condition and are hence not subclasses of Exception
        if issubclass(exc_type, Exception):
            assert self.applet is not None, "applet must be set in set_trace"
            self.transfer_to_ui('Exception caught in debugger',self.applet,frame,exc=exc_value)

not_callable = lambda a:not callable(a) 
