"""Support for exposing Home Assistant via Zeroconf."""
import fnmatch
from functools import partial
import ipaddress
import logging
import socket

import voluptuous as vol
from zeroconf import (
    DNSPointer,
    DNSRecord,
    Error as ZeroconfError,
    InterfaceChoice,
    IPVersion,
    NonUniqueNameException,
    ServiceBrowser,
    ServiceInfo,
    ServiceStateChange,
    Zeroconf,
)

from homeassistant import util
from homeassistant.const import (
    ATTR_NAME,
    EVENT_HOMEASSISTANT_START,
    EVENT_HOMEASSISTANT_STARTED,
    EVENT_HOMEASSISTANT_STOP,
    __version__,
)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.singleton import singleton
from homeassistant.loader import async_get_homekit, async_get_zeroconf

from .usage import install_multiple_zeroconf_catcher

_LOGGER = logging.getLogger(__name__)

DOMAIN = "zeroconf"

ATTR_HOST = "host"
ATTR_PORT = "port"
ATTR_HOSTNAME = "hostname"
ATTR_TYPE = "type"
ATTR_PROPERTIES = "properties"

ZEROCONF_TYPE = "_home-assistant._tcp.local."
HOMEKIT_TYPE = "_hap._tcp.local."

CONF_DEFAULT_INTERFACE = "default_interface"
CONF_IPV6 = "ipv6"
DEFAULT_DEFAULT_INTERFACE = False
DEFAULT_IPV6 = True

HOMEKIT_PROPERTIES = "properties"
HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL = "md"

# Property key=value has a max length of 255
# so we use 230 to leave space for key=
MAX_PROPERTY_VALUE_LEN = 230

# Dns label max length
MAX_NAME_LEN = 63

CONFIG_SCHEMA = vol.Schema(
    {
        DOMAIN: vol.Schema(
            {
                vol.Optional(
                    CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
                ): cv.boolean,
                vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
            }
        )
    },
    extra=vol.ALLOW_EXTRA,
)


@singleton(DOMAIN)
async def async_get_instance(hass):
    """Zeroconf instance to be shared with other integrations that use it."""
    return await _async_get_instance(hass)


async def _async_get_instance(hass, **zcargs):
    logging.getLogger("zeroconf").setLevel(logging.NOTSET)

    zeroconf = await hass.async_add_executor_job(partial(HaZeroconf, **zcargs))

    install_multiple_zeroconf_catcher(zeroconf)

    def _stop_zeroconf(_):
        """Stop Zeroconf."""
        zeroconf.ha_close()

    hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_zeroconf)

    return zeroconf


class HaServiceBrowser(ServiceBrowser):
    """ServiceBrowser that only consumes DNSPointer records."""

    def update_record(self, zc: "Zeroconf", now: float, record: DNSRecord) -> None:
        """Pre-Filter update_record to DNSPointers for the configured type."""

        #
        # Each ServerBrowser currently runs in its own thread which
        # processes every A or AAAA record update per instance.
        #
        # As the list of zeroconf names we watch for grows, each additional
        # ServiceBrowser would process all the A and AAAA updates on the network.
        #
        # To avoid overwhemling the system we pre-filter here and only process
        # DNSPointers for the configured record name (type)
        #
        if record.name not in self.types or not isinstance(record, DNSPointer):
            return
        super().update_record(zc, now, record)


class HaZeroconf(Zeroconf):
    """Zeroconf that cannot be closed."""

    def close(self):
        """Fake method to avoid integrations closing it."""

    ha_close = Zeroconf.close


async def async_setup(hass, config):
    """Set up Zeroconf and make Home Assistant discoverable."""
    zc_config = config.get(DOMAIN, {})
    zc_args = {}
    if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE):
        zc_args["interfaces"] = InterfaceChoice.Default
    if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
        zc_args["ip_version"] = IPVersion.V4Only

    zeroconf = hass.data[DOMAIN] = await _async_get_instance(hass, **zc_args)

    async def _async_zeroconf_hass_start(_event):
        """Expose Home Assistant on zeroconf when it starts.

        Wait till started or otherwise HTTP is not up and running.
        """
        uuid = await hass.helpers.instance_id.async_get()
        await hass.async_add_executor_job(
            _register_hass_zc_service, hass, zeroconf, uuid
        )

    async def _async_zeroconf_hass_started(_event):
        """Start the service browser."""

        await _async_start_zeroconf_browser(hass, zeroconf)

    hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_zeroconf_hass_start)
    hass.bus.async_listen_once(
        EVENT_HOMEASSISTANT_STARTED, _async_zeroconf_hass_started
    )

    return True


def _register_hass_zc_service(hass, zeroconf, uuid):
    # Get instance UUID
    valid_location_name = _truncate_location_name_to_valid(hass.config.location_name)

    params = {
        "location_name": valid_location_name,
        "uuid": uuid,
        "version": __version__,
        "external_url": "",
        "internal_url": "",
        # Old base URL, for backward compatibility
        "base_url": "",
        # Always needs authentication
        "requires_api_password": True,
    }

    # Get instance URL's
    try:
        params["external_url"] = get_url(hass, allow_internal=False)
    except NoURLAvailableError:
        pass

    try:
        params["internal_url"] = get_url(hass, allow_external=False)
    except NoURLAvailableError:
        pass

    # Set old base URL based on external or internal
    params["base_url"] = params["external_url"] or params["internal_url"]

    host_ip = util.get_local_ip()

    try:
        host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip)
    except OSError:
        host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip)

    _suppress_invalid_properties(params)

    info = ServiceInfo(
        ZEROCONF_TYPE,
        name=f"{valid_location_name}.{ZEROCONF_TYPE}",
        server=f"{uuid}.local.",
        addresses=[host_ip_pton],
        port=hass.http.server_port,
        properties=params,
    )

    _LOGGER.info("Starting Zeroconf broadcast")
    try:
        zeroconf.register_service(info)
    except NonUniqueNameException:
        _LOGGER.error(
            "Home Assistant instance with identical name present in the local network"
        )


async def _async_start_zeroconf_browser(hass, zeroconf):
    """Start the zeroconf browser."""

    zeroconf_types = await async_get_zeroconf(hass)
    homekit_models = await async_get_homekit(hass)

    types = list(zeroconf_types)

    if HOMEKIT_TYPE not in zeroconf_types:
        types.append(HOMEKIT_TYPE)

    def service_update(zeroconf, service_type, name, state_change):
        """Service state changed."""
        nonlocal zeroconf_types
        nonlocal homekit_models

        if state_change != ServiceStateChange.Added:
            return

        try:
            service_info = zeroconf.get_service_info(service_type, name)
        except ZeroconfError:
            _LOGGER.exception("Failed to get info for device %s", name)
            return

        if not service_info:
            # Prevent the browser thread from collapsing as
            # service_info can be None
            _LOGGER.debug("Failed to get info for device %s", name)
            return

        info = info_from_service(service_info)
        if not info:
            # Prevent the browser thread from collapsing
            _LOGGER.debug("Failed to get addresses for device %s", name)
            return

        _LOGGER.debug("Discovered new device %s %s", name, info)

        # If we can handle it as a HomeKit discovery, we do that here.
        if service_type == HOMEKIT_TYPE:
            discovery_was_forwarded = handle_homekit(hass, homekit_models, info)
            # Continue on here as homekit_controller
            # still needs to get updates on devices
            # so it can see when the 'c#' field is updated.
            #
            # We only send updates to homekit_controller
            # if the device is already paired in order to avoid
            # offering a second discovery for the same device
            if (
                discovery_was_forwarded
                and HOMEKIT_PROPERTIES in info
                and HOMEKIT_PAIRED_STATUS_FLAG in info[HOMEKIT_PROPERTIES]
            ):
                try:
                    # 0 means paired and not discoverable by iOS clients)
                    if int(info[HOMEKIT_PROPERTIES][HOMEKIT_PAIRED_STATUS_FLAG]):
                        return
                except ValueError:
                    # HomeKit pairing status unknown
                    # likely bad homekit data
                    return

        for entry in zeroconf_types[service_type]:
            if len(entry) > 1:
                if "macaddress" in entry:
                    if "properties" not in info:
                        continue
                    if "macaddress" not in info["properties"]:
                        continue
                    if not fnmatch.fnmatch(
                        info["properties"]["macaddress"], entry["macaddress"]
                    ):
                        continue
                if "name" in entry:
                    if "name" not in info:
                        continue
                    if not fnmatch.fnmatch(info["name"], entry["name"]):
                        continue

            hass.add_job(
                hass.config_entries.flow.async_init(
                    entry["domain"], context={"source": DOMAIN}, data=info
                )
            )

    _LOGGER.debug("Starting Zeroconf browser")
    HaServiceBrowser(zeroconf, types, handlers=[service_update])


def handle_homekit(hass, homekit_models, info) -> bool:
    """Handle a HomeKit discovery.

    Return if discovery was forwarded.
    """
    model = None
    props = info.get(HOMEKIT_PROPERTIES, {})

    for key in props:
        if key.lower() == HOMEKIT_MODEL:
            model = props[key]
            break

    if model is None:
        return False

    for test_model in homekit_models:
        if (
            model != test_model
            and not model.startswith(f"{test_model} ")
            and not model.startswith(f"{test_model}-")
        ):
            continue

        hass.add_job(
            hass.config_entries.flow.async_init(
                homekit_models[test_model], context={"source": "homekit"}, data=info
            )
        )
        return True

    return False


def info_from_service(service):
    """Return prepared info from mDNS entries."""
    properties = {"_raw": {}}

    for key, value in service.properties.items():
        # See https://ietf.org/rfc/rfc6763.html#section-6.4 and
        # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings
        # for property keys and values
        try:
            key = key.decode("ascii")
        except UnicodeDecodeError:
            _LOGGER.debug(
                "Ignoring invalid key provided by [%s]: %s", service.name, key
            )
            continue

        properties["_raw"][key] = value

        try:
            if isinstance(value, bytes):
                properties[key] = value.decode("utf-8")
        except UnicodeDecodeError:
            pass

    if not service.addresses:
        return None

    address = service.addresses[0]

    info = {
        ATTR_HOST: str(ipaddress.ip_address(address)),
        ATTR_PORT: service.port,
        ATTR_HOSTNAME: service.server,
        ATTR_TYPE: service.type,
        ATTR_NAME: service.name,
        ATTR_PROPERTIES: properties,
    }

    return info


def _suppress_invalid_properties(properties):
    """Suppress any properties that will cause zeroconf to fail to startup."""

    for prop, prop_value in properties.items():
        if not isinstance(prop_value, str):
            continue

        if len(prop_value.encode("utf-8")) > MAX_PROPERTY_VALUE_LEN:
            _LOGGER.error(
                "The property '%s' was suppressed because it is longer than the maximum length of %d bytes: %s",
                prop,
                MAX_PROPERTY_VALUE_LEN,
                prop_value,
            )
            properties[prop] = ""


def _truncate_location_name_to_valid(location_name):
    """Truncate or return the location name usable for zeroconf."""
    if len(location_name.encode("utf-8")) < MAX_NAME_LEN:
        return location_name

    _LOGGER.warning(
        "The location name was truncated because it is longer than the maximum length of %d bytes: %s",
        MAX_NAME_LEN,
        location_name,
    )
    return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore")
