# -*- coding: utf-8 -*-
"""Utilities for updating endpoints with resolved hostnames."""

from base64 import urlsafe_b64encode
from contextlib import suppress
from logging import getLogger
from re import fullmatch, search
from socket import AF_INET, AF_INET6, SOCK_DGRAM, SOCK_STREAM, SOL_SOCKET, getaddrinfo
from socket import socket as make_socket
from time import time
from urllib.parse import urlsplit, urlunsplit

from dns.exception import DNSException
from dns.message import from_wire as message_from_wire
from dns.message import make_query
from dns.name import from_text as make_qname
from dns.query import BadResponse
from dns.query import _connect as connect_tcp
from dns.query import tcp as query_tcp
from dns.query import udp as query_udp
from dns.rcode import NOERROR, NXDOMAIN
from dns.rcode import to_text as format_rcode
from dns.rdataclass import IN
from dns.rdatatype import AAAA, ANY, A
from dns.resolver import Answer, Cache, NoAnswer
from requests import Request, Session
from requests.adapters import HTTPAdapter

from procustodibus_agent.cnf import DEFAULT_DNS_SERVERS
from procustodibus_agent.wg import run_wg_set

QUAD_SERVERS = {"1.1.1.1", "8.8.8.8", "9.9.9.9"}
PREFER_IPV6_SERVERS = DEFAULT_DNS_SERVERS[2:4] + DEFAULT_DNS_SERVERS[0:2]
# linux/include/uapi/asm-generic/socket.h
SO_MARK = 36


def is_likely_ip(hostname):
    """Checks if a hostname is likely an IP address.

    Arguments:
        hostname (str): Hostname to check (eg 'foo.example.com').

    Returns:
        True if likely an IP address.
    """
    return bool(
        hostname
        and (
            search(r"[0-9](\.[0-9]{1,3}){3}\]?$", hostname)
            or (
                fullmatch(r"\[?[0-9A-Fa-f:]+\]?", hostname)
                and search(":[^:]*:", hostname)
            )
        )
    )


def lookup_ip(cnf, hostname, family=None, raises=True):
    """Looks up the top IP address for the specified hostname.

    Arguments:
        cnf (Config): Config object.
        hostname (str): Hostname to lookup (eg 'foo.example.com').
        family (int): Preferred family (eg AF_INET6).
        raises (bool): True to raise if cannot resolve hostname (default True).

    Returns:
        str: Top IP address (eg '10.1.2.3').

    Propagates:
        DNSException: Cannot resolve hostname.
    """
    ips = lookup_ips(cnf, hostname, family, raises)
    return ips[0] if ips else ""


def lookup_ips(cnf, hostname, family=None, raises=True):
    """Looks up the IP addresses for the specified hostname.

    Arguments:
        cnf (Config): Config object.
        hostname (str): Hostname to lookup (eg 'foo.example.com').
        family (int): Preferred family (eg AF_INET6).
        raises (bool): True to raise if cannot resolve hostname (default True).

    Returns:
        list: IP addresss (eg ['10.1.2.3', '10.4.5.6']).

    Raises:
        Exception: Cannot resolve hostname.
    """
    try:
        return get_resolver(cnf).lookup_ips(hostname, family)
    except Exception as e:
        if raises:
            raise e
        message = f"dns error resolving {hostname}: {e}"
        getLogger(__name__).debug(message)
        return []


def get_resolver(cnf):
    """Finds or creates resolver for the specified configuration object.

    Arguments:
        cnf (Config): Config object.

    Returns:
        Resolver: Resolver object.
    """
    resolver = cnf.resolver
    if not resolver:
        resolver = DnsResolver(cnf)
        cnf.resolver = resolver
    return resolver


class Resolver(dict):
    """Base hostname resolver."""

    def lookup_ips(self, hostname, family=None):
        """Looks up the IP addresses for the specified hostname.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').
            family (int): Preferred family (eg AF_INET6).

        Raises:
            DNSException: Cannot resolve hostname.
        """
        raise DNSException("cannot resolve hostnames")


class DictionaryResolver(Resolver):
    """Resolves hostnames through static dictionary."""

    def __init__(self, dictionary=None):
        """Creates new resolver object.

        Arguments:
            dictionary (dict): Map of hostnames to ip addresses.
        """
        self.dictionary = {} if dictionary is None else dictionary

    def __str__(self):  # noqa: D105
        return str(self.dictionary)

    def lookup_ips(self, hostname, family=None):
        """Looks up the IP addresses for the specified hostname.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').
            family (int): Preferred family (eg AF_INET6).

        Returns:
            list: IP addresss (eg ['10.1.2.3', '10.4.5.6']).

        Raises:
            DNSException: Cannot resolve hostname.
        """
        try:
            return self.dictionary[hostname]
        except KeyError:
            raise DNSException(f"hostname not found: {hostname}")

    def __bool__(self):  # noqa: D105
        return bool(self.dictionary)


class DnsResolver(Resolver):
    """Resolves hostnames through DNS queries, caching results."""

    class _MinimumCnf(dict):
        """Configuration object with defaults for resolver."""

        def __init__(self, resolve_hostnames=""):
            """Creates minimal configuration object.

            Arguments:
                resolve_hostnames (str): Family preference (eg "ipv4" or "ipv6").
            """
            self.dns = []
            self.dns_protocol = ""
            self.doh = ""
            self.resolve_hostnames = resolve_hostnames
            self.socket_mark = ""
            self.transport = None

    def __init__(self, cnf=None, resolve_hostnames=""):
        """Creates new resolver object.

        Arguments:
            cnf (Config): Config object.
            resolve_hostnames (str): Family preference (eg "ipv4" or "ipv6").
        """
        self.cnf = cnf or self._MinimumCnf(resolve_hostnames)
        self.cache = Cache()
        self.prefer_ipv6, self.has_ipv6 = self._calculate_prefer_ipv6()
        self.dns_servers = self._calculate_dns_servers()
        self.doh_url, self.doh_hostname = self._calculate_doh_url()
        self.doh_sessions = {}

    def __str__(self):  # noqa: D105
        return f"{self.cnf.doh}#{self.cnf.dns}"

    def lookup_ips(self, hostname, family=None):
        """Looks up the IP addresses for the specified hostname.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').
            family (int): Preferred family (eg AF_INET6).

        Returns:
            list: IP addresss (eg ['10.1.2.3', '10.4.5.6']).

        Propagates:
            DNSException: Cannot resolve hostname.
        """
        return self.lookup_ips_from_cache(hostname) or self.lookup_ips_from_servers(
            hostname, family
        )

    def lookup_ips_from_cache(self, hostname):
        """Looks up the IP addresses for the specified hostname in the cache.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').

        Returns:
            list: IP addresses, or empty if not in cache.

        Raises:
            NoAnswer: Cached response was that the hostname has no IP addresses.
        """
        if not self.dns_servers:
            return []

        qname = make_qname(hostname)
        answer = self.cache.get((qname, ANY, IN))

        # NB: the Answer object is falsy if it has no rrset sections
        # (ie the server answered with an empty list of addresses, aka NXDOMAIN)
        if answer is None:
            return []
        if not answer:
            raise NoAnswer(response=answer.response)

        return [x.address for x in answer]

    # simpler to keep logic in same function even if it makes cognitive-complexity high
    def lookup_ips_from_servers(self, hostname, family=None):  # noqa: CCR001
        """Looks up the IP addresses for the specified hostname from DNS.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').
            family (int): Preferred family (eg AF_INET6).

        Returns:
            list: IP addresss (eg ['10.1.2.3', '10.4.5.6']).

        Raises:
            NoAnswer: Hostname has no IP address.
            DNSException: Cannot resolve hostname.
        """
        servers = self.dns_servers

        if not servers:
            return self.lookup_ips_from_gai(hostname, family)

        if len(servers) > 1:
            for server in servers:
                try:
                    return self.lookup_ips_from_server(hostname, server, family)
                except NoAnswer as e:
                    raise e
                except Exception as e:
                    message = f"{server} dns error resolving {hostname}: {e}"
                    getLogger(__name__).debug(message)
                    self._adjust_dns_server_preference(server)

        try:
            return self.lookup_ips_from_server(hostname, servers[0], family, 5)
        except DNSException as e:
            raise e
        except Exception as e:
            raise DNSException(f"dns error resolving {hostname}: {e}") from e

    def lookup_ips_from_gai(self, hostname, family=None):
        """Looks up the IP addresses for the specified hostname from OS.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').
            family (int): Preferred family (eg AF_INET6).

        Returns:
            list: IP addresss (eg ['10.1.2.3', '10.4.5.6']).

        Raises:
            DNSException: Cannot resolve hostname.
        """
        family = family or (AF_INET6 if self.prefer_ipv6 else AF_INET)
        try:
            return [x[4][0] for x in getaddrinfo(hostname, 0, family)]
        except Exception as e:
            family = AF_INET6 if family == AF_INET else AF_INET
            try:
                return [x[4][0] for x in getaddrinfo(hostname, 0, family)]
            except Exception:
                raise DNSException(f"dns error resolving {hostname}: {e}") from e

    def lookup_ips_from_server(self, hostname, server, family=None, timeout=0.5):
        """Looks up IP address for the specified hostname from the specified server.

        Arguments:
            hostname (str): Hostname to lookup (eg 'foo.example.com').
            server (str): DNS server to query (eg '9.9.9.9').
            family (int): Preferred family (eg AF_INET6).
            timeout (float): Timeout in seconds after which to error (default 0.5).

        Returns:
            list: IP addresss (eg ['10.1.2.3', '10.4.5.6']).

        Raises:
            NoAnswer: Hostname has no IP addresses.
            ErrorAnswer: Error querying the DNS server.
        """
        qname = make_qname(hostname)
        prefer_ipv6 = family == AF_INET6 if family else self.prefer_ipv6
        types = [AAAA, A] if prefer_ipv6 else [A, AAAA]

        (code, response, answer) = self.query_types_from_server(
            qname, server, types, timeout
        )
        if answer is not None:
            self.cache.put((qname, ANY, IN), answer)

        if code not in (NOERROR, NXDOMAIN):
            raise ErrorAnswer(response=response)
        if not answer:
            raise NoAnswer(response=response)

        return [x.address for x in answer]

    def query_types_from_server(self, qname, server, types, timeout=0.5):
        """Queries the specified server for the specified name of each specified type.

        Returns the response and answer for the first type with a non-empty answer;
        or the first response and answer if all types had an empty answer;
        or the first response and no answer if an error occurred querying the server.

        Arguments:
            qname (str): Fully-qualified name to lookup (eg 'foo.example.com.').
            server (str): DNS server to query (eg '9.9.9.9').
            types (list): List of record types to query (eg [A, AAAA]).
            timeout (float): Timeout in seconds after which to error (default 0.5).

        Returns:
            tuple: Response code, response object, answer object.
        """
        rdtype = types.pop(0)
        query = make_query(qname, rdtype)
        (response, port) = self.query_server(query, server, timeout)
        code = response.rcode()

        if code not in (NOERROR, NXDOMAIN):
            return (code, response, None)

        answer = Answer(qname, rdtype, IN, response, server, port)
        if not answer and len(types):
            try:
                (c, r, a) = self.query_types_from_server(qname, server, types, timeout)
                if a:
                    return (c, r, a)
            except Exception as e:
                message = f"{server} dns error resolving {query.question[0]}: {e}"
                getLogger(__name__).debug(message)

        return (code, response, answer)

    def query_server(self, query, server, timeout=0.5):
        """Sends the specified query to the specified server.

        Arguments:
            query (Message): Message object.
            server (str): DNS server to query (eg '9.9.9.9').
            timeout (float): Timeout in seconds after which to error (default 0.5).

        Returns:
            tuple: Response object, server port used.
        """
        protocol = self.cnf.dns_protocol
        if (not protocol or protocol == "https") and self.cnf.doh:
            return (self.query_with_doh(query, server, timeout), 443)
        if protocol == "tcp":
            return (self.query_with_tcp(query, server, timeout), 53)
        return (self.query_with_udp(query, server, timeout), 53)

    def query_with_udp(self, query, server, timeout=0.5):
        """Sends the specified query to the specified server with UDP.

        Arguments:
            query (Message): Message object.
            server (str): DNS server to query (eg '9.9.9.9').
            timeout (float): Timeout in seconds after which to error (default 0.5).

        Returns:
            Response object.
        """
        socket = self._make_socket(server, SOCK_DGRAM)
        try:
            return query_udp(query, server, timeout, sock=socket)
        finally:
            socket.close()

    def query_with_tcp(self, query, server, timeout=0.5):
        """Sends the specified query to the specified server with TCP.

        Arguments:
            query (Message): Message object.
            server (str): DNS server to query (eg '9.9.9.9').
            timeout (float): Timeout in seconds after which to error (default 0.5).

        Returns:
            Response object.
        """
        socket = self._make_socket(server, SOCK_STREAM)
        try:
            connect_tcp(socket, (server, 53), time() + timeout)
            return query_tcp(query, server, timeout, sock=socket)
        finally:
            socket.close()

    def query_with_doh(self, query, server, timeout=0.5):
        """Sends the specified query to the specified server with DoH.

        Arguments:
            query (Message): Message object.
            server (str): DNS server to query (eg '9.9.9.9').
            timeout (float): Timeout in seconds after which to error (default 0.5).

        Returns:
            Response object.
        """
        session = self._get_session(server)
        return query_doh(query, self.doh_url, timeout, session)

    def __bool__(self):  # noqa: D105
        return True

    def _make_socket(self, ip, socket_type):
        """Creates a new socket of the specified type.

        Arguments:
            ip (str): Server IP address.
            socket_type (int): SOCK_STREAM or SOCK_DGRAM.

        Raises:
            Exception: Socket set-up failed.

        Returns:
            Socket object.
        """
        socket = make_socket(AF_INET6 if ":" in ip else AF_INET, socket_type)
        try:
            socket.setblocking(False)
            mark = coerce_socket_mark(self.cnf.socket_mark)
            if mark:
                socket.setsockopt(SOL_SOCKET, SO_MARK, mark)
            return socket
        except Exception:
            socket.close()
            raise

    def _get_session(self, ip):
        """Finds or creates a new requests session for the specified server IP.

        Arguments:
            ip (str): Server IP address.

        Returns:
            Session object.
        """
        session = self.doh_sessions.get(ip)
        if not session:
            session = self._make_session(ip)
            self.doh_sessions[ip] = session
        return session

    def _make_session(self, ip):
        """Creates a new requests session for the specified server IP.

        Arguments:
            ip (str): Server IP address.

        Returns:
            Session object.
        """
        resolver = DictionaryResolver(dictionary={self.doh_hostname: [ip]})
        adapter = ResolverHTTPAdapter(
            resolver=resolver,
            socket_mark=self.cnf.socket_mark,
        )
        session = Session()
        session.mount("http://", adapter)
        session.mount("https://", adapter)
        return session

    def _adjust_dns_server_preference(self, server):
        """Adjust DNS preferences based on failed connection to the specified server.

        Arguments:
            server (str): Server IP address.
        """
        if (
            self.prefer_ipv6
            and self.cnf.resolve_hostnames != "ipv6"
            and server == DEFAULT_DNS_SERVERS[-1]
            and self.dns_servers == PREFER_IPV6_SERVERS
        ):
            getLogger(__name__).info("adjusting preference from ipv6 to ipv4")
            self.prefer_ipv6 = False
            self.dns_servers = DEFAULT_DNS_SERVERS
            self.cache.flush()
        elif (
            self.has_ipv6
            and not self.prefer_ipv6
            and self.cnf.resolve_hostnames != "ipv4"
            and server == PREFER_IPV6_SERVERS[-1]
            and self.dns_servers == DEFAULT_DNS_SERVERS
        ):
            getLogger(__name__).info("adjusting preference from ipv4 to ipv6")
            self.prefer_ipv6 = True
            self.dns_servers = PREFER_IPV6_SERVERS
            self.cache.flush()

    def _calculate_prefer_ipv6(self):
        """Checks if IPv6 is preferred and available.

        Returns:
            tuple: True if IPv6 preferred, True if IPv6 available
        """
        if self.cnf.resolve_hostnames == "ipv6":
            return (True, True)
        if self.cnf.resolve_hostnames == "ipv4":
            return (False, False)
        # if no explicit preference, use ipv6 if preferred address for localhost is ipv6
        try:
            families = [x[0] for x in getaddrinfo("localhost", 0)]
            return (families[0] == AF_INET6, AF_INET6 in families)
        except Exception:
            return (False, False)

    def _calculate_dns_servers(self):
        """Returns list of DNS servers in preferred order.

        Returns:
            list: List of DNS servers.
        """
        servers = self.cnf.dns
        servers_6 = [x for x in servers if ":" in x]
        servers_4 = [x for x in servers if ":" not in x]

        # if ipv6 not available, use just ipv4 servers
        if not self.has_ipv6 and servers_4:
            return servers_4
        # when ipv6 is preferred, reorder default ipv6 servers first
        elif self.prefer_ipv6 and len(servers) > 1 and servers[0] in QUAD_SERVERS:
            return servers_6 + servers_4
        return servers

    def _calculate_doh_url(self):
        """Returns URL and hostname to use for DoH.

        Returns:
            Tuple of DoH URL, DoH hostname
            (eg ('https://example.org/dns-query', 'example.org')).
        """
        doh = self.cnf.doh
        if doh.startswith("https:") or doh.startswith("http:"):
            return (doh, urlsplit(doh).hostname)
        return (f"https://{doh}:443/dns-query", doh)


class ErrorAnswer(NoAnswer):
    """The DNS response was an error."""

    supp_kwargs = {"response"}

    def __init__(self, *args, **kwargs):  # noqa: D107
        super().__init__(*args, **kwargs)

    def __str__(self):  # noqa: D105
        return f"The DNS response was {self.rcode()} to the question: {self.query()}"

    def query(self):
        """Formats DNS query.

        Returns:
            str: DNS query.
        """
        q = self.kwargs["response"].question
        return str(q[0]) if q else ""

    def rcode(self):
        """Formats DNS response code.

        Returns:
            str: DNS response code.
        """
        return format_rcode(self.kwargs["response"].rcode())


class ResolverHTTPAdapter(HTTPAdapter):
    """Custom requests library HTTP adapter."""

    def __init__(self, *args, **kwargs):
        """Creates new adapter.

        Arguments:
            *args (list): List of arguments to pass to super.
            *kwargs (dict): Dictionary of arguments to pass to super.
        """
        self.resolver = kwargs.pop("resolver", None)
        self.socket_mark = kwargs.pop("socket_mark", None)
        super(ResolverHTTPAdapter, self).__init__(*args, **kwargs)

    def init_poolmanager(self, *args, **kwargs):
        """Initializes the adapter's connection pool manager.

        Arguments:
            *args (list): List of arguments to pass to super.
            *kwargs (dict): Dictionary of arguments to pass to super.
        """
        mark = coerce_socket_mark(self.socket_mark)
        if mark:
            kwargs["socket_options"] = [(SOL_SOCKET, SO_MARK, mark)]
        super(ResolverHTTPAdapter, self).init_poolmanager(*args, **kwargs)

    def send(self, request, **kwargs):
        """Sends the specified request.

        Arguments:
            request: Request object.
            *kwargs (dict): Dictionary of arguments to pass to super.

        Returns:
            Response object.
        """
        url = urlsplit(request.url)
        if not is_likely_ip(url.hostname):
            self._replace_hostname_with_ip(request, url)

        return super(ResolverHTTPAdapter, self).send(request, **kwargs)

    def _replace_hostname_with_ip(self, request, url):
        """Replaces hostname in request URL with resolved IP address.

        Arguments:
            request: Request object.
            url: Parsed URL object.
        """
        hostname = url.hostname
        ip = self.resolver.lookup_ips(hostname)[0] if self.resolver else hostname
        x = format_endpoint_address(ip, url.port or 443)

        request.url = urlunsplit((url.scheme, x, url.path, url.query, url.fragment))
        request.headers["host"] = hostname

        pool_kw = self.poolmanager.connection_pool_kw
        pool_kw["assert_hostname"] = hostname
        pool_kw["server_hostname"] = hostname


def query_doh(query, url, timeout, session):
    """Queries the specified DoH URL with the specified DNS query.

    Arguments:
        query: DNS query object.
        url (str): Base DoH URL (eg 'https://example.org/dns-query').
        timeout (float): Request timeout in seconds (eg '0.5').
        session: Requests session object.

    Returns:
        DNS response object.

    Raises:
        BadResponse: Bad response from DoH URL.

    Propagates:
        RequestException: Error connecting to DoH URL.
    """
    http = _send_doh_query(query, url, timeout, session)
    response = message_from_wire(http.content)
    if not query.is_response(response):
        raise BadResponse
    return response


def _send_doh_query(query, url, timeout, session):
    """Sends the specified DNS query to the specified DoH URL.

    Arguments:
        query: DNS query object.
        url (str): Base DoH URL (eg 'https://example.org/dns-query').
        timeout (float): Request timeout in seconds (eg '0.5').
        session: Requests session object.

    Returns:
        Requests response object.

    Propagates:
        RequestException: Error connecting to DoH URL.
    """
    params = {"dns": urlsafe_b64encode(query.to_wire()).decode("utf-8").rstrip("=")}
    headers = {"accept": "application/dns-message"}
    request = Request("GET", url, params=params, headers=headers)
    request = request.prepare()
    response = session.send(request, timeout=timeout)
    response.raise_for_status()
    return response


def coerce_socket_mark(mark):
    """Converts the socket mark to an int.

    Arguments:
        mark: Mark value (eg '' or 'off' or 123 or '0x7b').

    Returns:
        int: Mark value (eg 0 or 123).
    """
    if not mark or mark == "off":
        return 0
    if isinstance(mark, int):
        return mark
    return int(str(mark), 0)


def resolve_endpoint_hostname(cnf, endpoint):
    """Resolves the specified endpoint to an IP address.

    Arguments:
        cnf (Cnf): Config object.
        endpoint (str): Endpoint with a hostname (eg 'vpn.example.com:51820').

    Returns:
        tuple: Resolved endpoint, hostname, port
        (eg ('[fd01::1]:51820', 'vpn.example.com', 51820)).
    """
    hostname, port, family = split_endpoint_address(endpoint)
    if not hostname or family:
        return endpoint, "", 0

    ip = lookup_ip(cnf, hostname, raises=False)
    if ip:
        endpoint = format_endpoint_address(ip, port)
    return endpoint, hostname, port


def apply_endpoint_hostnames(cnf, interfaces):
    """Updates the endpoints of the specified peers if configured with a hostname.

    Arguments:
        cnf (Cnf): Config object.
        interfaces (dict): Interfaces data.
    """
    if cnf.resolve_hostnames == "once":
        return

    for name, interface in interfaces.items():
        for pubkey, peer in interface.get("peers", {}).items():
            hostname = peer.get("hostname")
            endpoint = peer.get("endpoint")
            apply_endpoint_hostname(cnf, name, pubkey, hostname, endpoint)


def apply_endpoint_hostname(cnf, name, pubkey, hostname, endpoint):
    """Updates the specified peer's endpoint using the specified hostname.

    Arguments:
        cnf (Cnf): Config object.
        name (str): Interface name (eg 'wg0').
        pubkey (str): Peer public key.
        hostname (str): Hostname to use (eg 'foo.example.com').
        endpoint (str): Existing endpoint (eg '10.10.10.10:51820').
    """
    if not name or not pubkey or not hostname or not endpoint:
        return

    ip, port, family = split_endpoint_address(endpoint)
    ips = lookup_ips(cnf, hostname, family, raises=False)
    if ips and ip not in ips:
        set_endpoint_address(cnf, name, pubkey, ips[0], port)


def set_endpoint_address(cnf, name, pubkey, address, port):
    """Runs the `wg set` command to set the endpoint of the specified peer.

    Arguments:
        cnf (Cnf): Config object.
        name (str): Interface name (eg 'wg0').
        pubkey (str): Peer public key.
        address (str): Endpoint IP address (eg '10.10.10.10').
        port (int): Endpoint port (eg 51820).
    """
    endpoint = format_endpoint_address(address, port)
    run_wg_set(cnf, [name, "peer", pubkey, "endpoint", endpoint])


def format_endpoint_address(address, port):
    """Formats the specified endpoint IP address and port.

    Arguments:
        address (str): Endpoint IP address (eg 'fd01::1').
        port (int): Endpoint port (eg 51820).

    Returns:
        str: Formatted endpoint address (eg '[fd01::1]:51820').
    """
    port = port or 51820
    return f"[{address}]:{port}" if address.find(":") >= 0 else f"{address}:{port}"


def split_endpoint_address(endpoint):
    """Splits the specified endpoint into IP address, port, and address family.

    Arguments:
        endpoint (str): IP address and port (eg '[fc00:0:0:1::]:51820').

    Returns:
        tuple: String IP address, integer port number, integer family.
    """
    if not endpoint:
        return "", 0, 0

    ipv6 = fullmatch(r"\[([^\]]+)\](?::(\d+))?", endpoint)
    if ipv6:
        return ipv6[1], int(ipv6[2] or 0), AF_INET6

    family = 0 if search("[A-Za-z]", endpoint) else AF_INET
    ipv4 = endpoint.split(":")
    if len(ipv4) == 2:
        with suppress(ValueError):
            return ipv4[0], int(ipv4[1]), family

    return ipv4[0], 0, family


def split_endpoint_hostname_and_port(endpoint):
    """Splits the specified endpoint into hostname and port.

    If the endpoint has an IP address instead of a hostname, returns ('', 0).

    Arguments:
        endpoint (str): Hostname and port (eg 'vpn.example.com:51820').

    Returns:
        Tuple of string hostname and integer port number.
    """
    address, port, family = split_endpoint_address(endpoint)
    if family:
        return "", 0
    return address, port
