"""
This module defines the FlowModel abstract class,

All HTC Flow-based calculations show inherith from this class and the
extend the implementation by adding new pydantic Fields and methods
that are either required by the abstract interface or by the specification calculation that is being performed.

See FlowModel declaration for further details.
"""
from __future__ import annotations

import traceback
import sys
import logging
#import functools
import pandas as pd

from datetime import datetime
from pprint import pformat
from enum import Enum
from abc import ABC, abstractmethod
from typing import ClassVar, Union, TypeVar, Type, Optional, Any  # , Type,
from pydantic import BaseModel, Field
from bson.objectid import ObjectId
from pymongo.collection import Collection
from abipy.flowtk.tasks import Task
from abipy.flowtk.events import EventReport
from abipy.flowtk import TaskManager, Work, Flow
from abipy.abio.inputs import AbinitInput
from abipy.htc.base_models import (AbipyModel, TopLevelModel, cls2dict, AbipyDecoder,
        QueryResults, MongoConnector, GfsFileDesc)
from abipy.htc.structure_models import StructureData
from abipy.htc.pseudos_models import PseudoSpecs
from abipy.htc.worker import AbipyWorker

logger = logging.getLogger(__name__)


class PresetQuery(BaseModel):
    """
    A MongoDB preset query that can be used in GUIs or command line interfaces
    to provide ready-to-use values for queries and projections.

    This model is generated by the FlowModel in the `get_preset_queries` method using class methods
    Indeed only the FlowModel is aware of the different submodels, their name, type
    and the physical results stored in the Field.
    """

    query: dict = Field(..., description="MongoDB filter")

    projection: Union[None, list, dict] = Field(..., description="MongoDB projection")

    info: str = Field(..., description="Human-reable description of the query")

    def to_string(self, title: Optional[str] = None, verbose: int = 0) ->  str:
        """
        String representation.
        """
        lines = []
        app = lines.append
        if title is None:
            app(f"=== {self.__class__.__name__} ===")
        else:
            app(title)
        app(f"{self.info}")
        app("MongoDB filter:")
        app(pformat(self.query))
        #if verbose:
        app("MongoDB projection:")
        app(pformat(self.projection))
        app("")

        return "\n".join(lines)

    def __str__(self) -> str:
        return self.to_string()

    def find(self, collection: Collection, **kwargs):
        return collection.find(self.query, self.projection, **kwargs)

    def get_dataframe(self, collection: Collection, try_short_keys: bool = False, **kwargs) -> pd.DataFrame:
        """
        Args:
            shor_keys:
                WARNING don't use this option if you need reproducible results
        """

        def can_use_short_colnames(names):
            """
            We usually receive column names of the form `foo.bar`, `foo.hello`.
            This function detects if it possible to use the string after the last "." as column name.
            without incurring in duplication.
            """
            d = {}
            for name in names:
                i = name.rfind(".")
                if i != -1 and i != len(name) - 1:
                    d[name] = name[i + 1:]
                else:
                    d[name] = name

            can_shorten = len(set(d.values())) == len(names)
            return can_shorten, d

        #TODO Add limit
        # https://towardsdatascience.com/all-pandas-json-normalize-you-should-know-for-flattening-json-13eae1dfb7dd
        cursor = self.find(collection, **kwargs)
        df = pd.json_normalize(list(cursor))

        if try_short_keys:
            can_use, mapper = can_use_short_colnames(df.keys())
            if can_use:
                df = df.rename(columns=mapper)

        return df

    ##############
    # Constructors
    ##############

    @classmethod
    def for_large_forces_or_high_pressure(cls, root_name: str, model_cls: Type[TopLevelModel],
                                          force_ev_ang: float = 1e-4, p_gpa: float = 2.0) -> PresetQuery:
        """
        Find documents with large forces or large pressure.

        Args:
            root_name: Usually `scf_data`. Uses results stored in a ScfData submodel
        """
        #assert "." not in root_name "Nested fields are not supported"
        model_field = model_cls.__fields__[root_name]

        # Build query strings from root_name.
        abs_pressure_gpa = f"{root_name}.abs_pressure_gpa"
        max_force = f"{root_name}.max_force_ev_over_ang"

        #assert model_field.type_ is ScfData
        #for f in [abs_pressure, max_force]:
        #    assert f in model_field.type_

        return cls(
                query={"$or": [
                    {abs_pressure_gpa: {"$gt": p_gpa}},
                    {max_force: {"$gt": force_ev_ang}}]
                },
                projection=["in_structure_data.formula_pretty", abs_pressure_gpa, max_force],
                info=f"Filter `{model_cls.__name__}` documents with pressure > {p_gpa} GPa "
                     f"or max |force| > {force_ev_ang} eV/Ang",
                )

    @classmethod
    def for_collinear_magnetic(cls, root_name: str, model_cls: Type[TopLevelModel]) -> PresetQuery:
        """
        Find documents with non-zero collinear magnetization"

        Args:
            root_name: Usually `scf_data`. Uses results stored in a ScfData submodel
        """
        #assert "." not in root_name "Nested fields are not supported"
        #model_field = model_cls.__fields__[root_name]

        # Build query strings from root_name.
        key = f"{root_name}.collinear_mag"

        return cls(
                query={key: {"$nin": [None, 0.0]}},
                projection=["in_structure_data.formula_pretty", key],
                info=f"Filter `{model_cls.__name__}` documents with non-zero collinear magnetization"
                )

    @classmethod
    def for_semiconductors(cls, root_name: str, model_cls: Type[TopLevelModel],
                           min_gap_ev=0.1) -> PresetQuery:
        """
        Find documents with non-zero collinear magnetization"

        Args:
            root_name: Usually `nscf_data`. Uses results stored in a NscfData submodel
        """
        #assert "." not in root_name "Nested fields are not supported"
        #model_field = model_cls.__fields__[root_name]

        # Build query strings from root_name.
        key = f"{root_name}.fundamental_gap_ev"

        return cls(
                query={key: {"$gt": min_gap}},
                projection=["in_structure_data.formula_pretty", key,
                            f"{root_name}.direct_gap_ev",
                ],
                info=f"Filter `{model_cls.__name__}` documents with non-zero collinear magnetization"
                )


class _NodeData(AbipyModel):

    node_id: str = Field(..., description="Node identifier")

    node_class: str = Field(..., description="Class of the Node")

    status: str = Field(..., description="Status of the Node")

    @classmethod
    def get_data_for_node(cls, node) -> dict:
        return dict(
            node_id=node.node_id,
            node_class=node.__class__.__name__,
            status=str(node.status),
        )


class TaskData(_NodeData):
    """
    Data Model associated to an AbiPy |Task|.
    """

    input: AbinitInput = Field(..., description="Abinit input object")

    #name: str: Field(..., description="Name of the Task e.g. w0_t0")

    num_warnings: int = Field(..., description="Number of warnings found the in log file")

    num_errors: int = Field(..., description="Number of errors")

    num_comments: int = Field(..., description="Number of comments found in the log file")

    num_restarts: int = Field(..., description="Number of restarts performed by AbiPy")

    num_corrections: int = Field(..., description="Number of correction performed by AbiPy")

    report: EventReport = Field(..., description="Number of warnings")

    #abinit_abort_file_str: str = Field(None, description="Number of warnings")

    mpi_procs: int = Field(..., description="Number of MPI processes used by the Task")

    omp_threads: int = Field(..., description="Number of OpenMP threads. -1 if not used.")

    #cpu_time: float = Field(..., description="CPU-time in seconds")
    #wall_time: float = Field(..., description="Wall-time in seconds")
    #history: list[str] = Field(..., description="Task history. List of strings logged by AbiPy")

    out_gfsd: GfsFileDesc = Field(None, description="Metadata needed to retrieve the output file from GridFS.")

    log_gfsd: GfsFileDesc = Field(None, description="Metadata needed to retrieve the log file from GridFS.")

    @classmethod
    def from_task(cls, task: Task, mng_connector: MongoConnector, with_out: bool, with_log: bool) -> TaskData:
        """
        Build the model from an AbiPy |Task|
        """
        data = cls.get_data_for_node(task)
        data["input"] = task.input

        # TODO: Handle None!
        report = task.get_event_report()
        data["report"] = report
        for a in ("num_errors", "num_comments", "num_warnings"):
            data[a] = getattr(report, a)

        data.update(dict(
            #"%.1f" % task.mem_per_proc.to("Gb"))))
            ##(task.num_launches,
            num_restarts=task.num_restarts,
            num_corrections=task.num_corrections,
            #stime,
            #task.node_id]))
            #task.status
            mpi_procs=task.mpi_procs,
            omp_threads=task.omp_threads,
        ))

        if with_out:
            data["out_gfsd"] = mng_connector.gfs_put_filepath(task.output_file.path)

        if with_log:
            data["log_gfsd"] = mng_connector.gfs_put_filepath(task.log_file.path)

        return cls(**data)


class WorkData(_NodeData):
    """
    Data Model associated to an AbiPy |Work|
    """

    tasks_data: list[TaskData] = Field(..., description="List of TaskData")

    @classmethod
    def from_work(cls, work: Work, mng_connector: MongoConnector, with_out: bool, with_log: bool) -> WorkData:
        """
        Build the model from a AbiPy |Work| instance.
        """
        data = cls.get_data_for_node(work)
        data["tasks_data"] = [TaskData.from_task(task, mng_connector, with_out, with_log) for task in work]

        return cls(**data)

    def summarize(self, verbose: int = 0, stream=sys.stdout) -> None:
        def p(string: str) -> None:
            print(string, file=stream)

        df = self.get_dataframe()
        p(df)

    def get_dataframe(self) -> pd.DataFrame:
        rows = []
        anames = [
            "num_warnings", "num_errors", "num_comments", "num_restarts", "num_corrections",
            "mpi_procs", "omp_threads"
            #"cpu_time", "wall_time"

        ]

        for task_data in self.tasks_data:
            rows.append({k: getattr(task_data, k) for k in anames})

        return pd.DataFrame(rows)


class ExecStatus(str, Enum):
    """
    Possible status of the execution flow.
    """
    init = "Initialized"
    built = "Built"
    errored = "Errored"
    completed = "Completed"

    #@classmethod
    #def is_member(cls, key):
    #    return key in cls.__members__


class FlowData(AbipyModel):
    """
    Submodel storing the status the Flow and additional metadata related to the execution.
    Users do not need to interact with FlowData explicitly as this operation is delegated to the AbipyWorker.
    """
    exec_status: ExecStatus = ExecStatus.init

    worker_name: str = Field(None, description="The name of the AbiPy worker executing this Flow.")

    worker_hostname: str = Field(None, description="The name of the machine executing this Flow.")

    workdir: str = Field(None, description="The directory where the Flow is executed.")

    created_at: datetime = Field(
        default_factory=datetime.utcnow,
        description="Timestamp for when this document was first created (UTC)",
    )

    completed_at: datetime = Field(
        None,
        description="Timestamp for the most recent calculation update for this property (UTC)",
    )

    works_data: list[WorkData] = Field([], description="List of WorkData objects")

    tracebacks: list[str] = Field([], description="List of exception tracebacks produced by the AbiPy Worker")

    # Private class attributes
    #_aggregation_class_method_names: list[str] = []

    def summarize(self, verbose: int = 0, stream=sys.stdout) -> None:
        """

        Args:
            verbose: Verbosity level.
            stream: File-like object, Default: sys.stdout
        """
        def p(string: str) -> None:
            print(string, file=stream)

        p(f"FlowModel: {self.__class__.__name__}")
        p(f"AbiPyWorker: {self.worker_name}@{self.worker_hostname}")
        p(f"Workdir: {self.workdir}")
        p(f"Created at:   {self.created_at} (UTC)")
        p(f"Completed at: {self.completed_at} (UTC)")
        p(f"ExecStatus: {self.exec_status.value}")

        if self.tracebacks:
            p("WARNING racebacks is not empty:")
            for trace in self.tracebacks:
                p(trace)
                p(80 * "*", "\n")
            return

        for work_data in self.works_data:
            work_data.summarize(verbose=verbose, stream=stream)
            #p(work_data)
            #df = work_data.get_dataframe()
            #print(df)


#class __FlowData(_NodeData):
#    """
#    Document associated to an AbiPy |Flow|
#    """
#
#    works_data: list[WorkData] = Field(..., description="")
#
#    @classmethod
#    def from_flow(cls, flow: Flow) -> __FlowData:
#        """
#        Build the model from an AbiPy |Flow|
#        """
#        data = cls.get_data_for_node(flow)
#        data["works_data"] = [WorkData.from_work(work) for work in flow]
#
#        return cls(**data)


FM = TypeVar('FM', bound='FlowModel')


class FlowModel(TopLevelModel, ABC):
    """
    Abstract base class for models associated to a Flow calculation performed by an AbiPy Worker.
    This model implements the businness logic to create a Flow from a MongoDB collection
    post-process the results once the Flow is completed and insert results in the MongoDB collection.

    Users are supposed to use this model to initialize a MongoDB collection with all
    the input arguments that will be used to generate the Flow and provide a concrete
    implementation of:

        - build_flow.
        - postprocess_flow

    The first method receives the input arguments from the MongoDB database
    and use these values to build a flow.

    The second method is invoked by the AbiPy worker when the calculation is completed.
    The function uses the Flow API to fill the ouput part of the model that
    will be then be stored in the database collection.
    The AbipyWorker uses this API to automate calculations.

    NOTES: The flow is assumed to have the same tructure and pseudopotentials.
    """

    flow_data: FlowData = Field(default_factory=FlowData,
                                description="Stores info on the Flow execution status. Set by the AbiPy Worker.")

    in_structure_data: StructureData = Field(..., description="Input structure and associated metadata. Used ")

    pseudos_specs: PseudoSpecs = Field(..., description="Pseudopotential specifications.")

    with_out: bool = Field(False, description="True if main output files should be added to GridFS.")

    with_log: bool = Field(False, description="True if log files should be added to GridFS.")

    #previous_flow_oids: list[ObjectId] = Field(
    #    None,
    #   description="List of ObjectId associated to documents in the some collection that are connected to this one. "
    #               "May be used to implement convergence studies and/or relaxations with different settings "

    # TODO: Add support for:
    #   - Detecting duplicated FlowModels

    #in_hash_value: str = Field(None, description="Hash value computed from the input data.
    #                                              "Used to find duplicated documents in the collection")

    #priority: int = Field(1, description="Priority associated to the calculation. Can be changed by the user at runtime)

    custom_data: dict[str, Any] = Field(None, description="Dictionary with custom entries set by the user.")

    # Private class attributes
    _magic_key: ClassVar[str] = "_flow_model_"

    #_json_for_eqtest: str

    #def __init__(self, **data):
    #    super().__init__(**data)
    #    self.in_hash_value = hash(self.get_json_for_eqtest())

    #def __eq__(self, other) -> bool:
    #   if self.in_hash_value != other.in_hash_value: return False
    #   if seif is other: return True
    #   return self.get_json_for_eqtest() == other.get_json_for_eqtest()
    #   return self._json_for_eqtest == other.json_for_eqtest

    #def get_json_for_eqtest(self) -> str:
    #    # Do not change the order of the keys.
    #    keys = [
    #        "in_structure_data.structure",
    #        "pseudos_specs",
    #    ]
    #    data = {k: getattr(self, k) for k in keys}
    #    return json.dumps(data)


    # TODO: Remove this method. use mongo_connector.add_flow_models

    @classmethod
    def init_collection(cls: Type[FM], collection: Collection) -> ObjectId:
        """
        Initialize a MongoDB collection by inserting a special document containing
        the serialized FlowModel subclass.
        This function must be used when creating the collection of FlowModels for the first time.

        Return: ObiectID of the new document.
        """
        new_dict = cls2dict(cls)

        cnt, oid = 0, None
        for doc in collection.find({cls._magic_key: {"$exists": True}}):
            oid = doc.pop("_id")
            old_dict = doc.get(cls._magic_key)
            cnt += 1
            if new_dict != old_dict:
                raise TypeError(f"Cannot register new Model {new_dict}\n"
                                f"Collection is already associated to the FlowModel {old_dict}")

        if cnt:
            return oid

        return collection.insert_one({cls._magic_key: new_dict}).inserted_id

    @classmethod
    def get_subclass_from_collection(cls, collection: Collection) -> Type[FlowModel]:
        """
        Retrieve the FlowModel subclass from a MongoDB collection.
        The collections should have been initialized previously by calling `init_collection`.

        Return: FlowModel subclass.
        """
        cnt, data = 0, None
        for doc in collection.find({cls._magic_key: {"$exists": True}}):
            _ = doc.pop("_id")
            data = doc.get(cls._magic_key)
            cnt += 1

        if cnt == 0:
            raise RuntimeError(f"Cannot find document with magic key: `{cls._magic_key}` in collection: `{collection.name}`")
        if cnt > 1:
            raise RuntimeError(f"Found {cnt} documents with magic key: `{cls._magic_key}` in collection: `{collection.name}`")

        sub_class = AbipyDecoder().process_decoded(data)

        if sub_class is not cls and cls is not FlowModel:
            raise TypeError(f"Found FlowModel {sub_class} in collection\nwhile it should be {cls}")

        return sub_class

    @classmethod
    def mongo_get_flow_data(cls, oid: ObjectId, collection: Collection, **kwargs) -> FlowData:
        """
        Return the FlowData submodel given the ObjectId of the MongoDB document.
        """
        d = collection.find_one({"_id": ObjectId(oid)}, projection=["flow_data"], **kwargs)
        if d is None:
            raise ValueError(f"Cannot find ObjectID {oid} in {repr(collection)}")
        data = AbipyDecoder().process_decoded(d["flow_data"])
        return FlowData(**data)

    @classmethod
    def mng_find_completed_oids(cls, collection: Collection, **kwargs) -> list[ObjectId]:
        """
        Find all the models in collection that are completed.
        Return: list of ObjectId.
        """
        d = cls.mng_get_status2oids(collection, **kwargs)
        return d[ExecStatus.completed]

    @classmethod
    def mng_get_status2oids(cls, collection: Collection, **kwargs) -> dict[ExecStatus, list[ObjectId]]:
        """
        Return dictionary mapping the Execution status to the list of ObjectId.
        """
        status2oids = {e: [] for e in ExecStatus}
        key = "flow_data.exec_status"
        collection.create_index(key)
        query = {key: {"$exists": True}}
        #if "add_filter" in kwargs: query.update(kwargs.get("add_filter"))

        for doc in collection.find(query, projection=[key]):
            status = ExecStatus(doc["flow_data"]["exec_status"])
            status2oids[status].append(doc["_id"])

        return status2oids

    @classmethod
    def find_runnable_oid_models(cls, collection: Collection, **kwargs) -> list[tuple[ObjectId, FlowModel]]:
        """
        Return list of (oid, model) tuple with the FlowModels in collection that are ready to run.
        """
        # NB: None not only matches itself but also matches “does not exist.”
        # Thus, querying for a key with the value None will return all documents lacking that key
        # hence we have to check that the key is None and $exists:

        # TODO: If something goes wrong when creating the Flow, flow_data is not None and flow_data.status is Init
        #query = {"flow_data": {"$in": [None], "$exists": True}}
        key = "flow_data.exec_status"
        collection.create_index(key)
        query = {key: ExecStatus.init.value}
        items = []
        decoder = AbipyDecoder()
        for doc in collection.find(query, **kwargs):
            oid = doc.pop("_id")
            doc = decoder.process_decoded(doc)
            model = cls(**doc)
            items.append((oid, model))

        return items

    @property
    def is_init(self) -> bool:
        """True if the model is in `init` state"""
        return self.flow_data.exec_status == ExecStatus.init

    @property
    def is_built(self) -> bool:
        """True if the model is in `built` state"""
        return self.flow_data.exec_status == ExecStatus.built

    @property
    def is_completed(self) -> bool:
        """True if the model is in `completed` state"""
        return self.flow_data.exec_status == ExecStatus.completed

    @property
    def is_errored(self) -> bool:
        """True if the model is in `errored` state"""
        return self.flow_data.exec_status == ExecStatus.errored

    @abstractmethod
    def build_flow(self, workdir: str, worker: AbipyWorker) -> Flow:
        """
        Use the data stored in the model to build and return a Flow to the AbipyWorker.
        Must be implemented in the concrete subclass.
        """

    # TODO: Replace arguments with a Context object or introduce Plan object
    # to support multiple collections from which one can fetch input data if needed.

    def build_flow_and_update_collection(self, workdir: str, oid: ObjectId, worker: AbipyWorker) -> Union[Flow, None]:
        """
        This is the API used by the AbiPyWorker to build a Flow from the model.

        It Wraps the ``build_flow`` method implemented by the subclass in order to perform extra operations
        on the model, exception handling and the final insertion in the MongoDB collection.
        """
        collection = worker.mng_connector.get_collection()

        #self.flow_data = flow_data = FlowData()
        flow_data = self.flow_data

        # Set the workdir of the Flow in the model.
        flow_data.workdir = workdir
        flow_data.worker_name = worker.name
        from socket import gethostname
        flow_data.worker_hostname = gethostname()

        try:
            # Call the method provided by the concrete class to build the Flow.
            flow = self.build_flow(workdir, worker)
            flow_data.exec_status = ExecStatus.built
            return flow

        except Exception:
            flow_data.exec_status = ExecStatus.errored
            # Log the exception and add it to tracebacks so to have it in the DB collection.
            exc_string = traceback.format_exc()
            logger.critical(exc_string)
            flow_data.tracebacks.append(exc_string)
            return None

        finally:
            # Update document in the MongoDB collection.
            self.mng_full_update_oid(oid, collection)

    @abstractmethod
    def postprocess_flow(self, flow: Flow, worker: AbipyWorker) -> None:
        """
        Postprocess the Flow and update the model with output results.
        MongoConnector should be used only to insert files in GridFs
        as the final insertion is done by the caller.
        Must be implemented in the concrete subclass.
        """

    def postprocess_flow_and_update_db(self, flow: Flow, oid: ObjectId, worker: AbipyWorker) -> None:
        """
        API used by the AbiPy Worker to postprocess a Flow from the model.
        Wraps the postprocess_flow method implemented in the subclass to add extra
        operations on the model, error handling and finally the insertion in the collection of the model.
        """
        flow_data = self.flow_data
        mng_connector = worker.mng_connector
        collection = mng_connector.get_collection()

        try:
            self.postprocess_flow(flow, worker)

            # Builds works_data entry.
            for work in flow:
                w_data = WorkData.from_work(work, mng_connector, self.with_out, self.with_log)
                flow_data.works_data.append(w_data)

            flow_data.exec_status = ExecStatus.completed

        except Exception:
            flow_data.exec_status = ExecStatus.errored
            flow_data.tracebacks.append(traceback.format_exc())

        finally:
            #print("in postprocessing_flow_and_update_collection with status:", flow_data.exec_status)
            flow_data.completed_at = datetime.utcnow()
            self.mng_full_update_oid(oid, collection)

    @classmethod
    def mng_get_crystal_systems_incoll(cls, collection: Collection, **kwargs) -> list[int]:
        """
        Return list of crystal systems in collection
        kwargs are passed to pymongo `create_index`
        """
        key = "in_structure_data.crystal_system"
        collection.create_index(key, **kwargs)
        return [k for k in collection.distinct(key) if k is not None]

    @classmethod
    def mng_find_by_crystal_system(cls, crystal_system: str, collection: Collection, **kwargs) -> QueryResults:
        """
        Filter documents in the collection according to the crystal system.
        kwargs are passed to mongo.find.
        """
        key = "in_structure_data.crystal_system"
        collection.create_index(key)
        return cls.mng_find({key: crystal_system}, collection, **kwargs)

    @classmethod
    def mng_get_spg_numbers_incoll(cls, collection: Collection, **kwargs) -> list[int]:
        """
        Return list if all space group numbers found in the collection.
        """
        key = "in_structure_data.spg_number"
        collection.create_index(key, **kwargs)
        return [k for k in collection.distinct(key) if k is not None]

    @classmethod
    def mng_find_by_spg_number(cls, spg_number: int, collection: Collection, **kwargs) -> QueryResults:
        """
        Filter documents in the collection according to the space group number.
        kwargs are passed to mongo.find.
        """
        key = "in_structure_data.spg_number"
        collection.create_index(key)
        return cls.mng_find({key: int(spg_number)}, collection, **kwargs)

    @classmethod
    def mng_find_by_formula(cls, formula_pretty: str, collection: Collection, **kwargs) -> QueryResults:
        """
        Filter documents in the collection according to the formula.
        kwargs are passed to pymongo `collection.find`.
        """
        key = "in_structure_data.formula_pretty"
        collection.create_index(key)
        return cls.mng_find({key: formula_pretty}, collection, **kwargs)

    @classmethod
    @abstractmethod
    def get_preset_queries(cls) -> list[PresetQuery]:
        """
        Return list of dictionaries with the MongoDB queries typically used to filter results.
        Empty list if no suggestion is available. Mainly used by the panel-based GUI.
        Must be implemented in the subclass.
        """

    @abstractmethod
    def get_panel_view(self, mng_connector: MongoConnector):
        """Return panel object with a view of the model."""

    @classmethod
    def mng_aggregate_in_struct(cls, collection: Collection) -> pd.DataFrame:
        """
        Aggregate input structures in collection. Return Dataframe.
        """
        rows = []
        for oid in cls.mng_find_completed_oids(collection):
            d = collection.find_one({"_id": oid}, projection=["in_structure_data"])
            structure_data = StructureData(**d["in_structure_data"])
            row = {"_id": oid}
            row.update(structure_data.dict(exclude={"structure"}))
            rows.append(row)

        return pd.DataFrame(rows)

    #@abstractmethod
    #@classmethod
    #def get_aggregate_panel_ui(cls, collection: Collection):
    #    """Return panel object with a view of the model."""

    #def create_indexes(self, collection: Collection):
    #    creates an index if an index of the same specification does not already exist.
    #    from pymongo import IndexModel, ASCENDING, DESCENDING
    #    index1 = IndexModel([("hello", DESCENDING),
    #                         ("world", ASCENDING)], name="hello_world")
    #    index2 = IndexModel([("goodbye", DESCENDING)])
    #    collection.create_indexes([index1, index2])
