from dataclasses import dataclass

from PIL import Image, ImageDraw, ImageFont

ERROR_TEXT_LINE_SPACING = 2
THIN_SHAPE_FINAL_STROKE_WIDTH = 1.5
KINDLE_GREYSCALE_LEVELS = 16
KINDLE_GREYSCALE_STEP = 255 / (KINDLE_GREYSCALE_LEVELS - 1)

MIN_CHART_DIMENSION = 20
TEMPERATURE_PLOT_LINE_WIDTH = 3
TEMPERATURE_LABEL_OFFSET = 10

TEXT_ELLIPSIS = "..."

COLOUR_BLACK = 0
COLOUR_WHITE = 255

FontType = ImageFont.ImageFont | ImageFont.FreeTypeFont


@dataclass(frozen=True, slots=True)
class RenderMetrics:
    border_width: int
    padding: int
    group_spacing: int
    battery_icon_min_height: int
    battery_icon_text_padding: int
    temperature_plot_line_width: int
    temperature_label_offset: int
    error_text_line_spacing: int
    thin_shape_stroke_width: int
    tick_length: int
    axis_label_gap: int
    plot_padding: int
    marker_radius: int
    min_chart_dimension: int


@dataclass(frozen=True, slots=True)
class ChartBounds:
    left: int
    top: int
    right: int
    bottom: int


def build_render_metrics(width: int, height: int, render_scale: int) -> RenderMetrics:
    scaled_width = width * render_scale
    scaled_height = height * render_scale
    return RenderMetrics(
        border_width=max(1, min(scaled_width, scaled_height) // 200),
        padding=max(8 * render_scale, min(scaled_width, scaled_height) // 60),
        group_spacing=6 * render_scale,
        battery_icon_min_height=12 * render_scale,
        battery_icon_text_padding=2 * render_scale,
        temperature_plot_line_width=TEMPERATURE_PLOT_LINE_WIDTH * render_scale,
        temperature_label_offset=TEMPERATURE_LABEL_OFFSET * render_scale,
        error_text_line_spacing=ERROR_TEXT_LINE_SPACING * render_scale,
        thin_shape_stroke_width=scale_thin_shape_stroke(render_scale),
        tick_length=3 * render_scale,
        axis_label_gap=4 * render_scale,
        plot_padding=2 * render_scale,
        marker_radius=3 * render_scale,
        min_chart_dimension=MIN_CHART_DIMENSION * render_scale,
    )


def build_x_positions(
    chart_left: int,
    chart_right: int,
    point_count: int,
) -> list[int]:
    return [
        chart_left + round((chart_right - chart_left) * index / (point_count - 1))
        for index in range(point_count)
    ]


def build_chart_bounds(
    scaled_width: int,
    scaled_height: int,
    metrics: RenderMetrics,
) -> ChartBounds:
    return ChartBounds(
        left=metrics.border_width + metrics.padding,
        top=metrics.border_width,
        right=scaled_width - metrics.border_width - metrics.padding,
        bottom=scaled_height - metrics.border_width - metrics.padding,
    )


def build_vertical_band(
    chart_top: int,
    chart_bottom: int,
    *,
    top_ratio: float,
    bottom_ratio: float,
) -> tuple[int, int]:
    chart_height = chart_bottom - chart_top + 1
    band_top = chart_top + round((chart_height - 1) * top_ratio)
    band_bottom = chart_top + round((chart_height - 1) * bottom_ratio)
    return band_top, band_bottom


def measure_text(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    text: str,
) -> tuple[int, int, int]:
    left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
    return round(right - left), round(bottom - top), round(top)


def measure_text_width(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    text: str,
) -> int:
    return measure_text(draw, font, text)[0]


def fit_text_to_width(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    text: str,
    max_width: int,
    suffix: str,
) -> str:
    if measure_text_width(draw, font, f"{text}{suffix}") <= max_width:
        return text

    low = 0
    high = len(text)
    while low < high:
        middle = (low + high + 1) // 2
        if measure_text_width(draw, font, f"{text[:middle]}{suffix}") <= max_width:
            low = middle
        else:
            high = middle - 1

    return text[:low]


def wrap_text_to_width(
    draw: ImageDraw.ImageDraw,
    font: FontType,
    text: str,
    max_width: int,
) -> list[str]:
    wrapped_lines: list[str] = []

    def append_fitted_segments(line: str) -> None:
        if measure_text_width(draw, font, line) <= max_width:
            wrapped_lines.append(line)
            return

        remainder = line
        while remainder:
            segment = fit_text_to_width(draw, font, remainder, max_width, suffix="")
            if not segment:
                wrapped_lines.append(line)
                return
            wrapped_lines.append(segment)
            remainder = remainder[len(segment) :]

    for paragraph in text.splitlines() or [""]:
        if not paragraph:
            wrapped_lines.append("")
            continue

        words = paragraph.split()
        if not words:
            wrapped_lines.append("")
            continue

        line = words[0]
        for word in words[1:]:
            candidate = f"{line} {word}"
            if measure_text_width(draw, font, candidate) <= max_width:
                line = candidate
            else:
                append_fitted_segments(line)
                line = word

        append_fitted_segments(line)

    return wrapped_lines


def quantise_to_kindle_greyscale(image: Image.Image) -> Image.Image:
    greyscale_image = image.convert("L")
    return greyscale_image.point(
        lambda value: max(
            0,
            min(
                255,
                round(round(value / KINDLE_GREYSCALE_STEP) * KINDLE_GREYSCALE_STEP),
            ),
        ),
    )


def scale_thin_shape_stroke(render_scale: int) -> int:
    return max(1, round(THIN_SHAPE_FINAL_STROKE_WIDTH * render_scale))


def finalise_dashboard_image(
    image: Image.Image,
    width: int,
    height: int,
) -> Image.Image:
    image = image.resize((width, height), resample=Image.Resampling.LANCZOS)
    return quantise_to_kindle_greyscale(image)
