import re
import os
import sys
import time
import shutil
import subprocess
import pexpect.popen_spawn

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt

from parse import parse

from . import stream_residuals, stream_transcript  # Import as module to get its path
from ..utilities import is_float, wait_for_file

RESIDUAL_LABEL_MAPPING = {
    "continuity": r"$p$ - continuity",
    "x-velocity": r"$v_x$ - momentum",
    "y-velocity": r"$v_y$ - momentum",
    "z-velocity": r"$v_z$ - momentum",
    "k": r"$k$ - turbulence",
    "omega": r"$\omega$ - turbulence",
}

# Define mapping betweet export filenames and Fluent variable names
EXPORT_VARS = {
    "pressure": "pressure",
    "density": "density",
    "viscosity": "viscosity-lam",
    "speed_sound": "sound-speed",
    "velocity": "velocity-magnitude",
    "y_plus": "y-plus",
    "barotropic_density": "expr:barotropic_density",
    "barotropic_speed_sound": "expr:barotropic_speed_sound",
    "barotropic_viscosity": "expr:barotropic_viscosity",
    "barotropic_void_fraction": "expr:barotropic_void_fraction",
    "barotropic_vapor_quality": "expr:barotropic_vapor_quality",
}

EXPORT_VARS_2 = {
    "pressure": "pressure",
    "density": "density",
    "viscosity": "viscosity-lam",
    "speed_sound": "sound-speed",
    "velocity": "velocity-magnitude",
    "y_plus": "y-plus",
    "barotropic_density": "expr:barotropic_density",
    "barotropic_speed_sound": "expr:barotropic_speed_sound",
    "barotropic_viscosity": "expr:barotropic_viscosity",
    # "barotropic_void_fraction": "expr:barotropic_void_fraction",
    # "barotropic_vapor_quality": "expr:barotropic_vapor_quality",
}


def read_expression_file(filename):
    """
    Read expressions from the output file of the barotropic model.

    This function parses the output expression file from the barotropic model 
    and stores each expression in a dictionary. Each variable is stored with a 
    key formatted as "barotropic_{variable_name}".

    Parameters
    ----------
    filename : str
        Path to the expression file generated by the barotropic model.

    Returns
    -------
    dict
        A dictionary containing the parsed expressions named as "barotropic_{variable_name}".
    """

    # Read the file and store all in lines
    with open(filename, "r") as file:
        lines = file.readlines()

    # Initialize the dictionary
    expressions = {}
    start_collecting = False

    for line in lines:

        if line.startswith("barotropic_"):
            name = line.strip()
            start_collecting = True
            expressions[name] = []
            expression_str = ""
            continue

        elif start_collecting:
            if line.strip() == "":
                start_collecting = False
                expressions[name] = expression_str.strip()
                continue
            # Append the line to the expression string
            expression_str += line.strip() + " "

    return expressions


def add_solution_strategy(journal, strategy):
    """
    Insert the solution strategy into a Fluent journal after the designated comment.

    This function takes a Fluent journal list of commands and adds the solution strategy
    provided in the `strategy` dictionary right after the comment:
    `"; Run the simulation according to solution strategy"`.

    Parameters
    ----------
    journal : list of str
        List of strings, where each string corresponds to a command or comment in the initial Fluent journal content.
    strategy : dict
        A dictionary containing the solution strategy. It should have the following keys:

        - time_scale_factors : list of float
            List of time scale factors.
        - density_relaxation_factors : list of float
            List of density relaxation factors.
        - number_of_iterations : list of int
            List of number of iterations.

    Returns
    -------
    list of str
        List of strings with the modified Fluent journal commands.

    Raises
    ------
    ValueError
        If the "; Run the simulation according to solution strategy" comment is not found in the initial journal.

    Examples
    --------
    >>> journal_content = ["... [journal commands] ...", "; Run the simulation according to solution strategy", "... other commands ..."]
    >>> strategy = {
        "time_scale_factors": [0.25, 0.50],
        "density_relaxation_factors": [1.0, 1e-3],
        "number_of_iterations": [200, 300]
    }
    >>> new_journal = add_solution_strategy_to_journal(journal_content, strategy)
    """

    # Generate iteration commands as a list
    iteration_commands = []
    for timescale, relaxation, n_iter in zip(
        strategy["time_scale_factors"],
        strategy["density_relaxation_factors"],
        strategy["number_of_iterations"],
    ):
        iteration_commands.append(
            f"solve/set/pseudo-time-method/global-time-step-settings yes 1 {timescale}"
        )
        iteration_commands.append(
            f"solve/set/pseudo-time-method/relaxation-factors/density {relaxation}"
        )
        iteration_commands.append(f"solve/iterate {n_iter}")

    # Find the index of the comment
    try:
        idx = journal.index("; Run the simulation according to solution strategy")
    except ValueError:
        raise ValueError(
            'The "; Run the simulation according to solution strategy" comment was not found in the journal.'
        )

    # Insert the generated iteration commands right after the comment
    for i, command in enumerate(iteration_commands, start=idx + 1):
        journal.insert(i, command)

    return journal


def parse_fluent_out(filename):
    """
    Parse an out-formatted datafile from Fluent and return a DataFrame.

    Parameters
    ----------
    filename : str
        The path to the Fluent output file.

    Returns
    -------
    pd.DataFrame
        A DataFrame containing the parsed data from the Fluent output file.

    Raises
    ------
    ValueError
        If the file format is not as expected.
    FileNotFoundError
        If the provided filename does not exist.

    Examples
    --------
    >>> df = parse_fluent_out("path_to_file.out")
    >>> print(df.head())
    """

    if not os.path.exists(filename):
        raise FileNotFoundError(f"The file {filename} was not found.")

    try:
        # Load file using Pandas
        df = pd.read_csv(filename, skiprows=2, sep=r"\s+", header=0)

        # Process the column names
        cleaned_columns = [
            col.strip('"()').replace("(", "_").replace(")", "") for col in df.columns
        ]

        # Update the DataFrame columns and convert all values to floats
        df.columns = cleaned_columns
        df = df.apply(pd.to_numeric, errors="coerce")

    except Exception as e:
        raise ValueError(f"An error occurred while processing the file: {e}")

    return df


def parse_fluent_xy(filename):
    """
    Parse a Fluent .xy file and return its contents as a dictionary of DataFrames.

    Fluent's .xy files contain datasets, each representing XY plot data for some solution data
    (like pressure or velocity) across a specific domain or region (like wall, axis, etc.).
    This function reads such files and outputs a structured dictionary where the keys are dataset
    labels (like "wall", "axis") and values are corresponding pandas DataFrames.

    Parameters
    ----------
    filename : str
        Path to the Fluent .xy file to be parsed.

    Returns
    -------
    data_out : dict
        A dictionary where:
        - The keys are dataset labels from the .xy file, e.g., "wall", "axis".
        - The values are pandas DataFrames containing the XY data. Each DataFrame has two columns
        representing the X and Y axis data, labeled as per the .xy file's axis labels.

    Examples
    --------
    For a Fluent .xy file containing datasets "wall" and "axis", the output dictionary keys might be ['wall', 'axis'].
    """

    with open(filename, "r") as file:
        lines = file.readlines()

    # Extract axis labels
    result = parse('(labels "{x_label}" "{y_label}")\n', lines[1])
    x_label = result["x_label"]
    y_label = result["y_label"]

    data_out = {}
    zone_data = []
    zone_name = None

    # Iterate over the lines to extract datasets and their data points
    for line in lines[2:]:
        if line == "\n":  # skip empty lines
            continue

        # Detect start of a new dataset
        if line.startswith("((xy/key/label"):
            # If previous data exists, store it in the dictionary before starting a new dataset
            if zone_name and zone_data:
                data_out[zone_name] = pd.DataFrame(
                    zone_data, columns=[x_label, y_label]
                )
                zone_data = []

            zone_name = parse('((xy/key/label "{zone}")\n', line)["zone"]

        # Detect end of current dataset
        elif line.startswith(")"):
            if zone_name and zone_data:
                data_out[zone_name] = pd.DataFrame(
                    zone_data, columns=[x_label, y_label]
                )
                zone_data = []

        # Extract XY data points
        else:
            x, y = map(float, line.split("\t"))  # Conver all values to numeric
            zone_data.append([x, y])

    return data_out


def plot_residuals(transcript_file, fig=None, ax=None, savefig=False, fullpath=None):
    """
    Plot the residual history from the given transcript file.

    Parameters
    ----------
    transcript_file : str
        Path to the transcript file containing residuals.
    fig : matplotlib.figure.Figure, optional
        Existing figure to plot on, if provided. Defaults to None.
    ax : matplotlib.axes.Axes, optional
        Existing axis to plot on, if provided. Defaults to None.
    savefig : bool, optional
        Flag to save the figure to a file. Defaults to False.
    fullpath : str, optional
        If savefig is True, path to save the figure to (excluding file extension). Defaults to None.

    Returns
    -------
    fig : matplotlib.figure.Figure
        Figure containing the plotted residuals.
    ax : matplotlib.axes.Axes
        Axes containing the plotted residuals.

    Notes
    -----
    The function reads the residuals from the given transcript file using `read_residual_file`
    and then plots them on a semi-log scale. If `fig` and `ax` are not provided,
    it creates a new figure and axis. The residuals are mapped to readable labels
    using a predefined name_map.
    """

    # Read residuals from the transcript file
    df, _ = read_residual_file(transcript_file)

    # If no figure or axis provided, create new ones
    if not fig and not ax:
        fig, ax = plt.subplots(figsize=(6.4, 4.8))

    # Set plot title, labels and scales
    ax.set_title(f"Convergence history from file {os.path.basename(transcript_file)}")
    ax.set_xlabel("Iteration number")
    ax.set_ylabel("Normalized RMS residual")
    ax.set_yscale("log")

    # Plot residuals using the mapped labels
    for column in df.columns[1:]:
        ax.plot(
            df["iter"],
            df[column],
            linewidth=1.0,
            linestyle="-",
            marker="none",
            label=RESIDUAL_LABEL_MAPPING.get(column, column),
        )

    # Add legend to the plot
    ax.legend(loc="upper right", fontsize=11)

    # Adjust plot layout
    fig.tight_layout(pad=1)

    # If save flag is set and a path is provided, save the figure in multiple formats
    if savefig and fullpath:
        for ext in [".png", ".svg", ".eps"]:
            fig.savefig(fullpath + ext, bbox_inches="tight")

    return fig, ax


# def read_residual_file(filename, res_number=5, line_start=0, header=None):
#     """
#     Reads a transcript file to extract the residual history and returns it as a Pandas DataFrame.

#     Parameters
#     ----------
#     filename : str
#         Path to the transcript file.
#     res_number : int, optional
#         Number of residuals to be extracted. Defaults to 5.
#     line_start : int, optional
#         Line number to start reading from. Useful for files with non-relevant introductory lines. Defaults to 0.
#     header : list of str, optional
#         List of column names for the DataFrame. If not provided, the function tries to extract it from the file.

#     Returns
#     -------
#     pd.DataFrame
#         DataFrame containing the extracted residuals. Column names are either provided or extracted from the file.

#     Notes
#     -----
#     It's assumed that the residual headers in the transcript file start with 'iter' and 'continuity'.
#     """

#     # Read data from the transcript file
#     with open(filename, 'r') as file:
#         lines = file.readlines()

#     # Initialize lists to store values
#     residuals = []

#     # Use the provided header if available, otherwise extract from the file
#     if not header:
#         header = []

#     # Loop through lines to extract headers and residuals
#     for line in lines[line_start:]:

#         # If header hasn't been found, look for it based on the expected starting words
#         words = line.split()
#         if not header and len(words) >= 2 and words[0] == 'iter' and words[1] == 'continuity':
#             header = line.split()
#             header = [item.split("/") for item in header]
#             header = [item for sublist in header for item in sublist]
#             header = header[0:res_number+1]

#         # Extract lines that contain numeric residual values
#         line_elements = line.split()[0:res_number+1]
#         numeric_line = all([is_float(element) for element in line_elements])

#         # Append lines that match the expected structure and content
#         if header and len(header) == len(line_elements) and numeric_line:
#             residuals.append(line_elements)

#     # Convert the list of residuals into a DataFrame
#     df = pd.DataFrame(residuals, columns=header)

#     # Convert values to float for computation purposes
#     df = df.astype(float)

#     return df


# def read_residual_file(filename, line_start=0):
#     """
#     Reads a transcript file to extract the residual history and returns it as a Pandas DataFrame.

#     Parameters
#     ----------
#     filename : str
#         Path to the transcript file.
#     line_start : int, optional
#         Line number to start reading from. Useful for files with non-relevant introductory lines. Defaults to 0.

#     Returns
#     -------
#     pd.DataFrame
#         DataFrame containing the extracted residuals with appropriate column headers.

#     Notes
#     -----
#     It is assumed that the residual headers in the transcript file start with 'iter' end with 'time/iter'.

#     """

#     # Read data from the transcript file
#     with open(filename, 'r') as file:
#         lines = file.readlines()

#     # Initialize variables
#     residuals = []
#     header = None

#     # Loop through lines to extract headers and residuals
#     for line in lines[line_start:]:

#         # Skip empty lines
#         words = line.split()
#         if not words:
#             continue

#         # Detect header based on first column being "iter" and last column being "time/iter"
#         if header is None and words[0] == "iter" and words[-1] == "time/iter":
#             header = words

#         # Extract numeric values for residuals
#         elif header:
#             if len(words) == len(header) + 1:  # Ensure line matches header length
#                 if words[0].isdigit() and is_float(words[1]):  # Check "iter" is numeric and first residual is valid
#                     residuals.append(words[0:-2]) # Exclude "time/iter" which are 2 entries

#     # Create empty DataFrame if no data is found
#     if not residuals or header is None:
#         return pd.DataFrame()

#     # # Validate header and residuals
#     # if header is None:
#     #     raise ValueError("No valid header found in the file.")
#     # if not residuals:
#     #     raise ValueError("No valid residual data found in the file.")

#     # Create dataframe (excluding "iter" and "time/iter")
#     df = pd.DataFrame(residuals, columns=header[0:-1]) # Exclude "time/iter" which is 1 entry

#     # Convert column data to numbers
#     df["iter"] = df["iter"].astype(int)
#     df[header[1:-1]] = df[header[1:-1]].astype(float)

#     # Check if there is an error

#     return df


def read_residual_file(
    filename,
    line_start=0,
    error_messages=[
        "Error object: #f",
        "Error Object: #f",
        "Error Object: ()",
        "ERROR: floating point exception",
    ],
):
    """
    Reads a transcript file to extract the residual history and returns it as a Pandas DataFrame.

    Parameters
    ----------
    filename : str
        Path to the transcript file.
    line_start : int, optional
        Line number to start reading from. Useful for files with non-relevant introductory lines. Defaults to 0.

    Returns
    -------
    pd.DataFrame
        DataFrame containing the extracted residuals with appropriate column headers.

    Notes
    -----
    It is assumed that the residual headers in the transcript file start with 'iter' end with 'time/iter'.

    """

    # Read data from the transcript file
    with open(filename, "r") as file:
        lines = file.readlines()

    # Initialize variables
    residuals = []
    header = None

    # Loop through lines to extract headers and residuals
    for line in lines[line_start:]:

        # Skip empty lines
        words = line.split()
        if not words:
            continue

        # Detect header based on first column being "iter" and last column being "time/iter"
        if header is None and words[0] == "iter" and words[-1] == "time/iter":
            header = words

        # Extract numeric values for residuals
        elif header:
            if len(words) == len(header) + 1:  # Ensure line matches header length
                if words[0].isdigit() and is_float(
                    words[1]
                ):  # Check "iter" is numeric and first residual is valid
                    residuals.append(
                        words[0:-2]
                    )  # Exclude "time/iter" which are 2 entries

    # Create empty DataFrame if no data is found
    if not residuals or header is None:
        return pd.DataFrame()

    # # Validate header and residuals
    # if header is None:
    #     raise ValueError("No valid header found in the file.")
    # if not residuals:
    #     raise ValueError("No valid residual data found in the file.")

    # Create dataframe (excluding "iter" and "time/iter")
    df = pd.DataFrame(
        residuals, columns=header[0:-1]
    )  # Exclude "time/iter" which is 1 entry

    # Convert column data to numbers
    df["iter"] = df["iter"].astype(int)
    df[header[1:-1]] = df[header[1:-1]].astype(float)

    def contains_any_error_ignore_spaces(error_messages, lines):
        # Remove spaces from all error messages once
        cleaned_errors = [msg.replace(" ", "") for msg in error_messages]

        # Check each line, ignoring spaces
        for line in lines:
            cleaned_line = line.replace(" ", "")
            if any(error in cleaned_line for error in cleaned_errors):
                return True
        return False

    # Check if there is an error
    if contains_any_error_ignore_spaces(error_messages, lines):
        # if error_message in lines:
        # print("Error in the simulation...")
        simulation_finished = False
    else:
        simulation_finished = True

    return df, simulation_finished


def print_transcript_real_time(transcript_file):
    """
    Print the content of transcript_file.trn in real time.

    This function launches the `stream_transcript.py` script as a subprocess
    to print the content of the given transcript file in real time.

    Parameters
    ----------
    transcript_file : str
        Path to the transcript file to be printed.

    Returns
    -------
    subprocess.Popen
        The subprocess object representing the background process.
    """
    wait_for_file(transcript_file)
    args = [sys.executable, stream_transcript.__file__, transcript_file]
    return subprocess.Popen(args)


def plot_residuals_real_time(transcript_file):
    """
    Plot the residual history contained in the transcript_file.trn in real time.

    This function launches the `stream_residuals.py` script as a subprocess
    to plot the residual history of the given transcript file in real time.

    Parameters
    ----------
    transcript_file : str
        Path to the transcript file whose residuals are to be plotted.

    Returns
    -------
    subprocess.Popen
        The subprocess object representing the background process.
    """
    wait_for_file(transcript_file)
    args = [sys.executable, stream_residuals.__file__, transcript_file]
    return subprocess.Popen(args)


def run_fluent_simulation(
    journal,
    n_proc=1,
    timeout=3600,
    plot_residuals=False,
    print_transcript=False,
    transcript_file=None,
):
    """
    Orchestrates the execution of a Fluent simulation using the commands from the provided journal file.

    The function manages the entire simulation process from initiating Fluent, sending it the right
    commands, visualizing output, to cleaning up post-simulation resources. It offers functionalities
    to both visualize the Fluent console output in Python and plot the residual history in real time.

    Parameters
    ----------
    journal : str
        The sequence of Fluent commands to be executed.
    n_proc : int, optional
        Number of processor cores Fluent should use. Default is 1.
    timeout : int, optional
        Maximum allowable time for the Fluent process to run, in seconds. Default is 3600 (1 hour).
    plot_residuals : bool, optional
        If True, plots residuals in real time. Requires `transcript_file` to be provided. Default is False.
    print_transcript : bool, optional
        If True, the Fluent console output will be displayed in Python. Requires `transcript_file` to be provided. Default is False.
    transcript_file : str, optional
        Path to the transcript file which contains the residuals and other output data from Fluent.
        Mandatory if either `plot_residuals` or `print_transcript` are set to True.

    Raises
    ------
    TranscriptFileMissingError
        If either `plot_residuals` or `print_transcript` is True and `transcript_file` is not provided.

    Returns
    -------
    None

    Notes
    -----
    After the simulation, the function automatically invokes the Fluent cleanup script to close open processes.
    """

    # Check for FLUENT_EXEC environment variable
    fluent_exec_path = os.getenv("FLUENT_EXEC")
    if fluent_exec_path is None:
        raise EnvironmentError(
            "Environment variable FLUENT_EXEC is not set. Please add the following lines to your .bashrc file (or your shell's equivalent), "
            "adjusting the paths to match the location where Fluent is installed on your system:\n\n"
            "# Path to the Fluent v23.1 bin directory\n"
            'export FLUENT_PATH="/path/to/your/ansys/inc/v231/fluent/ntbin/win64"\n'
            'export FLUENT_EXEC="$FLUENT_PATH/fluent.exe"\n'
            "export PATH=$PATH:$FLUENT_PATH\n\n"
            "Then restart your shell or run 'source ~/.bashrc' (without quotes) for the changes to take effect."
        )

    # Check if the Fluent executable exists at the specified path
    if not shutil.which(fluent_exec_path):
        raise FileNotFoundError(
            f"Fluent executable not found at the path specified by the FLUENT_EXEC environment variable: {fluent_exec_path}. "
            "Please ensure that the FLUENT_EXEC environment variable is set correctly in your .bashrc file (or your shell's equivalent), "
            "and points to the Fluent executable."
        )

    plot_process = None
    print_process = None
    exit_flag = None
    cleanup_done = False

    # Run simulations
    try:

        # Create Fluent child process
        # 2dpp -> 2D solver with double precision arithmetic
        # -g -> Do not start up the graphical user interface
        # -tN -> Run the simulation using N processor cores
        process = pexpect.popen_spawn.PopenSpawn(
            f'{os.getenv("FLUENT_EXEC")} 2ddp -g -t{n_proc}'
        )

        # Send sequence of commands to Fluent CLI
        process.send(journal)

        # Plot residuals from transcript file in real time
        if plot_residuals:
            if transcript_file is None:
                raise TranscriptFileMissingError()
            plot_process = plot_residuals_real_time(
                transcript_file
            )  # Function waits until the transcript file exists

        # Print the transcript file in real time
        if print_transcript:
            if transcript_file is None:
                raise TranscriptFileMissingError()
            print_process = print_transcript_real_time(
                transcript_file
            )  # Function waits until the transcript file exists

        # Wait for the child process to finish
        exit_flag = process.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=timeout)
        # print(process.before.decode('utf-8'))

        # Check execution status
        if exit_flag == 0:
            # Fluent execution reached the End Of File (EOF)
            print("Simulation completed\n")

        elif exit_flag == 1:
            # Time out reached before Fluent execution was complete
            print("Timeout before execution was complete")

    except KeyboardInterrupt:
        print("Fluent execution terminated by user.")
        if not cleanup_done:
            run_cleanup_script()
            cleanup_done = True
        print("Continuing script execution...")
        return  # Exit the function

    except TranscriptFileMissingError as e:
        if not cleanup_done:
            run_cleanup_script()
            cleanup_done = True
        raise e

    finally:
        # Cleanup operations
        if plot_process:
            plot_process.terminate()

        if print_process:
            print_process.terminate()

        if exit_flag != 0 and not cleanup_done:
            run_cleanup_script()


class TranscriptFileMissingError(ValueError):
    """Raised when plotting residuals is requested but no transcript file is provided."""

    def __init__(self, message=None):
        if message is None:
            message = (
                "To plot residuals in real-time, a transcript file is required. "
                "Please provide the 'transcript_file' parameter when setting 'plot_residuals' to True."
            )
        super().__init__(message)


def run_cleanup_script(max_retries=5, wait_interval=5):
    """
    Execute the cleanup script found in the current working directory.

    This function repeatedly attempts to find and execute files with a prefix
    of "cleanup-fluent" in the current directory. If the script isn't found
    immediately, the function will wait for a short interval and try again,
    up to a specified maximum number of retries.

    Parameters:
        max_retries (int): Maximum number of times the function will attempt
                           to find and execute the cleanup script.
        wait_interval (int): Time in seconds to wait between consecutive
                             retries.

    Note:
        The function is used to ensure that necessary cleanup operations are
        performed after the simulation, especially when the creation of the
        cleanup script might be delayed.
    """

    print("Fluent did not terminate properly. Using the Fluent cleanup script...")
    CLEANUP_PREFIX = "cleanup-fluent"
    retries = 0

    while retries < max_retries:
        for filename in os.listdir(os.getcwd()):
            if filename.startswith(CLEANUP_PREFIX):
                os.system(filename)
                return
        retries += 1
        time.sleep(wait_interval)  # Wait before the next retry

    print("Failed to find cleanup script after", max_retries, "retries.")


def kill_fluent(transcript_file, fluent_pid):
    """
    Check if the simulation has converged; if not, terminate the Fluent process.

    This function examines the transcript file to determine whether the simulation 
    has successfully converged. If convergence has not been reached, the associated 
    Fluent process is forcefully terminated using its process ID.

    Parameters
    ----------
    transcript_file : str
        Path to the transcript file containing simulation output.
    fluent_pid : int
        Process ID of the running Fluent simulation to be terminated if not converged.

    Returns
    -------
    None
    """
    _, sim_converged = read_residual_file(transcript_file)

    if sim_converged == False:
        print(f"Simulation did not converged, forcing fluent closing")

        # Need to relunch fluent becuase the simulation crashed and it is stuck
        os.kill(fluent_pid, 9)


def compare_dicts(
    d1,
    d2,
    keys_to_skip=None,
    nested_skips={
        "barotropy_options": ["save_fig", "ODE_tolerance"],
        "fluent_options": [
            "processor_count",
            "show_gui",
            "plot_realtime_residuals",
            "res_settings",
            "relaxation",
            "solution_strategy",
            "stop_simulation"
        ],
    },
):
    """
    Compare two dictionaries for equality, with options to skip specific keys.

    This function checks if two dictionaries are equal, allowing for the exclusion 
    of specified top-level and nested keys during the comparison.

    Parameters
    ----------
    d1 : dict
        The first dictionary to compare.
    d2 : dict
        The second dictionary to compare.
    keys_to_skip : list
        A list of top-level keys to ignore during comparison.
    nested_skips : dict
        A dictionary specifying nested keys to skip for certain top-level keys 
        (e.g., {'barotropy_options': ['save_fig']}).

    Returns
    -------
    bool
        True if the dictionaries are equal after applying the skip rules; 
        False otherwise.
    """

    if keys_to_skip is None:
        keys_to_skip = []
    if nested_skips is None:
        nested_skips = {}

    keys_to_ignore = set(keys_to_skip)

    def filter_dict(d):
        filtered = {k: v for k, v in d.items() if k not in keys_to_ignore}
        for parent_key, child_keys in nested_skips.items():
            if parent_key in filtered and isinstance(filtered[parent_key], dict):
                filtered[parent_key] = {
                    k: v for k, v in filtered[parent_key].items() if k not in child_keys
                }
        return filtered

    return filter_dict(d1) == filter_dict(d2)


def get_fluent_zone_table(solver) -> pd.DataFrame:
    """
    Reads the current mesh from a running Fluent solver and extracts zone information.

    This function calls `solver.mesh.modify_zones.list_zones()` to retrieve a list of all
    mesh zones defined in the Fluent session. It parses the raw text output and returns it
    as a structured pandas DataFrame with zone-level metadata.

    The DataFrame includes the following columns:
        - id       : Zone ID (integer)
        - name     : Zone name (string, e.g., 'wall-5', 'solid-1')
        - type     : Zone type (e.g., 'wall', 'solid', 'interior')
        - material : Assigned material name if applicable (e.g., 'aluminum'); may be empty
        - kind     : Zone category ('cell' or 'face')

    Example output:
        id                name      type  material   kind
         1             solid-1     solid  aluminum   cell
         2             solid-2     solid  aluminum   cell
         3              wall-5      wall  aluminum   face
         4  default_interior-1  interior            face
         ...

    Parameters
    ----------
    solver : pyfluent.fluent.Solver
        A running Fluent solver instance launched via pyfluent.

    Returns
    -------
    pd.DataFrame
        A table of zone information with one row per zone.
    """
    import io
    import re
    from contextlib import redirect_stdout

    f = io.StringIO()
    with redirect_stdout(f):
        solver.mesh.modify_zones.list_zones()
    output = f.getvalue()

    lines = output.strip().split("\n")

    # Step 1: find the header line and column names
    header_line = next(line for line in lines if "name" in line and "type" in line)
    col_names = re.split(r'\s{2,}', header_line.strip())

    # Step 2: skip headers and separators
    data_lines = [
        line for line in lines
        if line.strip() and not line.startswith("----") and "name" not in line
    ]

    # Step 3: parse each line
    parsed = []
    for line in data_lines:
        fields = re.split(r'\s{2,}', line.strip())
        if len(fields) < len(col_names):
            fields += [""] * (len(col_names) - len(fields))
        parsed.append(dict(zip(col_names, fields)))

    return pd.DataFrame(parsed)
