from __future__ import annotations

import asyncio
from collections import OrderedDict
import logging
import random
from pathlib import Path

import async_timeout
from PIL import Image

from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import (
    DOMAIN,
    MAX_RESOLUTION_SHORT_EDGE,
    ORIENTATION_MISMATCH_PAIR,
    ORIENTATION_MISMATCH_AVOID,
    ORDER_ALBUM,
    ORDER_RANDOM,
    PROVIDER_GOOGLE_SHARED,
)
from . import image_processing as ip
from . import playlist
from .coordinator import AlbumCoordinator, MediaItem
from .store import SlideshowStore

_LOGGER = logging.getLogger(__name__)

# Cap a single download at 64 MB. Larger images are rejected before decode
# to protect low-memory devices. This is well above any realistic camera
# JPEG; RAW/NEF/etc. aren't supported as camera frames anyway.
_MAX_DOWNLOAD_BYTES = 64 * 1024 * 1024

# Only these content types are accepted as image bodies. If a server returns
# HTML (captive portal, 404 page rendered as 200, etc.) we reject it early.
_ACCEPTED_IMAGE_PREFIX = ("image/",)

# Max candidates we'll scan when searching for a mismatched-orientation
# pairing partner. Metadata-only checks are nearly free; decode-only checks
# (no metadata available) are expensive.
_PAIR_SEARCH_LIMIT = 12
_SKIP_SEARCH_LIMIT = 30


def _ts_to_iso(ts_ms: int | None) -> str | None:
    """Convert epoch milliseconds to an ISO-8601 string in UTC, or None."""
    if not isinstance(ts_ms, int):
        return None
    from datetime import datetime, timezone
    try:
        return datetime.fromtimestamp(ts_ms / 1000, tz=timezone.utc).isoformat()
    except (OverflowError, OSError, ValueError):
        return None


class _DownloadCache:
    """Byte-budget LRU cache for downloaded image data, O(1) per operation."""

    def __init__(self, max_bytes: int) -> None:
        self._cache: "OrderedDict[str, bytes]" = OrderedDict()
        self._total_bytes: int = 0
        self._max_bytes: int = max(max_bytes, 1)

    @property
    def total_bytes(self) -> int:
        return self._total_bytes

    def get(self, url: str) -> bytes | None:
        data = self._cache.get(url)
        if data is None:
            return None
        self._cache.move_to_end(url)
        return data

    def put(self, url: str, data: bytes) -> None:
        if len(data) > self._max_bytes:
            # Item exceeds the entire cache budget; skip caching but don't raise.
            return
        if url in self._cache:
            self._total_bytes -= len(self._cache[url])
            del self._cache[url]
        self._cache[url] = data
        self._total_bytes += len(data)
        self._evict()

    def resize(self, max_bytes: int) -> None:
        self._max_bytes = max(max_bytes, 1)
        self._evict()

    def _evict(self) -> None:
        while self._total_bytes > self._max_bytes and self._cache:
            _, data = self._cache.popitem(last=False)
            self._total_bytes -= len(data)


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None:
    coordinator: AlbumCoordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"]
    store: SlideshowStore = hass.data[DOMAIN][entry.entry_id]["store"]

    cam = AlbumSlideshowCamera(hass, entry, coordinator, store)
    hass.data[DOMAIN][entry.entry_id]["camera"] = cam

    async_add_entities([cam])


class AlbumSlideshowCamera(Camera):
    _attr_should_poll = False

    def __init__(self, hass: HomeAssistant, entry: ConfigEntry, coordinator: AlbumCoordinator, store: SlideshowStore) -> None:
        super().__init__()
        self.hass = hass
        self.entry = entry
        self.coordinator = coordinator
        self.store = store

        self._attr_name = f"Album Slideshow {entry.title}"
        self._attr_unique_id = f"{entry.entry_id}_camera"

        self._rng = random.Random()
        self._index = 0
        self._random_order: list[int] = []
        self._random_pos = 0

        self._download_cache = _DownloadCache(
            max_bytes=store.image_cache_mb * 1024 * 1024
        )
        self._recent_urls: list[str] = []
        self._last_is_portrait: bool | None = None
        # When the current frame is a paired image, this is [taken_a, taken_b]
        # ISO strings (top/left first); None for single frames.
        self._last_captured_at_pair: list[str | None] | None = None
        # Full per-half caption metadata for a paired frame: a list of two
        # dicts (top/left first) each carrying captured_at / location /
        # latitude / longitude. None for single frames. Lets the Lovelace
        # card overlay an accurate caption on each half of a pair.
        self._last_pair_frames: list[dict] | None = None
        # ``horizontal`` (side-by-side, left/right) or ``vertical`` (stacked,
        # top/bottom) for a paired frame; None for single frames.
        self._last_pair_orientation: str | None = None
        # Cached effective playlist (after date filter + ordering). Invalidated
        # by any store change or coordinator update.
        self._effective_cache: tuple[int, list[MediaItem]] | None = None

        self._framebuffer: bytes | None = None

        # Monotonic counter incremented every time a new still is committed.
        # Exposed as the ``frame_id`` state attribute so the Lovelace card
        # has an unambiguous "new frame ready" signal even when other
        # attributes happen not to change between slides.
        self._frame_id: int = 0

        self._interrupt_event: asyncio.Event = asyncio.Event()
        self._force_next: bool = False
        self._consecutive_failures: int = 0
        self._render_task: asyncio.Task | None = None

        def _on_coordinator_update() -> None:
            self._effective_cache = None
            self._interrupt_event.set()
            self.async_write_ha_state()

        coordinator.async_add_listener(_on_coordinator_update)

        def _on_store_change() -> None:
            self._download_cache.resize(self.store.image_cache_mb * 1024 * 1024)
            self._effective_cache = None
            self._interrupt_event.set()
            self.async_write_ha_state()

        store.add_listener(_on_store_change)

    async def async_added_to_hass(self) -> None:
        await super().async_added_to_hass()
        # Restore last framebuffer (if the store kept one) so the camera has
        # something to show immediately after a restart, rather than a broken
        # image placeholder while the first render completes.
        restored = getattr(self.store, "last_frame", None)
        if isinstance(restored, (bytes, bytearray)) and restored:
            self._framebuffer = bytes(restored)
        # Stagger the first render across multiple albums so they don't all
        # decode + encode at the same instant on HA startup. Deterministic
        # offset based on entry_id keeps the pattern stable across
        # restarts. Up to ~3 s spread across albums.
        startup_delay = (hash(self.entry.entry_id) % 3000) / 1000.0
        self._render_task = self.hass.async_create_background_task(
            self._render_loop(initial_delay=startup_delay),
            name="album_slideshow_render_loop",
        )

    async def async_will_remove_from_hass(self) -> None:
        if self._render_task is not None:
            self._render_task.cancel()
            try:
                await self._render_task
            except asyncio.CancelledError:
                pass

    @property
    def device_info(self):
        return {
            "identifiers": {(DOMAIN, self.entry.entry_id)},
            "name": f"Album Slideshow {self.entry.title}",
            "manufacturer": "Album Slideshow",
        }

    @property
    def icon(self) -> str:
        if self.coordinator.provider == PROVIDER_GOOGLE_SHARED:
            return "mdi:google-photos"
        return "mdi:folder-multiple-image"

    @property
    def extra_state_attributes(self):
        data = self.coordinator.data or {}
        items: list[MediaItem] = self._effective_items()
        cur = items[self._index] if items and 0 <= self._index < len(items) else None
        captured_at = _ts_to_iso(getattr(cur, "captured_at", None))
        captured_at_pair = self._last_captured_at_pair
        return {
            "album_title": data.get("title"),
            "media_count": len(items),
            "media_count_total": len(data.get("items", []) or []),
            "current_index": self._index,
            "current_filename": getattr(cur, "filename", None),
            "current_url": getattr(cur, "url", None),
            "current_is_portrait": self._last_is_portrait,
            "captured_at": captured_at_pair if captured_at_pair else captured_at,
            "captured_at_primary": captured_at,
            "uploaded_at": _ts_to_iso(getattr(cur, "uploaded_at", None)),
            "byte_size": getattr(cur, "byte_size", None),
            # GPS + reverse-geocoded label come from EXIF for local-folder
            # entries; Google albums leave these as ``None``.
            "latitude": getattr(cur, "latitude", None),
            "longitude": getattr(cur, "longitude", None),
            "location": getattr(cur, "location", None),
            "description": getattr(cur, "description", None),
            # Structured per-image caption metadata. A single-element list for
            # normal slides; two elements (top/left first) for paired slides,
            # so the card can overlay an accurate date/location on each half.
            # ``pair_orientation`` tells the card how the two halves are laid
            # out: ``horizontal`` (left/right) or ``vertical`` (top/bottom).
            "caption_frames": self._caption_frames(cur, captured_at),
            "pair_orientation": self._last_pair_orientation,
            "slide_interval": int(self.store.slide_interval),
            "fill_mode": self.store.fill_mode,
            "portrait_mode": self.store.portrait_mode,
            "order_mode": self.store.order_mode,
            "date_filter": self.store.date_filter,
            "missing_date_mode": self.store.missing_date_mode,
            "paused": bool(self.store.paused),
            "refresh_hours": int(self.store.refresh_hours),
            "aspect_ratio": self.store.aspect_ratio,
            "pair_divider_px": int(self.store.pair_divider_px),
            "pair_divider_color": self.store.pair_divider_color,
            "frame_id": self._frame_id,
            "pagination_debug": data.get("pagination_debug"),
        }

    def _caption_frames(self, cur, captured_at: str | None) -> list[dict]:
        """Per-image caption metadata for the current slide.

        Returns a list with one dict for a normal slide, or two (top/left
        first) for a paired slide. Each dict carries ``captured_at`` (ISO
        string or ``None``), ``location`` (human label or ``None``), and
        ``latitude`` / ``longitude``. The card reads this to overlay an
        accurate caption on each image, including each half of a pair.
        """
        if self._last_pair_frames:
            return self._last_pair_frames
        return [
            {
                "captured_at": captured_at,
                "location": getattr(cur, "location", None),
                "latitude": getattr(cur, "latitude", None),
                "longitude": getattr(cur, "longitude", None),
                "description": getattr(cur, "description", None),
            }
        ]

    @property
    def entity_picture(self) -> str | None:
        """Return the camera proxy URL with a per-frame cache-buster.

        HA core's default ``entity_picture`` only changes when the access
        token rotates (about every five minutes). Browsers happily serve
        the cached image to the more-info dialog and other surfaces in
        between rotations, so they end up showing the previous slide
        while a fresh slide is already in the framebuffer. Appending the
        ``frame_id`` invalidates that cache as soon as a new slide is
        committed, no matter where in HA the picture is rendered.
        """
        base = super().entity_picture
        if not base:
            return base
        sep = "&" if "?" in base else "?"
        return f"{base}{sep}frame={self._frame_id}"

    @property
    def cache_usage_mb(self) -> float:
        return round(self._download_cache.total_bytes / (1024 * 1024), 1)

    def _effective_items(self) -> list[MediaItem]:
        """Return the playlist after applying the date filter and order mode.

        Cached until the coordinator or store changes (see invalidations
        wired up in __init__).
        """
        data = self.coordinator.data or {}
        raw: list[MediaItem] = data.get("items", []) or []
        cache_key = (
            id(raw),
            self.store.date_filter,
            self.store.missing_date_mode,
            self.store.order_mode,
        )
        if self._effective_cache is not None and self._effective_cache[0] == hash(cache_key):
            return self._effective_cache[1]

        filtered = playlist.filter_items(
            raw,
            mode=self.store.date_filter,
            missing_date=self.store.missing_date_mode,
        )
        ordered = playlist.order_items(filtered, self.store.order_mode)
        self._effective_cache = (hash(cache_key), ordered)
        return ordered

    async def async_force_next(self) -> None:
        self._force_next = True
        self._interrupt_event.set()
        self.async_write_ha_state()

    async def async_force_refresh(self) -> None:
        await self.coordinator.async_request_refresh()

    async def async_camera_image(self, width: int | None = None, height: int | None = None) -> bytes | None:
        return self._framebuffer

    async def handle_async_mjpeg_stream(self, request):
        """Serve the current slide as MJPEG for Home Assistant core surfaces.

        This is what the more-info dialog and picture-glance live view use
        (the camera advertises no live stream, so the frontend falls back to
        ``/api/camera_proxy_stream``). We delegate to HA's still-stream
        helper, which polls ``async_camera_image`` at ``frame_interval`` and
        writes a correct multipart response.

        Crucially it emits frames *continuously* rather than only on slide
        change. A browser parsing ``multipart/x-mixed-replace`` holds the
        current part until the next boundary arrives, so a stream that sent
        one frame and then went quiet until the next slide (potentially many
        seconds away, or never while paused) left the more-info view blank.
        Polling keeps a boundary coming right away, so the current frame
        renders immediately.
        """
        # Imported lazily so the module still loads in test environments
        # that stub out homeassistant.
        from homeassistant.components.camera import async_get_still_stream

        return await async_get_still_stream(
            request,
            self.async_camera_image,
            self.content_type,
            self.frame_interval,
        )

    # Older HA cores may dispatch via the alt name; alias for compatibility.
    async def async_handle_async_mjpeg_stream(self, request):
        return await self.handle_async_mjpeg_stream(request)

    async def _wait_or_interrupt(self, timeout: float) -> bool:
        """Wait up to ``timeout`` seconds, returning True if interrupted.

        Does NOT clear ``_interrupt_event`` - the render loop clears it once
        per cycle, before rendering, so a signal that arrives while we're
        rendering (a "next slide" press, a coordinator/store change) survives
        until we get here instead of being wiped.

        A force-next that's already pending is honored immediately without
        sleeping, so a press that landed during the last render (or right at
        the loop boundary) isn't held until the full slide interval elapses.
        """
        if self._force_next:
            return True
        try:
            await asyncio.wait_for(self._interrupt_event.wait(), timeout=timeout)
            return True
        except asyncio.TimeoutError:
            return False

    async def _render_loop(self, initial_delay: float = 0.0) -> None:
        """Background task: render slides into _framebuffer, advance on timer or interrupt."""
        if initial_delay > 0:
            try:
                await asyncio.sleep(initial_delay)
            except asyncio.CancelledError:
                raise
        should_advance = False  # Don't advance on the very first render
        while True:
            # Clear the wake signal before rendering. Anything that happens
            # from here on (a "next slide" press, a coordinator/store change)
            # re-sets it and is picked up after this frame commits, so no
            # wake is lost while we're mid-render.
            self._interrupt_event.clear()
            try:
                await self._render_cycle(advance=should_advance)
                self._consecutive_failures = 0
            except asyncio.CancelledError:
                raise
            except Exception as err:
                self._consecutive_failures += 1
                backoff = min(2 ** self._consecutive_failures, 60)
                _LOGGER.warning(
                    "Album Slideshow: render cycle failed (attempt %d), retrying in %ds: %s",
                    self._consecutive_failures, backoff, err,
                )
                try:
                    await asyncio.sleep(backoff)
                except asyncio.CancelledError:
                    raise
                should_advance = True  # Skip the broken image on retry
                continue

            interrupted = await self._wait_or_interrupt(float(int(self.store.slide_interval)))
            if self._force_next:
                # Explicit "next slide" request: always advance.
                should_advance = True
                self._force_next = False
            elif interrupted:
                # A coordinator/store change woke us: re-render the current
                # frame (new data or settings) without skipping ahead.
                should_advance = False
            else:
                # Paused slideshows hold the current frame until the user
                # un-pauses or hits "next slide" explicitly.
                should_advance = not bool(self.store.paused)

    async def _render_cycle(self, advance: bool) -> None:
        """Render one frame.

        The slideshow is just "advance index, compose, encode, broadcast".
        Visible transitions are handled by the Lovelace card client-side,
        so this path stays minimal: at most one PIL decode + encode per
        slide change.

        Compose work is serialised across all albums via a domain-wide
        semaphore so 4 cameras don't all decode + encode at once.
        """
        items: list[MediaItem] = self._effective_items()
        if not items:
            return

        count = len(items)
        if advance:
            self._do_advance(count, items)

        async with self._compose_semaphore:
            composed, meta = await self._compose_for_index(items)
            if composed is None:
                return
            try:
                await self._commit_composed(composed, meta)
            finally:
                ip.safe_close(composed)

    async def _commit_composed(self, composed: Image.Image, meta: dict) -> None:
        """Encode the composed slide into the framebuffer and broadcast.

        Encodes off the loop so the JPEG encode (30-80 ms at 1080p, more
        at 4K) doesn't block HA.
        """
        encoded = await self.hass.async_add_executor_job(
            ip.encode_image, composed
        )

        self._framebuffer = encoded
        self.store.last_frame = encoded
        self._frame_id += 1
        if meta:
            self._last_is_portrait = meta.get("is_portrait")
        else:
            self._last_is_portrait = None
        self._last_captured_at_pair = meta.get("captured_at_pair") if meta else None
        self._last_pair_frames = meta.get("pair_frames") if meta else None
        self._last_pair_orientation = meta.get("pair_orientation") if meta else None

        self.async_write_ha_state()

    @property
    def _compose_semaphore(self) -> asyncio.Semaphore:
        """Return the domain-wide compose semaphore, creating it on demand.

        ``__init__.py`` populates it during setup, but defensive
        initialisation here means a partially-loaded integration can
        still render without crashing.
        """
        domain_data = self.hass.data.setdefault(DOMAIN, {})
        sem = domain_data.get("compose_semaphore")
        if sem is None:
            sem = asyncio.Semaphore(1)
            domain_data["compose_semaphore"] = sem
        return sem

    def _do_advance(self, count: int, items: list) -> None:
        """Advance _index to the next slide and commit random-order position."""
        if count <= 0:
            self._index = 0
            return

        self._index %= count
        order_mode = self.store.order_mode

        # Sequential modes (album order + sorted-by-time orderings) walk in
        # order. The list is already pre-sorted by ``order_items``, so we
        # only need to step forward.
        if order_mode != ORDER_RANDOM:
            self._index = (self._index + 1) % count
            return

        self._index = self._next_random_index(count)
        cur_url = items[self._index].url
        self._recent_urls.append(cur_url)
        keep = min(20, max(1, count - 1))
        if len(self._recent_urls) > keep:
            self._recent_urls = self._recent_urls[-keep:]

    def _peek_advance(self, count: int, items: list) -> None:
        """Advance _index without committing to random-order bookkeeping.

        Used by the orientation-avoid search so that rejected candidates
        don't burn through the random cycle and cause premature repeats.
        """
        if count <= 0:
            self._index = 0
            return
        self._index = (self._index + 1) % count

    async def _compose_for_index(
        self, items: list[MediaItem]
    ) -> tuple[Image.Image | None, dict | None]:
        """Compose the slide at ``self._index`` into a PIL image.

        Returns ``(composed, meta)`` where ``meta`` carries the orientation
        and paired-capture metadata that ``_commit_composed`` will publish
        as state attributes. Returns ``(None, None)`` if compose failed.

        Pure compose - does NOT mutate ``self._framebuffer`` or
        ``self._last_*`` state. The caller commits via ``_commit_composed``.
        """
        fill_mode = self.store.fill_mode
        portrait_mode = self.store.portrait_mode
        divider = max(0, int(self.store.pair_divider_px))
        divider_fill, transparent_divider = ip.parse_divider_color(self.store.pair_divider_color)
        max_short_edge = MAX_RESOLUTION_SHORT_EDGE.get(self.store.max_resolution)
        width, height = ip.resolve_output_size(None, None, self.store.aspect_ratio, max_short_edge)

        cur = items[self._index]
        is_portrait_canvas = height > width

        # Metadata fast path: if we can resolve orientation without downloading,
        # we may short-circuit the mismatch handling before any bytes are read.
        meta_portrait = ip.is_portrait_item_by_metadata(cur)
        if (
            meta_portrait is not None
            and meta_portrait != is_portrait_canvas
            and portrait_mode == ORIENTATION_MISMATCH_AVOID
        ):
            return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)

        cur_bytes = await self._fetch_bytes(cur.url)
        if not cur_bytes:
            raise RuntimeError(f"Failed to fetch image: {cur.url}")

        img = await self.hass.async_add_executor_job(
            ip.open_image, cur_bytes, (width, height)
        )
        try:
            cur_is_portrait = ip.is_portrait_item(cur, img)
            orientation_mismatch = cur_is_portrait != is_portrait_canvas

            if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_AVOID:
                ip.safe_close(img)
                img = None
                return await self._compose_skip_mismatch(items, width, height, fill_mode, is_portrait_canvas)

            if orientation_mismatch and portrait_mode == ORIENTATION_MISMATCH_PAIR:
                pair = await self._find_next_mismatch_image(
                    items, is_portrait_canvas, width, height, limit=_PAIR_SEARCH_LIMIT
                )
                other_img = pair[0] if pair else None
                other_item = pair[1] if pair else None
                pair_meta: list[str | None] | None = None
                pair_frames: list[dict] | None = None
                try:
                    if other_img is not None:
                        composed = await self.hass.async_add_executor_job(
                            ip.pair_images, img, other_img, width, height, fill_mode,
                            is_portrait_canvas, divider, divider_fill, transparent_divider,
                        )
                        pair_frames = [
                            {
                                "captured_at": _ts_to_iso(getattr(cur, "captured_at", None)),
                                "location": getattr(cur, "location", None),
                                "latitude": getattr(cur, "latitude", None),
                                "longitude": getattr(cur, "longitude", None),
                                "description": getattr(cur, "description", None),
                            },
                            {
                                "captured_at": _ts_to_iso(getattr(other_item, "captured_at", None)),
                                "location": getattr(other_item, "location", None),
                                "latitude": getattr(other_item, "latitude", None),
                                "longitude": getattr(other_item, "longitude", None),
                                "description": getattr(other_item, "description", None),
                            },
                        ]
                        pair_meta = [f["captured_at"] for f in pair_frames]
                    else:
                        composed = await self.hass.async_add_executor_job(
                            ip.render_image, img, fill_mode, width, height,
                        )
                finally:
                    ip.safe_close(other_img)
                meta = {
                    "is_portrait": cur_is_portrait,
                    "captured_at_pair": pair_meta,
                    "pair_frames": pair_frames,
                    # ``pair_images`` stacks images top/bottom on a portrait
                    # canvas and places them left/right on a landscape canvas.
                    "pair_orientation": (
                        ("vertical" if is_portrait_canvas else "horizontal")
                        if pair_frames
                        else None
                    ),
                }
                return composed, meta

            composed = await self.hass.async_add_executor_job(
                ip.render_image, img, fill_mode, width, height
            )
            return composed, {
                "is_portrait": cur_is_portrait,
                "captured_at_pair": None,
            }
        finally:
            ip.safe_close(img)

    async def _compose_skip_mismatch(
        self,
        items: list[MediaItem],
        width: int,
        height: int,
        fill_mode: str,
        is_portrait_canvas: bool,
    ) -> tuple[Image.Image | None, dict | None]:
        """Skip-mismatch variant of ``_compose_for_index``.

        Walks forward (peek-advancing for non-matches) until it finds an
        image whose orientation matches the canvas, then composes it.
        """
        count = len(items)
        if count <= 0:
            return None, None

        start = self._index

        for _ in range(min(count, _SKIP_SEARCH_LIMIT)):
            cur = items[self._index]

            meta_portrait = ip.is_portrait_item_by_metadata(cur)
            if meta_portrait is not None:
                if meta_portrait != is_portrait_canvas:
                    self._peek_advance(count, items)
                    continue
                if self._index != start:
                    self._do_advance(count, items)
                return await self._compose_single(cur, width, height, fill_mode)

            b = await self._fetch_bytes(cur.url)
            if not b:
                self._peek_advance(count, items)
                continue
            img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
            try:
                if ip.is_portrait_item(cur, img) != is_portrait_canvas:
                    self._peek_advance(count, items)
                    continue
                if self._index != start:
                    self._do_advance(count, items)
                composed = await self.hass.async_add_executor_job(
                    ip.render_image, img, fill_mode, width, height
                )
                return composed, {
                    "is_portrait": is_portrait_canvas,
                    "captured_at_pair": None,
                }
            finally:
                ip.safe_close(img)

        self._index = start
        return await self._compose_single(items[self._index], width, height, fill_mode)

    async def _compose_single(
        self,
        item: MediaItem,
        width: int,
        height: int,
        fill_mode: str,
    ) -> tuple[Image.Image | None, dict | None]:
        b = await self._fetch_bytes(item.url)
        if not b:
            return None, None
        img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
        try:
            cur_is_portrait = ip.is_portrait_item(item, img)
            composed = await self.hass.async_add_executor_job(
                ip.render_image, img, fill_mode, width, height
            )
            return composed, {
                "is_portrait": cur_is_portrait,
                "captured_at_pair": None,
            }
        finally:
            ip.safe_close(img)

    async def _render_current(self, items: list[MediaItem]) -> bytes | None:
        """Compatibility wrapper: compose + encode the current slide.

        Kept as a thin wrapper because external code paths (e.g., tests)
        may still call it. ``_render_cycle`` no longer does.
        """
        composed, _ = await self._compose_for_index(items)
        if composed is None:
            return None
        try:
            return await self.hass.async_add_executor_job(ip.encode_image, composed)
        finally:
            ip.safe_close(composed)

    async def _find_next_mismatch_image(
        self,
        items: list[MediaItem],
        is_portrait_canvas: bool,
        width: int,
        height: int,
        limit: int = _PAIR_SEARCH_LIMIT,
    ) -> tuple[Image.Image, MediaItem] | None:
        """Find an image with the opposite orientation of the canvas.

        Uses metadata wherever possible - only candidates without width/height
        metadata are downloaded and decoded for their orientation. The returned
        PIL image is the caller's to close. The matching ``MediaItem`` is
        returned alongside so the caller can attribute timestamps etc.
        """
        if not items:
            return None
        n = len(items)
        tries = 0
        offset = 1
        while tries < limit and offset < n:
            idx = (self._index + offset) % n
            it = items[idx]
            offset += 1
            tries += 1

            if it.url in self._recent_urls:
                continue

            meta_portrait = ip.is_portrait_item_by_metadata(it)
            if meta_portrait is not None and meta_portrait == is_portrait_canvas:
                # Metadata says this one is the wrong orientation for pairing; skip.
                continue

            b = await self._fetch_bytes(it.url)
            if not b:
                continue

            try:
                img = await self.hass.async_add_executor_job(ip.open_image, b, (width, height))
            except Exception:
                continue

            if ip.is_portrait_item(it, img) != is_portrait_canvas:
                return img, it
            ip.safe_close(img)

        return None

    def _next_random_index(self, count: int) -> int:
        if count <= 1:
            self._random_order = [0]
            self._random_pos = 0
            return 0

        needs_new_cycle = len(self._random_order) != count or self._random_pos >= len(self._random_order)
        if needs_new_cycle:
            self._random_order = list(range(count))
            self._rng.shuffle(self._random_order)
            self._random_pos = 0

            if self._random_order and self._random_order[0] == self._index:
                self._random_order.append(self._random_order.pop(0))

        idx = self._random_order[self._random_pos]
        self._random_pos += 1
        return idx

    async def _fetch_bytes(self, url: str) -> bytes | None:
        cached = self._download_cache.get(url)
        if cached is not None:
            return cached

        if url.startswith("file://"):
            try:
                p = Path(url[7:])
                data = await self.hass.async_add_executor_job(p.read_bytes)
            except Exception as err:
                _LOGGER.warning("Album Slideshow: failed to read local image: %s", err)
                return None
            if len(data) > _MAX_DOWNLOAD_BYTES:
                _LOGGER.warning(
                    "Album Slideshow: local image %s is %d bytes, exceeds %d byte limit; skipping",
                    url, len(data), _MAX_DOWNLOAD_BYTES,
                )
                return None
        else:
            data = await self._http_get(url)
            if data is None:
                return None

        self._download_cache.put(url, data)
        return data

    def _image_request_headers(self, url: str) -> dict[str, str] | None:
        """Auth headers required to fetch image bytes for some providers.

        The Immich provider stores an ``x-api-key`` header on the coordinator;
        it is sent server-side only, so the key never reaches the browser or
        the camera's ``current_url`` attribute. Returns ``None`` when no extra
        headers are needed (Google, local folder, media source).
        """
        headers = getattr(self.coordinator, "image_request_headers", None)
        if headers and isinstance(url, str) and url.startswith("http"):
            return dict(headers)
        return None

    async def _http_get(self, url: str) -> bytes | None:
        session = async_get_clientsession(self.hass)
        try:
            async with async_timeout.timeout(30):
                async with session.get(url, headers=self._image_request_headers(url)) as resp:
                    resp.raise_for_status()

                    content_type = resp.headers.get("Content-Type", "")
                    primary = content_type.split(";", 1)[0].strip().lower()
                    if primary and not primary.startswith(_ACCEPTED_IMAGE_PREFIX):
                        _LOGGER.debug(
                            "Album Slideshow: rejecting %s, content-type %r is not an image",
                            url, primary,
                        )
                        return None

                    content_length = resp.headers.get("Content-Length")
                    if content_length is not None:
                        try:
                            declared = int(content_length)
                        except ValueError:
                            declared = -1
                        if declared > _MAX_DOWNLOAD_BYTES:
                            _LOGGER.warning(
                                "Album Slideshow: %s advertises %d bytes, exceeds %d byte limit; skipping",
                                url, declared, _MAX_DOWNLOAD_BYTES,
                            )
                            return None

                    chunks: list[bytes] = []
                    total = 0
                    async for chunk in resp.content.iter_chunked(64 * 1024):
                        total += len(chunk)
                        if total > _MAX_DOWNLOAD_BYTES:
                            _LOGGER.warning(
                                "Album Slideshow: %s exceeded %d byte limit mid-download; aborting",
                                url, _MAX_DOWNLOAD_BYTES,
                            )
                            return None
                        chunks.append(chunk)
                    return b"".join(chunks)
        except Exception as err:
            _LOGGER.warning("Album Slideshow: failed to fetch image: %s", err)
            return None
