from math import ceil, floor
from typing import TYPE_CHECKING

from .cloud_cover import CLOUD_COVER_PLOT_HEIGHT_RATIO
from .common import (
    COLOUR_BLACK,
    ChartBounds,
    FontType,
    RenderMetrics,
    build_vertical_band,
    measure_text,
    measure_text_width,
)
from .forecast import build_hourly_series, build_sun_event_markers

if TYPE_CHECKING:
    from collections.abc import Callable
    from datetime import datetime

    from PIL import ImageDraw

    from kindle_dashboard.weather import Forecast

    from .chart_context import ChartRenderContext

COLOUR_LIGHT_GREY = 191
COLOUR_MID_GREY = 128
TEMPERATURE_PLOT_TOP_RATIO = 0.06
TEMPERATURE_PLOT_BOTTOM_RATIO = 0.65
TEMPERATURE_PLOT_DEFAULT_MIN = 5.0
TEMPERATURE_PLOT_DEFAULT_MAX = 25.0
TEMPERATURE_RULE_INTERVAL_DEGREES = 5
TEMPERATURE_RULE_COLOUR = 223


def format_temperature_label(value: float) -> str:
    return f"{round(value):.0f}\N{DEGREE SIGN}"


def draw_temperature_annotation(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    anchor_x: int,
    anchor_y: int,
    text: str,
    *,
    prefer_above: bool,
    temperature_label_offset: int,
    minimum_y_for_above_label: int | None = None,
) -> None:
    text_width, text_height, _ = measure_text(draw, font, text)

    text_x = anchor_x - (text_width // 2)
    image_width = draw.im.size[0]
    max_text_x = max(0, image_width - text_width)
    text_x = max(0, min(text_x, max_text_x))

    if prefer_above:
        preferred_above_y = anchor_y - text_height - temperature_label_offset
        if (
            minimum_y_for_above_label is not None
            and preferred_above_y < minimum_y_for_above_label
        ):
            text_y = anchor_y + temperature_label_offset
        else:
            text_y = preferred_above_y
    else:
        text_y = anchor_y + temperature_label_offset
    draw.text((text_x, text_y), text, fill=COLOUR_BLACK, font=font)


def draw_temperature_extrema(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    point_locations: dict[int, tuple[int, int]],
    extrema_points: list[tuple[int, float, bool]],
    *,
    marker_radius: int,
    temperature_label_offset: int,
    minimum_y_for_above_label: int | None = None,
) -> None:
    seen_marker_indices: set[int] = set()
    for point_index, point_temperature, is_max in extrema_points:
        point_x, point_y = point_locations[point_index]
        if point_index not in seen_marker_indices:
            draw.ellipse(
                (
                    point_x - marker_radius,
                    point_y - marker_radius,
                    point_x + marker_radius,
                    point_y + marker_radius,
                ),
                fill=COLOUR_BLACK,
                outline=COLOUR_BLACK,
            )
            seen_marker_indices.add(point_index)

        draw_temperature_annotation(
            draw=draw,
            font=font,
            anchor_x=point_x,
            anchor_y=point_y,
            text=format_temperature_label(point_temperature),
            prefer_above=is_max,
            temperature_label_offset=temperature_label_offset,
            minimum_y_for_above_label=minimum_y_for_above_label,
        )


def draw_sunrise_sunset_arrows(
    draw: ImageDraw.ImageDraw,
    chart_bounds: ChartBounds,
    hour_slots: list[datetime],
    forecast: Forecast,
    slot_temperatures: list[float],
    temperature_to_y: Callable[[float], int],
    metrics: RenderMetrics,
) -> None:
    window_start = hour_slots[0]
    window_end = hour_slots[-1]
    sun_markers = build_sun_event_markers(forecast, window_start, window_end)
    span_hours = float(len(hour_slots) - 1)
    for marker_time, is_sunrise in sun_markers:
        offset_hours = (marker_time - window_start).total_seconds() / 3600.0
        if offset_hours < 0 or offset_hours > span_hours:
            continue
        marker_x = chart_bounds.left + round(
            ((chart_bounds.right - chart_bounds.left) * offset_hours) / span_hours
        )
        temperature_line_y = estimate_temperature_line_y(
            marker_time=marker_time,
            window_start=window_start,
            slot_temperatures=slot_temperatures,
            temperature_to_y=temperature_to_y,
        )

        marker_tip_y = (
            temperature_line_y - metrics.temperature_label_offset
            if is_sunrise
            else temperature_line_y + metrics.temperature_label_offset
        )
        draw_directional_arrow(
            draw,
            x=marker_x,
            tip_y=marker_tip_y,
            is_up=is_sunrise,
            metrics=metrics,
        )


def draw_directional_arrow(
    draw: ImageDraw.ImageDraw,
    *,
    x: int,
    tip_y: int,
    is_up: bool,
    metrics: RenderMetrics,
) -> None:
    arrow_head_size = max(2, metrics.thin_shape_stroke_width * 2)
    stem_length = max(4, metrics.tick_length * 2)
    line_width = metrics.thin_shape_stroke_width
    marker_colour = COLOUR_MID_GREY

    if is_up:
        stem_top = tip_y + arrow_head_size
        stem_bottom = stem_top + stem_length
        draw.line((x, stem_top, x, stem_bottom), fill=marker_colour, width=line_width)
        draw.line(
            (x, tip_y, x - arrow_head_size, stem_top),
            fill=marker_colour,
            width=line_width,
        )
        draw.line(
            (x, tip_y, x + arrow_head_size, stem_top),
            fill=marker_colour,
            width=line_width,
        )
        return

    stem_bottom = tip_y - arrow_head_size
    stem_top = stem_bottom - stem_length
    draw.line((x, stem_top, x, stem_bottom), fill=marker_colour, width=line_width)
    draw.line(
        (x, tip_y, x - arrow_head_size, stem_bottom),
        fill=marker_colour,
        width=line_width,
    )
    draw.line(
        (x, tip_y, x + arrow_head_size, stem_bottom),
        fill=marker_colour,
        width=line_width,
    )


def estimate_temperature_line_y(
    *,
    marker_time: datetime,
    window_start: datetime,
    slot_temperatures: list[float],
    temperature_to_y: Callable[[float], int],
) -> int:
    marker_offset_hours = max(
        0.0,
        min(
            float(len(slot_temperatures) - 1),
            (marker_time - window_start).total_seconds() / 3600.0,
        ),
    )
    left_index = int(marker_offset_hours)
    right_index = min(left_index + 1, len(slot_temperatures) - 1)
    interpolation = marker_offset_hours - left_index

    left_temperature = slot_temperatures[left_index]
    right_temperature = slot_temperatures[right_index]
    chosen_temperature = left_temperature + (
        (right_temperature - left_temperature) * interpolation
    )
    return temperature_to_y(chosen_temperature)


def draw_temperature_chart(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    chart_bounds: ChartBounds,
    chart_context: ChartRenderContext,
    forecast: Forecast,
    metrics: RenderMetrics,
) -> None:
    hour_slots = chart_context.hour_slots
    x_axis_y = chart_context.x_axis_y
    x_positions = chart_context.x_positions

    slot_temperatures = build_hourly_series(
        forecast,
        forecast.temperature_2m,
        hour_slots,
        hour_shift=0,
    )

    temperature_band_top, temperature_band_bottom = build_vertical_band(
        chart_bounds.top,
        chart_bounds.bottom,
        top_ratio=TEMPERATURE_PLOT_TOP_RATIO,
        bottom_ratio=TEMPERATURE_PLOT_BOTTOM_RATIO,
    )
    _, cloud_band_bottom = build_vertical_band(
        chart_bounds.top,
        chart_bounds.bottom,
        top_ratio=0.0,
        bottom_ratio=CLOUD_COVER_PLOT_HEIGHT_RATIO,
    )
    plot_top = temperature_band_top + metrics.plot_padding
    plot_bottom = temperature_band_bottom - metrics.plot_padding
    draw.line(
        (
            chart_bounds.left,
            x_axis_y,
            chart_bounds.right,
            x_axis_y,
        ),
        fill=COLOUR_BLACK,
        width=metrics.thin_shape_stroke_width,
    )

    hour_label_widths: dict[str, int] = {}
    for index, slot in enumerate(hour_slots):
        x_position = x_positions[index]
        if slot.hour == 0:
            draw.line(
                (
                    x_position,
                    chart_bounds.top,
                    x_position,
                    x_axis_y,
                ),
                fill=COLOUR_LIGHT_GREY,
                width=metrics.thin_shape_stroke_width,
            )

        draw.line(
            (
                x_position,
                x_axis_y,
                x_position,
                x_axis_y - metrics.tick_length,
            ),
            fill=COLOUR_BLACK,
            width=metrics.thin_shape_stroke_width,
        )

        if slot.hour == 0:
            continue

        hour_label = slot.strftime("%H")
        hour_width = hour_label_widths.get(hour_label)
        if hour_width is None:
            hour_width = measure_text_width(draw, font, hour_label)
            hour_label_widths[hour_label] = hour_width
        draw.text(
            (
                x_position - hour_width // 2,
                x_axis_y + metrics.axis_label_gap,
            ),
            hour_label,
            fill=COLOUR_BLACK,
            font=font,
        )

    observed_min_temperature = min(slot_temperatures)
    observed_max_temperature = max(slot_temperatures)
    first_relevant_rule_temperature = int(
        floor(observed_min_temperature / TEMPERATURE_RULE_INTERVAL_DEGREES)
        * TEMPERATURE_RULE_INTERVAL_DEGREES
    )
    last_relevant_rule_temperature = int(
        ceil(observed_max_temperature / TEMPERATURE_RULE_INTERVAL_DEGREES)
        * TEMPERATURE_RULE_INTERVAL_DEGREES
    )
    min_chart_temperature = min(
        TEMPERATURE_PLOT_DEFAULT_MIN,
        float(first_relevant_rule_temperature),
    )
    max_chart_temperature = max(
        TEMPERATURE_PLOT_DEFAULT_MAX,
        float(last_relevant_rule_temperature),
    )
    value_span = max_chart_temperature - min_chart_temperature
    plot_height = plot_bottom - plot_top

    def temperature_to_y(temperature: float) -> int:
        clamped_temperature = max(
            min_chart_temperature,
            min(temperature, max_chart_temperature),
        )
        normalised = (clamped_temperature - min_chart_temperature) / value_span
        return plot_bottom - round(normalised * plot_height)

    for rule_temperature in range(
        first_relevant_rule_temperature,
        last_relevant_rule_temperature + 1,
        TEMPERATURE_RULE_INTERVAL_DEGREES,
    ):
        normalised = (rule_temperature - min_chart_temperature) / value_span
        rule_y = plot_bottom - round(normalised * plot_height)
        if plot_top <= rule_y <= plot_bottom:
            draw.line(
                (
                    chart_bounds.left,
                    rule_y,
                    chart_bounds.right,
                    rule_y,
                ),
                fill=TEMPERATURE_RULE_COLOUR,
                width=max(1, metrics.thin_shape_stroke_width),
            )

    draw_sunrise_sunset_arrows(
        draw=draw,
        chart_bounds=chart_bounds,
        hour_slots=hour_slots,
        forecast=forecast,
        slot_temperatures=slot_temperatures,
        temperature_to_y=temperature_to_y,
        metrics=metrics,
    )

    line_points = [
        (x_position, temperature_to_y(temperature))
        for x_position, temperature in zip(x_positions, slot_temperatures, strict=False)
    ]
    point_locations = dict(enumerate(line_points))

    draw.line(
        line_points,
        fill=COLOUR_BLACK,
        width=metrics.temperature_plot_line_width,
    )

    min_index, min_temperature = min(
        enumerate(slot_temperatures), key=lambda item: item[1]
    )
    max_index, max_temperature = max(
        enumerate(slot_temperatures), key=lambda item: item[1]
    )
    extrema_points = [
        (min_index, min_temperature, False),
        (max_index, max_temperature, True),
    ]
    draw_temperature_extrema(
        draw=draw,
        font=font,
        point_locations=point_locations,
        extrema_points=extrema_points,
        marker_radius=metrics.marker_radius,
        temperature_label_offset=metrics.temperature_label_offset,
        minimum_y_for_above_label=cloud_band_bottom + metrics.plot_padding,
    )
