import math
from datetime import datetime
from copy import deepcopy
from typing import Union, Dict

import numpy as np
import pandas as pd

from recommendations.rdsap_tables import (
    epc_wall_description_map,
    wall_uvalues_df,
    default_wall_thickness,
    table_s9 as s9,
    table_s10 as s10,
    table_s11 as s11,
    table_s12 as s12,
)
from recommendations.config import (
    PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION,
    PARTIAL_CAVITY_DESCRIPTIONS,
)


def r_value_per_mm_to_u_value(depth_mm: int, r_value_per_mm: float):
    """
    Converts R-value per mm to U-value in W/m²K.

    Parameters
    ----------
    depth_mm : int
        Depth of the material in mm.
    r_value_per_mm : float
        R-value per mm.

    Returns
    -------
    float
        U-value in W/m²K.
    """
    return 1 / (depth_mm * r_value_per_mm)


def calculate_u_value_uplift(u_value, insulation_u_value):
    """
    Calculates the U-value uplift (improvement) when applying internal wall insulation to a wall.


    :param u_value: Float, Starting U-value of the wall (without insulation) in W/m²K.
    :param insulation_u_value: Float, U-value of the internal wall insulation in W/m²K.

    Returns:
        float: U-value uplift (improvement) achieved by applying internal wall insulation in W/m²K.

    Raises:
        ZeroDivisionError: If either u_value or iwi_u_value is zero.

    Notes:
        This function assumes 100% coverage of the internal wall insulation and does not account for other factors
        such as thermal bridging or the specific configuration of the wall.
    """

    inverse_u_value = 1 / u_value
    inverse_insulation_u_value = 1 / insulation_u_value

    inverse_u_total = inverse_u_value + inverse_insulation_u_value
    new_u_value = 1 / inverse_u_total

    u_value_uplift = u_value - new_u_value

    return u_value_uplift, new_u_value


def is_diminishing_returns(
    recommendations, new_u_value, lowest_selected_u_value, diminishing_returns_u_value
):
    """
    What are defines diminishing returns?
    1) The new u value is lower than the lowest selected u value
    2) The new u value is below the diminishing returns threshold
    3) We already have some recommendations so there is no need to
       insert another recommendation in
    """

    # if we don't have anything selected, lowest_selected_u_value will be missing
    if lowest_selected_u_value is None:
        if recommendations:
            raise ValueError("Recommendations should be empty - investigate")
        # This means that nothing has been selected yet
        # the new u value is less than the threshold, however this MIGHT be the only
        # solution and so we consider it
        return False

    # We should already have recommendations
    if not recommendations:
        raise ValueError("Recommendations should not be empty - investigate")

    # We already have a solution that is suitable so we want to make sure that
    # any new solutin actually has a higher u-value as it will either be
    # 1) cheaper
    # 2) thinner with a more efficient material
    is_diminishing = (new_u_value < diminishing_returns_u_value) and (
        new_u_value < lowest_selected_u_value
    )

    return is_diminishing


def update_lowest_selected_u_value(lowest_selected_u_value, new_u_value):
    """
    Utility funciton which holds the logic for how we update the lowest selected u value
    :param lowest_selected_u_value:  current lowest selected u value, initialised as None
    :param new_u_value: new u value to compare against
    :return:
    """
    if lowest_selected_u_value is None:
        lowest_selected_u_value = new_u_value

    if new_u_value <= lowest_selected_u_value:
        lowest_selected_u_value = new_u_value

    return lowest_selected_u_value


def get_recommended_part(part, cost_result, quantity, quantity_unit):
    """
    Utility function to return a recommended part with the selected depth.
    :param part: part to be recommended
    :param cost_result: Total cost of the selected part, as returned by the Cost class
    :param quantity: Quantity of the selected part
    :param quantity_unit: Unit of the quantity
    :return:
    """
    recommended_part = deepcopy(part)
    recommended_part["quantity"] = quantity
    recommended_part["quantity_unit"] = quantity_unit

    recommended_part.update(cost_result)

    return recommended_part


def apply_formula_s_5_1_1(is_granite_or_whinstone, is_sandstone_or_limestone, age_band):
    """
    As the u-value table in https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf
    on page 19, certain u-values as indicated by an "a", should be populated using a formula as defined in section
    S.5.1.1
    """

    stone_wall_thickness = [x for x in default_wall_thickness if x["type"] == "stone"][
        0
    ]

    thickness = (
        stone_wall_thickness["J_K_L"]
        if age_band in ["J", "L", "L"]
        else stone_wall_thickness[age_band]
    )

    if is_granite_or_whinstone:
        return 3.3 - 0.002 * thickness

    if is_sandstone_or_limestone:
        return 3 - 0.002 * thickness

    raise ValueError(
        "This should only be called when is_granite_or_whinstone or is_sandstone_or_limestone is True"
    )


def get_wall_u_value(
    clean_description, age_band, is_granite_or_whinstone, is_sandstone_or_limestone
):
    """
    Given some features about a wall, this function will query the wall u-value table and return the u-value
    :param clean_description:  Cleaned up description of the wall from the EPC data
    :param age_band: age band of the property from the EPC data
    :param is_granite_or_whinstone: Boolean indicating if the wall is made of granite or whinstone
    :param is_sandstone_or_limestone: Boolean indicating if the wall is made of sandstone or limestone
    :return:
    """

    if clean_description in PARTIAL_CAVITY_DESCRIPTIONS:
        # If we have a partial cavity fill, we linearly interpolate the u-value. This isn't necessarily the perfect
        # method and how we do this should be explored, however we want to distinguish between the old
        filled_uvalue = float(
            wall_uvalues_df[wall_uvalues_df["Wall_type"] == "Filled cavity"][
                age_band
            ].values[0]
        )
        unfilled_uvalue = float(
            wall_uvalues_df[wall_uvalues_df["Wall_type"] == "Cavity as built"][
                age_band
            ].values[0]
        )

        mapped_value = str(
            unfilled_uvalue
            - (
                PARTIALLY_FILLED_PERCENTAGE_ASSUMPTION
                * (unfilled_uvalue - filled_uvalue)
            )
        )
    else:

        # Handle rare edge case
        if clean_description == "":
            return 0

        mapped_description = epc_wall_description_map[clean_description]

        mapped_value = wall_uvalues_df[
            wall_uvalues_df["Wall_type"] == mapped_description
            ][age_band].values[0]

        if pd.isnull(mapped_value) and "Park home" in mapped_description:
            # We don't know enough in this case so we default to 0
            return 0

    if mapped_value == "a":
        # The rdSap documentation indicateswe should use a formula to calculate the u-value
        return float(
            apply_formula_s_5_1_1(
                is_granite_or_whinstone=is_granite_or_whinstone,
                is_sandstone_or_limestone=is_sandstone_or_limestone,
                age_band=age_band,
            )
        )

    if "b" in mapped_value:
        potential_uvalue = float(mapped_value.replace("b", ""))
        formula_uvalue = float(
            apply_formula_s_5_1_1(
                is_granite_or_whinstone=is_granite_or_whinstone,
                is_sandstone_or_limestone=is_sandstone_or_limestone,
                age_band=age_band,
            )
        )
        return min(potential_uvalue, formula_uvalue)

    if mapped_value == "s1.1.2":
        # We don't know enough in this case so we default to 0
        return 0

    return float(mapped_value)


def _try_convert_to_int(value):
    try:
        return int(value)
    except (TypeError, ValueError):
        return None


def extract_thickness(thickness, is_roof_room, is_at_rafters, is_loft, is_flat):
    thickness_map = {
        "below average": "50",
        "average": "100",
        "above average": "150",
        "none": "0",
    }

    # Normalise none value early
    if thickness is None:
        thickness = "none"

    if is_roof_room or is_at_rafters:

        int_thickness = _try_convert_to_int(thickness)
        if int_thickness is not None:
            return int_thickness
        # We re-map the thickness

        thickness = thickness_map.get(thickness)
        if thickness is None:
            return None

        return int(thickness)

    if is_flat:
        return _try_convert_to_int(thickness)

    # Thicknes will never be none
    if thickness in thickness_map or (
        not (is_loft or is_roof_room or is_at_rafters)
    ):
        return None

    if isinstance(thickness, str) and str(thickness).endswith("+"):
        return _try_convert_to_int(thickness[:-1])

    # final attempt
    return _try_convert_to_int(thickness)


def get_u_value_from_s9(
    thickness, s9, is_loft, is_roof_room, is_thatched, is_at_rafters
):
    """Get the U-value from table S9 based on the insulation thickness."""

    if thickness in ["below average", "average", "above average", "none", None] or (
        not is_loft and not is_roof_room and not is_at_rafters
    ):
        return None

    if thickness in [0, "0"] and (is_loft or is_roof_room):
        return None

    # Determine the column to refer based on the roof type
    column = (
        "Thatched_roof_U_value_W_m2K"
        if is_thatched
        else "Slates_or_tiles_U_value_W_m2K"
    )

    if thickness in [0, "0"] and is_roof_room:
        return s9[pd.isnull(s9["Insulation_thickness_mm"])][column].iloc[0]
    else:
        # Get the correct U-value based on the insulation thickness
        return s9[s9["Insulation_thickness_mm"] >= thickness][column].iloc[0]


def get_roof_u_value(
    insulation_thickness,
    has_dwelling_above,
    is_loft,
    is_roof_room,
    is_thatched,
    age_band,
    is_flat,
    is_pitched,
    is_at_rafters,
    **kwargs,
):
    """
    Determine the U-value for a roof based on the description dictionary and age band.

    We use table s9 is the insulation thickness was measured, otherwise we use table s10.

    The methodology for this process can be found in page 23 of the BRE rdsap 2012 document found here:
    https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf

    Parameters:
    insulation_thickness (str): contains description of the insulation thickness - may be missing
    has_dwelling_above (bool): Indicates if there is a property above
    is_loft (bool): Indicates if ther oof has a loft
    is_roof_room (bool): Indicates if there is a room in roof
    is_thatched (bool): Indicates if the roof is thatched
    is_flat (bool): Indicates if the roof is flat
    is_pitched (bool): Indicates if the roof is pitched
    is_at_rafters (bool): Indicates if there is insulation at the rafters of the roof
    age_band (str): The age band of the property.
    s9 (pd.DataFrame): The DataFrame representing table S9.
    s10 (pd.DataFrame): The DataFrame representing table S10.

    Returns:
    float: The determined U-value.
    """

    # If there is a dwelling above, the U-value is 0
    if has_dwelling_above:
        return 0.0

    thickness = extract_thickness(
        thickness=insulation_thickness,
        is_roof_room=is_roof_room,
        is_at_rafters=is_at_rafters,
        is_loft=is_loft,
        is_flat=is_flat,
    )

    # Step 1: Try to get the U-value from table S9 based on the insulation thickness
    # The conditions for using table S9 are:
    # - The insulation thickness is known
    # - The roof is either a loft or a roof room
    # The criteria for using this table is predominately defined by insulation around joists which is predominately
    # a feature of lofts and roof rooms
    u_value = get_u_value_from_s9(
        thickness=thickness,
        s9=s9,
        is_loft=is_loft,
        is_roof_room=is_roof_room,
        is_thatched=is_thatched,
        is_at_rafters=is_at_rafters,
    )

    if u_value is not None:
        return u_value

    # Step 2: If the U-value could not be determined from table S9, use table S10

    # Define the columns to be used based on the description details
    if is_flat:
        column = "Flat_roof"
    elif is_thatched:
        if is_roof_room:
            column = "Thatched_roof_room_in_roof"
        else:
            column = "Thatched_roof"
    elif is_roof_room:
        column = "Room_in_roof_slates_or_tiles"
    elif is_pitched:
        if is_at_rafters:
            column = "Pitched_slates_or_tiles_insulation_at_rafters"
        else:
            column = "Pitched_slates_or_tiles_insulation_between_joists_or_unknown"
    else:
        # Default to pitched roof with insulation between joists or unknown
        column = "Pitched_slates_or_tiles_insulation_between_joists_or_unknown"

    # Get the U-value from table S10 based on the age band and the determined column
    if is_flat and thickness is not None:
        u_value = s10.loc[
            (s10["Insulation_Thickness"] == thickness)
            | s10["Age_band"].str.contains(age_band),
            column,
        ].values.min()
    else:
        u_value = s10.loc[s10["Age_band"].str.contains(age_band), column].values[0]

    u_value = float(u_value)

    # As per the documentation here: https://bregroup.com/documents/d/bre-group/rdsap_2012_9-94-20-09-2019
    # Table s.10
    # "The value from the table applies for unknown and as built. If the roof is known to have more insulation than
    # would normally be expected for the age band, either observed or on the basis of documentary evidence, use the
    # lower of the value in the table and:
    # 50 mm insulation 0.68
    # 100 mm insulation: 0.40
    # 150 mm or more insulation: 0.30"
    if thickness is not None:
        if thickness == 50:
            u_value = min(u_value, 0.68)
        if thickness == 100:
            u_value = min(u_value, 0.40)
        if thickness >= 150:
            u_value = min(u_value, 0.30)

    return u_value


def estimate_number_of_floors(property_type):
    """
    Using the property type, we estimate the number of floors in the property
    """

    if property_type is None:
        return None

    if property_type == "House":
        number_of_floors = 2
    elif property_type in ["Flat", "Bungalow"]:
        number_of_floors = 1
    elif property_type == "Maisonette":
        number_of_floors = 2
    else:
        raise NotImplementedError("Implement me")

    return number_of_floors


def estimate_perimeter(floor_area, num_rooms):
    """
    Uses a basic methodology to attempt to estimate perimeter. Works better for
    :param floor_area: floor area of the home
    :param num_rooms: number of rooms in the home
    :return: estimated perimeter
    """
    if floor_area < 0:
        raise ValueError("Floor area cannot be negative.")
    if num_rooms <= 0:
        raise ValueError("Number of rooms must be greater than zero.")
    # Compute average room size based on total floor area and number of rooms
    avg_room_size = floor_area / num_rooms

    # Estimate the side length of a square room with the average room size
    avg_room_side_length = math.sqrt(avg_room_size)

    # Estimate total side length assuming rooms are lined up in a row
    total_side_length = avg_room_side_length * num_rooms

    # Estimate the length and width of the property assuming it is rectangular
    length = total_side_length / 2
    width = floor_area / length

    # Compute the perimeter of the property
    perimeter = 2 * (length + width)

    return perimeter


def get_exposed_floor_uvalue(insulation_thickness_str: None | str, age_band: str) -> float:
    """
    We implement the methodology as defined in section 5.6 and table S12 of the RdSAP document
    :param insulation_thickness_str: Insulation thickness as defined in the EPC data
    :param age_band: Age band of the property
    :return:
    """

    unknown_insulation_age_bands = ["A", "B", "C", "D", "E", "F", "G", "H", "I"]
    # As directed by the documentation, if the insulation thickness is not known, we assume it's
    # 50mm for these age bands
    if insulation_thickness_str in ["below average", "average", "above average"] and (
        age_band in unknown_insulation_age_bands
    ):
        insulation_thickness = 50
    elif insulation_thickness_str in ["none", None]:
        insulation_thickness = 0
    elif insulation_thickness_str == "below average":
        insulation_thickness = 50
    elif insulation_thickness_str == "average":
        insulation_thickness = 100
    elif insulation_thickness_str == "above average":
        insulation_thickness = 150
    else:
        insulation_thickness = int(insulation_thickness_str.replace("mm", ""))

    filtered = s12[s12["age_band"] == age_band][
        f"insulation_{insulation_thickness}"
    ]

    if filtered.empty:
        # We don't have data so we use the median value
        return float(s12[f"insulation_{insulation_thickness}"].median())

    return float(filtered.values[0])


def get_floor_u_value(
    floor_type, area, perimeter, age_band, wall_type, insulation_thickness=None
):
    """
    Estimate the u-value of a suspended floor, based on RdSap methodology
    Default U-value for UNINSULATED suspended floor, based on RdSAP methodology
    https://files.bregroup.com/bre-co-uk-file-library-copy/filelibrary/SAP/2012/RdSAP-9.93/RdSAP_2012_9.93.pdf

    w = wall thickness, where these estimates are based on the RD SAP methodology, as in table S3
    A = floor area
    Exposed perimeter = P
    soil type clas thermal conductivity lambda_g = 1.5 W/mK
    Rsi = 0.17m^2K/W
    Rse = 0.04m^2K/W
    Rf = 0.001 * d_ins / 0.035 where d_ins is the insulation thickness in mm
    height above external ground h = 0.3m
    average wind speed at 10m height v=5m/s
    wind sheilding factor fw = 0.05
    vantilation factor E = 0.003 m^2/m
    U-value of walls to underfloor space Uw = 1.5 W/m^2K

    # Calulations for suspended ground floors, example for 5 bedroom house with permiter estimated at
    44.36214602563767
    1) dg = w + lambda_g x (Rsi + Rse) =  0.5 + 1.5 * (0.17 + 0.04) = 0.615
    2) B = 2 * A/P = 2 * 123.0 / 44.36214602563767 = 5.545268253204708
    3) Ug = 2 * lambda_g * log(pi * B/dg + 1)/(pi * B + dg) =
          2 * 1.5  * log(3.141592653589793 * 5.545268253204708/0.615 + 1) / (3.141592653589793 * 5.545268253204708
          + 0.615) = 0.5619604457160708
    4) Ux = (2 * h * Uw /B) + (1450 * E * v * fw/B) = (2 * 0.3 * 1.5 / 5.545268253204708) + (1450 * 0.003 * 5 *
          0.05/5.545268253204708) = 0.35841367978030436
    5) U = 1/ (2 * Rsi + Rf + 1/(Ug + Ux)) = 1 / (2 * 0.17 + 0 + 1/(0.5619604457160708 + 0.35841367978030436)) =
       0.701
    """

    if floor_type == "exposed_floor":
        # In this case, we extract the u-value from table s12
        # See section 5.6 of the RdSAP document for more details
        # https://bregroup.com/wp-content/uploads/2019/09/RdSAP_2012_9.94-20-09-2019.pdf
        return get_exposed_floor_uvalue(insulation_thickness, age_band)

    # Cleans our regularly inputted insulation thickness for usage in this function
    insulation_thickness = extract_insulation_thickness(insulation_thickness)

    # Define constants
    lambda_g = 1.5  # thermal conductivity of soil in W/m·K
    Rsi = 0.17  # in m²K/W
    Rse = 0.04  # in m²K/W
    lambda_ins = 0.035  # thermal conductivity of floor insulation in W/m·K
    wall_thickness = [
        x[age_band] for x in default_wall_thickness if x["type"] == wall_type
    ]
    if not wall_thickness:
        # In some cases, we may estimate an EPC and end up with a slightly mixed EPC, with some fields associated
        # to a new build and others to an existing. So we might end up with a None wall type here, because of this.
        # If this happens, nothing will be in the wall_thickness list so this is the fallback, the defauly thickness
        # for many EPC assessment systems like Elmhurst
        wall_thickness = 300
    else:
        wall_thickness = wall_thickness[0]
    if wall_thickness is None and wall_type == "park home":
        # We don't know enough and likely won't make recommendations
        return 0
    wall_thickness = wall_thickness / 1000

    if insulation_thickness is None:
        insulation_lookup = s11[
            s11["Age_band"].str.contains(age_band) & s11["Floor_construction"]
            == floor_type
            ]
        if insulation_lookup.empty:
            insulation_thickness = 0
        else:
            insulation_thickness = insulation_lookup["England_Wales"].values[0]

    # Calculate Rf for insulated floors
    Rf = 0.001 * insulation_thickness / lambda_ins

    # Calculate B
    B = 2 * area / perimeter

    if floor_type == "solid":
        # Calculate dt
        dt = wall_thickness + lambda_g * (Rsi + Rf + Rse)

        # Calculate U value based on dt and B
        if dt < B:
            U = 2 * lambda_g * math.log(math.pi * B / dt + 1) / (math.pi * B + dt)
        else:
            U = lambda_g / (0.457 * B + dt)

    elif floor_type == "suspended":
        # Define additional constants for suspended floors
        h = 0.3  # height above external ground level in meters
        v = 5  # average wind speed at 10 m height in m/s
        fw = 0.05  # wind shielding factor
        epsilon = 0.003  # ventilation openings per m exposed perimeter in m²/m
        Uw = 1.5  # U-value of walls to underfloor space in W/m²K

        # Calculate dg
        dg = wall_thickness + lambda_g * (Rsi + Rse)

        # Calculate Ug and Ux
        Ug = 2 * lambda_g * math.log(math.pi * B / dg + 1) / (math.pi * B + dg)
        Ux = (2 * h * Uw / B) + (1450 * epsilon * v * fw / B)

        # Calculate final U value for suspended floors
        if insulation_thickness > 0:
            Rf += 0.2  # adding thermal resistance of floor deck
        else:
            Rf = 0.2  # thermal resistance of uninsulated floor deck

        U = 1 / (2 * Rsi + Rf + 1 / (Ug + Ux))
    else:
        raise ValueError(
            "Invalid floor type. Acceptable values are 'solid' or 'suspended'."
        )

    return round(U, 2)  # rounding U value to two decimal places


def extract_insulation_thickness(insulation_thickness_str):
    """
    Converts insulation thickness to a float
    :param insulation_thickness_str:
    :return:
    """
    if insulation_thickness_str in [
        "none",
        "average",
        "below average",
        "above average",
        None,
    ]:
        return None

    if isinstance(insulation_thickness_str, (float, int)):
        return insulation_thickness_str

    return int(insulation_thickness_str.replace("mm", ""))


def get_wall_type(
    is_cavity_wall,
    is_solid_brick,
    is_granite_or_whinstone,
    is_sandstone_or_limestone,
    is_timber_frame,
    is_cob,
    is_system_built,
    is_park_home,
    **kwargs,
) -> Union[str, None]:
    """
    Converts booleans to a string wall type, for querying the wall thickness table
    :return:
    """
    if is_cavity_wall:
        return "cavity"

    if is_solid_brick:
        return "solid brick"

    if is_granite_or_whinstone or is_sandstone_or_limestone:
        return "stone"

    if is_timber_frame:
        return "timber frame"

    if is_cob:
        return "cob"

    if is_system_built:
        return "system build"

    if is_park_home:
        return "park home"

    return None


def estimate_external_wall_area(num_floors, floor_height, perimeter, built_form):
    """
    This method estimates the external wall area based on fundamental assumptions about the home


    :param num_floors: Number of floors in the building.
    :param floor_height: Height of one floor in meters.
    :param perimeter: Total perimeter of the building on one floor in meters.
    :param built_form: The built form of the property. This is used to determine the number of exposed walls.
    :return:
    """
    wall_area_one_floor = perimeter * floor_height

    total_wall_area = wall_area_one_floor * num_floors

    number_exposed_walls = {
        "End-Terrace": 3,
        "Mid-Terrace": 2,
        "Semi-Detached": 3,
        "Detached": 4,
    }

    exposed_wall_area = total_wall_area * (number_exposed_walls.get(built_form, 3) / 4)

    return exposed_wall_area


def calculate_r_value_per_mm(thickness_mm, thermal_conductivity_w_mK):
    """
    # Calculate R-value (thermal resistance) using the formula: R = thickness / thermal_conductivity
    # Note: The thickness should be converted to meters for the units to be consistent.
    :param thickness_mm:
    :param thermal_conductivity_w_mK:
    :return:
    """

    if thermal_conductivity_w_mK is None:
        return None

    r_value_m2k_w = (thickness_mm / 1000) / thermal_conductivity_w_mK

    # Calculate R-value per mm
    r_value_per_mm = r_value_m2k_w / thickness_mm

    return r_value_per_mm


def convert_thickness_to_numeric(string_thickness, is_pitched, is_flat):
    """
    Roof insulation thickness could be a string like "None", "300mm+" or a numeric string.
    This function will convert these strings to a number for easy usage

    we handle loft insulation differently to flat roof or room in roof insulation, since for loft insulation,
    we are presented with an insulation thickness, whereas for the other forms of roof, we are just told whether or not
    the roof is insulated or not.

    :param string_thickness: string measure of insulation thickness
    :param is_pitched: boolean indicating if the roof is a pitched roof
    :return: integer measure of insulation thickness
    """

    if string_thickness is None:
        return 0

    if is_pitched:
        lookup = {"none": 0, "below average": 50, "average": 100, "above average": 270}
    elif is_flat:
        # For a flat roof, if it's below average, we assume it's 0 and requires a re-roof
        lookup = {"none": 0, "below average": 0, "average": 100, "above average": 150}
    else:
        lookup = {"none": 0, "below average": 100, "average": 270, "above average": 270}

    mapped = lookup.get(string_thickness)

    if mapped is not None:
        return mapped

    if "+" in string_thickness:
        return int(string_thickness.replace("+", ""))

    return int(string_thickness)


def estimate_pitched_roof_area(floor_area: float) -> float:
    """
    This function mimics the methodology for calculating floor area in Elmhurst, so that we can simulate the outcomes
    in a way that is consistent with the Elmhurst methodology.

    :param floor_area:      area of the home's floor
    :return:                Numerical estimate of the surface area of the top of the pitched roof
    """

    scalar = 1.0571283428862048
    return scalar * (floor_area / np.cos(np.radians(30)))


def estimate_windows(
    property_type, built_form, construction_age_band, floor_area, number_habitable_rooms
):
    # If there is an extension, that will boost the number of habitable rooms

    # Base window count based on habitable rooms
    window_count = number_habitable_rooms

    # Additional windows for non-habitable rooms (e.g., kitchen, bathroom)
    # Assuming most houses will have at least one kitchen and one bathroom
    # Scale non-habitable windows with the number of habitable rooms
    non_habitable_base = 2  # Base for kitchen and bathroom
    extra_non_habitable = max(
        0, (number_habitable_rooms - 3) // 2
    )  # Extra for large houses
    window_count += non_habitable_base + extra_non_habitable

    # Adjustments based on built form and property type
    if property_type in ["House", "Bungalow"] and built_form in [
        "Semi-Detached",
        "Detached",
    ]:
        built_form_lookup = {
            "Semi-Detached": 3,
            "Detached": 4,
        }
    else:
        # For Flats and Maisonettes, adjustments might be less
        built_form_lookup = {
            "Mid-Terrace": 0,
            "End-Terrace": 1,
            "Semi-Detached": 1,
            "Detached": 2,
        }
    window_count += built_form_lookup.get(built_form, 0)

    # Adjust for floor area (larger floor area might indicate more rooms/windows)
    if floor_area < 85:  # Small to medium properties
        # Standard window count likely sufficient
        pass
    elif 85 <= floor_area <= 120:  # Medium to large properties
        # More rooms or larger rooms likely, potentially more windows
        window_count += 1
    elif floor_area > 120:  # Very large properties
        # Likely to have significantly more or larger rooms
        window_count += 2

    # Adjust for construction age band
    if construction_age_band in [
        "England and Wales: before 1900",
        "England and Wales: 1900-1929",
    ]:
        # Older houses with smaller, more numerous windows
        window_count += 1

    # Adjustments for specific property types
    if property_type in ["Flat", "Maisontte"]:
        # Flats might have fewer windows due to shared walls
        # Maisonettes might follow a similar pattern to flats or small houses
        window_count -= 1

    # Ensure window count is not negative
    if window_count < 0:
        raise ValueError("Window count cannot be negative.")

    return window_count


def calculate_cavity_age(newest_epc, older_epcs, cleaned):
    all_epcs = [newest_epc] + older_epcs

    df = []
    for x in all_epcs:
        # Get the cleaned mapping
        mapped = [
            y
            for y in cleaned["walls-description"]
            if y["original_description"] == x["walls-description"]
        ]
        if not mapped:
            continue
        df.append(
            {
                **mapped[0],
                "inspection-date": x["lodgement-date"],
            }
        )

    df = pd.DataFrame(df)
    df = df[df["is_cavity_wall"] & df["is_filled_cavity"]]

    cavity_age = (datetime.now() - pd.to_datetime(df["inspection-date"].max())).days
    return cavity_age


def check_simulation_difference(
    old_config, new_config, prefix="", keys_with_prefix=None
):
    """
    Given two dictionaries, that describe the heating control configurations, this method will compare the two
    and pick out the differences. These differences will be things that have been added and things that have been
    removed. This will be used to determine how we should be updating the configuration in the simulation
    :return:
    """

    keys_with_prefix = (
        ["is_assumed", "thermal_transmittance", "insulation_thickness"]
        if keys_with_prefix is None
        else keys_with_prefix
    )

    differences = {}
    for key in new_config:
        if old_config[key] != new_config[key]:
            new_key = (
                prefix + key + "_ending" if key in keys_with_prefix else key + "_ending"
            )
            differences[new_key] = new_config[key]

    return differences


def override_costs(costs):
    """
    If the method is overridden, we want to make sure that the costs are zero. This function sets the costs to zero
    :param costs: Dictionary of costing, as returned by the Costs class
    :return:
    """
    for k in costs:
        costs[k] = 0

    return costs


def combine_recommendation_configs(recommendation_config1, recommendation_config2):
    """
    Given two simulation configs, this function will combine them into one
    :param recommendation_config1:
    :param recommendation_config2:
    :return:
    """
    # Efficiency values - keys which contain _energy_eff_ending
    eff_1 = {
        k: v
        for k, v in recommendation_config1.items()
        if ("_energy_eff_ending" in k) or ("-energy-eff" in k)
    }
    eff_2 = {
        k: v
        for k, v in recommendation_config2.items()
        if ("_energy_eff_ending" in k) or ("-energy-eff" in k)
    }

    # We combine the simulation configs
    combined = {**recommendation_config1, **recommendation_config2}

    # Find overlapping keys
    overlapping_keys = set(eff_1.keys()).intersection(set(eff_2.keys()))
    if overlapping_keys:
        # We make sure we take the best value - map efficiency values to numbers
        numerical_embedding = {
            "Very poor": 1,
            "Poor": 2,
            "Average": 3,
            "Good": 4,
            "Very good": 5,
        }
        for key in overlapping_keys:
            if numerical_embedding[eff_1[key]] >= numerical_embedding[eff_2[key]]:
                combined[key] = eff_1[key]
            else:
                combined[key] = eff_2[key]

    return combined


def check_use_survey(non_invasive_recommendations: Dict[str, bool], has_been_remodelled: bool):
    """
    Determines if we should use a survey SAP points or not
    :return: 
    """

    use_survey = (
        non_invasive_recommendations.get("survey", False) if not
        has_been_remodelled else False
    )
    return use_survey
