"""Weather icon-drawing primitives (draw_cloud/draw_weather_icon/
draw_weather_row -- extracted out of calendar_render.py, which still
imports draw_weather_row for its own embedded weather strip, unchanged)
plus the standalone weather widget's four per-mode renderers
(build_current/build_hourly/build_daily/build_multi_city, dispatched by
build()) and its preview-PNG wrapper -- the weather analogue of
calendar_render.py's own _build_tasks/render_tasks_preview_png
relationship.

Every build_* function takes already-normalized data (see app/weather/'s
provider modules -- a `category` key from the shared clear/partly_cloudy/
cloudy/fog/rain/snow/thunderstorm set, never a raw provider code) and
returns an RGB Image exactly target_w x target_h, same contract every
other widget renderer in this project follows.
"""

from __future__ import annotations

import io
import math
from datetime import date, datetime

from PIL import Image, ImageDraw, ImageFont

from .image_pipeline import DEFAULT_PALETTE_RGB, _apply_manage_overlay, _quantize, draw_text, logical_render_size

MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
RULE = (0, 0, 0)


def _ink(palette_rgb: list | None, index: int) -> tuple[int, int, int]:
    """One of this frame's actual panel colors by DEFAULT_PALETTE_RGB
    index (2=yellow, 3=red, 4=blue, 5=green -- 0/1 are black/white,
    already this module's BG/FG) -- same resolution idiom as
    calendar_render.py's _event_colors, so a custom palette override
    (Frame.palette_rgb) still gets its own actual yellow/blue, not a
    hardcoded RGB triple."""
    return tuple((palette_rgb or DEFAULT_PALETTE_RGB)[index])


def draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, fill=BG, outline=FG) -> None:
    """A simple puffy-cloud silhouette (three overlapping lobes + a base)
    with a clean outline -- drawn as one outline-color pass slightly
    larger than the shapes, then the same shapes again in `fill` on top.
    Overlapping ellipses each drawn with their own `outline=` would leave
    visible seams where they cross; this double-draw trick sidesteps that
    entirely regardless of how the lobes overlap."""
    stroke = 2
    lobes = [
        (cx - r, cy - r * 0.3, cx - r * 0.1, cy + r * 0.6),
        (cx - r * 0.45, cy - r * 0.8, cx + r * 0.35, cy + r * 0.25),
        (cx, cy - r * 0.35, cx + r, cy + r * 0.6),
    ]
    base = (cx - r * 0.8, cy, cx + r * 0.8, cy + r * 0.5)
    for x0, y0, x1, y1 in lobes:
        draw.ellipse([x0 - stroke, y0 - stroke, x1 + stroke, y1 + stroke], fill=outline)
    draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=outline)
    for x0, y0, x1, y1 in lobes:
        draw.ellipse([x0, y0, x1, y1], fill=fill)
    draw.rectangle(base, fill=fill)


def draw_sun(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, color) -> None:
    """A filled disc + 8 thick radiating rays -- the standard weather-app
    "sun" glyph, in the panel's actual yellow ink rather than a bare
    outline (a plain circle-with-4-ticks at small sizes read as a
    crosshair/target, not a sun)."""
    draw.ellipse([cx - r * 0.55, cy - r * 0.55, cx + r * 0.55, cy + r * 0.55], fill=color)
    for i in range(8):
        angle = i * (math.pi / 4)
        dx, dy = math.cos(angle), math.sin(angle)
        draw.line([(cx + dx * r * 0.68, cy + dy * r * 0.68), (cx + dx * r * 1.05, cy + dy * r * 1.05)],
                 fill=color, width=max(2, round(r * 0.14)))


def draw_raindrop(draw: ImageDraw.ImageDraw, x: float, y: float, size: float, color) -> None:
    """A rounded teardrop (point up, bulb down) -- the standard rain
    glyph, not a bare diagonal tick."""
    draw.polygon([(x, y), (x - size * 0.38, y + size * 0.55), (x + size * 0.38, y + size * 0.55)], fill=color)
    draw.ellipse([x - size * 0.4, y + size * 0.25, x + size * 0.4, y + size * 1.05], fill=color)


def draw_snowflake(draw: ImageDraw.ImageDraw, x: float, y: float, r: float, color) -> None:
    """A 6-pointed asterisk -- the standard snowflake glyph."""
    for i in range(3):
        angle = i * (math.pi / 3)
        dx, dy = math.cos(angle) * r, math.sin(angle) * r
        draw.line([(x - dx, y - dy), (x + dx, y + dy)], fill=color, width=max(2, round(r * 0.3)))


def draw_lightning_bolt(draw: ImageDraw.ImageDraw, cx: float, cy: float, size: float, color) -> None:
    """A zigzag bolt polygon -- the standard lightning glyph, not a bare
    3-segment line."""
    points = [
        (cx + size * 0.15, cy - size * 0.7),
        (cx - size * 0.35, cy + size * 0.05),
        (cx - size * 0.05, cy + size * 0.05),
        (cx - size * 0.2, cy + size * 0.7),
        (cx + size * 0.4, cy - size * 0.1),
        (cx + size * 0.05, cy - size * 0.1),
    ]
    draw.polygon(points, fill=color)


def draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str,
                      palette_rgb: list | None = None) -> None:
    """A small glyph for one weather category -- no custom font/icon
    asset (hand-primitives only, same approach calendar_render.py uses
    elsewhere for e.g. month view's density dots), but drawn in the
    panel's own ink colors (yellow sun/bolt, blue rain/snow) rather than
    flat black -- a plain monochrome outline at small sizes read as
    abstract shapes (a "sun" that looked like a crosshair), not
    recognizable weather icons."""
    yellow = _ink(palette_rgb, 2)
    blue = _ink(palette_rgb, 4)

    if category == "clear":
        draw_sun(draw, cx, cy, r, yellow)
        return

    if category == "partly_cloudy":
        draw_sun(draw, cx - r * 0.45, cy - r * 0.45, r * 0.75, yellow)
        draw_cloud(draw, cx + r * 0.1, cy + r * 0.2, r * 0.9)
        return

    cloud_cy = cy if category in ("cloudy", "fog") else cy - r * 0.25
    draw_cloud(draw, cx, cloud_cy, r)

    if category == "fog":
        for i in range(3):
            y = cy + r * 0.55 + i * (r * 0.4)
            draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
    elif category == "rain":
        for dx in (-0.55, 0, 0.55):
            draw_raindrop(draw, cx + dx * r, cloud_cy + r * 0.55, r * 0.55, blue)
    elif category == "snow":
        for dx in (-0.55, 0, 0.55):
            draw_snowflake(draw, cx + dx * r, cloud_cy + r * 0.85, r * 0.3, blue)
    elif category == "thunderstorm":
        draw_lightning_bolt(draw, cx, cloud_cy + r * 0.7, r * 0.7, yellow)


def draw_weather_row(img: Image.Image, draw: ImageDraw.ImageDraw, x0: int, y0: int, max_w: int,
                     entries: list[dict], icon_r: int, font: ImageFont.ImageFont, units: str,
                     show_labels: bool = True, palette_rgb: list | None = None) -> int:
    """Draws one or more cities' weather side by side starting at
    (x0, y0), stopping once another entry wouldn't fit within max_w
    (narrow views like week columns just end up showing fewer cities --
    same graceful-degradation approach month view takes with density
    dots). Returns the row height consumed (0 if there was nothing to
    draw, so callers can skip reserving space entirely)."""
    if not entries:
        return 0
    unit_suffix = "F" if units == "fahrenheit" else "C"
    row_h = icon_r * 2 + 8
    x = x0
    drew_any = False
    for entry in entries:
        temps = f"{round(entry['high'])}°/{round(entry['low'])}°{unit_suffix}"
        label = f"{entry['label']} {temps}" if show_labels else temps
        entry_w = round(icon_r * 2 + 6 + draw.textlength(label, font=font) + 18)
        if drew_any and x + entry_w > x0 + max_w:
            break
        cx, cy = x + icon_r, y0 + icon_r
        draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
        draw_text(img, (x + icon_r * 2 + 6, y0 + (row_h - font.size) // 2), label, font)
        x += entry_w
        drew_any = True
    return row_h + 6


# --- Standalone weather widget (app/widgets/weather.py) -----------------

def _format_hour_label(iso_time: str) -> str:
    dt = datetime.fromisoformat(iso_time)
    text = dt.strftime("%I %p").lstrip("0")
    return text if text else "12 AM"


def _fit_font_size(draw: ImageDraw.ImageDraw, texts: list[str], max_width: int, max_size: int,
                   min_size: int = 9) -> int:
    """Largest size <= max_size at which every string in `texts` fits
    within max_width -- used to size a per-column label/temp font against
    the actual column width instead of an icon-radius-derived guess,
    which (e.g. "Tomorrow" vs. "Wed") let long labels overlap into the
    next column at a large icon size on a narrow column."""
    for size in range(max_size, min_size - 1, -1):
        font = ImageFont.load_default(size=size)
        if all(draw.textlength(t, font=font) <= max_width for t in texts):
            return size
    return min_size


def _day_label(day_date: date) -> str:
    delta = (day_date - date.today()).days
    if delta == 0:
        return "Today"
    if delta == 1:
        return "Tomorrow"
    return day_date.strftime("%a")


def build_current(entry: dict | None, target_w: int, target_h: int, palette_rgb: list | None = None,
                  units: str = "fahrenheit", city_label: str = "") -> Image.Image:
    """One big icon + big temp number + (optional) city label, centered --
    entry is {"temp", "category"} or None if nothing's been fetched yet
    (callers normally catch that earlier and show a placeholder instead,
    but this degrades to a blank canvas rather than erroring either
    way)."""
    img = Image.new("RGB", (target_w, target_h), BG)
    if not entry:
        return img
    draw = ImageDraw.Draw(img)

    icon_r = max(20, min(target_w, target_h) // 4)
    cx, cy = target_w // 2, target_h // 2 - icon_r // 2
    draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)

    unit_suffix = "F" if units == "fahrenheit" else "C"
    temp_size = max(24, min(target_w, target_h) // 3)
    temp_font = ImageFont.load_default(size=temp_size)
    temp_text = f"{round(entry['temp'])}°{unit_suffix}"
    bbox = draw.textbbox((0, 0), temp_text, font=temp_font)
    temp_y = cy + icon_r + 12
    draw_text(img, (target_w // 2 - (bbox[2] - bbox[0]) // 2, temp_y), temp_text, temp_font)

    if city_label:
        label_size = max(12, temp_size // 3)
        label_font = ImageFont.load_default(size=label_size)
        lbbox = draw.textbbox((0, 0), city_label, font=label_font)
        draw_text(img, (target_w // 2 - (lbbox[2] - lbbox[0]) // 2, temp_y + temp_size + 8),
                 city_label, label_font, MUTED)
    return img


def build_hourly(entries: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
                 units: str = "fahrenheit", interval_hours: int = 4, city_label: str = "") -> Image.Image:
    """A row of ticks across the day, one every `interval_hours` hours
    (entries is always 1-hour resolution -- see app/weather's provider
    fetch_hourly), each showing an hour label, icon, and temp. Same
    "draw however many fit" graceful degradation as draw_weather_row if
    the box is too narrow for every tick."""
    img = Image.new("RGB", (target_w, target_h), BG)
    draw = ImageDraw.Draw(img)
    text_x0 = MARGIN
    text_w = target_w - MARGIN * 2
    y = MARGIN

    title_size = max(14, min(target_w, target_h) // 16)
    if city_label:
        title_font = ImageFont.load_default(size=title_size)
        draw_text(img, (text_x0, y), city_label, title_font)
        y += title_size + 10
        draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
        y += 12

    # Capped to however many columns actually fit at a legible width
    # (narrower widgets/smaller intervals just show fewer ticks) rather
    # than cramming every sampled tick in regardless of how narrow that
    # makes each one -- same graceful-degradation idiom as
    # draw_weather_row's own per-pixel-width stopping point.
    min_col_w = 46
    max_ticks = max(1, text_w // min_col_w)
    ticks = entries[::max(1, interval_hours)][:max_ticks]
    if not ticks:
        return img
    col_w = max(1, text_w // len(ticks))
    icon_r = max(10, min(col_w // 3, (target_h - y - MARGIN) // 4))
    unit_suffix = "F" if units == "fahrenheit" else "C"
    time_labels = [_format_hour_label(e["time"]) for e in ticks]
    temp_labels = [f"{round(e['temp'])}°{unit_suffix}" for e in ticks]
    label_size = _fit_font_size(draw, time_labels + temp_labels, col_w - 6, max_size=icon_r)
    label_font = ImageFont.load_default(size=label_size)

    for i, entry in enumerate(ticks):
        cx = text_x0 + i * col_w + col_w // 2
        time_label = time_labels[i]
        tbbox = draw.textbbox((0, 0), time_label, font=label_font)
        draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, y), time_label, label_font, MUTED)
        cy = y + label_size + 10 + icon_r
        draw_weather_icon(draw, cx, cy, icon_r, entry["category"], palette_rgb)
        temp_label = temp_labels[i]
        tempbbox = draw.textbbox((0, 0), temp_label, font=label_font)
        draw_text(img, (cx - (tempbbox[2] - tempbbox[0]) // 2, cy + icon_r + 6), temp_label, label_font)
    return img


def build_daily(daily: dict[str, dict], target_w: int, target_h: int, palette_rgb: list | None = None,
                units: str = "fahrenheit", city_label: str = "") -> Image.Image:
    """A day-by-day strip (day label, icon, high/low), however many days
    fit `target_w` (`daily` is already clamped to the widget's own
    configured day count by app/weather's provider fetch_daily -- this
    just draws whatever it's handed, same "stop once it doesn't fit"
    graceful degradation as draw_weather_row)."""
    img = Image.new("RGB", (target_w, target_h), BG)
    draw = ImageDraw.Draw(img)
    text_x0 = MARGIN
    text_w = target_w - MARGIN * 2
    y = MARGIN

    title_size = max(14, min(target_w, target_h) // 16)
    if city_label:
        title_font = ImageFont.load_default(size=title_size)
        draw_text(img, (text_x0, y), city_label, title_font)
        y += title_size + 10
        draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
        y += 12

    days = list(daily.items())
    if not days:
        return img
    col_w = max(1, text_w // len(days))
    icon_r = max(12, min(col_w // 3, (target_h - y - MARGIN) // 4))
    unit_suffix = "F" if units == "fahrenheit" else "C"
    labels = [_day_label(date.fromisoformat(day_str)) for day_str, _ in days]
    temps_strs = [f"{round(d['high'])}°/{round(d['low'])}°{unit_suffix}" for _, d in days]
    label_size = _fit_font_size(draw, labels + temps_strs, col_w - 6, max_size=icon_r)
    label_font = ImageFont.load_default(size=label_size)

    for i, (_, d) in enumerate(days):
        x0 = text_x0 + i * col_w
        label, temps = labels[i], temps_strs[i]
        lbbox = draw.textbbox((0, 0), label, font=label_font)
        draw_text(img, (x0 + col_w // 2 - (lbbox[2] - lbbox[0]) // 2, y), label, label_font, MUTED)
        cx, cy = x0 + col_w // 2, y + label_size + 10 + icon_r
        draw_weather_icon(draw, cx, cy, icon_r, d["category"], palette_rgb)
        tbbox = draw.textbbox((0, 0), temps, font=label_font)
        draw_text(img, (cx - (tbbox[2] - tbbox[0]) // 2, cy + icon_r + 8), temps, label_font)
    return img


def build_multi_city(cities: list[dict], target_w: int, target_h: int, palette_rgb: list | None = None,
                     units: str = "fahrenheit") -> Image.Image:
    """Several cities' current-day high/low/icon side by side -- directly
    reuses draw_weather_row (the same layout calendar_render.py's
    embedded strip uses), just as the whole widget's own content instead
    of a strip above an agenda day."""
    img = Image.new("RGB", (target_w, target_h), BG)
    if not cities:
        return img
    draw = ImageDraw.Draw(img)
    # Just the city name on-panel ("Portland", not the full disambiguated
    # "Portland, Oregon, United States") -- that fuller form matters for
    # telling apart geocoder candidates when adding a city (see
    # weather.geocode_city, and the dialog's own "Cities" management
    # list), not for a compact display row. Same shortening
    # calendar_render.py's _weather_for_day already does for its own
    # embedded strip.
    cities = [{**c, "label": c["label"].split(",")[0].strip()} for c in cities]
    text_w = target_w - MARGIN * 2
    # Sized against how many entries actually need to fit side by side,
    # not just the box's height -- an icon/font picked from target_h
    # alone (as this used to do) drew each entry so wide that only the
    # first city ever fit, and draw_weather_row's own "stop once it
    # doesn't fit" degradation silently dropped every city after it,
    # even in an ordinary-sized widget with plenty of cities configured.
    col_w = max(1, text_w // len(cities))
    icon_r = max(10, min(col_w // 6, target_h // 6, 40))
    unit_suffix = "F" if units == "fahrenheit" else "C"
    labels = [f"{c['label']} {round(c['high'])}°/{round(c['low'])}°{unit_suffix}" for c in cities]
    font_size = _fit_font_size(draw, labels, col_w - (icon_r * 2 + 24), max_size=icon_r)
    font = ImageFont.load_default(size=font_size)
    y = max(MARGIN, (target_h - (icon_r * 2 + 8)) // 2)
    draw_weather_row(img, draw, MARGIN, y, text_w, cities, icon_r=icon_r, font=font, units=units,
                     show_labels=True, palette_rgb=palette_rgb)
    return img


def build(mode: str, data, target_w: int, target_h: int, palette_rgb: list | None = None,
         units: str = "fahrenheit", city_label: str = "", interval_hours: int = 4) -> Image.Image:
    """Dispatches to the right build_* function for this widget's
    configured mode -- shared by app/widgets/weather.py's render() and
    render_weather_preview_png below, so the two never drift apart."""
    if mode == "current":
        return build_current(data, target_w, target_h, palette_rgb, units, city_label)
    if mode == "hourly":
        return build_hourly(data, target_w, target_h, palette_rgb, units, interval_hours, city_label)
    if mode == "daily":
        return build_daily(data, target_w, target_h, palette_rgb, units, city_label)
    if mode == "multi_city":
        return build_multi_city(data, target_w, target_h, palette_rgb, units)
    return Image.new("RGB", (target_w, target_h), BG)  # unreachable via a valid config -- see WeatherWidgetConfig.mode


def render_weather_preview_png(mode: str, data, orientation: str, palette_rgb: list | None,
                               units: str = "fahrenheit", manage: dict | None = None,
                               city_label: str = "", interval_hours: int = 4) -> bytes:
    """Same pipeline as calendar_render.render_tasks_preview_png -- a
    normal browser-viewable PNG in logical (upright) orientation."""
    target_w, target_h = logical_render_size(orientation)
    img = build(mode, data, target_w, target_h, palette_rgb, units, city_label, interval_hours)
    img = _apply_manage_overlay(img, manage)
    quantized = _quantize(img, palette_rgb, dither_strength=1.0)
    buf = io.BytesIO()
    quantized.convert("RGB").save(buf, format="PNG")
    return buf.getvalue()
