# -*- coding: utf-8 -*-
"""novelWriter Project Wrapper

 novelWriter – Project Wrapper
===============================
 Class holding a project

 File History:
 Created: 2018-09-29 [0.0.1]

"""

import logging
import nw

from os import path, mkdir, listdir
from shutil import copyfile
from lxml import etree
from hashlib import sha256
from datetime import datetime
from time import time

from nw.project.status import NWStatus
from nw.project.item import NWItem
from nw.common import checkString, checkBool, checkInt
from nw.constants import (
    nwFiles, nwConst, nwItemType, nwItemClass, nwItemLayout, nwAlert
)

logger = logging.getLogger(__name__)

class NWProject():

    def __init__(self, theParent):

        # Internal
        self.theParent   = theParent
        self.mainConf    = self.theParent.mainConf
        self.projOpened  = None # The time stamp of when the project file was opened
        self.projChanged = None # The project has unsaved changes
        self.projAltered = None # The project has been altered this session

        # Debug
        self.handleSeed = None

        # Class Settings
        self.projTree  = None # Holds all the items of the project
        self.treeOrder = None # The order of the tree items on the tree view
        self.treeRoots = None # The root items of the tree
        self.trashRoot = None # The handle of the trash root folder
        self.projPath  = None # The full path to where the currently open project is saved
        self.projMeta  = None # The full path to the project's meta data folder
        self.projCache = None # The full path to the project's cache folder
        self.projDict  = None # The spell check dictionary
        self.projFile  = None # The file name of the project main xml file

        # Project Meta
        self.projName    = None
        self.bookTitle   = None
        self.bookAuthors = None

        # Various
        self.autoReplace = None

        # Project Settings
        self.spellCheck  = False
        self.statusItems = None
        self.importItems = None
        self.lastEdited  = None
        self.lastViewed  = None
        self.lastWCount  = 0
        self.currWCount  = 0
        self.doBackup    = True

        # Set Defaults
        self.clearProject()

        # Internal Mapping
        self.makeAlert = self.theParent.makeAlert

        return

    ##
    #  Item Methods
    ##

    def newRoot(self, rootName, rootClass):
        if not self.checkRootUnique(rootClass):
            self.makeAlert("Duplicate root item detected!", nwAlert.ERROR)
            return None
        newItem = NWItem(self)
        newItem.setName(rootName)
        newItem.setType(nwItemType.ROOT)
        newItem.setClass(rootClass)
        newItem.setStatus(0)
        self._appendItem(None,None,newItem)
        return newItem.itemHandle

    def newFolder(self, folderName, folderClass, pHandle):
        newItem = NWItem(self)
        newItem.setName(folderName)
        newItem.setType(nwItemType.FOLDER)
        newItem.setClass(folderClass)
        newItem.setStatus(0)
        self._appendItem(None,pHandle,newItem)
        return newItem.itemHandle

    def newFile(self, fileName, fileClass, pHandle):
        newItem = NWItem(self)
        newItem.setName(fileName)
        newItem.setType(nwItemType.FILE)
        if fileClass == nwItemClass.NOVEL:
            newItem.setLayout(nwItemLayout.SCENE)
        else:
            newItem.setLayout(nwItemLayout.NOTE)
        newItem.setClass(fileClass)
        newItem.setStatus(0)
        self._appendItem(None,pHandle,newItem)
        return newItem.itemHandle

    def addTrash(self):
        newItem = NWItem(self)
        newItem.setName("Trash")
        newItem.setType(nwItemType.TRASH)
        newItem.setClass(nwItemClass.TRASH)
        self._appendItem(None,None,newItem)
        return newItem.itemHandle

    ##
    #  Project Methods
    ##

    def newProject(self):
        hNovel = self.newRoot("Novel",         nwItemClass.NOVEL)
        hChars = self.newRoot("Characters",    nwItemClass.CHARACTER)
        hWorld = self.newRoot("Plot",          nwItemClass.PLOT)
        hWorld = self.newRoot("World",         nwItemClass.WORLD)
        hChapt = self.newFolder("New Chapter", nwItemClass.NOVEL, hNovel)
        hScene = self.newFile("New Scene",     nwItemClass.NOVEL, hChapt)
        self.projOpened = time()
        self.setProjectChanged(True)
        return True

    def clearProject(self):

        self.projOpened  = None
        self.projChanged = None
        self.projAltered = False

        # Project Settings
        self.projTree    = {}
        self.treeOrder   = []
        self.treeRoots   = []
        self.trashRoot   = None
        self.projPath    = None
        self.projMeta    = None
        self.projCache   = None
        self.projDict    = None
        self.projFile    = nwFiles.PROJ_FILE
        self.projName    = ""
        self.bookTitle   = ""
        self.bookAuthors = []
        self.autoReplace = {}
        self.spellCheck  = False
        self.statusItems = NWStatus()
        self.statusItems.addEntry("New",     (100,100,100))
        self.statusItems.addEntry("Note",    (200, 50,  0))
        self.statusItems.addEntry("Draft",   (200,150,  0))
        self.statusItems.addEntry("Finished",( 50,200,  0))
        self.importItems = NWStatus()
        self.importItems.addEntry("New",     (100,100,100))
        self.importItems.addEntry("Minor",   (200, 50,  0))
        self.importItems.addEntry("Major",   (200,150,  0))
        self.importItems.addEntry("Main",    ( 50,200,  0))
        self.lastEdited = None
        self.lastViewed = None
        self.lastWCount = 0
        self.currWCount = 0

        return

    def openProject(self, fileName):

        if not path.isfile(fileName):
            fileName = path.join(fileName, nwFiles.PROJ_FILE)
            if not path.isfile(fileName):
                self.makeAlert("File not found: %s" % fileName, nwAlert.ERROR)
                return False

        self.clearProject()
        self.projPath = path.dirname(fileName)
        logger.debug("Opening project: %s" % self.projPath)

        self.projMeta  = path.join(self.projPath,"meta")
        self.projCache = path.join(self.projPath,"cache")
        self.projDict  = path.join(self.projMeta, nwFiles.PROJ_DICT)

        if not self._checkFolder(self.projMeta):
            return
        if not self._checkFolder(self.projCache):
            return

        try:
            nwXML = etree.parse(fileName)
        except Exception as e:
            self.makeAlert(["Failed to parse project xml.",str(e)], nwAlert.ERROR)
            self.clearProject()
            return False

        xRoot   = nwXML.getroot()
        nwxRoot = xRoot.tag

        appVersion  = xRoot.attrib["appVersion"]
        fileVersion = xRoot.attrib["fileVersion"]

        logger.verbose("XML root is %s" % nwxRoot)
        logger.verbose("File version is %s" % fileVersion)

        if not nwxRoot == "novelWriterXML" or not fileVersion == "1.0":
            self.makeAlert(
                "Project file does not appear to be a novelWriterXML file version 1.0",
                nwAlert.ERROR
            )
            return False

        for xChild in xRoot:
            if xChild.tag == "project":
                logger.debug("Found project meta")
                for xItem in xChild:
                    if xItem.text is None:
                        continue
                    if xItem.tag == "name":
                        logger.verbose("Working Title: '%s'" % xItem.text)
                        self.projName = xItem.text
                    elif xItem.tag == "title":
                        logger.verbose("Title is '%s'" % xItem.text)
                        self.bookTitle = xItem.text
                    elif xItem.tag == "author":
                        logger.verbose("Author: '%s'" % xItem.text)
                        self.bookAuthors.append(xItem.text)
                    elif xItem.tag == "backup":
                        self.doBackup = checkBool(xItem.text,False)
            elif xChild.tag == "settings":
                logger.debug("Found project settings")
                for xItem in xChild:
                    if xItem.text is None:
                        continue
                    if xItem.tag == "spellCheck":
                        self.spellCheck = checkBool(xItem.text,False)
                    elif xItem.tag == "lastEdited":
                        self.lastEdited = checkString(xItem.text,None,True)
                    elif xItem.tag == "lastViewed":
                        self.lastViewed = checkString(xItem.text,None,True)
                    elif xItem.tag == "lastWordCount":
                        self.lastWCount = checkInt(xItem.text,0,False)
                    elif xItem.tag == "status":
                        self.statusItems.unpackEntries(xItem)
                    elif xItem.tag == "importance":
                        self.importItems.unpackEntries(xItem)
                    elif xItem.tag == "autoReplace":
                        for xEntry in xItem:
                            self.autoReplace[xEntry.tag] = checkString(xEntry.text,None,False)
            elif xChild.tag == "content":
                logger.debug("Found project content")
                for xItem in xChild:
                    itemAttrib = xItem.attrib
                    if "handle" in xItem.attrib:
                        tHandle = itemAttrib["handle"]
                    else:
                        logger.error("Skipping entry missing handle")
                        continue
                    if "parent" in xItem.attrib:
                        pHandle = itemAttrib["parent"]
                    else:
                        pHandle = None
                    nwItem = NWItem(self)
                    for xValue in xItem:
                        nwItem.setFromTag(xValue.tag,xValue.text)
                    self._appendItem(tHandle,pHandle,nwItem)

        self.mainConf.setRecent(self.projPath)
        self.theParent.setStatus("Opened Project: %s" % self.projName)

        self._scanProjectFolder()
        self.setProjectChanged(False)
        self.projOpened = time()
        self.projAltered = False

        return True

    def saveProject(self, isAuto=False):

        if self.projPath is None:
            self.makeAlert("Project path not set, cannot save.", nwAlert.ERROR)
            return False

        self.projMeta  = path.join(self.projPath,"meta")
        self.projCache = path.join(self.projPath,"cache")

        if not self._checkFolder(self.projPath):  return
        if not self._checkFolder(self.projMeta):  return
        if not self._checkFolder(self.projCache): return

        logger.debug("Saving project: %s" % self.projPath)

        # Save a copy of the current file, just in case
        if not isAuto:
            self._maintainPrevious()

        # Root element and project details
        logger.debug("Writing project meta")
        nwXML = etree.Element("novelWriterXML",attrib={
            "appVersion"  : str(nw.__version__),
            "fileVersion" : "1.0",
            "timeStamp"   : datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        })

        # Save Project Meta
        xProject = etree.SubElement(nwXML, "project")
        self._saveProjectValue(xProject, "name", self.projName,  True)
        self._saveProjectValue(xProject, "title", self.bookTitle, True)
        self._saveProjectValue(xProject, "author", self.bookAuthors)
        self._saveProjectValue(xProject, "backup", self.doBackup)

        # Save Project Settings
        xSettings = etree.SubElement(nwXML, "settings")
        self._saveProjectValue(xSettings, "spellCheck", self.spellCheck)
        self._saveProjectValue(xSettings, "lastEdited", self.lastEdited)
        self._saveProjectValue(xSettings, "lastViewed", self.lastViewed)
        self._saveProjectValue(xSettings, "lastWordCount", self.currWCount)
        xAutoRep = etree.SubElement(xSettings, "autoReplace")
        for aKey, aValue in self.autoReplace.items():
            if len(aKey) > 0:
                self._saveProjectValue(xAutoRep,aKey,aValue)

        xStatus = etree.SubElement(xSettings,"status")
        self.statusItems.packEntries(xStatus)
        xStatus = etree.SubElement(xSettings,"importance")
        self.importItems.packEntries(xStatus)

        # Save Tree Content
        logger.debug("Writing project content")
        xContent = etree.SubElement(nwXML, "content", attrib={"count":str(len(self.treeOrder))})
        for tHandle in self.treeOrder:
            self.projTree[tHandle].packXML(xContent)

        # Write the xml tree to file
        saveFile = path.join(self.projPath,self.projFile)
        try:
            with open(saveFile,mode="wb") as outFile:
                outFile.write(etree.tostring(
                    nwXML,
                    pretty_print    = True,
                    encoding        = "utf-8",
                    xml_declaration = True
                ))
        except Exception as e:
            self.makeAlert(["Failed to save project.",str(e)], nwAlert.ERROR)
            return False

        self.mainConf.setRecent(self.projPath)
        self.theParent.setStatus("Saved Project: %s" % self.projName)
        self.setProjectChanged(False)

        return True

    def closeProject(self):
        self._appendSessionStats()
        self.clearProject()
        return True

    ##
    #  Set Functions
    ##

    def setProjectPath(self, projPath):
        if projPath is None or projPath == "":
            self.projPath = None
        else:
            if projPath.startswith("~"):
                projPath = path.expanduser(projPath)
            self.projPath = projPath
        self.setProjectChanged(True)
        return True

    def setProjectName(self, projName):
        self.projName = projName.strip()
        self.setProjectChanged(True)
        return True

    def setBookTitle(self, bookTitle):
        self.bookTitle = bookTitle.strip()
        self.setProjectChanged(True)
        return True

    def setBookAuthors(self, bookAuthors):
        self.bookAuthors = []
        for bookAuthor in bookAuthors.split("\n"):
            bookAuthor = bookAuthor.strip()
            if bookAuthor == "":
                continue
            self.bookAuthors.append(bookAuthor)
        self.setProjectChanged(True)
        return True

    def setProjBackup(self, doBackup):
        self.doBackup = False
        if doBackup:
            if not path.isdir(self.mainConf.backupPath):
                self.theParent.makeAlert((
                    "You must set a valid backup path in preferences to use "
                    "the automatic project backup feature."
                ), nwAlert.ERROR)
                return False
            if self.projName == "":
                self.theParent.makeAlert((
                    "You must set a valid project name in project settings to "
                    "use the automatic project backup feature."
                ), nwAlert.ERROR)
                return False
            self.doBackup = True
        return True

    def setSpellCheck(self, theMode):
        if self.spellCheck != theMode:
            self.spellCheck = theMode
            self.setProjectChanged(True)
        return True

    def setTreeOrder(self, newOrder):
        if len(self.treeOrder) != len(newOrder):
            logger.warning("Size of new and old tree order does not match")
        self.treeOrder = newOrder
        self.setProjectChanged(True)
        return True

    def setLastEdited(self, tHandle):
        if self.lastEdited != tHandle:
            self.lastEdited = tHandle
            self.setProjectChanged(True)
        return True

    def setLastViewed(self, tHandle):
        if self.lastViewed != tHandle:
            self.lastViewed = tHandle
            self.setProjectChanged(True)
        return True

    def setProjectWordCount(self, theCount):
        if self.currWCount != theCount:
            self.currWCount = theCount
            self.setProjectChanged(True)
        return True

    def getSessionWordCount(self):
        return self.currWCount - self.lastWCount

    def setStatusColours(self, newCols):
        replaceMap = self.statusItems.setNewEntries(newCols)
        if self.projTree is not None:
            for nwItem in self.projTree.values():
                if nwItem.itemClass == nwItemClass.NOVEL:
                    if nwItem.itemStatus in replaceMap.keys():
                        nwItem.setStatus(replaceMap[nwItem.itemStatus])
        self.setProjectChanged(True)
        return

    def setImportColours(self, newCols):
        replaceMap = self.importItems.setNewEntries(newCols)
        if self.projTree is not None:
            for nwItem in self.projTree.values():
                if nwItem.itemClass != nwItemClass.NOVEL:
                    if nwItem.itemStatus in replaceMap.keys():
                        nwItem.setStatus(replaceMap[nwItem.itemStatus])
        self.setProjectChanged(True)
        return

    def setAutoReplace(self, autoReplace):
        self.autoReplace = autoReplace
        return

    def setProjectChanged(self, bValue):
        self.projChanged = bValue
        self.theParent.setProjectStatus(self.projChanged)
        if bValue:
            # If we've changed the project at all, this should be True
            self.projAltered = True
        return self.projChanged

    ##
    #  Get Functions
    ##

    def getItem(self, tHandle):
        if tHandle in self.projTree:
            return self.projTree[tHandle]
        logger.error("No tree item with handle %s" % str(tHandle))
        return None

    def getRootItem(self, tHandle):
        """Iterate upwards in the tree until we find the item with
        parent None, the root item. We do this with a for loop with a
        maximum depth of 200 to make infinite loops impossible.
        """
        tItem = self.getItem(tHandle)
        if tItem is not None:
            for i in range(200):
                tHandle = tItem.parHandle
                tItem   = self.getItem(tHandle)
                if tItem is None:
                    return tHandle
        return None

    def getProjectItems(self):
        """This function is called from the tree view when building the
        tree. Each item in the project is returned in the order saved in
        the project file, but first it checks that it has a parent item
        already sent to the tree.
        """
        sentItems = []
        iterItems = self.treeOrder.copy()
        n    = 0
        nMax = len(iterItems)
        while n < nMax:
            tHandle = iterItems[n]
            tItem   = self.getItem(tHandle)
            n += 1
            if n > 10000:
                return # Just in case
            if tItem is None:
                # Technically a bug since treeOrder is built from the
                # same data as projTree
                continue
            elif tItem.parHandle is None:
                # Item is a root, or already been identified as an
                # orphaned item
                sentItems.append(tHandle)
                yield tItem
            elif tItem.parHandle in sentItems:
                # Item's parent has been sent, so all is fine
                sentItems.append(tHandle)
                yield tItem
            elif tItem.parHandle in iterItems:
                # Item's parent exists, but hasn't been sent yet, so add
                # it again to the end
                logger.warning("Item %s found before its parent" % tHandle)
                iterItems.append(tHandle)
                nMax = len(iterItems)
            else:
                # Item is orphaned
                logger.error("Item %s has no parent in current tree" % tHandle)
                tItem.setParent(None)
                yield tItem

    ##
    #  Class Methods
    ##

    def deleteItem(self, tHandle):
        """This only removes the item from the order list, but not from
        the project tree.
        """
        self.treeOrder.remove(tHandle)
        self.setProjectChanged(True)
        return True

    def findRootItem(self, theClass):
        for aRoot in self.treeRoots:
            if theClass == self.projTree[aRoot].itemClass:
                return self.projTree[aRoot].itemHandle
        return None

    def checkRootUnique(self, theClass):
        """Checks if there already is a root entry of class 'theClass'
        in the root of the project tree.
        """
        if theClass == nwItemClass.CUSTOM:
            return True
        for aRoot in self.treeRoots:
            if theClass == self.projTree[aRoot].itemClass:
                return False
        return True

    def countStatus(self):
        """Count how many times the various status flags are used in the
        project tree.
        """
        self.statusItems.resetCounts()
        self.importItems.resetCounts()
        for nwItem in self.projTree.values():
            if nwItem.itemClass == nwItemClass.NOVEL:
                self.statusItems.countEntry(nwItem.itemStatus)
            else:
                self.importItems.countEntry(nwItem.itemStatus)
        return

    ##
    #  Internal Functions
    ##

    def _checkFolder(self, thePath):
        if not path.isdir(thePath):
            try:
                mkdir(thePath)
                logger.debug("Created folder %s" % thePath)
            except Exception as e:
                self.makeAlert(["Could not create folder.",str(e)], nwAlert.ERROR)
                return False
        return True

    def _saveProjectValue(self, xParent, theName, theValue, allowNone=True):
        if not isinstance(theValue, list):
            theValue = [theValue]
        for aValue in theValue:
            if not isinstance(aValue, str):
                aValue = str(aValue)
            if aValue == "" and not allowNone: continue
            xItem = etree.SubElement(xParent,theName)
            xItem.text = aValue
        return

    def _scanProjectFolder(self):

        if self.projPath is None:
            return

        # First, scan the project data folders
        itemList = []
        for subItem in listdir(self.projPath):
            if subItem[:5] != "data_":
                continue
            dataDir = path.join(self.projPath,subItem)
            for subFile in listdir(dataDir):
                if subFile[-4:] == ".nwd":
                    newItem = path.join(subItem,subFile)
                    itemList.append(newItem)

        # Then check the valid files
        orphanFiles = []
        for fileItem in itemList:
            if len(fileItem) != 28:
                # Just to be safe, shouldn't happen
                logger.warning("Skipping file %s" % fileItem)
                continue
            fHandle = fileItem[5]+fileItem[7:19]
            if fHandle in self.treeOrder:
                logger.debug("Checking file %s, handle %s: OK" % (fileItem,fHandle))
            else:
                logger.debug("Checking file %s, handle %s: Orphaned" % (fileItem,fHandle))
                orphanFiles.append(fHandle)

        # Report status
        if len(orphanFiles) > 0:
            self.makeAlert(
                "Found %d orphaned file(s) in project folder!" % len(orphanFiles),
                nwAlert.WARN
            )
        else:
            logger.debug("File check OK")
            return

        # Handle orphans
        nOrph = 0
        for oHandle in orphanFiles:
            nOrph += 1
            orItem = NWItem(self)
            orItem.setName("Orphaned File %d" % nOrph)
            orItem.setType(nwItemType.FILE)
            orItem.setClass(nwItemClass.NO_CLASS)
            orItem.setLayout(nwItemLayout.NO_LAYOUT)
            self._appendItem(oHandle,None,orItem)

        return

    def _appendItem(self, tHandle, pHandle, nwItem):
        tHandle = checkString(tHandle,self._makeHandle(),False)
        pHandle = checkString(pHandle,None,True)
        logger.verbose("Adding entry %s with parent %s" % (str(tHandle),str(pHandle)))

        nwItem.setHandle(tHandle)
        nwItem.setParent(pHandle)

        self.projTree[tHandle] = nwItem
        self.treeOrder.append(tHandle)

        if nwItem.itemType == nwItemType.ROOT:
            logger.verbose("Entry %s is a root item" % str(tHandle))
            self.treeRoots.append(tHandle)

        if nwItem.itemType == nwItemType.TRASH:
            if self.trashRoot is None:
                logger.verbose("Entry %s is the trash folder" % str(tHandle))
                self.trashRoot = tHandle
            else:
                logger.error("Only one trash folder allowed")

        self.setProjectChanged(True)

        return

    def _appendSessionStats(self):

        if self.projMeta is None:
            return False

        sessionFile = path.join(self.projMeta, nwFiles.SESS_INFO)

        with open(sessionFile, mode="a+", encoding="utf8") as outFile:
            print((
                "Start: {opened:s}  "
                "End: {closed:s}  "
                "Words: {words:8d}"
            ).format(
                opened = datetime.fromtimestamp(self.projOpened).strftime(nwConst.tStampFmt),
                closed = datetime.now().strftime(nwConst.tStampFmt),
                words  = self.getSessionWordCount(),
            ), file=outFile)

        return True

    def _makeHandle(self, addSeed=""):
        if self.handleSeed is None:
            newSeed = str(time()) + addSeed
        else:
            # This is used for debugging
            newSeed = str(self.handleSeed)
            self.handleSeed += 1
        logger.verbose("Generating handle with seed '%s'" % newSeed)
        itemHandle = sha256(newSeed.encode()).hexdigest()[0:13]
        if itemHandle in self.projTree.keys():
            logger.warning("Duplicate handle encountered! Retrying ...")
            itemHandle = self._makeHandle(addSeed+"!")
        return itemHandle

    def _maintainPrevious(self):
        """This function will take the current project file and copy it
        into the project cache folder with an incremental file extension
        added. These serve as a backup in case the xml file gets
        corrupted.
        """

        countFile = path.join(self.projCache, nwFiles.PROJ_COUNT)
        projCount = 0

        if path.isfile(countFile):
            try:
                with open(countFile, mode="r") as inFile:
                    projCount = int(inFile.read())+1
            except:
                projCount = 0

        if projCount > 9:
            projCount = 0

        projBackup = "%s.%d" % (nwFiles.PROJ_FILE, projCount)

        try:
            copyfile(
                path.join(self.projPath, self.projFile),
                path.join(self.projCache, projBackup)
            )
        except:
            logger.error("Failed to write to file %s" % projBackup)

        try:
            with open(countFile, mode="w") as outFile:
                outFile.write(str(projCount))
        except:
            logger.error("Failed to write to file %s" % countFile)

        return

# END Class NWProject
