import pandas as pd
from graphviz import Digraph
import ipycytoscape
import ipywidgets as widgets

VALID_TIME_UNITS = {"seconds", "minutes", "hours", "days", "weeks"}

VALID_FORMATS = {
    "ascii",
    "bmp",
    "dot",
    "gif",
    "jpeg",
    "json",
    "pdf",
    "png",
    "svg",
    "tiff",
}


def add_sim_timestamp(
    log: pd.DataFrame,
    time_col: str = "time",
    timestamp_col: str = "timestamp",
    sim_start: pd.Timestamp | str | None = None,
    time_unit: str = "minutes",
) -> pd.DataFrame:
    """
    Add a pseudo-timestamp column to a simulation event log.
    This is a helper function for process-mining style outputs.

    If the simulation does not have a 'true' start time

    Parameters
    ----------
    log : pd.DataFrame
        Event log with a column containing the simulation-relative time per event.
    time_col : str
        Column containing simulation time since model start.
        Default: "time". This will be the name of the column if you have
        made use of Vidigi's EventLogger defaults.
    timestamp_col : str
        Desired name of output timestamp column.
        Default: "timestamp"
    sim_start : pd.Timestamp, str, or None
        Start datetime of the simulation.
        If None, a fixed pseudo-start of '2000-01-01 00:00:00' is used.
    time_unit : str
        Unit of the simulation time.
        Accepted values are 'seconds', 'minutes', 'hours', 'days' or 'weeks'.
        Default: "minutes".

    Returns
    -------
    pd.DataFrame
        Copy of the provided event log (parameter `log`) with an added timestamp column.
    """
    if time_unit not in VALID_TIME_UNITS:
        raise ValueError(
            f"Invalid time_unit '{time_unit}'. "
            f"Supported values are: {', '.join(sorted(VALID_TIME_UNITS))}."
        )

    if time_unit == "weeks":
        time_unit = "W"

    if time_col not in log.columns:
        raise KeyError(f"Column '{time_col}' not found in event log")

    df = log.copy()

    if sim_start is None:
        sim_start = pd.Timestamp("2000-01-01 00:00:00")
    else:
        sim_start = pd.to_datetime(sim_start)

    df[timestamp_col] = sim_start + pd.to_timedelta(
        df[time_col], unit=time_unit
    )

    return df


def discover_dfg(
    log: pd.DataFrame,
    case_col: str = "entity_id",
    activity_col: str = "event",
    timestamp_col: str = "timestamp",
    time_unit: str = "minutes",
):
    """
    Discover a Directly-Follows Graph (DFG) from an event log.

    The event log must represent a *single simulation run or process
    execution*. Logs containing multiple independent runs should be
    filtered prior to calling this function.

    This function constructs a Directly-Follows Graph (DFG) from a
    case-based event log by identifying pairs of consecutive activities
    within each case. It returns two tables:

    1. A node table containing activity occurrence counts.
    2. An edge table containing directly-follows relations with
       frequency, transition time statistics, and transition probabilities.

    Parameters
    ----------
    log : pandas.DataFrame
        Event log in tabular form. Each row represents an event and must
        include a case identifier, an activity name, and a timestamp.
        The timestamp column must be of a datetime-like dtype.
    case_col : str, default="entity_id"
        Name of the column identifying cases (process instances).
        Events are ordered and analysed independently within each case.
        The default reflects the default column names generated by vidigi's EventLogger.
    activity_col : str, default="event"
        Name of the column containing activity or event labels.
        The default reflects the default column names generated by vidigi's EventLogger.
    timestamp_col : str, default="timestamp"
        Name of the column containing event timestamps. Values must be
        timezone-consistent and convertible to ``datetime64``.
        The helper function :func:`vidigi.process_mapping.add_sim_timestamp()` can be used
        to add this column to a dataframe if provided with a sim-start-relative time column.
        The default reflects the default name of the column added by that helper function.
    time_unit : {"seconds", "minutes", "hours", "days", "weeks"}, default="minutes"
        Time unit used when computing the duration between consecutive
        events. Determines the scale of all time-based edge statistics.
        This should reflect the time unit used in your simulation.

    Returns
    -------
    nodes : pandas.DataFrame
        Node table with one row per activity and the following columns:

        - ``activity`` : str
          Activity label.
        - ``count`` : int
          Total number of times the activity appears in the log.

    edges : pandas.DataFrame
        Edge table describing directly-follows relations between activities.
        Each row corresponds to a directed edge ``source -> target`` with
        the following columns:

        - ``source`` : str
          Preceding activity.
        - ``target`` : str
          Succeeding activity.
        - ``frequency`` : int
          Number of times ``target`` directly follows ``source``.
        - ``mean_time`` : float
          Mean transition time between ``source`` and ``target``.
        - ``median_time`` : float
          Median transition time between ``source`` and ``target``.
        - ``max_time`` : float
          Maximum observed transition time.
        - ``min_time`` : float
          Minimum observed transition time.
        - ``standard_deviation_time`` : float
          Standard deviation of transition times.
        - ``probability`` : float
          Conditional probability of transitioning to ``target`` given
          ``source``. Computed as the edge frequency divided by the total
          outgoing frequency from ``source``.

    Raises
    ------
    ValueError
        If ``time_unit`` is not one of the supported values.

    Notes
    -----
    - Case endings (i.e., events without a successor) are excluded from
      the edge table.
    - The input log is internally sorted by ``case_col`` and
      ``timestamp_col`` before analysis.
    - Transition probabilities are computed independently for each
      source activity and therefore sum to 1 per source (up to
      floating-point precision).

    Examples
    --------
    >>> nodes, edges = discover_dfg(
    ...     log=event_log,
    ...     case_col="case_id",
    ...     activity_col="activity",
    ...     timestamp_col="time",
    ...     time_unit="minutes",
    ... )
    >>> nodes.head()
    >>> edges.head()
    """
    if time_unit not in VALID_TIME_UNITS:
        raise ValueError(
            f"Invalid time_unit '{time_unit}'. "
            f"Supported values are: {', '.join(sorted(VALID_TIME_UNITS))}."
        )

    df = log.sort_values([case_col, timestamp_col]).copy()

    # Shift to get "next activity" per case
    df["next_activity"] = df.groupby(case_col)[activity_col].shift(-1)
    df["next_time"] = df.groupby(case_col)[timestamp_col].shift(-1)

    # Drop case endings
    dfg = df.dropna(subset=["next_activity"]).copy()

    # Transition duration
    if time_unit == "seconds":
        dfg["delta_time"] = (
            dfg["next_time"] - dfg[timestamp_col]
        ).dt.total_seconds()
    elif time_unit == "minutes":
        dfg["delta_time"] = (
            (dfg["next_time"] - dfg[timestamp_col]).dt.total_seconds()
        ) / 60
    elif time_unit == "hours":
        dfg["delta_time"] = (
            ((dfg["next_time"] - dfg[timestamp_col]).dt.total_seconds())
            / 60
            / 60
        )
    elif time_unit == "days":
        dfg["delta_time"] = (
            ((dfg["next_time"] - dfg[timestamp_col]).dt.total_seconds())
            / 60
            / 60
            / 24
        )
    elif time_unit == "weeks":
        dfg["delta_time"] = (
            ((dfg["next_time"] - dfg[timestamp_col]).dt.total_seconds())
            / 60
            / 60
            / 24
            / 7
        )

    # Aggregate edges
    edges = (
        dfg.groupby([activity_col, "next_activity"])
        .agg(
            frequency=("delta_time", "count"),
            mean_time=("delta_time", "mean"),
            median_time=("delta_time", "median"),
            max_time=("delta_time", "max"),
            min_time=("delta_time", "min"),
            standard_deviation_time=("delta_time", "std"),
        )
        .reset_index()
        .rename(columns={activity_col: "source", "next_activity": "target"})
    )

    # Transition probabilities
    edges["probability"] = edges["frequency"] / edges.groupby("source")[
        "frequency"
    ].transform("sum")

    # Node counts
    nodes = (
        df.groupby(activity_col)
        .agg(count=(case_col, "count"))
        .reset_index()
        .rename(columns={activity_col: "activity"})
    )

    return nodes, edges


def _scale_penwidth(values, min_width=0.8, max_width=5.0):
    vmin = min(values)
    vmax = max(values)

    if vmax == vmin:
        return {v: (min_width + max_width) / 2 for v in values}

    return {
        v: min_width + (v - vmin) / (vmax - vmin) * (max_width - min_width)
        for v in values
    }


def dfg_to_graphviz(
    nodes: pd.DataFrame,
    edges: pd.DataFrame,
    min_frequency: int | None = None,
    min_probability: float | None = None,
    time_unit: str = "minutes",
    direction: str = "LR",
    time_metric: str = "mean",
    title: str | None = None,
    title_font_size: int = 20,
    title_loc: str = "t",
    dashed_infrequent_paths: bool = True,
    infrequent_path_dash_threshold: float = 0.1,
    format: str = "png",
    return_image=False,
    show_transition_probabilities: bool = True,
    show_edge_counts: bool = True,
    show_metric: bool = True,
    show_node_counts: bool = True,
    size: tuple[float, float] | None = None,
    dpi: int | None = None,
    ratio: str | None = None,
    wrap_node_labels: bool = True,
    wrap_node_labels_at: int = 15,
):
    """
    Render a Directly-Follows Graph (DFG) as a Graphviz diagram.

    This function converts node and edge tables (as produced by
    :func:`vidigi.process_mapping.discover_dfg`) into a Graphviz directed graph. Nodes represent
    activities and edges represent directly-follows relations, annotated
    with frequency, transition probability, and a selected time statistic.

    The resulting graph can be returned either as a Graphviz ``Digraph``
    object for further manipulation, or rendered directly to an image.

    Parameters
    ----------
    nodes : pandas.DataFrame
        Node table describing activities. Must contain the following
        columns:
        - ``activity`` : str
          Activity label.
        - ``count`` : int
          Number of occurrences of the activity.
        This format of table is output from the :func:`vidigi.process_mapping.discover_dfg` function.
    edges : pandas.DataFrame
        Edge table describing directly-follows relations. Must contain
        at least the following columns:
        - ``source`` : str
          Source activity.
        - ``target`` : str
          Target activity.
        - ``frequency`` : int
          Transition frequency.
        - ``probability`` : float
          Conditional transition probability.
        - ``<time_metric>_time`` : float
          Time statistic used for edge annotation (e.g. ``mean_time``).
        This format of table is output from the :func:`vidigi.process_mapping.discover_dfg` function.
    min_frequency : int or None, default=None
        Minimum transition frequency required for an edge to be included.
        Edges with lower frequency are omitted.
    min_probability : float or None, default=None
        Minimum transition probability required for an edge to be included.
        Edges with lower probability are omitted.
    time_unit : {"seconds", "minutes", "hours", "days", "weeks"}, \
default="minutes"
        Time unit label displayed on edges for transition time statistics.
        This does not rescale values, but should match the unit used when
        computing the DFG.
    direction : {"LR", "RL", "TB", "BT"}, default="LR"
        Graph layout direction passed to Graphviz via ``rankdir``.
        For example, ``"LR"`` renders the graph left-to-right.
    time_metric : {"mean", "median", "min", "max", "standard_deviation"}, \
default="mean"
        Time statistic to display on edges. This value is used to select
        the corresponding ``<time_metric>_time`` column from ``edges``.
    title : str or None, default=None
        Optional title displayed above the graph.
    title_font_size : int, default=20
        Font size used for the graph title.
    title_loc : {"t", "b"}, default="t"
        Location of the title. ``"t"`` places the title at the top of the
        graph; ``"b"`` places it at the bottom.
    dashed_infrequent_paths : bool, default=True
        If True, edges with low transition probability are rendered using
        a dashed line style.
    infrequent_path_dash_threshold : float, default=0.1
        Probability threshold below which edges are considered infrequent
        and rendered as dashed (if enabled).
    format : str, default="png"
        Output image format used when ``return_image=True``. Must be a
        Graphviz-supported format (e.g. ``"png"``, ``"svg"``, ``"pdf"``).
    return_image : bool, default=False
        If False, return a Graphviz ``Digraph`` object. If True, render
        the graph and return the resulting image as raw bytes.
    show_transition_probabilities : bool, default=True
        If True, include transition probabilities in edge labels.
    show_edge_counts : bool, default=True
        If True, include transition frequencies in edge labels.
    show_metric : bool, default=True
        If True, include the selected time statistic in edge labels.
    show_node_counts : bool, default=True
        If True, include activity occurrence counts in node labels.
    size : tuple of float, optional
        Maximum size of the rendered graph in inches, given as ``(width, height)``.
        This value is passed to the Graphviz ``size`` graph attribute and acts as
        a *constraint* rather than a strict size. By default, Graphviz may produce
        a smaller graph to preserve aspect ratio.
        If either dimension is set to ``0``, that dimension is left unconstrained.
        For example, ``size=(15, 0)`` constrains the width only.
    ratio : {"auto", "fill", "compress"}, optional
        Controls how the graph is scaled to fit within the specified ``size``.
        - ``"auto"``: Use Graphviz default behavior (preserve aspect ratio).
        - ``"fill"``: Stretch the layout to fill the specified size exactly.
        - ``"compress"``: Tightly pack the layout to minimize unused space.
        This parameter is ignored if ``size`` is not specified.
    dpi : int, optional
        Resolution (dots per inch) used when rendering raster image formats
        such as PNG. Higher values produce sharper images at the cost of
        larger file sizes.
        This parameter has no effect when returning the ``Digraph`` object
        directly or when rendering vector formats such as SVG or PDF.
    wrap_node_labels: bool, optional
        Default true
    wrap_node_labels_at: int, optional
        Default 20 characters

    Returns
    -------
    graph : graphviz.Digraph or bytes
        If ``return_image=False``, returns a Graphviz ``Digraph`` object.
        If ``return_image=True``, returns the rendered image as a byte
        string in the specified format.

    Raises
    ------
    ValueError
        If ``time_unit`` is not a supported value.
    ValueError
        If ``format`` is not a supported output format.

    Notes
    -----
    - Edge widths are scaled proportionally to transition frequency.
    - Edge labels include frequency (``n``), probability (``p``), and the
      selected time statistic.
    - Filtering by ``min_frequency`` and ``min_probability`` is applied
      before rendering.
    - This function does not validate the internal consistency of the
      node and edge tables; it assumes they originate from a compatible
      DFG discovery process.

    Examples
    --------
    >>> dot = dfg_to_graphviz(nodes, edges, min_probability=0.05)
    >>> dot

    >>> img = dfg_to_graphviz(
    ...     nodes,
    ...     edges,
    ...     min_frequency=10,
    ...     format="svg",
    ...     return_image=True,
    ... )

    Constrain width only while allowing height to scale automatically::

    >>> generate_dfg(size=(15, 0))

    Limit the graph to a fixed size for inclusion in a report::

    >>> generate_dfg(size=(12, 8), ratio="fill", dpi=200)
    """
    if time_unit not in VALID_TIME_UNITS:
        raise ValueError(
            f"Invalid time_unit '{time_unit}'. "
            f"Supported values are: {', '.join(sorted(VALID_TIME_UNITS))}."
        )

    if format not in VALID_FORMATS:
        raise ValueError(
            f"Invalid format '{format}'. "
            f"Supported values are: {', '.join(sorted(VALID_FORMATS))}."
        )

    # Initialise the digraph object
    dot = Digraph(engine="dot")
    graph_attrs = {"rankdir": direction}

    if size is not None:
        graph_attrs["size"] = f"{size[0]},{size[1]}"

    if ratio is not None:
        graph_attrs["ratio"] = ratio

    if dpi is not None:
        graph_attrs["dpi"] = str(dpi)

    dot.attr(**graph_attrs)

    if wrap_node_labels:
        nodes["activity"] = nodes["activity"].str.wrap(wrap_node_labels_at)
        edges["source"] = edges["source"].str.wrap(wrap_node_labels_at)
        edges["target"] = edges["target"].str.wrap(wrap_node_labels_at)

    # Add nodes
    for _, row in nodes.iterrows():
        dot.node(
            row["activity"],
            label=str(row["activity"])
            + (f"\nn={row['count']}" if show_node_counts else ""),
            shape="box",
            style="rounded",
        )

    # Add edges
    freqs = edges["frequency"].tolist()
    penwidth_map = _scale_penwidth(freqs)

    for _, row in edges.iterrows():
        if dashed_infrequent_paths:
            style = (
                "dashed"
                if row["probability"] < infrequent_path_dash_threshold
                else "solid"
            )
        else:
            None

        pw = penwidth_map[row["frequency"]]

        # Filter out any edges where minimum frequency or probability not achieved
        if min_frequency is not None:
            if row["frequency"] < min_frequency:
                continue

        if min_probability is not None:
            if row["probability"] < min_probability:
                continue

        # Label the edges
        label = (
            (f"n={row.frequency}\n" if show_edge_counts else "")
            + (
                f"p={row.probability:.2f}\n"
                if show_transition_probabilities
                else ""
            )
            + (
                f"{time_metric}={row[f'{time_metric}_time']:.1f} {time_unit}"
                if show_metric
                else ""
            )
        )

        dot.edge(
            row["source"],
            row["target"],
            label=label,
            penwidth=f"{pw:.2f}",
            style=style,
        )

    if title:
        dot.attr(
            label=title, labelloc=title_loc, fontsize=f"{title_font_size}"
        )

    if not return_image:
        return dot
    else:
        return dot.pipe(format=format)


def process_nodes_and_edges_for_cytoscape(
    nodes,
    edges,
    node_label: str = "activity",
    time_unit: str = "minutes",
    time_metric: str = "mean",
    show_transition_probabilities: bool = True,
    show_edge_counts: bool = True,
    show_metric: bool = True,
    show_node_counts: bool = True,
):
    """
    Convert DFG node and edge tables into Cytoscape-compatible elements.

    Note that this is called automatically by all cytoscape functions in vidigi.process_mapping.
    However, it is made available for use in case you wish to develop your own custom
    cytoscape-based outputs.

    This function transforms node and edge tables (e.g. as produced by
    :func:`vidigi.process_mapping.discover_dfg`) into lists of dictionaries formatted for use with
    Cytoscape.js or Cytoscape-based Python wrappers. The output can be passed
    directly to visualization components such as Dash Cytoscape or similar
    graph rendering frameworks.

    Node and edge labels are constructed dynamically based on the provided
    display flags, allowing flexible control over which statistics are shown
    in the visualization.

    Parameters
    ----------
    nodes : pandas.DataFrame
        Node table describing activities. Must contain at least the following
        columns:

        - ``node_label`` (default ``"activity"``) : str
          Activity label used as the node identifier.
        - ``count`` : int
          Number of occurrences of the activity.
    edges : pandas.DataFrame
        Edge table describing directly-follows relations. Must contain at
        least the following columns:

        - ``source`` : str
          Source activity.
        - ``target`` : str
          Target activity.
        - ``frequency`` : int
          Transition frequency.
        - ``probability`` : float
          Conditional transition probability.
        - ``<time_metric>_time`` : float
          Time statistic used for edge annotation (e.g. ``mean_time``).
    node_label : str, default="activity"
        Name of the column in ``nodes`` used as the node identifier and
        display label.
    time_unit : str, default="minutes"
        Time unit label displayed alongside transition time statistics.
        This does not rescale values and should match the unit used during
        DFG discovery.
    time_metric : {"mean", "median", "min", "max", "standard_deviation"}, default="mean"
        Time statistic to display on edges. This value determines which
        ``<time_metric>_time`` column is used from ``edges``.
    show_transition_probabilities : bool, default=True
        If True, include transition probabilities in edge labels.
    show_edge_counts : bool, default=True
        If True, include transition frequencies in edge labels.
    show_metric : bool, default=True
        If True, include the selected time statistic in edge labels.
    show_node_counts : bool, default=True
        If True, include activity occurrence counts in node labels.

    Returns
    -------
    cy_nodes : list of dict
        List of node elements formatted for Cytoscape. Each element contains
        a ``data`` dictionary with ``id`` and ``label`` fields, and a
        ``classes`` entry enabling multiline labels.
    cy_edges : list of dict
        List of edge elements formatted for Cytoscape. Each element contains
        a ``data`` dictionary with ``source``, ``target``, ``label``, and
        ``weight`` fields.

    Notes
    -----
    - All node and edge identifiers are cast to strings to ensure consistent
      matching within Cytoscape.
    - Edge weights are set to transition probabilities, which can be used
      for styling or layout algorithms.
    - This function performs no validation of column presence or value
      ranges and assumes the input tables are consistent and complete.

    Examples
    --------
    >>> cy_nodes, cy_edges = process_nodes_and_edges_for_cytoscape(
    ...     nodes,
    ...     edges,
    ...     show_metric=False,
    ... )
    >>> cy_nodes[0]
    >>> cy_edges[0]
    """
    # Cast all IDs to strings to ensure matching
    cy_nodes = [
        {
            "data": {
                "id": str(row[node_label]),
                "label": str(row[node_label])
                + (f"\nn={row['count']}" if show_node_counts else ""),
            },
            "classes": "multiline-manual",
        }
        for _, row in nodes.iterrows()
    ]

    cy_edges = [
        {
            "data": {
                "source": str(row["source"]),
                "target": str(row["target"]),
                "label": (f"n={row.frequency}\n" if show_edge_counts else "")
                + (
                    f"p={row.probability:.2f}\n"
                    if show_transition_probabilities
                    else ""
                )
                + (
                    f"{time_metric}={row[f'{time_metric}_time']:.1f} {time_unit}"
                    if show_metric
                    else ""
                ),
                "weight": row.probability,
            }
        }
        for _, row in edges.iterrows()
    ]

    return cy_nodes, cy_edges


def dfg_to_cytoscape(
    nodes: pd.DataFrame,
    edges: pd.DataFrame,
    edge_label: str = "frequency",
    node_label: str = "activity",
    min_frequency: int = 1,
    layout_name: str = "dagre",
    layout_orientation: str = "LR",
    spacing_factor: float = 1.0,
    time_unit: str = "minutes",
    time_metric: str = "mean",
    width: int = 1200,
    height: int = 600,
    show_transition_probabilities: bool = True,
    show_edge_counts: bool = True,
    show_metric: bool = True,
    show_node_counts: bool = True,
    line_color: str = "#9dbaea",
    edge_font_size: int = 8,
    node_font_size: int = 10,
):
    """
    Convert DFG node/edge tables to interactive Cytoscape widget.

    Parameters
    ----------
    nodes : pd.DataFrame
        Must contain 'activity' (node ID) and optional 'count'
    edges : pd.DataFrame
        Must contain 'source', 'target', and edge_label column
    edge_label : str
        Column to use for edge labels (e.g., frequency, avg_time)
    node_label : str
        Column to use for node labels
    min_frequency : int or None
        Filter edges below this frequency
    layout_name: str
        Algorithm for layout to use. Supported variants include 'cose', 'cose-bilkent',
        'breadthfirst', 'circle', 'grid', 'concentric', and 'dagre'
        Default: 'dagre'
    layout_orientation: str
        Ignored if layout_name is not 'dagre' or 'breadthfirst'.
        For dagre, valid inputs are 'TB', 'LR', 'RL', 'BT'
        For breadthfirst, valid inputs are 'downward', 'upward', 'rightward', 'leftward'
    spacing_factor : float, default=1.0
        Scaling factor controlling the spacing between nodes in the layout.
        time_unit : str, default="minutes"
        Time unit label displayed alongside transition time statistics.
        This should match the unit used during DFG discovery.
    time_metric : {"mean", "median", "min", "max", "standard_deviation"}, \
default="mean"
        Time statistic to display on edges. Determines which
        ``<time_metric>_time`` column is used from ``edges``.
    width: int, default=1200
        Width, in pixels, of output box
    height: int, default=1200
        Height, in pixels, of output box
    show_transition_probabilities : bool, default=True
        If True, include transition probabilities in edge labels.
    show_edge_counts : bool, default=True
        If True, include transition frequencies in edge labels.
    show_metric : bool, default=True
        If True, include the selected time statistic in edge labels.
    show_node_counts : bool, default=True
        If True, include activity occurrence counts in node labels.
    line_color: str
        Line colour as a hex colour string. Will also define colour of arrowhead.
    edge_font_size: int, default=8
        Font size, in pixels, of edge labels
    node_font_size: int, default=10
        Font size, in pixels, of node labels

    Returns
    -------
    ipywidgets.Box
    """
    # Filter low-frequency edges
    edges_filtered = edges[edges[edge_label] >= min_frequency]

    cy_nodes, cy_edges = process_nodes_and_edges_for_cytoscape(
        nodes,
        edges_filtered,
        node_label=node_label,
        time_unit=time_unit,
        time_metric=time_metric,
        show_transition_probabilities=show_transition_probabilities,
        show_edge_counts=show_edge_counts,
        show_metric=show_metric,
        show_node_counts=show_node_counts,
    )

    # Build widget
    cytoscapeobj = ipycytoscape.CytoscapeWidget()

    cytoscapeobj.layout.width = f"{width - 20}px"
    cytoscapeobj.layout.height = f"{height - 20}px"

    cytoscapeobj.graph.add_graph_from_json(
        {"nodes": cy_nodes, "edges": cy_edges}
    )

    cytoscapeobj.set_tooltip_source("label")

    # Style: simple default style
    style_list = [
        {
            "selector": "node",
            "style": {
                "content": "data(label)",
                "background-color": "skyblue",
                "text-valign": "center",
                "text-halign": "center",
                "width": "40px",
                "height": "40px",
                "font-size": f"{node_font_size}px",
                "text-wrap": "wrap",
                "text-max-width": 40,
            },
        },
        {
            "selector": "edge",
            "style": {
                "content": "data(label)",
                "curve-style": "bezier",
                "target-arrow-shape": "triangle",
                "line-color": line_color,
                "target-arrow-color": line_color,
                "font-size": f"{edge_font_size}px",
                "text-wrap": "wrap",
                "text-max-width": 80,
                "width": "mapData(weight, 0, 1, 1, 6)",
            },
        },
    ]
    cytoscapeobj.set_style(style_list)

    layout_options = {
        "name": layout_name,
        "directed": True,
        "animate": True,
        "randomize": True,
        "numIter": 1,
        "fit": False,
        "padding": 30,
        "spacingFactor": spacing_factor,
    }

    if layout_name == "dagre":
        layout_options["rankDir"] = layout_orientation
    elif layout_name == "breadthfirst":
        layout_options["direction"] = layout_orientation

    cytoscapeobj.set_layout(**layout_options)

    container = widgets.Box(
        [cytoscapeobj],
        layout=widgets.Layout(
            width=f"{width}px",
            height=f"{height}px",
            border="1px solid lightgray",
        ),
    )

    return container


def dfg_to_cytoscape_streamlit(
    nodes: pd.DataFrame,
    edges: pd.DataFrame,
    edge_label: str = "frequency",
    node_label: str = "activity",
    min_frequency: int = 1,
    layout_name: str = "breadthfirst",
    layout_orientation: str = "rightward",
    spacing_factor: float = 1.0,
    time_unit: str = "minutes",
    time_metric: str = "mean",
    width: str = "1200px",
    height: str = "600px",
    show_transition_probabilities: bool = True,
    show_edge_counts: bool = True,
    show_metric: bool = True,
    show_node_counts: bool = True,
    additional_layout_options: dict | None = None,
    **kwargs,
):
    """
    Render a Directly-Follows Graph (DFG) interactively in Streamlit using Cytoscape.

    This function displays a Directly-Follows Graph derived from an event log
    inside a Streamlit application using the ``st-cytoscape`` component.
    Node and edge tables are converted into Cytoscape-compatible elements,
    styled, and rendered with a configurable layout.

    The visualization supports filtering low-frequency edges, displaying
    transition statistics in labels, and customising Cytoscape layout and
    rendering options.

    Parameters
    ----------
    nodes : pandas.DataFrame
        Node table describing activities. Must contain at least:

        - ``node_label`` (default ``"activity"``) : str
          Activity identifier.
        - ``count`` : int
          Number of occurrences of the activity.
    edges : pandas.DataFrame
        Edge table describing directly-follows relations. Must contain at
        least:

        - ``source`` : str
          Source activity.
        - ``target`` : str
          Target activity.
        - ``edge_label`` (default ``"frequency"``) : int
          Value used to filter edges by ``min_frequency``.
        - ``probability`` : float
          Conditional transition probability.
        - ``<time_metric>_time`` : float
          Time statistic used for edge annotation.
    edge_label : str, default="frequency"
        Name of the column in ``edges`` used for filtering by
        ``min_frequency``.
    node_label : str, default="activity"
        Name of the column in ``nodes`` used as the node identifier and label.
    min_frequency : int, default=1
        Minimum value of ``edge_label`` required for an edge to be included
        in the visualization.
    layout_name : str, default="breadthfirst"
        Name of the Cytoscape layout algorithm to use (e.g. ``"breadthfirst"``,
        ``"circle"``, ``"grid"``, ``"cose"``, ``"dagre"`` if available).
    layout_orientation : {"rightward", "leftward", "downward", "upward"}, \
default="rightward"
        Direction used by the ``breadthfirst`` layout. Ignored for other
        layouts.
    spacing_factor : float, default=1.0
        Scaling factor controlling the spacing between nodes in the layout.
    time_unit : str, default="minutes"
        Time unit label displayed alongside transition time statistics.
        This should match the unit used during DFG discovery.
    time_metric : {"mean", "median", "min", "max", "standard_deviation"}, \
default="mean"
        Time statistic to display on edges. Determines which
        ``<time_metric>_time`` column is used from ``edges``.
    width : str, default="1200px"
        Width of the Cytoscape component in the Streamlit layout.
    height : str, default="600px"
        Height of the Cytoscape component in the Streamlit layout.
    show_transition_probabilities : bool, default=True
        If True, include transition probabilities in edge labels.
    show_edge_counts : bool, default=True
        If True, include transition frequencies in edge labels.
    show_metric : bool, default=True
        If True, include the selected time statistic in edge labels.
    show_node_counts : bool, default=True
        If True, include activity occurrence counts in node labels.
    additional_layout_options : dict or None, default=None
        Additional Cytoscape layout options to merge into the base layout
        configuration. Values in this dictionary override defaults.
    **kwargs
        Additional keyword arguments passed directly to
        ``st_cytoscape.cytoscape``.

    Returns
    -------
    result
        Return value of ``st_cytoscape.cytoscape``. The exact type depends on
        the component configuration and may include information about user
        interactions (e.g. selected nodes or edges).

    Notes
    -----
    - Low-frequency edges are filtered *before* conversion to Cytoscape
      elements.
    - Edge widths are scaled using transition probabilities.
    - Node and edge labels support multiline text via Cytoscape classes.
    - This function assumes that the input node and edge tables are
      consistent and originate from a compatible DFG discovery process.

    See Also
    --------
    discover_dfg : Discover a directly-follows graph from an event log.
    process_nodes_and_edges_for_cytoscape :
        Convert DFG tables into Cytoscape-compatible elements.
    dfg_to_graphviz : Render a DFG as a static Graphviz diagram.

    Examples
    --------
    >>> dfg_to_cytoscape_streamlit(
    ...     nodes,
    ...     edges,
    ...     min_frequency=5,
    ...     layout_name="breadthfirst",
    ... )
    """
    from st_cytoscape import cytoscape as streamlit_cytoscape

    # Filter low-frequency edges
    edges_filtered = edges[edges[edge_label] >= min_frequency]

    cy_nodes, cy_edges = process_nodes_and_edges_for_cytoscape(
        nodes,
        edges_filtered,
        node_label=node_label,
        time_unit=time_unit,
        time_metric=time_metric,
        show_transition_probabilities=show_transition_probabilities,
        show_edge_counts=show_edge_counts,
        show_metric=show_metric,
        show_node_counts=show_node_counts,
    )

    elements = cy_nodes + cy_edges

    # print(elements)

    stylesheet = [
        {
            "selector": "node",
            "style": {
                "content": "data(label)",
                "background-color": "skyblue",
                "text-valign": "center",
                "text-halign": "center",
                "width": "40px",
                "height": "40px",
                "font-size": "10px",
                "text-wrap": "wrap",
                "text-max-width": 40,
            },
        },
        {
            "selector": "edge",
            "style": {
                "content": "data(label)",
                "curve-style": "bezier",
                "target-arrow-shape": "triangle",
                "line-color": "#9dbaea",
                "target-arrow-color": "#9dbaea",
                "font-size": "8px",
                "text-wrap": "wrap",
                "text-max-width": 80,
                "width": "mapData(weight, 0, 1, 1, 6)",
                # 'opacity': 'mapData(weight, 0, 1, 0.3, 1.0)',
            },
        },
    ]

    layout_options = {"name": layout_name, "spacingFactor": spacing_factor}

    if layout_name == "breadthfirst":
        layout_options["direction"] = layout_orientation

    if additional_layout_options is not None:
        layout_options = layout_options | additional_layout_options

    return streamlit_cytoscape(
        elements,
        stylesheet,
        width=width,
        height=height,
        layout=layout_options,
        **kwargs,
    )
