# Code that allows Pythonwin to pretend it is IDLE
# (at least as far as most IDLE extensions are concerned)

import string
import sys

import win32api
import win32con
import win32ui
from pywin import default_scintilla_encoding
from pywin.mfc.dialog import GetSimpleInput

wordchars = string.ascii_uppercase + string.ascii_lowercase + string.digits


class TextError(Exception):  # When a TclError would normally be raised.
    pass


class EmptyRange(Exception):  # Internally raised.
    pass


def GetIDLEModule(module):
    try:
        # First get it from Pythonwin it is exists.
        modname = "pywin.idle." + module
        __import__(modname)
    except ImportError as details:
        msg = (
            f"The IDLE extension '{module}' can not be located.\r\n\r\n"
            "Please correct the installation and restart the"
            f" application.\r\n\r\n{details}"
        )
        win32ui.MessageBox(msg)
        return None
    mod = sys.modules[modname]
    mod.TclError = TextError  # A hack that can go soon!
    return mod


# A class that is injected into the IDLE auto-indent extension.
# It allows for decent performance when opening a new file,
# as auto-indent uses the tokenizer module to determine indents.
# The default AutoIndent readline method works OK, but it goes through
# this layer of Tk index indirection for every single line.  For large files
# without indents (and even small files with indents :-) it was pretty slow!
def fast_readline(self):
    if self.finished:
        val = ""
    else:
        if "_scint_lines" not in self.__dict__:
            # XXX - note - assumes this is only called once the file is loaded!
            self._scint_lines = self.text.edit.GetTextRange().split("\n")
        sl = self._scint_lines
        i = self.i = self.i + 1
        if i >= len(sl):
            val = ""
        else:
            val = sl[i] + "\n"
    return val.encode(default_scintilla_encoding)


try:
    GetIDLEModule("AutoIndent").IndentSearcher.readline = fast_readline
except AttributeError:  # GetIDLEModule may return None
    pass


# A class that attempts to emulate an IDLE editor window.
# Construct with a Pythonwin view.
class IDLEEditorWindow:
    def __init__(self, edit):
        self.edit = edit
        self.text = TkText(edit)
        self.extensions = {}
        self.extension_menus = {}

    def close(self):
        self.edit = self.text = None
        self.extension_menus = None
        try:
            for ext in self.extensions.values():
                closer = getattr(ext, "close", None)
                if closer is not None:
                    closer()
        finally:
            self.extensions = {}

    def IDLEExtension(self, extension):
        ext = self.extensions.get(extension)
        if ext is not None:
            return ext
        mod = GetIDLEModule(extension)
        if mod is None:
            return None
        klass = getattr(mod, extension)
        ext = self.extensions[extension] = klass(self)
        # Find and bind all the events defined in the extension.
        events = [item for item in dir(klass) if item[-6:] == "_event"]
        for event in events:
            name = "<<{}>>".format(event[:-6].replace("_", "-"))
            self.edit.bindings.bind(name, getattr(ext, event))
        return ext

    def GetMenuItems(self, menu_name):
        # Get all menu items for the menu name (eg, "edit")
        bindings = self.edit.bindings
        ret = []
        for ext in self.extensions.values():
            menudefs = getattr(ext, "menudefs", [])
            for name, items in menudefs:
                if name == menu_name:
                    for text, event in [item for item in items if item is not None]:
                        text = text.replace("&", "&&")
                        text = text.replace("_", "&")
                        ret.append((text, event))
        return ret

    ######################################################################
    # The IDLE "Virtual UI" methods that are exposed to the IDLE extensions.
    #
    def askinteger(
        self, caption, prompt, parent=None, initialvalue=0, minvalue=None, maxvalue=None
    ):
        while 1:
            rc = GetSimpleInput(prompt, str(initialvalue), caption)
            if rc is None:
                return 0  # Correct "cancel" semantics?
            err = None
            try:
                rc = int(rc)
            except ValueError:
                err = "Please enter an integer"
            if not err and minvalue is not None and rc < minvalue:
                err = f"Please enter an integer greater then or equal to {minvalue}"
            if not err and maxvalue is not None and rc > maxvalue:
                err = f"Please enter an integer less then or equal to {maxvalue}"
            if err:
                win32ui.MessageBox(err, caption, win32con.MB_OK)
                continue
            return rc

    def askyesno(self, caption, prompt, parent=None):
        return win32ui.MessageBox(prompt, caption, win32con.MB_YESNO) == win32con.IDYES

    ######################################################################
    # The IDLE "Virtual Text Widget" methods that are exposed to the IDLE extensions.
    #

    # Is character at text_index in a Python string?  Return 0 for
    # "guaranteed no", true for anything else.
    def is_char_in_string(self, text_index):
        # A helper for the code analyser - we need internal knowledge of
        # the colorizer to get this information
        # This assumes the colorizer has got to this point!
        text_index = self.text._getoffset(text_index)
        c = self.text.edit._GetColorizer()
        if c and c.GetStringStyle(text_index) is None:
            return 0
        return 1

    # If a selection is defined in the text widget, return
    # (start, end) as Tkinter text indices, otherwise return
    # (None, None)
    def get_selection_indices(self):
        try:
            first = self.text.index("sel.first")
            last = self.text.index("sel.last")
            return first, last
        except TextError:
            return None, None

    def set_tabwidth(self, width):
        self.edit.SCISetTabWidth(width)

    def get_tabwidth(self):
        return self.edit.GetTabWidth()


# A class providing the generic "Call Tips" interface
class CallTips:
    def __init__(self, edit):
        self.edit = edit

    def showtip(self, tip_text):
        self.edit.SCICallTipShow(tip_text)

    def hidetip(self):
        self.edit.SCICallTipCancel()


########################################
#
# Helpers for the TkText emulation.
def TkOffsetToIndex(offset, edit):
    lineoff = 0
    # May be 1 > actual end if we pretended there was a trailing '\n'
    offset = min(offset, edit.GetTextLength())
    line = edit.LineFromChar(offset)
    lineIndex = edit.LineIndex(line)
    return "%d.%d" % (line + 1, offset - lineIndex)


def _NextTok(str, pos):
    # Returns (token, endPos)
    end = len(str)
    if pos >= end:
        return None, 0
    while pos < end and str[pos] in string.whitespace:
        pos += 1
    # Special case for +-
    if str[pos] in "+-":
        return str[pos], pos + 1
    # Digits also a special case.
    endPos = pos
    while endPos < end and str[endPos] in string.digits + ".":
        endPos += 1
    if pos != endPos:
        return str[pos:endPos], endPos
    endPos = pos
    while endPos < end and str[endPos] not in string.whitespace + string.digits + "+-":
        endPos += 1
    if pos != endPos:
        return str[pos:endPos], endPos
    return None, 0


def TkIndexToOffset(bm, edit, marks):
    base, nextTokPos = _NextTok(bm, 0)
    if base is None:
        raise ValueError("Empty bookmark ID!")
    if base.find(".") > 0:
        try:
            line, col = base.split(".", 2)
            if col == "first" or col == "last":
                # Tag name
                if line != "sel":
                    raise ValueError("Tags aren't here!")
                sel = edit.GetSel()
                if sel[0] == sel[1]:
                    raise EmptyRange
                if col == "first":
                    pos = sel[0]
                else:
                    pos = sel[1]
            else:
                # Lines are 1 based for tkinter
                line = int(line) - 1
                if line > edit.GetLineCount():
                    pos = edit.GetTextLength() + 1
                else:
                    pos = edit.LineIndex(line)
                    if pos == -1:
                        pos = edit.GetTextLength()
                    pos += int(col)
        except (ValueError, IndexError):
            raise ValueError("Unexpected literal in '%s'" % base)
    elif base == "insert":
        pos = edit.GetSel()[0]
    elif base == "end":
        pos = edit.GetTextLength()
        # Pretend there is a trailing '\n' if necessary
        if pos and edit.SCIGetCharAt(pos - 1) != "\n":
            pos += 1
    else:
        try:
            pos = marks[base]
        except KeyError:
            raise ValueError("Unsupported base offset or undefined mark '%s'" % base)

    while 1:
        word, nextTokPos = _NextTok(bm, nextTokPos)
        if word is None:
            break
        if word in ("+", "-"):
            num, nextTokPos = _NextTok(bm, nextTokPos)
            if num is None:
                raise ValueError("+/- operator needs 2 args")
            what, nextTokPos = _NextTok(bm, nextTokPos)
            if what is None:
                raise ValueError("+/- operator needs 2 args")
            if what[0] != "c":
                raise ValueError("+/- only supports chars")
            if word == "+":
                pos += int(num)
            else:
                pos -= int(num)
        elif word == "wordstart":
            while pos > 0 and edit.SCIGetCharAt(pos - 1) in wordchars:
                pos -= 1
        elif word == "wordend":
            end = edit.GetTextLength()
            while pos < end and edit.SCIGetCharAt(pos) in wordchars:
                pos += 1
        elif word == "linestart":
            while pos > 0 and edit.SCIGetCharAt(pos - 1) not in "\n\r":
                pos -= 1
        elif word == "lineend":
            end = edit.GetTextLength()
            while pos < end and edit.SCIGetCharAt(pos) not in "\n\r":
                pos += 1
        else:
            raise ValueError("Unsupported relative offset '%s'" % word)
    return max(pos, 0)  # Tkinter is tollerant of -ve indexes - we aren't


# A class that resembles an IDLE (ie, a Tk) text widget.
# Construct with an edit object (eg, an editor view)
class TkText:
    def __init__(self, edit):
        self.calltips = None
        self.edit = edit
        self.marks = {}

    ##	def __getattr__(self, attr):
    ##		if attr=="tk": return self # So text.tk.call works.
    ##		if attr=="master": return None # ditto!
    ##		raise AttributeError, attr
    ##	def __getitem__(self, item):
    ##		if item=="tabs":
    ##			size = self.edit.GetTabWidth()
    ##			if size==8: return "" # Tk default
    ##			return size # correct semantics?
    ##		elif item=="font": # Used for measurements we don't need to do!
    ##			return "Don't know the font"
    ##		raise IndexError, "Invalid index '%s'" % item
    def make_calltip_window(self):
        if self.calltips is None:
            self.calltips = CallTips(self.edit)
        return self.calltips

    def _getoffset(self, index):
        return TkIndexToOffset(index, self.edit, self.marks)

    def _getindex(self, off):
        return TkOffsetToIndex(off, self.edit)

    def _fix_indexes(self, start, end):
        # first some magic to handle skipping over utf8 extended chars.
        while start > 0 and ord(self.edit.SCIGetCharAt(start)) & 0xC0 == 0x80:
            start -= 1
        while (
            end < self.edit.GetTextLength()
            and ord(self.edit.SCIGetCharAt(end)) & 0xC0 == 0x80
        ):
            end += 1
        # now handling fixing \r\n->\n disparities...
        if (
            start > 0
            and self.edit.SCIGetCharAt(start) == "\n"
            and self.edit.SCIGetCharAt(start - 1) == "\r"
        ):
            start -= 1
        if (
            end < self.edit.GetTextLength()
            and self.edit.SCIGetCharAt(end - 1) == "\r"
            and self.edit.SCIGetCharAt(end) == "\n"
        ):
            end += 1
        return start, end

    ##	def get_tab_width(self):
    ##		return self.edit.GetTabWidth()
    ##	def call(self, *rest):
    ##		# Crap to support Tk measurement hacks for tab widths
    ##		if rest[0] != "font" or rest[1] != "measure":
    ##			raise ValueError, "Unsupport call type"
    ##		return len(rest[5])
    ##	def configure(self, **kw):
    ##		for name, val in kw.items():
    ##			if name=="tabs":
    ##				self.edit.SCISetTabWidth(int(val))
    ##			else:
    ##				raise ValueError, "Unsupported configuration item %s" % kw
    def bind(self, binding, handler):
        self.edit.bindings.bind(binding, handler)

    def get(self, start, end=None):
        try:
            start = self._getoffset(start)
            if end is None:
                end = start + 1
            else:
                end = self._getoffset(end)
        except EmptyRange:
            return ""
        # Simple semantic checks to conform to the Tk text interface
        if end <= start:
            return ""
        max = self.edit.GetTextLength()
        checkEnd = 0
        if end > max:
            end = max
            checkEnd = 1
        start, end = self._fix_indexes(start, end)
        ret = self.edit.GetTextRange(start, end)
        # pretend a trailing '\n' exists if necessary.
        if checkEnd and (not ret or ret[-1] != "\n"):
            ret += "\n"
        return ret.replace("\r", "")

    def index(self, spec):
        try:
            return self._getindex(self._getoffset(spec))
        except EmptyRange:
            return ""

    def insert(self, pos, text):
        try:
            pos = self._getoffset(pos)
        except EmptyRange:
            raise TextError("Empty range")
        self.edit.SetSel((pos, pos))
        # IDLE only deals with "\n" - we will be nicer

        bits = text.split("\n")
        self.edit.SCIAddText(bits[0])
        for bit in bits[1:]:
            self.edit.SCINewline()
            self.edit.SCIAddText(bit)

    def delete(self, start, end=None):
        try:
            start = self._getoffset(start)
            if end is not None:
                end = self._getoffset(end)
        except EmptyRange:
            raise TextError("Empty range")
        # If end is specified and == start, then we must delete nothing.
        if start == end:
            return
        # If end is not specified, delete one char
        if end is None:
            end = start + 1
        else:
            # Tk says not to delete in this case, but our control would.
            if end < start:
                return
        if start == self.edit.GetTextLength():
            return  # Nothing to delete.
        old = self.edit.GetSel()[0]  # Lose a selection
        # Hack for partial '\r\n' and UTF-8 char removal
        start, end = self._fix_indexes(start, end)
        self.edit.SetSel((start, end))
        self.edit.Clear()
        if old >= start and old < end:
            old = start
        elif old >= end:
            old -= end - start
        self.edit.SetSel(old)

    def bell(self):
        win32api.MessageBeep()

    def see(self, pos):
        # Most commands we use in Scintilla actually force the selection
        # to be seen, making this unnecessary.
        pass

    def mark_set(self, name, pos):
        try:
            pos = self._getoffset(pos)
        except EmptyRange:
            raise TextError("Empty range '%s'" % pos)
        if name == "insert":
            self.edit.SetSel(pos)
        else:
            self.marks[name] = pos

    def tag_add(self, name, start, end):
        if name != "sel":
            raise ValueError("Only sel tag is supported")
        try:
            start = self._getoffset(start)
            end = self._getoffset(end)
        except EmptyRange:
            raise TextError("Empty range")
        self.edit.SetSel(start, end)

    def tag_remove(self, name, start, end):
        if name != "sel" or start != "1.0" or end != "end":
            raise ValueError("Can't remove this tag")
        # Turn the sel into a cursor
        self.edit.SetSel(self.edit.GetSel()[0])

    def compare(self, i1, op, i2):
        try:
            i1 = self._getoffset(i1)
        except EmptyRange:
            i1 = ""
        try:
            i2 = self._getoffset(i2)
        except EmptyRange:
            i2 = ""
        return eval("%d%s%d" % (i1, op, i2))

    def undo_block_start(self):
        self.edit.SCIBeginUndoAction()

    def undo_block_stop(self):
        self.edit.SCIEndUndoAction()


######################################################################
#
# Test related code.
#
######################################################################
def TestCheck(index, edit, expected=None):
    rc = TkIndexToOffset(index, edit, {})
    if rc != expected:
        print("ERROR: Index", index, ", expected", expected, "but got", rc)


def TestGet(fr, to, t, expected):
    got = t.get(fr, to)
    if got != expected:
        print(f"ERROR: get({fr!r}, {to!r}) expected {expected!r}, but got {got!r}")


def test():
    import pywin.framework.editor

    d = pywin.framework.editor.editorTemplate.OpenDocumentFile(None)
    e = d.GetFirstView()
    t = TkText(e)
    e.SCIAddText("hi there how\nare you today\r\nI hope you are well")
    e.SetSel((4, 4))

    skip = """
    TestCheck("insert", e, 4)
    TestCheck("insert wordstart", e, 3)
    TestCheck("insert wordend", e, 8)
    TestCheck("insert linestart", e, 0)
    TestCheck("insert lineend", e, 12)
    TestCheck("insert + 4 chars", e, 8)
    TestCheck("insert +4c", e, 8)
    TestCheck("insert - 2 chars", e, 2)
    TestCheck("insert -2c", e, 2)
    TestCheck("insert-2c", e, 2)
    TestCheck("insert-2 c", e, 2)
    TestCheck("insert- 2c", e, 2)
    TestCheck("1.1", e, 1)
    TestCheck("1.0", e, 0)
    TestCheck("2.0", e, 13)
    try:
        TestCheck("sel.first", e, 0)
        print("*** sel.first worked with an empty selection")
    except TextError:
        pass
    e.SetSel((4,5))
    TestCheck("sel.first- 2c", e, 2)
    TestCheck("sel.last- 2c", e, 3)
    """
    # Check EOL semantics
    e.SetSel((4, 4))
    TestGet("insert lineend", "insert lineend +1c", t, "\n")
    e.SetSel((20, 20))
    TestGet("insert lineend", "insert lineend +1c", t, "\n")
    e.SetSel((35, 35))
    TestGet("insert lineend", "insert lineend +1c", t, "\n")


class IDLEWrapper:
    def __init__(self, control):
        self.text = control


def IDLETest(extension):
    import os
    import sys

    modname = "pywin.idle." + extension
    __import__(modname)
    mod = sys.modules[modname]
    mod.TclError = TextError
    klass = getattr(mod, extension)

    # Create a new Scintilla Window.
    import pywin.framework.editor

    d = pywin.framework.editor.editorTemplate.OpenDocumentFile(None)
    v = d.GetFirstView()
    fname = os.path.splitext(__file__)[0] + ".py"
    v.SCIAddText(open(fname).read())
    d.SetModifiedFlag(0)
    r = klass(IDLEWrapper(TkText(v)))
    return r


if __name__ == "__main__":
    test()
