from __future__ import annotations

import asyncio
import json
import logging
import os

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er

from .const import DOMAIN, SERVICE_NEXT_SLIDE, SERVICE_REFRESH_ALBUM, ATTR_ENTRY_ID
from .store import SlideshowStore

_LOGGER = logging.getLogger(__name__)

PLATFORMS: list[str] = ["camera", "sensor", "button", "number", "select", "text", "switch"]

CARD_STATIC_PATH = "/album_slideshow_static"
CARD_FILE = "album-slideshow-card.js"


async def _async_register_card(hass: HomeAssistant) -> None:
    """Serve the Lovelace card JS and register it as a frontend module.

    Idempotent: only the first config entry to load triggers registration
    for the HA session. The card lets dashboards cross-fade between slides
    in the browser (GPU compositor) instead of forcing the camera entity
    to render a JPEG burst on the event loop.
    """
    if hass.data.get(DOMAIN, {}).get("card_registered"):
        return

    integration_dir = os.path.dirname(__file__)
    www_dir = os.path.join(integration_dir, "www")
    card_path = os.path.join(www_dir, CARD_FILE)

    if not os.path.isfile(card_path):
        # Some HACS upgrade paths (and broken zip extractors) drop the
        # ``www/`` subdirectory. Try to recover by checking whether the
        # integration root has the file under a literal-backslash name
        # (a symptom of zips written with Windows path separators) or
        # directly at the root, and salvage it into ``www/`` so the
        # rest of the registration can proceed.
        recovered = await hass.async_add_executor_job(
            _recover_card_from_root, integration_dir, www_dir, card_path
        )
        if not recovered:
            _LOGGER.warning(
                "Album Slideshow card missing on disk (%s). Re-install"
                " the integration via HACS (3-dot menu -> Redownload)"
                " or copy %s/%s into the album_slideshow folder."
                " The custom:album-slideshow-card type will not be"
                " available until this is fixed.",
                card_path,
                "www",
                CARD_FILE,
            )
            return

    try:
        from homeassistant.components.http import StaticPathConfig

        await hass.http.async_register_static_paths(
            [StaticPathConfig(CARD_STATIC_PATH, www_dir, False)]
        )
    except Exception:  # noqa: BLE001 - many possible failure modes here
        _LOGGER.exception(
            "Failed to register static path for Album Slideshow card"
        )
        return

    # Cache-bust the card URL with the integration version so dashboards
    # always pick up the script that matches the running integration
    # rather than a stale copy from a previous release.
    version = await hass.async_add_executor_job(
        _read_manifest_version, integration_dir
    )
    card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
    if version:
        card_url = f"{card_url}?v={version}"

    # Prefer registering the card as a Lovelace resource for storage-mode
    # dashboards. Resources are loaded as part of the Lovelace bootstrap,
    # before any dashboard renders custom cards, which removes the race
    # where the dashboard can hit "Custom element doesn't exist" if the
    # browser hasn't finished loading the module yet (a real risk after
    # HA restarts and integration upgrades that bust the cache).
    #
    # The storage-mode resources collection is only consumed by
    # storage-mode dashboards; YAML-mode dashboards (whether the user
    # has ``lovelace.mode: yaml`` globally or per-dashboard
    # ``mode: yaml`` entries) read only the resources declared in their
    # own YAML and would otherwise never load the card. That's why we
    # *always* also call ``add_extra_js_url``: the frontend injects a
    # ``<script>`` tag on every Lovelace render regardless of mode, so
    # it's the universal fallback. Module loads are deduplicated by URL
    # and the card's ``customElements.define()`` calls have an
    # idempotency guard, so a storage-mode dashboard picking up both
    # paths is a harmless no-op.
    resource_registered = await _try_register_lovelace_resource(hass, card_url)
    if resource_registered:
        hass.data.setdefault(DOMAIN, {})[
            "lovelace_resource_registered"
        ] = True

    try:
        from homeassistant.components.frontend import add_extra_js_url

        add_extra_js_url(hass, card_url)
    except Exception:  # noqa: BLE001
        _LOGGER.exception(
            "Failed to add Album Slideshow card via add_extra_js_url"
            " (URL %s); the card may still load if a Lovelace resource"
            " was registered above.",
            card_url,
        )

    hass.data.setdefault(DOMAIN, {})["card_registered"] = True
    if resource_registered:
        _LOGGER.info(
            "Album Slideshow card registered (Lovelace resource +"
            " add_extra_js_url) at %s",
            card_url,
        )
    else:
        _LOGGER.info(
            "Album Slideshow card registered via add_extra_js_url at"
            " %s; Lovelace resources collection was not available so"
            " no resource entry was created.",
            card_url,
        )


async def _try_register_lovelace_resource(
    hass: HomeAssistant, card_url: str
) -> bool:
    """Register the card as a Lovelace resource for storage-mode dashboards.

    Returns ``True`` if the resource was added (or already present at the
    requested version); ``False`` if Lovelace isn't loaded, the dashboard
    is in YAML mode, or anything else went wrong - in which case the
    caller is expected to fall back to ``add_extra_js_url``.

    The resource collection API is internal to HA and has changed shape
    between versions, so we feel our way through it with ``getattr`` and
    swallow any unexpected exception. The downside of getting this wrong
    is one extra script tag in the dashboard, not a crash.
    """
    try:
        lovelace_data = hass.data.get("lovelace")
        if lovelace_data is None:
            _LOGGER.debug(
                "Lovelace data not yet present; cannot register resource"
            )
            return False

        # In recent HA the lovelace key is a LovelaceData object exposing
        # ``resources``; in older versions it was a dict with the same key.
        if isinstance(lovelace_data, dict):
            resources = lovelace_data.get("resources")
        else:
            resources = getattr(lovelace_data, "resources", None)

        if resources is None or not hasattr(resources, "async_create_item"):
            # YAML-mode dashboards expose a ResourceYAMLCollection that is
            # read-only; users edit ``configuration.yaml`` themselves.
            _LOGGER.debug(
                "Lovelace resources unavailable or read-only;"
                " falling back to add_extra_js_url"
            )
            return False

        # Make sure the storage collection has loaded its file. Some HA
        # versions lazy-load on first ``async_items()`` access; calling
        # ``async_load`` explicitly is safe either way.
        if hasattr(resources, "async_load"):
            try:
                await resources.async_load()
            except Exception:  # noqa: BLE001
                # Storage collection may be in an unloaded state with no
                # file yet - treated the same as "no items".
                pass

        items = []
        if hasattr(resources, "async_items"):
            try:
                items = list(resources.async_items())
            except Exception:  # noqa: BLE001
                items = []

        base = card_url.split("?", 1)[0]
        same_version_present = False
        stale_ids: list[str] = []
        for item in items:
            if isinstance(item, dict):
                url = item.get("url", "") or ""
                item_id = item.get("id")
            else:
                url = getattr(item, "url", "") or ""
                item_id = getattr(item, "id", None)
            if url.split("?", 1)[0] != base:
                continue
            if url == card_url:
                same_version_present = True
            elif item_id:
                stale_ids.append(item_id)

        # Strip stale registrations (older versions of the card pinned via
        # an out-of-date ``?v=...`` query) so the dashboard doesn't pull
        # both the new and the old script.
        for item_id in stale_ids:
            try:
                await resources.async_delete_item(item_id)
            except Exception:  # noqa: BLE001
                _LOGGER.debug(
                    "Could not delete stale Lovelace resource id=%s",
                    item_id,
                    exc_info=True,
                )

        if not same_version_present:
            await resources.async_create_item(
                {"url": card_url, "res_type": "module"}
            )
        return True
    except Exception:  # noqa: BLE001
        _LOGGER.debug(
            "Lovelace resource registration failed; will fall back to"
            " add_extra_js_url",
            exc_info=True,
        )
        return False


def _read_manifest_version(integration_dir: str) -> str | None:
    try:
        with open(
            os.path.join(integration_dir, "manifest.json"),
            "r",
            encoding="utf-8",
        ) as fh:
            return json.load(fh).get("version")
    except Exception:  # noqa: BLE001
        return None


def _recover_card_from_root(
    integration_dir: str, www_dir: str, card_path: str
) -> bool:
    """Salvage the card file from a broken extraction.

    PowerShell's ``Compress-Archive`` writes zip entries with backslash
    separators, which Linux unzip implementations may treat as literal
    filenames. The resulting layout is::

        custom_components/album_slideshow/www\\album-slideshow-card.js

    instead of the expected ``www/album-slideshow-card.js``. Move it to
    the right place so subsequent installs don't need a re-download.
    """
    candidates = [
        os.path.join(integration_dir, f"www\\{CARD_FILE}"),
        os.path.join(integration_dir, CARD_FILE),
    ]
    for src in candidates:
        if os.path.isfile(src):
            try:
                os.makedirs(www_dir, exist_ok=True)
                os.replace(src, card_path)
                _LOGGER.info(
                    "Recovered Album Slideshow card from %s", src
                )
                return True
            except OSError:
                _LOGGER.exception(
                    "Found candidate card at %s but could not move it"
                    " to %s",
                    src,
                    card_path,
                )
                return False
    return False


async def _async_cleanup_legacy_entities(hass: HomeAssistant, entry: ConfigEntry) -> None:
    registry = er.async_get(hass)
    # Server-side transitions were tried in earlier 0.7-rc builds and
    # removed before the first public 0.7 pre-release because the
    # resource cost on low-end hardware was too high. Drop any leftover
    # transition entities so users don't see stale disabled rows under
    # the device.
    legacy_unique_ids = {
        f"{entry.entry_id}_max_items",
        f"{entry.entry_id}_transition",
        f"{entry.entry_id}_transition_duration_ms",
        f"{entry.entry_id}_transition_fps",
    }

    for entity in er.async_entries_for_config_entry(registry, entry.entry_id):
        if entity.unique_id in legacy_unique_ids:
            registry.async_remove(entity.entity_id)


async def async_setup(hass: HomeAssistant, config: dict) -> bool:
    """Register the Lovelace card during HA bootstrap.

    Running this in ``async_setup`` (not only in ``async_setup_entry``)
    registers the card's static path and frontend resource at
    integration load time, well before any config entry finishes
    setting up. The earlier the resource is registered, the smaller
    the window in which a dashboard can render a card before the
    script's ``customElements.define()`` has run, which is what
    produces the intermittent "Custom element doesn't exist:
    album-slideshow-card" errors on slow devices (tablets) that open
    the dashboard during HA startup.

    We also retry the Lovelace storage-resource registration once HA
    finishes starting, because ``hass.data['lovelace']`` may not yet
    be populated when ``async_setup`` runs. The storage-resource path
    is the only mechanism that *gates* dashboard render on resource
    load (``add_extra_js_url`` just injects a script tag with no
    ordering guarantee), so we want it to succeed even if we beat
    Lovelace to the punch on the first attempt.
    """
    hass.data.setdefault(DOMAIN, {})
    await _async_register_card(hass)

    async def _retry_lovelace_resource(_event) -> None:
        if hass.data.get(DOMAIN, {}).get("lovelace_resource_registered"):
            return
        integration_dir = os.path.dirname(__file__)
        version = await hass.async_add_executor_job(
            _read_manifest_version, integration_dir
        )
        card_url = f"{CARD_STATIC_PATH}/{CARD_FILE}"
        if version:
            card_url = f"{card_url}?v={version}"
        if await _try_register_lovelace_resource(hass, card_url):
            hass.data.setdefault(DOMAIN, {})[
                "lovelace_resource_registered"
            ] = True
            _LOGGER.info(
                "Album Slideshow card registered as Lovelace resource"
                " on HA started (late retry; Lovelace was not yet"
                " ready during integration setup)"
            )

    hass.bus.async_listen_once(
        EVENT_HOMEASSISTANT_STARTED, _retry_lovelace_resource
    )
    return True


async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    from .coordinator import AlbumCoordinator

    hass.data.setdefault(DOMAIN, {})
    # Domain-wide concurrency limit on compose work. Multiple album cameras
    # share HA's small executor pool; without coordination they can all
    # decode + render in parallel and saturate the loop. One ticket means
    # at most one album does PIL work at a time, queueing the rest.
    if "compose_semaphore" not in hass.data[DOMAIN]:
        hass.data[DOMAIN]["compose_semaphore"] = asyncio.Semaphore(1)

    # Register the Lovelace card once per HA session. The card runs the
    # GPU-composited transitions in the browser so the camera entity
    # itself never has to render a transition burst on the event loop.
    await _async_register_card(hass)

    await _async_cleanup_legacy_entities(hass, entry)

    store = SlideshowStore()
    coordinator = AlbumCoordinator(hass, entry, store)
    await coordinator.async_config_entry_first_refresh()

    hass.data[DOMAIN][entry.entry_id] = {
        "coordinator": coordinator,
        "store": store,
        "camera": None,
    }

    async def _next_slide(call) -> None:
        entry_id = call.data.get(ATTR_ENTRY_ID)
        if not entry_id:
            return
        data = hass.data.get(DOMAIN, {}).get(entry_id)
        if not data:
            return
        cam = data.get("camera")
        if cam:
            await cam.async_force_next()

    async def _refresh_album(call) -> None:
        entry_id = call.data.get(ATTR_ENTRY_ID)
        if not entry_id:
            return
        data = hass.data.get(DOMAIN, {}).get(entry_id)
        if not data:
            return
        cam = data.get("camera")
        if cam:
            await cam.async_force_refresh()

    if not hass.services.has_service(DOMAIN, SERVICE_NEXT_SLIDE):
        hass.services.async_register(DOMAIN, SERVICE_NEXT_SLIDE, _next_slide)

    if not hass.services.has_service(DOMAIN, SERVICE_REFRESH_ALBUM):
        hass.services.async_register(DOMAIN, SERVICE_REFRESH_ALBUM, _refresh_album)

    await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

    store.notify()

    return True


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
    unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
    if unload_ok:
        domain_data = hass.data.get(DOMAIN, {})
        domain_data.pop(entry.entry_id, None)
        # Drop the shared semaphore once the last album is gone so it's
        # re-created if the integration is re-added later.
        entry_keys = [
            k for k in domain_data.keys() if k != "compose_semaphore"
        ]
        if not entry_keys:
            domain_data.pop("compose_semaphore", None)
    return unload_ok
