import time
import requests
import pandas as pd
import numpy as np
from typing import List
from functools import lru_cache
from sklearn.preprocessing import MinMaxScaler
from tqdm import tqdm
from math import sin, cos, sqrt, atan2, radians

from utils.logger import setup_logger
from recommendations.Costs import Costs
from backend.ml_models.AnnualBillSavings import AnnualBillSavings
from backend.Property import Property
from backend.app.db.functions.solar_functions import get_solar_data, store_batch_data
import backend.app.assumptions as assumptions
from backend.app.plan.schemas import PlanTriggerRequest

logger = setup_logger()


class GoogleSolarApi:
    NORTH_FACING_AZIMUTH_RANGE = (-30, 30)

    # These are variables, described in the documentation for cost analysis for non-us locations, seen here
    # https://developers.google.com/maps/documentation/solar/calculate-costs-non-us
    # We use the default figures that the API uses for US locations

    # The factor by which the cost of electricity increases annually. The Solar API uses 1.022 (2.2% annual increase)
    # for US locations.
    cost_increase_factor = 1.022

    # The efficiency at which an inverter converts the DC electricity that is produced by the solar panels to the AC
    # electricity that is used in a household. The Solar API uses 85% for US locations. We use 0.95.5 which is the
    # middle value of the 93-98% range, cited by Sunsave:
    # https://www.sunsave.energy/solar-panels-advice/system-size/inverters
    dc_to_ac_rate = 0.955

    # The Solar API uses 1.04 (4% annual increase) for US locations
    discount_rate = 1.04

    # How much the efficiency of the solar panels declines each year. The Solar API uses 0.995 (0.5% annual decrease)
    # for US locations
    efficiency_depreciation_factor = 0.995

    # The expected lifespan of the solar installation. The Solar API uses 20 years. Adjust this value as needed for
    # your area
    installation_life_span = 20

    MIN_UNIT_PANELS = 4  # Minimum number of panels we allow for a domestic building
    MIN_BUILDING_PANELS = 10  # Minimum number of panels we allow for a block of flats

    # Max area of a roof space we allow panels for
    PERCENTAGE_OF_ROOF_LIMIT = 0.8

    # If the roof area that comes back from the solar API is more than 25% larger than the estiamted roof area
    # that we calcualte based on the property dimensions, we will correct the roof area
    ROOF_AREA_TOLERANCE = 1.25

    # Error Messages
    ENTITY_NOT_FOUND_ERROR = 'Requested entity was not found.'

    def __init__(self, api_key, solar_materials: list, max_retries=5):
        """
        Initialize the GoogleSolarApi class with the provided API key and maximum retries.

        :param api_key: The API key to authenticate requests to the Google Solar API.
        :param max_retries: The maximum number of retries for the API request (default is 5).
        """
        self.api_key = api_key
        self.max_retries = max_retries
        self.base_url = "https://solar.googleapis.com/v1"

        self.insights_data = None
        self.roof_segments = []

        # property attributes:
        self.floor_area = None
        self.roof_area = None
        self.panel_area = assumptions.RDSAP_AREA_PER_PANEL
        self.panel_wattage = None
        self.panel_performance = None

        # Indicates if we need to store the data to the db
        self.need_to_store = False

        # Indicates if we think we have both units attached to a semi-detached property
        self.double_property = False
        self.solar_materials = solar_materials

        self.allowed_segment_indices = None

    def get_building_insights(self, longitude, latitude, required_quality="MEDIUM", max_retries=None):
        """
        Make an API request to retrieve building insights based on the given longitude and latitude, with retry
        mechanism.

        :param longitude: The longitude of the location.
        :param latitude: The latitude of the location.
        :param required_quality: The required quality of the data (default is "MEDIUM").
        :param max_retries: The maximum number of retries for the API request (default is None, which uses the
        instance's max_retries).
        :return: The JSON response containing the building insights data.
        """
        if max_retries is None:
            max_retries = self.max_retries

        insights_url = f"{self.base_url}/buildingInsights:findClosest"
        params = {
            'location.latitude': f'{latitude:.5f}',
            'location.longitude': f'{longitude:.5f}',
            'requiredQuality': required_quality,
            'key': self.api_key
        }

        attempt = 0
        while attempt < max_retries:
            try:
                response = requests.get(insights_url, params=params)
                response.raise_for_status()  # Raise an error for bad status codes
                return response.json()
            except requests.exceptions.RequestException as e:
                if (
                    (e.response.status_code == 404) &
                    (e.response.json()["error"]["message"] == self.ENTITY_NOT_FOUND_ERROR)
                ):
                    logger.warning("No building insights found for the given location.")
                    return {"error": self.ENTITY_NOT_FOUND_ERROR}

                attempt += 1
                print(f"Attempt {attempt} failed: {e}")
                time.sleep(2 ** attempt)  # Exponential backoff
                if attempt >= max_retries:
                    raise

    @lru_cache(maxsize=128)
    def get(
        self,
        longitude,
        latitude,
        energy_consumption,
        property_instance=None,
        required_quality="MEDIUM",
        is_building=False,
        session=None,
        uprn=None,
    ):
        """
        Wrapper function that calls get_building_insights and extracts roof segments, with caching.

        :param longitude: The longitude of the location.
        :param latitude: The latitude of the location.
        :param energy_consumption: The energy consumption of the building/unit associated to the longitude and latitude,
                                    that we wish to size the solar panels up against
        :param property_instance: The property instance associated to the longitude and latitude.
        :param required_quality: The required quality of the data (default is "MEDIUM").
        :param is_building: Whether the energy consumption is for a building or a unit.
        :param session: The database session to use for the query (default is None).
        :param uprn: The unique property reference number (default is None).
        :return: The JSON response containing the building insights data.
        """

        is_outdated = False
        if session is not None:
            # Check if the data is already in the database
            self.insights_data, _, is_outdated = get_solar_data(
                session, longitude=longitude, latitude=latitude, uprn=uprn
            )

        # If we have no data in the db, or updated_at is more than 6 months
        if self.insights_data is None or is_outdated:
            self.insights_data = self.get_building_insights(longitude, latitude, required_quality)
            if self.insights_data.get("error") == self.ENTITY_NOT_FOUND_ERROR:
                # We use default performance since in this case, we couldn't retrieve data. We don't store
                self.panel_performance = self.default_panel_performance(property_instance=property_instance)
                return
            self.need_to_store = True

        # Extract key data from the insights response
        self.roof_segments = self.insights_data["solarPotential"].get('roofSegmentStats', [])
        # Automatically exclude north-facing segments
        self.exclude_north_facing_segments(property_instance=property_instance)
        # If a property is semi-detached, it's possible for us to include segments from an attached unit
        if property_instance is not None:
            if (property_instance.epc_record.built_form == "Semi-Detached") and (
                property_instance.epc_record.extension_count == 0
            ):
                self.exclude_likely_duplicate_surfaces()

        # We constrain the roof area, based on the floor area to be more conservative
        self.roof_area = self.insights_data["solarPotential"]["wholeRoofStats"]['areaMeters2']
        if (
            self.roof_area > property_instance.roof_area * self.ROOF_AREA_TOLERANCE
        ) | (self.roof_area < (2 - self.ROOF_AREA_TOLERANCE) * property_instance.roof_area):
            self.roof_area = property_instance.roof_area

        self.floor_area = self.insights_data["solarPotential"]["wholeRoofStats"]['groundAreaMeters2']
        self.panel_wattage = self.insights_data["solarPotential"]["panelCapacityWatts"]
        if self.panel_wattage != 400:
            # In the API documentation, it claims that the default output is 250W, however we've only seen 400W, so if
            # we get anything other than 400W, we'll need to adjust the calculations in the output. For this, we should
            # refer to https://developers.google.com/maps/documentation/solar/calculate-costs-non-us
            # Where the documentation explains how to adjust the yearlyEnergyDcKwh figures.
            # It should be straightforward, but I'd rather see an actual instance of this happening
            raise NotImplementedError("Panel wattage is not 400W - implement me")

        # We now start finding the solar panel configurations
        self.optimise_solar_configuration(
            energy_consumption=energy_consumption,
            is_building=is_building,
            property_instance=property_instance,
        )

        # Finally, if we have a double property, we half the data we stored area
        if self.double_property:
            self.roof_area = self.roof_area / 2
            self.floor_area = float(self.floor_area) / 2

    def save_to_db(self, session, uprns_to_location, scenario_type):
        if self.insights_data is None:
            raise ValueError("No api data to store")

        if scenario_type not in ["unit", "building"]:
            raise Exception("Invalid scenario type. Must be either 'unit' or 'building'")

        if not self.need_to_store:
            return

        scenarios_data = self.panel_performance.head(1)[
            [
                "n_panels",
                "yearly_dc_energy",
                "total_cost",
                "panneled_roof_area",
                "array_wattage",
                "initial_ac_kwh_per_year",
                "lifetime_ac_kwh",
                "roi",
                "expected_payback_years",
                "lifetime_dc_kwh"
            ]
        ].rename(
            columns={
                "n_panels": "number_panels",
                "yearly_dc_energy": "yearly_dc_kwh",
                "total_cost": "cost",
                "panneled_roof_area": "panelled_roof_area",
                "array_wattage": "array_kwhp",
                "initial_ac_kwh_per_year": "yearly_ac_kwh",
            }
        )
        scenarios_data["is_default"] = True
        scenarios_data["scenario_type"] = scenario_type
        scenarios_data = scenarios_data.to_dict(orient="records")

        store_batch_data(
            session=session,
            api_data=self.insights_data,
            uprns_to_location=uprns_to_location,
            scenarios_data=scenarios_data
        )

    @staticmethod
    def lifetime_production_kwh(
        row,
        efficiency_depreciation_factor,
        installation_life_span,
        column_name="initial_ac_kwh_per_year"
    ):
        """
        Mimics the function described in the Google Solar API documentation, presenting the lifetime production
        AC KWH as a geometric sum
        """

        return (
            row[column_name] *
            (1 - pow(
                efficiency_depreciation_factor,
                installation_life_span)) /
            (1 - efficiency_depreciation_factor))

    def optimise_solar_configuration(
        self, energy_consumption, is_building=False, property_instance=None
    ):
        """
        Optimise the solar panel configuration for the building.
        :return:
        """
        # If we look at the building level, we don't include any projects fewer than 10 panels, otherwise the
        # minimum is 4
        min_panels = self.MIN_BUILDING_PANELS if is_building else self.MIN_UNIT_PANELS

        # Remove any north facing roof segments
        panel_performance = []
        for config in self.insights_data["solarPotential"].get("solarPanelConfigs", []):

            roof_segment_summaries = [
                s for s in config.get("roofSegmentSummaries", [])
                if s["segmentIndex"] in self.allowed_segment_indices
            ]

            roi_summary = []
            for segment in roof_segment_summaries:
                wattage = segment["panelsCount"] * self.insights_data["solarPotential"]["panelCapacityWatts"]
                generated_dc_energy = segment["yearlyEnergyDcKwh"]
                ratio = generated_dc_energy / wattage

                roi_summary.append(
                    {
                        "segmentIndex": segment["segmentIndex"],
                        "wattage": wattage,
                        "generated_dc_energy": generated_dc_energy,
                        "ratio": ratio,
                        "n_panels": segment["panelsCount"],
                        "panneled_roof_area": self.panel_area * int(segment["panelsCount"])
                    }
                )

            roi_summary = pd.DataFrame(roi_summary)
            if roi_summary.empty:
                continue

            if roi_summary["n_panels"].sum() < min_panels:
                continue

            total_panels = roi_summary["n_panels"].sum()
            # find a product which is suitable for the ROI calc, without a battery. 400 Watts for the baseline
            solar_product = next(
                (m for m in self.solar_materials if m["type"] == "solar_pv" and
                 abs(m["size"] - (400 * total_panels) / 1000) < 0.1 and not m["includes_battery"]),
                None
            )

            if solar_product is None:
                continue

            total_cost = Costs.solar_pv(
                solar_product=solar_product,
                # We don't actually need scaffolding for the ROI calc
                scaffolding_options=[
                    {"total_cost": 1000, "size": property_instance.number_of_floors},
                    {"total_cost": 1000, "size": 3}
                ],
                # Assume the most amount of scaffolding
                n_floors=3 if property_instance is None else property_instance.number_of_floors
            )["total"]

            weighted_ratio = np.average(
                roi_summary["ratio"].values, weights=roi_summary["generated_dc_energy"].values
            )
            yearly_dc_energy = roi_summary["generated_dc_energy"].sum()

            panel_performance.append(
                {
                    "n_panels": roi_summary["n_panels"].sum(),
                    "yearly_dc_energy": yearly_dc_energy,
                    "total_cost": total_cost,
                    "weighted_ratio": weighted_ratio,
                    "panneled_roof_area": roi_summary["panneled_roof_area"].sum(),
                    "array_wattage": roi_summary["n_panels"].sum() * self.panel_wattage
                }
            )

        panel_performance = pd.DataFrame(panel_performance)

        if panel_performance.empty:
            self.panel_performance = pd.DataFrame(
                columns=[
                    "n_panels",
                    "yearly_dc_energy",
                    "total_cost",
                    "panneled_roof_area",
                    "array_wattage",
                    "initial_ac_kwh_per_year",
                    "lifetime_ac_kwh",
                    "roi",
                    "expected_payback_years",
                    "lifetime_dc_kwh"
                ]
            )
            return

            # We can have duplicate configurations

        panel_performance = panel_performance.drop_duplicates()

        if panel_performance.empty:
            self.panel_performance = pd.DataFrame(
                columns=[
                    "n_panels",
                    "yearly_dc_energy",
                    "total_cost",
                    "panneled_roof_area",
                    "array_wattage",
                    "initial_ac_kwh_per_year",
                    "lifetime_ac_kwh",
                    "roi",
                    "expected_payback_years",
                    "lifetime_dc_kwh"
                ]
            )
            return

        panel_performance["initial_ac_kwh_per_year"] = panel_performance["yearly_dc_energy"] * self.dc_to_ac_rate

        # Remove anything where the total ac energy is less than half of the array wattage
        # But - only where this is possible
        wattage_filter = (panel_performance["initial_ac_kwh_per_year"] / panel_performance["array_wattage"]) >= 0.5
        if any(wattage_filter):
            panel_performance = panel_performance[wattage_filter]

        # 2) Calculate the liftime solar energy production
        panel_performance['lifetime_ac_kwh'] = panel_performance.apply(
            self.lifetime_production_kwh,
            axis=1,
            efficiency_depreciation_factor=self.efficiency_depreciation_factor,
            installation_life_span=self.installation_life_span,
            column_name="initial_ac_kwh_per_year"
        )

        panel_performance['lifetime_dc_kwh'] = panel_performance.apply(
            self.lifetime_production_kwh,
            axis=1,
            efficiency_depreciation_factor=self.efficiency_depreciation_factor,
            installation_life_span=self.installation_life_span,
            column_name="yearly_dc_energy",
        )

        # Now that we know the lifetime cnsumption of ac kwh, we can estimate the roi
        # Key things we estimate:
        # - generation_value: this is the gbp value of the electricity generated
        # - roi: the return on investment, calcualated as generation_value / total_cost
        # - surplus: this is the amount of additional energy generated, and therefore how much will be exported
        # - surplus_value: the value of the surplus energy - this feeds into generation_value, when relevant
        # - expected_payback_years: the number of years it will take to pay back the initial investment

        # If we have a double property (i.e. the solar api has returned data for two units) we size up the solar panels
        # for double the consumption, as if for two units.
        if self.double_property:
            lifetime_energy_consumption = energy_consumption * 2 * self.installation_life_span
        else:
            lifetime_energy_consumption = energy_consumption * self.installation_life_span
        roi_results = []
        for _, panel_config in panel_performance.iterrows():
            lifetime_ac_kwh = panel_config["lifetime_ac_kwh"]

            surplus = 0
            generation_deficit = 0
            if lifetime_ac_kwh < lifetime_energy_consumption:
                # We estimate the amount of electricity generated, based on the price cap
                generation_value = lifetime_ac_kwh * AnnualBillSavings.ELECTRICITY_PRICE_CAP
                roi = generation_value / panel_config["total_cost"]
                generation_deficit = lifetime_energy_consumption - lifetime_ac_kwh
            else:

                # We now have a surplus of energy, which we can sell back to the grid
                surplus = lifetime_ac_kwh - lifetime_energy_consumption
                surplus_value = surplus * AnnualBillSavings.ELECTRICITY_EXPORT_PAYMENT
                generation_value = lifetime_energy_consumption * AnnualBillSavings.ELECTRICITY_PRICE_CAP
                roi = (generation_value + surplus_value) / panel_config["total_cost"]

            # Calculate expected payback years
            if generation_value > 0:
                expected_payback_years = panel_config["total_cost"] / (
                    generation_value / self.installation_life_span)
            else:
                expected_payback_years = None  # or some high value indicating no payback

            # Generation deficit tells us how much more energy we need to meet the generation demand.
            roi_results.append(
                {
                    "n_panels": panel_config["n_panels"],
                    "roi": roi,
                    "generation_value": generation_value,
                    "generation_deficit": generation_deficit,
                    "expected_payback_years": expected_payback_years,
                    "surplus": surplus
                }
            )

        roi_results = pd.DataFrame(
            roi_results,
            columns=["n_panels", "roi", "generation_value", "generation_deficit", "expected_payback_years", "surplus"]
        )

        panel_performance = panel_performance.merge(roi_results, how="left", on="n_panels")

        # We want max roi, minimal generation deficit, and max generation value - we create a ranking score
        # Assign equal weights to each metric
        weights = {'roi': 0.8, 'generation_value': 0.2}
        metrics = panel_performance[['roi', 'generation_value']].copy()

        # Normalize the columns (0 to 1 scale)
        scaler = MinMaxScaler()
        normalized_metrics = scaler.fit_transform(metrics)

        # Convert normalized metrics back to a dataframe
        normalized_metrics_df = pd.DataFrame(
            normalized_metrics, columns=['roi', 'generation_value']
        )
        normalized_metrics_df['combined_score'] = (
            normalized_metrics_df['roi'] * weights['roi'] +
            normalized_metrics_df['generation_value'] * weights['generation_value']
        )

        panel_performance['combined_score'] = normalized_metrics_df['combined_score'].values
        panel_performance['rank'] = panel_performance['combined_score'].rank(ascending=False)
        panel_performance = panel_performance.sort_values(by='rank')

        panel_performance["expected_payback_years"] = np.ceil(panel_performance["expected_payback_years"]).astype(int)

        if self.double_property:
            # Now that we've optimise to an energy consumption that is double the original, we need to half the
            # results
            panel_performance["n_panels_halved"] = panel_performance["n_panels"] / 2
            n_panels_required = {int(x) for x in np.floor(panel_performance["n_panels"] / 2)}
            # We filter the data on this number of panels
            panel_performance = panel_performance[panel_performance["n_panels_halved"].isin(n_panels_required)]
            # We half the generation values
            for col in [
                "yearly_dc_energy",
                "total_cost",
                "panneled_roof_area",
                "array_wattage",
                "initial_ac_kwh_per_year",
                "lifetime_ac_kwh",
                "lifetime_dc_kwh",
                "generation_value",
                "generation_deficit",
                "surplus"
            ]:
                panel_performance[col] = panel_performance[col] / 2

            panel_performance["n_panels"] = panel_performance["n_panels_halved"]
            panel_performance = panel_performance.drop(columns=["n_panels_halved"])
            panel_performance = panel_performance[panel_performance["n_panels"] >= min_panels]

        # Finally, we prevent pannelled roof area being above a limit
        panel_performance = panel_performance[
            panel_performance["panneled_roof_area"] <= self.roof_area * self.PERCENTAGE_OF_ROOF_LIMIT
            ]

        self.panel_performance = panel_performance

    def exclude_north_facing_segments(self, property_instance):
        """
        Filter out any north-facing roof segments from self.roof_segments.
        Keep API's original segmentIndex; optionally add a localIndex.
        """
        is_flat = property_instance.roof["is_flat"]

        kept = []
        allowed = set()
        for i, seg in enumerate(self.roof_segments):  # i is the API segmentIndex
            if not is_flat and (
                self.NORTH_FACING_AZIMUTH_RANGE[0] <= seg['azimuthDegrees'] <= self.NORTH_FACING_AZIMUTH_RANGE[1]):
                continue
            s = dict(seg)
            s["localIndex"] = len(kept)  # for charts/UI only
            kept.append(s)
            allowed.add(i)  # this i IS the API segmentIndex

        self.roof_segments = kept
        self.allowed_segment_indices = allowed

    @staticmethod
    def haversine(lat1, lon1, lat2, lon2):
        """
        Calculate the great-circle distance between two points on the Earth
        given their latitude and longitude in decimal degrees. Using haversine formula.
        """
        R = 6373.0  # approximate radius of earth in km

        lat1 = radians(lat1)
        lon1 = radians(lon1)
        lat2 = radians(lat2)
        lon2 = radians(lon2)

        dlon = lon2 - lon1
        dlat = lat2 - lat1

        a = sin(dlat / 2) ** 2 + cos(lat1) * cos(lat2) * sin(dlon / 2) ** 2
        c = 2 * atan2(sqrt(a), sqrt(1 - a))

        distance = R * c
        return distance

    def exclude_likely_duplicate_surfaces(self):
        """
        By checking the azimuth of the segments, we can exclude any segments that are likely to be duplicates
        :return:
        """

        def is_similar(segment1, segment2, azimuth_tol=20):
            azimuth_diff = abs(segment1['azimuthDegrees'] - segment2['azimuthDegrees'])
            return azimuth_diff <= azimuth_tol

        property_center = self.insights_data["center"]

        deduped_segments = []
        dropped_segments = []
        for segment in self.roof_segments:
            if not deduped_segments:
                deduped_segments.append(segment)
                continue

            similar_segments = [s for s in deduped_segments if is_similar(segment, s)]
            if not similar_segments:
                deduped_segments.append(segment)
            else:
                # Compare distances to the property center and keep the closer segment
                for similar_segment in similar_segments:
                    current_dist = self.haversine(
                        property_center['latitude'], property_center['longitude'],
                        segment['center']['latitude'], segment['center']['longitude']
                    )
                    similar_dist = self.haversine(
                        property_center['latitude'], property_center['longitude'],
                        similar_segment['center']['latitude'], similar_segment['center']['longitude']
                    )

                    if current_dist < similar_dist:
                        deduped_segments.remove(similar_segment)
                        deduped_segments.append(segment)
                        dropped_segments.append(similar_segment)
                    else:
                        dropped_segments.append(segment)

        # If we have a semi-detached property that has duplicated segments, we should expect to half the number of
        # segments
        if len(deduped_segments) < len(self.roof_segments):
            if len(deduped_segments) != len(self.roof_segments) / 2:
                # We don't perform any dropping in this case
                return

            # Because the segments are duplicated, but the sizes aren't necessarily split perfectly in half, what
            # we need to do is perform the solar analysis and then half the results. We set an indicator which
            # implies we should do this
            self.double_property = True

    @staticmethod
    def calculate_percentage_decrease(start_efficiency, end_efficiency, consumption_averages):
        """
        Calculate the percentage decrease in consumption between two energy efficiency ratings.
        :param start_efficiency: The starting energy efficiency rating.
        :param end_efficiency: The ending energy efficiency rating.
        :param consumption_averages: The DataFrame containing the consumption averages.
        :return:
        """

        start_consumption = consumption_averages.loc[
            consumption_averages["current-energy-efficiency"].astype(str) == str(start_efficiency), "total_consumption"
        ].values[0]

        end_consumption = consumption_averages.loc[
            consumption_averages["current-energy-efficiency"].astype(str) == str(end_efficiency), "total_consumption"
        ].values[0]

        percentage_decrease = ((start_consumption - end_consumption) / start_consumption) * 100
        # percentage_decrease cannot be nehative
        if percentage_decrease < 0:
            percentage_decrease = 0
        return percentage_decrease

    @classmethod
    def estimate_new_consumption(
        cls, current_energy_efficiency, target_efficiency, current_consumption, ofgem_consumption_averages
    ):
        """
        Given then consumption_averages dataset, which is produced as a result of the training_data.py script,
        for the energy kwh models, this function will estimate the new consumption based on the current consumption,
        based on the expected reduction in consumption from the current rating to the target rating.
        :param current_energy_efficiency: The current energy efficiency rating
        :param target_efficiency: The target energy efficiency rating
        :param current_consumption: The current consumption of the property
        :param ofgem_consumption_averages: DataFrame of the Ofgem consumption averages
        :return:
        """
        percentage_decrease = cls.calculate_percentage_decrease(
            start_efficiency=current_energy_efficiency,
            end_efficiency=target_efficiency,
            consumption_averages=ofgem_consumption_averages
        )
        new_consumption = current_consumption * (1 - percentage_decrease / 100)
        return new_consumption

    @classmethod
    def prepare_input_data(
        cls,
        input_properties: List[Property],
        ofgem_consumption_averages: pd.DataFrame,
        body: PlanTriggerRequest
    ):
        """
        :param input_properties: List of properties
        :param ofgem_consumption_averages: DataFrame of the Ofgem consumption averages
        :param body: PlanTriggerRequest instance
        This sets up the data required to make the solar api request
        :return:
        """

        building_solar_config = [
            {
                "building_id": p.building_id,
                "longitude": p.spatial["longitude"],
                "latitude": p.spatial["latitude"],
                # Energy consumption is adjusted for the property's expected post retrofit state
                # We set the target rating to EPC C, which is the typical EPC rating we would expect the
                # property to achieve post retrofit of just the fabric
                "energy_consumption": cls.estimate_new_consumption(
                    current_energy_efficiency=min(p.epc_record.current_energy_efficiency, 100),
                    target_efficiency="69",
                    current_consumption=p.estimate_electrical_consumption(
                        assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions
                    ),
                    ofgem_consumption_averages=ofgem_consumption_averages
                ),
                "property_id": p.id,
                "uprn": p.uprn
            } for p in input_properties if p.building_id is not None
        ]
        unit_solar_config = [
            {
                "longitude": p.spatial["longitude"],
                "latitude": p.spatial["latitude"],
                # Energy consumption is adjusted for the property's expected post retrofit state
                # We set the target rating to EPC C, which is the typical EPC rating we would expect the
                # property to achieve post retrofit of just the fabric
                "energy_consumption": cls.estimate_new_consumption(
                    current_energy_efficiency=min(p.epc_record.current_energy_efficiency, 100),
                    target_efficiency="69",
                    current_consumption=p.estimate_electrical_consumption(
                        assumed_ashp_efficiency=assumptions.AVERAGE_ASHP_EFFICIENCY, exclusions=body.exclusions
                    ),
                    ofgem_consumption_averages=ofgem_consumption_averages
                ),
                "property_id": p.id,
                "uprn": p.uprn
            } for p in input_properties if p.building_id is None
        ]

        return building_solar_config, unit_solar_config

    @classmethod
    def building_solar_analysis(
        cls, building_solar_config: List, input_properties: List[Property], session, google_solar_api_key: str,
        solar_materials: list,
    ):
        """
        Perform the solar analysis for the building level
        :param building_solar_config: List of building solar configurations
        :param input_properties: List of properties
        :param session: Database session
        :param google_solar_api_key: Google Solar API key
        :param solar_materials: List of solar materials
        :return:
        """

        if not building_solar_config:
            return input_properties

        # Find the unique longitude and latitude pairs for each building id
        unique_coordinates = {}
        building_uprns = {}
        for entry in building_solar_config:
            building_id = entry['building_id']
            coordinate_pair = {'longitude': entry['longitude'], 'latitude': entry['latitude']}

            if building_id not in unique_coordinates:
                unique_coordinates[building_id] = []

            if coordinate_pair not in unique_coordinates[building_id]:
                unique_coordinates[building_id].append(coordinate_pair)

            if building_id not in building_uprns:
                building_uprns[building_id] = []

            if entry['uprn'] not in building_uprns[building_id]:
                building_uprns[building_id].append(
                    {
                        "uprn": entry['uprn'], "longitude": entry['longitude'], "latitude": entry['latitude']
                    }
                )

        solar_panel_configuration = {}
        for building_id, coordinates in unique_coordinates.items():
            if len(coordinates) > 1:
                raise NotImplementedError("more than one coordinate for a building - handle me")

            coordinates = coordinates[0]
            energy_consumption = sum(
                [entry['energy_consumption'] for entry in building_solar_config if entry['building_id'] == building_id]
            )
            solar_api_client = cls(api_key=google_solar_api_key, solar_materials=solar_materials)
            solar_api_client.get(
                longitude=coordinates["longitude"],
                latitude=coordinates["latitude"],
                energy_consumption=energy_consumption,
                is_building=True,
                session=session
            )
            solar_panel_configuration[building_id] = {
                "insights_data": solar_api_client.insights_data,
                "panel_performance": solar_api_client.panel_performance,
                "n_units": len([entry for entry in building_solar_config if entry['building_id'] == building_id])
            }

            # Store the data in the database
            # TODO: Rather than just doing a straight insert, we should overwrite what's already there if it
            #  exists
            solar_api_client.save_to_db(
                session=session, uprns_to_location=building_uprns[building_id], scenario_type="building"
            )

            # Insert this into the properties that have this building id
            for p in input_properties:
                if p.building_id == building_id:
                    unit_solar_panel_configuration = solar_panel_configuration[building_id].copy()

                    unit_solar_panel_configuration["unit_share_of_energy"] = (
                        [x for x in building_solar_config if x["property_id"] == p.id][0]["energy_consumption"] /
                        energy_consumption
                    )
                    p.set_solar_panel_configuration(unit_solar_panel_configuration)

        return input_properties

    @classmethod
    def unit_solar_analysis(
        cls, unit_solar_config: List, input_properties: List[Property], session, body, google_solar_api_key: str,
        solar_materials: list, inspections_map: dict
    ):

        """
        Perform the solar analysis for the unit level
        :param unit_solar_config: List of unit solar configurations
        :param input_properties: List of properties
        :param session: Database session
        :param body: PlanTriggerRequest instance
        :param google_solar_api_key: Google Solar API key
        :param solar_materials: List of solar materials
        :param inspections_map: Dictionary mapping property IDs to inspection data
        :return:
        """

        if not unit_solar_config:
            return input_properties

        # Model the solar potential at the property level
        for unit in tqdm(unit_solar_config):

            # We don't need to do this if we have global inclusions that don't include solar
            if body.inclusions:
                if "solar_pv" not in body.inclusions:
                    continue

            property_instance = [p for p in input_properties if p.id == unit["property_id"]][0]
            # At this level, we check if the property is suitable for solar and if now, skip
            # Or if we have a solar non-invasive recommendation

            non_invasive_rec = next(
                (r for r in property_instance.non_invasive_recommendations if r["type"] == "solar_pv"), {}
            ).get("array_wattage")

            if (
                (not property_instance.is_solar_pv_valid()) or
                non_invasive_rec is not None
            ):
                continue

            solar_api_client = cls(api_key=google_solar_api_key, solar_materials=solar_materials)

            if unit["longitude"] is None or unit["latitude"] is None:
                # At this point, we've checked that solar PV is valid, and so we provide some defaults
                property_instance.set_solar_panel_configuration(
                    solar_panel_configuration={
                        "insights_data": None,
                        "panel_performance": solar_api_client.default_panel_performance(
                            property_instance=property_instance
                        ),
                        "unit_share_of_energy": 1
                    },
                )
                continue

            solar_api_client.get(
                longitude=unit["longitude"],
                latitude=unit["latitude"],
                energy_consumption=unit["energy_consumption"],
                is_building=False,
                session=session,
                uprn=unit["uprn"],
                property_instance=property_instance,
            )

            property_inspections = inspections_map.get(property_instance.id, {})

            if property_inspections:
                # If we have some inspections data, we check if we have some data which indicates solar cannot
                # be installed. We're loose about this now since this is post review
                if solar_api_client.panel_performance.empty:
                    # We assume solar is a suitable option
                    solar_api_client.panel_performance = solar_api_client.default_panel_performance(property_instance)

            # Store the data in the database
            solar_api_client.save_to_db(
                session=session,
                uprns_to_location=[
                    {
                        "uprn": property_instance.uprn,
                        "longitude": property_instance.spatial["longitude"],
                        "latitude": property_instance.spatial["latitude"]
                    }
                ],
                scenario_type="unit"
            )

            property_instance.set_solar_panel_configuration(
                solar_panel_configuration={
                    "insights_data": solar_api_client.insights_data,
                    "panel_performance": solar_api_client.panel_performance,
                    "unit_share_of_energy": 1
                },
            )

        return input_properties

    def default_panel_performance(self, property_instance):
        """
        In a small number of cases, where properties have simulated uprns, we do not have a longitude and latitude
        value and therefore we just return a default panel performance
        :param property_instance:
        :return:
        """

        cost_instance = Costs(property_instance=property_instance)

        material_1_6 = next(
            (m for m in self.solar_materials if m["type"] == "solar_pv" and
             abs(m["size"] - 1.6) < 0.1 and not m["includes_battery"]),
            None
        )
        material_3_2 = next(
            (m for m in self.solar_materials if m["type"] == "solar_pv" and
             abs(m["size"] - 3.2) < 0.1 and not m["includes_battery"]),
            None
        )

        material_4_35 = next(
            (m for m in self.solar_materials if m["type"] == "solar_pv" and
             abs(m["size"] - 4.35) < 0.1 and not m["includes_battery"]),
            None
        )

        if material_1_6 is None or material_3_2 is None or material_4_35 is None:
            raise ValueError("No suitable solar product found for the default configuration.")

        # We return a 1.6 and 3.2 kwp system
        panel_performance = pd.DataFrame(
            [
                {
                    'n_panels': 10,
                    'yearly_dc_energy': 4350 * assumptions.MEDIAN_WATTAGE_TO_DC,
                    'total_cost': cost_instance.solar_pv(
                        solar_product=material_4_35,
                        scaffolding_options=[
                            {"total_cost": 1000, "size": property_instance.number_of_floors},
                            {"total_cost": 1000, "size": 3}
                        ],
                        n_floors=property_instance.number_of_floors
                    )["total"],
                    'weighted_ratio': None,
                    'panneled_roof_area': 9 * assumptions.RDSAP_AREA_PER_PANEL,
                    'array_wattage': 4350,
                    'initial_ac_kwh_per_year': 4350 * assumptions.MEDIAN_WATTAGE_TO_AC,
                    'lifetime_ac_kwh': None,
                    'lifetime_dc_kwh': None,
                    'roi': None,
                    'generation_value': None,
                    'generation_deficit': None,
                    'expected_payback_years': None,
                    'surplus': None,
                    'combined_score': None,
                    'rank': None
                },
                {
                    'n_panels': 8,
                    'yearly_dc_energy': 3200 * assumptions.MEDIAN_WATTAGE_TO_DC,
                    'total_cost': cost_instance.solar_pv(
                        solar_product=material_1_6,
                        scaffolding_options=[
                            {"total_cost": 1000, "size": property_instance.number_of_floors},
                            {"total_cost": 1000, "size": 3}
                        ],
                        n_floors=property_instance.number_of_floors
                    )["total"],
                    'weighted_ratio': None,
                    'panneled_roof_area': 8 * assumptions.RDSAP_AREA_PER_PANEL,
                    'array_wattage': 3200,
                    'initial_ac_kwh_per_year': 3200 * assumptions.MEDIAN_WATTAGE_TO_AC,
                    'lifetime_ac_kwh': None,
                    'lifetime_dc_kwh': None,
                    'roi': None,
                    'generation_value': None,
                    'generation_deficit': None,
                    'expected_payback_years': None,
                    'surplus': None,
                    'combined_score': None,
                    'rank': None
                },
                {
                    'n_panels': 4,
                    'yearly_dc_energy': 1600 * assumptions.MEDIAN_WATTAGE_TO_DC,
                    'total_cost': cost_instance.solar_pv(
                        solar_product=material_3_2,
                        scaffolding_options=[
                            {"total_cost": 1000, "size": property_instance.number_of_floors},
                            {"total_cost": 1000, "size": 3}
                        ],
                        n_floors=property_instance.number_of_floors
                    )["total"],
                    'weighted_ratio': None,
                    'panneled_roof_area': 4 * assumptions.RDSAP_AREA_PER_PANEL,
                    'array_wattage': 1600,
                    'initial_ac_kwh_per_year': 1600 * assumptions.MEDIAN_WATTAGE_TO_AC,
                    'lifetime_ac_kwh': None,
                    'lifetime_dc_kwh': None,
                    'roi': None,
                    'generation_value': None,
                    'generation_deficit': None,
                    'expected_payback_years': None,
                    'surplus': None,
                    'combined_score': None,
                    'rank': None
                },
            ]
        )

        # We add the key elements that are required for the database
        panel_performance['lifetime_ac_kwh'] = panel_performance.apply(
            self.lifetime_production_kwh,
            axis=1,
            efficiency_depreciation_factor=self.efficiency_depreciation_factor,
            installation_life_span=self.installation_life_span,
            column_name="initial_ac_kwh_per_year"
        )

        panel_performance['lifetime_dc_kwh'] = panel_performance.apply(
            self.lifetime_production_kwh,
            axis=1,
            efficiency_depreciation_factor=self.efficiency_depreciation_factor,
            installation_life_span=self.installation_life_span,
            column_name="yearly_dc_energy",
        )

        return panel_performance
