"""
Set Telescope Pointing from Observatory Engineering Telemetry.

Calculate and update the pointing-related and world coordinate system-related
keywords. Given a time period, usually defined by an exposure, the engineering
mnemonic database is queried for observatory orientation. The orientation
defines the sky coordinates a particular point on the observatory is pointed to.
Then, using a set of matrix transformations, the sky coordinates of the
reference pixel of a desired aperture is calculated.

The transformations are defined by the STScI Innerspace (non-public) document
titles "Quaternion Transforms for Coarse Pointing WCS". The code itself follows
a demonstrative jupyter notebook. The significant feature currently missing is
the correction for velocity aberration.

**Interface**

The primary usage is through the command line interface
``set_telescope_pointing``. Operating on a list of Roman Level 1 exposures,
this command updates the world coordinate system keywords with the values
necessary to translate from aperture pixel to sky coordinates.

Access to the Roman Engineering Mnemonic database is required. See the
:ref:`Engineering Database Interface <engdb>` for more information. Note that
currently only the MAST access has been defined; there is no need for a direct
database connection.

Programmatically, the command line is implemented by the function
`~roman.orientation.set_telescope_pointing.add_wcs`, which calls the basic function
`~roman.orientation.set_telescope_pointing.calc_wcs`.

There are two data structures used to maintain the state of the transformation.
`~roman.orientation.set_telescope_pointing.TransformParameters` contains the parameters
needed to perform the transformations.
`~roman.orientation.set_telescope_pointing.Transforms` contains the calculated
transformation matrices.

**Transformation Matrices**

All the transformation matrices, as defined by
`~roman.orientation.set_telescope_pointing.Transforms`, are Direction Cosine Matrices
(DCM). A DCM contains the Euler rotation angles that represent the sky
coordinates for a particular frame-of-reference. The initial DCM is provided
through the engineering telemetry and represents how the observatory is orientated.

** META Affected**
The following meta values are populated:

- meta.pointing.dec_v1
- meta.pointing.pa_aperture
- meta.pointing.pa_v3
- meta.pointing.pointing_engineering_source
- meta.pointing.ra_v1
- meta.wcsinfo.dec_ref
- meta.wcsinfo.ra_ref
- meta.wcsinfo.roll_ref
- meta.wcsinfo.s_region

"""

import dataclasses
import logging
from collections import defaultdict, namedtuple
from collections.abc import Callable
from copy import copy
from math import cos, sin
from typing import Any

import asdf
import numpy as np
import roman_datamodels as rdm
from astropy.table import Table
from astropy.time import Time, TimeDelta
from stcal.alignment.util import compute_s_region_keyword

from ..lib.engdb.engdb_tools import engdb_service

__all__ = [
    "TransformParameters",
    "Transforms",
    "WCSRef",
    "add_wcs",
    "calc_transforms",
    "calc_wcs",
    "calc_wcs_over_time",
    "update_wcs",
]

# Setup logging
logger = logging.getLogger(__name__)
logger.addHandler(logging.NullHandler())
DEBUG_FULL = logging.DEBUG - 1
LOGLEVELS = [logging.INFO, logging.DEBUG, DEBUG_FULL]

# Datamodels that can be updated, normally
EXPECTED_MODELS = rdm.datamodels.ScienceRawModel

# Exposure types that can be updated, normally
TYPES_TO_UPDATE = set()

# Mnemonics needed.
# dict where value indicates whether the mnemonic is required or not.
COARSE_MNEMONICS_QUATERNION_ECI = [f"SCF_AC_SDR_QBJ_{idx + 1}" for idx in range(4)]
COARSE_MNEMONICS = {q: True for q in COARSE_MNEMONICS_QUATERNION_ECI}

# Conversion from seconds to MJD
SECONDS2MJD = 1 / 24 / 60 / 60

# Conversion of the FCS reference point from the V-Frame.
# This is the pre-launch value, later to be refined and provided
# in the SIAF
M_V2FCS0 = np.array(
    [
        [-0.0000001, 0.5000141, 0.8660173],
        [0.0086567, -0.8659848, 0.4999953],
        [0.9999625, 0.0074969, -0.0043284],
    ],
)

# Default B-frame to FCS frame, M_b_to_fcs
# Pre-launch this is the same as M_v_to_fcs.
M_B2FCS0 = M_V2FCS0

# Define the transformation matrices to move between the Idealized Coordinate System (ICS)
# and the Idealized Coordinate System (Idl). ICS is the spacecraft-centric system used by
# all frames up through the V-frame. Idl is used by the instruments.
# Reference: Innerspace document
M_idl2ics = MX2Z = np.array([[0, 1, 0], [0, 0, 1], [1, 0, 0]])
M_ics2idl = MZ2X = np.array([[0, 0, 1], [1, 0, 0], [0, 1, 0]])

# Degree, radian, angle transformations
R2D = 180.0 / np.pi
D2R = np.pi / 180.0
A2R = D2R / 3600.0
R2A = 3600.0 * R2D
PI2 = np.pi * 2.0

# Potential errors generated by mnemonic retrival
EXPECTED_ERRORS = (OSError, RuntimeError, ValueError)


# Pointing container
# Attributes are as follows. Except for the observation time, all values
# are retrieved from the engineering data.
#    obstime      : Time the pointing information refers to.
#    q            : Quaternion of the FGS.
Pointing = namedtuple("Pointing", ["obstime", "q"])
Pointing.__new__.__defaults__ = (None,) * 2


# Transforms
@dataclasses.dataclass
class Transforms:
    """The matrices used in calculation of the M_eci2siaf transformation."""

    #: ECI to B-frame
    m_eci2b: np.ndarray | None = None
    #: ECI to FCS
    m_eci2fcs: np.ndarray | None = None
    #: ECI to GS
    m_eci2gs: np.ndarray | None = None
    #: ECI to V
    m_eci2v: np.ndarray | Any = None

    @classmethod
    def from_asdf(cls, asdf_file):
        """
        Create Transforms from AsdfFile.

        Parameters
        ----------
        asdf_file : Stream-like or `asdf.AsdfFile`
            The asdf to create from.

        Returns
        -------
        transforms : Transforms
            The Transforms instance.
        """
        if isinstance(asdf_file, asdf.AsdfFile):
            transforms = asdf_file.tree["transforms"]
        else:
            with asdf.open(asdf_file, memmap=False, lazy_load=False) as af:
                transforms = af.tree["transforms"]

        return cls(**transforms)

    def to_asdf(self):
        """
        Serialize to AsdfFile.

        Returns
        -------
        asdf_file : asdf.AsdfFile
            The ASDF serialization.
        """
        self_dict = dataclasses.asdict(self)
        asdf_file = asdf.AsdfFile({"transforms": self_dict})
        return asdf_file

    def write_to_asdf(self, path):
        """
        Serialize to a file path.

        Parameters
        ----------
        path : Stream-like
            Output file path.
        """
        asdf_file = self.to_asdf()
        asdf_file.write_to(path, all_array_storage="inline")


# WCS reference container
WCSRef = namedtuple("WCSRef", ["ra", "dec", "pa", "s_region"])
WCSRef.__new__.__defaults__ = (None, None, None, "")


@dataclasses.dataclass
class TransformParameters:
    """Parameters required for the calculations."""

    #: If telemetry cannot be determined, use existing information in the observation's header.
    allow_default: bool = False
    #: Aperture in use
    aperture: str = ""
    #: The V3 position angle to use if the pointing information is not found.
    default_pa_v3: float = 0.0
    #: Do not write out the modified file.
    dry_run: bool = False
    #: Observation end time
    obsend: float | None = None
    #: Observation start time
    obsstart: float | None = None
    #: The observatory orientation, represented by the ECI quaternion,
    # and other engineering mnemonics
    pointing: Pointing | Any = None
    #: Reduction function to use on values.
    reduce_func: Callable | None = None
    #: Engineering database information
    service_kwargs: dict | None = None
    #: If no telemetry can be found during the observation,
    #: the time, in seconds, beyond the observation time to search for telemetry.
    tolerance: float = 60.0

    def as_reprdict(self):
        """Return a dict where all values are REPR of their values."""  # numpydoc ignore=RT01
        r = {
            field.name: repr(getattr(self, field.name))
            for field in dataclasses.fields(self)
        }
        return r

    def update_pointing(self):
        """Update pointing information."""
        self.pointing = get_pointing(
            self.obsstart,
            self.obsend,
            mnemonics_to_read=COARSE_MNEMONICS,
            service_kwargs=self.service_kwargs,
            tolerance=self.tolerance,
            reduce_func=self.reduce_func,
        )


def add_wcs(
    filename,
    default_pa_v3=0.0,
    service_kwargs=None,
    tolerance=60,
    allow_default=False,
    reduce_func=None,
    dry_run=False,
    save_transforms=None,
    **transform_kwargs,
):
    """Add WCS information to a Roman DataModel.

    Telescope orientation is attempted to be obtained from
    the engineering database. Failing that, a default pointing
    is used based on proposal target.

    The file is updated in-place.

    Parameters
    ----------
    filename : str
        The path to a data file.

    default_pa_v3 : float
        The V3 position angle to use if the pointing information
        is not found.

    service_kwargs : dict or None
        Keyword arguments passed to `engdb_service` defining what
        engineering database service to use.

    tolerance : int
        If no telemetry can be found during the observation,
        the time, in seconds, beyond the observation time to
        search for telemetry.

    allow_default : bool
        If telemetry cannot be determine, use existing
        information in the observation's header.

    reduce_func : func or None
        Reduction function to use on values.

    dry_run : bool
        Do not write out the modified file.

    save_transforms : Path-like or None
        File to save the calculated transforms to.

    **transform_kwargs : dict
        Keyword arguments used by matrix calculation routines.

    Notes
    -----
    This function adds absolute pointing information to the Roman datamodels
    provided. By default, only Level 1 exposures are allowed to be updated.
    These have the suffixes of "uncal" representing datamodel ScienceRawModel.
    Any higher level product, from Level 2 and beyond, that has had the
    `assign_wcs` step applied, have improved WCS information. Running this task
    on such files will potentially corrupt the WCS.
    """
    logger.info("Updating WCS info for file %s", filename)

    with rdm.open(filename) as model:
        if not isinstance(model, EXPECTED_MODELS):
            logger.warning("Input %s is not of an expected type (uncal)", model)
            logger.warning(
                "    Updating pointing may have no effect or detrimental effects on the "
                "WCS information,"
            )
            logger.warning(
                "    especially if the input is the result of Level2b or higher calibration."
            )
            raise TypeError(
                f"Input model {model} is not one of {EXPECTED_MODELS}."
                "\n\tFailing WCS processing."
            )

        t_pars, transforms = update_wcs(
            model,
            default_pa_v3=default_pa_v3,
            service_kwargs=service_kwargs,
            tolerance=tolerance,
            allow_default=allow_default,
            reduce_func=reduce_func,
            **transform_kwargs,
        )

        if dry_run:
            logger.info("Dry run requested; results are not saved.")
        else:
            logger.info("Saving updated model %s", filename)
            model.save(filename)
            if transforms and save_transforms:
                logger.info("Saving transform matrices to %s", save_transforms)
                transforms.write_to_asdf(save_transforms)

    logger.info("...update completed")


def update_wcs(
    model,
    default_pa_v3=0.0,
    default_roll_ref=0.0,
    service_kwargs=None,
    tolerance=60,
    allow_default=False,
    reduce_func=None,
    **transform_kwargs,
):
    """
    Update WCS pointing information.

    Given a `roman.datamodels.DataModel`, determine the simple WCS parameters
    from the SIAF keywords in the model and the engineering parameters
    that contain information about the telescope pointing.

    It presumes all the accessed keywords are present (see first block).

    Parameters
    ----------
    model : `~roman.datamodels.DataModel`
        The model to update.

    default_roll_ref : float
        If pointing information cannot be retrieved,
        use this as the roll ref angle.

    service_kwargs : dict or None
        Keyword arguments passed to `engdb_service` defining what
        engineering database service to use.

    tolerance : int
        If no telemetry can be found during the observation,
        the time, in seconds, beyond the observation time to
        search for telemetry.

    allow_default : bool
        If telemetry cannot be determine, use existing
        information in the observation's header.

    reduce_func : func or None
        Reduction function to use on values.

    **transform_kwargs : dict
        Keyword arguments used by matrix calculation routines.

    Returns
    -------
    t_pars, transforms : TransformParameters, Transforms
        The parameters and transforms calculated. May be
        None for either if telemetry calculations were not
        performed.
    """
    t_pars = transforms = None  # Assume telemetry is not used.

    # Configure transformation parameters.
    t_pars = t_pars_from_model(
        model,
        default_pa_v3=default_pa_v3,
        service_kwargs=service_kwargs,
        tolerance=tolerance,
        allow_default=allow_default,
        reduce_func=reduce_func,
        **transform_kwargs,
    )

    # Calculate WCS.
    transforms = update_wcs_from_telem(model, t_pars)

    return t_pars, transforms


def update_wcs_from_telem(model, t_pars: TransformParameters):
    """
    Update WCS pointing information.

    Given a `roman.datamodels.DataModel`, determine the simple WCS parameters
    from the SIAF keywords in the model and the engineering parameters
    that contain information about the telescope pointing.

    It presumes all the accessed keywords are present (see first block).

    Parameters
    ----------
    model : `~roman.datamodels.DataModel`
        The model to update. The update is done in-place.

    t_pars : `TransformParameters`
        The transformation parameters. Parameters are updated during processing.

    Returns
    -------
    transforms : Transforms or None
        If available, the transformation matrices.
    """
    logger.info("Updating wcs from telemetry.")
    transforms = None  # Assume no transforms are calculated.

    # Setup default WCS info if actual pointing and calculations fail.
    wcsinfo = WCSRef(
        model.meta.pointing.target_ra,
        model.meta.pointing.target_dec,
        model.meta.pointing.pa_v3,
    )
    vinfo = wcsinfo

    # Get the pointing information
    quality = "PLANNED"
    try:
        t_pars.update_pointing()
    except ValueError as exception:
        if not t_pars.allow_default:
            raise
        else:
            logger.warning(
                "Cannot retrieve valid telescope pointing."
                " Default pointing parameters will be used."
            )
            logger.warning("Exception is %s", exception)
            logger.info("Setting pointing quality to PLANNED")
            quality = "PLANNED"
    else:
        logger.info("Successful read of engineering quaternions:")
        logger.info("\tPointing: %s", t_pars.pointing)

    # If pointing is available, attempt to calculate WCS information
    if t_pars.pointing is not None:
        try:
            wcsinfo, vinfo, transforms = calc_wcs(t_pars)
            quality = "CALCULATED"
            logger.info("Setting pointing quality to %s", quality)
        except EXPECTED_ERRORS as e:
            logger.warning(
                "WCS calculation has failed and will be skipped."
                "Default pointing parameters will be used."
            )
            logger.warning("Exception is %s", e)
            if not t_pars.allow_default:
                raise
            else:
                logger.info("Setting pointing quality to PLANNED")
                quality = "PLANNED"
    logger.info("Aperture WCS info: %s", wcsinfo)
    logger.info("V1 WCS info: %s", vinfo)

    # Update model meta.
    update_meta(model, wcsinfo, vinfo, quality)

    return transforms


def calc_wcs_over_time(obsstart, obsend, t_pars: TransformParameters):
    """
    Calculate V1 and WCS over a time period.

    Parameters
    ----------
    obsstart, obsend : float
        MJD observation start/end times

    t_pars : `TransformParameters`
        The transformation parameters. Parameters are updated during processing.

    Returns
    -------
    obstimes, wcsinfos, vinfos : [astropy.time.Time[,...]], [WCSRef[,...]], [WCSRef[,...]]
        A 3-tuple is returned with the WCS pointings for
        the aperture and the V1 axis.
    """
    # Setup structures
    obstimes = []
    wcsinfos = []
    vinfos = []

    # Calculate WCS
    try:
        pointings = get_pointing(
            obsstart,
            obsend,
            service_kwargs=t_pars.service_kwargs,
            tolerance=t_pars.tolerance,
            reduce_func=t_pars.reduce_func,
        )
    except ValueError:
        logger.warning(
            "Cannot get valid engineering mnemonics from engineering database"
        )
        raise
    if not isinstance(pointings, list):
        pointings = [pointings]
    for pointing in pointings:
        t_pars.pointing = pointing
        wcsinfo, vinfo, transforms = calc_wcs(t_pars)
        obstimes.append(pointing.obstime)
        wcsinfos.append(wcsinfo)
        vinfos.append(vinfo)

    return obstimes, wcsinfos, vinfos


def calc_wcs(t_pars: TransformParameters):
    """
    Calculate WCS.

    Given observatory orientation and target aperture,
    calculate V1 and Reference Pixel sky coordinates.

    Parameters
    ----------
    t_pars : `TransformParameters`
        The transformation parameters. Parameters are updated during processing.

    Returns
    -------
    wcsinfo, vinfo, transforms : WCSRef, WCSRef, Transforms
        A 3-tuple is returned with the WCS pointing for
        the aperture and the V1 axis, and the transformation matrices.
    """
    # Calculate transforms
    transforms = calc_transforms(t_pars)

    # Calculate the V1 WCS information
    vinfo = calc_wcs_from_matrix(transforms.m_eci2v)

    # Calculate the Aperture WCS
    wcsinfo = wcsinfo_from_siaf(t_pars.aperture, vinfo)

    # That's all folks
    return wcsinfo, vinfo, transforms


def wcsinfo_from_siaf(aperture, vinfo):
    """Calculate aperture reference point WCS from V-frame WCS and SIAF

    Parameters
    ----------
    aperture : str
        The aperture in use

    vinfo : WCSRef
        The V-frame WCS

    Returns
    -------
    wcsinfo : WCSRef
        The WCS for the aperture's reference point, as defined by its SIAF.
    """
    from pysiaf import Siaf
    from pysiaf.utils.rotations import attitude_matrix, sky_posangle

    siaf = Siaf("roman")
    boresight = siaf["BORESIGHT"]
    wfi = siaf[aperture.upper()]

    # For transformations between the telescope frame and all other frames,
    # an attitude matrix is created using the V-frame WCS information.
    v_refpoint = boresight.reference_point(to_frame="tel")
    attitude = attitude_matrix(*v_refpoint, vinfo.ra, vinfo.dec, vinfo.pa)
    wfi.set_attitude_matrix(attitude)
    skycoord = wfi.reference_point(to_frame="sky")
    pa_v3 = sky_posangle(attitude, *skycoord)

    # Compute S_REGION keyword
    corners = wfi.corners("sky")
    footprint = np.array([corners[0], corners[1]]).T
    s_region_keyword = compute_s_region_keyword(footprint)

    wcsinfo = WCSRef(
        ra=skycoord[0], dec=skycoord[1], pa=pa_v3, s_region=s_region_keyword
    )
    return wcsinfo


def calc_transforms(t_pars: TransformParameters):
    """
    COARSE calculation.

    This implements the overall coarse-mode transfomations as defined by the
    example notebook as provided by T.Sohn.

    Parameters
    ----------
    t_pars : TransformParameters
        The transformation parameters. Parameters are updated during processing.

    Returns
    -------
    transforms : Transforms
        The list of coordinate matrix transformations

    Notes
    -----
    """
    logger.info("Calculating transforms...")
    t = Transforms()

    # Quaternion to M_eci2b
    t.m_eci2b = calc_m_eci2b(t_pars.pointing.q)

    # ECI to FCS
    t.m_eci2fcs = np.dot(M_B2FCS0, t.m_eci2b)

    # ECI to GS
    t.m_eci2gs = np.dot(M_ics2idl, t.m_eci2fcs)

    # ECI to V
    t.m_eci2v = np.linalg.multi_dot([M_B2FCS0.T, M_idl2ics, t.m_eci2gs])

    return t


def calc_attitude_matrix(wcs, yangle, position):
    """
    Calculate the DCM attitude from known positions and roll angles.

    Parameters
    ----------
    wcs : WCSRef
        The sky position.

    yangle : float
        The IdlYangle of the point in question.

    position : numpy.array(2)
        The position in Ideal frame.

    Returns
    -------
    m : np.array(3,3)
        The transformation matrix
    """
    # Convert to radians
    ra = wcs.ra * D2R
    dec = wcs.dec * D2R
    yangle_ra = yangle * D2R
    pos_rads = position * A2R
    v2 = pos_rads[0]
    v3 = pos_rads[1]

    # Create the matrices
    r1 = dcm(ra, dec, yangle_ra)

    r2 = np.array(
        [
            [cos(v2) * cos(v3), -sin(v2), -cos(v2) * sin(v3)],
            [sin(v2) * cos(v3), cos(v2), -sin(v2) * sin(v3)],
            [sin(v3), 0.0, cos(v3)],
        ]
    )

    # Final transformation
    m = np.dot(r2, r1)

    logger.debug("attitude DCM: %s", m)
    return m


def calc_wcs_from_matrix(m):
    """
    Calculate the WCS information from a DCM.

    Parameters
    ----------
    m : np.array((3, 3))
        The DCM matrix to extract WCS information from.

    Returns
    -------
    wcs : WCSRef
        The WCS.
    """
    # V1 RA/Dec is the first row of the transform
    v1_ra, v1_dec = vector_to_angle(m[0])
    wcs = WCSRef(v1_ra, v1_dec, None)

    # V3 is the third row of the transformation
    v3_ra, v3_dec = vector_to_angle(m[2])
    v3wcs = WCSRef(v3_ra, v3_dec, None)

    # Calculate the V3 position angle
    v1_pa = calc_position_angle(wcs, v3wcs)

    # Convert to degrees
    wcs = WCSRef(ra=wcs.ra * R2D, dec=wcs.dec * R2D, pa=v1_pa * R2D)

    logger.debug("wcs: %s", wcs)
    return wcs


def calc_m_eci2b(q):
    """
    Calculate ECI to B-frame matrix from quaternions.


    This implements the M_eci_to_b calculation as presented in the
    STScI Innerspace document "Quaternion Transforms for Coarse Pointing WCS".

    Parameters
    ----------
    q : np.array(q1, q2, q3, q4)
        Array of quaternions from the engineering database.

    Returns
    -------
    transform : np.array((3, 3))
        The transform matrix representing the transformation
        from observatory orientation to J-Frame.
    """
    q1, q2, q3, q4 = q
    transform = np.array(
        [
            [
                1.0 - 2.0 * q2 * q2 - 2.0 * q3 * q3,
                2.0 * (q1 * q2 + q3 * q4),
                2.0 * (q3 * q1 - q2 * q4),
            ],
            [
                2.0 * (q1 * q2 - q3 * q4),
                1.0 - 2.0 * q3 * q3 - 2.0 * q1 * q1,
                2.0 * (q2 * q3 + q1 * q4),
            ],
            [
                2.0 * (q3 * q1 + q2 * q4),
                2.0 * (q2 * q3 - q1 * q4),
                1.0 - 2.0 * q1 * q1 - 2.0 * q2 * q2,
            ],
        ],
        dtype=float,
    )

    logger.debug("quaternion: %s", transform)
    return transform


def calc_position_angle(point, ref):
    """
    Calculate position angle from reference to point.

        tan(pa) = cos(dec_r) * sin(ra_r - ra_p) / (sin(dec_r)cos(dec_p) - cos(dec_r)sin(dec_p)cos(ra_r-ra_p))

    where::

        pa : position angle
        *_r : reference
        *_p : point

    Typically the reference is the V3 RA/DEC and point is the object RA/DEC.

    Parameters
    ----------
    point : WCSRef
        The POINT wcs parameters, in radians.

    ref : WCSRef
        The TARGET wcs parameters, in radians.

    Returns
    -------
    point_pa : float
      The POINT position angle, in radians
    """
    y = cos(ref.dec) * sin(ref.ra - point.ra)
    x = sin(ref.dec) * cos(point.dec) - cos(ref.dec) * sin(point.dec) * cos(
        ref.ra - point.ra
    )
    point_pa = np.arctan2(y, x)
    if point_pa < 0:
        point_pa += PI2
    if point_pa >= PI2:
        point_pa -= PI2

    logger.debug("Given reference: %s, point: %s, then PA: %s", ref, point, point_pa)
    return point_pa


def get_pointing(
    obsstart,
    obsend,
    mnemonics_to_read=COARSE_MNEMONICS,
    service_kwargs=None,
    tolerance=60,
    reduce_func=None,
):
    """
    Get telescope pointing engineering data.

    Parameters
    ----------
    obsstart, obsend : float
        MJD observation start/end times

    mnemonics_to_read : {str: bool[,...]}
        The mnemonics to read. Key is the mnemonic name.
        Value is a boolean indicating whether the mnemonic
        is required to have values or not.

    service_kwargs : dict or None
        Keyword arguments passed to `engdb_service` defining what
        engineering database service to use.

    tolerance : int
        If no telemetry can be found during the observation,
        the time, in seconds, beyond the observation time to
        search for telemetry.

    reduce_func : func or None
        Reduction function to use on values.
        If None, the average pointing is returned.

    Returns
    -------
    pointing : Pointing or [Pointing(, ...)]
        The engineering pointing parameters.
        If the `result_type` is `all`, a list
        of pointings will be returned.

    Raises
    ------
    ValueError
        Cannot retrieve engineering information.

    Notes
    -----
    For the moment, the first found values will be used.
    This will need be re-examined when more information is
    available.
    """
    if reduce_func is None:
        reduce_func = pointing_from_average

    logger.info("Determining pointing between observations times (mjd):")
    logger.info("obsstart: %s obsend: %s", obsstart, obsend)
    logger.info("Telemetry search tolerance: %s", tolerance)
    logger.info("Reduction function: %s", reduce_func)

    mnemonics = get_mnemonics(
        obsstart,
        obsend,
        mnemonics_to_read=mnemonics_to_read,
        tolerance=tolerance,
        service_kwargs=service_kwargs,
    )
    reduced = reduce_func(mnemonics_to_read, mnemonics)

    logger.log(DEBUG_FULL, "Mnemonics found:")
    logger.log(DEBUG_FULL, "%s", mnemonics)
    logger.info("Reduced set of pointings:")
    logger.info("%s", reduced)

    return reduced


def vector_to_angle(v):
    """
    Return tuple of spherical angles from unit direction Vector.

    The Direction Cosine Matrix (DCM) that provides the transformation of a
    unit pointing vector defined in inertial frame (ECI J2000) coordinates to a
    unit vector defined in the science aperture Ideal frame coordinates is
    defined as.

    Parameters
    ----------
    v : [v0, v1, v2]
        Direction vector.

    Returns
    -------
    alpha, delta : float, float
        The spherical angles, in radians.
    """
    alpha = np.arctan2(v[1], v[0])
    delta = np.arcsin(v[2])
    if alpha < 0.0:
        alpha += 2.0 * np.pi
    return alpha, delta


def angle_to_vector(alpha, delta):
    """
    Convert spherical angles to unit vector.

    Parameters
    ----------
    alpha, delta : float
        Spherical angles in radians.

    Returns
    -------
    v : [float, float, float]
        Unit vector.
    """
    v0 = cos(delta) * cos(alpha)
    v1 = cos(delta) * sin(alpha)
    v2 = sin(delta)

    return [v0, v1, v2]


def get_mnemonics(
    obsstart, obsend, tolerance, mnemonics_to_read=COARSE_MNEMONICS, service_kwargs=None
):
    """
    Retrieve pointing mnemonics from the engineering database.

    Parameters
    ----------
    obsstart, obsend : float
        astropy.Time observation start/end times.

    tolerance : int
        If no telemetry can be found during the observation,
        the time, in seconds, beyond the observation time to
        search for telemetry.

    mnemonics_to_read : {str: bool[,...]}
        The mnemonics to fetch. key is the mnemonic and
        value is whether it is required to be found.

    service_kwargs : dict or None
        Keyword arguments passed to `engdb_service` defining what
        engineering database service to use.

    Returns
    -------
    mnemonics : {mnemonic: [value[,...]][,...]}
        The values for each pointing mnemonic.

    Raises
    ------
    ValueError
        Cannot retrieve engineering information.
    """
    if service_kwargs is None:
        service_kwargs = dict()
    try:
        engdb = engdb_service(**service_kwargs)
    except EXPECTED_ERRORS as exception:
        raise ValueError(
            f"Cannot open engineering DB connection\nException: {exception}"
        ) from None
    logger.info("Querying engineering DB: %s", engdb)

    # Construct the mnemonic values structure.
    mnemonics = {mnemonic: None for mnemonic in mnemonics_to_read}

    # Retrieve the mnemonics from the engineering database.
    # Check for whether the bracket values are used and
    # within tolerance.
    for mnemonic in mnemonics:
        try:
            mnemonics[mnemonic] = engdb.get_values(
                mnemonic,
                obsstart,
                obsend,
                time_format="mjd",
                include_obstime=True,
                include_bracket_values=False,
            )
        except EXPECTED_ERRORS as exception:
            raise ValueError(
                f"Cannot retrieve {mnemonic} from engineering."
            ) from exception

        # If more than two points exist, throw off the bracket values.
        # Else, ensure the bracket values are within the allowed time.
        if len(mnemonics[mnemonic]) < 2:
            logger.warning(
                "Mnemonic %s has no telemetry within the observation time.", mnemonic
            )
            logger.warning(
                "Attempting to use bracket values within %s seconds", tolerance
            )

            mnemonics[mnemonic] = engdb.get_values(
                mnemonic,
                obsstart,
                obsend,
                time_format="mjd",
                include_obstime=True,
                include_bracket_values=True,
            )

            tolerance_mjd = TimeDelta(tolerance, format="sec")
            allowed_start = obsstart - tolerance_mjd
            allowed_end = obsend + tolerance_mjd
            allowed = [
                value
                for value in mnemonics[mnemonic]
                if allowed_start <= value.obstime <= allowed_end
            ]
            if not len(allowed):
                raise ValueError(
                    "No telemetry exists for mnemonic {} within {} and {}".format(
                        mnemonic,
                        Time(allowed_start, format="mjd").isot,
                        Time(allowed_end, format="mjd").isot,
                    )
                )
            mnemonics[mnemonic] = allowed

    # All mnemonics must have some values.
    if not all(len(mnemonic) for mnemonic in mnemonics.values()):
        raise ValueError("Incomplete set of pointing mnemonics")

    return mnemonics


def all_pointings(mnemonics_to_read, mnemonics):
    """
    V1 of making pointings.

    Parameters
    ----------
    mnemonics_to_read : {str: bool[,...]}
        The mnemonics to read. Key is the mnemonic name.
        Value is a boolean indicating whether the mnemonic
        is required to have values or not.

    mnemonics : {mnemonic: [value[,...]][,...]}
        The values for each pointing mnemonic.

    Returns
    -------
    pointings : [Pointing[,...]]
        List of pointings.
    """
    pointings = []
    filled = fill_mnemonics_chronologically(mnemonics)
    for obstime, mnemonics_at_time in filled.items():
        # Fill out the matrices
        q = np.array(
            [mnemonics_at_time[m].value for m in COARSE_MNEMONICS_QUATERNION_ECI]
        )

        pointing = Pointing(
            q=q,
            obstime=obstime,
        )
        pointings.append(pointing)

    if not len(pointings):
        raise ValueError("No non-zero quaternion found.")

    return pointings


def first_pointing(mnemonics_to_read, mnemonics):
    """
    Return first pointing.

    Parameters
    ----------
    mnemonics_to_read : {str: bool[,...]}
        The mnemonics to read. Key is the mnemonic name.
        Value is a boolean indicating whether the mnemonic
        is required to have values or not.

    mnemonics : {mnemonic: [value[,...]][,...]}
        The values for each pointing mnemonic.

    Returns
    -------
    pointing : Pointing
        First pointing.
    """
    pointings = all_pointings(mnemonics_to_read, mnemonics)
    return pointings[0]


def pointing_from_average(mnemonics_to_read, mnemonics):
    """
    Determine single pointing from average of available pointings.

    Parameters
    ----------
    mnemonics_to_read : {str: bool[,...]}
        The mnemonics to read. Key is the mnemonic name.
        Value is a boolean indicating whether the mnemonic
        is required to have values or not.

    mnemonics : {mnemonic: [value[,...]][,...]}
        The values for each pointing mnemonic.

    Returns
    -------
    pointing : Pointing
        Pointing from average.
    """
    # Get average observation time.
    times = [
        eng_param.obstime.unix
        for key in mnemonics
        for eng_param in mnemonics[key]
        if eng_param.obstime.unix != 0.0
    ]
    if len(times) > 0:
        obstime = Time(np.average(times), format="unix")
    else:
        raise ValueError("No valid times in range")

    # Get averages for all the mnemonics.
    mnemonic_averages = {}
    zero_mnemonics = []
    for mnemonic in mnemonics:
        values = [eng_param.value for eng_param in mnemonics[mnemonic]]
        # Weed out mnemonic entries that are zero, though some are OK to be zero.
        if mnemonics_to_read[mnemonic]:
            good_mnemonic = []
            for this_value in values:
                if this_value != 0.0:
                    good_mnemonic.append(this_value)
            if len(good_mnemonic) > 0:
                mnemonic_averages[mnemonic] = np.average(good_mnemonic)
            else:
                zero_mnemonics.append(mnemonic)
        else:
            mnemonic_averages[mnemonic] = np.average(values)

    # Raise exception if there are mnemonics with only zeros in the time range
    if len(zero_mnemonics):
        logger.warning(
            "The following engineering mnemonics only contained zeros in the requested "
            "time interval:"
        )
        badmnemonicsstring = " ".join(zero_mnemonics)
        logger.info(badmnemonicsstring)
        raise ValueError("Bad telemetry values")

    # Fill out the pointing matrices.
    q = np.array([mnemonic_averages[m] for m in COARSE_MNEMONICS_QUATERNION_ECI])

    pointing = Pointing(
        obstime=obstime,
        q=q,
    )

    # That's all folks
    return pointing


def fill_mnemonics_chronologically(mnemonics, filled_only=True):
    """
    Return time-ordered mnemonic list with progressive values.

    The different set of mnemonics used for observatory orientation
    appear at different cadences. This routine creates a time-ordered dictionary
    with all the mnemonics for each time found in the engineering. For mnemonics
    missing for a particular time, the last previous value is used.

    Parameters
    ----------
    mnemonics : {mnemonic: [value[,...]]}
        Dictionary mapping mnemonics to their respective values.

    filled_only : bool
        Only return a matrix where observation times have all the mnemonics defined.

    Returns
    -------
    filled_by_time : {obstime: {mnemonic: value}}
        Time-ordered mnemonic list with progressive values.
    """
    # Collect all information by observation time and order.
    by_obstime = defaultdict(dict)
    n_mnemonics = len(mnemonics)
    for mnemonic, values in mnemonics.items():
        for value in values:
            by_obstime[value.obstime][mnemonic] = value
    by_obstime = sorted(by_obstime.items())

    # Created the filled matrix
    filled = {}
    last_obstime = {}
    for obstime, mnemonics_at_time in by_obstime:
        last_obstime.update(mnemonics_at_time)
        if len(last_obstime) >= n_mnemonics or not filled_only:
            # Engineering data may be present, but all zeros.
            # Filter out this situation also.
            if filled_only:
                values = [value.value for value in last_obstime.values()]
                if not any(values):
                    continue

            filled[obstime] = copy(last_obstime)

    return filled


def fill_mnemonics_chronologically_table(mnemonics, filled_only=True):
    """
    Return time-ordered mnemonic list with progressive values.

    The different set of mnemonics used for observatory orientation
    appear at different cadences. This routine creates a time-ordered dictionary
    with all the mnemonics for each time found in the engineering. For mnemonics
    missing for a particular time, the last previous value is used.

    Parameters
    ----------
    mnemonics : {mnemonic: [value[,...]]}
        Dictionary mapping mnemonics to their respective values.

    filled_only : bool
        Only return a matrix where observation times have all the mnemonics defined.

    Returns
    -------
    filled_by_time : `astropy.table.Table`
        Time-ordered mnemonic list with progressive values.
    """
    filled = fill_mnemonics_chronologically(mnemonics, filled_only=filled_only)

    names = list(mnemonics.keys())
    names = ["time", *names]
    time_idx = 0

    values = [[] for _ in names]

    for time in filled:
        values[time_idx].append(time)
        for mnemonic in filled[time]:
            idx = names.index(mnemonic)
            values[idx].append(filled[time][mnemonic].value)

    t = Table(values, names=names)

    return t


def t_pars_from_model(model, **t_pars_kwargs):
    """
    Initialize TransformParameters from a DataModel.

    Parameters
    ----------
    model : DataModel
        Data model to initialize from.

    **t_pars_kwargs : dict
        Keyword arguments used to initialize the TransformParameters object
        before reading from the model meta information.

    Returns
    -------
    t_par : TransformParameters
        The initialized parameters.
    """
    t_pars = TransformParameters(**t_pars_kwargs)

    # Instrument details
    t_pars.aperture = model.meta.wcsinfo.aperture_name
    try:
        exp_type = model.meta.exposure.type.lower()
    except AttributeError:
        exp_type = None
    t_pars.exp_type = exp_type

    # observation parameters
    t_pars.obsstart = model.meta.exposure.start_time
    t_pars.obsend = model.meta.exposure.end_time
    logger.debug("Observation time: %s - %s", t_pars.obsstart, t_pars.obsend)

    # Set pointing reduction function if not already set.
    if not t_pars.reduce_func:
        t_pars.reduce_func = pointing_from_average

    return t_pars


def dcm(alpha, delta, angle):
    """
    Construct the Direction Cosine Matrix (DCM).

    Typical usage is passing of (RA, DEC, PositionAngle).
    All values must be in radians.

    Parameters
    ----------
    alpha : float
        First coordinate in radians.

    delta : float
        Second coordinate in radians.

    angle : float
        Position angle in radians.

    Returns
    -------
    dcm : np.array((3, 3))
        The 3x3 direction cosine matrix.
    """
    dcm = np.array(
        [
            [cos(delta) * cos(alpha), cos(delta) * sin(alpha), sin(delta)],
            [
                -cos(angle) * sin(alpha) + sin(angle) * sin(delta) * cos(alpha),
                cos(angle) * cos(alpha) + sin(angle) * sin(delta) * sin(alpha),
                -sin(angle) * cos(delta),
            ],
            [
                -sin(angle) * sin(alpha) - cos(angle) * sin(delta) * cos(alpha),
                sin(angle) * cos(alpha) - cos(angle) * sin(delta) * sin(alpha),
                cos(angle) * cos(delta),
            ],
        ]
    )

    return dcm


def update_meta(model, wcsinfo, vinfo, quality):
    """Update model's meta info with the given pointing.

    The following meta are update:
    - meta.pointing.dec_v1
    - meta.pointing.pa_aperture
    - meta.pointing.pa_v3
    - meta.pointing.ra_v1
    - meta.wcsinfo.dec_ref
    - meta.wcsinfo.ra_ref
    - meta.wcsinfo.roll_ref
    - meta.wcsinfo.s_region

    Parameters
    ----------
    model : `~roman.datamodels.DataModel`
        The model to update. Updates are done in-place.

    wcsinfo : `WCSRef``
        The aperture-specific pointing.

    vinfo : ``WCSRef`
        The V1-specific pointing

    quality : str
        Indicator of the success of the pointing determination.
    """
    from pysiaf import Siaf

    # Set the quality
    model.meta.pointing.pointing_engineering_source = quality

    # Update SIAF-related meta
    wm = model.meta.wcsinfo
    siaf = Siaf("roman")
    aper = siaf[wm.aperture_name.upper()]
    wm.v2_ref = aper.V2Ref
    wm.v3_ref = aper.V3Ref
    wm.vparity = aper.VIdlParity
    wm.v3yangle = aper.V3IdlYAngle

    # Update Aperture pointing
    wm.ra_ref = wcsinfo.ra
    wm.dec_ref = wcsinfo.dec
    wm.s_region = wcsinfo.s_region
    wm.roll_ref = wcsinfo.pa

    # Update V1 pointing
    model.meta.pointing.pa_aperture = wcsinfo.pa
    model.meta.pointing.ra_v1 = vinfo.ra
    model.meta.pointing.dec_v1 = vinfo.dec
    model.meta.pointing.pa_v3 = vinfo.pa
