"""
Functions to read and write CPHD files.
"""

import copy
import dataclasses
import importlib.resources
import logging
import os
from typing import Final

import lxml.etree
import numpy as np
import numpy.typing as npt

SCHEMA_DIR = importlib.resources.files("sarkit.cphd.schemas")
SECTION_TERMINATOR: Final[bytes] = b"\f\n"
DEFINED_HEADER_KEYS: Final[set] = {
    "XML_BLOCK_SIZE",
    "XML_BLOCK_BYTE_OFFSET",
    "SUPPORT_BLOCK_SIZE",
    "SUPPORT_BLOCK_BYTE_OFFSET",
    "PVP_BLOCK_SIZE",
    "PVP_BLOCK_BYTE_OFFSET",
    "SIGNAL_BLOCK_SIZE",
    "SIGNAL_BLOCK_BYTE_OFFSET",
    "CLASSIFICATION",
    "RELEASE_INFO",
}

# Keys in ascending order
VERSION_INFO: Final[dict] = {
    "http://api.nsgreg.nga.mil/schema/cphd/1.0.1": {
        "version": "1.0.1",
        "date": "2018-05-21T00:00:00Z",
        "schema": SCHEMA_DIR / "CPHD_schema_V1.0.1_2018_05_21.xsd",
    },
    "http://api.nsgreg.nga.mil/schema/cphd/1.1.0": {
        "version": "1.1.0",
        "date": "2021-11-30T00:00:00Z",
        "schema": SCHEMA_DIR / "CPHD_schema_V1.1.0_2021_11_30_FINAL.xsd",
    },
}


def _to_binary_format_string_recursive(dtype):
    dtype = np.dtype(dtype)
    if dtype.subdtype is not None:
        dt, shape = dtype.subdtype
        f = _to_binary_format_string_recursive(dt)
        if shape == (3,):
            return "".join(["%s=%s;" % (xyz, f) for xyz in "XYZ"])
        elif shape == (2,):
            return "".join(["DC%s=%s;" % (xy, f) for xy in "XY"])
        else:
            raise ValueError(
                "only dtype arrays of length 2 or 3 supported: %s" % repr(dtype)
            )

    if dtype.kind == "V":
        offset_sorted = sorted(dtype.fields.items(), key=lambda x: x[-1][-1])
        return "".join(
            [
                "%s=%s;" % (name, _to_binary_format_string_recursive(dt))
                for name, (dt, _) in offset_sorted
            ]
        )

    types = {"u": "U", "i": "I", "f": "F", "c": "CF", "S": "S"}
    return "%s%s" % (types[dtype.kind], dtype.itemsize)


def dtype_to_binary_format_string(dtype: np.dtype) -> str:
    """Return the binary format string corresponding to a `numpy.dtype`.

    See the "Allowed Binary Formats" table in the Design & Implementation Description Document

    Parameters
    ----------
    dtype : `numpy.dtype`
        Data-type about which to get binary format string.
        Endianness is ignored.

    Returns
    -------
    str
        Binary format string for the specified `numpy.dtype`

    Examples
    --------

    .. doctest::

        >>> import numpy as np
        >>> import sarkit.cphd as skcphd

        >>> skcphd.dtype_to_binary_format_string(np.uint8)
        'U1'

        >>> skcphd.dtype_to_binary_format_string(np.dtype([('a', np.int16), ('b', 'S30')]))
        'a=I2;b=S30;'
    """
    result = _to_binary_format_string_recursive(dtype)

    if ";;" in result:  # pragma: nocover
        raise ValueError("dtype not supported: %s" % repr(dtype))

    return result


def _single_binary_format_string_to_dtype(form):
    if form.startswith("S"):
        dtype = np.dtype(form)
    else:
        lookup = {
            "U1": np.dtype("u1"),
            "U2": np.dtype("u2"),
            "U4": np.dtype("u4"),
            "U8": np.dtype("u8"),
            "I1": np.dtype("i1"),
            "I2": np.dtype("i2"),
            "I4": np.dtype("i4"),
            "I8": np.dtype("i8"),
            "F4": np.dtype("f4"),
            "F8": np.dtype("f8"),
            "CI2": np.dtype([("real", np.int8), ("imag", np.int8)]),
            "CI4": np.dtype([("real", np.int16), ("imag", np.int16)]),
            "CI8": np.dtype([("real", np.int32), ("imag", np.int32)]),
            "CI16": np.dtype([("real", np.int64), ("imag", np.int64)]),
            "CF8": np.dtype("c8"),
            "CF16": np.dtype("c16"),
        }
        dtype = lookup[form]

    return dtype


def binary_format_string_to_dtype(format_string: str) -> np.dtype:
    """Return the `numpy.dtype` corresponding to a binary format string.

    See the "Allowed Binary Formats" table in the Design & Implementation Description Document

    Parameters
    ----------
    format_string : str
        Binary format string about which to get the dtype.

    Returns
    -------
    `numpy.dtype`
        `numpy.dtype` corresponding to the binary format string (in native byte order)

    Examples
    --------

    .. doctest::

        >>> import numpy as np
        >>> import sarkit.cphd as skcphd

        >>> skcphd.binary_format_string_to_dtype('U1')
        dtype('uint8')

        >>> skcphd.binary_format_string_to_dtype('a=I2;b=S30;')
        dtype([('a', '<i2'), ('b', 'S30')])
    """
    components = format_string.split(";")

    if "=" in components[0]:
        comptypes = []
        for comp in components[:-1]:
            kvp = comp.split("=")
            comptypes.append((kvp[0], _single_binary_format_string_to_dtype(kvp[1])))

        # special handling of XYZ and EB types
        keys, types = list(zip(*comptypes))
        if keys == ("X", "Y", "Z") and len(set(types)) == 1:
            dtype = np.dtype("3" + comptypes[0][1].name)
        elif keys == ("DCX", "DCY") and len(set(types)) == 1:
            dtype = np.dtype("2" + comptypes[0][1].name)
        else:
            dtype = np.dtype(comptypes)
    else:
        dtype = _single_binary_format_string_to_dtype(components[0])

    return dtype


def mask_support_array(
    array: npt.NDArray, nodata_hex: str | None = None
) -> np.ma.MaskedArray:
    """Apply a NODATA hex string to a support array to mask the array.

    Parameters
    ----------
    array : np.ndarray
        Support array to compare to NODATA and create a masked array from
    nodata_hex : str, optional
        If None, all array elements are valid. Otherwise, use the hex string to
        compare to the values in ``array`` to make the mask

    Returns
    -------
    masked_array : :py:class:`~numpy.ma.MaskedArray`
        ``array`` with NODATA elements masked
    """
    if nodata_hex is None:
        return np.ma.array(array)
    nodata_v = np.void(bytes.fromhex(nodata_hex))
    return np.ma.array(
        array,
        mask=array.view(nodata_v.dtype) == nodata_v,
        fill_value=nodata_v.view(array.dtype),
    )


def _describe_signal(
    cphd_xmltree: lxml.etree.ElementTree,
    channel_identifier: str,
    *,
    standard_format=False,
) -> tuple[tuple[int, ...], np.dtype]:
    """Return the shape and dtype of the signal array identified by ``channel_identifier``"""
    channel_info = cphd_xmltree.find(
        f"{{*}}Data/{{*}}Channel[{{*}}Identifier='{channel_identifier}']"
    )
    is_compressed = (
        compressed_signal_size := channel_info.findtext("{*}CompressedSignalSize")
    ) is not None
    if is_compressed and not standard_format:
        dtype = np.dtype(np.uint8)
        shape: tuple[int, ...] = (int(compressed_signal_size),)
    else:
        dtype = binary_format_string_to_dtype(
            cphd_xmltree.find("./{*}Data/{*}SignalArrayFormat").text
        )
        shape = (
            int(channel_info.find("{*}NumVectors").text),
            int(channel_info.find("{*}NumSamples").text),
        )
    return shape, dtype


@dataclasses.dataclass(kw_only=True)
class FileHeaderPart:
    """CPHD header fields which are set per program specific Product Design Document

    Attributes
    ----------
    additional_kvps : dict of {str : str}
        Additional key-value pairs
    """

    additional_kvps: dict[str, str] = dataclasses.field(default_factory=dict)


@dataclasses.dataclass(kw_only=True)
class Metadata:
    """Settable CPHD metadata

    Attributes
    ----------
    file_header_part : FileHeaderPart
        CPHD File Header fields which can be set
    xmltree : lxml.etree.ElementTree
        CPHD XML
    """

    file_header_part: FileHeaderPart = dataclasses.field(default_factory=FileHeaderPart)
    xmltree: lxml.etree.ElementTree


def read_file_header(file):
    """Read a file header.

    The file object's position is assumed to be at the start of the file.

    Parameters
    ----------
    file : `file object`
        The open file object, which will be progressively read.

    Returns
    -------
    file_type_header : str
        File type header from the first line of the file
    kvp_list : dict of {str : str}
        Key-Value pair list of header fields
    """
    file_type_header = file.readline().decode()

    kvp_list = {}
    while (line := file.readline()) != SECTION_TERMINATOR:
        field, value = line.decode().strip("\n").split(" := ")
        kvp_list[field] = value
    return file_type_header, kvp_list


def get_pvp_dtype(cphd_xmltree):
    """Get PVP dtype.

    Parameters
    ----------
    cphd_xmltree : lxml.etree.ElementTree
        CPHD XML ElementTree

    Returns
    -------
    numpy.dtype
    """

    pvp_node = cphd_xmltree.find("./{*}PVP")

    bytes_per_word = 8
    names = []
    formats = []
    offsets = []

    def handle_field(field_node):
        node_name = lxml.etree.QName(field_node).localname
        if node_name == "AddedPVP":
            names.append(field_node.find("./{*}Name").text)
        else:
            names.append(node_name)

        formats.append(
            binary_format_string_to_dtype(field_node.find("./{*}Format").text)
        )
        offsets.append(int(field_node.find("./{*}Offset").text) * bytes_per_word)

    for pnode in pvp_node:
        if lxml.etree.QName(pnode).localname in ("TxAntenna", "RcvAntenna"):
            for subnode in pnode:
                handle_field(subnode)
        else:
            handle_field(pnode)

    dtype = np.dtype(({"names": names, "formats": formats, "offsets": offsets}))
    return dtype


class Reader:
    """Read a CPHD file

    A Reader object can be used as a context manager in a ``with`` statement.
    Attributes, but not methods, can be safely accessed outside of the context manager's context.

    Parameters
    ----------
    file : `file object`
        CPHD file to read

    Attributes
    ----------
    metadata : Metadata
       CPHD metadata

    See Also
    --------
    Writer

    Examples
    --------

    .. testsetup:: cphd_io

        import sarkit.cphd as skcphd
        import lxml.etree
        meta = skcphd.Metadata(xmltree=lxml.etree.parse("data/example-cphd-1.0.1.xml"))

        file = pathlib.Path(tmpdir.name) / "foo"
        with file.open("wb") as f, skcphd.Writer(f, meta) as w:
            f.seek(
                w._file_header_kvp["SIGNAL_BLOCK_BYTE_OFFSET"]
                + w._file_header_kvp["SIGNAL_BLOCK_SIZE"]
                - 1
            )
            f.write(b"0")

    .. doctest:: cphd_io

        >>> import sarkit.cphd as skcphd
        >>> with file.open("rb") as f, skcphd.Reader(f) as r:
        ...     ch_id = r.metadata.xmltree.findtext("{*}Data/{*}Channel/{*}Identifier")
        ...     sig, pvp = r.read_channel(ch_id)
        ...     sa_id = r.metadata.xmltree.findtext("{*}Data/{*}SupportArray/{*}Identifier")
        ...     sa = r.read_support_array(sa_id)
    """

    def __init__(self, file):
        self._file_object = file

        # skip the version line and read header
        _, self._kvp_list = read_file_header(self._file_object)

        extra_header_keys = set(self._kvp_list.keys()) - DEFINED_HEADER_KEYS
        additional_kvps = {key: self._kvp_list[key] for key in extra_header_keys}

        self._file_object.seek(self._xml_block_byte_offset)
        xml_bytes = self._file_object.read(int(self._kvp_list["XML_BLOCK_SIZE"]))

        self.metadata = Metadata(
            xmltree=lxml.etree.fromstring(xml_bytes).getroottree(),
            file_header_part=FileHeaderPart(additional_kvps=additional_kvps),
        )

    @property
    def _xml_block_byte_offset(self) -> int:
        """Offset to the XML block"""
        return int(self._kvp_list["XML_BLOCK_BYTE_OFFSET"])

    @property
    def _xml_block_size(self) -> int:
        """Size of the XML block"""
        return int(self._kvp_list["XML_BLOCK_SIZE"])

    @property
    def _pvp_block_byte_offset(self) -> int:
        """Offset to the PVP block"""
        return int(self._kvp_list["PVP_BLOCK_BYTE_OFFSET"])

    @property
    def _pvp_block_size(self) -> int:
        """Size of the PVP block"""
        return int(self._kvp_list["PVP_BLOCK_SIZE"])

    @property
    def _signal_block_byte_offset(self) -> int:
        """Offset to the Signal block"""
        return int(self._kvp_list["SIGNAL_BLOCK_BYTE_OFFSET"])

    @property
    def _signal_block_size(self) -> int:
        """Size of the Signal block"""
        return int(self._kvp_list["SIGNAL_BLOCK_SIZE"])

    @property
    def _support_block_byte_offset(self) -> int | None:
        """Offset to the Support block"""
        if "SUPPORT_BLOCK_BYTE_OFFSET" in self._kvp_list:
            return int(self._kvp_list["SUPPORT_BLOCK_BYTE_OFFSET"])
        return None

    @property
    def _support_block_size(self) -> int | None:
        """Size of the Support block"""
        if "SUPPORT_BLOCK_SIZE" in self._kvp_list:
            return int(self._kvp_list["SUPPORT_BLOCK_SIZE"])
        return None

    def read_signal(self, channel_identifier: str) -> npt.NDArray:
        """Read signal data from a CPHD file

        Parameters
        ----------
        channel_identifier : str
            Channel unique identifier

        Returns
        -------
        ndarray
            Signal array identified by ``channel_identifier``

            When standard, shape=(NumVectors, NumSamples), dtype determined by SignalArrayFormat.

            When compressed, shape=(CompressedSignalSize,), dtype= `numpy.uint8`
        """
        signal_offset = int(
            self.metadata.xmltree.findtext(
                f"{{*}}Data/{{*}}Channel[{{*}}Identifier='{channel_identifier}']/{{*}}SignalArrayByteOffset"
            )
        )
        self._file_object.seek(signal_offset + self._signal_block_byte_offset)
        shape, dtype = _describe_signal(self.metadata.xmltree, channel_identifier)
        dtype = dtype.newbyteorder(">")
        nbytes = np.prod(shape) * dtype.itemsize
        sigarray = self._file_object.read(nbytes)
        nbytes_read = len(sigarray)
        if nbytes != nbytes_read:
            raise RuntimeError(f"Expected {nbytes=}; only read {nbytes_read}")
        return np.frombuffer(sigarray, dtype=dtype).reshape(shape)

    def read_pvps(self, channel_identifier: str) -> npt.NDArray:
        """Read pvp data from a CPHD file

        Parameters
        ----------
        channel_identifier : str
            Channel unique identifier

        Returns
        -------
        ndarray
            CPHD PVP array

        """
        channel_info = self.metadata.xmltree.find(
            f"{{*}}Data/{{*}}Channel[{{*}}Identifier='{channel_identifier}']"
        )
        num_vect = int(channel_info.find("./{*}NumVectors").text)

        pvp_offset = int(channel_info.find("./{*}PVPArrayByteOffset").text)
        self._file_object.seek(pvp_offset + self._pvp_block_byte_offset)

        pvp_dtype = get_pvp_dtype(self.metadata.xmltree).newbyteorder("B")
        return np.fromfile(self._file_object, pvp_dtype, count=num_vect)

    def read_channel(self, channel_identifier: str) -> tuple[npt.NDArray, npt.NDArray]:
        """Read signal and pvp data from a CPHD file channel

        Parameters
        ----------
        channel_identifier : str
            Channel unique identifier

        Returns
        -------
        signal_array : ndarray
            Signal array for channel = channel_identifier
        pvp_array : ndarray
            PVP array for channel = channel_identifier

        """
        return self.read_signal(channel_identifier), self.read_pvps(channel_identifier)

    def _read_support_array(self, sa_identifier):
        elem_format = self.metadata.xmltree.find(
            f"{{*}}SupportArray/*[{{*}}Identifier='{sa_identifier}']/{{*}}ElementFormat"
        )
        dtype = binary_format_string_to_dtype(elem_format.text).newbyteorder("B")

        sa_info = self.metadata.xmltree.find(
            f"{{*}}Data/{{*}}SupportArray[{{*}}Identifier='{sa_identifier}']"
        )
        num_rows = int(sa_info.find("./{*}NumRows").text)
        num_cols = int(sa_info.find("./{*}NumCols").text)
        shape = (num_rows, num_cols)

        sa_offset = int(sa_info.find("./{*}ArrayByteOffset").text)
        self._file_object.seek(sa_offset + self._support_block_byte_offset)
        assert dtype.itemsize == int(sa_info.find("./{*}BytesPerElement").text)
        return np.fromfile(self._file_object, dtype, count=np.prod(shape)).reshape(
            shape
        )

    def read_support_array(self, sa_identifier, masked=True):
        """Read SupportArray"""
        array = self._read_support_array(sa_identifier)
        if not masked:
            return array
        nodata = self.metadata.xmltree.findtext(
            f"{{*}}SupportArray/*[{{*}}Identifier='{sa_identifier}']/{{*}}NODATA"
        )
        return mask_support_array(array, nodata)

    def done(self):
        "Indicates to the reader that the user is done with it"
        self._file_object = None

    def __enter__(self):
        return self

    def __exit__(self, *args, **kwargs):
        self.done()


class Writer:
    """Write a CPHD file

    A Writer object can be used as a context manager in a ``with`` statement.

    Parameters
    ----------
    file : `file object`
        CPHD file to write
    metadata : Metadata
        CPHD metadata to write (copied on construction)

    See Also
    --------
    Reader

    Examples
    --------
    Generate some metadata and data

    .. doctest:: cphd_io

        >>> import lxml.etree

        >>> xmltree = lxml.etree.parse("data/example-cphd-1.0.1.xml")
        >>> first_channel = xmltree.find("{*}Data/{*}Channel")
        >>> ch_id = first_channel.findtext("{*}Identifier")
        >>> num_v = int(first_channel.findtext("{*}NumVectors"))
        >>> num_s = int(first_channel.findtext("{*}NumSamples"))
        >>> sig_format = xmltree.findtext("{*}Data/{*}SignalArrayFormat")

        >>> import sarkit.cphd as skcphd

        >>> meta = skcphd.Metadata(
        ...     xmltree=xmltree,
        ...     file_header_part=skcphd.FileHeaderPart(additional_kvps={"K": "V"}),
        ... )

        >>> import numpy as np

        >>> sig = np.zeros((num_v, num_s), dtype=skcphd.binary_format_string_to_dtype(sig_format))
        >>> pvps = np.zeros(num_v, dtype=skcphd.get_pvp_dtype(xmltree))

    Write a channel's signal and PVP arrays to a file.

    .. doctest:: cphd_io

        >>> with (tmppath / "written.cphd").open("wb") as f, skcphd.Writer(f, meta) as w:
        ...     w.write_signal(ch_id, sig)
        ...     w.write_pvp(ch_id, pvps)
    """

    def __init__(self, file, metadata: Metadata):
        align_to = 64
        self._file_object = file

        self._metadata = copy.deepcopy(metadata)
        cphd_xmltree = self._metadata.xmltree

        xml_block_body = lxml.etree.tostring(cphd_xmltree, encoding="utf-8")

        pvp_itemsize = int(cphd_xmltree.find("./{*}Data/{*}NumBytesPVP").text)
        self._channel_size_offsets = {}
        for chan_node in cphd_xmltree.findall("./{*}Data/{*}Channel"):
            channel_identifier = chan_node.find("./{*}Identifier").text
            channel_signal_offset = int(
                chan_node.find("./{*}SignalArrayByteOffset").text
            )
            shape, dtype = _describe_signal(cphd_xmltree, channel_identifier)
            channel_signal_size = int(np.prod(shape)) * dtype.itemsize
            channel_pvp_offset = int(chan_node.find("./{*}PVPArrayByteOffset").text)
            channel_pvp_size = (
                int(chan_node.find("./{*}NumVectors").text) * pvp_itemsize
            )

            self._channel_size_offsets[channel_identifier] = {
                "signal_offset": channel_signal_offset,
                "signal_size": channel_signal_size,
                "pvp_offset": channel_pvp_offset,
                "pvp_size": channel_pvp_size,
            }

        signal_block_size = max(
            chan["signal_size"] + chan["signal_offset"]
            for chan in self._channel_size_offsets.values()
        )
        pvp_block_size = max(
            chan["pvp_size"] + chan["pvp_offset"]
            for chan in self._channel_size_offsets.values()
        )

        self._sa_size_offsets = {}
        for sa_node in cphd_xmltree.findall("./{*}Data/{*}SupportArray"):
            sa_identifier = sa_node.find("./{*}Identifier").text
            sa_offset = int(sa_node.find("./{*}ArrayByteOffset").text)
            sa_size = (
                int(sa_node.find("./{*}NumRows").text)
                * int(sa_node.find("./{*}NumCols").text)
                * int(sa_node.find("./{*}BytesPerElement").text)
            )

            self._sa_size_offsets[sa_identifier] = {
                "offset": sa_offset,
                "size": sa_size,
            }

        def _align(val):
            return int(np.ceil(float(val) / align_to) * align_to)

        self._file_header_kvp = {
            "XML_BLOCK_SIZE": len(xml_block_body),
            "XML_BLOCK_BYTE_OFFSET": np.iinfo(np.uint64).max,  # placeholder
            "PVP_BLOCK_SIZE": pvp_block_size,
            "PVP_BLOCK_BYTE_OFFSET": np.iinfo(np.uint64).max,  # placeholder
            "SIGNAL_BLOCK_SIZE": signal_block_size,
            "SIGNAL_BLOCK_BYTE_OFFSET": np.iinfo(np.uint64).max,  # placeholder
            "CLASSIFICATION": cphd_xmltree.findtext(
                "{*}CollectionID/{*}Classification"
            ),
            "RELEASE_INFO": cphd_xmltree.findtext("{*}CollectionID/{*}ReleaseInfo"),
        }
        if self._sa_size_offsets:
            self._file_header_kvp["SUPPORT_BLOCK_SIZE"] = max(
                sa["size"] + sa["offset"] for sa in self._sa_size_offsets.values()
            )
            self._file_header_kvp["SUPPORT_BLOCK_BYTE_OFFSET"] = (
                np.iinfo(np.uint64).max,
            )  # placeholder

        self._file_header_kvp.update(self._metadata.file_header_part.additional_kvps)

        def _serialize_header():
            version = VERSION_INFO[lxml.etree.QName(cphd_xmltree.getroot()).namespace][
                "version"
            ]
            header_str = f"CPHD/{version}\n"
            header_str += "".join(
                (f"{key} := {value}\n" for key, value in self._file_header_kvp.items())
            )
            return header_str.encode() + SECTION_TERMINATOR

        next_offset = _align(len(_serialize_header()))
        self._file_header_kvp["XML_BLOCK_BYTE_OFFSET"] = next_offset
        next_offset = _align(
            next_offset
            + self._file_header_kvp["XML_BLOCK_SIZE"]
            + len(SECTION_TERMINATOR)
        )

        if self._sa_size_offsets:
            self._file_header_kvp["SUPPORT_BLOCK_BYTE_OFFSET"] = next_offset
            next_offset = _align(
                next_offset + self._file_header_kvp["SUPPORT_BLOCK_SIZE"]
            )

        self._file_header_kvp["PVP_BLOCK_BYTE_OFFSET"] = next_offset
        next_offset = _align(next_offset + self._file_header_kvp["PVP_BLOCK_SIZE"])

        self._file_header_kvp["SIGNAL_BLOCK_BYTE_OFFSET"] = next_offset
        next_offset = _align(next_offset + self._file_header_kvp["SIGNAL_BLOCK_SIZE"])

        self._file_object.seek(0)
        self._file_object.write(_serialize_header())
        self._file_object.seek(self._file_header_kvp["XML_BLOCK_BYTE_OFFSET"])
        self._file_object.write(xml_block_body + SECTION_TERMINATOR)

        self._signal_arrays_written: set[str] = set()
        self._pvp_arrays_written: set[str] = set()
        self._support_arrays_written: set[str] = set()

    def write_signal(self, channel_identifier: str, signal_array: npt.NDArray):
        """Write signal data to a CPHD file

        Parameters
        ----------
        channel_identifier : str
            Channel unique identifier
        signal_array : ndarray
            Signal data to write.

            When standard, shape=(NumVectors, NumSamples), dtype determined by SignalArrayFormat.

            When compressed, shape=(CompressedSignalSize,), dtype= `numpy.uint8`
        """
        # TODO Add support for partial CPHD writing
        shape, dtype = _describe_signal(self._metadata.xmltree, channel_identifier)
        if dtype != signal_array.dtype.newbyteorder("="):
            raise ValueError(f"{signal_array.dtype=} is not compatible with {dtype=}")
        if shape != signal_array.shape:
            raise ValueError(f"{signal_array.shape=} does not match {shape=}")

        buff_to_write = signal_array.astype(dtype.newbyteorder(">"), copy=False).data
        expected_nbytes = self._channel_size_offsets[channel_identifier]["signal_size"]
        if buff_to_write.nbytes != expected_nbytes:
            raise ValueError(
                f"{buff_to_write.nbytes=} does not match {expected_nbytes=}"
            )

        self._file_object.seek(
            self._file_header_kvp["SIGNAL_BLOCK_BYTE_OFFSET"]
            + self._channel_size_offsets[channel_identifier]["signal_offset"]
        )
        self._file_object.write(buff_to_write)
        self._signal_arrays_written.add(channel_identifier)

    def write_pvp(self, channel_identifier: str, pvp_array: npt.NDArray):
        """Write pvp data to a CPHD file

        Parameters
        ----------
        channel_identifier : str
            Channel unique identifier
        pvp_array : ndarray
            Array of PVPs

        """
        assert (
            pvp_array.nbytes
            == self._channel_size_offsets[channel_identifier]["pvp_size"]
        )

        self._pvp_arrays_written.add(channel_identifier)
        self._file_object.seek(self._file_header_kvp["PVP_BLOCK_BYTE_OFFSET"])
        self._file_object.seek(
            self._channel_size_offsets[channel_identifier]["pvp_offset"], os.SEEK_CUR
        )
        output_dtype = pvp_array.dtype.newbyteorder(">")
        pvp_array.astype(output_dtype, copy=False).tofile(self._file_object)

    def write_support_array(
        self, support_array_identifier: str, support_array: npt.NDArray
    ):
        """Write support array data to a CPHD file

        Parameters
        ----------
        support_array_identifier : str
            Unique support array identifier
        support_array : ndarray
            Array of support data
        """
        data_sa_elem = self._metadata.xmltree.find(
            f"{{*}}Data/{{*}}SupportArray[{{*}}Identifier='{support_array_identifier}']"
        )
        expected_shape = (
            int(data_sa_elem.findtext("{*}NumRows")),
            int(data_sa_elem.findtext("{*}NumCols")),
        )
        sa_elem = self._metadata.xmltree.find(
            f"{{*}}SupportArray/*[{{*}}Identifier='{support_array_identifier}']"
        )
        element_format = sa_elem.findtext("{*}ElementFormat")
        expected_dtype = binary_format_string_to_dtype(element_format)
        expected_nodata = sa_elem.findtext("{*}NODATA")

        if expected_dtype != support_array.dtype.newbyteorder("="):
            raise ValueError(
                f"{support_array.dtype=} is not compatible with {expected_dtype=}"
            )
        if expected_shape != support_array.shape:
            raise ValueError(f"{support_array.shape=} does not match {expected_shape=}")
        if isinstance(support_array, np.ma.MaskedArray):
            actual_nodata = (
                support_array.fill_value.astype(expected_dtype.newbyteorder(">"))
                .tobytes()
                .hex()
            )

            def _is_masked(array):
                # structured arrays don't play nice with np.ma
                if array.dtype.names is None:
                    return np.ma.is_masked(array)
                return any(_is_masked(array[n]) for n in array.dtype.names)

            if _is_masked(support_array) and expected_nodata != actual_nodata:
                raise ValueError(f"{actual_nodata=} does not match {expected_nodata=}")

        self._file_object.seek(self._file_header_kvp["SUPPORT_BLOCK_BYTE_OFFSET"])
        self._file_object.seek(
            self._sa_size_offsets[support_array_identifier]["offset"], os.SEEK_CUR
        )
        output_dtype = support_array.dtype.newbyteorder(">")
        self._file_object.write(support_array.astype(output_dtype, copy=False).data)
        self._support_arrays_written.add(support_array_identifier)

    def done(self):
        """Warn about unwritten arrays declared in the XML"""
        channel_names = set(
            node.text
            for node in self._metadata.xmltree.findall(
                "./{*}Data/{*}Channel/{*}Identifier"
            )
        )
        missing_signal_channels = channel_names - self._signal_arrays_written
        if missing_signal_channels:
            logging.warning(
                f"Not all Signal Arrays written.  Missing {missing_signal_channels}"
            )

        missing_pvp_channels = channel_names - self._pvp_arrays_written
        if missing_pvp_channels:
            logging.warning(
                f"Not all PVP Arrays written.  Missing {missing_pvp_channels}"
            )

        sa_names = set(
            node.text
            for node in self._metadata.xmltree.findall(
                "./{*}Data/{*}SupportArray/{*}Identifier"
            )
        )
        missing_sa = sa_names - self._support_arrays_written
        if missing_sa:
            logging.warning(f"Not all Support Arrays written.  Missing {missing_sa}")

        self._file_object = None

    def __enter__(self):
        return self

    def __exit__(self, *args, **kwargs):
        self.done()
