import codecs
import re
import string

import win32con
import win32ui
from pywin import default_scintilla_encoding
from pywin.mfc import docview

from . import scintillacon

crlf_bytes = b"\r\n"
lf_bytes = b"\n"

# re from pep263 - but we use it both on bytes and strings.
re_encoding_bytes = re.compile(rb"coding[:=]\s*([-\w.]+)")
re_encoding_text = re.compile(r"coding[:=]\s*([-\w.]+)")

ParentScintillaDocument = docview.Document


class CScintillaDocument(ParentScintillaDocument):
    "A SyntEdit document."

    def __init__(self, *args):
        self.bom = None  # the BOM, if any, read from the file.
        # the encoding we detected from the source.  Might have
        # detected via the BOM or an encoding decl.  Note that in
        # the latter case (ie, while self.bom is None), it can't be
        # trusted - the user may have edited the encoding decl between
        # open and save.
        self.source_encoding = None
        ParentScintillaDocument.__init__(self, *args)

    def DeleteContents(self):
        pass

    def OnOpenDocument(self, filename):
        # init data members
        # print("Opening", filename)
        self.SetPathName(filename)  # Must set this early!
        try:
            # load the text as binary we can get smart
            # about detecting any existing EOL conventions.
            f = open(filename, "rb")
            try:
                self._LoadTextFromFile(f)
            finally:
                f.close()
        except OSError:
            rc = win32ui.MessageBox(
                "Could not load the file from %s\n\nDo you want to create a new file?"
                % filename,
                "Pythonwin",
                win32con.MB_YESNO | win32con.MB_ICONWARNING,
            )
            if rc == win32con.IDNO:
                return 0
            assert rc == win32con.IDYES, rc
            try:
                f = open(filename, "wb+")
                try:
                    self._LoadTextFromFile(f)
                finally:
                    f.close()
            except OSError as e:
                rc = win32ui.MessageBox("Cannot create the file %s" % filename)
        return 1

    def SaveFile(self, fileName, encoding=None):
        view = self.GetFirstView()
        ok = view.SaveTextFile(fileName, encoding=encoding)
        if ok:
            view.SCISetSavePoint()
        return ok

    def ApplyFormattingStyles(self):
        self._ApplyOptionalToViews("ApplyFormattingStyles")

    # #####################
    # File related functions
    # Helper to transfer text from the MFC document to the control.
    def _LoadTextFromFile(self, f):
        # detect EOL mode - we don't support \r only - so find the
        # first '\n' and guess based on the char before.
        l = f.readline()
        l2 = f.readline()
        # If line ends with \r\n or has no line ending, use CRLF.
        if l.endswith(crlf_bytes) or not l.endswith(lf_bytes):
            eol_mode = scintillacon.SC_EOL_CRLF
        else:
            eol_mode = scintillacon.SC_EOL_LF

        # Detect the encoding - first look for a BOM, and if not found,
        # look for a pep263 encoding declaration.
        for bom, encoding in (
            (codecs.BOM_UTF8, "utf8"),
            (codecs.BOM_UTF16_LE, "utf_16_le"),
            (codecs.BOM_UTF16_BE, "utf_16_be"),
        ):
            if l.startswith(bom):
                self.bom = bom
                self.source_encoding = encoding
                l = l[len(bom) :]  # remove it.
                break
        else:
            # no bom detected - look for pep263 encoding decl.
            for look in (l, l2):
                # Note we are looking at raw bytes here: so
                # both the re itself uses bytes and the result
                # is bytes - but we need the result as a string.
                match = re_encoding_bytes.search(look)
                if match is not None:
                    self.source_encoding = match.group(1).decode("ascii")
                    break

        # reading by lines would be too slow?  Maybe we can use the
        # incremental encoders? For now just stick with loading the
        # entire file in memory.
        text = l + l2 + f.read()

        # Translate from source encoding to UTF-8 bytes for Scintilla
        source_encoding = self.source_encoding
        # If we don't know an encoding, try utf-8 - if that fails we will
        # fallback to latin-1 to treat it as bytes...
        if source_encoding is None:
            source_encoding = "utf-8"
        # we could optimize this by avoiding utf8 to-ing and from-ing,
        # but then we would lose the ability to handle invalid utf8
        # (and even then, the use of encoding aliases makes this tricky)
        # To create an invalid utf8 file:
        # >>> open(filename, "wb").write(codecs.BOM_UTF8+"bad \xa9har\r\n")
        try:
            dec = text.decode(source_encoding)
        except UnicodeError:
            print(
                "WARNING: Failed to decode bytes from '%s' encoding - treating as latin1"
                % source_encoding
            )
            dec = text.decode("latin1")
        except LookupError:
            print(
                "WARNING: Invalid encoding '%s' specified - treating as latin1"
                % source_encoding
            )
            dec = text.decode("latin1")
        # and put it back as utf8 - this shouldn't fail.
        text = dec.encode(default_scintilla_encoding)

        view = self.GetFirstView()
        if view.IsWindow():
            # Turn off undo collection while loading
            view.SendScintilla(scintillacon.SCI_SETUNDOCOLLECTION, 0, 0)
            # Make sure the control isn't read-only
            view.SetReadOnly(0)
            view.SendScintilla(scintillacon.SCI_CLEARALL)
            view.SendMessage(scintillacon.SCI_ADDTEXT, text)
            view.SendScintilla(scintillacon.SCI_SETUNDOCOLLECTION, 1, 0)
            view.SendScintilla(win32con.EM_EMPTYUNDOBUFFER, 0, 0)
            # set EOL mode
            view.SendScintilla(scintillacon.SCI_SETEOLMODE, eol_mode)

    def _SaveTextToFile(self, view, filename, encoding=None):
        s = view.GetTextRange()  # already decoded from scintilla's encoding
        source_encoding = encoding
        if source_encoding is None:
            if self.bom:
                source_encoding = self.source_encoding
            else:
                # no BOM - look for an encoding.
                bits = re.split(r"[\r\n]+", s, 3)
                for look in bits[:-1]:
                    match = re_encoding_text.search(look)
                    if match is not None:
                        source_encoding = match.group(1)
                        self.source_encoding = source_encoding
                        break

            if source_encoding is None:
                source_encoding = "utf-8"

        ## encode data before opening file so script is not lost if encoding fails
        file_contents = s.encode(source_encoding)
        # Open in binary mode as scintilla itself ensures the
        # line endings are already appropriate
        f = open(filename, "wb")
        try:
            if self.bom:
                f.write(self.bom)
            f.write(file_contents)
        finally:
            f.close()
        self.SetModifiedFlag(0)

    def FinalizeViewCreation(self, view):
        pass

    def HookViewNotifications(self, view):
        parent = view.GetParentFrame()
        parent.HookNotify(
            ViewNotifyDelegate(self, "OnBraceMatch"), scintillacon.SCN_UPDATEUI
        )
        parent.HookNotify(
            ViewNotifyDelegate(self, "OnMarginClick"), scintillacon.SCN_MARGINCLICK
        )
        parent.HookNotify(
            ViewNotifyDelegate(self, "OnNeedShown"), scintillacon.SCN_NEEDSHOWN
        )

        parent.HookNotify(
            DocumentNotifyDelegate(self, "OnSavePointReached"),
            scintillacon.SCN_SAVEPOINTREACHED,
        )
        parent.HookNotify(
            DocumentNotifyDelegate(self, "OnSavePointLeft"),
            scintillacon.SCN_SAVEPOINTLEFT,
        )
        parent.HookNotify(
            DocumentNotifyDelegate(self, "OnModifyAttemptRO"),
            scintillacon.SCN_MODIFYATTEMPTRO,
        )
        # Tell scintilla what characters should abort auto-complete.
        view.SCIAutoCStops(string.whitespace + "()[]:;+-/*=\\?'!#@$%^&,<>\"'|")

        if view != self.GetFirstView():
            view.SCISetDocPointer(self.GetFirstView().SCIGetDocPointer())

    def OnSavePointReached(self, std, extra):
        self.SetModifiedFlag(0)

    def OnSavePointLeft(self, std, extra):
        self.SetModifiedFlag(1)

    def OnModifyAttemptRO(self, std, extra):
        self.MakeDocumentWritable()

    # All Marker functions are 1 based.
    def MarkerAdd(self, lineNo, marker):
        self.GetEditorView().SCIMarkerAdd(lineNo - 1, marker)

    def MarkerCheck(self, lineNo, marker):
        v = self.GetEditorView()
        lineNo -= 1  # Make 0 based
        markerState = v.SCIMarkerGet(lineNo)
        return markerState & (1 << marker) != 0

    def MarkerToggle(self, lineNo, marker):
        v = self.GetEditorView()
        if self.MarkerCheck(lineNo, marker):
            v.SCIMarkerDelete(lineNo - 1, marker)
        else:
            v.SCIMarkerAdd(lineNo - 1, marker)

    def MarkerDelete(self, lineNo, marker):
        self.GetEditorView().SCIMarkerDelete(lineNo - 1, marker)

    def MarkerDeleteAll(self, marker):
        self.GetEditorView().SCIMarkerDeleteAll(marker)

    def MarkerGetNext(self, lineNo, marker):
        return self.GetEditorView().SCIMarkerNext(lineNo - 1, 1 << marker) + 1

    def MarkerAtLine(self, lineNo, marker):
        markerState = self.GetEditorView().SCIMarkerGet(lineNo - 1)
        return markerState & (1 << marker)

    # Helper for reflecting functions to views.
    def _ApplyToViews(self, funcName, *args):
        for view in self.GetAllViews():
            func = getattr(view, funcName)
            func(*args)

    def _ApplyOptionalToViews(self, funcName, *args):
        for view in self.GetAllViews():
            func = getattr(view, funcName, None)
            if func is not None:
                func(*args)

    def GetEditorView(self):
        # Find the first frame with a view,
        # then ask it to give the editor view
        # as it knows which one is "active"
        try:
            frame_gev = self.GetFirstView().GetParentFrame().GetEditorView
        except AttributeError:
            return self.GetFirstView()
        return frame_gev()


# Delegate to the correct view, based on the control that sent it.
class ViewNotifyDelegate:
    def __init__(self, doc, name):
        self.doc = doc
        self.name = name

    def __call__(self, std, extra):
        (hwndFrom, idFrom, code) = std
        for v in self.doc.GetAllViews():
            if v.GetSafeHwnd() == hwndFrom:
                return getattr(v, self.name)(*(std, extra))


# Delegate to the document, but only from a single view (as each view sends it seperately)
class DocumentNotifyDelegate:
    def __init__(self, doc, name):
        self.doc = doc
        self.delegate = getattr(doc, name)

    def __call__(self, std, extra):
        (hwndFrom, idFrom, code) = std
        if hwndFrom == self.doc.GetEditorView().GetSafeHwnd():
            self.delegate(*(std, extra))
