"""Renders calendar frame mode's three views (agenda/week/month) into the
panel's packed format, following image_pipeline.render_placeholder's own
precedent: build an RGB canvas with ImageDraw/ImageFont, then the same
_quantize/_transpose_and_pack every other renderer ends on.

Event dicts here are calendar_feed.py's shape: {"summary", "start", "end"
(ISO 8601 strings), "all_day", "sources": [{"owner_display_name",
"color_index"}, ...]} -- more than one entry in "sources" means
merge_events collapsed several calendars' identical (same title/time)
events into one, see _event_colors/_draw_color_bar below.
"""

from __future__ import annotations

import calendar as calendar_module
import io
import re
from datetime import date, datetime, timedelta
from functools import lru_cache
from pathlib import Path
from zoneinfo import ZoneInfo

from PIL import Image, ImageDraw, ImageFont

from .image_pipeline import (
    DEFAULT_PALETTE_RGB,
    _apply_manage_overlay,
    _quantize,
    _transpose_and_pack,
    draw_text,
    logical_render_size,
)
from .weather import weather_category

CALENDAR_VIEWS = ["agenda", "today_tomorrow", "week", "month"]
CALENDAR_VIEW_LABELS = {"agenda": "Agenda (today)", "today_tomorrow": "Agenda (today & tomorrow)",
                        "week": "Week", "month": "Month"}
WEEKDAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]

MARGIN = 20
BG = (255, 255, 255)
FG = (0, 0, 0)
MUTED = (110, 110, 110)
# Was a light gray, but that dithers away to near-invisible once quantized
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
RULE = (0, 0, 0)

# Fallback for any event whose calendar has no manually pinned color
# (event["color_index"] is None): cycled per distinct owner_display_name
# so a merged multi-person calendar can still visually tell whose event
# is whose -- the panel's own non-black/white ink colors, skipping
# black/white (index 0/1 in DEFAULT_PALETTE_RGB) since those are already
# the page's text/background.
OWNER_COLORS = DEFAULT_PALETTE_RGB[2:]


def _event_colors(event: dict, owners_seen: list[str], palette_rgb: list | None) -> list[tuple[int, int, int]]:
    """One color per contributing calendar (event["sources"] -- see
    calendar_feed.merge_events, which collapses events sharing the exact
    same title/time across different calendars into one entry with
    several sources, e.g. a shared family event synced onto more than
    one person's calendar). Usually just one color; more than one is
    what tells the "same event, more than one calendar" case apart from
    an ordinary single-calendar event at render time -- see
    _draw_color_bar. Each source's own manually pinned color
    (FrameCalendar.color_index -- see routers/api_frames.py's
    api_calendar_color) resolves against whichever palette this frame
    actually renders with, so a pinned "Blue" stays this frame's actual
    blue; a source with no color pinned falls back to the old
    auto-cycle-by-owner-name behavior. owners_seen is shared across every
    event/source in a render so that cycle stays consistent view-wide."""
    sources = event.get("sources") or [
        {"owner_display_name": event.get("owner_display_name"), "color_index": event.get("color_index")}
    ]
    colors = []
    for source in sources:
        color_index = source.get("color_index")
        if color_index is not None:
            palette = palette_rgb or DEFAULT_PALETTE_RGB
            colors.append(tuple(palette[color_index]))
            continue
        owner_display_name = source.get("owner_display_name")
        if owner_display_name not in owners_seen:
            owners_seen.append(owner_display_name)
        colors.append(OWNER_COLORS[owners_seen.index(owner_display_name) % len(OWNER_COLORS)])
    return colors


def _draw_color_bar(draw: ImageDraw.ImageDraw, x0: int, y0: int, x1: int, y1: int,
                    colors: list[tuple[int, int, int]], radius: int) -> None:
    """One rounded bar for a single-source event, or that same overall
    footprint split into equal-width side-by-side segments -- one per
    contributing calendar -- for a deduplicated shared event (see
    _event_colors/calendar_feed.merge_events). Splitting rather than
    e.g. concentric rings keeps every color equally "thick and bold" at
    a glance, the same design goal a single pinned color already has."""
    if len(colors) == 1:
        draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=colors[0])
        return
    seg_w = (x1 - x0) / len(colors)
    for i, color in enumerate(colors):
        seg_x0 = round(x0 + i * seg_w)
        seg_x1 = round(x0 + (i + 1) * seg_w) - (2 if i < len(colors) - 1 else 0)
        draw.rectangle([seg_x0, y0, seg_x1, y1], fill=color)


def _event_start(event: dict, tz: ZoneInfo) -> datetime | date:
    """Parses event["start"] and, for timed events, converts to `tz` --
    calendar_feed.py stores whatever timezone each source event carried
    (often UTC), but display/bucketing needs to happen in the frame's own
    timezone."""
    dt = datetime.fromisoformat(event["start"])
    if event["all_day"]:
        return dt if isinstance(dt, date) and not isinstance(dt, datetime) else dt.date()
    return dt.astimezone(tz)


def _events_on_day(events: list[dict], day: date, tz: ZoneInfo) -> list[dict]:
    on_day = [e for e in events if _local_date(e, tz) == day]
    on_day.sort(key=lambda e: (not e["all_day"], e["start"]))
    return on_day


def _local_date(event: dict, tz: ZoneInfo) -> date:
    start = _event_start(event, tz)
    return start if isinstance(start, date) and not isinstance(start, datetime) else start.date()


def _add_months(d: date, months: int) -> date:
    total = d.month - 1 + months
    year = d.year + total // 12
    month = total % 12 + 1
    day = min(d.day, calendar_module.monthrange(year, month)[1])
    return date(year, month, day)


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


def _fmt_task_due(due: str | None) -> str:
    """"2026-07-25" or "2026-07-25T14:00:00+00:00" -> "Jul 25" -- tasks
    only need a compact reminder of when they're due, not the precision
    an event's own start/end time gets."""
    if not due:
        return ""
    try:
        dt = datetime.fromisoformat(due)
    except ValueError:
        return ""
    d = dt.date() if isinstance(dt, datetime) else dt
    return d.strftime("%b %-d")


# ImageFont.load_default() (used for everything else in this module --
# see the module docstring) has no emoji glyphs, and PIL/FreeType don't
# skip an unsupported codepoint, they substitute a ".notdef" tofu box (a
# visible filled rectangle) -- reads as a rendering glitch, not "emoji
# not supported". So event titles get drawn with two fonts: the normal
# text font for everything else, and one of these for actual emoji runs
# (see _split_emoji_runs/_draw_mixed_line) -- both Noto Emoji, OFL-1.1,
# vendored at app/fonts/ (license alongside at app/fonts/OFL.txt).
#
# Color (NotoColorEmoji.ttf) is tried first: full-color CBDT bitmap
# glyphs, which the panel's own Floyd-Steinberg dithering turns into a
# recognizable (if slightly speckled) color rendering rather than a flat
# monochrome shape -- confirmed by actually rendering a test agenda row
# through the real quantizer, not just theorizing about it. Its one
# real quirk: CBDT stores glyphs at a single embedded bitmap size
# (_COLOR_EMOJI_NATIVE_SIZE), so every glyph is rasterized once at that
# size and scaled down to the target row height, unlike normal vector
# text which draws directly at whatever size is asked for.
#
# NotoEmoji.ttf (monochrome, vector) is the fallback for a deployment
# whose Pillow/FreeType wasn't built with embedded color bitmap support
# -- confirmed working locally, but that's a build-time detail this
# project doesn't control everywhere it might run, so a rendering
# failure falls back instead of showing nothing/crashing.
_COLOR_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoColorEmoji.ttf"
_MONO_EMOJI_FONT_PATH = Path(__file__).parent / "fonts" / "NotoEmoji.ttf"
_COLOR_EMOJI_NATIVE_SIZE = 109


@lru_cache(maxsize=1)
def _color_emoji_font() -> ImageFont.FreeTypeFont | None:
    try:
        return ImageFont.truetype(str(_COLOR_EMOJI_FONT_PATH), _COLOR_EMOJI_NATIVE_SIZE)
    except Exception:
        return None


@lru_cache(maxsize=None)
def _mono_emoji_font(size: int) -> ImageFont.FreeTypeFont:
    return ImageFont.truetype(str(_MONO_EMOJI_FONT_PATH), size)


@lru_cache(maxsize=512)
def _emoji_glyph(run_text: str, target_h: int) -> Image.Image:
    """One emoji run (consecutive emoji collapse into a single run, see
    _split_emoji_runs) as an RGBA image target_h tall, ready to
    alpha-composite onto the canvas. Tries color first, falls back to
    monochrome (rendered directly at target_h, since that font is
    vector) if the color font failed to load or this Pillow/FreeType
    build can't decode its embedded bitmaps. Cached -- the same emoji
    recurs across a household's events, and rasterizing+scaling isn't
    free."""
    color_font = _color_emoji_font()
    if color_font is not None:
        try:
            probe = ImageDraw.Draw(Image.new("RGBA", (1, 1)))
            raw_w = max(1, round(probe.textlength(run_text, font=color_font)))
            tmp = Image.new("RGBA", (raw_w, _COLOR_EMOJI_NATIVE_SIZE), (255, 255, 255, 0))
            ImageDraw.Draw(tmp).text((0, 0), run_text, font=color_font, embedded_color=True)
            scale = target_h / _COLOR_EMOJI_NATIVE_SIZE
            return tmp.resize((max(1, round(raw_w * scale)), target_h), Image.LANCZOS)
        except Exception:
            pass  # this deployment's Pillow can't render embedded color bitmaps -- fall back

    mono_font = _mono_emoji_font(target_h)
    bbox = mono_font.getbbox(run_text)
    w, h = max(1, bbox[2] - bbox[0]), max(1, bbox[3] - bbox[1])
    mask = Image.new("L", (w, h), 0)
    ImageDraw.Draw(mask).text((-bbox[0], -bbox[1]), run_text, fill=255, font=mono_font)
    glyph = Image.new("RGBA", (w, h), (255, 255, 255, 0))
    glyph.paste((0, 0, 0, 255), (0, 0), mask)
    return glyph


# Matches runs of actual emoji base characters (the standard Unicode
# emoji blocks -- stable ranges even as new individual emoji get added
# within them, so this doesn't need updating as emoji sets grow).
_EMOJI_PATTERN = re.compile(
    "["
    "\U0001F1E6-\U0001F1FF"  # regional indicator symbols (flag emoji)
    "\U0001F300-\U0001F5FF"  # misc symbols & pictographs
    "\U0001F600-\U0001F64F"  # emoticons
    "\U0001F680-\U0001F6FF"  # transport & map symbols
    "\U0001F900-\U0001F9FF"  # supplemental symbols & pictographs
    "\U0001FA70-\U0001FAFF"  # symbols & pictographs extended-A
    "\U00002600-\U000026FF"  # misc symbols (☀☂☕ etc.)
    "\U00002700-\U000027BF"  # dingbats (✂✈✉ etc.)
    "]+"
)
_EMOJI_SPLIT_PATTERN = re.compile(f"({_EMOJI_PATTERN.pattern})")
# Codepoints with no meaningful standalone glyph once color/ligature
# context is dropped: skin-tone modifiers (this is monochrome -- no
# color to modify), the variation selector that just requests emoji
# presentation, and the zero-width joiner used to fuse multiple emoji
# into one combined glyph. That fusion (e.g. the "family" emoji from
# four base emoji + 3 ZWJs) needs OpenType ligature substitution
# (raqm/harfbuzz), which Pillow only does with a specific, non-default
# build -- not something to depend on. Stripping the ZWJ instead means a
# ZWJ sequence just draws as its individual base glyphs side by side
# (four separate people instead of one family glyph) -- a real fallback,
# not a crash or tofu.
_EMOJI_MODIFIER_PATTERN = re.compile("[\U0001F3FB-\U0001F3FF\U0000FE0F\U0000200D]")


def _split_emoji_runs(text: str) -> list[tuple[str, bool]]:
    """text -> [(run, is_emoji), ...], modifier/joiner codepoints
    dropped first (see _EMOJI_MODIFIER_PATTERN). Consecutive emoji
    collapse into one run (_EMOJI_PATTERN's own "+"), consecutive
    plain-text characters into the other."""
    cleaned = _EMOJI_MODIFIER_PATTERN.sub("", text)
    parts = [p for p in _EMOJI_SPLIT_PATTERN.split(cleaned) if p]
    return [(p, bool(_EMOJI_PATTERN.fullmatch(p))) for p in parts]


def _draw_mixed_line(img: Image.Image, draw: ImageDraw.ImageDraw, xy: tuple[int, int], text: str,
                     text_font: ImageFont.ImageFont, max_width: int) -> None:
    """Draws `text` left-to-right, switching between text_font (normal
    characters) and an emoji glyph image (actual emoji runs, per
    _split_emoji_runs/_emoji_glyph) so emoji visibly render instead of a
    tofu box. Truncates with "..." once max_width is exceeded -- unlike
    _truncate_to_width this can't binary-search a single font's metrics
    across mixed fonts/images, so it works run-by-run instead (and can't
    partially truncate an emoji run the way it can a text run -- one
    that doesn't fit just isn't drawn). Fine for the short single-line
    strings this draws (event titles), not meant as a general rich-text
    layout engine."""
    x, y = xy
    cursor = x
    # A little taller than text_font's own size so glyphs don't look
    # cramped next to it; the -2 paste offset below roughly centers that
    # against the surrounding text's row -- tuned by eye against a real
    # rendered agenda row, not derived from font metrics.
    emoji_h = text_font.size + 6
    for run_text, is_emoji in _split_emoji_runs(text):
        remaining = max_width - (cursor - x)
        if remaining <= 0:
            break
        if is_emoji:
            glyph = _emoji_glyph(run_text, emoji_h)
            if glyph.width <= remaining:
                img.paste(glyph, (round(cursor), y - 2), glyph)
                cursor += glyph.width
            else:
                break
        else:
            run_w = draw.textlength(run_text, font=text_font)
            if run_w <= remaining:
                draw_text(img, (round(cursor), y), run_text, text_font)
                cursor += run_w
            else:
                draw_text(img, (round(cursor), y), _truncate_to_width(draw, run_text, text_font, remaining), text_font)
                break


def _truncate_to_width(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont, max_width: int) -> str:
    """Pixel-width-aware truncation (unlike device.py's char-count
    _truncate, tuned for a fixed firmware font at a fixed size) -- this
    module draws at several different sizes, so truncation has to
    measure the actual font/size in play. Still uses `draw.textlength`
    for measurement (identical metrics to draw_text's own bbox), just
    doesn't paint anything."""
    if draw.textlength(text, font=font) <= max_width:
        return text
    ellipsis = "..."
    lo, hi = 0, len(text)
    while lo < hi:
        mid = (lo + hi + 1) // 2
        if draw.textlength(text[:mid] + ellipsis, font=font) <= max_width:
            lo = mid
        else:
            hi = mid - 1
    return text[:lo] + ellipsis if lo else ellipsis


# --- Size tiers ---------------------------------------------------------
#
# A calendar widget can now be placed at any grid footprint (see
# app/grid.py), not just the full panel -- these three discrete tiers
# (chosen by nearest-fit against the target box's pixel area) drive font
# sizes/margins instead of continuously scaling a layout that was tuned
# by eye for the full ~800x480 panel, which would risk ugly proportions
# at odd in-between sizes. Area-based (not width/height-based) so the
# same footprint tiers the same regardless of landscape/portrait target
# box shape.
_TIER_LARGE_AREA = 280_000   # near/at a full 800x480 panel (384,000px^2)
_TIER_MEDIUM_AREA = 120_000  # roughly a half-panel split


def _size_tier(target_w: int, target_h: int) -> str:
    area = target_w * target_h
    if area >= _TIER_LARGE_AREA:
        return "large"
    if area >= _TIER_MEDIUM_AREA:
        return "medium"
    return "small"


def _month_view_fits(target_w: int, target_h: int) -> bool:
    """Month view needs real width to keep 7 columns' day numbers and
    density dots legible -- below the "small" size tier that stops being
    true, so _build falls back to agenda view instead of drawing an
    unreadable grid."""
    return _size_tier(target_w, target_h) != "small"


# --- Weather strip, agenda/today & tomorrow/week views only (never
# month -- see _BUILDERS/_build) --------------------------------------

def _weather_for_day(weather_cities: list[dict] | None, day: date) -> list[dict]:
    """[{"label", "code", "high", "low", "category"}, ...] for every
    configured city that has a cached forecast for this specific date --
    weather_cities is routers/common.py's get_or_refresh_weather() cache
    shape, [{"label", "days": {"YYYY-MM-DD": {"code","high","low"}}}]."""
    if not weather_cities:
        return []
    key = day.isoformat()
    entries = []
    for city in weather_cities:
        d = (city.get("days") or {}).get(key)
        if d is None:
            continue
        # 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), not for a compact display row.
        entries.append({"label": city["label"].split(",")[0].strip(), "high": d["high"], "low": d["low"],
                        "category": weather_category(d["code"])})
    return entries


def _draw_cloud(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
    """A simple puffy-cloud silhouette (three overlapping lobes + a base)
    with a clean outline -- drawn as one black pass slightly larger than
    the shapes, then the same shapes again in white 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=FG)
    draw.rectangle([base[0] - stroke, base[1], base[2] + stroke, base[3] + stroke], fill=FG)
    for x0, y0, x1, y1 in lobes:
        draw.ellipse([x0, y0, x1, y1], fill=BG)
    draw.rectangle(base, fill=BG)


def _draw_weather_icon(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float, category: str) -> None:
    """A small hand-drawn glyph for one weather category -- no custom
    font/icon asset, same hand-primitives-only approach the rest of this
    module uses (colored rectangles for owner indicators, density dots
    for month view)."""
    if category == "clear":
        # Kept within a ~1.1r visual radius overall (rays included) to
        # match _draw_cloud's own footprint -- _draw_weather_row lays
        # icons out assuming each one stays roughly within icon_r of its
        # center, and the first entry in a row sits flush against the
        # region's own left margin, so any icon that draws wider than
        # that pokes out past it with nothing to visually connect to.
        draw.ellipse([cx - r * 0.7, cy - r * 0.7, cx + r * 0.7, cy + r * 0.7], fill=FG)
        for dx, dy in ((0, -1), (0, 1), (-1, 0), (1, 0)):
            draw.line([(cx + dx * r * 0.65, cy + dy * r * 0.65), (cx + dx * r * 1.0, cy + dy * r * 1.0)],
                     fill=FG, width=3)
        return

    cloud_cy = cy if category in ("partly_cloudy", "cloudy", "fog") else cy - r * 0.3
    if category == "partly_cloudy":
        draw.ellipse([cx - r * 1.3, cy - r * 1.3, cx - r * 0.1, cy - r * 0.1], fill=FG)
    _draw_cloud(draw, cx, cloud_cy, r)

    if category == "fog":
        for i in range(3):
            y = cy + r * 0.5 + i * (r * 0.45)
            draw.line([(cx - r, y), (cx + r, y)], fill=FG, width=2)
    elif category == "rain":
        for dx in (-0.6, 0, 0.6):
            x = cx + dx * r
            draw.line([(x, cloud_cy + r * 0.6), (x - r * 0.25, cloud_cy + r * 1.2)], fill=FG, width=2)
    elif category == "snow":
        for dx in (-0.6, 0, 0.6):
            x, y = cx + dx * r, cloud_cy + r * 0.9
            draw.ellipse([x - 2, y - 2, x + 2, y + 2], fill=FG)
    elif category == "thunderstorm":
        x, y = cx, cloud_cy + r * 0.5
        draw.line([(x, y), (x - r * 0.3, y + r * 0.5), (x + r * 0.1, y + r * 0.5), (x - r * 0.2, y + r * 1.1)],
                 fill=FG, width=2)


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) -> 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"])
        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


def _draw_agenda_day(img: Image.Image, draw: ImageDraw.ImageDraw, day: date, events: list[dict], tz: ZoneInfo,
                     region: tuple[int, int, int, int], title_font: ImageFont.ImageFont,
                     body_font: ImageFont.ImageFont, owners_seen: list[str], palette_rgb: list | None = None,
                     weather_cities: list[dict] | None = None, weather_font: ImageFont.ImageFont | None = None,
                     weather_units: str = "fahrenheit") -> None:
    """Draws one day's header + weather strip (if any) + event rows
    within `region` (x0, y0, w, h) -- factored out of _build_agenda so
    the today-and-tomorrow view (_build_today_tomorrow) can stack two of
    these vertically without duplicating the row-layout/truncation
    logic. Weather is drawn above the event list -- eating into the same
    row budget the event count is truncated against, exactly like the
    header/rule above it already does."""
    x0, y0, w, h = region
    text_x0, text_y0 = x0 + MARGIN, y0 + MARGIN
    text_w = w - MARGIN * 2
    header = day.strftime("%A, %B ") + str(day.day)
    draw_text(img, (text_x0, text_y0), _truncate_to_width(draw, header, title_font, text_w), title_font)
    y = text_y0 + title_font.size + 12
    draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
    y += 12

    weather_entries = _weather_for_day(weather_cities, day)
    if weather_entries:
        y += _draw_weather_row(img, draw, text_x0, y, text_w, weather_entries,
                               icon_r=title_font.size // 2, font=weather_font or body_font, units=weather_units)

    day_events = _events_on_day(events, day, tz)
    row_h = body_font.size + 14
    max_rows = max(0, (y0 + h - MARGIN - y) // row_h)

    if not day_events:
        draw_text(img, (text_x0, y), "Nothing scheduled", body_font, MUTED)
    for i, event in enumerate(day_events):
        if i >= max_rows:
            draw_text(img, (text_x0, y), f"+{len(day_events) - max_rows} more", body_font, MUTED)
            break
        colors = _event_colors(event, owners_seen, palette_rgb)
        _draw_color_bar(draw, text_x0, y + 2, text_x0 + 10, y + row_h - 7, colors, radius=3)
        time_str = "All day" if event["all_day"] else _fmt_time(_event_start(event, tz))
        prefix = f"{time_str}  "
        draw_text(img, (text_x0 + 18, y), prefix, body_font)
        prefix_w = draw.textlength(prefix, font=body_font)
        _draw_mixed_line(img, draw, (text_x0 + 18 + prefix_w, y), event["summary"],
                         body_font, text_w - 18 - prefix_w)
        y += row_h


def _draw_tasks(img: Image.Image, draw: ImageDraw.ImageDraw, region: tuple[int, int, int, int],
                tasks: list[dict], title_font: ImageFont.ImageFont, body_font: ImageFont.ImageFont,
                margin: int = MARGIN) -> None:
    """A simple checklist filling `region` (x0, y0, w, h) -- unchecked-box
    glyph + due date (if any) + summary per outstanding task, same
    header/rule/row-cap/truncation shape as _draw_agenda_day's event
    list so the "week view, one slot replaced by tasks instead of a day"
    layout (see _build_week) reads as one consistent design rather than
    two different widgets bolted together. Reuses _draw_mixed_line so a
    task summary with emoji in it renders the same way an event
    title's does.

    `margin` defaults to the module-wide MARGIN (vertical layout's
    stacked bands are as wide as the whole content region, same as
    _draw_agenda_day's own sections) but a narrow horizontal-layout
    column passes a much smaller one -- MARGIN on both sides of an
    already-cramped ~150px week column left almost nothing for the
    title text itself."""
    x0, y0, w, h = region
    text_x0, text_y0 = x0 + margin, y0 + margin
    text_w = w - margin * 2
    draw_text(img, (text_x0, text_y0), "Tasks", title_font)
    y = text_y0 + title_font.size + 12
    draw.line([(text_x0, y), (text_x0 + text_w, y)], fill=RULE)
    y += 12

    row_h = body_font.size + 14
    max_rows = max(0, (y0 + h - margin - y) // row_h)

    if not tasks:
        draw_text(img, (text_x0, y), "Nothing outstanding", body_font, MUTED)
        return
    for i, task in enumerate(tasks):
        if i >= max_rows:
            draw_text(img, (text_x0, y), f"+{len(tasks) - max_rows} more", body_font, MUTED)
            break
        box = body_font.size - 6
        box_y = y + (row_h - box) // 2 - 5
        draw.rectangle([text_x0, box_y, text_x0 + box, box_y + box], outline=FG, width=2)
        text_x = text_x0 + box + 10
        due_str = _fmt_task_due(task.get("due"))
        prefix = f"{due_str}  " if due_str else ""
        if prefix:
            draw_text(img, (text_x, y), prefix, body_font, MUTED)
        prefix_w = draw.textlength(prefix, font=body_font) if prefix else 0
        _draw_mixed_line(img, draw, (round(text_x + prefix_w), y), task["summary"],
                         body_font, text_w - box - 10 - prefix_w)
        y += row_h


# Per-tier (title, body, weather) font sizes -- "Wednesday, July 22" at
# full size doesn't fit a narrow column, and a narrower box is exactly
# when a smaller font (rather than truncating to "Wednesday...") keeps
# the header actually informative.
_AGENDA_FONTS = {"large": (34, 22, 20), "medium": (24, 22, 16), "small": (18, 16, 13)}


def _build_agenda(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
                  palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
                  weather_units: str = "fahrenheit") -> Image.Image:
    img = Image.new("RGB", (target_w, target_h), BG)
    draw = ImageDraw.Draw(img)

    title_size, body_size, weather_size = _AGENDA_FONTS[_size_tier(target_w, target_h)]
    title_font = ImageFont.load_default(size=title_size)
    body_font = ImageFont.load_default(size=body_size)
    weather_font = ImageFont.load_default(size=weather_size)

    day = datetime.now(tz).date() + timedelta(days=browse_offset)
    owners_seen: list[str] = []
    _draw_agenda_day(img, draw, day, events, tz, (0, 0, target_w, target_h), title_font, body_font, owners_seen,
                     palette_rgb, weather_cities, weather_font, weather_units)

    return img


_TODAY_TOMORROW_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (15, 12, 10)}


def _build_today_tomorrow(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
                          palette_rgb: list | None = None, weather_cities: list[dict] | None = None,
                          weather_units: str = "fahrenheit") -> Image.Image:
    """Two _draw_agenda_day sections stacked vertically (below each other
    rather than side-by-side -- narrower than tall doesn't leave enough
    width per day for the event-row text at smaller sizes). browse_offset
    shifts the whole two-day window together, same "days" unit
    _build_agenda already uses, so NEXT/BACK behaves identically across
    both views."""
    img = Image.new("RGB", (target_w, target_h), BG)
    draw = ImageDraw.Draw(img)

    title_size, body_size, weather_size = _TODAY_TOMORROW_FONTS[_size_tier(target_w, target_h)]
    title_font = ImageFont.load_default(size=title_size)
    body_font = ImageFont.load_default(size=body_size)
    weather_font = ImageFont.load_default(size=weather_size)

    start_day = datetime.now(tz).date() + timedelta(days=browse_offset)
    section_h = target_h // 2
    owners_seen: list[str] = []
    for i in range(2):
        section_y0 = i * section_h
        if i > 0:
            draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
        _draw_agenda_day(img, draw, start_day + timedelta(days=i), events, tz,
                         (0, section_y0, target_w, section_h), title_font, body_font, owners_seen,
                         palette_rgb, weather_cities, weather_font, weather_units)

    return img


# Vertical layout's base (title, body, weather) sizes, before the
# per-day-count reduction below -- same three tiers as every other view.
_WEEK_VERTICAL_FONTS = {"large": (26, 18, 16), "medium": (20, 15, 13), "small": (16, 12, 10)}
# Horizontal layout's (header, chip, weather) sizes.
_WEEK_HORIZONTAL_FONTS = {"large": (18, 14, 12), "medium": (14, 12, 10), "small": (11, 10, 8)}


def _build_week(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
                week_start: int, palette_rgb: list | None = None,
                weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
                days: int = 7, layout: str = "horizontal", tasks: list[dict] | None = None,
                start_offset: int = 0) -> Image.Image:
    """`days` (2-10, see routers/api_frames.py's clamp) side-by-side
    columns (layout="horizontal", the original fixed-at-7 behavior
    generalized) or stacked bands (layout="vertical", reusing
    _draw_agenda_day the same way _build_today_tomorrow does, just for
    an arbitrary day count instead of a hardcoded 2). `tasks` (see
    routers/common.py's get_or_refresh_tasks), if not None, takes the
    LAST slot instead of adding an extra one -- "N days" always means N
    slots total, whether they're all days or N-1 days plus a task list.

    At the default 7 days, the view anchors to week_start (a fixed
    weekday, "start on the most recent Monday") exactly like before --
    otherwise "start of the week" doesn't mean much for an arbitrary day
    count, so it instead starts `start_offset` days from today (0 =
    today, see routers/api_frames.py's api_config_save)."""
    img = Image.new("RGB", (target_w, target_h), BG)
    draw = ImageDraw.Draw(img)
    tier = _size_tier(target_w, target_h)

    today = datetime.now(tz).date()
    if days == 7:
        days_since_start = (today.weekday() - week_start) % 7
        week_first_day = today - timedelta(days=days_since_start) + timedelta(days=days * browse_offset)
    else:
        week_first_day = today + timedelta(days=start_offset) + timedelta(days=days * browse_offset)
    day_count = days - 1 if tasks is not None else days
    owners_seen: list[str] = []

    if layout == "vertical":
        title_base, body_base, weather_base = _WEEK_VERTICAL_FONTS[tier]
        title_font = ImageFont.load_default(size=max(14, title_base - days))
        body_font = ImageFont.load_default(size=max(11, body_base - days))
        weather_font = ImageFont.load_default(size=max(9, weather_base - days))
        section_h = target_h // days
        for i in range(day_count):
            section_y0 = i * section_h
            if i > 0:
                draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
            day = week_first_day + timedelta(days=i)
            _draw_agenda_day(img, draw, day, events, tz, (0, section_y0, target_w, section_h),
                             title_font, body_font, owners_seen, palette_rgb,
                             weather_cities, weather_font, weather_units)
        if tasks is not None:
            section_y0 = day_count * section_h
            if day_count > 0:
                draw.line([(MARGIN, section_y0), (target_w - MARGIN, section_y0)], fill=RULE)
            _draw_tasks(img, draw, (0, section_y0, target_w, section_h), tasks, title_font, body_font)
        return img

    header_size, chip_size, weather_size = _WEEK_HORIZONTAL_FONTS[tier]
    header_font = ImageFont.load_default(size=header_size)
    chip_font = ImageFont.load_default(size=chip_size)
    weather_font = ImageFont.load_default(size=weather_size)
    col_w = (target_w - MARGIN * 2) // days
    header_h = 44

    for col in range(day_count):
        day = week_first_day + timedelta(days=col)
        x0 = MARGIN + col * col_w
        if col > 0:
            draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
        label = day.strftime("%a %-d") if day != today else f"* {day.strftime('%a %-d')}"
        draw_text(img, (x0 + 6, MARGIN), _truncate_to_width(draw, label, header_font, col_w - 10), header_font)

        y = MARGIN + header_h
        # Columns are narrow, so only what actually fits gets drawn (see
        # _draw_weather_row) -- typically one city, no label (the column
        # itself makes which day it's for obvious; a city name wouldn't fit
        # anyway). Never more than that -- this is already the tight view.
        weather_entries = _weather_for_day(weather_cities, day)
        if weather_entries:
            y += _draw_weather_row(img, draw, x0 + 4, y, col_w - 8, weather_entries,
                                   icon_r=8, font=weather_font, units=weather_units, show_labels=False)
        row_h = chip_font.size + 10
        max_rows = max(0, (target_h - MARGIN - y) // row_h)
        day_events = _events_on_day(events, day, tz)
        for i, event in enumerate(day_events):
            if i >= max_rows:
                draw_text(img, (x0 + 6, y), f"+{len(day_events) - max_rows}", chip_font, MUTED)
                break
            colors = _event_colors(event, owners_seen, palette_rgb)
            _draw_color_bar(draw, x0 + 4, y + 1, x0 + 11, y + row_h - 5, colors, radius=2)
            if event["all_day"]:
                _draw_mixed_line(img, draw, (x0 + 16, y), event["summary"], chip_font, col_w - 20)
            else:
                prefix = f"{_fmt_time(_event_start(event, tz))[:-3]} "
                draw_text(img, (x0 + 16, y), prefix, chip_font)
                prefix_w = draw.textlength(prefix, font=chip_font)
                _draw_mixed_line(img, draw, (x0 + 16 + prefix_w, y), event["summary"],
                                 chip_font, col_w - 20 - prefix_w)
            y += row_h

    if tasks is not None:
        col = day_count
        x0 = MARGIN + col * col_w
        if col > 0:
            draw.line([(x0, MARGIN), (x0, target_h - MARGIN)], fill=RULE)
        _draw_tasks(img, draw, (x0, 0, col_w, target_h), tasks, header_font, chip_font, margin=6)

    return img


# Only "large"/"medium" in practice -- _build falls back to agenda view
# below the "small" tier (see _month_view_fits) -- but keyed defensively
# by tier rather than a bare bool so a future tier addition can't
# silently fall through to a KeyError here.
_MONTH_FONTS = {"large": (16, 18), "medium": (12, 13), "small": (12, 13)}


def _build_month(events: list[dict], browse_offset: int, target_w: int, target_h: int, tz: ZoneInfo,
                 week_start: int, palette_rgb: list | None = None) -> Image.Image:
    """Density dots per day, not literal event text -- real text at
    typical month-cell size (~100x70px) is close to unreadable on a
    6-color dithered e-ink panel. Capped at 4 visible dots, "+N" beyond."""
    img = Image.new("RGB", (target_w, target_h), BG)
    draw = ImageDraw.Draw(img)

    header_size, day_size = _MONTH_FONTS[_size_tier(target_w, target_h)]
    header_font = ImageFont.load_default(size=header_size)
    day_font = ImageFont.load_default(size=day_size)

    today = datetime.now(tz).date()
    target_month = _add_months(date(today.year, today.month, 1), browse_offset)
    weeks = list(calendar_module.Calendar(firstweekday=week_start).monthdatescalendar(target_month.year, target_month.month))

    col_w = (target_w - MARGIN * 2) // 7
    header_h = 28
    grid_top = MARGIN + header_h
    row_h = (target_h - MARGIN - grid_top) // len(weeks)

    day_names = WEEKDAY_NAMES[week_start:] + WEEKDAY_NAMES[:week_start]
    for col, name in enumerate(day_names):
        draw_text(img, (MARGIN + col * col_w + 6, MARGIN), name[:3], header_font, MUTED)

    owners_seen: list[str] = []
    dot_r = 6
    for row, week in enumerate(weeks):
        for col, day in enumerate(week):
            x0 = MARGIN + col * col_w
            y0 = grid_top + row * row_h
            draw.rectangle([x0, y0, x0 + col_w, y0 + row_h], outline=RULE)
            in_month = day.month == target_month.month
            text_color = FG if in_month else MUTED
            if day == today:
                draw.rectangle([x0 + 2, y0 + 2, x0 + 24, y0 + 20], outline=FG)
            draw_text(img, (x0 + 6, y0 + 4), str(day.day), day_font, text_color)

            day_events = _events_on_day(events, day, tz)
            dot_x = x0 + 8
            dot_y = y0 + row_h - dot_r * 2 - 6
            for i, event in enumerate(day_events[:4]):
                # First contributing calendar's color only, even for a
                # deduplicated shared event -- month view is density
                # dots, not a place to also show which calendars a
                # shared event came from (see _event_colors).
                event_color = _event_colors(event, owners_seen, palette_rgb)[0]
                draw.ellipse([dot_x, dot_y, dot_x + dot_r * 2, dot_y + dot_r * 2], fill=event_color)
                dot_x += dot_r * 2 + 5
            if len(day_events) > 4:
                draw_text(img, (dot_x, dot_y - 2), f"+{len(day_events) - 4}", header_font, MUTED)

    return img


_BUILDERS = {"agenda": _build_agenda, "today_tomorrow": _build_today_tomorrow, "week": _build_week,
            "month": _build_month}


def _build(events: list[dict], view: str, browse_offset: int, target_w: int, target_h: int, timezone: str,
          fetch_summary: str, week_start: int, palette_rgb: list | None = None,
          weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
          week_days: int = 7, week_layout: str = "horizontal", tasks: list[dict] | None = None,
          week_start_offset: int = 0) -> Image.Image:
    tz = ZoneInfo(timezone) if timezone else ZoneInfo("UTC")
    effective_view = view
    if view == "month" and not _month_view_fits(target_w, target_h):
        effective_view = "agenda"

    if effective_view == "agenda":
        img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
                            weather_cities, weather_units)
    elif effective_view == "today_tomorrow":
        img = _build_today_tomorrow(events, browse_offset, target_w, target_h, tz, palette_rgb,
                                    weather_cities, weather_units)
    elif effective_view == "week":
        img = _build_week(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb,
                          weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
    elif effective_view == "month":
        # Never given weather or tasks -- no room for either at typical
        # month-cell size, same reasoning that already keeps this view
        # to density dots instead of literal event text (see
        # _build_month's own docstring). Colors are still passed
        # through, though -- that's a different concern (legibility of
        # individual events) than needing a whole extra strip/slot of
        # content.
        img = _build_month(events, browse_offset, target_w, target_h, tz, week_start, palette_rgb)
    else:
        img = _build_agenda(events, browse_offset, target_w, target_h, tz, palette_rgb,
                            weather_cities, weather_units)

    if fetch_summary:
        font = ImageFont.load_default(size=14 if _size_tier(target_w, target_h) != "small" else 11)
        draw_text(img, (MARGIN, target_h - MARGIN - font.size), fetch_summary, font, MUTED)

    return img


def render_calendar(events: list[dict], view: str, browse_offset: int, orientation: str,
                    palette_rgb: list | None, timezone: str,
                    fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
                    weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
                    week_days: int = 7, week_layout: str = "horizontal",
                    tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
    """Renders one of CALENDAR_VIEWS full-panel to the panel's packed
    format. Always returns exactly EPD_WIDTH*EPD_HEIGHT/2 bytes, same
    invariant every other renderer honors. weather_cities is
    routers/common.py's get_or_refresh_weather() cache, or None/[] to
    omit the weather strip entirely (also always omitted for view ==
    "month"). tasks is get_or_refresh_tasks()'s cache, or None to omit
    the task list entirely -- only ever drawn for view == "week", see
    _build_week."""
    target_w, target_h = logical_render_size(orientation)
    img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
                palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
    img = _apply_manage_overlay(img, manage)
    quantized = _quantize(img, palette_rgb, dither_strength=1.0)
    return _transpose_and_pack(quantized, orientation)


def render_calendar_preview_png(events: list[dict], view: str, browse_offset: int, orientation: str,
                                palette_rgb: list | None, timezone: str,
                                fetch_summary: str = "", manage: dict | None = None, week_start: int = 0,
                                weather_cities: list[dict] | None = None, weather_units: str = "fahrenheit",
                                week_days: int = 7, week_layout: str = "horizontal",
                                tasks: list[dict] | None = None, week_start_offset: int = 0) -> bytes:
    """Same pipeline as render_calendar, but a normal browser-viewable
    PNG in logical (upright) orientation -- mirrors
    image_pipeline.render_preview_png's relationship to render_frame."""
    target_w, target_h = logical_render_size(orientation)
    img = _build(events, view, browse_offset, target_w, target_h, timezone, fetch_summary, week_start,
                palette_rgb, weather_cities, weather_units, week_days, week_layout, tasks, week_start_offset)
    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()
