"""Processing functions for raw DNS messages."""
import collections.abc
import enum
import io
from ipaddress import IPv4Address
import logging
import struct
import typing
import unicodedata

from zeroconf import ServiceInfo

from pyatv.support.collections import CaseInsensitiveDict

_LOGGER = logging.getLogger(__name__)


def unpack_stream(fmt: str, buffer: typing.BinaryIO) -> typing.Tuple:
    """Unpack data from a binary stream according to the given format string.

    This is basically `struct.unpack`, but for binary streams.
    """
    size = struct.calcsize(fmt)
    return struct.unpack(fmt, buffer.read(size))


class ServiceInstanceName(typing.NamedTuple):
    """Represents either a service or service instance name in the DNS.

    The special thing this class can do is (attempt) to handle periods in the instance
    name correctly.
    """

    instance: typing.Optional[str]
    service: str
    domain: str = "local"

    def __str__(self):
        """Join all components together with periods."""
        return ".".join(typing.cast(typing.Iterable[str], filter(None, self)))

    @classmethod
    def split_name(cls, name: str) -> "ServiceInstanceName":
        """Split a name into instance (optional), service, and domain parts.

        If the name given isn't a service name or service instance name, this method
        raise a `ValueError`.
        """
        labels = name.split(".")
        if len(labels) < 2:
            raise ValueError("There must be at least three labels in a service name")
        for index in range(len(labels) - 1):
            label, next_label = labels[index : index + 2]
            if label.startswith("_") and next_label.lower() in ("_tcp", "_udp"):
                return cls(
                    ".".join(labels[0:index]) or None,
                    label + "." + next_label,
                    ".".join(labels[index + 2 :]),
                )
        raise ValueError(
            f"'{name}' is not a service domain, nor a service instance name"
        )

    @property
    def ptr_name(self):
        """Return just the service name, like the name for a PTR record."""
        return ".".join((self.service, self.domain))


def qname_encode(name: typing.Union[str, typing.Sequence[str]]) -> bytes:
    """Encode QNAME without using name compression.

    Labels (each component of a domain name) are encoded using UTF-8, as that is what
    the Apple TV has been observed to use for all domain names.

    This function can take either a single string, with each label separated by dots, or
    a sequence of strings, and each element of the sequence is treated as a single
    label. A null (empty) label is added for the root domain if it is not already
    present for both types of arguments.
    """
    encoded = bytearray()
    labels: typing.List[str]
    if isinstance(name, collections.abc.Sequence) and not isinstance(name, str):
        # Copy the sequence so we can make changes to it
        labels = list(name) or []
    else:
        # Try to parse it as a service instance name, so we can handle the instance
        # label having dots in it.
        try:
            srv_name = ServiceInstanceName.split_name(name)
        except ValueError:
            labels = typing.cast(str, name).split(".")
        else:
            labels = []
            if srv_name.instance:
                labels.append(srv_name.instance)
            # the ptr_name just has the instance name dropped off
            labels.extend(srv_name.ptr_name.split("."))
    # Ensure there's always an empty label for the root domain
    if not labels or labels[-1] != "":
        labels.append("")
    # DNS-SD uses UTF-8 for names, not IDNA. Apple extends this to basically all places
    # where names are used (except for A/AAAA records, which are transliterated!).
    encoding = "utf-8"
    # Normalize all labels using NFC, as specified in RFC 6763, section 4.1.3
    normalized_labels = (unicodedata.normalize("NFC", label) for label in labels)
    for label in normalized_labels:
        encoded_label = label.encode(encoding)
        encoded_length = len(encoded_label)
        # When truncating the label, we can't just stop at 63 bytes as that might be
        # splitting a multi-byte Unicode codepoint.
        truncated = False
        while encoded_length > 63:
            truncated = True
            truncated_label = encoded_label.decode(encoding)[:-1]
            encoded_label = truncated_label.encode(encoding)
            encoded_length = len(encoded_label)
        if truncated:
            _LOGGER.warning(
                (
                    "A label (%s) is being truncated (to %s) in the DNS name '%s' "
                    "as it is over 63 bytes long."
                ),
                label,
                encoded_label.decode(encoding),
                name,
            )
        encoded.append(encoded_length)
        if encoded_length == 0:
            # If we've reached an empty label, assume this is the last component.
            # Empty labels (two periods right after each other) aren't legal anyways.
            break
        encoded.extend(encoded_label)
    return encoded


def parse_string(buffer: typing.BinaryIO) -> bytes:
    """Unpack a DNS character string.

    These are simply a single length byte, followed by up to that many bytes of data.

    This is distinct from "domain-name" encoding; use `parse_domain_name` for that.
    """
    chunk_length = unpack_stream(">B", buffer)[0]
    return buffer.read(chunk_length)


def parse_domain_name(buffer: typing.BinaryIO) -> str:
    """Unpack a domain name, handling any name compression encountered.

    Basically, each component of a domain name (called a "label") is prefixed with a
    length followed by the encoded label. The final component has a zero length
    with a null label for the DNS root. The tricky part is that labels are limited to 63
    bytes, and the upper two bits of the length are used as a flag for "name
    compression". For full details, see RFC 1035, sections 3.1 and 4.1.4.

    If labels start with the "ASCII Compatible Encoding" prefix ("xn--"), they are
    decoded with IDNA. Otherwise each label is decoded as UTF-8, as that is what is used
    for DNS-SD and Apple doesn't seem to use IDNA anywhere in their mDNS/DNS-SD stack.

    This is distinct from "character-string" encoding; use `parse_string` for that.
    """
    labels = []
    compression_offset = None
    while buffer:
        length = unpack_stream(">B", buffer)[0]
        if length == 0:
            break
        # The two high bits of the length are a flag for DNS name compression
        length_flags = (length & 0xC0) >> 6
        # The 10 and 01 flags are reserved
        assert length_flags in (0, 0b11)
        if length_flags == 0b11:
            # Mask off the upper two bits, then concatenate the next byte from the
            # stream to get the offset.
            high_bits: int = length & 0x3F
            new_offset_data = bytearray(buffer.read(1))
            new_offset_data.insert(0, high_bits)
            new_offset = struct.unpack(">H", new_offset_data)[0]
            # I think it's technically possible to have multiple levels of name
            # compression, so make sure we don't lose the original place we need to go
            # back to.
            if compression_offset is None:
                compression_offset = buffer.tell()
            buffer.seek(new_offset)
        elif length_flags == 0:
            label = buffer.read(length)
            if label[:4] == b"xn--":
                decoded_label = label.decode("idna")
            else:
                decoded_label = label.decode("utf-8")
            labels.append(decoded_label)
    if compression_offset is not None:
        buffer.seek(compression_offset)
    return ".".join(labels)


def format_txt_dict(
    data: typing.Mapping[typing.Any, typing.Any],
) -> bytes:
    """Format a `dict` into a DNS-SD TXT record."""
    return ServiceInfo(
        "_x.local.", "_x.local.", addresses=[], port=12345, properties=data
    ).text


def parse_txt_dict(buffer: typing.BinaryIO, length: int) -> CaseInsensitiveDict[bytes]:
    """Parse DNS-SD TXT records into a `dict`."""
    output: CaseInsensitiveDict[bytes] = CaseInsensitiveDict()
    stop_position = buffer.tell() + length
    while buffer.tell() < stop_position:
        chunk = parse_string(buffer)
        if b"=" not in chunk:
            decoded_chunk = chunk.decode("ascii")
            # missing "=" means it's just present with no value.
            output[decoded_chunk] = b""
        else:
            key, value = chunk.split(b"=", 1)
            if not key:
                # Missing keys are skipped
                continue
            try:
                # Keys are explicitly ASCII only
                decoded_key = key.decode("ascii")
            except UnicodeDecodeError:
                _LOGGER.debug("Non-ASCII DNS-SD key encountered: %s", key)
                continue
            # Compared to the keys (ASCII strings), values are opaque binary blobs
            output[decoded_key] = value
    return output


def parse_srv_dict(buffer: typing.BinaryIO):
    """Parse DNS SRV record."""
    priority, weight, port = unpack_stream(">3H", buffer)
    # Name compression isn't allowed by the RFC, but let's accept it anyways
    target = parse_domain_name(buffer)
    # TODO: Should there be a check for target == ".", for marking services that
    # (according to the RFC), "the service is decidedly not available at this domain."
    return {
        "priority": priority,
        "weight": weight,
        "port": port,
        "target": target,
    }


class QueryType(enum.IntEnum):
    """A DNS type ID."""

    A = 0x01
    PTR = 0x0C
    TXT = 0x10
    SRV = 0x21
    ANY = 0xFF

    def parse_rdata(self, buffer: typing.BinaryIO, length: int) -> typing.Any:
        """Parse the RDATA from DNS resource record according to the type of the record.

        If the record type isn't specifically handled, the raw binary data is returned.
        """
        if self is self.A:
            if length != 4:
                raise ValueError(
                    f"An A record must have exactly 4 bytes of data (not {length})"
                )
            return str(IPv4Address(buffer.read(length)))
        if self is self.PTR:
            return parse_domain_name(buffer)
        if self is self.TXT:
            return parse_txt_dict(buffer, length)
        if self is self.SRV:
            return parse_srv_dict(buffer)
        return buffer.read(length)


class DnsHeader(typing.NamedTuple):
    """Represents the header to a DNS message."""

    id: int
    flags: int
    qdcount: int
    ancount: int
    nscount: int
    arcount: int

    @classmethod
    # Py3.7 allows the quotes to be removes as long as `annotations` is imported from
    # __future__. Py3.10 makes the behavior default.
    def unpack_read(cls, buffer: typing.BinaryIO) -> "DnsHeader":
        """Create a `DnsHeader` from a data stream.

        Only as much data as is needed to create the header is read from the stream,
        leaving it prepped to start parsing questions or resource records immediately
        afterwards.
        """
        return cls._make(unpack_stream(">6H", buffer))

    def pack(self) -> bytes:
        """Generate the packed DNS header data."""
        return struct.pack(">6H", *self)  # pylint: disable=not-an-iterable


class DnsQuestion(typing.NamedTuple):
    """Represents a DNS query."""

    qname: str
    qtype: QueryType
    qclass: int

    @classmethod
    def unpack_read(cls, buffer: typing.BinaryIO) -> "DnsQuestion":
        """Create a `DnsQuestion` from a data stream.

        The entire question data is read from the stream, leaving it ready to parse
        further questions form it, or to parse resource records out of it (depending on
        the DNS message structure).
        """
        qname = parse_domain_name(buffer)
        return cls(qname, *unpack_stream(">2H", buffer))

    def pack(self) -> bytes:
        """Encode the question data as needed for a DNS query or response."""
        data = bytearray(qname_encode(self.qname))
        data.extend(
            struct.pack(">2H", *self[1:])  # pylint: disable=unsubscriptable-object
        )
        return data


class DnsResource(typing.NamedTuple):
    """Represents a DNS resource record."""

    qname: str
    qtype: QueryType
    qclass: int
    ttl: int
    rd_length: int
    rd: typing.Any

    @classmethod
    def unpack_read(cls, buffer: typing.BinaryIO) -> "DnsResource":
        """Create a `DnsResource` from data in a data stream.

        All data from a resource record is consumed, leaving the stream ready to be used
        in another call to this method (as needed).
        """
        qname = parse_domain_name(buffer)
        qtype, qclass, ttl, rd_length = unpack_stream(">2HIH", buffer)
        before_rd = buffer.tell()
        if qtype in QueryType.__members__.values():
            qtype = QueryType(qtype)
            rd = qtype.parse_rdata(buffer, rd_length)
        else:
            rd = buffer.read(rd_length)
        assert buffer.tell() == before_rd + rd_length
        return cls(qname, qtype, qclass, ttl, rd_length, rd)


class DnsMessage:
    """Represent a DNS message."""

    def __init__(self, msg_id=0, flags=0x0120) -> None:
        """Initialize a new DnsMessage."""
        self.msg_id = msg_id
        self.flags = flags
        self.questions: typing.List[DnsQuestion] = []
        self.answers: typing.List[DnsResource] = []
        self.authorities: typing.List[DnsResource] = []
        self.resources: typing.List[DnsResource] = []

    def unpack(self, msg: bytes):
        """Unpack bytes into a DnsMessage."""
        buffer = io.BytesIO(msg)

        header = DnsHeader.unpack_read(buffer)
        self.msg_id = header.id
        self.flags = header.flags

        # Unpack questions
        self.questions.extend(
            DnsQuestion.unpack_read(buffer) for _ in range((header.qdcount))
        )

        # Unpack answers
        self.answers.extend(
            DnsResource.unpack_read(buffer) for _ in range((header.ancount))
        )

        # Unpack authorities
        self.authorities.extend(
            DnsResource.unpack_read(buffer) for _ in range((header.nscount))
        )

        # Unpack additional resources
        self.resources.extend(
            DnsResource.unpack_read(buffer) for _ in range((header.arcount))
        )

        return self

    def pack(self):
        """Pack message into bytes."""
        header = DnsHeader(
            self.msg_id,
            self.flags,
            len(self.questions),
            len(self.answers),
            len(self.authorities),
            len(self.resources),
        )

        buf = bytearray()

        buf.extend(header.pack())

        for question in self.questions:
            buf.extend(question.pack())

        for answer in self.answers:
            data = qname_encode(answer.rd)
            buf += qname_encode(answer.qname)
            buf += struct.pack(">H", answer.qtype)
            buf += struct.pack(">H", answer.qclass)
            buf += struct.pack(">I", answer.ttl)
            buf += struct.pack(">H", len(data))
            buf += data

        for section in [self.authorities, self.resources]:
            for resource in section:
                buf += qname_encode(resource.qname)
                buf += struct.pack(">H", resource.qtype)
                buf += struct.pack(">H", resource.qclass)
                buf += struct.pack(">I", resource.ttl)
                buf += struct.pack(">H", len(resource.rd))
                buf += resource.rd

        return buf

    def __str__(self):
        """Return string representation of DnsMessage."""
        return (
            f"MsgId=0x{self.msg_id:04X}\nFlags=0x{self.flags:04X}\n"
            f"Questions={self.questions}\nAnswers={self.answers}\n"
            f"Authorities={self.authorities}\n"
            f"Resources={self.resources}"
        )
