'''
Generate (replace existing) Makefile file in workspace folder wtih data from
original Makefile and 'c_cpp_properties.json'.
'''

import os
import datetime
from subprocess import Popen, PIPE

import utilities as utils
import templateStrings as tmpStr

import updatePaths as pth
import updateWorkspaceSources as wks
import updateBuildData as build

__version__ = utils.__version__


class MakefileStrings():
    projectName = 'TARGET'
    buildDir = 'BUILD_DIR'

    cSources = 'C_SOURCES'
    asmSources = 'ASM_SOURCES'
    ldSources = 'LIBS'
    cDefines = 'C_DEFS'
    asmDefines = 'AS_DEFS'
    cIncludes = 'C_INCLUDES'
    asmIncludes = 'AS_INCLUDES'
    ldIncludes = 'LIBDIR'
    cFlags = 'CFLAGS'
    asmFlags = 'ASFLAGS'
    ldFlags = 'LDFLAGS'


class Makefile():
    def __init__(self):
        self.mkfStr = MakefileStrings()
        self.cPStr = wks.CPropertiesStrings()

    def checkMakefileFile(self):
        '''
        Check if 'Makefile' file exists. If it doesn't, report as error.
        '''
        if not utils.pathExists(utils.makefilePath):
            errorMsg = "Makefile does not exist! Did CubeMX generated Makefile?\n"
            errorMsg += "File name must be 'Makefile'."
            utils.printAndQuit(errorMsg)

    def restoreOriginalMakefile(self):
        '''
        Check wether current 'Makefile' has print capabilities. If it has, this means it was already altered by this script.
        If it was, replace it with backup copy: 'Makefile.backup'.
        If it does not have print capabilities, it is assumed 'Makefile' was regenerated with CubeMX
        tool - print function is added and backup file is overwritten with this new 'Makefile'.

        At the end, fresh 'Makefile' with print function should be available.
        '''
        if utils.pathExists(utils.makefilePath):
            # Makefile exists, check if it is original (no print capabilities)
            if self.hasPrintCapabilities(utils.makefilePath):
                # Makefile exists, already modified
                if utils.pathExists(utils.makefileBackupPath):
                    # can original file be restored from backup file?
                    if self.hasPrintCapabilities(utils.makefileBackupPath):
                        errorMsg = "Both, 'Makefile' and 'Makefile.backup' exists, but they are both modified!\n"
                        errorMsg += "Did you manually delete, replace or modify any of Makefiles?\n"
                        errorMsg += "-> Delete all Makefiles and regenerate with CubeMX."
                        utils.printAndQuit(errorMsg)
                    else:
                        # original will be restored from backup file
                        print("Existing 'Makefile' file will be restored from 'Makefile.backup'.")
                        utils.copyAndRename(utils.makefileBackupPath, utils.makefilePath)
                else:
                    errorMsg = "'Makefile.backup' does not exist, while 'Makefile' was already modified!\n"
                    errorMsg += "Did you manually delete, replace or modify any of Makefiles?\n"
                    errorMsg += "-> Delete all Makefiles and regenerate with CubeMX."
                    utils.printAndQuit(errorMsg)
            else:
                print("Existing 'Makefile' file found (original).")
                utils.copyAndRename(utils.makefilePath, utils.makefileBackupPath)
        elif utils.pathExists(utils.makefileBackupPath):
            # Makefile does not exist, but Makefile.backup does
            if self.hasPrintCapabilities(utils.makefileBackupPath):
                errorMsg = "'Makefile.backup' exists, but is already modified!\n"
                errorMsg += "Did you manually delete, replace or modify any of Makefiles?\n"
                errorMsg += "-> Delete all Makefiles and regenerate with CubeMX."
                utils.printAndQuit(errorMsg)
            else:
                # original will be restored from backup file
                print("'Makefile' file will be restored from 'Makefile.backup'.")
                utils.copyAndRename(utils.makefileBackupPath, utils.makefilePath)
        else:
            errorMsg = "No Makefiles available, unable to proceed!\n"
            errorMsg += "-> Regenerate with CubeMX."
            utils.printAndQuit(errorMsg)

        self.addMakefileCustomFunctions(pathToMakefile=utils.makefilePath)

    def getMakefileData(self, makeExePath, gccExePath):
        '''
        Get Makefile data.
        Returns data in dictionary.
        '''
        dataDictionaryList = {}

        # project name
        projectName = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.projectName)[0]
        dataDictionaryList[self.mkfStr.projectName] = projectName

        # dir name
        buildDirName = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.buildDir)[0]
        dataDictionaryList[self.mkfStr.buildDir] = buildDirName

        # source files
        cSourcesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.cSources)
        dataDictionaryList[self.mkfStr.cSources] = cSourcesList

        asmSourcesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.asmSources)
        dataDictionaryList[self.mkfStr.asmSources] = asmSourcesList

        ldSourcesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.ldSources)
        # ldSourcesList = utils.stripStartOfString(ldSourcesList, '-l') # more readable without stripping
        dataDictionaryList[self.mkfStr.ldSources] = ldSourcesList

        # defines
        asmDefinesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.asmDefines)
        asmDefinesList = utils.stripStartOfString(asmDefinesList, '-D')
        dataDictionaryList[self.mkfStr.asmDefines] = asmDefinesList

        cDefinesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.cDefines)
        cDefinesList = utils.stripStartOfString(cDefinesList, '-D')
        dataDictionaryList[self.mkfStr.cDefines] = cDefinesList

        # source & include directories
        asmIncludesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.asmIncludes)
        asmIncludesList = utils.stripStartOfString(asmIncludesList, '-I')
        dataDictionaryList[self.mkfStr.asmIncludes] = asmIncludesList

        cIncludesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.cIncludes)
        cIncludesList = utils.stripStartOfString(cIncludesList, '-I')
        dataDictionaryList[self.mkfStr.cIncludes] = cIncludesList

        ldIncludesList = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.ldIncludes)
        ldIncludesList = utils.stripStartOfString(ldIncludesList, '-L')
        dataDictionaryList[self.mkfStr.ldIncludes] = ldIncludesList

        # flags
        cFlags = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.cFlags)
        dataDictionaryList[self.mkfStr.cFlags] = cFlags

        asmFlags = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.asmFlags)
        dataDictionaryList[self.mkfStr.asmFlags] = asmFlags

        ldFlags = self.getMakefileVariable(makeExePath, gccExePath, self.mkfStr.ldFlags)
        dataDictionaryList[self.mkfStr.ldFlags] = ldFlags

        return dataDictionaryList

    def parseMakefileData(self, data, startString):
        '''
        Fetch and unparse data from existing Makefile (generated by CubeMX) starting with 'startString'.
        '''
        endOfLineChars = "\\"
        startString = startString + ' = '
        NOT_FOUND = -1

        items = []
        # find start and end of defines and
        for lineIndex, line in enumerate(data):
            line = line.rstrip('\n')  # strip string of '\n'

            startCharacter = line.find(startString)
            if startCharacter != NOT_FOUND:  # search for start string

                # check if one-liner
                if line.find(endOfLineChars) == NOT_FOUND:
                    line = line[len(startString):]
                    if len(line) != 0:  # check for 'SOMETHING = ' (empty line after '=')
                        # not an empty line after '='
                        items.append(line)  # strip string of start and and characters
                    return items

                else:  # multiline item in Makefile
                    for line2 in data[lineIndex+1:]:
                        line2 = line2.rstrip('\n')
                        if line2.find(endOfLineChars) != NOT_FOUND:
                            line2 = line2.rstrip('\\')  # strip of '\'
                            line2 = line2.rstrip(' ')   # strip of ' '
                            items.append(line2)
                        else:
                            line2 = line2.rstrip('\\')  # strip of '\'
                            line2 = line2.rstrip(' ')   # strip of ' '
                            items.append(line2)
                            return items

        errorMsg = "String item '" + str(startString) + "' not found!\n"
        errorMsg += "Invalid/changed Makefile or this script is outdated (change in CubeMX Makefile syntax?)."
        utils.printAndQuit(errorMsg)

    def createNewMakefile(self):
        '''
        Merge existing Makefile data and user fields from existing 'c_cpp_properties.json.'
        '''
        cP = wks.CProperties()
        cPropertiesData = cP.getCPropertiesData()

        with open(utils.makefilePath, 'r') as makefile:
            data = makefile.readlines()

        # sources
        cSources = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_cSources)
        data = self.searchAndAppend(data, self.mkfStr.cSources, cSources)

        asmSources = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_asmSources)
        data = self.searchAndAppend(data, self.mkfStr.asmSources, asmSources)

        ldSources = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_ldSources)
        data = self.searchAndAppend(data, self.mkfStr.ldSources, ldSources, preappend='-l:')

        # includes
        cIncludes = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_cIncludes)
        data = self.searchAndAppend(data, self.mkfStr.cIncludes, cIncludes, preappend='-I')

        asmIncludes = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_asmIncludes)
        data = self.searchAndAppend(data, self.mkfStr.asmIncludes, asmIncludes, preappend='-I')

        ldIncludes = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_ldIncludes)
        data = self.searchAndAppend(data, self.mkfStr.ldIncludes, ldIncludes, preappend='-L')

        # defines
        cDefines = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_cDefines)
        data = self.searchAndAppend(data, self.mkfStr.cDefines, cDefines, preappend='-D')

        asmDefines = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_asmDefines)
        data = self.searchAndAppend(data, self.mkfStr.asmDefines, asmDefines, preappend='-D')

        # compiler flags
        cFlags = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_cFlags)
        data = self.searchAndAppend(data, self.mkfStr.cFlags, cFlags)

        asmFlags = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_asmFlags)
        data = self.searchAndAppend(data, self.mkfStr.asmFlags, asmFlags)

        ldFlags = cP.getCPropertiesKeyData(cPropertiesData, self.cPStr.user_ldFlags)
        data = self.searchAndAppend(data, self.mkfStr.ldFlags, ldFlags)

        data = self.replaceMakefileHeader(data)

        try:
            with open(utils.makefilePath, 'w') as makefile:
                for line in data:
                    makefile.write(line)
            print("New Makefile data succesfully written.")

        except Exception as err:
            errorMsg = "Exception error writing new data to Makefile:\n"
            errorMsg += str(err)
            utils.printAndQuit(errorMsg)

    def searchAndAppend(self, data, searchString, appendData, preappend=None):
        '''
        Search for string in 'data' list and append 'appendData' according to Makefile syntax.
        if 'preappend' is defined, each item of 'appendData' is preappended with this string.
        '''
        NOT_FOUND = -1

        if preappend is not None:
            appendData = utils.preappendString(appendData, preappend)

        for lineIndex, line in enumerate(data):
            line = line.rstrip('\n')  # strip string of '\n'

            if line.find(searchString) != NOT_FOUND:  # search for start string
                if line[0] == '#':  # this is a comment
                    continue

                if line.find("\\") == NOT_FOUND:
                    # one-liner, no '\' sign at the end of the line
                    if isinstance(appendData, list):  # if this is list
                        if appendData:  # and it is not empty
                            if len(appendData) == 1:  # this list has only one item, add it without '\'
                                if line[-1] != ' ':  # avoid double spaces
                                    line += " "
                                data[lineIndex] = line + appendData[0] + "\n"

                            else:
                                # this is list with multiple items, '\' will be needed
                                line += " \\\n"
                                data[lineIndex] = line

                                for itemIndex, item in enumerate(appendData):
                                    stringToInsert = item
                                    if item != appendData[-1]:  # for last item do not append "\"
                                        stringToInsert += "\\"
                                    stringToInsert += "\n"  # new line must always be added
                                    data.insert(lineIndex + itemIndex + 1, stringToInsert)

                            return data

                    else:  # appendData is string (not list)
                        if appendData != '':
                            if data[lineIndex][-1] != ' ':  # avoid double spaces
                                data[lineIndex] += " "
                            data[lineIndex] += appendData + "\n"

                    return data
                else:  # already a multi-liner, append at the beginning, but in new line
                    if isinstance(appendData, list):
                        for itemIndex, item in enumerate(appendData):
                            stringToInsert = item + " \\\n"
                            data.insert(lineIndex + itemIndex + 1, stringToInsert)
                    else:  # appendData is string (not list)
                        data[lineIndex] += item + " \\\n"

                    return data

        errorMsg = "String item " + str(searchString) + " not found!"
        utils.printAndQuit(errorMsg)

    def searchAndCleanData(self, data, searchString):
        '''
        Search for string in 'data' list and clear all belonging data according to Makefile syntax.
        '''
        NOT_FOUND = -1

        for lineIndex, line in enumerate(data):
            line = line.rstrip('\n')  # strip string of '\n'

            if line.find(searchString) != NOT_FOUND:  # search for start string
                if line[0] == '#':  # this is a comment
                    continue
                if line.find("\\") == NOT_FOUND:
                    # keep searchString and equaliy sign, append '\n'
                    equalitySignCharIndex = line.find('=')
                    data[lineIndex] = data[lineIndex][: equalitySignCharIndex+1] + ' \n'
                    return data

                else:  # multi-liner, get last line index and delete this lines
                    lastLineIndex = lineIndex + 1
                    while data[lastLineIndex].rstrip('\n') != '':
                        lastLineIndex = lastLineIndex + 1
                        if lastLineIndex >= len(data):
                            errorMsg = "Unable to find end of multi-line Makefile item (" + searchString + "). "
                            errorMsg += "Was Makefile manually modified?"
                            utils.printAndQuit(errorMsg)
                    # delete this lines
                    delLineIndex = lineIndex + 1
                    constLineIndex = lineIndex + 1  # this line will be deleted until an empty line is present
                    while delLineIndex != lastLineIndex:
                        del data[constLineIndex]
                        delLineIndex = delLineIndex + 1
                    # keep searchString and equaliy sign, append '\n'
                    equalitySignCharIndex = line.find('=')
                    data[lineIndex] = line[: equalitySignCharIndex+1] + ' \n'
                    return data

        errorMsg = "String item " + str(searchString) + " not found!"
        utils.printAndQuit(errorMsg)

    ########################################################################################################################

    def getMakefileVariable(self, makeExePath, gccExePath, variableName):
        '''
        Open subproces, call make print-variableName and catch stout.
        Syntax with absolute paths:
            "path to make.exe with spaces" GCC_PATH="path to gccsomething.exe with spaces" print-VARIABLE

        With
        '''
        # change directory to the same folder as Makefile
        cwd = os.getcwd()
        os.chdir(utils.workspacePath)

        printStatement = "print-" + str(variableName)
        gccExeFolderPath = os.path.dirname(gccExePath)
        # gccPath = "\"\"GCC_PATH=" + gccExeFolderPath
        gccPath = "GCC_PATH=\"" + gccExeFolderPath + "\""
        arguments = [makeExePath, gccPath, printStatement]

        proc = Popen(arguments, stdout=PIPE)
        returnString = str((proc.communicate()[0]).decode('UTF-8'))
        returnString = returnString.rstrip('\n')
        returnString = returnString.rstrip('\r')

        os.chdir(cwd)  # change directory back to where it was

        if returnString.find("make: *** No rule to make target") != -1:
            errorMsg = "Can't retrieve " + variableName + " value from makefile."
            utils.printAndQuit(errorMsg)

        # remove "VARIABLE=" string start. This string must be present, or 'Echo is off.' is displayed for empy variables.
        if returnString.find(tmpStr.printMakefileDefaultString) != -1:
            returnString = returnString.replace(tmpStr.printMakefileDefaultString, '')

        returnStringList = returnString.split(' ')  # split string to list and remove empty items
        returnStringListCopy = []
        for itemIndex, item in enumerate(returnStringList):
            # handle strings where print statement (print-variableName) is present, like '-MF"print-VARIABLE"'
            quotedPrintStatement = "\"" + printStatement + "\""
            if item.find(quotedPrintStatement) != -1:
                item = item.replace(quotedPrintStatement, '')
            elif item.find(printStatement) != -1:
                item = item.replace(printStatement, '')

            # handle empty items
            if item not in ['', ' ']:
                returnStringListCopy.append(item)

        return returnStringListCopy

    def replaceMakefileHeader(self, data):
        '''
        Change header, to distinguish between original and new Makefile.
        '''
        # first find last line before '# target', that must not be changed
        lastLine = None
        for lineIndex, line in enumerate(data):
            twoLinesAhead = data[lineIndex + 2]  # first line is ######... and second should be '# target'
            twoLinesAhead = twoLinesAhead.rstrip('\n')  # strip string of '\n'
            if twoLinesAhead.find("# target") != -1:  # search for start string
                lastLine = lineIndex
                break
        if lastLine is None:
            print('')  # previously there was no new line
            errorMsg = "Makefile '# target' string missing.\n"
            errorMsg += "Invalid/changed Makefile or this script is outdated (change in CubeMX Makefile syntax?)."
            utils.printAndQuit(errorMsg)

        else:  # '# target' line found
            # delete current header
            lineIndex = 0
            while lineIndex != lastLine:
                lineIndex = lineIndex + 1
                del data[0]

            # add new header
            for line in reversed(tmpStr.makefileHeader.splitlines()):
                if line.find(tmpStr.versionString) != -1:
                    line = line.replace('***', __version__)
                if line.find(tmpStr.lastRunString) != -1:
                    timestamp = datetime.datetime.now()
                    line = line.replace('***', str(timestamp))

                line = line + "\n"
                data.insert(0, line)

        return data

    def hasPrintCapabilities(self, pathToMakefile):
        '''
        Check wether current Makefile has 'print-variable' function.
        Returns True or False.
        '''
        with open(pathToMakefile, 'r+') as makefile:
            data = makefile.readlines()

            # Try to find existing print function
            for line in reversed(data):
                line = line.rstrip('\n')  # strip string of '\n'
                if line.find(tmpStr.printMakefileVariableFunction) != -1:
                    # existing print function found!
                    return True

        return False

    def addMakefileCustomFunctions(self, pathToMakefile):
        '''
        Add all functions to makefile:
            - print-variable
            - clean-build-dir

        This function is called only if current Makefile does not have 'print-variable' capabilities.
        '''
        with open(pathToMakefile, 'r+') as makefile:
            makefileDataLines = makefile.readlines()

            makefileDataLines = self.addPrintVariableFunction(makefileDataLines)

            makefile.seek(0)
            makefile.truncate()
            for line in makefileDataLines:
                makefile.write(line)

    def addPrintVariableFunction(self, makefileDataLines):
        '''
        Add print Makefile variable capabilities to Makefile
        '''
        makefileDataLines.append("\n\n")
        for line in tmpStr.printMakefileVariable.splitlines():
            line = line + "\n"
            makefileDataLines.append(line)

        print("Makefile 'print-variable' function added.")
        return makefileDataLines


########################################################################################################################
if __name__ == "__main__":
    utils.verifyFolderStructure()

    paths = pth.UpdatePaths()
    bData = build.BuildData()
    cP = wks.CProperties()
    makefile = Makefile()

    # Makefile must exist
    makefile.checkMakefileFile()  # no point in continuing if Makefile does not exist

    buildData = bData.prepareBuildData()

    makefile.restoreOriginalMakefile()
    makeExePath = buildData[bData.bStr.buildToolsPath]
    gccExePath = buildData[bData.bStr.gccExePath]
    makefileData = makefile.getMakefileData(makeExePath, gccExePath)

    buildData = bData.addMakefileDataToBuildDataFile(buildData, makefileData)

    # get data from 'c_cpp_properties.json' and create new Makefile
    cP.checkCPropertiesFile()
    makefile.createNewMakefile()  # reads 'c_cpp_properties.json' internally
