from __future__ import annotations
from typing import Any, Dict, TYPE_CHECKING, Tuple
import math
import pandas as pd
import numpy as np

from mento.units import psi, mm, cm, inch, MPa
from mento.material import Concrete_ACI_318_19

if TYPE_CHECKING:
    from mento.beam import RectangularBeam
    from pint import Quantity
    from pandas import DataFrame


class Rebar:
    def __init__(self, beam: RectangularBeam):
        """
        Initializes the Rebar object with the associated beam and settings.
        """

        self.mode = getattr(beam, "mode", "beam")  # "beam" or "slab"

        self.beam = beam
        self._long_combos_df: DataFrame = pd.DataFrame()
        self._trans_combos_df: DataFrame = pd.DataFrame()
        self._clear_spacing: Quantity = 0 * mm
        # Unit system default rebar
        if self.beam.concrete.unit_system == "metric":
            self.rebar_diameters = [
                6 * mm,
                8 * mm,
                10 * mm,
                12 * mm,
                16 * mm,
                20 * mm,
                25 * mm,
                32 * mm,
            ]
            self.rebar_areas = {d: (math.pi * d**2) / 4 for d in self.rebar_diameters}
        else:
            self.rebar_diameters = [
                3 * inch / 8,
                4 * inch / 8,
                5 * inch / 8,
                6 * inch / 8,
                7 * inch / 8,
                8 * inch / 8,
                1.128 * inch,
                1.27 * inch,
                1.41 * inch,
                1.693 * inch,
            ]
            rebar_areas_list = [(d**2 * np.pi / 4) for d in self.rebar_diameters]
            self.rebar_areas = dict(zip(self.rebar_diameters, rebar_areas_list))

    @property
    def longitudinal_rebar_design(self) -> DataFrame:
        return self._long_combos_df.iloc[0]

    @property
    def transverse_rebar_design(self) -> DataFrame:
        return self._trans_combos_df.iloc[0]

    ##########################################################
    # TRANSVERSE REBAR DESIGN
    ##########################################################

    def calculate_max_spacing_ACI_318_19(
        self, V_s_req: Quantity, A_cv: Quantity
    ) -> Tuple[Quantity, Quantity]:
        """
        Calculate the maximum allowable spacing across the length and width of the beam
        based on design requirements.

        Parameters:
        -----------
        V_s_req : float
            Required shear force for the rebar.
        A_cv : float
            Effective shear area of the concrete section.

        Returns:
        --------
        tuple
            (s_max_l, s_max_w): The maximum spacing across the length and width of the beam.
        """

        f_c = self.beam.concrete.f_c
        if isinstance(self.beam.concrete, Concrete_ACI_318_19):
            lambda_factor = self.beam.concrete.lambda_factor

        # Determine maximum spacing based on V_s_req condition
        # Maximum spacing for lower shear demand
        if self.beam.concrete.unit_system == "metric":
            if V_s_req <= 0.083 * lambda_factor * math.sqrt(f_c / MPa) * MPa * A_cv:
                s_max_l = min(self.beam._d_shear / 2, 60 * cm)
                s_max_w = min(self.beam._d_shear, 60 * cm)
            else:
                # Maximum spacing for higher shear demand
                s_max_l = min(self.beam._d_shear / 4, 30 * cm)
                s_max_w = min(self.beam._d_shear / 2, 30 * cm)
        else:
            if V_s_req <= 4 * lambda_factor * math.sqrt(f_c / psi) * psi * A_cv:
                s_max_l = min(self.beam._d_shear / 2, 24 * inch)
                s_max_w = min(self.beam._d_shear, 24 * inch)
            else:
                s_max_l = min(self.beam._d_shear / 4, 12 * inch)
                s_max_w = min(self.beam._d_shear / 2, 12 * inch)

        return s_max_l, s_max_w

    def calculate_max_spacing_EN_1992_2004(
        self, alpha: float
    ) -> Tuple[Quantity, Quantity]:
        """
        Calculate the maximum allowable spacing across the length and width of the beam
        based on design requirements for EN 1992-2004.

        Parameters:
        -----------
        alpha: stirrups angle

        Returns:
        --------
        tuple
            (s_max_l, s_max_w): The maximum spacing along the length and width of the beam.
        """
        # Maximum of 40 cm for stirrup spacing
        s_max_l = min(0.75 * self.beam._d_shear * (1 + 1 / math.tan(alpha)), 40 * cm)
        s_max_w = min(0.75 * self.beam._d_shear, 60 * cm)

        return s_max_l, s_max_w

    def transverse_rebar_ACI_318_19(self, V_s_req: Quantity) -> Any:
        if self.beam.concrete.unit_system == "metric":
            valid_diameters = self.rebar_diameters[2:5]  # Minimum 10 mm
        else:
            valid_diameters = self.rebar_diameters[0:3]

        A_cv = self.beam.width * self.beam._d_shear
        s_max_l, s_max_w = self.calculate_max_spacing_ACI_318_19(V_s_req, A_cv)

        return valid_diameters, s_max_l, s_max_w

    def transverse_rebar_CIRSOC_201_25(self, V_s_req: Quantity) -> Any:
        valid_diameters = self.rebar_diameters[0:5]  # Minimum 6 mm

        A_cv = self.beam.width * self.beam._d_shear
        s_max_l, s_max_w = self.calculate_max_spacing_ACI_318_19(V_s_req, A_cv)

        return valid_diameters, s_max_l, s_max_w

    def transverse_rebar_EN_1992_2004(self, alpha: float) -> Any:
        valid_diameters = self.rebar_diameters[0:5]  # Minimum 6 mm

        s_max_l, s_max_w = self.calculate_max_spacing_EN_1992_2004(alpha)

        return valid_diameters, s_max_l, s_max_w

    def transverse_rebar(
        self, A_v_req: Quantity, V_s_req: Quantity, alpha: float
    ) -> DataFrame:
        """
        Computes the required transverse reinforcement based on ACI 318-19.

        Args:
            A_v_req: Required area for transverse reinforcement.

        Returns:
            A dictionary containing the transverse rebar design.
        """

        # Prepare the list for valid combinations
        valid_combinations = []

        # Get code specific limitations
        if self.beam.concrete.design_code == "ACI 318-19":
            valid_diameters, s_max_l, s_max_w = self.transverse_rebar_ACI_318_19(
                V_s_req
            )
        elif self.beam.concrete.design_code == "CIRSOC 201-25":
            valid_diameters, s_max_l, s_max_w = self.transverse_rebar_CIRSOC_201_25(
                V_s_req
            )
        elif self.beam.concrete.design_code == "EN 1992-2004":
            valid_diameters, s_max_l, s_max_w = self.transverse_rebar_EN_1992_2004(
                alpha
            )
        else:
            raise ValueError(
                f"Shear design method not implemented for concrete type: {type(self.beam.concrete).__name__}"
            )

        # Iterate through available diameters
        for d_b in valid_diameters:
            # Start with 2 legs = 1 stirrup
            n_legs = 2

            # Start with maximum allowed spacing s_max_l
            if self.beam.concrete.unit_system == "metric":
                s_l: Quantity = math.floor(s_max_l.to("cm").magnitude) * cm
            else:
                s_l = math.floor(s_max_l.to("inch").magnitude) * inch

            while True:
                # Calculate spacing based on current legs
                n_stirrups = math.ceil(
                    n_legs / 2
                )  # Number of stirrups based on number of legs
                n_legs_actual = n_stirrups * 2  # Ensure legs are even
                # Consider 1 leg less for spacing laong width
                s_w = (self.beam.width - 2 * self.beam.c_c - self.beam._stirrup_d_b) / (
                    n_legs_actual - 1
                )  # noqa: F841

                A_db = self.rebar_areas[d_b]  # Area of a stirrup bar
                A_vs = n_legs_actual * A_db  # Area of vertical stirrups
                A_v: Quantity = A_vs / s_l  # Area of vertical stirrups per unit length

                # Store the valid combination if spacing is also valid
                if self.beam.concrete.unit_system == "metric":
                    # Check if the calculated A_v meets or exceeds the required A_v
                    if A_v >= A_v_req:
                        valid_combinations.append(
                            {
                                "n_stir": int(n_stirrups),
                                "d_b": d_b,
                                "s_l": s_l.to("cm"),  # spacing along length
                                "s_w": s_w.to("cm"),  # spacing along width
                                "A_v": A_v.to("cm**2/m"),
                                "s_max_l": s_max_l.to("cm"),
                                "s_max_w": s_max_w.to("cm"),
                            }
                        )
                        # Stop checking larger diameters
                        break

                    # If A_v is insufficient, reduce s_l by 1 cm
                    s_l -= 1 * cm
                    # If s_l becomes less than 5 cm, increase the number of legs by 2 and reset s_l to s_max_l
                    if s_l < 5 * cm:  # If spacing is less than 5 cm, increase 1 stirrup
                        n_legs += 2
                        s_l = (
                            math.floor(s_max_l.to("cm").magnitude) * cm
                        )  # Reset s_l to the max allowed spacing
                    # Break the loop if legs exceed the limit of legs or max stirrup diameter
                    if n_legs > 6:
                        break
                else:
                    # Check if the calculated A_v meets or exceeds the required A_v
                    if A_v >= A_v_req:
                        valid_combinations.append(
                            {
                                "n_stir": int(n_stirrups),
                                "d_b": d_b,
                                "s_l": s_l.to("inch"),  # spacing along length
                                "s_w": s_w.to("inch"),  # spacing along width
                                "A_v": A_v.to("inch**2/ft"),
                                "s_max_l": s_max_l.to("inch"),
                                "s_max_w": s_max_w.to("inch"),
                            }
                        )
                        # Stop checking larger diameters
                        break

                    # If A_v is insufficient, reduce s_l by 1 inch
                    s_l -= 1 * inch
                    # If s_l becomes less than 2 inch, increase the number of legs by 2 and reset s_l to s_max_l
                    if (
                        s_l < 2 * inch
                    ):  # If spacing is less than 2 inch, increase 1 stirrup
                        n_legs += 2
                        s_l = (
                            math.floor(s_max_l.to("inch").magnitude) * inch
                        )  # Reset s_l to the max allowed spacing
                    # Break the loop if legs exceed the limit of legs or max stirrup diameter
                    if n_legs > 6:
                        break

        # Create a DataFrame with all valid combinations
        df_combinations = pd.DataFrame(valid_combinations)

        # Sort combinations by the total rebar area required (ascending)
        # Sort by 'A_v' first, then by 'n_stir' to prioritize fewer bars
        df_combinations.sort_values(by=["n_stir", "A_v"], inplace=True)
        df_combinations.reset_index(drop=True, inplace=True)
        self._trans_combos_df = df_combinations
        return df_combinations

    ##########################################################
    # LONGITUDINAL REBAR DESIGN
    ##########################################################

    def longitudinal_rebar_ACI_318_19(
        self,
        A_s_req: Quantity,
        A_s_max: Quantity | None = None,
        mech_cover: Quantity | None = None,
    ) -> DataFrame:
        """
        Computes the required longitudinal reinforcement based on ACI 318-19.

        Args:
            A_s_req: Required longitudinal rebar area.
            A_s_max: Optional maximum allowable longitudinal rebar area. If not
                provided, the limit defaults to ``10 * A_s_req``.

        Returns:
            A DataFrame containing the best combinations of rebar details. If no
            combination satisfies ``A_s_req``, returns the combination with the
            maximum possible area.
        """
        self.A_s_req = A_s_req
        self.original_mech_cover = mech_cover
        effective_width = self.beam.width - 2 * (self.beam.c_c + self.beam._stirrup_d_b)

        # Variables to track the combinations
        valid_combinations = []
        best_fallback_combination = None  # To store the best fallback design
        max_fallback_area = 0 * cm**2  # To track the maximum area in fallback cases
        # Create a list of rebar diameters that are equal to or greater than the minimum diameter
        if self.beam.concrete.unit_system == "metric":
            self.min_long_rebar = 10 * mm
        else:
            self.min_long_rebar = 3 * inch / 8

        # Filter valid rebar diameters based on the minimum longitudinal diameter per design code and beam settings
        valid_rebar_diameters = [
            d
            for d in self.rebar_diameters
            if d >= self.min_long_rebar
            and d >= self.beam.settings.minimum_longitudinal_diameter
        ]

        for d_b1 in valid_rebar_diameters:  # Without taking Ø6 as a possible solution
            for d_b2 in [d for d in valid_rebar_diameters if d <= d_b1]:
                for d_b3 in [d for d in valid_rebar_diameters if d <= d_b2]:
                    for d_b4 in [d for d in valid_rebar_diameters if d <= d_b3]:
                        # Condition 5: |d_b1 - d_b2| and |d_b3 - d_b4| must not exceed max_diameter_diff
                        # Ensure all diameter combinations satisfy the ordering constraint
                        if not (d_b1 >= d_b2 >= d_b3 >= d_b4):
                            continue  # Skip combinations that do not meet the ordering condition
                        # Apply diameter difference condition across all combinations
                        # Ensure that no two bars exceed `max_diameter_diff`
                        diameters = [d_b1, d_b2, d_b3, d_b4]
                        diameters = [
                            d for d in diameters if d is not None
                        ]  # Filter out None values for unused bars

                        # Apply the diameter difference check across all bars
                        if not self._check_diameter_differences(diameters):
                            continue  # Skip this combination if any diameter pair exceeds max_diameter_diff`

                        n1 = 0 if self.A_s_req == 0*cm**2 else 2  # This is a fixed value for every beam

                        # Iterate over possible numbers of bars in each group
                        for n2 in range(
                            0, self.beam.settings.max_bars_per_layer + 1
                        ):  # n2 can be 0 or more
                            # Check spacing for the first set of bars
                            self._check_spacing(n1, n2, d_b1, d_b2, effective_width)

                            if n1 + n2 > self.beam.settings.max_bars_per_layer:
                                continue  # Skip if the total bars in layer 1 exceed the limit

                            # Calculate area for layer 1
                            A_s_layer_1 = n1 * self.rebar_areas[d_b1] + (
                                n2 * self.rebar_areas[d_b2] if n2 > 0 else 0 * cm**2
                            )

                            # Condition 6 and 7: Check clear spacing in layer 1
                            if n2 > 0 and not self._check_spacing(
                                n1, n2, d_b1, d_b2, effective_width
                            ):
                                continue

                            max_limit = 10 * A_s_req
                            if A_s_max is not None:
                                max_limit = min(max_limit, A_s_max)
                            max_limit = max(
                                max_limit, n1 * self.rebar_areas[self.min_long_rebar]
                            )

                            # Check if total area from layer 1 is enough for required A_s
                            # And also less than the maximum limit
                            if A_s_layer_1 >= A_s_req and A_s_layer_1 <= max_limit:
                                total_as = A_s_layer_1  # Only consider layer 1
                                total_bars = n1 + n2  # Total bars only in layer 1
                                valid_combinations.append(
                                    {
                                        "n_1": n1,
                                        "d_b1": d_b1,
                                        "n_2": n2,
                                        "d_b2": d_b2
                                        if n2 > 0
                                        else None,  # Display as None if n2 is 0
                                        "n_3": 0,  # No bars in layer 2
                                        "d_b3": None,
                                        "n_4": 0,  # No bars in layer 2
                                        "d_b4": None,
                                        "total_as": total_as.to("cm**2"),
                                        "total_bars": total_bars,
                                        "clear_spacing": self._clear_spacing.to("mm"),
                                    }
                                )
                            else:
                                # Track the combination with the maximum possible area (fallback)
                                if A_s_layer_1 > max_fallback_area:
                                    max_fallback_area = A_s_layer_1
                                    best_fallback_combination = {
                                        "n_1": n1,
                                        "d_b1": d_b1,
                                        "n_2": n2,
                                        "d_b2": d_b2 if n2 > 0 else None,
                                        "n_3": 0,
                                        "d_b3": None,
                                        "n_4": 0,
                                        "d_b4": None,
                                        "total_as": A_s_layer_1.to("cm**2"),
                                        "total_bars": n1 + n2,
                                        "clear_spacing": self._clear_spacing.to("mm"),
                                    }

                            # =============================================================
                            # --- Layer 2 combinations (beam vs slab logic) ---
                            # =============================================================

                            if self.mode == "slab":
                                # ---------------------------------------------------------
                                # In slab mode:
                                #  - Only two cases are considered:
                                #       (1) one single layer
                                #       (2) second layer identical to the first (mirror)
                                #  - n3 = n1, n4 = n2 when a second layer exists
                                # ---------------------------------------------------------
                                for has_second_layer in [False, True]:
                                    if not has_second_layer:
                                        n3, n4 = 0, 0
                                    else:
                                        n3, n4 = n1, n2

                                    # --- Compute total reinforcement in layer 2 ------------
                                    A_s_layer_2 = n3 * self.rebar_areas[d_b3] + (
                                        n4 * self.rebar_areas[d_b4]
                                        if n4 > 0
                                        else 0 * cm**2
                                    )

                                    # Condition 4: area of layer 1 ≥ area of layer 2
                                    if A_s_layer_1 < A_s_layer_2:
                                        continue

                                    # Condition 6 and 7: check clear spacing in layer 2
                                    if n4 > 0 and not self._check_spacing(
                                        n3, n4, d_b3, d_b4, effective_width
                                    ):
                                        continue

                                    # --- Compute total reinforcement and evaluate -----------
                                    total_as = A_s_layer_1 + A_s_layer_2
                                    if total_as >= A_s_req and total_as <= max_limit:
                                        total_bars = n1 + n2 + n3 + n4
                                        valid_combinations.append(
                                            {
                                                "n_1": n1,
                                                "d_b1": d_b1,
                                                "n_2": n2,
                                                "d_b2": d_b2 if n2 > 0 else None,
                                                "n_3": n3,
                                                "d_b3": d_b3 if n3 > 0 else None,
                                                "n_4": n4,
                                                "d_b4": d_b4 if n4 > 0 else None,
                                                "total_as": total_as.to("cm**2"),
                                                "total_bars": total_bars,
                                                "clear_spacing": self._clear_spacing.to(
                                                    "mm"
                                                ),
                                            }
                                        )
                                    else:
                                        # Track fallback combination with maximum As
                                        if total_as > max_fallback_area:
                                            max_fallback_area = total_as
                                            best_fallback_combination = {
                                                "n_1": n1,
                                                "d_b1": d_b1,
                                                "n_2": n2,
                                                "d_b2": d_b2 if n2 > 0 else None,
                                                "n_3": n3,
                                                "d_b3": d_b3 if n3 > 0 else None,
                                                "n_4": n4,
                                                "d_b4": d_b4 if n4 > 0 else None,
                                                "total_as": total_as.to("cm**2"),
                                                "total_bars": n1 + n2 + n3 + n4,
                                                "clear_spacing": self._clear_spacing.to(
                                                    "mm"
                                                ),
                                            }

                            else:
                                # ---------------------------------------------------------
                                # Normal beam logic (original)
                                # ---------------------------------------------------------
                                for n3 in [0, 2]:
                                    for n4 in range(
                                        0, self.beam.settings.max_bars_per_layer + 1
                                    ):
                                        # Ensure layer 2 bars are not more than layer 1 bars
                                        if n3 + n4 > n1 + n2:
                                            continue
                                        if n3 == 0 and n4 > 0:
                                            continue
                                        if (
                                            n3 + n4
                                            > self.beam.settings.max_bars_per_layer
                                        ):
                                            continue

                                        A_s_layer_2 = n3 * self.rebar_areas[d_b3] + (
                                            n4 * self.rebar_areas[d_b4]
                                            if n4 > 0
                                            else 0 * cm**2
                                        )

                                        if A_s_layer_1 < A_s_layer_2:
                                            continue
                                        if n4 > 0 and not self._check_spacing(
                                            n3, n4, d_b3, d_b4, effective_width
                                        ):
                                            continue

                                        total_as = A_s_layer_1 + A_s_layer_2
                                        if (
                                            total_as >= A_s_req
                                            and total_as <= max_limit
                                        ):
                                            total_bars = n1 + n2 + n3 + n4
                                            valid_combinations.append(
                                                {
                                                    "n_1": n1,
                                                    "d_b1": d_b1,
                                                    "n_2": n2,
                                                    "d_b2": d_b2 if n2 > 0 else None,
                                                    "n_3": n3,
                                                    "d_b3": d_b3 if n3 > 0 else None,
                                                    "n_4": n4,
                                                    "d_b4": d_b4 if n4 > 0 else None,
                                                    "total_as": total_as.to("cm**2"),
                                                    "total_bars": total_bars,
                                                    "clear_spacing": self._clear_spacing.to(
                                                        "mm"
                                                    ),
                                                }
                                            )
                                        else:
                                            if total_as > max_fallback_area:
                                                max_fallback_area = total_as
                                                best_fallback_combination = {
                                                    "n_1": n1,
                                                    "d_b1": d_b1,
                                                    "n_2": n2,
                                                    "d_b2": d_b2 if n2 > 0 else None,
                                                    "n_3": n3,
                                                    "d_b3": d_b3 if n3 > 0 else None,
                                                    "n_4": n4,
                                                    "d_b4": d_b4 if n4 > 0 else None,
                                                    "total_as": total_as.to("cm**2"),
                                                    "total_bars": n1 + n2 + n3 + n4,
                                                    "clear_spacing": self._clear_spacing.to(
                                                        "mm"
                                                    ),
                                                }

        # Convert valid combinations to DataFrame
        df = pd.DataFrame(valid_combinations)
        # Drop duplicate rows based on the specified columns
        df = df.drop_duplicates(
            subset=["n_1", "d_b1", "n_2", "d_b2", "n_3", "d_b3", "n_4", "d_b4"]
        )
        # If no valid combinations satisfy A_s_req, use the best fallback combination
        if df.empty and best_fallback_combination is not None:
            df = pd.DataFrame([best_fallback_combination])

        if self.A_s_req == 0*cm**2:
            modified_df: pd.DataFrame = df
            modified_df['d_b1'] = 0
        else:
            modified_df = self._calculate_penalties_long_rebar(df)
            # Sort by 'Functional' to sort by the best options
            modified_df.sort_values(by=["functional"], inplace=True)
            modified_df.reset_index(drop=True, inplace=True)

        self._long_combos_df = modified_df

        return modified_df.head(10)

    def longitudinal_rebar_EN_1992_2004(self, A_s_req: Quantity) -> None:
        self.longitudinal_rebar_ACI_318_19(A_s_req) #TODO WE HAVE TO CHANGE THIS

    def _estimate_mechanical_cover(self, row: pd.Series) -> "Quantity":
        """
        Estimate the total mechanical cover (bottom fiber to centroid of tension steel)
        based on the bar diameters and number of layers.
        """
        # first layer centroid = c_c + stirrup + 0.5 * db1
        cover = self.beam.c_c + self.beam._stirrup_d_b + 0.5 * row["d_b1"]

        # if there is a second layer, add one bar diameter + clear vertical spacing
        if (row["n_3"] + row["n_4"]) > 0:
            # conservative vertical gap between layers
            v_clear = max(self.beam.settings.layers_spacing, row["d_b1"])
            cover += row["d_b1"] + v_clear + 0.5 * (row["d_b3"] or row["d_b1"])
        return cover

    def _check_spacing(
        self,
        n1: int,
        n2: int,
        d_b1: Quantity,
        d_b2: Quantity,
        effective_width: Quantity,
    ) -> bool:
        """
        Checks the clear spacing between rebars in a layer.

        Parameters:
            n1 (int): Number of bars in the first group of the layer.
            n2 (int): Number of bars in the second group of the layer.
            d_b1 (Quantity): Diameter of bars in the first group of the layer.
            d_b2 (Quantity): Diameter of bars in the second group of the layer.
            effective_width (Quantity): The effective width available for bar placement.

        Returns:
            bool: True if the clear spacing satisfies the design limits, False otherwise.
        """

        def layer_clear_spacing(
            n_a: int, d_a: Quantity, n_b: int, d_b: Quantity
        ) -> Quantity:
            """
            Helper function to calculate clear spacing for a given layer.

            Parameters:
                n_a (int): Number of bars in the first group of the layer.
                d_a (Quantity): Diameter of bars in the first group of the layer.
                n_b (int): Number of bars in the second group of the layer.
                d_b (Quantity): Diameter of bars in the second group of the layer.

            Returns:
                Quantity: Clear spacing for the given layer.
            """
            total_bars = n_a + n_b
            if total_bars <= 1:
                return effective_width - max(d_a, d_b)  # Clear space for one bar
            total_bar_width = n_a * d_a + n_b * d_b
            return (effective_width - total_bar_width) / (total_bars - 1)

        # Calculate the clear spacing for the layer
        self._clear_spacing = layer_clear_spacing(n1, d_b1, n2, d_b2)

        # Determine the maximum clear spacing limit
        max_clear_spacing = max(
            self.beam.settings.clear_spacing,
            self.beam.settings.vibrator_size,
            max(d_b1, d_b2),
        )

        # Check if the clear spacing is within limits
        return self._clear_spacing >= max_clear_spacing

    def _check_diameter_differences(self, diameters: list) -> bool:
        """
        Checks that all diameter differences between bars in the list
        do not exceed max_diameter_diff.
        """
        for i, d1 in enumerate(diameters):
            for d2 in diameters[i + 1 :]:
                if abs(d1 - d2) > self.beam.settings.max_diameter_diff:
                    return False
        return True

    def _calculate_penalties_long_rebar(
        self,
        df: pd.DataFrame,
        alpha: float = 3.5,
        beta: float = 0.30,
        gamma: float = 0.25,
        delta: float = 1,
        epsilon: float = 0,  # No penalty for beams, just slabs
    ) -> pd.DataFrame:
        """
        Calculate penalties for rebar configurations and add them as columns to the DataFrame.

        Args:
            df (pd.DataFrame): The input DataFrame containing rebar configurations.
            alpha (float): Weight for the number of bars penalty.
            beta (float): Weight for the diameter difference penalty.
            gamma (float): Weight for the layer penalty.

        Returns:
            pd.DataFrame: The modified DataFrame with penalty columns and the final functional.
        """

        # Adjust penalty weights depending on element type
        if getattr(self, "mode", "beam") == "slab":
            # Slabs prefer many small bars → penalize large diameters and spacing more
            alpha *= 0.8  # reduce area weight (slabs have smaller demand differences)
            beta *= 0.1  # stronger sensitivity to number of bars
            gamma *= 0.5  # less penalty for multi-diameter use
            delta *= 0.5  # allow two layers if needed
            epsilon = 0.75  # strong penalty on large bar sizes

        # Calculate minimum bars and minimum area of steel
        min_bars = df["total_bars"].min()
        min_as = df["total_as"].min()

        # Diameter difference penalty function
        def diameter_difference_penalty(row: pd.Series) -> float:
            """
            Calculate the penalty for variation in bar diameters.

            Args:
                row (pd.Series): A row of the DataFrame.

            Returns:
                float: The standard deviation of the diameters (penalty).
            """
            diameters = []
            if row["n_1"] > 0:
                diameters.extend([row["d_b1"].magnitude] * row["n_1"])
            if row["n_2"] > 0:
                diameters.extend([row["d_b2"].magnitude] * row["n_2"])
            if row["n_3"] > 0:
                diameters.extend([row["d_b3"].magnitude] * row["n_3"])
            if row["n_4"] > 0:
                diameters.extend([row["d_b4"].magnitude] * row["n_4"])
            return np.std(diameters) if diameters else 0.0

        # Layer penalty function
        def layer_penalty(row: pd.Series) -> int:
            """
            Calculate the penalty for using additional layers of reinforcement.

            Args:
                row (pd.Series): A row of the DataFrame.

            Returns:
                int: 1 if additional layers are used, 0 otherwise.
            """
            # Check if any bars are in n_3, n_4, or future layers
            if row["n_3"] > 0 or row["n_4"] > 0:
                return 1  # Penalize if additional layers are used
            return 0  # No penalty if only the first layer is used

        # Calculate penalties and add them as columns
        df["area_penalty"] = df.apply(
            lambda row: (alpha * row["total_as"] / min_as), axis=1
        )
        # Prefer moderate bar counts, where very high or very low will be penalized
        df["bars_penalty"] = beta * ((df["total_bars"] - min_bars) / min_bars) ** 2
        df["diameter_penalty"] = gamma * df.apply(diameter_difference_penalty, axis=1)
        df["layer_penalty"] = delta * df.apply(layer_penalty, axis=1)

        # Diameter size penalty, for very large or very small
        min_d = (
            df[["d_b1", "d_b2", "d_b3", "d_b4"]]
            .stack()
            .dropna()
            .apply(lambda x: x.magnitude)
            .min()
        )
        df["diameter_size_penalty"] = epsilon * (
            df.apply(
                lambda r: (
                    max(
                        [
                            d.magnitude
                            for d, n in zip(
                                [r["d_b1"], r["d_b2"], r["d_b3"], r["d_b4"]],
                                [r["n_1"], r["n_2"], r["n_3"], r["n_4"]],
                            )
                            if n > 0 and d is not None
                        ]
                    )
                    / min_d
                    - 1
                ),
                axis=1,
            )
        )
        # Slab penalty for large spacing
        if getattr(self, "mode", "beam") == "slab":
            max_spacing_allowed = 300  # mm
            df["spacing_penalty"] = df["clear_spacing"].apply(
                lambda s: 0
                if s.magnitude <= max_spacing_allowed
                else (s.magnitude - max_spacing_allowed) / max_spacing_allowed
            )
        else:
            df["spacing_penalty"] = 0

        # Calculate the final functional
        df["functional"] = df.apply(
            lambda row: (
                row["area_penalty"]
                + row["bars_penalty"]
                + row["diameter_penalty"]
                + row["layer_penalty"]
                + row["diameter_size_penalty"]
                + row["spacing_penalty"]
            ),
            axis=1,
        )

        return df

    def longitudinal_rebar(
        self, A_s_req: Quantity, A_s_max: Quantity | None = None
    ) -> Dict[str, Any]:
        """
        Selects the appropriate longitudinal rebar method based on the design
        code.

        Args:
            A_s_req: Required longitudinal rebar area.
            A_s_max: Optional maximum allowable longitudinal rebar area.
        """
        if (
            self.beam.concrete.design_code == "ACI 318-19"
            or self.beam.concrete.design_code == "CIRSOC 201-25"
        ):
            return self.longitudinal_rebar_ACI_318_19(A_s_req, A_s_max)
        # elif self.beam.concrete.design_code=="EN 1992":
        #     return self.beam_longitudinal_rebar_EN_1992(A_s_req)
        # elif self.beam.concrete.design_code=="EHE-08":
        #     return self.beam_longitudinal_rebar_EHE_08(A_s_req)
        else:
            raise ValueError(
                f"Longitudinal design method not implemented \
                             for concrete type: {type(self.beam.concrete).__name__}"
            )

    # Factory method to select the longitudinal rebar method
    # def transverse_rebar(self, A_v_req: Quantity, V_s_req: Quantity) -> DataFrame:
    #     """
    #     Selects the appropriate transverse rebar method based on the design code.
    #     """
    #     if self.beam.concrete.design_code=="ACI 318-19":
    #         return self.transverse_rebar_ACI_318_19(A_v_req, V_s_req)
    #     # elif self.beam.concrete.design_code=="EN 1992":
    #     #     return self.beam_transverse_rebar_EN_1992(A_v_req, V_s_req)
    #     # elif self.beam.concrete.design_code=="EHE-08":
    #     #     return self.beam_transverse_rebar_EHE_08(A_v_req, V_s_req)
    #     else:
    #         raise ValueError(f"Shear design method not implemented \
    #                          for concrete type: {type(self.beam.concrete).__name__}")
