"""OTA Firmware providers."""

from __future__ import annotations

import asyncio
import dataclasses
import datetime
import hashlib
import json
import logging
import pathlib
import re
import ssl
import typing
import urllib.parse

import aiohttp
import attrs
import jsonschema
import voluptuous as vol

import zigpy.config
from zigpy.ota import json_schemas
from zigpy.ota.image import BaseOTAImage, parse_ota_image
import zigpy.types as t
import zigpy.util

if typing.TYPE_CHECKING:
    import zigpy.device

LOGGER = logging.getLogger(__name__)

# `openssl s_client -connect fw.ota.homesmart.ikea.com:443 -showcerts`
IKEA_CERT = """\
-----BEGIN CERTIFICATE-----
MIICGDCCAZ+gAwIBAgIUdfH0KDnENv/dEcxH8iVqGGGDqrowCgYIKoZIzj0EAwMw
SzELMAkGA1UEBhMCU0UxGjAYBgNVBAoMEUlLRUEgb2YgU3dlZGVuIEFCMSAwHgYD
VQQDDBdJS0VBIEhvbWUgc21hcnQgUm9vdCBDQTAgFw0yMTA1MjYxOTAxMDlaGA8y
MDcxMDUxNDE5MDEwOFowSzELMAkGA1UEBhMCU0UxGjAYBgNVBAoMEUlLRUEgb2Yg
U3dlZGVuIEFCMSAwHgYDVQQDDBdJS0VBIEhvbWUgc21hcnQgUm9vdCBDQTB2MBAG
ByqGSM49AgEGBSuBBAAiA2IABIDRUvKGFMUu2zIhTdgfrfNcPULwMlc0TGSrDLBA
oTr0SMMV4044CRZQbl81N4qiuHGhFzCnXapZogkiVuFu7ZqSslsFuELFjc6ZxBjk
Kmud+pQM6QQdsKTE/cS06dA+P6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4E
FgQUcdlEnfX0MyZA4zAdY6CLOye9wfwwDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49
BAMDA2cAMGQCMG6mFIeB2GCFch3r0Gre4xRH+f5pn/bwLr9yGKywpeWvnUPsQ1KW
ckMLyxbeNPXdQQIwQc2YZDq/Mz0mOkoheTUWiZxK2a5bk0Uz1XuGshXmQvEg5TGy
2kVHW/Mz9/xwpy4u
-----END CERTIFICATE-----"""

# `openssl s_client -connect otau.meethue.com:443 -showcerts`
HUE_CERT = """\
-----BEGIN CERTIFICATE-----
MIIBwDCCAWagAwIBAgIJAJtrMkoTxs+WMAoGCCqGSM49BAMCMDIxCzAJBgNVBAYT
Ak5MMRQwEgYDVQQKDAtQaGlsaXBzIEh1ZTENMAsGA1UEAwwEcm9vdDAgFw0xNjA4
MjUwNzU5NDNaGA8yMDY4MDEwNTA3NTk0M1owMjELMAkGA1UEBhMCTkwxFDASBgNV
BAoMC1BoaWxpcHMgSHVlMQ0wCwYDVQQDDARyb290MFkwEwYHKoZIzj0CAQYIKoZI
zj0DAQcDQgAEENC1JOl6BxJrwCb+YK655zlM57VKFSi5OHDsmlCaF/EfTGGgU08/
JUtkCyMlHUUoYBZyzCBKXqRKkrT512evEKNjMGEwHQYDVR0OBBYEFAlkFYACVzir
qTr++cWia8AKH/fOMB8GA1UdIwQYMBaAFAlkFYACVzirqTr++cWia8AKH/fOMA8G
A1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMAoGCCqGSM49BAMCA0gAMEUC
IQDcGfyXaUl5hjr5YE8m2piXhMcDzHTNbO1RvGgz4r9IswIgFTTw/R85KyfIiW+E
clwJRVSsq8EApeFREenCkRM0EIk=
-----END CERTIFICATE-----"""

OTA_PROVIDER_TYPES: dict[str, type[BaseOtaProvider]] = {}


def register_provider(provider: type[BaseOtaProvider]) -> type[BaseOtaProvider]:
    """Register a new OTA provider."""
    OTA_PROVIDER_TYPES[provider.NAME] = provider
    return provider


@attrs.define(frozen=True, kw_only=True)
class BaseOtaImageMetadata(t.BaseDataclassMixin):
    file_version: int
    manufacturer_id: int | None = None
    image_type: int | None = None

    checksum: str | None = None
    file_size: int | None = None

    manufacturer_names: tuple[str, ...] = ()
    model_names: tuple[str, ...] = ()

    changelog: str | None = None
    release_notes: str | None = None
    release_url: str | None = None

    min_hardware_version: int | None = None
    max_hardware_version: int | None = None
    min_current_file_version: int | None = None
    max_current_file_version: int | None = None
    specificity: int | None = None

    source: str = "Unknown"
    trusted: bool = False

    async def _fetch(self) -> bytes:
        raise NotImplementedError

    async def _validate(self, data: bytes) -> None:
        if self.file_size is not None and len(data) != self.file_size:
            raise ValueError(
                f"Image size is invalid: expected {self.file_size} bytes,"
                f" got {len(data)} bytes"
            )

        if self.checksum is not None:
            algorithm, checksum = self.checksum.split(":")
            hasher = hashlib.new(algorithm)
            await asyncio.get_running_loop().run_in_executor(None, hasher.update, data)

            if hasher.hexdigest() != checksum:
                raise ValueError(
                    f"Image checksum is invalid: expected {checksum},"
                    f" got {hasher.hexdigest()}"
                )

    async def fetch(self) -> BaseOTAImage:
        data = await self._fetch()
        await self._validate(data)

        image, _ = parse_ota_image(data)
        return image


@attrs.define(frozen=True, kw_only=True)
class RemoteOtaImageMetadata(BaseOtaImageMetadata):
    url: str

    # If a provider uses a self-signed certificate, it can override this
    ssl_ctx: ssl.SSLContext | None = None

    async def _fetch(self) -> bytes:
        async with aiohttp.ClientSession(raise_for_status=True) as req:
            async with req.get(self.url, ssl=self.ssl_ctx) as rsp:
                return await rsp.read()


@attrs.define(frozen=True, kw_only=True)
class LocalOtaImageMetadata(BaseOtaImageMetadata):
    path: pathlib.Path

    async def _fetch(self) -> bytes:
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self.path.read_bytes)


@attrs.define(frozen=True, kw_only=True)
class IkeaRemoteOtaImageMetadata(RemoteOtaImageMetadata):
    ssl_ctx = dataclasses.field(default_factory=lambda: Tradfri.SSL_CTX)

    async def _fetch(self) -> bytes:
        async with aiohttp.ClientSession(raise_for_status=True) as req:
            # Use IKEA's self-signed certificate
            async with req.get(self.url, ssl=Tradfri.SSL_CTX) as rsp:
                return await rsp.read()


@attrs.define(frozen=True, kw_only=True)
class SignedIkeaRemoteOtaImageMetadata(IkeaRemoteOtaImageMetadata):
    ssl_ctx = dataclasses.field(default_factory=lambda: Tradfri.SSL_CTX)

    async def _validate(self, data: bytes) -> None:
        ota_offset = int.from_bytes(data[16:20], "little")
        ota_size = int.from_bytes(data[20:24], "little")
        block_size = int.from_bytes(data[32:36], "little")
        num_block_hashes = int.from_bytes(data[36:40], "little")

        if (
            not data.startswith(b"NGIS")
            or self.file_size != ota_size
            or 40 + 32 * num_block_hashes != ota_offset
            or block_size * num_block_hashes < ota_size
        ):
            raise ValueError(f"Invalid signed container: {data[:16]!r}")

        loop = asyncio.get_running_loop()

        for block_num in range(num_block_hashes):
            offset = ota_offset + block_size * block_num
            size = block_size - max(0, offset + block_size - (ota_offset + ota_size))

            block = data[offset : offset + size]
            expected_checksum = data[40 + 32 * block_num : 40 + 32 * (block_num + 1)]
            hasher = await loop.run_in_executor(None, hashlib.sha256, block)

            if hasher.digest() != expected_checksum:
                raise ValueError(f"Block {block_num} has invalid checksum")


class BaseOtaProvider:
    NAME: str
    MANUFACTURER_IDS: tuple[int, ...] = ()
    DEFAULT_URL: str | None = None
    VOL_SCHEMA: vol.Schema
    JSON_SCHEMA: dict | None = None
    INDEX_EXPIRATION_TIME = datetime.timedelta(hours=24)
    TRUSTED: bool = False

    def __init__(
        self,
        url: str | typing.Literal[True] | None = None,
        manufacturer_ids: list[int] | None = None,
        *,
        override_previous: bool = False,
    ) -> None:
        self.url = self.DEFAULT_URL if url in (True, None) else url
        self._index_last_updated = datetime.datetime.fromtimestamp(0, tz=datetime.UTC)

        if manufacturer_ids is not None:
            self.manufacturer_ids = tuple(manufacturer_ids)
        else:
            self.manufacturer_ids = tuple(self.MANUFACTURER_IDS)

        self.override_previous = override_previous

    def compatible_with_device(self, device: zigpy.device.Device) -> bool:
        if not self.manufacturer_ids:
            return True

        return device.manufacturer_id in self.manufacturer_ids

    async def load_index(self) -> list[BaseOtaImageMetadata] | None:
        now = datetime.datetime.now(datetime.UTC)

        # Don't hammer the OTA indexes too frequently
        if now - self._index_last_updated < self.INDEX_EXPIRATION_TIME:
            return None

        try:
            async with aiohttp.ClientSession(
                headers={"accept": "application/json"},
                raise_for_status=True,
            ) as session:
                return [meta async for meta in self._load_index(session)]
        finally:
            self._index_last_updated = now

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        if typing.TYPE_CHECKING:
            yield

        raise NotImplementedError

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, BaseOtaProvider):
            return NotImplemented

        # Providers of a different concrete type are never equal
        if type(self) is not type(other):
            return False

        return self.url == other.url and self.manufacturer_ids == other.manufacturer_ids

    def __hash__(self) -> int:
        return hash((type(self), self.url, self.manufacturer_ids))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(url={self.url!r}, manufacturer_ids={self.manufacturer_ids!r})"


@register_provider
class Tradfri(BaseOtaProvider):
    NAME = "ikea"
    MANUFACTURER_IDS = (4476,)
    DEFAULT_URL = "https://fw.ota.homesmart.ikea.com/DIRIGERA/version_info.json"
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_URL
    JSON_SCHEMA = json_schemas.TRADFRI_SCHEMA

    SSL_CTX: ssl.SSLContext = ssl.create_default_context()
    SSL_CTX.load_verify_locations(cadata=IKEA_CERT)

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        async with session.get(self.url, ssl=self.SSL_CTX) as rsp:
            # IKEA does not always respond with an appropriate Content-Type but the
            # response is always JSON
            fw_lst = await rsp.json(content_type=None)

        jsonschema.validate(fw_lst, self.JSON_SCHEMA)

        for fw in fw_lst:
            # Skip the gateway image
            if "fw_image_type" not in fw:
                continue

            if "fw_sha3_256" in fw:
                # New style IKEA
                file_version_match = re.match(r".*_v(?P<v>\d+)_.*", fw["fw_binary_url"])

                if file_version_match is None:
                    LOGGER.warning("Could not parse IKEA OTA JSON: %r", fw)
                    continue

                image = IkeaRemoteOtaImageMetadata(
                    file_version=int(file_version_match.group("v"), 10),
                    manufacturer_id=self.MANUFACTURER_IDS[0],
                    image_type=fw["fw_image_type"],
                    checksum="sha3-256:" + fw["fw_sha3_256"],
                    url=fw["fw_binary_url"],
                    source="IKEA (DIRIGERA)",
                )
            else:
                # Old style IKEA
                if fw["fw_type"] != 2:
                    continue

                image = SignedIkeaRemoteOtaImageMetadata(
                    file_version=(
                        (fw["fw_file_version_MSB"] << 16)
                        | (fw["fw_file_version_LSB"] << 0)
                    ),
                    manufacturer_id=fw["fw_manufacturer_id"],
                    image_type=fw["fw_image_type"],
                    # The file size is of the contained image, not the container!
                    file_size=fw["fw_filesize"],
                    url=fw["fw_binary_url"].replace("http://", "https://", 1),
                    source="IKEA (TRÅDFRI)",
                )

            # Bricking update: https://github.com/zigpy/zigpy/issues/1428
            if image.image_type in (8704, 8710):
                continue

            yield image


@register_provider
class Ledvance(BaseOtaProvider):
    NAME = "ledvance"
    # This isn't static but no more than these two have ever existed
    MANUFACTURER_IDS = (4489, 4364)
    DEFAULT_URL = "https://api.update.ledvance.com/v1/zigbee/firmwares"
    JSON_SCHEMA = json_schemas.LEDVANCE_SCHEMA
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_URL

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        async with session.get(self.url) as rsp:
            fw_lst = await rsp.json()

        jsonschema.validate(fw_lst, self.JSON_SCHEMA)

        for fw in fw_lst["firmwares"]:
            identity = fw["identity"]
            version = identity["version"]

            yield RemoteOtaImageMetadata(
                file_version=int(fw["fullName"].split("/")[1], 16),
                manufacturer_id=identity["company"],
                image_type=identity["product"],
                checksum="sha256:" + fw["shA256"],
                file_size=fw["length"],
                model_names=(fw["productName"],),
                url=(
                    "https://api.update.ledvance.com/v1/zigbee/firmwares/download?"
                    + urllib.parse.urlencode(
                        {
                            "Company": identity["company"],
                            "Product": identity["product"],
                            "Version": (
                                f"{version['major']}.{version['minor']}"
                                f".{version['build']}.{version['revision']}"
                            ),
                        }
                    )
                ),
                release_notes=fw["releaseNotes"],
                source="Ledvance",
            )


class StubOtaProvider(BaseOtaProvider):
    """Stub provider to keep existing configurations working."""

    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_URL

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        if typing.TYPE_CHECKING:
            yield  # pragma: no cover


@register_provider
class Salus(StubOtaProvider):
    NAME = "salus"
    MANUFACTURER_IDS = (4216, 43981)


@register_provider
class ThirdReality(StubOtaProvider):
    NAME = "thirdreality"
    MANUFACTURER_IDS = (4659, 4877, 5127)


@register_provider
class Inovelli(StubOtaProvider):
    NAME = "inovelli"
    MANUFACTURER_IDS = (4655,)


@register_provider
class Sonoff(BaseOtaProvider):
    NAME = "sonoff"
    MANUFACTURER_IDS = (4742,)

    JSON_SCHEMA = json_schemas.SONOFF_SCHEMA
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_URL

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        async with session.get(
            "https://zigbee-ota.sonoff.tech/releases/upgrade.json"
        ) as rsp:
            fw_lst = await rsp.json()

        jsonschema.validate(fw_lst, self.JSON_SCHEMA)

        for fw in fw_lst:
            yield RemoteOtaImageMetadata(
                file_version=fw["fw_file_version"],
                manufacturer_id=fw["fw_manufacturer_id"],
                image_type=fw["fw_image_type"],
                file_size=fw["fw_filesize"],
                url=fw["fw_binary_url"],
                model_names=(fw["model_id"],),
                source="Sonoff",
            )


class BaseZigpyProvider(BaseOtaProvider):
    JSON_SCHEMA = json_schemas.REMOTE_PROVIDER_SCHEMA

    @classmethod
    def _load_zigpy_index(
        cls,
        index: dict,
        *,
        index_root: pathlib.Path | None = None,
        ssl_ctx: ssl.SSLContext | None = None,
    ):
        jsonschema.validate(index, cls.JSON_SCHEMA)

        for fw in index["firmwares"]:
            shared_kwargs = {
                "file_version": fw["file_version"],
                "manufacturer_id": fw["manufacturer_id"],
                "image_type": fw["image_type"],
                "manufacturer_names": tuple(fw.get("manufacturer_names", [])),
                "model_names": tuple(fw.get("model_names", [])),
                "checksum": fw["checksum"],
                "file_size": fw["file_size"],
                "min_hardware_version": fw.get("min_hardware_version"),
                "max_hardware_version": fw.get("max_hardware_version"),
                "min_current_file_version": fw.get("min_current_file_version"),
                "max_current_file_version": fw.get("max_current_file_version"),
                "changelog": fw.get("changelog"),
                "release_notes": fw.get("release_notes"),
                "release_url": fw.get("release_url"),
                "specificity": fw.get("specificity"),
                "source": "",  # Set in a subclass
            }

            if "path" in fw and index_root is not None:
                yield LocalOtaImageMetadata(
                    **shared_kwargs, path=index_root / fw["path"]
                )
            else:
                yield RemoteOtaImageMetadata(
                    **shared_kwargs, url=fw["binary_url"], ssl_ctx=ssl_ctx
                )


@register_provider
class LocalZigpyProvider(BaseZigpyProvider):
    NAME = "zigpy_local"
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_JSON_INDEX

    def __init__(self, index_file: pathlib.Path, **kwargs):
        super().__init__(url=None, **kwargs)
        self.index_file = index_file

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        index_text = await asyncio.get_running_loop().run_in_executor(
            None, self.index_file.read_text
        )
        index = json.loads(index_text)

        for img in self._load_zigpy_index(index, index_root=self.index_file.parent):
            yield img.replace(source=f"Local zigpy provider ({self.index_file})")

    def __eq__(self, other: object) -> bool:
        if (
            not isinstance(other, self.__class__)
            or super().__eq__(other) is NotImplemented
        ):
            return NotImplemented

        return super().__eq__(other) and self.index_file == other.index_file

    def __hash__(self) -> int:
        return hash((type(self), self.index_file, self.manufacturer_ids))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(index_file={self.index_file!r}, manufacturer_ids={self.manufacturer_ids!r})"


@register_provider
class RemoteZigpyProvider(BaseZigpyProvider):
    NAME = "zigpy_remote"
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_URL_REQUIRED

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        async with session.get(self.url) as rsp:
            fw_lst = await rsp.json(content_type=None)

        jsonschema.validate(fw_lst, self.JSON_SCHEMA)

        for img in self._load_zigpy_index(fw_lst):
            yield img.replace(source=f"Remote zigpy provider ({self.url})")


class BaseZ2MProvider(BaseOtaProvider):
    JSON_SCHEMA = json_schemas.Z2M_SCHEMA

    @classmethod
    def _load_z2m_index(
        cls,
        index: dict,
        *,
        index_root: pathlib.Path | None = None,
        ssl_ctx: ssl.SSLContext | None = None,
    ) -> typing.Iterator[LocalOtaImageMetadata | RemoteOtaImageMetadata]:
        jsonschema.validate(index, cls.JSON_SCHEMA)

        for fw in index:
            shared_kwargs = {
                "file_version": fw["fileVersion"],
                "manufacturer_id": fw["manufacturerCode"],
                "image_type": fw["imageType"],
                "checksum": "sha512:" + fw["sha512"],
                "file_size": fw["fileSize"],
                "manufacturer_names": tuple(fw.get("manufacturerName", [])),
                "model_names": tuple([fw["modelId"]] if "modelId" in fw else []),
                "min_current_file_version": fw.get("minFileVersion"),
                "max_current_file_version": fw.get("maxFileVersion"),
                "min_hardware_version": fw.get("hardwareVersionMin"),
                "max_hardware_version": fw.get("hardwareVersionMax"),
                "changelog": fw.get("releaseNotes"),  # Changelog is short
                "source": "",  # Set in a subclass
            }

            if "path" in fw and index_root is not None:
                yield LocalOtaImageMetadata(
                    **shared_kwargs, path=index_root / fw["path"]
                )
            else:
                yield RemoteOtaImageMetadata(
                    **shared_kwargs, url=fw["url"], ssl_ctx=ssl_ctx
                )


@register_provider
class LocalZ2MProvider(BaseZ2MProvider):
    NAME = "z2m_local"
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_JSON_INDEX

    def __init__(self, index_file: pathlib.Path, **kwargs):
        super().__init__(**kwargs)
        self.index_file = index_file

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        index_text = await asyncio.get_running_loop().run_in_executor(
            None, self.index_file.read_text
        )
        index = json.loads(index_text)

        for img in self._load_z2m_index(index, index_root=self.index_file.parent):
            yield img.replace(source=f"Local Z2M provider ({self.index_file})")

    def __eq__(self, other: object) -> bool:
        if (
            not isinstance(other, self.__class__)
            or super().__eq__(other) is NotImplemented
        ):
            return NotImplemented

        return super().__eq__(other) and self.index_file == other.index_file

    def __hash__(self) -> int:
        return hash((type(self), self.index_file, self.manufacturer_ids))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(index_file={self.index_file!r}, manufacturer_ids={self.manufacturer_ids!r})"


@register_provider
class RemoteZ2MProvider(BaseZ2MProvider):
    NAME = "z2m"
    DEFAULT_URL = (
        "https://raw.githubusercontent.com/Koenkk/zigbee-OTA/master/index.json"
    )
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_URL

    SSL_CTX = ssl.create_default_context()
    SSL_CTX.load_verify_locations(cadata=HUE_CERT)

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        async with session.get(self.url) as rsp:
            fw_lst = await rsp.json(content_type=None)

        for img in self._load_z2m_index(fw_lst, ssl_ctx=self.SSL_CTX):
            yield img.replace(source=f"Remote Z2M provider ({self.url})")


@register_provider
class ZigpyOtaProvider(BaseZigpyProvider):
    """OTA provider for zigpy-ota repository with multiple release channels.

    The provider uses a two-step fetch process:
    1. Fetch version file from GitHub to get the current index URL
    2. Fetch the actual OTA index from the URL in the version file
    """

    NAME = "zigpy_ota"
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_ZIGPY_OTA
    TRUSTED = True

    DEFAULT_CHANNEL = "stable"
    SUPPORTED_CHANNELS = {"stable", "beta", "dev"}

    VERSION_FILE_BASE_URL = (
        "https://raw.githubusercontent.com/zigpy/zigpy-ota/release/version"
    )

    # Combined SSL context for downloading images from IKEA and Hue servers
    # (zigpy-ota index may point to images hosted on these servers)
    SSL_CTX: ssl.SSLContext = ssl.create_default_context()
    SSL_CTX.load_verify_locations(cadata=IKEA_CERT)
    SSL_CTX.load_verify_locations(cadata=HUE_CERT)

    def __init__(
        self,
        url: str | typing.Literal[True] | None = None,
        channel: str | None = None,
        **kwargs,
    ) -> None:
        # If a specific URL is provided, use it directly (for testing or custom endpoints)
        # Otherwise, construct the version file URL based on the channel
        if url in (True, None):
            self.channel = channel or self.DEFAULT_CHANNEL
            if self.channel not in self.SUPPORTED_CHANNELS:
                raise ValueError(
                    f"Invalid channel '{self.channel}'. Must be one of: {self.SUPPORTED_CHANNELS}"
                )
            url = f"{self.VERSION_FILE_BASE_URL}/{self.channel}.json"
        else:
            # Custom URL provided (direct to version file)
            self.channel = channel

        super().__init__(url=url, **kwargs)

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        # First fetch the version file to get the actual index URL
        async with session.get(self.url) as rsp:
            version_data = await rsp.json(content_type=None)

        # Extract the index URL from the version file
        # Format: {"schemas": {"zigpy_v2": {"version": "...", "url": "..."}}}
        index_url = version_data["schemas"]["zigpy_v2"]["url"]

        # Now fetch the actual OTA index
        async with session.get(index_url) as rsp:
            fw_lst = await rsp.json(content_type=None)

        jsonschema.validate(fw_lst, self.JSON_SCHEMA)

        for img in self._load_zigpy_index(fw_lst, ssl_ctx=self.SSL_CTX):
            channel_name = self.channel or "custom"
            yield img.replace(source=f"zigpy-ota provider ({channel_name} channel)")

    def __eq__(self, other: object) -> bool:
        if (
            not isinstance(other, self.__class__)
            or super().__eq__(other) is NotImplemented
        ):
            return NotImplemented

        return super().__eq__(other) and self.channel == other.channel

    def __hash__(self) -> int:
        return hash((type(self), self.url, self.channel, self.manufacturer_ids))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(url={self.url!r}, channel={self.channel!r}, manufacturer_ids={self.manufacturer_ids!r})"


@register_provider
class AdvancedFileProvider(BaseOtaProvider):
    NAME = "advanced"
    VOL_SCHEMA = zigpy.config.SCHEMA_OTA_PROVIDER_FOLDER

    def __init__(self, path: pathlib.Path, **kwargs):
        # The `vol` schema passes through the `warning` key, which is unused
        kwargs.pop("warning", None)

        super().__init__(url=None, **kwargs)
        self.path = path

    async def _load_index(
        self, session: aiohttp.ClientSession
    ) -> typing.AsyncIterator[BaseOtaImageMetadata]:
        loop = asyncio.get_running_loop()

        paths = await loop.run_in_executor(None, self.path.rglob, "*")

        async for chunk in zigpy.util.async_iterate_in_chunks(paths, chunk_size=100):
            for path in chunk:
                if not path.is_file():
                    continue

                data = await loop.run_in_executor(None, path.read_bytes)

                try:
                    image, _ = parse_ota_image(data)
                except Exception as exc:  # noqa: BLE001
                    LOGGER.debug("Failed to parse image %s: %r", path, exc)
                    continue

                # This protects against images being swapped out in the local filesystem
                hasher = await loop.run_in_executor(None, hashlib.sha1, data)

                yield LocalOtaImageMetadata(
                    path=path,
                    file_version=image.header.file_version,
                    manufacturer_id=image.header.manufacturer_id,
                    image_type=image.header.image_type,
                    checksum="sha1:" + hasher.hexdigest(),
                    file_size=len(data),
                    min_hardware_version=image.header.minimum_hardware_version,
                    max_hardware_version=image.header.maximum_hardware_version,
                    source=f"Advanced file provider ({self.path})",
                )

    def __eq__(self, other: object) -> bool:
        if (
            not isinstance(other, self.__class__)
            or super().__eq__(other) is NotImplemented
        ):
            return NotImplemented

        return super().__eq__(other) and self.path == other.path

    def __hash__(self) -> int:
        return hash((type(self), self.path, self.manufacturer_ids))

    def __repr__(self) -> str:
        return f"{self.__class__.__name__}(path={self.path!r}, manufacturer_ids={self.manufacturer_ids!r})"
