import csv
import math
from datetime import datetime, timezone

from pvl_p90_client.grpcclient import weatherData_pb2
from pvl_p90_client.grpcclient.uncertaintyMessages_pb2 import (
    Distribution,
    DistributionFunction,
    DistributionInput,
    DistributionType,
    ElectricalSettings,
    ModuleInfo,
    OperationalSettings,
    OpticalSettings,
    ResultOptions,
    SystemInfo,
    ThermalSettings,
    TimeStepData,
    TrackingType,
    UncertaintyRequest,
    UncertaintySimulationOptions,
    YieldTimeSeriesSolarPosition,
    YieldTimeSeriesWeather,
)

from .exception import TimeStepDataError


def format_number_for_display(value: float, precision: int = 4) -> str:
    """Format a number for human-readable display.

    Uses scientific notation for very large numbers (>= 1e9) or very small numbers (<= 1e-4),
    otherwise uses fixed-point notation with the specified precision.

    Args:
        value: The number to format
        precision: Number of decimal places for fixed-point notation

    Returns:
        Formatted string representation of the number
    """
    if math.isinf(value):
        return "∞" if value > 0 else "-∞"
    elif math.isnan(value):
        return "NaN"
    elif abs(value) >= 1e9 or (abs(value) <= 1e-4 and value != 0):
        return f"{value:.{precision}e}"
    else:
        return f"{value:.{precision}f}"


def _extract_float_from_nullable(nullable_float: weatherData_pb2.NullableFloat) -> float | None:
    """Extract float value from NullableFloat, returning None if null."""
    if hasattr(nullable_float, "null") and nullable_float.HasField("null"):
        return None
    return nullable_float.data if hasattr(nullable_float, "data") else None


def build_module_info(
    length: float | None = None,
    width: float | None = None,
    cell_to_cell_mismatch: float | None = None,
    power_temperature_coefficient: float | None = None,
    power_at_stc: float | None = None,
    bifaciality: float | None = None,
) -> ModuleInfo:
    """Builds and returns a ModuleInfo message with optional values."""
    module_info = ModuleInfo()
    if length is not None:
        module_info.LengthInM = length
    if width is not None:
        module_info.WidthInM = width
    if cell_to_cell_mismatch is not None:
        module_info.CellToCellMismatch = cell_to_cell_mismatch
    if power_temperature_coefficient is not None:
        module_info.ModulePowerTemperatureCoefficient = power_temperature_coefficient
    if power_at_stc is not None:
        module_info.PowerRatingAtSTCInW = power_at_stc
    if bifaciality is not None:
        module_info.Bifaciality = bifaciality
    return module_info


def build_system_info(
    number_of_inverters: int | None = None,
    strings_per_inverter: int | None = None,
    modules_per_string: int | None = None,
    row_pitch: float | None = None,
    module_azimuth: float | None = None,
    module_tilt: float | None = None,
    module_tracking: TrackingType | None = None,
    module_tilt_limit: float | None = None,
    module_height: float | None = None,
) -> SystemInfo:
    """Builds and returns a SystemInfo message with optional values."""
    system_info = SystemInfo()

    if strings_per_inverter is not None:
        system_info.StringsPerInverter = strings_per_inverter
    if modules_per_string is not None:
        system_info.ModulesPerString = modules_per_string
    if row_pitch is not None:
        system_info.RowPitchInM = row_pitch
    if module_azimuth is not None:
        system_info.ModuleAzimuthInDegrees = module_azimuth
    if number_of_inverters is not None:
        system_info.NumberOfInverters = number_of_inverters
    if module_tilt is not None:
        system_info.FallbackModuleTiltInDegrees = module_tilt
    if module_tracking is not None:
        system_info.ModuleTracking = module_tracking
    if module_tilt_limit is not None:
        system_info.TiltLimitInDegrees = module_tilt_limit
    if module_height is not None:
        system_info.ModuleHeight = module_height

    return system_info


def build_electrical_settings(
    max_power_tracking_loss: float | None = None,
    inverter_efficiency: float | None = None,
    inverter_wiring_loss: float | None = None,
    string_wiring_loss: float | None = None,
    string_to_string_mismatch: float | None = None,
    module_to_module_mismatch: float | None = None,
    inverter_to_inverter_mismatch: float | None = None,
    inverter_clipping: float | None = None,
) -> ElectricalSettings:
    """Builds and returns an ElectricalSettings message with optional values."""
    electrical = ElectricalSettings()
    if inverter_efficiency is not None:
        electrical.InverterEfficiency = inverter_efficiency
    if module_to_module_mismatch is not None:
        electrical.ModuleToModuleMismatch = module_to_module_mismatch
    if string_wiring_loss is not None:
        electrical.StringWiringLoss = string_wiring_loss
    if max_power_tracking_loss is not None:
        electrical.MaxPowerTrackingLoss = max_power_tracking_loss
    if inverter_wiring_loss is not None:
        electrical.InverterWiringLoss = inverter_wiring_loss
    if string_to_string_mismatch is not None:
        electrical.StringToStringMismatch = string_to_string_mismatch
    if inverter_to_inverter_mismatch is not None:
        electrical.InverterToInverterMismatch = inverter_to_inverter_mismatch
    if inverter_clipping is not None:
        electrical.InverterClipping = inverter_clipping

    return electrical


def build_optical_settings(
    beam_multiplier_front: float | None = None,
    beam_multiplier_rear: float | None = None,
    isotropic_multiplier_front: float | None = None,
    isotropic_multiplier_rear: float | None = None,
    albedo: float | None = None,
    soiling_front: float | None = None,
    soiling_rear: float | None = None,
    spectral_correction: float | None = None,
    rear_transmission_factor: float | None = None,
    rear_structural_shading_factor: float | None = None,
) -> OpticalSettings:
    """Builds and returns an OpticalSettings message with optional values."""
    optical = OpticalSettings()

    if beam_multiplier_front is not None:
        optical.BeamMultiplierFront = beam_multiplier_front
    if beam_multiplier_rear is not None:
        optical.BeamMultiplierRear = beam_multiplier_rear
    if isotropic_multiplier_front is not None:
        optical.IsotropicMultiplierFront = isotropic_multiplier_front
    if isotropic_multiplier_rear is not None:
        optical.IsotropicMultiplierRear = isotropic_multiplier_rear
    if albedo is not None:
        optical.FallbackAlbedo = albedo
    if soiling_front is not None:
        optical.FallbackSoilingFront = soiling_front
    if soiling_rear is not None:
        optical.FallbackSoilingRear = soiling_rear
    if spectral_correction is not None:
        optical.FallbackSpectralCorrection = spectral_correction
    if rear_transmission_factor is not None:
        optical.FallbackRearTransmissionFactor = rear_transmission_factor
    if rear_structural_shading_factor is not None:
        optical.FallbackRearStructuralShadingFactor = rear_structural_shading_factor

    return optical


def build_thermal_settings(
    uc: float | None = None,
    uv: float | None = None,
    alpha: float | None = None,
) -> ThermalSettings:
    thermal = ThermalSettings()

    if uc is not None:
        thermal.Uc = uc
    if uv is not None:
        thermal.Uv = uv
    if alpha is not None:
        thermal.Alpha = alpha
    return thermal


def build_operational_settings(
    annual_degradation_rate: float | None = None,
    curtailment: float | None = None,
    availability: float | None = None,
    dc_health: float | None = None,
    undulating_ground: bool | None = None,
    yield_modifier: float | None = None,
) -> OperationalSettings:
    operational = OperationalSettings()

    if annual_degradation_rate is not None:
        operational.AnnualDegradationRate = annual_degradation_rate
    if curtailment is not None:
        operational.Curtailment = curtailment
    if availability is not None:
        operational.Availability = availability
    if dc_health is not None:
        operational.DCHealth = dc_health
    if undulating_ground is not None:
        operational.UndulatingGround = undulating_ground
    if yield_modifier is not None:
        operational.YieldModifier = yield_modifier

    return operational


def build_request(
    time_step_data: list[TimeStepData],
    module: ModuleInfo | None = None,
    system: SystemInfo | None = None,
    electrical: ElectricalSettings | None = None,
    optical: OpticalSettings | None = None,
    thermal: ThermalSettings | None = None,
    operational: OperationalSettings | None = None,
    distributions: list[Distribution] | None = None,
    simulation_options: UncertaintySimulationOptions | None = None,
    result_options: ResultOptions | None = None,
) -> UncertaintyRequest:
    request = UncertaintyRequest()

    if module is not None:
        request.Module.CopyFrom(module)
    if system is not None:
        request.System.CopyFrom(system)
    if electrical is not None:
        request.Electrical.CopyFrom(electrical)
    if optical is not None:
        request.Optical.CopyFrom(optical)
    if thermal is not None:
        request.Thermal.CopyFrom(thermal)
    if operational is not None:
        request.Operation.CopyFrom(operational)
    if simulation_options is not None:
        request.SimulationOptions.CopyFrom(simulation_options)
    if result_options is not None:
        request.ResultOptions.CopyFrom(result_options)
    else:
        # DefaulResult options are required by the service and should be set if not provided
        request.ResultOptions.CopyFrom(build_result_options())

    # Add modifiers if provided
    if distributions is not None:
        request.Distributions.extend(distributions)

    # Add Typical Mean Year data
    request.TMYDataSet.TimeStepDataPoints.extend(time_step_data)
    # Add any default settings if needed
    return request


def build_result_options(bin_min: float = 0.75, bin_delta: float = 0.01, p_values: list[int] | None = None) -> ResultOptions:
    """Builds and returns a ResultOptions message with default values."""
    if p_values is None:
        p_values = [5, 10, 50, 90, 95]
    result_options = ResultOptions()
    result_options.BinDelta = bin_delta
    result_options.PValues.extend(p_values)
    result_options.BinMin = bin_min
    return result_options


def build_simulation_options(number_of_years: int = 5, number_of_simulations: int = 100) -> UncertaintySimulationOptions:
    """Builds and returns a SimulationOptions message with default values."""
    simulation_options = UncertaintySimulationOptions()
    simulation_options.NumberOfYears = number_of_years
    simulation_options.NumberOfSimulations = number_of_simulations
    return simulation_options


def build_distribution(
    input: DistributionInput,
    sim_to_sim_distribution: DistributionFunction | None = None,
    yr_to_yr_distribution: DistributionFunction | None = None,
    step_to_step_distribution: DistributionFunction | None = None,
) -> Distribution:  # type: ignore
    """Builds and returns a Modifier message."""
    distribution = Distribution()
    distribution.Input = input

    if sim_to_sim_distribution is not None:
        distribution.SimToSimDistribution.CopyFrom(sim_to_sim_distribution)
    if yr_to_yr_distribution is not None:
        distribution.YrToYrDistribution.CopyFrom(yr_to_yr_distribution)
    if step_to_step_distribution is not None:
        distribution.StepToStepDistribution.CopyFrom(step_to_step_distribution)
    return distribution


def build_gaussian_distribution(
    x0: float,
    sigma: float,
    fmax: float | None = None,
    num_points_in_probability_function: int | None = None,
    lower_limit: float | None = None,
    upper_limit: float | None = None,
) -> DistributionFunction:
    """Builds and returns an ErrorFunction message."""
    distribution = DistributionFunction()
    distribution.Type = DistributionType.Gaussian
    distribution.Gaussian.x0 = x0
    distribution.Gaussian.sigma = sigma

    if fmax is not None:
        distribution.Gaussian.fmax = fmax
    if num_points_in_probability_function is not None:
        distribution.Gaussian.numPointsInProbabilityFunction = num_points_in_probability_function
    if lower_limit is not None:
        distribution.Gaussian.xLowerLimit = lower_limit
    if upper_limit is not None:
        distribution.Gaussian.xUpperLimit = upper_limit

    return distribution


def build_skewed_gaussian_distribution(
    alpha: float,
    zeta: float,
    omega: float,
    fmax: float | None = None,
    num_points_in_probability_function: int | None = None,
) -> DistributionFunction:
    """Builds and returns an ErrorFunction message."""
    distribution = DistributionFunction()
    distribution.Type = DistributionType.SkewedGaussian
    distribution.SkewedGaussian.alpha = alpha
    distribution.SkewedGaussian.zeta = zeta
    distribution.SkewedGaussian.omega = omega

    if fmax is not None:
        distribution.SkewedGaussian.fmax = fmax
    if num_points_in_probability_function is not None:
        distribution.SkewedGaussian.numPointsInProbabilityFunction = num_points_in_probability_function

    return distribution


def build_weibull_distribution(x0: float, lambda_term: float, k: float, has_positive_polarity: bool | None = None) -> DistributionFunction:
    """Builds and returns an ErrorFunction message."""
    distribution = DistributionFunction()
    distribution.Type = DistributionType.Weibull
    distribution.Weibull.x0 = x0
    distribution.Weibull.k = k
    # Note: 'lambda' is a Python keyword, so we use getattr/setattr
    setattr(distribution.Weibull, "lambda", lambda_term)

    if has_positive_polarity is not None:
        distribution.Weibull.hasPositivePolarity = has_positive_polarity

    return distribution


def build_arbitrary_distribution(x: list[float], y: list[float]) -> DistributionFunction:
    distribution = DistributionFunction()
    distribution.Type = DistributionType.Arbitrary
    # Create the nested message properly
    arbitrary_func = distribution.Arbitrary
    arbitrary_func.x[:] = x  # Use slice assignment for repeated fields
    arbitrary_func.y[:] = y  # Use slice assignment for repeated fields
    return distribution


def load_weather_data_from_pvw_file(file_path: str) -> list[TimeStepData] | None:
    """Load weather data from a .pvw file and convert it to WeatherData messages.

    Returns None if the file cannot be loaded or parsed.
    """
    try:
        weather_data = []
        with open(file_path, "rb") as f:
            data = f.read()
            weather_setting = weatherData_pb2.WeatherSetting()
            weather_setting.ParseFromString(data)
            for row in weather_setting.weatherRows:
                # Convert temperature from Celsius to Kelvin if available
                temp_celsius = _extract_float_from_nullable(row.DryBulbTemperature_DegreesCeliusAt2Metres)
                temp_kelvin = celsius_to_kelvin(temp_celsius) if temp_celsius is not None else None

                point = TimeStepData(
                    EndOfPeriodUTC=row.ObservationTime_UTC,
                    SolarPos=YieldTimeSeriesSolarPosition(
                        AzimuthDegrees=_extract_float_from_nullable(row.SolarAzimuth), ZenithDegrees=_extract_float_from_nullable(row.SolarZenith)
                    ),
                    Weather=YieldTimeSeriesWeather(
                        AmbientTemp=temp_kelvin,
                        DHI=_extract_float_from_nullable(row.DiffuseHorizontalIrradiance_WattsPerMetreSquared),
                        GHI=_extract_float_from_nullable(row.GlobalHorizontalIrradiance_WattsPerMetreSquared),
                        DNI=_extract_float_from_nullable(row.DirectNormalIrradiance_WattsPerMetreSquared),
                        WS=_extract_float_from_nullable(row.WindVelocity_MetresPerSecondAt10Metres),
                    ),
                )

                weather_data.append(point)
        return weather_data
    except (FileNotFoundError, OSError) as e:
        print(f"Error loading weather data from {file_path}: {e}")
        raise TimeStepDataError("Failed to load weather data") from e
    except Exception as e:
        print(f"Error parsing weather data from {file_path}: {e}")
        raise TimeStepDataError("Failed to load weather data") from e


def load_time_step_data_from_csv(file_path: str) -> list[TimeStepData]:
    """Load weather data from a CSV file and convert it to TimeStepData messages.

    The CSV file must start with a version header: # P90_CSV_VERSION=1.0

    The CSV file is expected to have the following columns:
    - EndOfPeriodUTC (dd/mm/yyyy HH:mm)
    - SolarAzimuth (degrees)
    - SolarZenith (degrees)
    - AmbientTemp (celsius)
    - DHI (W/m2)
    - GHI (W/m2)
    - DNI (W/m2)
    - WS (m/s)
    - Tilt (degrees) - column required but values may be blank
    - Albedo - column required but values may be blank
    - SoilingFrontRate - column required but values may be blank
    - SoilingRearRate - column required but values may be blank
    - RearTransmissionFactor (%) - column required but values may be blank, percentage format
    - RearStructuralShadingFactor (%) - column required but values may be blank, percentage format

    Returns a list of TimeStepData messages.

    Raises:
        TimeStepDataError: If the file format is invalid, version is missing/unsupported, or required columns are missing.
    """
    # Define expected columns (all must be present, but values may be blank)
    expected_columns = {
        "EndOfPeriodUTC (dd/mm/yyyy HH:mm)",
        "SolarAzimuth (degrees)",
        "SolarZenith (degrees)",
        "AmbientTemp (celsius)",
        "DHI (W/m2)",
        "GHI (W/m2)",
        "DNI (W/m2)",
        "WS (m/s)",
        "Tilt (degrees)",
        "Albedo",
        "SoilingFrontRate",
        "SoilingRearRate",
        "RearTransmissionFactor (%)",
        "RearStructuralShadingFactor (%)",
    }

    weather_data = []
    try:
        with open(file_path, newline="") as csvfile:
            # Check for required version header
            first_line = csvfile.readline().strip()

            if not first_line.startswith("# P90_CSV_VERSION="):
                raise TimeStepDataError(f"CSV file {file_path} is missing required version header. Expected: # P90_CSV_VERSION=1.0")

            # Extract and validate version
            version = first_line.split("=")[1].strip()
            if version != "1.0":
                raise TimeStepDataError(f"Unsupported CSV format version: {version}. Only version 1.0 is currently supported.")

            # Save position after version header
            header_position = csvfile.tell()

            # First, try to detect if it's a valid CSV with comma separator
            sample = csvfile.read(1024)

            # Make sure sample ends with a complete line
            if sample and not sample.endswith("\n"):
                last_newline = sample.rfind("\n")
                if last_newline != -1:
                    sample = sample[: last_newline + 1]

            # Use csv.Sniffer to detect format
            sniffer = csv.Sniffer()
            try:
                dialect = sniffer.sniff(sample, delimiters=",")
                if dialect.delimiter != ",":
                    raise TimeStepDataError(f"File {file_path} must use comma as separator, found '{dialect.delimiter}'")
            except csv.Error as e:
                raise TimeStepDataError(f"File {file_path} does not appear to be a valid CSV format") from e

            # Check if file has a header
            if not sniffer.has_header(sample):
                raise TimeStepDataError(f"File {file_path} must have a header row")

            # Seek back to position after version header to read the CSV
            csvfile.seek(header_position)

            # Read the CSV with detected dialect
            reader = csv.DictReader(csvfile, dialect=dialect)

            # Validate that all expected columns are present
            if reader.fieldnames is None:
                raise TimeStepDataError("Unable to read CSV header")

            actual_columns = set(reader.fieldnames)
            missing_columns = expected_columns - actual_columns

            if missing_columns:
                missing_list = sorted(missing_columns)
                raise TimeStepDataError(f"Missing required columns: {', '.join(missing_list)}")

            # Process the data rows
            for row in reader:
                # Parse the datetime string (expected format: dd/mm/yyyy HH:mm)
                try:
                    end_time = datetime.strptime(row["EndOfPeriodUTC (dd/mm/yyyy HH:mm)"], "%d/%m/%Y %H:%M")
                except ValueError as e:
                    date_value = row["EndOfPeriodUTC (dd/mm/yyyy HH:mm)"]
                    raise TimeStepDataError(f"Invalid date format in EndOfPeriodUTC: {date_value}. Expected format: dd/mm/yyyy HH:mm") from e

                temp_celsius = float(row["AmbientTemp (celsius)"]) if row["AmbientTemp (celsius)"] else None
                temp_kelvin = celsius_to_kelvin(temp_celsius) if temp_celsius is not None else None

                # Create the TimeStepData object with all fields
                point = TimeStepData(
                    EndOfPeriodUTC=end_time,
                    SolarPos=YieldTimeSeriesSolarPosition(
                        AzimuthDegrees=float(row["SolarAzimuth (degrees)"]) if row["SolarAzimuth (degrees)"] else None,
                        ZenithDegrees=float(row["SolarZenith (degrees)"]) if row["SolarZenith (degrees)"] else None,
                    ),
                    Weather=YieldTimeSeriesWeather(
                        AmbientTemp=temp_kelvin,
                        DHI=float(row["DHI (W/m2)"]) if row["DHI (W/m2)"] else None,
                        GHI=float(row["GHI (W/m2)"]) if row["GHI (W/m2)"] else None,
                        DNI=float(row["DNI (W/m2)"]) if row["DNI (W/m2)"] else None,
                        WS=float(row["WS (m/s)"]) if row["WS (m/s)"] else None,
                    ),
                )

                # Set optional fields only if they have values
                if row["Tilt (degrees)"].strip():
                    point.TiltAngleInDegrees = float(row["Tilt (degrees)"])

                if row["Albedo"].strip():
                    point.Albedo = float(row["Albedo"])

                if row["SoilingFrontRate"].strip():
                    point.SoilingFrontRate = float(row["SoilingFrontRate"])

                if row["SoilingRearRate"].strip():
                    point.SoilingRearRate = float(row["SoilingRearRate"])

                if row["RearTransmissionFactor (%)"].strip():
                    point.RearTransmissionFactor = float(row["RearTransmissionFactor (%)"]) / 100.0

                if row["RearStructuralShadingFactor (%)"].strip():
                    point.RearStructuralShadingFactor = float(row["RearStructuralShadingFactor (%)"]) / 100.0

                weather_data.append(point)
        return weather_data
    except (FileNotFoundError, OSError) as e:
        print(f"Error loading weather data from {file_path}: {e}")
        raise TimeStepDataError("Failed to load weather data") from e
    except TimeStepDataError:
        # Re-raise WeatherDataError as-is
        raise
    except Exception as e:
        print(f"Error parsing weather data from {file_path}: {e}")
        raise TimeStepDataError("Failed to load weather data") from e


def write_timestep_data_to_csv(file_path: str, time_step_data: list[TimeStepData]) -> None:
    """Save time step data to a CSV file with P90 CSV format versioning."""
    with open(file_path, mode="w", newline="") as csvfile:
        # Write version header first
        csvfile.write("# P90_CSV_VERSION=1.0\n")

        fieldnames = [
            "EndOfPeriodUTC (dd/mm/yyyy HH:mm)",
            "SolarAzimuth (degrees)",
            "SolarZenith (degrees)",
            "AmbientTemp (celsius)",
            "DHI (W/m2)",
            "GHI (W/m2)",
            "DNI (W/m2)",
            "WS (m/s)",
            "Tilt (degrees)",
            "Albedo",
            "SoilingFrontRate",
            "SoilingRearRate",
            "RearTransmissionFactor (%)",
            "RearStructuralShadingFactor (%)",
        ]
        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
        writer.writeheader()
        for point in time_step_data:
            # Convert protobuf Timestamp to Python datetime if it's a Timestamp object
            end_time = point.EndOfPeriodUTC.ToDatetime(tzinfo=timezone.utc)  # noqa

            writer.writerow(
                {
                    "EndOfPeriodUTC (dd/mm/yyyy HH:mm)": end_time.strftime("%d/%m/%Y %H:%M") if isinstance(end_time, datetime) else str(end_time),
                    "SolarAzimuth (degrees)": point.SolarPos.AzimuthDegrees,
                    "SolarZenith (degrees)": point.SolarPos.ZenithDegrees,
                    "AmbientTemp (celsius)": kelvin_to_celsius(point.Weather.AmbientTemp),
                    "DHI (W/m2)": point.Weather.DHI,
                    "GHI (W/m2)": point.Weather.GHI,
                    "DNI (W/m2)": point.Weather.DNI,
                    "WS (m/s)": point.Weather.WS,
                    "Tilt (degrees)": point.TiltAngleInDegrees if hasattr(point, "TiltAngleInDegrees") and point.TiltAngleInDegrees is not None else "",
                    "Albedo": point.Albedo if hasattr(point, "Albedo") and point.Albedo is not None else "",
                    "SoilingFrontRate": point.SoilingFrontRate if hasattr(point, "SoilingFrontRate") and point.SoilingFrontRate is not None else "",
                    "SoilingRearRate": point.SoilingRearRate if hasattr(point, "SoilingRearRate") and point.SoilingRearRate is not None else "",
                    "RearTransmissionFactor (%)": (
                        point.RearTransmissionFactor * 100.0 if hasattr(point, "RearTransmissionFactor") and point.RearTransmissionFactor is not None else ""
                    ),
                    "RearStructuralShadingFactor (%)": (
                        point.RearStructuralShadingFactor * 100.0
                        if hasattr(point, "RearStructuralShadingFactor") and point.RearStructuralShadingFactor is not None
                        else ""
                    ),
                }
            )


def celsius_to_kelvin(celsius: float | None) -> float | None:
    """Convert Celsius to Kelvin, handling None values."""
    return celsius + 273.15 if celsius is not None else None


def kelvin_to_celsius(kelvin: float | None) -> float | None:
    """Convert Kelvin to Celsius, handling None values."""
    return kelvin - 273.15 if kelvin is not None else None
