import json
import logging
import math
import os
from collections.abc import Iterable
from multiprocessing.pool import ThreadPool
from threading import Thread
from typing import Callable

from PySide6.QtCore import QObject, Slot, Property, Signal, QUrl, QSizeF, QPoint
from PySide6.QtGui import QMatrix4x4, QMatrix3x3, QQuaternion, QVector3D, QVector2D

import meshroom.core
import meshroom.common

from meshroom import multiview
from meshroom.common.qt import QObjectListModel
from meshroom.core import Version
from meshroom.core.node import Node, CompatibilityNode, Status, Position, CompatibilityIssue
from meshroom.core.taskManager import TaskManager
from meshroom.core.evaluation import MathEvaluator

from meshroom.ui import commands
from meshroom.ui.graph import UIGraph
from meshroom.ui.utils import makeProperty
from meshroom.ui.components.filepath import FilepathHelper


class Message(QObject):
    """ Simple structure wrapping a high-level message. """

    def __init__(self, title, text, detailedText="", parent=None):
        super().__init__(parent)
        self._title = title
        self._text = text
        self._detailedText = detailedText

    title = Property(str, lambda self: self._title, constant=True)
    text = Property(str, lambda self: self._text, constant=True)
    detailedText = Property(str, lambda self: self._detailedText, constant=True)


class ViewpointWrapper(QObject):
    """
    ViewpointWrapper is a high-level object that wraps an input image in the context of a Reconstruction.
    It exposes the attributes of the image and its corresponding camera when reconstructed.
    """

    initialParamsChanged = Signal()
    sfmParamsChanged = Signal()
    undistortedImageParamsChanged = Signal()
    internalChanged = Signal()
    principalPointCorrectedChanged = Signal()
    uvCenterOffsetChanged = Signal()

    def __init__(self, viewpointAttribute, reconstruction):
        """
        Viewpoint constructor

        Args:
            viewpointAttribute (GroupAttribute): viewpoint attribute
            reconstruction (Reconstruction): owner reconstruction of this Viewpoint
        """
        super().__init__(parent=reconstruction)
        self._viewpoint = viewpointAttribute
        self._reconstruction = reconstruction

        # CameraInit
        self._initialIntrinsics = None
        # StructureFromMotion
        self._T = None  # translation
        self._R = None  # rotation
        self._solvedIntrinsics = {}
        self._reconstructed = False
        # PrepareDenseScene
        self._undistortedImagePath = ''
        self._activeNode_PrepareDenseScene = self._reconstruction.activeNodes.get("PrepareDenseScene")
        self._activeNode_ExportAnimatedCamera = self._reconstruction.activeNodes.get("ExportAnimatedCamera")
        self._activeNode_ExportImages = self._reconstruction.activeNodes.get("ExportImages")
        self._principalPointCorrected = False
        self.principalPointCorrectedChanged.connect(self.uvCenterOffsetChanged)
        self.sfmParamsChanged.connect(self.uvCenterOffsetChanged)

        # update internally cached variables
        self._updateInitialParams()
        self._updateSfMParams()
        self._updateUndistortedImageParams()

        # trigger internal members updates when reconstruction members changes
        self._reconstruction.cameraInitChanged.connect(self._updateInitialParams)
        self._reconstruction.sfmReportChanged.connect(self._updateSfMParams)
        if self._activeNode_PrepareDenseScene:
            self._activeNode_PrepareDenseScene.nodeChanged.connect(self._updateUndistortedImageParams)
        if self._activeNode_ExportAnimatedCamera:
            self._activeNode_ExportAnimatedCamera.nodeChanged.connect(self._updateUndistortedImageParams)
        if self._activeNode_ExportImages:
            self._activeNode_ExportImages.nodeChanged.connect(self._updateUndistortedImageParams)

    def _updateInitialParams(self):
        """ Update internal members depending on CameraInit. """
        if not self._reconstruction.cameraInit:
            self._initialIntrinsics = None
            self._metadata = {}
        else:
            self._initialIntrinsics = self._reconstruction.getIntrinsic(self._viewpoint)
            try:
                # When the viewpoint attribute has already been deleted, metadata.value becomes a PySide property (whereas a string is expected)
                self._metadata = json.loads(self._viewpoint.metadata.value) if isinstance(self._viewpoint.metadata.value, str) and self._viewpoint.metadata.value else None
            except Exception as e:
                logging.warning(f"Failed to parse Viewpoint metadata: '{e}', '{str(self._viewpoint.metadata.value)}'")
                self._metadata = {}
            if not self._metadata:
                self._metadata = {}
        self.initialParamsChanged.emit()

    def _updateSfMParams(self):
        """ Update internal members depending on StructureFromMotion. """
        if not self._reconstruction.sfm:
            self._T = None
            self._R = None
            self._solvedIntrinsics = {}
            self._reconstructed = False
        else:
            self._solvedIntrinsics = self._reconstruction.getSolvedIntrinsics(self._viewpoint)
            self._R, self._T = self._reconstruction.getPoseRT(self._viewpoint)
            self._reconstructed = self._R is not None
        self.sfmParamsChanged.emit()

    def _updateUndistortedImageParams(self):
        """ Update internal members depending on PrepareDenseScene or ExportAnimatedCamera. """
        # undistorted image path
        try:
            if self._activeNode_ExportAnimatedCamera and self._activeNode_ExportAnimatedCamera.node:
                self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_ExportAnimatedCamera.node.outputImages.value, self._viewpoint)
                self._principalPointCorrected = self._activeNode_ExportAnimatedCamera.node.correctPrincipalPoint.value            
            elif self._activeNode_PrepareDenseScene and self._activeNode_PrepareDenseScene.node:
                self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_PrepareDenseScene.node.undistorted.value, self._viewpoint)
                self._principalPointCorrected = False
            elif self._activeNode_ExportImages and self._activeNode_ExportImages.node:
                self._undistortedImagePath = FilepathHelper.resolve(FilepathHelper, self._activeNode_ExportImages.node.undistorted.value, self._viewpoint)
                self._principalPointCorrected = False
            else:
                self._undistortedImagePath = ''
                self._principalPointCorrected = False
        except Exception as e:
            self._undistortedImagePath = ''
            self._principalPointCorrected = False
            logging.warning("Failed to retrieve undistorted images path.")
        self.undistortedImageParamsChanged.emit()
        self.principalPointCorrectedChanged.emit()

    # Get the underlying Viewpoint attribute wrapped by this Viewpoint.
    attribute = Property(QObject, lambda self: self._viewpoint, constant=True)

    @Property(type="QVariant", notify=initialParamsChanged)
    def initialIntrinsics(self):
        """ Get viewpoint's initial intrinsics. """
        return self._initialIntrinsics

    @Property(type="QVariant", notify=initialParamsChanged)
    def metadata(self):
        """ Get image metadata. """
        return self._metadata

    @Property(type=QSizeF, notify=initialParamsChanged)
    def imageSize(self):
        """ Get image size (width as the largest dimension). """
        if not self._initialIntrinsics:
            return QSizeF(0, 0)
        return QSizeF(self._initialIntrinsics.width.value, self._initialIntrinsics.height.value)

    @Property(type=int, notify=initialParamsChanged)
    def orientation(self):
        """ Get image orientation based on its metadata. """
        return int(self.metadata.get("Orientation", 1))

    @Property(type=QSizeF, notify=initialParamsChanged)
    def orientedImageSize(self):
        """ Get image size taking into account its orientation. """
        if self.orientation in (5, 6, 7, 8):
            return QSizeF(self.imageSize.height(), self.imageSize.width())
        else:
            return self.imageSize

    @Property(type=bool, notify=sfmParamsChanged)
    def isReconstructed(self):
        """ Return whether this viewpoint corresponds to a reconstructed camera. """
        return self._reconstructed

    @Property(type="QVariant", notify=sfmParamsChanged)
    def solvedIntrinsics(self):
        return self._solvedIntrinsics

    @Property(type=QVector3D, notify=sfmParamsChanged)
    def translation(self):
        """ Get the camera translation as a 3D vector. """
        if self._T is None:
            return None
        return QVector3D(self._T[0], -self._T[1], -self._T[2])

    @Property(type=QQuaternion, notify=sfmParamsChanged)
    def rotation(self):
        """ Get the camera rotation as a quaternion. """
        if self._R is None:
            return None

        rot = QMatrix3x3([
            self._R[0], -self._R[1], -self._R[2],
            -self._R[3], self._R[4], self._R[5],
            -self._R[6], self._R[7], self._R[8]]
        )

        return QQuaternion.fromRotationMatrix(rot)

    @Property(type=QMatrix4x4, notify=sfmParamsChanged)
    def pose(self):
        """ Get the camera pose of 'viewpoint' as a 4x4 matrix. """
        if self._R is None or self._T is None:
            return None

        # convert transform matrix for Qt
        return QMatrix4x4(
            self._R[0], -self._R[1], -self._R[2], self._T[0],
            -self._R[3], self._R[4], self._R[5], -self._T[1],
            -self._R[6], self._R[7], self._R[8], -self._T[2],
            0,          0,           0,           1
        )

    @Property(type=QVector3D, notify=sfmParamsChanged)
    def upVector(self):
        """ Get camera up vector. """
        return QVector3D(0.0, 1.0, 0.0)

    @Property(type=QVector2D, notify=uvCenterOffsetChanged)
    def uvCenterOffset(self):
        """ Get UV offset corresponding to the camera principal point. """
        if not self.solvedIntrinsics or self._principalPointCorrected:
            return None
        pp = self.solvedIntrinsics["principalPoint"]
        # compute principal point offset in UV space
        offset = QVector2D(float(pp[0]) / self.imageSize.width(), float(pp[1]) / self.imageSize.height())
        return offset

    @Property(type=float, notify=sfmParamsChanged)
    def fieldOfView(self):
        """ Get camera vertical field of view in degrees. """
        if not self.solvedIntrinsics:
            return None
        focalLength = self.solvedIntrinsics["focalLength"]

        #We assume that if the width is less than the weight
        #It's because the image has been rotated and not
        #because the sensor has some unusual shape
        sensorWidth = self.solvedIntrinsics["sensorWidth"]
        sensorHeight = self.solvedIntrinsics["sensorHeight"]
        if self.imageSize.height() > self.imageSize.width():
            sensorWidth, sensorHeight = sensorHeight, sensorWidth

        if self.orientation in (5, 6, 7, 8):
            return 2.0 * math.atan(float(sensorWidth) / (2.0 * float(focalLength))) * 180.0 / math.pi
        else:
            return 2.0 * math.atan(float(sensorHeight) / (2.0 * float(focalLength))) * 180.0 / math.pi

    @Property(type=float, notify=sfmParamsChanged)
    def pixelAspectRatio(self):
        """ Get camera pixel aspect ratio. """
        if not self.solvedIntrinsics:
            return 1.0

        return float(self.solvedIntrinsics["pixelRatio"])

    @Property(type=QUrl, notify=undistortedImageParamsChanged)
    def undistortedImageSource(self):
        """ Get path to undistorted image source if available. """
        return QUrl.fromLocalFile(self._undistortedImagePath)


def parseSfMJsonFile(sfmJsonFile):
    """
    Parse the SfM Json file and return views, poses and intrinsics as three dicts with viewId, poseId and intrinsicId as keys.
    """
    if not os.path.exists(sfmJsonFile):
        return {}, {}, {}

    with open(sfmJsonFile) as jsonFile:
        report = json.load(jsonFile)

    views = dict()
    poses = dict()
    intrinsics = dict()

    for view in report['views']:
        views[view['viewId']] = view

    if "poses" in report:
        for pose in report['poses']:
            poses[pose['poseId']] = pose['pose']

    for intrinsic in report['intrinsics']:
        intrinsics[intrinsic['intrinsicId']] = intrinsic

    return views, poses, intrinsics


class ActiveNode(QObject):
    """
    Hold one active node for a given NodeType.
    """
    def __init__(self, nodeType, parent=None):
        super().__init__(parent)
        self.nodeType = nodeType
        self._node = None

    nodeChanged = Signal()
    node = makeProperty(QObject, "_node", nodeChanged, resetOnDestroy=True)


class Reconstruction(UIGraph):
    """
    Specialization of a UIGraph designed to manage a 3D reconstruction.
    """
    activeNodeCategories = {
        # All nodes generating a sfm scene (3D reconstruction or panorama)
        "sfm": ["StructureFromMotion", "GlobalSfM", "PanoramaEstimation", "SfMTransform",
                "SfMAlignment", "SfMExpanding", "SfMBootstraping"],
        # All nodes generating a sfmData file
        "sfmData": ["CameraInit", "DistortionCalibration", "StructureFromMotion", "GlobalSfM",
                    "PanoramaEstimation", "SfMTransfer", "SfMTransform", "SfMAlignment",
                    "ApplyCalibration", "SfMExpanding", "SfMBootstraping"],
        # All nodes generating depth map files
        "allDepthMap": ["DepthMap", "DepthMapFilter"],
        # Nodes that can be used to provide features folders to the UI
        "featureProvider": ["FeatureExtraction", "FeatureMatching", "StructureFromMotion"],
        # Nodes that can be used to provide matches folders to the UI
        "matchProvider": ["FeatureMatching", "StructureFromMotion"],
        # Nodes that can be used to provide tracks files to the UI
        "trackProvider": ["TracksBuilding", "SfMBootstraping", "SfMExpanding"]
    }
    # Nodes accessed from the UI
    uiNodes = [
        "LdrToHdrMerge",
        "LdrToHdrCalibration",
        "ImageProcessing",
        "PhotometricStereo",
        "PanoramaInit",
        "ColorCheckerDetection",
        "SphereDetection",
    ]

    def __init__(self, undoStack: commands.UndoStack, taskManager: TaskManager, defaultPipeline: str="", parent: QObject=None):
        super().__init__(undoStack, taskManager, parent)

        # initialize member variables for key steps of the 3D reconstruction pipeline
        self._active = False
        self._activeNodes = meshroom.common.DictModel(keyAttrName="nodeType")
        self.initActiveNodes()

        # initialize activeAttributes (attributes currently visible in some viewers)
        self._displayedAttr2D = None
        self._displayedAttrs3D = meshroom.common.ListModel()

        # - CameraInit
        self._cameraInit = None                            # current CameraInit node
        self._cameraInits = QObjectListModel(parent=self)  # all CameraInit nodes
        self._buildingIntrinsics = False
        self.intrinsicsBuilt.connect(self.onIntrinsicsAvailable)

        self.cameraInitChanged.connect(self.onCameraInitChanged)

        self._tempCameraInit = None

        self.importImagesFailed.connect(self.onImportImagesFailed)

        # - SfM
        self._sfm = None
        self._views = None
        self._poses = None
        self._solvedIntrinsics = None
        self._selectedViewId = None
        self._selectedViewpoint = None
        self._pickedViewId = None

        self._currentViewPath = ""

        self._workerThreads = ThreadPool(processes=1)

        # react to internal graph changes to update those variables
        self.graphChanged.connect(self.onGraphChanged)

        # Connect the pluginsReloaded signal to the onPluginsReloaded function
        self.pluginsReloaded.connect(self._onPluginsReloaded)

        self.setDefaultPipeline(defaultPipeline)

    def __del__(self):
        self._workerThreads.terminate()
        self._workerThreads.join()

    def setActive(self, active):
        self._active = active

    @Slot()
    def clear(self):
        self.clearActiveNodes()
        super().clear()
        self.setActive(False)

    def setDefaultPipeline(self, defaultPipeline):
        self._defaultPipeline = defaultPipeline

    def setSubmitLabel(self, submitLabel):
        self.submitLabel = submitLabel

    def initActiveNodes(self):
        # Create all possible entries
        for category, _ in self.activeNodeCategories.items():
            self._activeNodes.add(ActiveNode(category, parent=self))
        # For all nodes declared to be accessed by the UI
        usedNodeTypes = {j for i in self.activeNodeCategories.values() for j in i}
        allLoadedNodeTypes = set(meshroom.core.pluginManager.getRegisteredNodePlugins().keys())
        allUiNodes = set(self.uiNodes) | usedNodeTypes | allLoadedNodeTypes

        for nodeType in allUiNodes:
            self._activeNodes.add(ActiveNode(nodeType, parent=self))

    def clearActiveNodes(self):
        for key in self._activeNodes.keys():
            self._activeNodes.get(key).node = None

    def onCameraInitChanged(self):
        if self._cameraInit is None:
            return
        # Update active nodes when CameraInit changes
        nodes = self._graph.dfsOnDiscover(startNodes=[self._cameraInit], reverse=True)[0]
        self.setActiveNodes(nodes)

    @Slot()
    def reloadPlugins(self):
        """ Launch _reloadPlugins in a worker thread to avoid blocking the ui. """
        self._workerThreads.apply_async(func=self._reloadPlugins, args=())

    def _reloadPlugins(self):
        """
        Reload all the NodePlugins from all the registered plugins.
        The nodes in the graph will be updated to match the changes in the description, if
        there was any.
        """
        nodeTypes: list[str] = []
        for plugin in meshroom.core.pluginManager.getPlugins().values():
            for node in plugin.nodes.values():
                if node.reload():
                    nodeTypes.append(node.nodeDescriptor.__name__)
        self.pluginsReloaded.emit(nodeTypes)

    @Slot(list)
    def _onPluginsReloaded(self, nodeTypes: list):
        self._graph.reloadNodePlugins(nodeTypes)
        self.parent().showMessage("Plugins reloaded!", "ok")

    @Slot()
    @Slot(str)
    def new(self, pipeline=None):
        """ Create a new pipeline. """
        p = pipeline if pipeline != None else self._defaultPipeline
        # Lower the input and the dictionary keys to make sure that all input types can be found:
        # - correct pipeline name but the case does not match (e.g. panoramaHDR instead of panoramaHdr)
        # - lowercase pipeline name given through the "New Pipeline" menu
        loweredPipelineTemplates = {k.lower(): v for k, v in meshroom.core.pipelineTemplates.items()}
        filepath = loweredPipelineTemplates.get(p.lower(), p)
        return self._loadWithErrorReport(self.initFromTemplate, filepath)

    @Slot(str, result=bool)
    @Slot(QUrl, result=bool)
    def load(self, url):
        if isinstance(url, QUrl):
            # depending how the QUrl has been initialized,
            # toLocalFile() may return the local path or an empty string
            localFile = url.toLocalFile() or url.toString()
        else:
            localFile = url
        return self._loadWithErrorReport(self.loadGraph, localFile)

    def _loadWithErrorReport(self, loadFunction: Callable[[str], None], filepath: str):
        logging.info(f"Load project file: '{filepath}'")
        try:
            loadFunction(filepath)
            # warn about pre-release projects being automatically upgraded
            if Version(self._graph.fileReleaseVersion).major == "0":
                self.warning.emit(Message(
                    "Automatic project upgrade",
                    "This project was created with an older version of Meshroom and has been automatically upgraded.\n"
                    "Data might have been lost in the process.",
                    "Open it with the corresponding version of Meshroom to recover your data."
                ))
            self.setActive(True)
            return True
        except FileNotFoundError:
            self.error.emit(
                Message(
                    "No Such File",
                    f"Error While Loading '{os.path.basename(filepath)}': No Such File.",
                    ""
                )
            )
            logging.error(f"Error while loading '{filepath}': No Such File.")
        except Exception:
            import traceback
            trace = traceback.format_exc()
            self.error.emit(
                Message(
                    "Error While Loading Project File",
                    f"An unexpected error has occurred while loading file: '{os.path.basename(filepath)}'",
                    trace
                )
            )
            logging.error(f"Error while loading '{filepath}'.")
            logging.error(trace)

        return False

    def onGraphChanged(self):
        """ React to the change of the internal graph. """
        self.selectedViewId = "-1"
        self.tempCameraInit = None
        self.updateCameraInits()
        self.resetActiveNodePerCategory()
        self.sfm = self.lastSfmNode()
        if not self._graph:
            return

        # TODO: listen specifically for cameraInit creation/deletion
        self._graph.nodes.countChanged.connect(self.updateCameraInits)

    @Slot(QObject)
    def getViewpoints(self):
        """ Return the Viewpoints model. """
        # TODO: handle multiple Viewpoints models
        if self.tempCameraInit:
            return self.tempCameraInit.viewpoints.value
        elif self._cameraInit:
            return self._cameraInit.viewpoints.value
        else:
            return QObjectListModel(parent=self)

    def updateCameraInits(self):
        cameraInits = self._graph.nodesOfType("CameraInit", sortedByIndex=True)
        if set(self._cameraInits.objectList()) == set(cameraInits):
            return
        self._cameraInits.setObjectList(cameraInits)

        if self.cameraInit is None or self.cameraInit not in cameraInits:
            self.cameraInit = cameraInits[0] if cameraInits else None

        # Manually emit the signal to ensure the active CameraInit index is always up-to-date in the UI
        self.cameraInitChanged.emit()

    def getCameraInitIndex(self):
        if not self._cameraInit:
            # No CameraInit node
            return -1
        if not self._cameraInit.graph:
            # The CameraInit node is a temporary one not attached to a graph
            return -1
        return self._cameraInits.indexOf(self._cameraInit)

    def setCameraInitIndex(self, idx):
        camInit = self._cameraInits[idx] if self._cameraInits else None
        self.cameraInit = camInit
        # Update the active viewpoint accordingly
        if self.viewpoints:
            self.setSelectedViewId(self.viewpoints[0].viewId.value)

    def setCameraInitNode(self, node):
        if self._cameraInit == node:
            return
        self.setCameraInitIndex(self._cameraInits.indexOf(node))

    @Slot()
    def clearTempCameraInit(self):
        self.tempCameraInit = None

    @Slot(QObject, str)
    def setupTempCameraInit(self, node, attrName):
        if not node or not attrName:
            self.tempCameraInit = None
            return
        sfmFile = node.attribute(attrName).value
        if not sfmFile or not os.path.isfile(sfmFile):
            self.tempCameraInit = None
            return
        nodeDesc = meshroom.core.pluginManager.getRegisteredNodePlugin("CameraInit").nodeDescriptor()
        views, intrinsics = nodeDesc.readSfMData(sfmFile)
        tmpCameraInit = Node("CameraInit", viewpoints=views, intrinsics=intrinsics)
        tmpCameraInit.locked = True
        self.tempCameraInit = tmpCameraInit
        rootNode = self.graph.dfsOnFinish([node])[0][0]
        if rootNode.nodeType == "CameraInit":
            self.setCameraInitNode(rootNode)

    @Slot(QObject, result=QVector3D)
    def getAutoFisheyeCircle(self, panoramaInit):
        if not panoramaInit or not panoramaInit.isComputed:
            return QVector3D(0.0, 0.0, 0.0)
        if not panoramaInit.attribute("estimateFisheyeCircle").value:
            return QVector3D(0.0, 0.0, 0.0)

        sfmFile = panoramaInit.attribute('outSfMData').value
        if not os.path.exists(sfmFile):
            return QVector3D(0.0, 0.0, 0.0)
        # skip decoding errors to avoid potential exceptions due to non utf-8 characters in images metadata
        with open(sfmFile, encoding='utf-8', errors='ignore') as f:
            data = json.load(f)

        intrinsics = data.get('intrinsics', [])
        if len(intrinsics) == 0:
            return QVector3D(0.0, 0.0, 0.0)
        intrinsic = intrinsics[0]

        res = QVector3D(float(intrinsic.get("fisheyeCircleCenterX", 0.0)) - float(intrinsic.get("width", 0.0)) * 0.5,
                        float(intrinsic.get("fisheyeCircleCenterY", 0.0)) - float(intrinsic.get("height", 0.0)) * 0.5,
                        float(intrinsic.get("fisheyeCircleRadius", 0.0)))
        return res

    def lastSfmNode(self):
        """ Retrieve the last SfM node from the initial CameraInit node. """
        return self.lastNodeOfType(self.activeNodeCategories['sfm'], self._cameraInit, Status.SUCCESS)

    def lastNodeOfType(self, nodeTypes, startNode, preferredStatus=None):
        """
        Returns the last node of the given type starting from 'startNode'.
        If 'preferredStatus' is specified, the last node with this status will be considered in priority.

        Args:
            nodeTypes (str list): the node types
            startNode (Node): the node to start from
            preferredStatus (Status): (optional) the node status to prioritize

        Returns:
            Node: the node matching the input parameters or None
        """
        if not startNode:
            return None
        nodes = self._graph.dfsOnDiscover(startNodes=[startNode],
                                          filterTypes=nodeTypes, reverse=True)[0]
        if not nodes:
            return None
        # order the nodes according to their depth in the graph, then according to their name
        nodes.sort(key=lambda n: (n.depth, n.name))
        node = nodes[-1]
        if preferredStatus:
            node = next((n for n in reversed(nodes)
                         if n.getGlobalStatus() == preferredStatus), node)
        return node

    @Slot(result="QVariantList")
    def allImagePaths(self):
        """ Get all image paths in the reconstruction. """
        return [vp.path.value for node in self._cameraInits for vp in node.viewpoints.value]

    def allViewIds(self):
        """ Get all view Ids involved in the reconstruction. """
        return [vp.viewId.value for node in self._cameraInits for vp in node.viewpoints.value]

    @Slot("QVariantMap", result=bool)
    @Slot("QVariantMap", Node, result=bool)
    @Slot("QVariantMap", Node, "QPoint", result=bool)
    def handleFilesUrl(self, filesByType, cameraInit=None, position=None):
        """ Handle drop events aiming to add images to the Reconstruction.
        This method allows to reduce process time by doing it on Python side.

        Args:
            {images, videos, panoramaInfo, meshroomScenes, otherFiles}: Map containing the
                lists of paths for recognized images, videos, Meshroom scenes and other files.
            Node: cameraInit node used to add new images to it
            QPoint: position to locate the node (usually the mouse position)
        """
        if filesByType["images"]:
            if cameraInit is None:
                if not self._cameraInits:
                    if isinstance(position, QPoint):
                        p = Position(position.x(), position.y())
                    else:
                        p = position
                    cameraInit = self.addNewNode("CameraInit", position=p)
                else:
                    boundingBox = self.layout.boundingBox()
                    if not position:
                        p = Position(boundingBox[0], boundingBox[1] + boundingBox[3])
                    elif isinstance(position, QPoint):
                        p = Position(position.x(), position.y())
                    else:
                        p = position
                    cameraInit = self.addNewNode("CameraInit", position=p)
            self._workerThreads.apply_async(func=self.importImagesSync,
                                            args=(filesByType["images"], cameraInit,))
        if filesByType["videos"]:
            if self.nodes:
                boundingBox = self.layout.boundingBox()
                p = Position(boundingBox[0], boundingBox[1] + boundingBox[3])
            else:
                p = position
            keyframeNode = self.addNewNode("KeyframeSelection", position=p)
            keyframeNode.inputPaths.value = filesByType["videos"]
            if len(filesByType["videos"]) == 1:
                newVideoNodeMessage = f"New node '{keyframeNode.getLabel()}' added for the input video."
            else:
                newVideoNodeMessage = f"New node '{keyframeNode.getLabel()}' added for a rig of {len(filesByType['videos'])} synchronized cameras."
            self.info.emit(
                Message(
                    "Video Input",
                    newVideoNodeMessage,
                    "Warning: You need to manually compute the KeyframeSelection node \n"
                    "and then reimport the created images into Meshroom for the reconstruction.\n\n"
                    "If you know the Camera Make/Model, it is highly recommended to declare "
                    "them in the Node."
                ))

        if filesByType["panoramaInfo"]:
            if len(filesByType["panoramaInfo"]) > 1:
                self.error.emit(
                    Message(
                        "Multiple XML files in input",
                        "Ignore the XML Panorama files:\n\n'{}'.".format(',\n'.join(filesByType["panoramaInfo"])),
                        "",
                    ))
            else:
                panoramaInitNodes = self.graph.nodesOfType("PanoramaInit")
                for panoramaInfoFile in filesByType["panoramaInfo"]:
                    for panoramaInitNode in panoramaInitNodes:
                        panoramaInitNode.attribute("initializeCameras").value = "File"
                        panoramaInitNode.attribute("config").value = panoramaInfoFile
                if panoramaInitNodes:
                    self.info.emit(
                        Message(
                            "Panorama XML",
                            "XML file declared on PanoramaInit node",
                            f"XML file '{','.join(filesByType['panoramaInfo'])}' set on node '{','.join([n.getLabel() for n in panoramaInitNodes])}'",
                        ))
                else:
                    self.error.emit(
                        Message(
                            "No PanoramaInit Node",
                            f"No PanoramaInit Node to set the Panorama file:\n'{','.join(filesByType['panoramaInfo'])}'.",
                            "",
                        ))

        if filesByType["meshroomScenes"]:
            if len(filesByType["meshroomScenes"]) > 1:
                self.error.emit(
                    Message(
                    "Too Many Meshroom Scenes",
                    "A single Meshroom scene (.mg file) can be imported at once."
                    )
                )
            else:
                return self.load(filesByType["meshroomScenes"][0])



        if not filesByType["images"] and not filesByType["videos"] and not filesByType["panoramaInfo"] and not filesByType["meshroomScenes"]:
            if filesByType["other"]:
                extensions = {os.path.splitext(url)[1] for url in filesByType["other"]}
                self.error.emit(
                    Message(
                        "No Recognized Input File",
                        f"No recognized input file in the {len(filesByType['other'])} dropped files",
                        "Unknown file extensions: " + ', '.join(extensions)
                    )
                )

        # As the boolean is introduced to check if the project is loaded or not, the return value is added to the function.
        # The default value is False, which means the project is not loaded.
        return False

    @Slot("QList<QUrl>", result="QVariantMap")
    def getFilesByTypeFromDrop(self, urls):
        """
        Given a list of filepaths, sort them into distinct categories and return a map for all
        these categories.

        Args:
            urls: list of filepaths

        Returns:
            {images, videos, panoramaInfo, meshroomScenes, otherFiles}: Map containing the lists of paths for
            recognized images, videos, Meshroom scenes and other files.
        """
        # Build the list of images paths
        filesByType = multiview.FilesByType()
        for url in urls:
            localFile = url.toLocalFile()
            if os.path.isdir(localFile):  # get folder content
                filesByType.extend(multiview.findFilesByTypeInFolder(localFile))
            else:
                filesByType.addFile(localFile)
        return {"images": filesByType.images,
                "videos": filesByType.videos,
                "panoramaInfo": filesByType.panoramaInfo,
                "meshroomScenes": filesByType.meshroomScenes,
                "other": filesByType.other}

    def importImagesFromFolder(self, path, recursive=False):
        """

        Args:
            path: A path to a folder or file or a list of files/folders
            recursive: List files in folders recursively.

        """
        logging.debug("importImagesFromFolder: " + str(path))
        filesByType = multiview.findFilesByTypeInFolder(path, recursive)
        if not self.cameraInit:
            # Create a CameraInit node if none exists
            self.cameraInit = self.addNewNode("CameraInit")
        if filesByType.images:
            self._workerThreads.apply_async(func=self.importImagesSync, args=(filesByType.images, self.cameraInit,))

    @Slot("QVariant")
    def importImagesUrls(self, imagePaths, recursive=False):
        paths = []
        for imagePath in imagePaths:
            if isinstance(imagePath, (QUrl)):
                p = imagePath.toLocalFile()
                if not p:
                    p = imagePath.toString()
            else:
                p = imagePath
            paths.append(p)
        self.importImagesFromFolder(paths)

    def importImagesSync(self, images, cameraInit):
        """ Add the given list of images to the Reconstruction. """
        try:
            self.buildIntrinsics(cameraInit, images)
        except Exception as e:
            self.importImagesFailed.emit(str(e))

    @Slot()
    def onImportImagesFailed(self, msg):
        self.error.emit(
            Message(
                "Failed to Import Images",
                "A corrupted image in the import set or an installation error may have caused this issue.",
                ""  # msg
            )
        )

    def buildIntrinsics(self, cameraInit, additionalViews, rebuild=False):
        """
        Build up-to-date intrinsics and views based on already loaded + additional images.
        Does not modify the graph, can be called outside the main thread.
        Emits intrinsicBuilt(views, intrinsics) when done.

        Args:
            cameraInit (Node): CameraInit node to build the intrinsics for
            additionalViews: list of additional views to add to the CameraInit viewpoints
            rebuild (bool): whether to rebuild already created intrinsics
        """
        views = []
        intrinsics = []

        # Duplicate 'cameraInit' outside the graph.
        #   => allows to compute intrinsics without modifying the node or the graph
        # If cameraInit is None:
        #   * create an uninitialized node
        #   * wait for the result before actually creating new nodes in the graph (see onIntrinsicsAvailable)
        inputs = cameraInit.toDict()["inputs"] if cameraInit else {}
        cameraInitCopy = Node("CameraInit", **inputs)
        if rebuild:
            # if rebuilding all intrinsics, for each Viewpoint:
            for vp in cameraInitCopy.viewpoints.value:
                vp.intrinsicId.resetToDefaultValue()  # reset intrinsic assignation
                vp.metadata.resetToDefaultValue()  # and metadata (to clear any previous 'SensorWidth' entries)
            # reset existing intrinsics list
            cameraInitCopy.intrinsics.resetToDefaultValue()

        try:
            self.setBuildingIntrinsics(True)
            # Retrieve the list of updated viewpoints and intrinsics
            views, intrinsics = cameraInitCopy.nodeDesc.buildIntrinsics(cameraInitCopy, additionalViews)
        except Exception as e:
            logging.error(f"Error while building intrinsics: {e}")
            raise
        finally:
            # Delete the duplicate
            cameraInitCopy.deleteLater()
            self.setBuildingIntrinsics(False)

        # always emit intrinsicsBuilt signal to inform listeners
        # in other threads that computation is over
        self.intrinsicsBuilt.emit(cameraInit, views, intrinsics, rebuild)

    @Slot(Node)
    def rebuildIntrinsics(self, cameraInit):
        """
        Rebuild intrinsics of 'cameraInit' from scratch.

        Args:
            cameraInit (Node): the CameraInit node
        """
        self._workerThreads.apply_async(func=self.buildIntrinsics, args=(cameraInit, (), True,))

    def onIntrinsicsAvailable(self, cameraInit, views, intrinsics, rebuild=False):
        """ Update CameraInit with given views and intrinsics. """
        commandTitle = "Add {} Images"

        if rebuild:
            commandTitle = f"Rebuild '{cameraInit.label}' Intrinsics"

        # No additional views: early return
        if not views:
            return

        commandTitle = commandTitle.format(len(views))
        # allow updates between commands so that node depths (useful for auto layout)
        with self.groupedGraphModification(commandTitle, disableUpdates=False):
            with self.groupedGraphModification("Set Views and Intrinsics"):
                self.setAttribute(cameraInit.viewpoints, views)
                self.setAttribute(cameraInit.intrinsics, intrinsics)
        self.cameraInit = cameraInit

    def setBuildingIntrinsics(self, value):
        if self._buildingIntrinsics == value:
            return
        self._buildingIntrinsics = value
        self.buildingIntrinsicsChanged.emit()

    activeNodes = makeProperty(QObject, "_activeNodes", resetOnDestroy=True)
    cameraInitChanged = Signal()
    cameraInit = makeProperty(QObject, "_cameraInit", cameraInitChanged, resetOnDestroy=True)
    tempCameraInitChanged = Signal()
    tempCameraInit = makeProperty(QObject, "_tempCameraInit", tempCameraInitChanged, resetOnDestroy=True)
    cameraInitIndex = Property(int, getCameraInitIndex, setCameraInitIndex, notify=cameraInitChanged)
    viewpoints = Property(QObject, getViewpoints, notify=cameraInitChanged)
    cameraInits = Property(QObject, lambda self: self._cameraInits, constant=True)
    importImagesFailed = Signal(str)
    intrinsicsBuilt = Signal(QObject, list, list, bool)
    buildingIntrinsicsChanged = Signal()
    buildingIntrinsics = Property(bool, lambda self: self._buildingIntrinsics, notify=buildingIntrinsicsChanged)

    displayedAttr2DChanged = Signal()
    displayedAttr2D = makeProperty(QObject, "_displayedAttr2D", displayedAttr2DChanged)   

    displayedAttrs3DChanged = Signal()    
    displayedAttrs3D = Property(QObject, lambda self: self._displayedAttrs3D, notify=displayedAttrs3DChanged)
    
    pluginsReloaded = Signal(list)

    @Slot(QObject)
    def setActiveNode(self, node, categories=True, inputs=True):
        """ Set node as the active node of its type and of its categories.
        Also upgrade related input nodes.
        """
        if categories:
            for category, nodeTypes in self.activeNodeCategories.items():
                if node.nodeType in nodeTypes:
                    self.activeNodes.getr(category).node = node

                    if category == "sfm":
                        self.setSfm(node)

        if node.nodeType == "CameraInit":
            # if the active node is a CameraInit node, update the camera init index
            self.setCameraInitNode(node)
        elif inputs:
            # Update the input node to ensure that it is part of the dependency of the new active node.
            # Retrieve all nodes that are input nodes of the new active node
            inputNodes = node.getInputNodes(recursive=True, dependenciesOnly=True)
            inputCameraInitNodes = [n for n in inputNodes if n.nodeType == "CameraInit"]
            # if the current camera init node is not the same as the camera init node of the active node
            if inputCameraInitNodes and self.cameraInit not in inputCameraInitNodes:
                # set the camera init node of the active node as the current camera init node
                # if multiple camera init, select one arbitrarily (the one with more viewpoints)
                inputCameraInitNodes.sort(key=lambda n: len(n.viewpoints.value), reverse=True)
                cameraInitNode = inputCameraInitNodes[0]
                self.setCameraInitNode(cameraInitNode)

        # Set the new active node (if it is not an unknown type)
        unknownType = isinstance(node, CompatibilityNode) and node.issue == CompatibilityIssue.UnknownNodeType
        if not unknownType:
            activeNode = self.activeNodes.get(node.nodeType)
            if activeNode:
                activeNode.node = node

    @Slot(QObject)
    def setActiveNodes(self, nodes):
        """ Set node as the active node of its type. """
        for node in nodes:
            if node is None:
                continue
            self.setActiveNode(node, categories=False, inputs=False)

    def resetActiveNodePerCategory(self):
        # Setup the active node per category only once, on the last one
        nodesByCategory = {}
        for category, nodeTypes in self.activeNodeCategories.items():
            node = self.lastNodeOfType(nodeTypes, self._cameraInit, Status.SUCCESS)
            self.activeNodes.get(category).node = node

    def updateSfMResults(self):
        """
        Update internal views, poses and solved intrinsics based on the current SfM node.
        """
        if not self._sfm or ('outputViewsAndPoses' not in self._sfm.getAttributes().keys()):
            self._views = dict()
            self._poses = dict()
            self._solvedIntrinsics = dict()
        else:
            self._views, self._poses, self._solvedIntrinsics = parseSfMJsonFile(self._sfm.outputViewsAndPoses.value)
        self.sfmReportChanged.emit()

    def getSfm(self):
        """ Returns the current SfM node. """
        return self._sfm

    def _unsetSfm(self):
        """ Unset current SfM node. This is shortcut equivalent to _setSfm(None). """
        self._setSfm(None)

    def _setSfm(self, node):
        """ Set current SfM node to 'node' and update views and poses.
        Notes: this should not be called directly, use setSfm instead.
        See Also: setSfm
        """
        self._sfm = node
        # Update sfm results and do so each time
        # the status of the SfM node's only chunk changes
        self.updateSfMResults()
        if self._sfm:
            # when destroyed, directly use '_setSfm' to bypass
            # disconnection step in 'setSfm' (at this point, 'self._sfm' underlying object
            # has been destroyed and can't be evaluated anymore)
            self._sfm.destroyed.connect(self._unsetSfm)
            self._sfm.chunks[0].statusChanged.connect(self.updateSfMResults)
        self.sfmChanged.emit()

    def setSfm(self, node):
        """ Set the current SfM node.
        This node will be used to retrieve sparse reconstruction result like camera poses.
        """
        # disconnect from previous SfM node if any
        if self._sfm:
            self._sfm.chunks[0].statusChanged.disconnect(self.updateSfMResults)
            self._sfm.destroyed.disconnect(self._unsetSfm)
        self._setSfm(node)

    @Slot(QObject, result=bool)
    def isInViews(self, viewpoint):
        if not viewpoint:
            return False
        # keys are strings (faster lookup)
        return str(viewpoint.viewId.value) in self._views

    @Slot(QObject, result=bool)
    def isReconstructed(self, viewpoint):
        if not viewpoint:
            return False
        # fetch up-to-date poseId from sfm result (in case of rigs, poseId might have changed)
        if not self._views:
            return False
        view = self._views.get(str(viewpoint.poseId.value), None)  # keys are strings (faster lookup)
        return view.get('poseId', -1) in self._poses if view else False

    @Slot(QObject, result=bool)
    def hasValidIntrinsic(self, viewpoint):
        # keys are strings (faster lookup)
        allIntrinsicIds = [i.intrinsicId.value for i in self._cameraInit.intrinsics.value]
        return viewpoint.intrinsicId.value in allIntrinsicIds

    @Slot(QObject, result=QObject)
    def getIntrinsic(self, viewpoint):
        """
        Get the intrinsic attribute associated to 'viewpoint' based on its intrinsicId.

        Args:
            viewpoint (Attribute): the Viewpoint to consider.
        Returns:
            Attribute: the Viewpoint's corresponding intrinsic or None if not found.
        """
        if not viewpoint:
            return None
        return next((i for i in self._cameraInit.intrinsics.value if i.intrinsicId.value == viewpoint.intrinsicId.value)
                    , None)

    @Slot(QObject, result=bool)
    def hasMetadata(self, viewpoint):
        # Should be greater than 2 to avoid the particular case of ""
        return len(viewpoint.metadata.value) > 2

    def setSelectedViewId(self, viewId):
        if viewId == self._selectedViewId:
            return
        self._selectedViewId = viewId
        self.setPickedViewId(viewId)
        vp = None
        if self.viewpoints:
            vp = next((v for v in self.viewpoints if str(v.viewId.value) == self._selectedViewId), None)
        self._setSelectedViewpoint(vp)
        self.selectedViewIdChanged.emit()

    def _setSelectedViewpoint(self, viewpointAttribute):
        if self._selectedViewpoint:
            # Reconstruction has ownership of Viewpoint object - destroy it when not needed anymore
            self._selectedViewpoint.deleteLater()
        self._selectedViewpoint = ViewpointWrapper(viewpointAttribute, self) if viewpointAttribute else None
        self.selectedViewpointChanged.emit()

    def setPickedViewId(self, viewId):
        if viewId == self._pickedViewId:
            return
        self._pickedViewId = viewId
        self.pickedViewIdChanged.emit()

    @Slot(str)
    def updateSelectedViewpoint(self, viewId):
        """ Update the currently set viewpoint if the provided view ID corresponds to one. """
        vp = None
        if self.viewpoints:
            vp = next((v for v in self.viewpoints if str(v.viewId.value) == viewId), None)
        self._setSelectedViewpoint(vp)

    def reconstructedCamerasCount(self):
        """ Get the number of reconstructed cameras in the current context. """
        viewpoints = self.getViewpoints()
        # Check that the object is iterable to avoid error with undefined Qt Property
        if not isinstance(viewpoints, Iterable):
            return 0
        return len([v for v in viewpoints if self.isReconstructed(v)])

    @Slot(QObject, result="QVariant")
    def getSolvedIntrinsics(self, viewpoint):
        """ Return viewpoint's solved intrinsics if it has been reconstructed, None otherwise.

        Args:
            viewpoint: the viewpoint object to instrinsics for.
        """
        if not viewpoint:
            return None
        return self._solvedIntrinsics.get(str(viewpoint.intrinsicId.value), None)

    def getPoseRT(self, viewpoint):
        """ Get the camera pose as rotation and translation of the given viewpoint.

        Args:
            viewpoint: the viewpoint attribute to consider.
        Returns:
            R, T: the rotation and translation as lists of floats
        """
        if not viewpoint:
            return None, None
        view = self._views.get(str(viewpoint.viewId.value), None)
        if not view:
            return None, None
        pose = self._poses.get(view.get('poseId', -1), None)
        if not pose:
            return None, None

        pose = pose["transform"]
        R = [float(i) for i in pose["rotation"]]
        T = [float(i) for i in pose["center"]]

        return R, T

    def setCurrentViewPath(self, path):
        if self._currentViewPath == path:
            return
        self._currentViewPath = path
        self.currentViewPathChanged.emit()

    @Slot(str, result="QVariantList")
    def evaluateMathExpression(self, expr):
        """ Evaluate a mathematical expression and return the result as a string 
        Returns a list of 2 values :
        - the result value
        - a boolean that indicate if an error occured
        """
        mev = MathEvaluator()
        try:
            res = mev.evaluate(expr)
            return [res, False]
        except Exception as err:
            self.parent().showMessage(f"Invalid field expression: {expr}", "error")
            return [None, True]

    selectedViewIdChanged = Signal()
    selectedViewId = Property(str, lambda self: self._selectedViewId, setSelectedViewId, notify=selectedViewIdChanged)
    selectedViewpointChanged = Signal()
    selectedViewpoint = Property(ViewpointWrapper, lambda self: self._selectedViewpoint, notify=selectedViewpointChanged)
    pickedViewIdChanged = Signal()
    pickedViewId = Property(str, lambda self: self._pickedViewId, setPickedViewId, notify=pickedViewIdChanged)

    sfmChanged = Signal()
    sfm = Property(QObject, getSfm, setSfm, notify=sfmChanged)

    sfmReportChanged = Signal()
    # convenient property for QML binding re-evaluation when sfm report changes
    sfmReport = Property(bool, lambda self: len(self._poses) > 0, notify=sfmReportChanged)

    nbCameras = Property(int, reconstructedCamerasCount, notify=sfmReportChanged)

    # Provides the path of the image that is currently displayed
    # This is an alternative to "selectedViewpoint.attribute.path.value" for images that are displayed
    # but not part of the list of viewpoints of a CameraInit node (i.e. "sequence" node outputs)
    currentViewPathChanged = Signal()
    currentViewPath = Property(str, lambda self: self._currentViewPath, setCurrentViewPath, notify=currentViewPathChanged)

    # Whether the Reconstruction object has been set ("new" has been called) or not ("new" has never
    # been called or "clear" has been called)
    activeChanged = Signal()
    active = Property(bool, lambda self: self._active, setActive, notify=activeChanged)

    # Signals to propagate high-level messages
    error = Signal(Message)
    warning = Signal(Message)
    info = Signal(Message)
