"""
@package gmodeler.model

@brief wxGUI Graphical Modeler (base classes & read/write)

Classes:
 - model::Model
 - model::ProcessModelFile
 - model::WriteModelFile

(C) 2010-2024 by the GRASS Development Team

This program is free software under the GNU General Public License
(>=v2). Read the file COPYING that comes with GRASS for details.

@author Martin Landa <landa.martin gmail.com>
@author Ondrej Pesek <pesej.ondrek gmail.com>
"""

import os
import getpass
import copy
import re
import mimetypes

import xml.etree.ElementTree as ET
from pathlib import Path
from xml.sax import saxutils

import wx

from core import utils
from core.gcmd import (
    GMessage,
    GException,
    GError,
    RunCommand,
    GWarning,
    GetDefaultEncoding,
)
from gmodeler.model_items import (
    ModelAction,
    ModelCondition,
    ModelComment,
    ModelData,
    ModelDataSeries,
    ModelDataSingle,
    ModelLoop,
    ModelRelation,
)
from gmodeler.dialogs import ModelParamDialog
from core.giface import StandaloneGrassInterface
from gui_core.forms import GUI

from grass.script import task as gtask


class Model:
    """Class representing the model"""

    def __init__(
        self,
        giface,
        canvas=None,
    ):
        self.items = []  # list of ordered items (action/loop/condition)

        # model properties
        self.properties = {
            "name": _("model"),
            "description": _("Script generated by wxGUI Graphical Modeler."),
            "author": getpass.getuser(),
        }
        # model variables
        self.variables = {}
        self.variablesParams = {}

        self.canvas = canvas
        self._giface = giface

    def GetCanvas(self):
        """Get canvas or None"""
        return self.canvas

    def GetItems(self, objType=None):
        """Get list of model items

        :param objType: Object type to filter model objects
        """
        if not objType:
            return self.items

        return [item for item in self.items if isinstance(item, objType)]

    def GetItem(self, aId, objType=None):
        """Get item of given id

        :param aId: item id

        :return: Model* instance
        :return: None if no item found
        """
        ilist = self.GetItems(objType)
        for item in ilist:
            if item.GetId() == aId:
                return item

        return None

    def GetItemIndex(self, item):
        """Return list index of given item"""
        return self.items.index(item)

    def GetNumItems(self, actionOnly=False):
        """Get number of items"""
        if actionOnly:
            return len(self.GetItems(objType=ModelAction))

        return len(self.GetItems())

    def ReorderItems(self, idxList):
        items = []
        for oldIdx, newIdx in idxList.items():
            item = self.items.pop(oldIdx)
            items.append(item)
            self.items.insert(newIdx, item)
            # try:
            #     nextItem = self.items[newIdx+1]
            # except IndexError:
            #     continue # newIdx is the last item in the list
            # items.append(nextItem)
            # x = item.GetX()
            # y = item.GetY()
            # item.SetX(nextItem.GetX())
            # item.SetY(nextItem.GetY())
            # nextItem.SetX(x)
            # nextItem.SetY(y)

        dc = wx.ClientDC(self.canvas)
        for item in items:
            item.MoveLinks(dc)
            for mo in item.GetBlock():
                if isinstance(mo, ModelLoop):
                    self.canvas.parent.DefineLoop(mo)
                elif isinstance(mo, ModelCondition):
                    self.canvas.parent.DefineCondition(mo)

    def Normalize(self):
        # check for inconsistencies
        for idx in range(1, len(self.items)):
            if not self.items[idx].GetBlock() and isinstance(
                self.items[idx - 1], ModelLoop
            ):
                # swap action not-in-block with previously defined
                # loop
                itemPrev = self.items[idx - 1]
                self.items[idx - 1] = self.items[idx]
                self.items[idx] = itemPrev

        # update ids
        iId = 1
        for item in self.items:
            item.SetId(iId)
            item.SetLabel()
            iId += 1

    def GetNextId(self):
        """Get next id (data ignored)

        :return: next id to be used (default: 1)
        """
        if len(self.items) < 1:
            return 1

        currId = self.items[-1].GetId()
        if currId > 0:
            return currId + 1

        return 1

    def GetProperties(self):
        """Get model properties"""
        return self.properties

    def GetVariables(self, params=False):
        """Get model variables"""
        if params:
            return self.variablesParams

        return self.variables

    def SetVariables(self, data):
        """Set model variables"""
        self.variables = data

    def Reset(self):
        """Reset model"""
        self.items = []

    def RemoveItem(self, item, reference=None):
        """Remove item from model

        :item: item to be removed
        :reference: reference item valid for deletion

        :return: list of related items to remove/update
        """
        relList = []
        upList = []
        doRemove = True

        if isinstance(item, ModelAction):
            for rel in item.GetRelations():
                relList.append(rel)
                data = rel.GetData()
                if len(data.GetRelations()) < 2:
                    relList.append(data)
                else:
                    upList.append(data)

        elif isinstance(item, ModelData):
            for rel in item.GetRelations():
                otherItem = rel.GetTo() if rel.GetFrom() == item else rel.GetFrom()
                if reference and reference != otherItem:
                    doRemove = False
                    continue  # avoid recursive deletion
                relList.append(rel)
                if reference and reference != otherItem:
                    relList.append(otherItem)
            if not doRemove:
                upList.append(item)

        elif isinstance(item, ModelLoop):
            for rel in item.GetRelations():
                relList.append(rel)
            for action in self.GetItems():
                action.UnSetBlock(item)

        if doRemove and item in self.items:
            self.items.remove(item)

        return relList, upList

    def FindAction(self, aId):
        """Find action by id"""
        alist = self.GetItems(objType=ModelAction)
        for action in alist:
            if action.GetId() == aId:
                return action

        return None

    def GetMaps(self, prompt):
        """Get list of maps of selected type

        :param prompt: to filter maps"""
        maps = []
        for data in self.GetData():
            if prompt == data.GetPrompt():
                mapName = data.GetValue()
                if not mapName or mapName[0] == "%":
                    continue  # skip variables
                maps.append(mapName)

        return maps

    def GetData(self):
        """Get list of data items"""
        result = []
        dataItems = self.GetItems(objType=ModelData)

        for action in self.GetItems(objType=ModelAction):
            for rel in action.GetRelations():
                dataItem = rel.GetData()
                if dataItem not in result:
                    result.append(dataItem)
                if dataItem in dataItems:
                    dataItems.remove(dataItem)

        # standalone data
        if dataItems:
            result += dataItems

        return result

    def FindData(self, value, prompt):
        """Find data item in the model

        :param value: value
        :param prompt: prompt

        :return: ModelData instance
        :return: None if not found
        """
        for data in self.GetData():
            if data.GetValue() == value and data.GetPrompt() == prompt:
                return data

        return None

    def LoadModel(self, filename):
        """Load model definition stored in GRASS Model XML file (gxm)

        .. todo::
             Validate against DTD

        Raise exception on error.
        """
        # parse workspace file
        try:
            gxmXml = ProcessModelFile(ET.parse(filename))
        except Exception as e:
            msg = "{}".format(e)
            raise GException(msg)

        if self.canvas:
            win = self.canvas.parent
            if isinstance(win._giface, StandaloneGrassInterface):
                if gxmXml.pos:
                    win.SetPosition(gxmXml.pos)
                if gxmXml.size:
                    win.SetSize(gxmXml.size)

        # load properties
        self.properties = gxmXml.properties
        self.variables = gxmXml.variables

        # load actions
        for action in gxmXml.actions:
            actionItem = ModelAction(
                parent=self,
                x=action["pos"][0],
                y=action["pos"][1],
                width=action["size"][0],
                height=action["size"][1],
                task=action["task"],
                id=action["id"],
                label=action["label"],
                comment=action["comment"],
            )

            if action["disabled"]:
                actionItem.Enable(False)

            self.AddItem(actionItem, pos=actionItem.GetId() - 1)

            actionItem.SetValid(actionItem.GetTask().get_options())
            actionItem.GetLog()  # substitute variables (-> valid/invalid)

        # load data & relations
        for data in gxmXml.data:
            dataClass = (
                ModelDataSeries if data["prompt"].startswith("st") else ModelDataSingle
            )
            dataItem = dataClass(
                parent=self,
                x=data["pos"][0],
                y=data["pos"][1],
                width=data["size"][0],
                height=data["size"][1],
                prompt=data["prompt"],
                value=data["value"],
            )
            dataItem.SetIntermediate(data["intermediate"])
            dataItem.SetHasDisplay(data["display"])

            for rel in data["rels"]:
                actionItem = self.FindAction(rel["id"])

                if rel["dir"] == "from":
                    relation = ModelRelation(
                        parent=self,
                        fromShape=dataItem,
                        toShape=actionItem,
                        param=rel["name"],
                    )
                else:
                    relation = ModelRelation(
                        parent=self,
                        fromShape=actionItem,
                        toShape=dataItem,
                        param=rel["name"],
                    )
                relation.SetControlPoints(rel["points"])
                actionItem.AddRelation(relation)
                dataItem.AddRelation(relation)

            if self.canvas:
                dataItem.Update()

        # load loops
        for loop in gxmXml.loops:
            loopItem = ModelLoop(
                parent=self,
                x=loop["pos"][0],
                y=loop["pos"][1],
                width=loop["size"][0],
                height=loop["size"][1],
                label=loop["text"],
                id=loop["id"],
            )
            self.AddItem(loopItem, pos=loopItem.GetId() - 1)

        # load conditions
        for condition in gxmXml.conditions:
            conditionItem = ModelCondition(
                parent=self,
                x=condition["pos"][0],
                y=condition["pos"][1],
                width=condition["size"][0],
                height=condition["size"][1],
                label=condition["text"],
                id=condition["id"],
            )
            self.AddItem(conditionItem, pos=conditionItem.GetId() - 1)

        # define loops & if/else items
        for loop in gxmXml.loops:
            loopItem = self.GetItem(loop["id"], objType=ModelLoop)
            loopItem.SetItems(loop["items"])
            for idx in loop["items"]:
                action = self.GetItem(idx, objType=ModelAction)
                action.SetBlock(loopItem)

        for condition in gxmXml.conditions:
            conditionItem = self.GetItem(condition["id"])
            for b in condition["items"].keys():
                alist = []
                for aId in condition["items"][b]:
                    action = self.GetItem(aId)
                    alist.append(action)
                conditionItem.SetItems(alist, branch=b)

            items = conditionItem.GetItems()
            for b in items.keys():
                for action in items[b]:
                    action.SetBlock(conditionItem)

        # load comments
        for comment in gxmXml.comments:
            commentItem = ModelComment(
                parent=self,
                x=comment["pos"][0],
                y=comment["pos"][1],
                width=comment["size"][0],
                height=comment["size"][1],
                id=comment["id"],
                label=comment["text"],
            )

            self.AddItem(commentItem, pos=commentItem.GetId() - 1)

    def AddItem(self, newItem, pos=-1):
        """Add item to the list"""
        if pos != -1:
            self.items.insert(pos, newItem)
        else:
            self.items.append(newItem)
        # i = 1
        # for item in self.items:
        #     item.SetId(i)
        #     i += 1

    def IsValid(self) -> bool:
        """Return True if model is valid"""
        return not self.Validate()

    def Validate(self):
        """Validate model, return None if model is valid otherwise
        error string"""
        errList = []

        variables = self.GetVariables().keys()
        pattern = re.compile(r"(.*)(%\{.+})(.*)")
        for action in self.GetItems(objType=ModelAction):
            cmd = action.GetLog(string=False)

            task = GUI(show=None).ParseCommand(cmd=cmd)
            errList += (cmd[0] + ": " + x for x in task.get_cmd_error())

            # check also variables
            for opt in cmd[1:]:
                if "=" not in opt:
                    continue
                key, value = opt.split("=", 1)
                sval = pattern.search(value)
                if not sval:
                    continue
                var = sval.group(2).strip()[2:-1]  # strip '%{...}'
                found = False
                for v in variables:
                    if var.startswith(v):
                        found = True
                        break
                if found:
                    continue
                report = True
                for item in filter(
                    lambda x: isinstance(x, ModelLoop), action.GetBlock()
                ):
                    if var in item.GetLabel():
                        report = False
                        break
                if report:
                    errList.append(cmd[0] + ": " + _("undefined variable '%s'") % var)
            # TODO: check variables in file only optionally
            # errList += self._substituteFile(action, checkOnly = True)

        return errList

    def _substituteFile(self, item, params=None, checkOnly=False):
        """Substitute variables in command file inputs

        :param bool checkOnly: True to check variable, don't touch files

        :return: list of undefined variables
        """
        errList = []

        self.fileInput = {}

        # collect ascii inputs
        for p in item.GetParams()["params"]:
            if (
                p.get("element", "") == "file"
                and p.get("prompt", "") == "input"
                and p.get("age", "") == "old"
            ):
                filename = p.get("value", p.get("default", ""))
                if filename and mimetypes.guess_type(filename)[0] == "text/plain":
                    self.fileInput[filename] = None

        for finput in self.fileInput:
            # read lines
            data = Path(finput).read_text()
            self.fileInput[finput] = data

            # substitute variables
            write = False
            variables = self.GetVariables()
            for variable in variables:
                pattern = re.compile("%{" + variable + "}")
                value = ""
                if params and "variables" in params:
                    for p in params["variables"]["params"]:
                        if variable == p.get("name", ""):
                            if p.get("type", "string") == "string":
                                value = p.get("value", "")
                            else:
                                value = str(p.get("value", ""))
                            break

                if not value:
                    value = variables[variable].get("value", "")

                data = pattern.sub(value, data)
                if not checkOnly:
                    write = True

            pattern = re.compile(r"(.*)(%\{.+})(.*)")
            sval = pattern.search(data)
            if sval:
                var = sval.group(2).strip()[2:-1]  # ignore '%{...}'
                cmd = item.GetLog(string=False)[0]
                errList.append(cmd + ": " + _("undefined variable '%s'") % var)

            if not checkOnly:
                if write:
                    Path(finput).write_text(data)
                else:
                    self.fileInput[finput] = None

        return errList

    def OnPrepare(self, item, params):
        self._substituteFile(item, params, checkOnly=False)

    def RunAction(self, item, params, log, onDone=None, onPrepare=None, statusbar=None):
        """Run given action

        :param item: action item
        :param params: parameters dict
        :param log: logging window
        :param onDone: on-done method
        :param onPrepare: on-prepare method
        :param statusbar: wx.StatusBar instance or None
        """
        name = "({0}) {1}".format(item.GetId(), item.GetLabel())
        if name in params:
            paramsOrig = item.GetParams(dcopy=True)
            item.MergeParams(params[name])

        if statusbar:
            statusbar.SetStatusText(_("Running model..."), 0)

        data = {"item": item, "params": copy.deepcopy(params)}
        log.RunCmd(
            command=item.GetLog(string=False, substitute=params),
            onDone=onDone,
            onPrepare=self.OnPrepare,
            userData=data,
        )

        if name in params:
            item.SetParams(paramsOrig)

    def Run(self, log, onDone, parent=None):
        """Run model

        :param log: logging window (see gconsole.GConsole)
        :param onDone: on-done method
        :param parent: window for messages or None
        """
        if self.GetNumItems() < 1:
            GMessage(parent=parent, message=_("Model is empty. Nothing to run."))
            return

        statusbar = None
        if isinstance(parent, wx.Frame):
            statusbar = parent.GetStatusBar()

        # validation
        if statusbar:
            statusbar.SetStatusText(_("Validating model..."), 0)
        errList = self.Validate()
        if statusbar:
            statusbar.SetStatusText("", 0)
        if errList:
            dlg = wx.MessageDialog(
                parent=parent,
                message=_(
                    "Model is not valid. Do you want to run the model anyway?\n\n%s"
                )
                % "\n".join(errList),
                caption=_("Run model?"),
                style=wx.YES_NO | wx.NO_DEFAULT | wx.ICON_QUESTION | wx.CENTRE,
            )
            ret = dlg.ShowModal()
            dlg.Destroy()
            if ret != wx.ID_YES:
                return

        # parametrization
        params = self.Parameterize()
        delInterData = False
        if params:
            dlg = ModelParamDialog(
                parent=parent, model=self, params=params, giface=self._giface
            )
            dlg.CenterOnParent()

            ret = dlg.ShowModal()
            if ret != wx.ID_OK:
                dlg.Destroy()
                return

            err = dlg.GetErrors()
            delInterData = dlg.DeleteIntermediateData()
            dlg.Destroy()
            if err:
                GError(parent=parent, message="\n".join(err))
                return

            err = [
                (key, p.get("name", ""), p.get("description", ""))
                for key, item in params.items()
                for p in item["params"]
                if p.get("value", "") == ""
            ]
            if err:
                GError(
                    parent=parent,
                    message=_("Variables below not defined:")
                    + "\n\n"
                    + "\n".join(f"{x[0]}: {x[1]} ({x[2]})" for x in err),
                )
                return

        log.cmdThread.SetId(-1)
        for item in self.GetItems():
            if not item.IsEnabled():
                continue
            if isinstance(item, ModelAction):
                if item.GetBlockId():
                    continue
                self.RunAction(item, params, log)
            elif isinstance(item, ModelLoop):
                cond = item.GetLabel()

                # substitute variables in condition
                variables = self.GetVariables()
                for variable in variables:
                    pattern = re.compile("%{" + variable + "}")
                    if not pattern.search(cond):
                        continue
                    value = ""
                    if params and "variables" in params:
                        for p in params["variables"]["params"]:
                            if variable == p.get("name", ""):
                                value = p.get("value", "")
                                break

                    if not value:
                        value = variables[variable].get("value", "")

                    if not value:
                        continue
                    vtype = variables[variable].get("type", "string")
                    if vtype == "string":
                        value = '"' + value + '"'
                    cond = pattern.sub(value, cond)

                # split condition
                # TODO: this part needs some better solution
                condVar, condText = (x.strip() for x in re.split(r"\s* in \s*", cond))
                pattern = re.compile("%{" + condVar + "}")
                # for vars()[condVar] in eval(condText): ?
                vlist = []
                if condText[0] == "`" and condText[-1] == "`":
                    # run command
                    cmd, dcmd = gtask.cmdlist_to_tuple(condText[1:-1].split(" "))
                    ret = RunCommand(cmd, read=True, **dcmd)
                    if ret:
                        vlist = ret.splitlines()
                else:
                    vlist = eval(condText)

                if "variables" not in params:
                    params["variables"] = {"params": []}
                varDict = {"name": condVar, "value": ""}
                params["variables"]["params"].append(varDict)

                for var in vlist:
                    for action in item.GetItems(self.GetItems()):
                        if not action.IsEnabled():
                            continue

                        varDict["value"] = var

                        self.RunAction(item=action, params=params, log=log)
                params["variables"]["params"].remove(varDict)

        if delInterData:
            self.DeleteIntermediateData(log)

        # discard values
        if params:
            for item in params.values():
                for p in item["params"]:
                    p["value"] = ""

    def DeleteIntermediateData(self, log):
        """Delete intermediate data"""
        rast, vect, rast3d, msg = self.GetIntermediateData()

        if rast:
            log.RunCmd(["g.remove", "-f", "type=raster", "name=%s" % ",".join(rast)])
        if rast3d:
            log.RunCmd(
                ["g.remove", "-f", "type=raster_3d", "name=%s" % ",".join(rast3d)]
            )
        if vect:
            log.RunCmd(["g.remove", "-f", "type=vector", "name=%s" % ",".join(vect)])

    def GetIntermediateData(self):
        """Get info about intermediate data"""
        rast = []
        rast3d = []
        vect = []
        for data in self.GetData():
            if not data.IsIntermediate():
                continue
            name = data.GetValue()
            prompt = data.GetPrompt()
            if prompt == "raster":
                rast.append(name)
            elif prompt == "vector":
                vect.append(name)
            elif prompt == "raster_3d":
                rast3d.append(name)

        msg = ""
        if rast:
            msg += "\n\n%s: " % _("Raster maps")
            msg += ", ".join(rast)
        if rast3d:
            msg += "\n\n%s: " % _("3D raster maps")
            msg += ", ".join(rast3d)
        if vect:
            msg += "\n\n%s: " % _("Vector maps")
            msg += ", ".join(vect)

        return rast, vect, rast3d, msg

    def Update(self):
        """Update model"""
        for item in self.items:
            item.Update()

    def IsParameterized(self) -> bool:
        """Return True if model is parameterized"""
        return bool(self.Parameterize())

    def Parameterize(self):
        """Return parameterized options"""
        result = {}
        idx = 0
        if self.variables:
            params = []
            result["variables"] = {"flags": [], "params": params, "idx": idx}
            for name, values in self.variables.items():
                gtype = values.get("type", "string")
                if gtype in {"raster", "vector", "mapset", "file", "region", "dir"}:
                    gisprompt = True
                    prompt = gtype
                    element = "cell" if gtype == "raster" else gtype
                    ptype = "string"
                else:
                    gisprompt = False
                    prompt = None
                    element = None
                    ptype = gtype
                params.append(
                    {
                        "gisprompt": gisprompt,
                        "multiple": False,
                        "description": values.get("description", ""),
                        "guidependency": "",
                        "default": "",
                        "age": None,
                        "required": True,
                        "value": values.get("value", ""),
                        "label": "",
                        "guisection": "",
                        "key_desc": "",
                        "values": [],
                        "parameterized": False,
                        "values_desc": [],
                        "prompt": prompt,
                        "element": element,
                        "type": ptype,
                        "name": name,
                    }
                )

            idx += 1

        for action in self.GetItems(objType=ModelAction):
            if not action.IsEnabled():
                continue
            name = "({0}) {1}".format(action.GetId(), action.GetLabel())
            params = action.GetParams()
            increment = False
            for f in params["flags"]:
                if f.get("parameterized", False):
                    if name not in result:
                        increment = True
                        result[name] = {"flags": [], "params": [], "idx": idx}
                    result[name]["flags"].append(f)
            for p in params["params"]:
                if p.get("parameterized", False):
                    if name not in result:
                        increment = True
                        result[name] = {"flags": [], "params": [], "idx": idx}
                    result[name]["params"].append(p)
            if increment:
                idx += 1

        self.variablesParams = result  # record parameters

        return result


class ProcessModelFile:
    """Process GRASS model file (gxm)"""

    def __init__(self, tree):
        """A ElementTree handler for the GXM XML file, as defined in
        grass-gxm.dtd.
        """
        self.tree = tree
        self.root = self.tree.getroot()
        # check if input is a valid GXM file
        if self.root is None or self.root.tag != "gxm":
            tagName = self.root.tag if self.root is not None else _("empty")
            raise GException(_("Details: unsupported tag name '{0}'.").format(tagName))

        # list of actions, data
        self.properties = {}
        self.variables = {}
        self.actions = []
        self.data = []
        self.loops = []
        self.conditions = []
        self.comments = []

        self._processWindow()
        self._processProperties()
        self._processVariables()
        self._processItems()
        self._processData()

    def _filterValue(self, value):
        """Filter value

        :param value:
        """
        value = value.replace("&lt;", "<")
        return value.replace("&gt;", ">")

    def _getNodeText(self, node, tag, default=""):
        """Get node text"""
        p = node.find(tag)
        if p is None:
            return default
        if p.text:
            return utils.normalize_whitespace(p.text)
        return ""

    def _processWindow(self):
        """Process window properties"""
        node = self.root.find("window")
        if node is None:
            self.pos = self.size = None
            return

        self.pos, self.size = self._getDim(node)

    def _processProperties(self):
        """Process model properties"""
        node = self.root.find("properties")
        if node is None:
            return
        for key in ("name", "description", "author"):
            self._processProperty(node, key)

        for f in node.findall("flag"):
            name = f.get("name", "")
            if name == "overwrite":
                self.properties["overwrite"] = True

    def _processProperty(self, pnode, name):
        """Process given property"""
        node = pnode.find(name)
        if node is not None:
            self.properties[name] = node.text
        else:
            self.properties[name] = ""

    def _processVariables(self):
        """Process model variables"""
        vnode = self.root.find("variables")
        if vnode is None:
            return
        for node in vnode.findall("variable"):
            name = node.get("name", "")
            if not name:
                continue  # should not happen
            self.variables[name] = {"type": node.get("type", "string")}
            for key in ("description", "value"):
                self._processVariable(node, name, key)

    def _processVariable(self, pnode, name, key):
        """Process given variable"""
        node = pnode.find(key)
        if node is not None:
            if node.text:
                self.variables[name][key] = node.text

    def _processItems(self):
        """Process model items (actions, loops, conditions)"""
        self._processActions()
        self._processLoops()
        self._processConditions()
        self._processComments()

    def _processActions(self):
        """Process model file"""
        for action in self.root.findall("action"):
            pos, size = self._getDim(action)
            disabled = False

            task = action.find("task")
            if task is not None:
                if task.find("disabled") is not None:
                    disabled = True
                task = self._processTask(task)
            else:
                task = None

            aId = int(action.get("id", -1))
            label = action.get("name")
            comment = action.find("comment")
            commentString = comment.text if comment is not None else ""

            self.actions.append(
                {
                    "pos": pos,
                    "size": size,
                    "task": task,
                    "id": aId,
                    "disabled": disabled,
                    "label": label,
                    "comment": commentString,
                }
            )

    def _getDim(self, node):
        """Get position and size of shape"""
        pos = size = None
        posAttr = node.get("pos", None)
        if posAttr:
            posVal = list(map(int, posAttr.split(",")))
            try:
                pos = (posVal[0], posVal[1])
            except IndexError:
                pos = None

        sizeAttr = node.get("size", None)
        if sizeAttr:
            sizeVal = list(map(int, sizeAttr.split(",")))
            try:
                size = (sizeVal[0], sizeVal[1])
            except IndexError:
                size = None

        return pos, size

    def _processData(self):
        """Process model file"""
        for data in self.root.findall("data"):
            pos, size = self._getDim(data)
            param = data.find("data-parameter")
            prompt = value = None
            if param is not None:
                prompt = param.get("prompt", None)
                value = self._filterValue(self._getNodeText(param, "value"))

            intermediate = data.find("intermediate") is not None

            display = data.find("display") is not None

            rels = []
            for rel in data.findall("relation"):
                defrel = {
                    "id": int(rel.get("id", -1)),
                    "dir": rel.get("dir", "to"),
                    "name": rel.get("name", ""),
                }
                points = []
                for point in rel.findall("point"):
                    x = self._filterValue(self._getNodeText(point, "x"))
                    y = self._filterValue(self._getNodeText(point, "y"))
                    points.append((float(x), float(y)))
                defrel["points"] = points
                rels.append(defrel)

            self.data.append(
                {
                    "pos": pos,
                    "size": size,
                    "prompt": prompt,
                    "value": value,
                    "intermediate": intermediate,
                    "display": display,
                    "rels": rels,
                }
            )

    def _processTask(self, node):
        """Process task

        :return: grassTask instance
        :return: None on error
        """
        cmd = []
        parameterized = []

        name = node.get("name", None)
        if not name:
            return None

        cmd.append(name)

        # flags
        for f in node.findall("flag"):
            flag = f.get("name", "")
            if f.get("parameterized", "0") == "1":
                parameterized.append(("flag", flag))
                if f.get("value", "1") == "0":
                    continue
            if len(flag) > 1:
                cmd.append("--" + flag)
            else:
                cmd.append("-" + flag)
        # parameters
        for p in node.findall("parameter"):
            name = p.get("name", "")
            if p.find("parameterized") is not None:
                parameterized.append(("param", name))
            cmd.append(
                "%s=%s" % (name, self._filterValue(self._getNodeText(p, "value")))
            )

        task, err = GUI(show=None, checkError=True).ParseCommand(cmd=cmd)
        if err:
            GWarning(os.linesep.join(err))

        for opt, name in parameterized:
            if opt == "flag":
                task.set_flag(name, True, element="parameterized")
            else:
                task.set_param(name, True, element="parameterized")

        return task

    def _processLoops(self):
        """Process model loops"""
        for node in self.root.findall("loop"):
            pos, size = self._getDim(node)
            text = self._filterValue(self._getNodeText(node, "condition")).strip()
            aid = []
            for anode in node.findall("item"):
                try:
                    aid.append(int(anode.text))
                except ValueError:
                    pass

            self.loops.append(
                {
                    "pos": pos,
                    "size": size,
                    "text": text,
                    "id": int(node.get("id", -1)),
                    "items": aid,
                }
            )

    def _processConditions(self):
        """Process model conditions"""
        for node in self.root.findall("if-else"):
            pos, size = self._getDim(node)
            text = self._filterValue(self._getNodeText(node, "condition")).strip()
            aid = {"if": [], "else": []}
            for b in aid.keys():  # noqa: PLC0206
                bnode = node.find(b)
                if bnode is None:
                    continue
                for anode in bnode.findall("item"):
                    try:
                        aid[b].append(int(anode.text))
                    except ValueError:
                        pass

            self.conditions.append(
                {
                    "pos": pos,
                    "size": size,
                    "text": text,
                    "id": int(node.get("id", -1)),
                    "items": aid,
                }
            )

    def _processComments(self):
        """Process model comments"""
        for node in self.root.findall("comment"):
            pos, size = self._getDim(node)
            text = self._filterValue(node.text)

            self.comments.append(
                {
                    "pos": pos,
                    "size": size,
                    "text": text,
                    "id": int(node.get("id", -1)),
                }
            )


class WriteModelFile:
    """Generic class for writing model file"""

    def __init__(self, fd, model):
        self.fd = fd
        self.model = model
        self.properties = model.GetProperties()
        self.variables = model.GetVariables()
        self.items = model.GetItems()

        self.indent = 0

        self._header()

        self._window()
        self._properties()
        self._variables()
        self._items()

        dataList = []
        for action in model.GetItems(objType=ModelAction):
            for rel in action.GetRelations():
                dataItem = rel.GetData()
                if dataItem not in dataList:
                    dataList.append(dataItem)
        self._data(dataList)

        self._footer()

    def _filterValue(self, value):
        """Escapes value to be stored in XML.

        :param value: string to be escaped as XML
        :return: an XML-valid string
        """
        return saxutils.escape(value)

    def _header(self):
        """Write header"""
        self.fd.write(
            '<?xml version="1.0" encoding="%s"?>\n' % GetDefaultEncoding(forceUTF8=True)
        )
        self.fd.write('<!DOCTYPE gxm SYSTEM "grass-gxm.dtd">\n')
        self.fd.write("%s<gxm>\n" % (" " * self.indent))
        self.indent += 4

    def _footer(self):
        """Write footer"""
        self.indent -= 4
        self.fd.write("%s</gxm>\n" % (" " * self.indent))

    def _window(self):
        """Write window properties"""
        canvas = self.model.GetCanvas()
        if canvas is None:
            return
        win = canvas.parent
        pos = win.GetPosition()
        size = win.GetSize()
        self.fd.write(
            '%s<window pos="%d,%d" size="%d,%d" />\n'
            % (" " * self.indent, pos[0], pos[1], size[0], size[1])
        )

    def _properties(self):
        """Write model properties"""
        self.fd.write("%s<properties>\n" % (" " * self.indent))
        self.indent += 4
        if self.properties["name"]:
            self.fd.write(
                "%s<name>%s</name>\n" % (" " * self.indent, self.properties["name"])
            )
        if self.properties["description"]:
            self.fd.write(
                "%s<description>%s</description>\n"
                % (" " * self.indent, self.properties["description"])
            )
        if self.properties["author"]:
            self.fd.write(
                "%s<author>%s</author>\n"
                % (" " * self.indent, self.properties["author"])
            )

        if "overwrite" in self.properties and self.properties["overwrite"]:
            self.fd.write('%s<flag name="overwrite" />\n' % (" " * self.indent))
        self.indent -= 4
        self.fd.write("%s</properties>\n" % (" " * self.indent))

    def _variables(self):
        """Write model variables"""
        if not self.variables:
            return
        self.fd.write("%s<variables>\n" % (" " * self.indent))
        self.indent += 4
        for name, values in self.variables.items():
            self.fd.write(
                '%s<variable name="%s" type="%s">\n'
                % (" " * self.indent, name, values["type"])
            )
            self.indent += 4
            if "value" in values:
                self.fd.write(
                    "%s<value>%s</value>\n" % (" " * self.indent, values["value"])
                )
            if "description" in values:
                self.fd.write(
                    "%s<description>%s</description>\n"
                    % (" " * self.indent, values["description"])
                )
            self.indent -= 4
            self.fd.write("%s</variable>\n" % (" " * self.indent))
        self.indent -= 4
        self.fd.write("%s</variables>\n" % (" " * self.indent))

    def _items(self):
        """Write actions/loops/conditions"""
        for item in self.items:
            if isinstance(item, ModelAction):
                self._action(item)
            elif isinstance(item, ModelLoop):
                self._loop(item)
            elif isinstance(item, ModelCondition):
                self._condition(item)
            elif isinstance(item, ModelComment):
                self._comment(item)

    def _action(self, action):
        """Write actions"""
        self.fd.write(
            '%s<action id="%d" name="%s" pos="%d,%d" size="%d,%d">\n'
            % (
                " " * self.indent,
                action.GetId(),
                action.GetLabel(),
                action.GetX(),
                action.GetY(),
                action.GetWidth(),
                action.GetHeight(),
            )
        )
        self.indent += 4
        comment = action.GetComment()
        if comment:
            self.fd.write("%s<comment>%s</comment>\n" % (" " * self.indent, comment))
        self.fd.write(
            '%s<task name="%s">\n' % (" " * self.indent, action.GetLog(string=False)[0])
        )
        self.indent += 4
        if not action.IsEnabled():
            self.fd.write("%s<disabled />\n" % (" " * self.indent))
        for key, val in action.GetParams().items():
            if key == "flags":
                for f in val:
                    if f.get("value", False) or f.get("parameterized", False):
                        if f.get("parameterized", False):
                            if f.get("value", False) is False:
                                self.fd.write(
                                    '%s<flag name="%s" value="0" parameterized="1" />\n'
                                    % (" " * self.indent, f.get("name", ""))
                                )
                            else:
                                self.fd.write(
                                    '%s<flag name="%s" parameterized="1" />\n'
                                    % (" " * self.indent, f.get("name", ""))
                                )
                        else:
                            self.fd.write(
                                '%s<flag name="%s" />\n'
                                % (" " * self.indent, f.get("name", ""))
                            )
            else:  # parameter
                for p in val:
                    if not p.get("value", "") and not p.get("parameterized", False):
                        continue
                    self.fd.write(
                        '%s<parameter name="%s">\n'
                        % (" " * self.indent, p.get("name", ""))
                    )
                    self.indent += 4
                    if p.get("parameterized", False):
                        self.fd.write("%s<parameterized />\n" % (" " * self.indent))
                    self.fd.write(
                        "%s<value>%s</value>\n"
                        % (" " * self.indent, self._filterValue(p.get("value", "")))
                    )
                    self.indent -= 4
                    self.fd.write("%s</parameter>\n" % (" " * self.indent))
        self.indent -= 4
        self.fd.write("%s</task>\n" % (" " * self.indent))
        self.indent -= 4
        self.fd.write("%s</action>\n" % (" " * self.indent))

    def _data(self, dataList):
        """Write data"""
        for data in dataList:
            self.fd.write(
                '%s<data pos="%d,%d" size="%d,%d">\n'
                % (
                    " " * self.indent,
                    data.GetX(),
                    data.GetY(),
                    data.GetWidth(),
                    data.GetHeight(),
                )
            )
            self.indent += 4
            self.fd.write(
                '%s<data-parameter prompt="%s">\n'
                % (" " * self.indent, data.GetPrompt())
            )
            self.indent += 4
            self.fd.write(
                "%s<value>%s</value>\n"
                % (" " * self.indent, self._filterValue(data.GetValue()))
            )
            self.indent -= 4
            self.fd.write("%s</data-parameter>\n" % (" " * self.indent))

            if data.IsIntermediate():
                self.fd.write("%s<intermediate />\n" % (" " * self.indent))
            if data.HasDisplay():
                self.fd.write("%s<display />\n" % (" " * self.indent))

            # relations
            for ft in ("from", "to"):
                for rel in data.GetRelations(ft):
                    aid = rel.GetTo().GetId() if ft == "from" else rel.GetFrom().GetId()
                    self.fd.write(
                        '%s<relation dir="%s" id="%d" name="%s">\n'
                        % (" " * self.indent, ft, aid, rel.GetLabel())
                    )
                    self.indent += 4
                    for point in rel.GetLineControlPoints()[1:-1]:
                        self.fd.write("%s<point>\n" % (" " * self.indent))
                        self.indent += 4
                        x, y = point.Get()
                        self.fd.write("%s<x>%d</x>\n" % (" " * self.indent, int(x)))
                        self.fd.write("%s<y>%d</y>\n" % (" " * self.indent, int(y)))
                        self.indent -= 4
                        self.fd.write("%s</point>\n" % (" " * self.indent))
                    self.indent -= 4
                    self.fd.write("%s</relation>\n" % (" " * self.indent))

            self.indent -= 4
            self.fd.write("%s</data>\n" % (" " * self.indent))

    def _loop(self, loop):
        """Write loops"""
        self.fd.write(
            '%s<loop id="%d" pos="%d,%d" size="%d,%d">\n'
            % (
                " " * self.indent,
                loop.GetId(),
                loop.GetX(),
                loop.GetY(),
                loop.GetWidth(),
                loop.GetHeight(),
            )
        )
        self.indent += 4
        cond = loop.GetLabel()
        if cond:
            self.fd.write(
                "%s<condition>%s</condition>\n"
                % (" " * self.indent, self._filterValue(cond))
            )
        for item in loop.GetItems(self.model.GetItems(objType=ModelAction)):
            self.fd.write("%s<item>%d</item>\n" % (" " * self.indent, item.GetId()))
        self.indent -= 4
        self.fd.write("%s</loop>\n" % (" " * self.indent))

    def _condition(self, condition):
        """Write conditions"""
        bbox = condition.GetBoundingBoxMin()
        self.fd.write(
            '%s<if-else id="%d" pos="%d,%d" size="%d,%d">\n'
            % (
                " " * self.indent,
                condition.GetId(),
                condition.GetX(),
                condition.GetY(),
                bbox[0],
                bbox[1],
            )
        )
        text = condition.GetLabel()
        self.indent += 4
        if text:
            self.fd.write(
                "%s<condition>%s</condition>\n"
                % (" " * self.indent, self._filterValue(text))
            )
        items = condition.GetItems()
        for b in items.keys():
            if len(items[b]) < 1:
                continue
            self.fd.write("%s<%s>\n" % (" " * self.indent, b))
            self.indent += 4
            for item in items[b]:
                self.fd.write("%s<item>%d</item>\n" % (" " * self.indent, item.GetId()))
            self.indent -= 4
            self.fd.write("%s</%s>\n" % (" " * self.indent, b))

        self.indent -= 4
        self.fd.write("%s</if-else>\n" % (" " * self.indent))

    def _comment(self, comment):
        """Write comment"""
        self.fd.write(
            '%s<comment id="%d" pos="%d,%d" size="%d,%d">%s</comment>\n'
            % (
                " " * self.indent,
                comment.GetId(),
                comment.GetX(),
                comment.GetY(),
                comment.GetWidth(),
                comment.GetHeight(),
                comment.GetLabel(),
            )
        )
