from datetime import datetime, timedelta
from itertools import combinations
import importlib.util
import numpy as np
from typing import Optional, List, Dict, Tuple, Union, NamedTuple

from asf_search import ASF_LOGGER
from .ASFProduct import ASFProduct
from .S1MultiBurstProduct import S1MultiBurstProduct
from .Pair import Pair
from .Stack import Stack
from .ASFSearchOptions import ASFSearchOptions
from .ASFSearchResults import ASFSearchResults

try:
    from ciso8601 import parse_datetime
except ImportError:
    from dateutil.parser import parse as parse_datetime

_SBASNETWORK_PLOT_OPT_DEPS = ['plotly', 'networkx', 'pandas']
try:
    for spec in _SBASNETWORK_PLOT_OPT_DEPS:
        if importlib.util.find_spec(spec) is None:
            raise ImportError
        
    import plotly.graph_objects as go
    import networkx as nx
    from plotly.colors import sample_colorscale
    import pandas as pd
except ImportError:
    go = None
    nx = None
    sample_colorscale = None
    pd = None

class S1MultiBurstSceneIDPair(NamedTuple):
    """
    Defines a named tuple of lists of reference and secondary ASFProducts ID strings
    for Pairs of S1MultiBurstProducts

    This is useful when ordering S1 multi-burst interferograms from HyP3
    """
    reference: List[str]
    secondary: List[str]
class SBASNetwork(Stack):
    """
    SBASNetwork is a child class of Stack, used to create seasonal SBAS networks. It can be used
    to create multiannual networks connected with multiannual bridge pairs.

    Construct using:
    - SBASNetwork.from_geo_reference()
    - SBASNetwork.from_search_results()
    """
    def __init__(
        self,
        geo_reference: Union[ASFProduct, S1MultiBurstProduct],
        *,
        start_date: Optional[str] = None,
        end_date: Optional[str] = None,
        season: Tuple[int, int] = (1, 365),
        perpendicular_baseline: int = 400,
        inseason_temporal_baseline: int = 36,
        bridge_year_threshold: int = 0,
        bridge_target_date: Optional[str] = None,
        opts: Optional[ASFSearchOptions] = None,
        allow_missing_state_vectors: bool = False
    ):
        """
        geo_reference: an ASFProduct used as a georeference scene for the network
        perpendicular_baseline: the perpendicular baseline for the SBAS network
        inseason_temporal_baseline: the temporal baseline for the SBAS network, not accounting for multiannual bridging
        bridge_year_threshold: the number of year for which to allow multiannual bridging
        bridge_target_date: %m-%d string of the inseason bridge date to target. 
                            Reference scenes for bridge pairs are valid within an inseason_temporal_baseline of this date.
        opts:  Additional ASFSearchOptions used for the stack search. Explicit start_date and end_date arguments 
               override corresponding values in opts. The opts season value is always overriden by the season argument.
        """
        self.start_date = start_date
        self.end_date = end_date
        self._set_network_parameters(
            season=season, 
            perpendicular_baseline=perpendicular_baseline, 
            inseason_temporal_baseline=inseason_temporal_baseline, 
            bridge_year_threshold=bridge_year_threshold,
            bridge_target_date=bridge_target_date,
            allow_missing_state_vectors=allow_missing_state_vectors
            )

        if opts is None:
            opts = ASFSearchOptions()

        merge_kwargs = {"season": self.season}

        if self.start_date is not None:
            merge_kwargs["start"] = self.start_date

        if self.end_date is not None:
            merge_kwargs["end"] = self.end_date

        opts.merge_args(**merge_kwargs)
        self.opts = opts

        super().__init__(
            geo_reference=geo_reference, 
            opts=self.opts,
            allow_missing_state_vectors = self.allow_missing_state_vectors
            )
        self._build_sbas_network()
        self.geo_reference = self.subset_stack[0].ref # must reset here because ref object is replaced with a duplicate when building full stack

    @classmethod
    def from_geo_reference(
        cls,
        geo_reference: Union[ASFProduct, S1MultiBurstProduct],
        **kwargs,
    ):
        """
        Class constructor using a geo_reference ASFProduct or S1MultiBurstProduct
        """
        if not isinstance(geo_reference, (ASFProduct, S1MultiBurstProduct)):
            raise TypeError(
                f"Unsupported geo_reference type: {type(geo_reference).__name__}"
            )

        return cls(geo_reference=geo_reference, **kwargs)

    @classmethod
    def from_search_results(
        cls,
        stack_search_results: ASFSearchResults,
        season: Tuple[int, int] = (1, 365),
        perpendicular_baseline: int = 400,
        inseason_temporal_baseline: int = 36,
        bridge_year_threshold: int = 1,
        bridge_target_date: Optional[str] = None,
        allow_missing_state_vectors: bool = False
    ):
        """
        Class constructor using ASFSearchResults instead of a single geo_reference.
        """
        if not stack_search_results:
            raise ValueError("stack_search_results cannot be empty")

        # Intentionally bypass __init__ and Stack.__init__:
        # search results are already available, so no stack search
        # or ASFSearchOptions are needed
        obj = cls.__new__(cls)
        dates = [parse_datetime(product.properties["startTime"]).date() for product in stack_search_results]
        obj.start_date = str(min(dates))
        obj.end_date = str(max(dates))

        obj._set_network_parameters(
            season=season, 
            perpendicular_baseline=perpendicular_baseline, 
            inseason_temporal_baseline=inseason_temporal_baseline, 
            bridge_year_threshold=bridge_year_threshold,
            bridge_target_date=bridge_target_date,
            allow_missing_state_vectors=allow_missing_state_vectors
            )
        
        obj.full_stack = obj._build_full_stack(stack_search_results)
        obj._remove_list = []
        obj.subset_stack = obj._get_subset_stack()
        obj.connected_substacks = obj._find_connected_substacks()
        obj.geo_reference = None

        obj._build_sbas_network()

        return obj
    
    def _set_network_parameters(
        self,
        season: Tuple[int, int],
        perpendicular_baseline: int,
        inseason_temporal_baseline: int,
        bridge_year_threshold: int,
        bridge_target_date: Optional[str],
        allow_missing_state_vectors: bool,
    ) -> None:
        self.season = season
        if bridge_target_date is not None:
            self.bridge_target_date = bridge_target_date
        elif self.start_date is not None:
            year = parse_datetime(self.start_date).year
            self.bridge_target_date = datetime.strptime(
                f"{year}-{season[0]}",
                "%Y-%j",
            ).strftime("%m-%d")
        else:
            raise ValueError(
                "start_date is required when bridge_target_date is not provided"
    )

        self.perpendicular_baseline = perpendicular_baseline
        self.inseason_temporal_baseline = inseason_temporal_baseline
        self.bridge_year_threshold = bridge_year_threshold
        self.allow_missing_state_vectors = allow_missing_state_vectors
        self.temporal_baseline = (
            bridge_year_threshold * 365
            + inseason_temporal_baseline
        )
    
    def _full_stack_pair_is_valid(self, pair: Pair) -> bool:
        perpendicular_baseline = pair.perpendicular_baseline

        if perpendicular_baseline is None and not self.allow_missing_state_vectors:
            return False

        if perpendicular_baseline is not None and abs(perpendicular_baseline) > self.perpendicular_baseline:
            return False

        maximum_temporal_baseline = self.inseason_temporal_baseline + self.bridge_year_threshold * 365

        return pair.temporal_baseline.days <= maximum_temporal_baseline
       
    def _build_full_stack(self, stack_search_results: Optional[ASFSearchResults]=None) -> List[Pair]:
        """
        Create self._full_stack, which involves performing a stack search
        of the georeference scene and creating a list of every possible Pair.

        Overrides Stack._build_full_stack() to naively exclude pairs based on self.perpendicular_baseline,
        self.inseason_temporal_baseline, and self.bridge_year_threshold. This is done for efficiency, to
        avoid build massive full_stacks. More targeted filtering occures in self._build_sbas_network()

        stack_search_results: (Optional) ASFSearchResults from an ASFProduct.stack search

        Returns: A list of pairs filtered by baselines, and including every possible bridge pair within 
                    self.bridge_year_threshold
        """
        if stack_search_results is None:
            stack_search_results = self.geo_reference.stack(opts=self.opts)

            if isinstance(self.geo_reference, S1MultiBurstProduct):
                multiburst_dict = {}
                for results in stack_search_results:
                    for p1, p2 in combinations(results, 2):
                        key = (parse_datetime(p1.properties["startTime"]).date(), parse_datetime(p2.properties["startTime"]).date())
                        if self._full_stack_pair_is_valid(Pair(p1, p2)):
                                multiburst_dict.setdefault(key, [[], []])
                                multiburst_dict[key][0].append(p1)
                                multiburst_dict[key][1].append(p2) 
                
                full_stack = []
                for product_lists in multiburst_dict.values():
                    # skip pairs that don't contain a product for every burst/subswath
                    if len(product_lists[0]) == len(self.geo_reference):
                        pair = Pair(
                            S1MultiBurstProduct(geo_reference_bursts=product_lists[0]), 
                            S1MultiBurstProduct(geo_reference_bursts=product_lists[1])
                            )
                        full_stack.append(pair)
                return self._sort_pair_list(full_stack)
    
        full_stack = []
        for p1, p2 in combinations(stack_search_results, 2):
            pair = Pair(p1, p2)
            if self._full_stack_pair_is_valid(pair):
                full_stack.append(pair)

        return self._sort_pair_list(full_stack)


    def _is_valid_bridge_pair(self, pair: Pair) -> bool:
        """
        Logic to determine if a pair meets the criteria for including in an SBAS network as a bridge pair:
        - reference scene date within 1/2 self.inseason_temporal_baseline on either side of self.bridge_target_date
        - secondary scene within 1/2 self.inseason_temporal_baseline on either side of self.bridge_target_date for 
          each year within the self.bridge_year_threshold

        pair: the Pair to check
        """
        # return True right away if pair within inseason temp baseline
        if pair.temporal_baseline.days <= self.inseason_temporal_baseline:
            return True

        # determine how far ref scene date is from target multi-annual bridge date 
        days_from_bridge_date = np.abs(
            (
                datetime.strptime(f"{self.bridge_target_date}-{pair.ref_time.year}", "%m-%d-%Y") 
                - pair.ref_time.replace(tzinfo=None)).days
            )
        half_inseason_temporal_baseline = self.inseason_temporal_baseline / 2

        # Create lists of valid secondary scene date ranges.
        # The number of years bridged is determined by the bridge_year_threshold.
        valid_ranges = []
        for i in range(1, self.bridge_year_threshold+1):
            valid_ranges.append((i * 365 - half_inseason_temporal_baseline, i * 365 + half_inseason_temporal_baseline))

        # return True if the ref scene is within the inseason_temporal_baseline / 2 on either
        # side of the target bridge date and the secondary scene falls within a valid date range
        return days_from_bridge_date <= half_inseason_temporal_baseline and \
            any(start <= pair.temporal_baseline.days <= end for start, end in valid_ranges)

    def _build_sbas_network(self):
        """
        Create a self._remove_list for filtering self.full_stack to produce self.subset_stack.
        Initial temporal and perpendicular baselng occur in SBASNetwork._build_full_stack().
        This method appies additional filtering to remove potential pairs based on self.inseason_temporal_baseline
        and self.bridge_year_threshold
        """
        remove_list = []
        for pair in self.full_stack:
            if not self._is_valid_bridge_pair(pair):
                remove_list.append(pair)
        self.remove_list = remove_list

    def get_scene_ids(
            self,
            pair_list: Optional[List[Pair]] = None
            ) -> Union[List[Tuple[List[str], List[str]]], List[Tuple[str, str]]]:
        """
        Overrides Stack.get_scene_ids

        Provides scene names for all asf_search.ASFProducts in a list of Pairs.
        Useful when ordering pair-based products from ASF HyP3 On-Demand Processing.

        If no pair_list is passed, defaults to the largest connected substack

        pair_list: (Optional) A list of `Pair`s for which to retrieve scene IDs.
        
        Returns: If the SBASNetwork is comprised of S1MultiBurstProducts, returns a list of tuples of lists. 
                 Each tuple contains a list of reference scene IDs and a list of secondary scene IDs for a 
                 Pair of S1MultiBurstProducts. If not an S1MultiBurstProducts SBASNetwork, defaults 
                 to using Stack.get_scene_ids
        """
        if pair_list is None:
            pair_list = max(self.connected_substacks, key=len)

        if pair_list and isinstance(pair_list[0].ref, S1MultiBurstProduct):
            return [S1MultiBurstSceneIDPair([p.properties["sceneName"] for p in pair.ref.geo_reference_bursts],
                     [p.properties["sceneName"] for p in pair.sec.geo_reference_bursts])
                     for pair in pair_list]

        return super().get_scene_ids(pair_list)

    def add_digraph_edge_traces(self, digraph, largest_network, pair_lists, node_positions):
        """
        Used for SBASNetwork plotting. Adds networkx traces to graph edges

        digraph: networkx.DiGraph
        largest_network: The largest (perhaps only) connected SBASNetwork pair list being plotted
        pair_lists: list of all pair lists being plotted
        node_positions: Tuple[Dict[networkx.DiGraph.node, datetime.timestamp], Dict[networkx.DiGraph.node, float]]
                        representing the x and y positions for each node    

        returns: A plotly.graph_objects.Scatter object for graph edges   
        """
    
        insar_node_pairs = [
            (
                datetime.strftime(pair.ref_time, "%Y-%m-%d"),
                datetime.strftime(pair.sec_time, "%Y-%m-%d"),
                {
                    "perp_bs": pair.perpendicular_baseline,
                    "temp_bs": pair.temporal_baseline.days
                }
            )
            for pair in largest_network
        ]
        digraph.add_edges_from(insar_node_pairs, data=True)

        edge_x = []
        edge_y = []
        edge_text = []
        for edge in digraph.edges(data=True):
            x0 = node_positions[0][edge[0]]
            y0 = node_positions[1][edge[0]]
            x1 = node_positions[0][edge[1]]
            y1 = node_positions[1][edge[1]]
            edge_x.extend([x0, x1, None])
            edge_y.extend([y0, y1, None])
            edge_text.append(
                f"{edge[0]} - {edge[1]}, perp baseline: {edge[2]['perp_bs']}, temp baseline: {edge[2]['temp_bs']}"
            )

        edge_traces = [
            go.Scatter(
                x=edge_x,
                y=edge_y,
                line=dict(width=4, color="rgba(52, 114, 168, 0.7)"),  # blue
                mode="lines",
            ),
            go.Scatter(
                x=[
                    (node_positions[0][edge[0]] + node_positions[0][edge[1]]) / 2
                    for edge in digraph.edges()
                ],
                y=[
                    (node_positions[1][edge[0]] + node_positions[1][edge[1]]) / 2
                    for edge in digraph.edges()
                ],
                mode="markers",
                marker=dict(size=20, color="rgba(255, 255, 255, 0)"),
                hoverinfo="text",
                text=edge_text,
            )
        ]

        # Add additional sub-networks
        other_networks = [s for s in pair_lists if s is not largest_network]
        colors = get_n_colors(len(other_networks))
        for color, other_network in zip(colors, other_networks):
            edge_x = []
            edge_y = []
            for pair in other_network:
                ref = datetime.strftime(pair.ref_time, "%Y-%m-%d")
                sec = datetime.strftime(pair.sec_time, "%Y-%m-%d")
                if ref in node_positions[0] and sec in node_positions[0]:
                    edge_x.extend([node_positions[0][ref], node_positions[0][sec], None])
                    edge_y.extend([node_positions[1][ref], node_positions[1][sec], None])
            edge_traces.append(go.Scatter(
                x=edge_x,
                y=edge_y,
                line=dict(width=4, color=color),
                mode="lines",
            ))

            edge_text = []
            hover_x = []
            hover_y = []
            for pair in other_network:
                ref = datetime.strftime(pair.ref_time, "%Y-%m-%d")
                sec = datetime.strftime(pair.sec_time, "%Y-%m-%d")
                if ref in node_positions[0] and sec in node_positions[0]:
                    edge_text.append(
                        f"{ref} - {sec}, perp baseline: {pair.perpendicular_baseline}, temp baseline: {pair.temporal_baseline.days}"
                    )
                    hover_x.append((node_positions[0][ref] + node_positions[0][sec]) / 2)
                    hover_y.append((node_positions[1][ref] + node_positions[1][sec]) / 2)

            edge_traces.append(go.Scatter(
                x=hover_x,
                y=hover_y,
                mode="markers",
                marker=dict(size=20, color="rgba(255, 255, 255, 0)"),
                hoverinfo="text",
                text=edge_text,
            ))
        return edge_traces

    def add_digraph_node_traces(self, digraph, plot_geo_ref, pair_lists, node_positions):
        """
        Used for SBASNetwork plotting. Adds networkx traces to nodes of used and unused SLCs

        digraph: networkx.DiGraph
        plot_geo_ref: The ASFProduct being used as the geographic reference scene for the plot
        pair_lists: list of all pair lists being plotted
        node_positions: Tuple[Dict[networkx.DiGraph.node, datetime.timestamp], Dict[networkx.DiGraph.node, float]]
                        representing the x and y positions for each node

        returns: Tuple of plotly.graph_objects.Scatter objects for used and unused SLCs
        """
        # Add used scene node locations
        node_x = [node_positions[0][node] for node in digraph.nodes()]
        node_y = [node_positions[1][node] for node in digraph.nodes()]
        node_text = [digraph.nodes[node]["date"] for node in digraph.nodes()]

        node_traces = go.Scatter(
            x=node_x,
            y=node_y,
            mode="markers+text",
            textposition="top center",
            marker=dict(size=15, color="rgba(32, 33, 32, 0.7)", line_width=0),
            hoverinfo="text",
            hovertext=node_text,
        )

        # Add unused scene node locations
        all_slcs = set(scene for pair in self.full_stack for scene in (pair.ref, pair.sec))
        used_slcs = set()
        for sd in pair_lists:
            used_slcs.update(scene for pair in sd for scene in (pair.ref, pair.sec))
        unused_slcs = list(all_slcs - used_slcs)

        unused_slc_dates_str = [i.properties["stopTime"].split("T")[0] for i in unused_slcs]
        unused_slc_dates_x = [datetime.strptime(i, "%Y-%m-%d").timestamp() for i in unused_slc_dates_str]
        unused_slc_perp_y = [Pair(plot_geo_ref, i).perpendicular_baseline for i in unused_slcs]

        unused_slcs_traces = go.Scatter(
            x=unused_slc_dates_x, y=unused_slc_perp_y,
            mode='markers',
            hoverinfo='text',
            text=unused_slc_dates_str,
            marker=dict(
                color='rgba(255,0,0,0.5)',
                size=10,
                line_width=2
            )
        )
        return node_traces, unused_slcs_traces
    
    def build_plot_header_text(self, plot_geo_ref: ASFProduct, largest_network: List[Pair], pair_list: Union[List[Pair], None]) -> str:
        """
        Used for SBASNetwork plotting. Creates the header text for a SBASNetwork plot

        plot_geo_ref: The ASFProduct being used as the geographic reference scene for the plot
        largest_network: The largest (perhaps only) connected SBASNetwork pair list being plotted
        pair_list: The value of the optional pair_list arg passed to plot (could be None)

        Returns: Header text string
        """
        largest_network_slc_count = len(set(scene for pair in largest_network for scene in (pair.ref, pair.sec)))

        if pair_list is self.full_stack:
            plot_header_text = (
                "<b>SBAS Stack</b><br>"
                f"Geographic Reference: {plot_geo_ref.properties['sceneName']}<br>"
                f"Temporal Bounds: {self.start_date.split('T')[0]} - {self.end_date.split('T')[0]}, "
                f"Seasonal Bounds: {julian_to_month_day(plot_geo_ref, self.season)}<br>"
                f"Full Stack Size: {len(largest_network)} pairs from {largest_network_slc_count} scenes<br>"
            )
        else:
            plot_header_text = (
                "<b>SBAS Stack</b><br>"
                f"Geographic Reference: {plot_geo_ref.properties['sceneName']}<br>"
                f"Temporal Bounds: {self.start_date.split('T')[0]} - {self.end_date.split('T')[0]}, "
                f"Seasonal Bounds: {julian_to_month_day(plot_geo_ref, self.season)}<br>"
                f"Temporal Baseline: {self.inseason_temporal_baseline} days, "
                f"Perpendicular Baseline: {self.perpendicular_baseline}m<br>"
                f"Bridge Target Date: {self.bridge_target_date}, "
                f"Bridge Year Threshold: {self.bridge_year_threshold}, Largest Stack Size: "
                f"{len(largest_network)} pairs from {largest_network_slc_count} scenes<br>"
            )
        return plot_header_text

    def plot(self, pair_list: Optional[List[Pair]] = None):
        """
        Plot the SBAS network(s). Accepts a pair_list or a list of pair_lists.
        The largest network is plotted in blue; others are plotted in distinct colors.
        Possible member networks to pass as pair_list are: 
            - `self.full_stack`: an SBAS network filtered by baselines, and including every possible bridge pair within 
               self.bridge_year_threshold
            - `self.subset_stack`: a possibly disconnected SBAS network filtered by baselines, with additional 
               bridge pair filtering applied
            - `self.connected_substacks`: self.subset_stack, broken up into a list of connected sub-networks

        pair_list (optional): One of the SBASNetwork's pair lists or lists of pair list (options listed above).
                               Default: `self.connected_substacks`.
        """

        if go is None or nx is None or sample_colorscale is None or pd is None:
            raise ImportError(
                'The `plot()` method requires the optional asf-search '
                f'dependencies {_SBASNETWORK_PLOT_OPT_DEPS}, '
                'but they could not be found in the current python environment. '
                'Enable this method by including the appropriate pip or conda install. '
                'Ex: `python -m pip install asf-search[sbasnetwork_plot]`'
            )

        pair_lists = self.connected_substacks if not pair_list else [pair_list]
        largest_network = max(pair_lists, key=lambda s: len(s))
        node_products = build_node_products(pair_lists)
        G = nx.DiGraph()
        plot_geo_ref = self.geo_reference if self.geo_reference is not None else self.subset_stack[0].ref
        add_digraph_nodes(G, plot_geo_ref, node_products)
        node_x_positions, node_y_positions = get_node_positions(G, plot_geo_ref, node_products)
        edge_traces = self.add_digraph_edge_traces(G, largest_network, pair_lists, (node_x_positions, node_y_positions))
        node_traces, unused_slcs_traces = self.add_digraph_node_traces(G, plot_geo_ref, pair_lists, (node_x_positions, node_y_positions))

        start_date, end_date = get_pair_lists_date_range([self.full_stack])
        date_range = (
            pd.date_range(
                start=start_date,
                end=end_date,
                freq="MS"
            )
            .strftime("%Y-%m")
            .tolist()
        )
        date_range_ts = [datetime.strptime(date, "%Y-%m").timestamp() for date in date_range]

        fig = go.Figure(
            data=edge_traces + [node_traces, unused_slcs_traces],
            layout=go.Layout(
                showlegend=False,
                hovermode="closest",
                height=800,
                margin=dict(t=180),
                xaxis=dict(
                    title="Acquisition Date",
                    tickvals=date_range_ts,
                    ticktext=date_range,
                    gridcolor="gray",
                    zerolinecolor="gray",
                ),
                yaxis=dict(
                    title="Perpendicular Baseline (m)",
                    gridcolor="gray",
                    zerolinecolor="gray",
                ),
                title=dict(
                    text=self.build_plot_header_text(plot_geo_ref, largest_network, pair_list),
                    y=0.95,
                    x=0.5,
                    xanchor="center",
                    yanchor="top",
                    font=dict(family="Helvetica, monospace", size=22),
                ),
                plot_bgcolor="white",
                paper_bgcolor="lightgrey",
            ),
        )
        fig.show()

def get_n_colors(n: int, colorscale: str="Rainbow", alpha: float=0.4) -> List[str]:
    """
    Used for SBASNetwork plotting. Creates an n-length list of rgba colors from a plotly colorscale

    n: The length of the list of colors to return
    colorscale: the plotly sample_colorscale to from which to extract rgba colors
    alpha: The opacity to apply to the returned rgba colors

    returns: An n-length list of rgba colors from the sample_coloscale
    """
    base_colors = sample_colorscale(colorscale, [i / max(n - 1, 1) for i in range(n)])
    rgba_colors = []
    for color_str in base_colors:
        if color_str.startswith("rgb"):
            nums = color_str.strip("rgb()").split(",")
            r, g, b = [int(x) for x in nums]
            rgba_colors.append(f"rgba({r}, {g}, {b}, {alpha})")
        else:
            raise ValueError(f"Unexpected color format: {color_str}")
    return rgba_colors

def build_node_products(pair_lists: List[List[Pair]]) -> Dict[str, ASFProduct]:
    """
    Used for SBASNetwrok plotting. Builds a dictionary of all ASFProducts in
    a list of lists of Pairs

    pair_lists: A list of lists of Pairs

    returns: A dictionary with date strings as keys and ASFProducts as values for
             every product in the passed Pair lists
    """
    node_products = {}
    for network in pair_lists:
        for pair in network:
            for date_obj, product in [(pair.ref_time, pair.ref), (pair.sec_time, pair.sec)]:
                date_str = datetime.strftime(date_obj, "%Y-%m-%d")
                if date_str not in node_products:
                    node_products[date_str] = product
    return node_products

def add_digraph_nodes(digraph, plot_geo_ref, node_products):
    """
    Used for SBASNetwork plotting. Adds nodes to digraph.

    digraph: networkx.DiGraph
    plot_geo_ref: ASFProduct being used as a geographic reference for the network plot
    node_products: Dict[str, ASFProduct] containing date strings as keys and ASFProducts as values

    """
    for date_str, product in node_products.items():
        digraph.add_node(date_str)
        digraph.nodes[date_str]["date"] = date_str
        digraph.nodes[date_str]["perp_bs"] = Pair(plot_geo_ref, product).perpendicular_baseline

def get_node_positions(digraph, plot_geo_ref, node_products):
    """
    Used for SBASNetwork plotting. Determines x/y node coordinates for digraph.

    digraph: networkx.DiGraph
    plot_geo_ref: ASFProduct being used as a geographic reference for the network plot
    node_products: Dict[str, ASFProduct] containing date strings as keys and ASFProducts as values

    returns: Tuple[Dict[networkx.DiGraph.node, datetime.timestamp], Dict[networkx.DiGraph.node, float]].
             node_x_positions is a dictionary with nodes as values and and ASFProducts' datetime.timestamps as values.
             node_y_positions is a dictionary with nodes as values and and ASFProducts's perpendicular baselines 
             relative to plot_geo_ref as values.
    """
    for date_str, product in node_products.items():
        digraph.add_node(date_str)
        digraph.nodes[date_str]["date"] = date_str
        digraph.nodes[date_str]["perp_bs"] = Pair(plot_geo_ref, product).perpendicular_baseline
    node_x_positions = {
        node: datetime.strptime(data["date"], "%Y-%m-%d").timestamp()
        for node, data in digraph.nodes(data=True)
    }
    node_y_positions = {
        node: (data["perp_bs"] if data.get("perp_bs") else 0)
        for node, data in digraph.nodes(data=True)
    }
    return node_x_positions, node_y_positions

def get_pair_lists_date_range(pair_lists: List[List[Pair]]) -> Tuple[str, str]:
    """
    Used for SBASNetwork plotting. Finds the acquisition date range from a list
    of lists of Pairs

    pair_lists: A list of lists of Pairs

    returns: The acquisition date range covered by the ASFProducts in pair_lists' Pairs
    """
    start_dates, end_dates = [], []
    for pair_list in pair_lists:
        start_dates.append(min([pair.ref_time for pair in pair_list]))
        end_dates.append(max([pair.sec_time for pair in pair_list]))

    start_date = min(start_dates)
    start_date_str = f"{start_date.year}-{start_date.month}"
    end_date = max(end_dates)
    end_date_str = f"{end_date.year}-{end_date.month}"

    return start_date_str, end_date_str

def julian_to_month_day(ref_product: ASFProduct, julian_tuple: Tuple[int, int]) -> Tuple[str, str]:
    """
    Used for SBASNetwork plotting. Converts a tuple of julian days to month-day strings

    ref_product: an ASFProduct from which to extract a year when converting julian days
    julian_tuple: a tuple of two julian days

    returns: A tuple of month-day strings cooresponding to the passed julian days in the 
             year of ref_product's acquisition
    """
    year = int(ref_product.properties['stopTime'].split('-')[0])
    month_day = []
    for day in julian_tuple:
        date = datetime(year, 1, 1) + timedelta(days=day - 1)
        month_day.append(date.strftime("%m-%d"))
    return tuple(month_day)        
