import fnmatch
import json
import math
import os
import pickle
import re
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Optional, Union

import commonlib_reader
import matplotlib.pyplot as plt
import networkx as nx
import numpy as np
import pandas as pd
from ots_sdk import TimeseriesAPI
from tagmapper import Model, Pipe, Well
from tagreader import IMSClient, ReaderType


def get_temp_folder():
    tmp_fol = Path(tempfile.gettempdir()) / "well_routing_cache"
    tmp_fol.mkdir(exist_ok=True)
    return tmp_fol


class Node:
    def __init__(self, unique_object_identifier: str, node_type: str = ""):
        self.uoi = unique_object_identifier
        self.node_type = node_type

    @property
    def name(self) -> str:
        return self.uoi

    def __repr__(self):
        return f"Node({self.uoi}, type={self.node_type})"


class GraphNode(Node):
    def __init__(
        self,
        unique_object_identifier: str,
        node_type: str = "",
        properties: Optional[dict] = None,
    ):
        super().__init__(unique_object_identifier, node_type)
        self.properties = properties if properties is not None else {}

    def __repr__(self):
        return f"GraphNode({self.uoi}, type={self.node_type}, properties={self.properties})"


class Edge:
    def __init__(
        self,
        from_node: str,
        to_node: str,
        weight: float = float("Inf"),
        tag_no: str = "",
        tag_source: str = "",
    ):
        self.from_node = from_node
        self.to_node = to_node
        self.weight = weight
        self.tag_no = tag_no
        self.tag_source = tag_source

    def __repr__(self):
        return f"Edge(from={self.from_node}, to={self.to_node}, weight={self.weight})"

    def __eq__(self, other):
        if not isinstance(other, Edge):
            return False
        return (
            self.from_node == other.from_node
            and self.to_node == other.to_node
            and self.weight == other.weight
            and self.tag_no == other.tag_no
            and self.tag_source == other.tag_source
        )


class Graph:
    def __init__(
        self,
        name: str,
        nodes: Optional[List[Node]] = None,
        edges: Optional[List[Edge]] = None,
    ):
        if nodes is None:
            nodes = []
        if edges is None:
            edges = []
        self.name = name
        self.nodes = nodes
        self.edges = edges

        self.ts_data = pd.DataFrame()

    def add_edge(self, edge: Edge):
        if not self.has_edge(edge):
            self.edges.append(edge)

    def has_edge(self, edge: Edge):
        for _edge in self.edges:
            if edge == _edge:
                return True

        return False

    def rm_edge(self, edge: Edge):
        for _edge in self.edges:
            if edge == _edge:
                self.edges.remove(edge)

    def add_node(self, node: Node):
        self.nodes.append(node)

    def get_node(self, name: str):
        for node in self.nodes:
            if "*" in name:
                if fnmatch.fnmatch(node.name, name):
                    return node
            elif node.name.casefold() == name.casefold():
                return node

    def get_nodes(self, node_type: str = "") -> List[Node]:
        if node_type == "":
            return self.nodes

        filtered_nodes = []
        for node in self.nodes:
            if node.node_type.casefold() == node_type.casefold():
                filtered_nodes.append(node)

        return filtered_nodes

    def get_islands(self) -> List[Node]:
        """Get list of nodes without any edges.

        Returns:
            List[Node]: Nodes with no edges in graph.
        """
        islands = []
        for node in self.nodes:
            node_found = False
            for edge in self.edges:
                if node.name.casefold() in [
                    edge.from_node.casefold(),
                    edge.to_node.casefold(),
                ]:
                    node_found = True
                    break
            if node_found is False:
                islands.append(node)

        return islands

    def get_sources(self) -> List[Node]:
        """Get list of nodes that has no incoming edges.

        Returns:
            List[Node]: Nodes with no incoming edges in graph.
        """
        sources = []
        islands = self.get_islands()
        for node in self.nodes:
            if node not in islands:
                if any(
                    [
                        edge.to_node.casefold() == node.name.casefold()
                        for edge in self.edges
                    ]
                ):
                    continue
                sources.append(node)

        return sources

    def get_sinks(self) -> List[Node]:
        """Get list of nodes that has no outgoing edges.

        Returns:
            List[Node]: Nodes with no outgoing edges in graph.
        """
        sinks = []
        islands = self.get_islands()

        for node in self.nodes:
            if node not in islands:
                if any(
                    [
                        edge.from_node.casefold() == node.name.casefold()
                        for edge in self.edges
                    ]
                ):
                    continue
                sinks.append(node)

        return sinks

    def has_node(self, name: str):
        for node in self.nodes:
            if node.name.casefold() == name.casefold():
                return True

        return False

    def rm_node(self, name: str):
        for node in self.nodes:
            if node.name.casefold() == name.casefold():
                self.nodes.remove(node)

    def read_timeseries_data(
        self,
        start_time: Union[str, datetime],
        stop_time: Union[str, datetime],
        read_type: str = "INT",
        interval: timedelta = timedelta(hours=1),
    ):
        def format_timedelta_to_hms_string_sparse(td: timedelta) -> str:
            """
            Transforms a datetime.timedelta object into a sparse string format
            like "1h30m20s", omitting hours (h) if the duration is less than one hour.

            Args:
                td: The timedelta object to format.

            Returns:
                A formatted string representing hours, minutes, and seconds
                (e.g., "1h30m20s", "5m45s", or "15s").
            """
            total_seconds = int(td.total_seconds())

            # Calculate hours, minutes, and remaining seconds
            hours, remainder = divmod(total_seconds, 3600)
            minutes, seconds = divmod(remainder, 60)

            # Build the output string, including only non-zero components,
            # and respecting the rule to omit 'h' if hours are 0.
            parts = []

            # 1. Hours (Only included if > 0)
            if hours > 0:
                parts.append(f"{hours}h")

            # 2. Minutes (Only included if > 0 OR if hours == 0 and minutes are 0
            #    but seconds are > 0. Sticking to the strict "sparse" requirement,
            #    we only include minutes if they are > 0.)
            if minutes > 0:
                parts.append(f"{minutes}m")

            # 3. Seconds (Only included if > 0)
            if seconds > 0:
                parts.append(f"{seconds}s")

            # Edge case: If the duration is exactly zero, all components will be zero.
            if not parts:
                return "0s"  # Simplest representation of zero duration

            return "".join(parts)

        if isinstance(start_time, datetime):
            start_time = str(start_time)

        if isinstance(stop_time, datetime):
            stop_time = str(stop_time)

        if self.ts_data is None:
            self.ts_data = pd.DataFrame()

        ots_client = None
        ims_client = {}
        for edge in self.edges:
            if isinstance(edge.tag_no, str) and len(edge.tag_no) > 0:
                if edge.tag_no in self.ts_data.columns:
                    # too simple skip check for existing data
                    continue
                if edge.tag_source == "ots":
                    if ots_client is None:
                        ots_client = TimeseriesAPI()
                    if read_type == "RAW":
                        ts_data = ots_client.get_datapoints(
                            id=edge.tag_no, startTime=start_time, endTime=stop_time
                        )
                    else:
                        ts_data = ots_client.get_aggregates(
                            id=edge.tag_no,
                            aggregateFunction=read_type,
                            startTime=start_time,
                            endTime=stop_time,
                            processingInterval=format_timedelta_to_hms_string_sparse(
                                interval
                            ),
                        )

                    pass
                else:
                    if edge.tag_source not in ims_client.keys():
                        ims_client[edge.tag_source] = IMSClient(
                            datasource=edge.tag_source
                        )

                    ts_data = ims_client[edge.tag_source].read(
                        tags=edge.tag_no,
                        start_time=start_time,
                        end_time=stop_time,
                        ts=interval,
                        read_type=ReaderType[read_type],
                    )
                    self.ts_data = pd.concat([self.ts_data, ts_data])

    def get_minimal_timeseries_data(self):
        if self.ts_data is None or self.ts_data.empty:
            return pd.DataFrame()

        valid_data = self.ts_data.ffill(axis=0).dropna(how="any")
        is_duplicate = valid_data.eq(valid_data.shift(1)).all(axis=1)
        return valid_data[~is_duplicate]

    def get_timeseries_value(self, timestamp: datetime):
        df = self.get_minimal_timeseries_data()

        if df.empty:
            raise ValueError("No valid time series data to get")

        if isinstance(timestamp, datetime) and timestamp.tzinfo is None:
            timestamp = pd.to_datetime(timestamp)
        elif isinstance(timestamp, str):
            timestamp = pd.to_datetime(timestamp)

        if isinstance(timestamp, pd.Timestamp) and timestamp.tzinfo is None:
            timestamp = timestamp.tz_localize("Europe/Oslo")

        df = df.reindex([timestamp], method="ffill")
        if df.isnull().any(axis=1).iloc[0]:
            return pd.DataFrame(columns=df.columns)

        return df

    def convert_to_networkx(
        self, directed: bool = True, timestamp: Optional[datetime] = None
    ) -> Union[nx.DiGraph, nx.Graph]:
        """Convert the graph to a NetworkX graph.

        Args:
            directed (bool, optional): _description_. Defaults to True.
            timestamp (Optional[datetime]): _description_. Defaults to None.

        Returns:
            Union[nx.DiGraph, nx.Graph]: A networkx graph.
        """
        with_weights = timestamp is not None
        if with_weights:
            try:
                df = self.get_timeseries_value(timestamp)
            except ValueError:
                raise ValueError(
                    "Graph has no timeseries data. Read data or get graph without weights."
                )

            if df.empty:
                raise ValueError(
                    "Graph has insufficient timeseries data for this time period. Read more data or get graph without weights."
                )

        if directed:
            G = nx.DiGraph()
        else:
            G = nx.Graph()

        for node in self.nodes:
            G.add_node(node.uoi, group=node.node_type)

        for edge in self.edges:
            if with_weights:
                if (
                    edge.tag_no is not None
                    and len(edge.tag_no) > 0
                    and edge.tag_no in df.columns
                ):
                    try:
                        curr_weight = df.at[df.index[0], edge.tag_no]
                    except:
                        curr_weight = edge.weight
                else:
                    curr_weight = edge.weight

                if curr_weight == 0:
                    curr_weight = float("Inf")

                G.add_edge(
                    u_of_edge=edge.from_node,
                    v_of_edge=edge.to_node,
                    weight=curr_weight,
                )
            else:
                G.add_edge(
                    u_of_edge=edge.from_node,
                    v_of_edge=edge.to_node,
                )

        return G

    def has_path(
        self,
        from_node: Union[str, Node],
        to_node: Union[str, Node],
    ) -> bool:
        if isinstance(from_node, Node):
            from_node = from_node.uoi
        if isinstance(to_node, Node):
            to_node = to_node.uoi
        G = self.convert_to_networkx(directed=True)
        return nx.has_path(G, from_node, to_node)

    def shortest_path(
        self, from_node: str, to_node: str, timestamp: Optional[datetime] = None
    ) -> List[str]:
        if isinstance(from_node, Node):
            from_node = from_node.uoi
        if isinstance(to_node, Node):
            to_node = to_node.uoi
        G = self.convert_to_networkx(directed=True, timestamp=timestamp)
        return nx.shortest_path(G, from_node, to_node, weight="weight")

    def path_weight(
        self, path: List[str], timestamp: Optional[datetime] = None
    ) -> float:
        G = self.convert_to_networkx(directed=True, timestamp=timestamp)
        if timestamp is None:
            return float(len(path) - 1)
        return nx.path_weight(G, path, weight="weight")

    def get_minimal_routing_table(
        self,
        sources: Union[list[str], list[Node]],
        sinks: Union[list[str], list[Node]],
        timestamp: datetime,
    ) -> pd.DataFrame:
        def get_routing_table(
            self,
            sources: Union[list[str], list[Node]],
            sinks: Union[list[str], list[Node]],
            timestamp: datetime,
        ) -> pd.DataFrame:
            for i in range(0, len(sources)):
                if isinstance(sources[i], Node):
                    sources[i] = sources[i].uoi
            for i in range(0, len(sinks)):
                if isinstance(sinks[i], Node):
                    sinks[i] = sinks[i].uoi

            df_routing = pd.DataFrame()

            for source in sources:
                for sink in sinks:
                    if self.has_path(source, sink):
                        path = self.shortest_path(source, sink, timestamp=timestamp)
                        pw = (
                            0
                            if self.path_weight(path, timestamp=timestamp) == np.inf
                            else self.path_weight(path, timestamp=timestamp)
                        )
                        if path[-2] in sinks:
                            pw = -pw
                        # df_routing.at[source, sink] = pw
                    else:
                        pw = int(0)
                    df_routing.at[source, sink] = pw

            return df_routing

        df = get_routing_table(self, sources, sinks, timestamp)

        # Check sinks are reachable from any sink
        # df_sinks = get_routing_table(self, sinks, sinks, timestamp)

        return df

    def plot(
        self,
        show: bool = False,
        save: bool = True,
        timestamp=None,
        pos: Optional[dict] = None,
        name: str = "",
    ):
        G = self.convert_to_networkx(timestamp=timestamp)

        if pos is None:
            pos = nx.nx_agraph.graphviz_layout(
                G, prog="dot", args="-Gnodesep=4.0 -Granksep=2.0"
            )

        styles = []
        for _, _, d in G.edges(data=True):
            w = d.get("weight", 0)
            if w == 0 or math.isinf(w):
                styles.append("dotted")
            if w < 0:
                styles.append("dashed")
            else:
                styles.append("solid")

        node_size = 150

        fig = plt.figure(figsize=(30, 20))
        ax = fig.add_axes(plt.axes())
        label = []
        for node in self.get_nodes(node_type=""):
            label.append(node.uoi)

        if pos is not None:
            nx.draw_networkx_nodes(
                G=G, pos=pos, node_size=node_size, nodelist=label, ax=ax
            )
            # nx.draw_networkx_labels(G=G, pos=pos)
            nx.draw_networkx_edges(
                G=G,
                pos=pos,
                arrows=True,
                arrowstyle="-|>",
                arrowsize=node_size / 15,
                node_size=node_size,  # Must match node_size above
                ax=ax,
                style=styles,
            )

            label_pos = {}
            offset_dist = node_size / 30
            rows = {}
            for node, (x, y) in pos.items():
                rows.setdefault(y, []).append(node)

            for _, nodes_in_row in rows.items():
                # Sort nodes in this row by X coordinate
                nodes_in_row.sort(key=lambda n: pos[n][0])

                for i, node in enumerate(nodes_in_row):
                    x, y = pos[node]
                    # Alternate: even index goes UP (+y), odd index goes DOWN (-y)
                    if i % 2 == 0:
                        label_pos[node] = (x, y + offset_dist)
                    else:
                        label_pos[node] = (x, y - 1.5 * offset_dist)

            # 4. Draw labels with the new positions
            nx.draw_networkx_labels(G, label_pos, font_size=8)
        else:
            nx.draw(G=G)

        title = self.name
        if len(name) == 0:
            title = f"{title}_{name}"
        if timestamp is not None:
            title = f"{title}_{timestamp}"

        if save:
            plt.savefig(title + ".png")
            plt.savefig(f"{title}.svg", format="svg", dpi=300)
        if show:
            plt.show()

        plt.close(fig=fig)

    def save_cytoscape_json(
        self,
        filepath: str,
    ):
        def serialize(obj):
            if isinstance(obj, Node):
                return obj.__dict__

            if isinstance(obj, Edge):
                return obj.__dict__
            # Handle other non-serializable objects if needed
            raise TypeError(f"Type {type(obj)} not serializable")

        cy_data = nx.cytoscape_data(self.convert_to_networkx())

        # json_graph.cytoscape.c(self.convert_to_networkx())
        with open(file=filepath, mode="w") as fh:
            json.dump(cy_data, fh, default=serialize)

    def save(self, filepath: str):
        with open(file=filepath, mode="wb+") as fh:
            pickle.dump(self, fh)

    def __str__(self):
        return f"Graph {self.name} with {len(self.nodes)} nodes, and {len(self.edges)} edges"

    @staticmethod
    def load(filepath: str) -> "Graph":
        """Load a graph from file.
        Args:
            filepath (str): Path to file.
        Returns:
            Graph: The loaded graph.
        """
        with open(file=filepath, mode="rb") as fh:
            G = pickle.load(fh)
            if isinstance(G, Graph):
                return G
            raise ValueError(f"File {filepath} does not contain a Graph object.")

    @staticmethod
    def create_asset_graph(stid_code: str) -> "Graph":
        """Create a graph for the given asset.

        Args:
            stid_code (str): The stid installation code.

        Returns:
            Graph: The created graph.
        """

        fac = commonlib_reader.Facility(code=stid_code)

        models, wells, flowlines, manifolds, pipes = Graph._get_spd_node_models(
            stid_code
        )
        pipe_ioc_names = [x.ioc_name for x in pipes]
        # pipe_fl = [x.functional_location for x in pipes]
        g = Graph(f"{stid_code}-well-routing")
        for mod in models:
            # print(mod.object_name)
            if mod.empty or not (
                mod.object_name.startswith(f"{stid_code}-")
                or mod.object_name.startswith(f"{fac.SAP}-")
                or mod.object_name.startswith("NO ")
            ):
                continue

            if mod.object_name.startswith("NO "):
                if mod.object_name in wells:
                    pass
                else:
                    continue
                    # tag = Tag.search_additional_field(
                    #     inst_code=stid_code, field_name="UWI", value=mod.object_name
                    # )
                    # if len(tag) > 1 or len(tag) == 0:
                    #     # Well not found for asset
                    #     continue
                    # pass
                node_type = "Well"
            elif "INLET" in mod.object_name:
                node_type = "Inlet"
            elif (
                "MANIFOLD" in mod.object_name
                or "-MAN-" in mod.object_name
                or mod.object_name in manifolds
            ):
                node_type = "Manifold"
            elif "BORE" in mod.object_name:
                node_type = "Bore"
            elif (
                "FLOWLINE" in mod.object_name
                or "-FL-" in mod.object_name
                or mod.object_name in flowlines
            ):
                node_type = "Flowline"
            elif mod.object_name in pipe_ioc_names:  # or mod.object_name in pipe_fl:
                node_type = "Pipe"
            else:
                node_type = "Unknown"
                pass

            g.add_node(
                Node(unique_object_identifier=mod.object_name, node_type=node_type)
            )

        for mod in models:
            if not g.has_node(mod.object_name):
                continue

            # print(mod.object_name)
            routings = []
            for att in mod.attribute:
                # Get all known exit routings for this model / node
                if att.name.startswith("Routing"):
                    if att.name.endswith("name"):
                        if att.mapping[0].value.startswith("NOT_IN_USE"):
                            continue
                        else:
                            d = re.findall(r"-?\d*\.?\d+", att.name)
                            if len(d) > 0:
                                d = d[0]
                            routings.append((d, att.mapping[0].value))
                    pass

                for index, (num, route) in enumerate(routings):
                    # Add any missing nodes to the graph
                    if not (g.has_node(route) or g.has_node(f"{stid_code}-{route}")):
                        if route.startswith(stid_code):
                            uoi = route
                        else:
                            uoi = f"{stid_code}-{route}"

                        if route in manifolds:
                            g.add_node(
                                Node(unique_object_identifier=uoi, node_type="Manifold")
                            )
                        elif route in flowlines:
                            g.add_node(
                                Node(unique_object_identifier=uoi, node_type="Flowline")
                            )
                        elif "-inlet-" in route.casefold():
                            # Some inlets are not defined in IOC-hierarchy
                            g.add_node(
                                Node(unique_object_identifier=uoi, node_type="Inlet")
                            )
                        elif any([route.replace("-", "") in f for f in flowlines]):
                            # uoi might not be the same as flowline name (which would be functional location or a stid tag)
                            ind_exact = [
                                i
                                for i, f in enumerate(flowlines)
                                if route.replace("-", "") == f
                            ]
                            if len(ind_exact) > 0:
                                routings[index] = (num, flowlines[ind_exact[0]])
                                continue
                            ind_inexact = [
                                i
                                for i, f in enumerate(flowlines)
                                if route.replace("-", "") in f
                            ]
                            if len(ind_inexact) > 0:
                                routings[index] = (num, flowlines[ind_inexact[0]])
                                continue
                            g.add_node(
                                Node(unique_object_identifier=uoi, node_type="Flowline")
                            )
                        elif route in pipe_ioc_names:
                            for p in pipes:
                                if p.ioc_name == route:
                                    if p.functional_location != "":
                                        uoi = p.functional_location
                                    else:
                                        pass

                                    routings[index] = (num, uoi)

                                    g.add_node(
                                        Node(
                                            unique_object_identifier=uoi,
                                            node_type="Pipe",
                                        )
                                    )
                                    break
                        else:
                            print(f"Adding unknown node {route}")
                            g.add_node(Node(unique_object_identifier=uoi))

            for att in mod.attribute:
                # print(att.name)
                to_node = None
                for route in routings[:]:
                    if not (
                        g.has_node(route[1]) or g.has_node(f"{stid_code}-{route[1]}")
                    ):
                        if route[1].startswith(stid_code):
                            uoi = route[1]
                        else:
                            uoi = f"{stid_code}-{route[1]}"
                        if route[1] in manifolds:
                            g.add_node(
                                Node(unique_object_identifier=uoi, node_type="Manifold")
                            )
                        elif route[1] in flowlines:
                            g.add_node(
                                Node(unique_object_identifier=uoi, node_type="Flowline")
                            )
                        else:
                            # print("Adding unknown node {route[1]}")
                            g.add_node(Node(unique_object_identifier=uoi))
                    if att.name.startswith(f"Routing {route[0]}") and att.name.endswith(
                        "name"
                    ):
                        for att_to in mod.attribute:
                            if att_to.mapping[0].comment.startswith(
                                f"Routing {route[0]}"
                            ) or (
                                att_to.name.startswith("Routing")
                                and att_to.name.endswith(route[1])
                            ):
                                if not g.has_node(mod.object_name):
                                    pass
                                if g.has_node(route[1]):
                                    to_node = g.get_node(route[1])
                                elif g.has_node(f"{stid_code}-{route[1]}"):
                                    to_node = g.get_node(f"{stid_code}-{route[1]}")

                                if to_node is not None:
                                    g.add_edge(
                                        Edge(
                                            from_node=mod.object_name,
                                            to_node=to_node.uoi,
                                            tag_no=att_to.mapping[0].tag,
                                            tag_source=att_to.mapping[0].source,
                                        )
                                    )
                                    routings.remove(route)
                                    break
                    if to_node is not None:
                        # to_node for this attribute is found, break outer loop
                        break
                if len(routings) == 0:
                    break

            if len(routings) > 0:
                # Add PDM fixed routing weights
                for route in routings[:]:
                    to_node = None
                    for att_to in mod.attribute:
                        if att_to.name.casefold() in ["flowline", "manifold"]:
                            if g.has_node(route[1]):
                                to_node = g.get_node(route[1])
                            elif g.has_node(f"{stid_code}-{route[1]}"):
                                to_node = g.get_node(f"{stid_code}-{route[1]}")
                            if to_node is not None:
                                if not g.has_node(mod.object_name):
                                    pass
                                if att_to.mapping[0].comment.startswith("PDM"):
                                    if att_to.mapping[0].value == route[1]:
                                        g.add_edge(
                                            Edge(
                                                from_node=mod.object_name,
                                                to_node=to_node.uoi,
                                                weight=1,
                                            )
                                        )
                                    else:
                                        g.add_edge(
                                            Edge(
                                                from_node=mod.object_name,
                                                to_node=to_node.uoi,
                                                weight=0,
                                            )
                                        )
                                    routings.remove(route)
                                    break  # Break att_to loop

        return g

    @classmethod
    def get_asset_graph(
        cls,
        stid_code: str,
        start_time: Union[str, datetime],
        stop_time: Union[str, datetime],
    ) -> "Graph":
        file_name_networkx = (
            f"{get_temp_folder()}/{stid_code}-graph_data_{start_time}_{stop_time}.pkl"
        )

        if not os.path.isfile(file_name_networkx):
            g = cls.create_asset_graph(stid_code)

            g.read_timeseries_data(start_time=start_time, stop_time=stop_time)
            if g.ts_data is not None and not g.ts_data.empty:
                g.save(file_name_networkx)
        else:
            g = cls.load(file_name_networkx)

        return g

    @staticmethod
    def get_weighted_networkx(
        stid_code: str,
        start_time: Union[str, datetime],
        stop_time: Union[str, datetime],
        chosen_time: Union[str, datetime],
    ):
        g = Graph.get_asset_graph(stid_code, start_time, stop_time)
        return g.convert_to_networkx(timestamp=chosen_time)

    @staticmethod
    def _get_spd_node_models(stid_code, refresh=False):
        file_name_spd_models = f"{stid_code}_spd_cache.pkl"
        if os.path.isfile(file_name_spd_models) and not refresh:
            with open(file_name_spd_models, "rb") as file:
                cached_data = pickle.load(file)
            if len(cached_data) < 5:
                return Graph._get_spd_node_models(stid_code=stid_code, refresh=True)
            models = cached_data[0]
            wells = cached_data[1]
            flowlines = cached_data[2]
            manifolds = cached_data[3]
            pipes = cached_data[4]
        else:
            p = commonlib_reader.Facility(code=stid_code)

            flowlines = Model.get_object_names(
                model_owner="IOC", model_name="8110 - FLOWLINE RATE"
            )
            flowlines = [
                m
                for m in flowlines
                if m.startswith(f"{stid_code}") or m.startswith(f"{p.sap}-")
            ]

            manifolds = Model.get_object_names(
                model_owner="IOC", model_name="8200 - MANIFOLD RATE"
            )
            manifolds = [
                m
                for m in manifolds
                if m.startswith(f"{stid_code}") or m.startswith(f"{p.sap}-")
            ]

            models = Model.get_models(
                model_owner="IOC",
                model_name="8002 - Rate distribution",
                uoi_filter=f"{stid_code}-*",
            )
            models = models + Model.get_models(
                model_owner="IOC",
                model_name="8002 - Rate distribution",
                uoi_filter=f"{stid_code}_*",
            )
            if len(p.sap) > 0:
                models = models + Model.get_models(
                    model_owner="IOC",
                    model_name="8002 - Rate distribution",
                    uoi_filter=f"{p.sap}-*",
                )

            models = models + Model.get_models(
                model_owner="IOC",
                model_name="8002 - Rate distribution",
                uoi_filter="NO *",
            )

            wells = Well.get_uwis(p.resolve_gov_facility_name())
            pipes = Pipe.get_pipes(facility=p.inst_code, with_mappings=False)

            with open(file_name_spd_models, "wb") as file:
                pickle.dump((models, wells, flowlines, manifolds, pipes), file)
        return models, wells, flowlines, manifolds, pipes

    @classmethod
    def clear_cache(cls):
        temp_folder = get_temp_folder()
        for filename in os.listdir(temp_folder):
            if filename.endswith(".pkl"):
                os.remove(os.path.join(temp_folder, filename))
